From c6fda1100899ac6410e8d12dbbea0db9b07face1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kalvis=20Kalni=C5=86=C5=A1?= Date: Wed, 20 Nov 2024 22:37:53 +0200 Subject: [PATCH 01/21] refactor(router): rename RequireOrgPermissions --- jsapp/js/account/routes.tsx | 26 +++++++++---------- jsapp/js/projects/routes.tsx | 6 ++--- ...sx => RequireOrgPermissions.component.tsx} | 5 ++-- 3 files changed, 19 insertions(+), 18 deletions(-) rename jsapp/js/router/{validateOrgPermissions.component.tsx => RequireOrgPermissions.component.tsx} (91%) diff --git a/jsapp/js/account/routes.tsx b/jsapp/js/account/routes.tsx index 927c1ef2e4..e609131cf7 100644 --- a/jsapp/js/account/routes.tsx +++ b/jsapp/js/account/routes.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {Navigate, Route} from 'react-router-dom'; import RequireAuth from 'js/router/requireAuth'; -import {ValidateOrgPermissions} from 'js/router/validateOrgPermissions.component'; +import {RequireOrgPermissions} from 'js/router/RequireOrgPermissions.component'; import {OrganizationUserRole} from './stripe.types'; import { ACCOUNT_ROUTES, @@ -37,12 +37,12 @@ export default function routes() { index element={ - - + } /> @@ -51,12 +51,12 @@ export default function routes() { index element={ - - + } /> @@ -65,7 +65,7 @@ export default function routes() { index element={ - - + } /> @@ -81,7 +81,7 @@ export default function routes() { path={ACCOUNT_ROUTES.USAGE_PROJECT_BREAKDOWN} element={ - - + } /> @@ -117,12 +117,12 @@ export default function routes() { path={ACCOUNT_ROUTES.ORGANIZATION_MEMBERS} element={ -
Organization members view to be implemented
-
+
} /> @@ -130,7 +130,7 @@ export default function routes() { path={ACCOUNT_ROUTES.ORGANIZATION_SETTINGS} element={ - - + } /> diff --git a/jsapp/js/projects/routes.tsx b/jsapp/js/projects/routes.tsx index 876dbc3351..3076166d1c 100644 --- a/jsapp/js/projects/routes.tsx +++ b/jsapp/js/projects/routes.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Navigate, Route} from 'react-router-dom'; import RequireAuth from 'js/router/requireAuth'; import {PROJECTS_ROUTES} from 'js/router/routerConstants'; -import {ValidateOrgPermissions} from 'js/router/validateOrgPermissions.component'; +import {RequireOrgPermissions} from 'js/router/RequireOrgPermissions.component'; const MyProjectsRoute = React.lazy( () => import(/* webpackPrefetch: true */ './myProjectsRoute') @@ -33,12 +33,12 @@ export default function routes() { path={PROJECTS_ROUTES.MY_ORG_PROJECTS} element={ - - + } /> diff --git a/jsapp/js/router/validateOrgPermissions.component.tsx b/jsapp/js/router/RequireOrgPermissions.component.tsx similarity index 91% rename from jsapp/js/router/validateOrgPermissions.component.tsx rename to jsapp/js/router/RequireOrgPermissions.component.tsx index a35ba98f1b..840f879f84 100644 --- a/jsapp/js/router/validateOrgPermissions.component.tsx +++ b/jsapp/js/router/RequireOrgPermissions.component.tsx @@ -1,4 +1,5 @@ -import React, {Suspense, useEffect} from 'react'; +import type React from 'react'; +import {Suspense, useEffect} from 'react'; import {useNavigate} from 'react-router-dom'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import {useOrganizationQuery} from 'js/account/stripe.api'; @@ -16,7 +17,7 @@ interface Props { * or members of MMOs. Defaults to allowing access for all users, so you must supply * any restrictions. */ -export const ValidateOrgPermissions = ({ +export const RequireOrgPermissions = ({ children, redirectRoute, validRoles = undefined, From 5db756aeabbc0562e6975b7507e55710c599c0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kalvis=20Kalni=C5=86=C5=A1?= Date: Thu, 21 Nov 2024 00:00:06 +0200 Subject: [PATCH 02/21] wip: dump skeleton of organizations settings page --- .../OrganizationSettingsField.tsx | 24 ++++ .../OrganizationSettingsRoute.tsx | 44 +++++- .../organizationSettingsRoute.module.scss | 128 ++++++++++++++++++ jsapp/js/components/common/textBox.tsx | 2 +- 4 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 jsapp/js/account/organizations/OrganizationSettingsField.tsx create mode 100644 jsapp/js/account/organizations/organizationSettingsRoute.module.scss diff --git a/jsapp/js/account/organizations/OrganizationSettingsField.tsx b/jsapp/js/account/organizations/OrganizationSettingsField.tsx new file mode 100644 index 0000000000..4542eaeb86 --- /dev/null +++ b/jsapp/js/account/organizations/OrganizationSettingsField.tsx @@ -0,0 +1,24 @@ +import organizationSettingsStyles from 'js/account/organizations/organizationSettingsRoute.module.scss'; +import TextBox from 'jsapp/js/components/common/textBox'; + +interface Props { + label: string; + value: string; + onChange?: (newValue: string) => void; + validateValue?: (currentValue: string) => string | boolean | string[] | undefined; +} + +export default function OrganizationSettingsField({label, value, onChange, validateValue}: Props) { + return ( +
+ +
+ ); +} diff --git a/jsapp/js/account/organizations/OrganizationSettingsRoute.tsx b/jsapp/js/account/organizations/OrganizationSettingsRoute.tsx index f56accea7c..ed82a4ffcc 100644 --- a/jsapp/js/account/organizations/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organizations/OrganizationSettingsRoute.tsx @@ -1,7 +1,47 @@ -import React from 'react'; +import {useState} from 'react'; +import organizationSettingsStyles from 'js/account/organizations/organizationSettingsRoute.module.scss'; +import OrganizationSettingsField from './OrganizationSettingsField'; + +interface State { + name: string; + website?: string; + type?: string; +} export default function OrganizationSettingsRoute() { + const [state, setState] = useState({name: 'Example Name', website: 'website', type: 'ngo'}); + const handleChangeName = (name: string) => setState((prevState) => {return {...prevState, name};}); + const handleChangeWebsite = (website: string) => setState((prevState) => {return {...prevState, website};}); + const validateName = (currentName: string) => !currentName; + const validateWebsite = (currentWebsite: string) => !currentWebsite; + return ( -
Organization settings view to be implemented
+
+
+

+ {t('Team Details')} +

+
+ + {state.website && ( + + )} + {state.type && ( + + )} +
); } diff --git a/jsapp/js/account/organizations/organizationSettingsRoute.module.scss b/jsapp/js/account/organizations/organizationSettingsRoute.module.scss new file mode 100644 index 0000000000..92f4350d19 --- /dev/null +++ b/jsapp/js/account/organizations/organizationSettingsRoute.module.scss @@ -0,0 +1,128 @@ +@use 'scss/mixins'; +@use 'scss/colors'; +@use 'scss/breakpoints'; +@use 'js/components/common/textBox.module'; + +.securityRouteRoot { + padding: 20px; + overflow-y: auto; + height: 100%; + + :global { + // Harmonize button widths + .k-button__label { + display: inline-block; + min-width: 6em; + text-align: center; + } + } +} + +header.securityHeader { + @include mixins.centerRowFlex; + margin: 24px 0; + + &:not(:first-child) { + margin-top: 44px; + } +} + +h2.securityHeaderText { + color: colors.$kobo-storm; + text-transform: uppercase; + font-size: 18px; + font-weight: 700; + flex: 1; + margin: 0; +} + +.securityHeaderActions { + @include mixins.centerRowFlex; +} + +// Shared styles for sections + +.securitySection { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 16px 0; + border-top: 1px solid colors.$kobo-gray-200; + + &:last-of-type { + border-bottom: 1px solid colors.$kobo-gray-200; + } +} + +.securitySectionTitle { + width: 100%; + flex: initial; + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; +} + +.securitySectionTitleText { + margin: 0; + color: colors.$kobo-gray-600; + font-weight: 600; + line-height: 1.6; + font-size: 16px; +} + +.securitySectionBody { + flex: 3; + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; +} + +@include breakpoints.breakpoint(mediumAndUp) { + .securitySectionTitle { + width: initial; + flex: 2; + } + + .securityRouteRoot { + padding: 50px; + } +} + +.row { + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-content: flex-start; + align-items: flex-start; + gap: 0 20px; + width: 100%; + + &:not(:last-child) { + margin-bottom: 15px; + } + + // This handles the issue of grouping the possibly not displayed items in + // a simple and elegant way - as opposed to adding more logic to the TSX file. + &:empty { + display: none; + } + + .checkboxLabel { + margin-top: 6px; + margin-bottom: 6px; + color: colors.$kobo-gray-800; + } + + :global { + .checkbox__wrapper { + margin-top: 6px; + } + } +} + +.field { + max-width: 285px; +} diff --git a/jsapp/js/components/common/textBox.tsx b/jsapp/js/components/common/textBox.tsx index 667a298f63..7468095dfe 100644 --- a/jsapp/js/components/common/textBox.tsx +++ b/jsapp/js/components/common/textBox.tsx @@ -37,7 +37,7 @@ interface TextBoxProps { endIcon?: IconName; value: string; /** Not needed if `readOnly` */ - onChange?: Function; + onChange?: (newValue: string) => void; onBlur?: Function; onKeyPress?: Function; /** From cde2230bcf34c32be3f8082c329ed36aba13730d Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 21 Nov 2024 10:23:11 -0500 Subject: [PATCH 03/21] use mmo label --- .../OrganizationSettingsRoute.tsx | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index ddf87c26ef..8273fbcf41 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -1,6 +1,14 @@ +// Libraries import {useState} from 'react'; -import organizationSettingsStyles from 'js/account/organization/organizationSettingsRoute.module.scss'; +// Partial components import OrganizationSettingsField from './OrganizationSettingsField'; +// Stores, hooks and utilities +import subscriptionStore from 'js/account/subscriptionStore'; +import envStore from 'js/envStore'; +import {getSimpleMMOLabel} from './organization.utils'; +// Constants and types +// Styles +import organizationSettingsStyles from 'js/account/organization/organizationSettingsRoute.module.scss'; interface State { name: string; @@ -15,31 +23,41 @@ export default function OrganizationSettingsRoute() { const validateName = (currentName: string) => !currentName; const validateWebsite = (currentWebsite: string) => !currentWebsite; + const mmoLabel = getSimpleMMOLabel( + envStore.data, + subscriptionStore.activeSubscriptions[0], + false, + true + ); + return (

- {t('Team Details')} + {t('##team or org## details').replace('##team or org##', mmoLabel)}

+ + {state.website && ( )} + {state.type && ( )}
From cb07f7fc799ecf73da6f30197087068d87bfd5be Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 21 Nov 2024 13:52:59 -0500 Subject: [PATCH 04/21] organization settings more work --- .../account/accountFieldsEditor.component.tsx | 2 +- .../OrganizationSettingsField.tsx | 2 +- .../OrganizationSettingsRoute.tsx | 114 +++++++++++++----- .../organizationSettingsRoute.module.scss | 96 ++------------- 4 files changed, 96 insertions(+), 118 deletions(-) diff --git a/jsapp/js/account/accountFieldsEditor.component.tsx b/jsapp/js/account/accountFieldsEditor.component.tsx index b52a1cdcaf..0a6f1d46c6 100644 --- a/jsapp/js/account/accountFieldsEditor.component.tsx +++ b/jsapp/js/account/accountFieldsEditor.component.tsx @@ -13,7 +13,7 @@ import type { } from './account.constants'; // See: kobo/apps/accounts/forms.py (KoboSignupMixin) -const ORGANIZATION_TYPE_SELECT_OPTIONS = [ +export const ORGANIZATION_TYPE_SELECT_OPTIONS = [ {value: 'non-profit', label: t('Non-profit organization')}, {value: 'government', label: t('Government institution')}, {value: 'educational', label: t('Educational organization')}, diff --git a/jsapp/js/account/organization/OrganizationSettingsField.tsx b/jsapp/js/account/organization/OrganizationSettingsField.tsx index 02e4ca69d3..4a832c4bfe 100644 --- a/jsapp/js/account/organization/OrganizationSettingsField.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsField.tsx @@ -1,5 +1,5 @@ -import organizationSettingsStyles from 'js/account/organization/organizationSettingsRoute.module.scss'; import TextBox from 'jsapp/js/components/common/textBox'; +import organizationSettingsStyles from 'js/account/organization/organizationSettingsRoute.module.scss'; interface Props { label: string; diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 8273fbcf41..3eaab1f48d 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -1,14 +1,23 @@ // Libraries -import {useState} from 'react'; +import {useState, useEffect} from 'react'; + // Partial components import OrganizationSettingsField from './OrganizationSettingsField'; +import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; +import InlineMessage from 'jsapp/js/components/common/inlineMessage'; + // Stores, hooks and utilities +import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; import subscriptionStore from 'js/account/subscriptionStore'; import envStore from 'js/envStore'; import {getSimpleMMOLabel} from './organization.utils'; + // Constants and types +import {ORGANIZATION_TYPE_SELECT_OPTIONS} from 'js/account/accountFieldsEditor.component'; + // Styles -import organizationSettingsStyles from 'js/account/organization/organizationSettingsRoute.module.scss'; +import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; interface State { name: string; @@ -17,11 +26,40 @@ interface State { } export default function OrganizationSettingsRoute() { - const [state, setState] = useState({name: 'Example Name', website: 'website', type: 'ngo'}); - const handleChangeName = (name: string) => setState((prevState) => {return {...prevState, name};}); - const handleChangeWebsite = (website: string) => setState((prevState) => {return {...prevState, website};}); - const validateName = (currentName: string) => !currentName; - const validateWebsite = (currentWebsite: string) => !currentWebsite; + const orgQuery = useOrganizationQuery(); + const [state, setState] = useState({name: ''}); + const [isStripeEnabled, setIsStripeEnabled] = useState(false); + + useEffect(() => { + if (orgQuery.data) { + setState({name: orgQuery.data.name}); + } + }, [orgQuery.data]); + + useWhenStripeIsEnabled(() => { + setIsStripeEnabled(true); + }, []); + + function handleChangeName(name: string) { + setState((prevState) => {return {...prevState, name};}); + // TODO: call the API endpoint and mark things as `isPending` + } + function handleChangeWebsite(website: string) { + setState((prevState) => {return {...prevState, website};}); + // TODO: call the API endpoint and mark things as `isPending` + } + function isNameValueValid(currentName: string) { + return !currentName; + } + function isWebsiteValueValid(currentWebsite: string) { + return !currentWebsite; + } + + function getTypeLabel(typeName: string) { + // TODO: see if this would be an actual source of the organization type label + const foundLabel = ORGANIZATION_TYPE_SELECT_OPTIONS.find((item) => item.value === typeName)?.label; + return foundLabel || typeName; + } const mmoLabel = getSimpleMMOLabel( envStore.data, @@ -29,37 +67,55 @@ export default function OrganizationSettingsRoute() { false, true ); + const mmoLabelLowercase = mmoLabel.toLowerCase(); + + if (!orgQuery.data) { + return ; + } return ( -
-
-

+
+
+

{t('##team or org## details').replace('##team or org##', mmoLabel)}

- - - {state.website && ( +
- )} - {state.type && ( - - )} + {isStripeEnabled && state.website && ( + + )} +
+ +
+ {isStripeEnabled && state.type && ( + + )} +
+ +
); } diff --git a/jsapp/js/account/organization/organizationSettingsRoute.module.scss b/jsapp/js/account/organization/organizationSettingsRoute.module.scss index 92f4350d19..c2672d8aba 100644 --- a/jsapp/js/account/organization/organizationSettingsRoute.module.scss +++ b/jsapp/js/account/organization/organizationSettingsRoute.module.scss @@ -3,22 +3,13 @@ @use 'scss/breakpoints'; @use 'js/components/common/textBox.module'; -.securityRouteRoot { +.orgSettingsRoot { padding: 20px; overflow-y: auto; height: 100%; - - :global { - // Harmonize button widths - .k-button__label { - display: inline-block; - min-width: 6em; - text-align: center; - } - } } -header.securityHeader { +header.orgSettingsHeader { @include mixins.centerRowFlex; margin: 24px 0; @@ -27,7 +18,7 @@ header.securityHeader { } } -h2.securityHeaderText { +h2.orgSettingsHeaderText { color: colors.$kobo-storm; text-transform: uppercase; font-size: 18px; @@ -36,93 +27,24 @@ h2.securityHeaderText { margin: 0; } -.securityHeaderActions { - @include mixins.centerRowFlex; -} - -// Shared styles for sections - -.securitySection { +.fieldsRow { display: flex; align-items: baseline; flex-wrap: wrap; gap: 16px; - padding: 16px 0; - border-top: 1px solid colors.$kobo-gray-200; - &:last-of-type { - border-bottom: 1px solid colors.$kobo-gray-200; + &:not(:first-child) { + margin-top: 16px; } } -.securitySectionTitle { +.field { + max-width: 285px; width: 100%; - flex: initial; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; -} - -.securitySectionTitleText { - margin: 0; - color: colors.$kobo-gray-600; - font-weight: 600; - line-height: 1.6; - font-size: 16px; -} - -.securitySectionBody { - flex: 3; - display: flex; - flex-direction: row; - align-items: center; - flex-wrap: wrap; } @include breakpoints.breakpoint(mediumAndUp) { - .securitySectionTitle { - width: initial; - flex: 2; - } - - .securityRouteRoot { + .orgSettingsRoot { padding: 50px; } } - -.row { - display: flex; - flex-wrap: wrap; - flex-direction: row; - align-content: flex-start; - align-items: flex-start; - gap: 0 20px; - width: 100%; - - &:not(:last-child) { - margin-bottom: 15px; - } - - // This handles the issue of grouping the possibly not displayed items in - // a simple and elegant way - as opposed to adding more logic to the TSX file. - &:empty { - display: none; - } - - .checkboxLabel { - margin-top: 6px; - margin-bottom: 6px; - color: colors.$kobo-gray-800; - } - - :global { - .checkbox__wrapper { - margin-top: 6px; - } - } -} - -.field { - max-width: 285px; -} From 35f3f6d2ae6a57c6e1b86e330af4f31596689418 Mon Sep 17 00:00:00 2001 From: Leszek Date: Mon, 25 Nov 2024 18:17:21 +0100 Subject: [PATCH 05/21] disable changing settings fields for non admin/non owner --- jsapp/js/account/organization/OrganizationSettingsField.tsx | 6 ++++-- jsapp/js/account/organization/OrganizationSettingsRoute.tsx | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/jsapp/js/account/organization/OrganizationSettingsField.tsx b/jsapp/js/account/organization/OrganizationSettingsField.tsx index 4a832c4bfe..6cc0241d52 100644 --- a/jsapp/js/account/organization/OrganizationSettingsField.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsField.tsx @@ -4,11 +4,12 @@ import organizationSettingsStyles from 'js/account/organization/organizationSett interface Props { label: string; value: string; + isDisabled?: boolean; onChange?: (newValue: string) => void; validateValue?: (currentValue: string) => string | boolean | string[] | undefined; } -export default function OrganizationSettingsField({label, value, onChange, validateValue}: Props) { +export default function OrganizationSettingsField({label, value, onChange, isDisabled, validateValue}: Props) { return (
diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 3eaab1f48d..a323100f0c 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -40,6 +40,9 @@ export default function OrganizationSettingsRoute() { setIsStripeEnabled(true); }, []); + // TODO: get this value from somewhere + const isUserAdminOrOwner = false; + function handleChangeName(name: string) { setState((prevState) => {return {...prevState, name};}); // TODO: call the API endpoint and mark things as `isPending` @@ -87,6 +90,7 @@ export default function OrganizationSettingsRoute() { onChange={handleChangeName} value={state.name} validateValue={isNameValueValid} + isDisabled={!isUserAdminOrOwner} /> {isStripeEnabled && state.website && ( @@ -95,6 +99,7 @@ export default function OrganizationSettingsRoute() { onChange={handleChangeWebsite} value={state.website} validateValue={isWebsiteValueValid} + isDisabled={!isUserAdminOrOwner} /> )} @@ -104,6 +109,7 @@ export default function OrganizationSettingsRoute() { )} From 7b4c42a370f0273b91d544e9fca4b50fab029392 Mon Sep 17 00:00:00 2001 From: Leszek Date: Tue, 26 Nov 2024 15:37:47 +0100 Subject: [PATCH 06/21] add save button, handle permissions to edit things --- .../OrganizationSettingsRoute.tsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index a323100f0c..90f64d284d 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -5,10 +5,11 @@ import {useState, useEffect} from 'react'; import OrganizationSettingsField from './OrganizationSettingsField'; import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; import InlineMessage from 'jsapp/js/components/common/inlineMessage'; +import Button from 'jsapp/js/components/common/button'; // Stores, hooks and utilities import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; -import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; +import {OrganizationUserRole, useOrganizationQuery} from 'js/account/organization/organizationQuery'; import subscriptionStore from 'js/account/subscriptionStore'; import envStore from 'js/envStore'; import {getSimpleMMOLabel} from './organization.utils'; @@ -40,20 +41,31 @@ export default function OrganizationSettingsRoute() { setIsStripeEnabled(true); }, []); - // TODO: get this value from somewhere - const isUserAdminOrOwner = false; + const isUserAdminOrOwner = ( + orgQuery.data?.request_user_role && + [OrganizationUserRole.admin, OrganizationUserRole.owner] + .includes(orgQuery.data?.request_user_role) + ); + + const isPendingOrgPatch = orgQuery.data && orgQuery.isPending; + + function handleSave() { + // TODO: call the API endpoint + console.log('save'); + } function handleChangeName(name: string) { setState((prevState) => {return {...prevState, name};}); - // TODO: call the API endpoint and mark things as `isPending` } + function handleChangeWebsite(website: string) { setState((prevState) => {return {...prevState, website};}); - // TODO: call the API endpoint and mark things as `isPending` } + function isNameValueValid(currentName: string) { return !currentName; } + function isWebsiteValueValid(currentWebsite: string) { return !currentWebsite; } @@ -90,7 +102,7 @@ export default function OrganizationSettingsRoute() { onChange={handleChangeName} value={state.name} validateValue={isNameValueValid} - isDisabled={!isUserAdminOrOwner} + isDisabled={!isUserAdminOrOwner || isPendingOrgPatch} /> {isStripeEnabled && state.website && ( @@ -99,7 +111,7 @@ export default function OrganizationSettingsRoute() { onChange={handleChangeWebsite} value={state.website} validateValue={isWebsiteValueValid} - isDisabled={!isUserAdminOrOwner} + isDisabled={!isUserAdminOrOwner || isPendingOrgPatch} /> )} @@ -114,6 +126,15 @@ export default function OrganizationSettingsRoute() { )} +

From 54714c9ec63ce210b75209111975f89c9ea97f3f Mon Sep 17 00:00:00 2001 From: Leszek Date: Tue, 26 Nov 2024 15:52:09 +0100 Subject: [PATCH 09/21] remove comment --- jsapp/js/account/organization/OrganizationSettingsRoute.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 80f8d89dad..dc7ad48a14 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -138,7 +138,6 @@ export default function OrganizationSettingsRoute() { Date: Tue, 26 Nov 2024 16:08:59 +0100 Subject: [PATCH 10/21] improve TODO comments --- jsapp/js/account/organization/OrganizationSettingsRoute.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index dc7ad48a14..7df6f46cf1 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -52,6 +52,7 @@ export default function OrganizationSettingsRoute() { function handleSave() { // TODO: call the API endpoint + // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a console.log('save'); } @@ -73,6 +74,7 @@ export default function OrganizationSettingsRoute() { function getTypeLabel(typeName: string) { // TODO: see if this would be an actual source of the organization type label + // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a const foundLabel = ORGANIZATION_TYPE_SELECT_OPTIONS.find((item) => item.value === typeName)?.label; return foundLabel || typeName; } From b3a2443b3dc1182ca005de41eaca47979982ae42 Mon Sep 17 00:00:00 2001 From: Leszek Date: Tue, 26 Nov 2024 20:24:26 +0100 Subject: [PATCH 11/21] improve comments, linter fixes --- .../organization/OrganizationSettingsField.tsx | 14 ++++++++++++-- .../organization/OrganizationSettingsRoute.tsx | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/jsapp/js/account/organization/OrganizationSettingsField.tsx b/jsapp/js/account/organization/OrganizationSettingsField.tsx index 6cc0241d52..9eebff1b72 100644 --- a/jsapp/js/account/organization/OrganizationSettingsField.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsField.tsx @@ -5,11 +5,22 @@ interface Props { label: string; value: string; isDisabled?: boolean; + /** If `onChange` is not provided, we make the field disabled for safety. */ onChange?: (newValue: string) => void; + /** + * Function that ensures that field value is valid. If invalid will cause + * an error to be displayed. + */ validateValue?: (currentValue: string) => string | boolean | string[] | undefined; } -export default function OrganizationSettingsField({label, value, onChange, isDisabled, validateValue}: Props) { +/** + * A `TextBox` wrapper componet for `OrganizationSettingsRoute` that makes code + * a bit more DRY. + */ +export default function OrganizationSettingsField( + {label, value, isDisabled, onChange, validateValue}: Props +) { return (
diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 7df6f46cf1..84529a3093 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -26,6 +26,11 @@ interface State { type?: string; } +/** + * Renders few fields with organization related settings, like name or website + * (with some logic in regards to their visibility). If user has necessary role, + * they can edit available fields. + */ export default function OrganizationSettingsRoute() { const orgQuery = useOrganizationQuery(); const [subscriptions] = useState(() => subscriptionStore); From 67e6d0af951870353143f4f5a0aa15bbeff59240 Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 28 Nov 2024 20:58:51 +0100 Subject: [PATCH 12/21] use better placeholders --- .../organization/OrganizationSettingsRoute.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 84529a3093..6b78ad28c4 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -100,13 +100,13 @@ export default function OrganizationSettingsRoute() {

- {t('##team or org## details').replace('##team or org##', mmoLabel)} + {t('##team/org## details').replace('##team/org##', mmoLabel)}

{isStripeEnabled && state.type && ( @@ -146,8 +146,8 @@ export default function OrganizationSettingsRoute() { From c11cfdefcb66ed66a1560a0f7dd22fe3e98cc1c4 Mon Sep 17 00:00:00 2001 From: Leszek Date: Tue, 3 Dec 2024 15:26:05 +0100 Subject: [PATCH 13/21] cleanup Organization types, add usePatchOrganization mutation hook and reorganize imports --- .../account/organization/organizationQuery.ts | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts index 70f499b09a..3a6ca84ca0 100644 --- a/jsapp/js/account/organization/organizationQuery.ts +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -1,19 +1,36 @@ -import type {FailResponse} from 'js/dataInterface'; -import {fetchGetUrl} from 'jsapp/js/api'; -import type {UndefinedInitialDataOptions} from '@tanstack/react-query'; -import {useQuery} from '@tanstack/react-query'; -import {QueryKeys} from 'js/query/queryKeys'; +// Libraries +import {useMutation, useQuery, useQueryClient, type UndefinedInitialDataOptions} from '@tanstack/react-query'; +import {useEffect} from 'react'; + +// Stores, hooks and utilities +import {fetchGetUrl, fetchPatch} from 'jsapp/js/api'; import {FeatureFlag, useFeatureFlag} from 'js/featureFlags'; import sessionStore from 'js/stores/session'; -import {useEffect} from 'react'; + +// Constants and types +import type {FailResponse} from 'js/dataInterface'; +import {QueryKeys} from 'js/query/queryKeys'; + +// Comes from `kobo/apps/accounts/forms.py` +type OrganizationTypeName = 'non-profit' | 'government' | 'educational' | 'commercial' | 'none'; + +export const ORGANIZATION_TYPES: { + [P in OrganizationTypeName]: {name: OrganizationTypeName; label: string} +} = { + 'non-profit': {name: 'non-profit', label: t('Non-profit organization')}, + government: {name: 'government', label: t('Government institution')}, + educational: {name: 'educational', label: t('Educational organization')}, + commercial: {name: 'commercial', label: t('A commercial/for-profit company')}, + none: {name: 'none', label: t('I am not associated with any organization')}, +}; export interface Organization { id: string; name: string; - is_active: boolean; + website: string; + organization_type: OrganizationTypeName; created: string; modified: string; - slug: string; is_owner: boolean; is_mmo: boolean; request_user_role: OrganizationUserRole; @@ -25,6 +42,22 @@ export enum OrganizationUserRole { owner = 'owner', } +/** + * Mutation hook for updating organization. It ensures that all related queries + * refetch data (are invalidated). + */ +export function usePatchOrganization(orgUrl: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data: Partial) => ( + fetchPatch(orgUrl, data) + ), + onSettled: () => { + queryClient.invalidateQueries({queryKey: [QueryKeys.organization]}); + }, + }); +} + /** * Organization object is used globally. * For convenience, errors are handled once at the top, see `RequireOrg`. From 984f205f155541a37b54e9d88894ae3872f06ece Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 4 Dec 2024 12:20:54 +0100 Subject: [PATCH 14/21] use single source of truth for organization types --- .../account/accountFieldsEditor.component.tsx | 17 +++++++++-------- .../organization/OrganizationSettingsRoute.tsx | 11 ++++------- .../account/organization/organizationQuery.ts | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/jsapp/js/account/accountFieldsEditor.component.tsx b/jsapp/js/account/accountFieldsEditor.component.tsx index 7ef14e2c62..dbf175b573 100644 --- a/jsapp/js/account/accountFieldsEditor.component.tsx +++ b/jsapp/js/account/accountFieldsEditor.component.tsx @@ -11,15 +11,16 @@ import type { AccountFieldsValues, AccountFieldsErrors, } from './account.constants'; +import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; + +const ORGANIZATION_TYPE_SELECT_OPTIONS = Object.keys(ORGANIZATION_TYPES) + .map((typeName) => { + return { + value: typeName, + label: ORGANIZATION_TYPES[typeName as OrganizationTypeName].label, + }; +}); -// See: kobo/apps/accounts/forms.py (KoboSignupMixin) -export const ORGANIZATION_TYPE_SELECT_OPTIONS = [ - {value: 'non-profit', label: t('Non-profit organization')}, - {value: 'government', label: t('Government institution')}, - {value: 'educational', label: t('Educational organization')}, - {value: 'commercial', label: t('A commercial/for-profit company')}, - {value: 'none', label: t('I am not associated with any organization')}, -]; const GENDER_SELECT_OPTIONS = [ {value: 'male', label: t('Male')}, {value: 'female', label: t('Female')}, diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 6b78ad28c4..c6e4b35373 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -15,7 +15,7 @@ import envStore from 'js/envStore'; import {getSimpleMMOLabel} from './organization.utils'; // Constants and types -import {ORGANIZATION_TYPE_SELECT_OPTIONS} from 'js/account/accountFieldsEditor.component'; +import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; // Styles import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; @@ -23,7 +23,7 @@ import styles from 'js/account/organization/organizationSettingsRoute.module.scs interface State { name: string; website?: string; - type?: string; + type?: OrganizationTypeName; } /** @@ -77,11 +77,8 @@ export default function OrganizationSettingsRoute() { return !currentWebsite; } - function getTypeLabel(typeName: string) { - // TODO: see if this would be an actual source of the organization type label - // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a - const foundLabel = ORGANIZATION_TYPE_SELECT_OPTIONS.find((item) => item.value === typeName)?.label; - return foundLabel || typeName; + function getTypeLabel(typeName: OrganizationTypeName) { + return ORGANIZATION_TYPES[typeName]?.label || typeName; } const mmoLabel = getSimpleMMOLabel( diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts index 3a6ca84ca0..6177c6ae88 100644 --- a/jsapp/js/account/organization/organizationQuery.ts +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -12,7 +12,7 @@ import type {FailResponse} from 'js/dataInterface'; import {QueryKeys} from 'js/query/queryKeys'; // Comes from `kobo/apps/accounts/forms.py` -type OrganizationTypeName = 'non-profit' | 'government' | 'educational' | 'commercial' | 'none'; +export type OrganizationTypeName = 'non-profit' | 'government' | 'educational' | 'commercial' | 'none'; export const ORGANIZATION_TYPES: { [P in OrganizationTypeName]: {name: OrganizationTypeName; label: string} From 147365ae3fc9c1a1a93d64584384254092242a58 Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 4 Dec 2024 15:09:58 +0100 Subject: [PATCH 15/21] some code review fixes --- .../OrganizationSettingsField.tsx | 36 ----- .../OrganizationSettingsRoute.tsx | 146 ++++++++++-------- .../organizationSettingsRoute.module.scss | 11 +- 3 files changed, 94 insertions(+), 99 deletions(-) delete mode 100644 jsapp/js/account/organization/OrganizationSettingsField.tsx diff --git a/jsapp/js/account/organization/OrganizationSettingsField.tsx b/jsapp/js/account/organization/OrganizationSettingsField.tsx deleted file mode 100644 index 9eebff1b72..0000000000 --- a/jsapp/js/account/organization/OrganizationSettingsField.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import TextBox from 'jsapp/js/components/common/textBox'; -import organizationSettingsStyles from 'js/account/organization/organizationSettingsRoute.module.scss'; - -interface Props { - label: string; - value: string; - isDisabled?: boolean; - /** If `onChange` is not provided, we make the field disabled for safety. */ - onChange?: (newValue: string) => void; - /** - * Function that ensures that field value is valid. If invalid will cause - * an error to be displayed. - */ - validateValue?: (currentValue: string) => string | boolean | string[] | undefined; -} - -/** - * A `TextBox` wrapper componet for `OrganizationSettingsRoute` that makes code - * a bit more DRY. - */ -export default function OrganizationSettingsField( - {label, value, isDisabled, onChange, validateValue}: Props -) { - return ( -
- -
- ); -} diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index c6e4b35373..808d1eb084 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -2,10 +2,11 @@ import {useState, useEffect} from 'react'; // Partial components -import OrganizationSettingsField from './OrganizationSettingsField'; import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; import InlineMessage from 'jsapp/js/components/common/inlineMessage'; import Button from 'jsapp/js/components/common/button'; +import TextBox from 'jsapp/js/components/common/textBox'; +import KoboSelect from 'jsapp/js/components/common/koboSelect'; // Stores, hooks and utilities import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; @@ -19,12 +20,7 @@ import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/or // Styles import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; - -interface State { - name: string; - website?: string; - type?: OrganizationTypeName; -} +import NlpUsageLimitBlockModal from 'jsapp/js/components/processing/nlpUsageLimitBlockModal/nlpUsageLimitBlockModal.component'; /** * Renders few fields with organization related settings, like name or website @@ -34,12 +30,16 @@ interface State { export default function OrganizationSettingsRoute() { const orgQuery = useOrganizationQuery(); const [subscriptions] = useState(() => subscriptionStore); - const [state, setState] = useState({name: ''}); + const [name, setName] = useState(''); + const [website, setWebsite] = useState(''); + const [orgType, setOrgType] = useState(null); const [isStripeEnabled, setIsStripeEnabled] = useState(false); useEffect(() => { if (orgQuery.data) { - setState({name: orgQuery.data.name}); + setName(orgQuery.data.name); + setWebsite(orgQuery.data.website); + setOrgType(orgQuery.data.organization_type); } }, [orgQuery.data]); @@ -55,30 +55,19 @@ export default function OrganizationSettingsRoute() { const isPendingOrgPatch = orgQuery.data && orgQuery.isPending; - function handleSave() { + function handleSave(e: React.FormEvent) { + e.preventDefault(); // TODO: call the API endpoint // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a - console.log('save'); - } - - function handleChangeName(name: string) { - setState((prevState) => {return {...prevState, name};}); - } - - function handleChangeWebsite(website: string) { - setState((prevState) => {return {...prevState, website};}); - } - - function isNameValueValid(currentName: string) { - return !currentName; + console.log('save', name, website); } - function isWebsiteValueValid(currentWebsite: string) { - return !currentWebsite; + function handleChangeName(newName: string) { + setName(newName); } - function getTypeLabel(typeName: OrganizationTypeName) { - return ORGANIZATION_TYPES[typeName]?.label || typeName; + function handleChangeWebsite(newWebsite: string) { + setWebsite(newWebsite); } const mmoLabel = getSimpleMMOLabel( @@ -89,12 +78,22 @@ export default function OrganizationSettingsRoute() { ); const mmoLabelLowercase = mmoLabel.toLowerCase(); - if (!orgQuery.data) { + if (orgQuery.isLoading) { return ; } + let deletionMessage = t('To delete this ##team/org##, please contact the server administrator.') + .replaceAll('##team/org##', mmoLabelLowercase); + if (isStripeEnabled) { + deletionMessage = t("To delete this ##team/org##, you need to cancel your current ##plan name## plan. At the end of the plan period your ##team/org##'s projects will be converted to projects owned by your personal account.") + .replaceAll('##team/org##', mmoLabelLowercase) + .replace('##plan name##', subscriptions.planName); + } + + const currentTypeLabel = orgType === null ? '' : ORGANIZATION_TYPES[orgType]?.label; + return ( -
+

{t('##team/org## details').replace('##team/org##', mmoLabel)} @@ -102,52 +101,75 @@ export default function OrganizationSettingsRoute() {

- - {isStripeEnabled && state.website && ( - )}
-
- {isStripeEnabled && state.type && ( - + null} /> - )} +
+ )} + +
+
-
+ + ); } diff --git a/jsapp/js/account/organization/organizationSettingsRoute.module.scss b/jsapp/js/account/organization/organizationSettingsRoute.module.scss index c2672d8aba..07117ff9b7 100644 --- a/jsapp/js/account/organization/organizationSettingsRoute.module.scss +++ b/jsapp/js/account/organization/organizationSettingsRoute.module.scss @@ -3,6 +3,8 @@ @use 'scss/breakpoints'; @use 'js/components/common/textBox.module'; +$s-field-width: 285px; + .orgSettingsRoot { padding: 20px; overflow-y: auto; @@ -39,7 +41,14 @@ h2.orgSettingsHeaderText { } .field { - max-width: 285px; + max-width: $s-field-width; + width: 100%; +} + +.fieldLong { + // When we display two fields in one row, and long field in other, we want + // them to align nicely, thus: + max-width: $s-field-width + 16px + $s-field-width; width: 100%; } From 8ffd210b0fab3d97508b55782dd51bcbee88a40a Mon Sep 17 00:00:00 2001 From: Leszek Date: Wed, 4 Dec 2024 15:33:41 +0100 Subject: [PATCH 16/21] finish hooking up, split out OrganizationSettingsForm --- .../organization/OrganizationSettingsForm.tsx | 177 ++++++++++++++++++ .../OrganizationSettingsRoute.tsx | 172 +---------------- .../account/organization/organizationQuery.ts | 2 +- 3 files changed, 188 insertions(+), 163 deletions(-) create mode 100644 jsapp/js/account/organization/OrganizationSettingsForm.tsx diff --git a/jsapp/js/account/organization/OrganizationSettingsForm.tsx b/jsapp/js/account/organization/OrganizationSettingsForm.tsx new file mode 100644 index 0000000000..31d05cc6e5 --- /dev/null +++ b/jsapp/js/account/organization/OrganizationSettingsForm.tsx @@ -0,0 +1,177 @@ +// Libraries +import {useState, useEffect} from 'react'; + +// Partial components +import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; +import InlineMessage from 'jsapp/js/components/common/inlineMessage'; +import Button from 'jsapp/js/components/common/button'; +import TextBox from 'jsapp/js/components/common/textBox'; +import KoboSelect from 'jsapp/js/components/common/koboSelect'; + +// Stores, hooks and utilities +import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; +import {OrganizationUserRole, useOrganizationQuery, usePatchOrganization} from 'js/account/organization/organizationQuery'; +import subscriptionStore from 'js/account/subscriptionStore'; +import envStore from 'js/envStore'; +import {getSimpleMMOLabel} from './organization.utils'; + +// Constants and types +import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; + +// Styles +import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; + +/** + * Renders few fields with organization related settings, like name or website + * (with some logic in regards to their visibility). If user has necessary role, + * they can edit available fields. + */ +export default function OrganizationSettingsForm(props: {orgUrl: string}) { + const orgQuery = useOrganizationQuery(); + const [subscriptions] = useState(() => subscriptionStore); + const [isStripeEnabled, setIsStripeEnabled] = useState(false); + const patchOrganization = usePatchOrganization(props.orgUrl); + + // All displayed fields + const [name, setName] = useState(''); + const [website, setWebsite] = useState(''); + const [orgType, setOrgType] = useState(null); + + useEffect(() => { + if (orgQuery.data) { + setName(orgQuery.data.name); + setWebsite(orgQuery.data.website); + setOrgType(orgQuery.data.organization_type); + } + }, [orgQuery.data]); + + useWhenStripeIsEnabled(() => { + setIsStripeEnabled(true); + }, []); + + const isUserAdminOrOwner = ( + orgQuery.data?.request_user_role && + [OrganizationUserRole.admin, OrganizationUserRole.owner] + .includes(orgQuery.data?.request_user_role) + ); + + function handleSave(e: React.FormEvent) { + e.preventDefault(); + // TODO: call the API endpoint + // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a + console.log('save', name, website); + + patchOrganization.mutateAsync({name, website}); + } + + function handleChangeName(newName: string) { + setName(newName); + } + + function handleChangeWebsite(newWebsite: string) { + setWebsite(newWebsite); + } + + const mmoLabel = getSimpleMMOLabel( + envStore.data, + subscriptionStore.activeSubscriptions[0], + false, + true + ); + const mmoLabelLowercase = mmoLabel.toLowerCase(); + + if (orgQuery.isLoading) { + return ; + } + + let deletionMessage = t('To delete this ##team/org##, please contact the server administrator.') + .replaceAll('##team/org##', mmoLabelLowercase); + if (isStripeEnabled) { + deletionMessage = t("To delete this ##team/org##, you need to cancel your current ##plan name## plan. At the end of the plan period your ##team/org##'s projects will be converted to projects owned by your personal account.") + .replaceAll('##team/org##', mmoLabelLowercase) + .replace('##plan name##', subscriptions.planName); + } + + const currentTypeLabel = orgType === null ? '' : ORGANIZATION_TYPES[orgType]?.label; + + return ( +
+
+

+ {t('##team/org## details').replace('##team/org##', mmoLabel)} +

+
+ +
+ {/* + On all instances, both owner and admins should be able to edit + organization name. + */} + + + {/* + On Stripe-enabled instances, both owner and admins should be able to + edit organization website. On non-Stripe enabled instances it is not + visible. + */} + {isStripeEnabled && ( + + )} +
+ + {/* + On Stripe-enabled instances, both owner and admins should be able to + view organization type. On non-Stripe enabled instances it is not + visible. + */} + {isStripeEnabled && orgType && ( +
+ null} + /> +
+ )} + +
+
+ + + + ); +} diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 808d1eb084..7ec5fcb9aa 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -1,175 +1,23 @@ -// Libraries -import {useState, useEffect} from 'react'; - // Partial components import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; -import InlineMessage from 'jsapp/js/components/common/inlineMessage'; -import Button from 'jsapp/js/components/common/button'; -import TextBox from 'jsapp/js/components/common/textBox'; -import KoboSelect from 'jsapp/js/components/common/koboSelect'; +import OrganizationSettingsForm from './OrganizationSettingsForm'; // Stores, hooks and utilities -import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; -import {OrganizationUserRole, useOrganizationQuery} from 'js/account/organization/organizationQuery'; -import subscriptionStore from 'js/account/subscriptionStore'; -import envStore from 'js/envStore'; -import {getSimpleMMOLabel} from './organization.utils'; - -// Constants and types -import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; - -// Styles -import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; -import NlpUsageLimitBlockModal from 'jsapp/js/components/processing/nlpUsageLimitBlockModal/nlpUsageLimitBlockModal.component'; +import {useSession} from 'jsapp/js/stores/useSession'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; /** - * Renders few fields with organization related settings, like name or website - * (with some logic in regards to their visibility). If user has necessary role, - * they can edit available fields. + * Renders Organization Settings form and handles loading requirements. + * Note: we keep this separate from OrganizationSettingsForm to ensure that + * required `organizationUrl` is present. */ export default function OrganizationSettingsRoute() { const orgQuery = useOrganizationQuery(); - const [subscriptions] = useState(() => subscriptionStore); - const [name, setName] = useState(''); - const [website, setWebsite] = useState(''); - const [orgType, setOrgType] = useState(null); - const [isStripeEnabled, setIsStripeEnabled] = useState(false); - - useEffect(() => { - if (orgQuery.data) { - setName(orgQuery.data.name); - setWebsite(orgQuery.data.website); - setOrgType(orgQuery.data.organization_type); - } - }, [orgQuery.data]); - - useWhenStripeIsEnabled(() => { - setIsStripeEnabled(true); - }, []); - - const isUserAdminOrOwner = ( - orgQuery.data?.request_user_role && - [OrganizationUserRole.admin, OrganizationUserRole.owner] - .includes(orgQuery.data?.request_user_role) - ); + const session = useSession(); + const organizationUrl = session.currentLoggedAccount?.organization?.url; - const isPendingOrgPatch = orgQuery.data && orgQuery.isPending; - - function handleSave(e: React.FormEvent) { - e.preventDefault(); - // TODO: call the API endpoint - // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a - console.log('save', name, website); - } - - function handleChangeName(newName: string) { - setName(newName); - } - - function handleChangeWebsite(newWebsite: string) { - setWebsite(newWebsite); - } - - const mmoLabel = getSimpleMMOLabel( - envStore.data, - subscriptionStore.activeSubscriptions[0], - false, - true - ); - const mmoLabelLowercase = mmoLabel.toLowerCase(); - - if (orgQuery.isLoading) { + if (orgQuery.isLoading || !organizationUrl) { return ; } - - let deletionMessage = t('To delete this ##team/org##, please contact the server administrator.') - .replaceAll('##team/org##', mmoLabelLowercase); - if (isStripeEnabled) { - deletionMessage = t("To delete this ##team/org##, you need to cancel your current ##plan name## plan. At the end of the plan period your ##team/org##'s projects will be converted to projects owned by your personal account.") - .replaceAll('##team/org##', mmoLabelLowercase) - .replace('##plan name##', subscriptions.planName); - } - - const currentTypeLabel = orgType === null ? '' : ORGANIZATION_TYPES[orgType]?.label; - - return ( -
-
-

- {t('##team/org## details').replace('##team/org##', mmoLabel)} -

-
- -
- {/* - On all instances, both owner and admins should be able to edit - organization name. - */} - - - {/* - On Stripe-enabled instances, both owner and admins should be able to - edit organization website. On non-Stripe enabled instances it is not - visible. - */} - {isStripeEnabled && ( - - )} -
- - {/* - On Stripe-enabled instances, both owner and admins should be able to - view organization type. On non-Stripe enabled instances it is not - visible. - */} - {isStripeEnabled && orgType && ( -
- null} - /> -
- )} - -
-
- - - - ); + return ; } diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts index 6177c6ae88..bea17cb29d 100644 --- a/jsapp/js/account/organization/organizationQuery.ts +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -50,7 +50,7 @@ export function usePatchOrganization(orgUrl: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data: Partial) => ( - fetchPatch(orgUrl, data) + fetchPatch(orgUrl, data, {prependRootUrl: false}) ), onSettled: () => { queryClient.invalidateQueries({queryKey: [QueryKeys.organization]}); From b0faa7a09c907c07b6944a0c010b7c5bf572582a Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 5 Dec 2024 14:54:24 +0100 Subject: [PATCH 17/21] don't require passing orgUrl in usePatchOrganization plus update some comments --- .../account/organization/organizationQuery.ts | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts index 3a6ca84ca0..0d2ad27f67 100644 --- a/jsapp/js/account/organization/organizationQuery.ts +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -6,6 +6,7 @@ import {useEffect} from 'react'; import {fetchGetUrl, fetchPatch} from 'jsapp/js/api'; import {FeatureFlag, useFeatureFlag} from 'js/featureFlags'; import sessionStore from 'js/stores/session'; +import {useSession} from 'jsapp/js/stores/useSession'; // Constants and types import type {FailResponse} from 'js/dataInterface'; @@ -46,11 +47,18 @@ export enum OrganizationUserRole { * Mutation hook for updating organization. It ensures that all related queries * refetch data (are invalidated). */ -export function usePatchOrganization(orgUrl: string) { +export function usePatchOrganization() { const queryClient = useQueryClient(); + const session = useSession(); + const organizationUrl = session.currentLoggedAccount?.organization?.url; + return useMutation({ mutationFn: async (data: Partial) => ( - fetchPatch(orgUrl, data) + // We're asserting the `organizationUrl` is not `undefined` here, because + // the parent query (`useOrganizationQuery`) wouldn't be enabled without + // it. Plus all the organization-related UI is accessible only to + // logged in users. + fetchPatch(organizationUrl!, data) ), onSettled: () => { queryClient.invalidateQueries({queryKey: [QueryKeys.organization]}); @@ -66,32 +74,34 @@ export function usePatchOrganization(orgUrl: string) { export const useOrganizationQuery = (options?: Omit, 'queryFn' | 'queryKey'>) => { const isMmosEnabled = useFeatureFlag(FeatureFlag.mmosEnabled); - const currentAccount = sessionStore.currentAccount; - - const organizationUrl = - 'organization' in currentAccount ? currentAccount.organization?.url : null; + const session = useSession(); + const organizationUrl = session.currentLoggedAccount?.organization?.url; // Using a separated function to fetch the organization data to prevent // feature flag dependencies from being added to the hook const fetchOrganization = async (): Promise => { - // organizationUrl is a full url with protocol and domain name, so we're using fetchGetUrl - // We're asserting the organizationUrl is not null here because the query is disabled if it is + // `organizationUrl` is a full url with protocol and domain name, so we're + // using fetchGetUrl. + // We're asserting the `organizationUrl` is not `undefined` here because + // the query is disabled without it. const organization = await fetchGetUrl(organizationUrl!); if (isMmosEnabled) { return organization; } - // While the project is in development we will force a false return for the is_mmo - // to make sure we don't have any implementations appearing for users + // While the project is in development we will force a `false` return for + // the `is_mmo` to make sure we don't have any implementations appearing + // for users. return { ...organization, is_mmo: false, }; }; - // Setting the 'enabled' property so the query won't run until we have the session data - // loaded. Account data is needed to fetch the organization data. + // Setting the 'enabled' property so the query won't run until we have + // the session data loaded. Account data is needed to fetch the organization + // data. const isQueryEnabled = !sessionStore.isPending && sessionStore.isInitialLoadComplete && @@ -104,9 +114,10 @@ export const useOrganizationQuery = (options?: Omit { if (query.error?.status === 404) { From 931a2fa0704cef0631be26f06d0b51b0561d2daa Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 5 Dec 2024 14:57:42 +0100 Subject: [PATCH 18/21] add export --- jsapp/js/account/organization/organizationQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts index 0d2ad27f67..41064ea134 100644 --- a/jsapp/js/account/organization/organizationQuery.ts +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -13,7 +13,7 @@ import type {FailResponse} from 'js/dataInterface'; import {QueryKeys} from 'js/query/queryKeys'; // Comes from `kobo/apps/accounts/forms.py` -type OrganizationTypeName = 'non-profit' | 'government' | 'educational' | 'commercial' | 'none'; +export type OrganizationTypeName = 'non-profit' | 'government' | 'educational' | 'commercial' | 'none'; export const ORGANIZATION_TYPES: { [P in OrganizationTypeName]: {name: OrganizationTypeName; label: string} From 7c92e19391b22ce13953270758441156133364d4 Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 5 Dec 2024 15:01:57 +0100 Subject: [PATCH 19/21] add prepend url false --- jsapp/js/account/organization/organizationQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts index 41064ea134..604a2116a5 100644 --- a/jsapp/js/account/organization/organizationQuery.ts +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -58,7 +58,7 @@ export function usePatchOrganization() { // the parent query (`useOrganizationQuery`) wouldn't be enabled without // it. Plus all the organization-related UI is accessible only to // logged in users. - fetchPatch(organizationUrl!, data) + fetchPatch(organizationUrl!, data, {prependRootUrl: false}) ), onSettled: () => { queryClient.invalidateQueries({queryKey: [QueryKeys.organization]}); From cd52acfa470e285b0e2f1b21153ebf4c40f73883 Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 5 Dec 2024 15:02:07 +0100 Subject: [PATCH 20/21] deduplicate organization type const --- .../account/accountFieldsEditor.component.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/jsapp/js/account/accountFieldsEditor.component.tsx b/jsapp/js/account/accountFieldsEditor.component.tsx index ce8e5dea81..dbf175b573 100644 --- a/jsapp/js/account/accountFieldsEditor.component.tsx +++ b/jsapp/js/account/accountFieldsEditor.component.tsx @@ -11,15 +11,16 @@ import type { AccountFieldsValues, AccountFieldsErrors, } from './account.constants'; +import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; + +const ORGANIZATION_TYPE_SELECT_OPTIONS = Object.keys(ORGANIZATION_TYPES) + .map((typeName) => { + return { + value: typeName, + label: ORGANIZATION_TYPES[typeName as OrganizationTypeName].label, + }; +}); -// See: kobo/apps/accounts/forms.py (KoboSignupMixin) -const ORGANIZATION_TYPE_SELECT_OPTIONS = [ - {value: 'non-profit', label: t('Non-profit organization')}, - {value: 'government', label: t('Government institution')}, - {value: 'educational', label: t('Educational organization')}, - {value: 'commercial', label: t('A commercial/for-profit company')}, - {value: 'none', label: t('I am not associated with any organization')}, -]; const GENDER_SELECT_OPTIONS = [ {value: 'male', label: t('Male')}, {value: 'female', label: t('Female')}, From a23c908f72679b3b629c90639008a966191eafaa Mon Sep 17 00:00:00 2001 From: Leszek Date: Thu, 5 Dec 2024 15:09:59 +0100 Subject: [PATCH 21/21] use simpler usePatchOrganization, merge files --- .../organization/OrganizationSettingsForm.tsx | 177 ------------------ .../OrganizationSettingsRoute.tsx | 170 ++++++++++++++++- 2 files changed, 160 insertions(+), 187 deletions(-) delete mode 100644 jsapp/js/account/organization/OrganizationSettingsForm.tsx diff --git a/jsapp/js/account/organization/OrganizationSettingsForm.tsx b/jsapp/js/account/organization/OrganizationSettingsForm.tsx deleted file mode 100644 index 31d05cc6e5..0000000000 --- a/jsapp/js/account/organization/OrganizationSettingsForm.tsx +++ /dev/null @@ -1,177 +0,0 @@ -// Libraries -import {useState, useEffect} from 'react'; - -// Partial components -import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; -import InlineMessage from 'jsapp/js/components/common/inlineMessage'; -import Button from 'jsapp/js/components/common/button'; -import TextBox from 'jsapp/js/components/common/textBox'; -import KoboSelect from 'jsapp/js/components/common/koboSelect'; - -// Stores, hooks and utilities -import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; -import {OrganizationUserRole, useOrganizationQuery, usePatchOrganization} from 'js/account/organization/organizationQuery'; -import subscriptionStore from 'js/account/subscriptionStore'; -import envStore from 'js/envStore'; -import {getSimpleMMOLabel} from './organization.utils'; - -// Constants and types -import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; - -// Styles -import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; - -/** - * Renders few fields with organization related settings, like name or website - * (with some logic in regards to their visibility). If user has necessary role, - * they can edit available fields. - */ -export default function OrganizationSettingsForm(props: {orgUrl: string}) { - const orgQuery = useOrganizationQuery(); - const [subscriptions] = useState(() => subscriptionStore); - const [isStripeEnabled, setIsStripeEnabled] = useState(false); - const patchOrganization = usePatchOrganization(props.orgUrl); - - // All displayed fields - const [name, setName] = useState(''); - const [website, setWebsite] = useState(''); - const [orgType, setOrgType] = useState(null); - - useEffect(() => { - if (orgQuery.data) { - setName(orgQuery.data.name); - setWebsite(orgQuery.data.website); - setOrgType(orgQuery.data.organization_type); - } - }, [orgQuery.data]); - - useWhenStripeIsEnabled(() => { - setIsStripeEnabled(true); - }, []); - - const isUserAdminOrOwner = ( - orgQuery.data?.request_user_role && - [OrganizationUserRole.admin, OrganizationUserRole.owner] - .includes(orgQuery.data?.request_user_role) - ); - - function handleSave(e: React.FormEvent) { - e.preventDefault(); - // TODO: call the API endpoint - // to be done while doing: https://www.notion.so/kobotoolbox/Add-react-query-mutation-hook-for-org-changes-1307e515f6548010b5d3c087b634f01a - console.log('save', name, website); - - patchOrganization.mutateAsync({name, website}); - } - - function handleChangeName(newName: string) { - setName(newName); - } - - function handleChangeWebsite(newWebsite: string) { - setWebsite(newWebsite); - } - - const mmoLabel = getSimpleMMOLabel( - envStore.data, - subscriptionStore.activeSubscriptions[0], - false, - true - ); - const mmoLabelLowercase = mmoLabel.toLowerCase(); - - if (orgQuery.isLoading) { - return ; - } - - let deletionMessage = t('To delete this ##team/org##, please contact the server administrator.') - .replaceAll('##team/org##', mmoLabelLowercase); - if (isStripeEnabled) { - deletionMessage = t("To delete this ##team/org##, you need to cancel your current ##plan name## plan. At the end of the plan period your ##team/org##'s projects will be converted to projects owned by your personal account.") - .replaceAll('##team/org##', mmoLabelLowercase) - .replace('##plan name##', subscriptions.planName); - } - - const currentTypeLabel = orgType === null ? '' : ORGANIZATION_TYPES[orgType]?.label; - - return ( -
-
-

- {t('##team/org## details').replace('##team/org##', mmoLabel)} -

-
- -
- {/* - On all instances, both owner and admins should be able to edit - organization name. - */} - - - {/* - On Stripe-enabled instances, both owner and admins should be able to - edit organization website. On non-Stripe enabled instances it is not - visible. - */} - {isStripeEnabled && ( - - )} -
- - {/* - On Stripe-enabled instances, both owner and admins should be able to - view organization type. On non-Stripe enabled instances it is not - visible. - */} - {isStripeEnabled && orgType && ( -
- null} - /> -
- )} - -
-
- - - - ); -} diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx index 7ec5fcb9aa..360f03e762 100644 --- a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -1,23 +1,173 @@ +// Libraries +import {useState, useEffect} from 'react'; + // Partial components import LoadingSpinner from 'jsapp/js/components/common/loadingSpinner'; -import OrganizationSettingsForm from './OrganizationSettingsForm'; +import InlineMessage from 'jsapp/js/components/common/inlineMessage'; +import Button from 'jsapp/js/components/common/button'; +import TextBox from 'jsapp/js/components/common/textBox'; +import KoboSelect from 'jsapp/js/components/common/koboSelect'; // Stores, hooks and utilities -import {useSession} from 'jsapp/js/stores/useSession'; -import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; +import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; +import {OrganizationUserRole, useOrganizationQuery, usePatchOrganization} from 'js/account/organization/organizationQuery'; +import subscriptionStore from 'js/account/subscriptionStore'; +import envStore from 'js/envStore'; +import {getSimpleMMOLabel} from './organization.utils'; + +// Constants and types +import {ORGANIZATION_TYPES, type OrganizationTypeName} from 'jsapp/js/account/organization/organizationQuery'; + +// Styles +import styles from 'js/account/organization/organizationSettingsRoute.module.scss'; /** - * Renders Organization Settings form and handles loading requirements. - * Note: we keep this separate from OrganizationSettingsForm to ensure that - * required `organizationUrl` is present. + * Renders few fields with organization related settings, like name or website + * (with some logic in regards to their visibility). If user has necessary role, + * they can edit available fields. */ export default function OrganizationSettingsRoute() { const orgQuery = useOrganizationQuery(); - const session = useSession(); - const organizationUrl = session.currentLoggedAccount?.organization?.url; + const [subscriptions] = useState(() => subscriptionStore); + const [isStripeEnabled, setIsStripeEnabled] = useState(false); + const patchOrganization = usePatchOrganization(); + + // All displayed fields + const [name, setName] = useState(''); + const [website, setWebsite] = useState(''); + const [orgType, setOrgType] = useState(null); + + useEffect(() => { + if (orgQuery.data) { + setName(orgQuery.data.name); + setWebsite(orgQuery.data.website); + setOrgType(orgQuery.data.organization_type); + } + }, [orgQuery.data]); + + useWhenStripeIsEnabled(() => { + setIsStripeEnabled(true); + }, []); - if (orgQuery.isLoading || !organizationUrl) { + const isUserAdminOrOwner = ( + orgQuery.data?.request_user_role && + [OrganizationUserRole.admin, OrganizationUserRole.owner] + .includes(orgQuery.data?.request_user_role) + ); + + function handleSave(e: React.FormEvent) { + e.preventDefault(); + patchOrganization.mutateAsync({name, website}); + } + + function handleChangeName(newName: string) { + setName(newName); + } + + function handleChangeWebsite(newWebsite: string) { + setWebsite(newWebsite); + } + + const mmoLabel = getSimpleMMOLabel( + envStore.data, + subscriptionStore.activeSubscriptions[0], + false, + true + ); + const mmoLabelLowercase = mmoLabel.toLowerCase(); + + if (orgQuery.isLoading) { return ; } - return ; + + let deletionMessage = t('To delete this ##team/org##, please contact the server administrator.') + .replaceAll('##team/org##', mmoLabelLowercase); + if (isStripeEnabled) { + deletionMessage = t("To delete this ##team/org##, you need to cancel your current ##plan name## plan. At the end of the plan period your ##team/org##'s projects will be converted to projects owned by your personal account.") + .replaceAll('##team/org##', mmoLabelLowercase) + .replace('##plan name##', subscriptions.planName); + } + + const currentTypeLabel = orgType === null ? '' : ORGANIZATION_TYPES[orgType]?.label; + + return ( +
+
+

+ {t('##team/org## details').replace('##team/org##', mmoLabel)} +

+
+ +
+ {/* + On all instances, both owner and admins should be able to edit + organization name. + */} + + + {/* + On Stripe-enabled instances, both owner and admins should be able to + edit organization website. On non-Stripe enabled instances it is not + visible. + */} + {isStripeEnabled && ( + + )} +
+ + {/* + On Stripe-enabled instances, both owner and admins should be able to + view organization type. On non-Stripe enabled instances it is not + visible. + */} + {isStripeEnabled && orgType && ( +
+ null} + /> +
+ )} + +
+
+ + + + ); }