Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(organizations): add Organization Settings route TASK-981 #5299

Merged
merged 31 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c6fda11
refactor(router): rename RequireOrgPermissions
Akuukis Nov 20, 2024
5db756a
wip: dump skeleton of organizations settings page
Akuukis Nov 20, 2024
15c6d81
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Nov 21, 2024
cde2230
use mmo label
magicznyleszek Nov 21, 2024
b5e980f
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Nov 21, 2024
cb07f7f
organization settings more work
magicznyleszek Nov 21, 2024
bf09e84
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Nov 25, 2024
35f3f6d
disable changing settings fields for non admin/non owner
magicznyleszek Nov 25, 2024
7b4c42a
add save button, handle permissions to edit things
magicznyleszek Nov 26, 2024
6d9ec12
move planName to subscriptionStore
magicznyleszek Nov 26, 2024
902a982
use plan name in OrganizationSettingsRoute
magicznyleszek Nov 26, 2024
54714c9
remove comment
magicznyleszek Nov 26, 2024
8789179
improve TODO comments
magicznyleszek Nov 26, 2024
b3a2443
improve comments, linter fixes
magicznyleszek Nov 26, 2024
14c9f06
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Nov 26, 2024
f88d64f
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Nov 28, 2024
67e6d0a
use better placeholders
magicznyleszek Nov 28, 2024
be201d6
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Dec 3, 2024
c11cfde
cleanup Organization types, add usePatchOrganization mutation hook an…
magicznyleszek Dec 3, 2024
e5b3059
Merge branch 'main' into kalvis/organizations-settings
magicznyleszek Dec 3, 2024
83b74a0
Merge branch 'main' into leszek/task-1219-org-settings-mutating
magicznyleszek Dec 3, 2024
9edc623
Merge branch 'leszek/task-1219-org-settings-mutating' into kalvis/org…
magicznyleszek Dec 3, 2024
984f205
use single source of truth for organization types
magicznyleszek Dec 4, 2024
147365a
some code review fixes
magicznyleszek Dec 4, 2024
8ffd210
finish hooking up, split out OrganizationSettingsForm
magicznyleszek Dec 4, 2024
b0faa7a
don't require passing orgUrl in usePatchOrganization
magicznyleszek Dec 5, 2024
931a2fa
add export
magicznyleszek Dec 5, 2024
7c92e19
add prepend url false
magicznyleszek Dec 5, 2024
cd52acf
deduplicate organization type const
magicznyleszek Dec 5, 2024
2b23d07
Merge branch 'leszek/task-1219-org-settings-mutating' into kalvis/org…
magicznyleszek Dec 5, 2024
a23c908
use simpler usePatchOrganization, merge files
magicznyleszek Dec 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions jsapp/js/account/accountFieldsEditor.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')},
Expand Down
170 changes: 168 additions & 2 deletions jsapp/js/account/organization/OrganizationSettingsRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,173 @@
import React from 'react';
// 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 OrganizationSettingsRoute() {
const orgQuery = useOrganizationQuery();
const [subscriptions] = useState(() => subscriptionStore);
const [isStripeEnabled, setIsStripeEnabled] = useState(false);
const patchOrganization = usePatchOrganization();

// All displayed fields
const [name, setName] = useState<string>('');
const [website, setWebsite] = useState<string>('');
const [orgType, setOrgType] = useState<OrganizationTypeName | null>(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();
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 <LoadingSpinner />;
}

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);
}

Comment on lines +83 to +89
Copy link
Contributor

@Akuukis Akuukis Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more of a linter thing, but don't use let and mutation if possible. Here one way to rewrite it

Suggested change
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 deletionMessage = isStripeEnabled
? 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)
: t('To delete this ##team/org##, please contact the server administrator.')
.replaceAll('##team/org##', mmoLabelLowercase);

or

  const deletionMessage = t(isStripeEnabled
    ? "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."
    : 'To delete this ##team/org##, please contact the server administrator.'
    )
      .replaceAll('##team/org##', mmoLabelLowercase)
      .replace('##plan name##', subscriptions.planName)

const currentTypeLabel = orgType === null ? '' : ORGANIZATION_TYPES[orgType]?.label;

return (
<div>Organization settings view to be implemented</div>
<form className={styles.orgSettingsRoot} onSubmit={handleSave}>
<header className={styles.orgSettingsHeader}>
<h2 className={styles.orgSettingsHeaderText}>
{t('##team/org## details').replace('##team/org##', mmoLabel)}
</h2>
</header>

<section className={styles.fieldsRow}>
{/*
On all instances, both owner and admins should be able to edit
organization name.
*/}
<TextBox
className={styles.field}
label={t('##team/org## name').replace('##team/org##', mmoLabel)}
value={name}
required
onChange={handleChangeName}
disabled={!isUserAdminOrOwner || orgQuery.isPending || patchOrganization.isPending}
errors={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 && (
<TextBox
className={styles.field}
type='url'
label={t('##team/org## website').replace('##team/org##', mmoLabel)}
value={website}
required
onChange={handleChangeWebsite}
disabled={!isUserAdminOrOwner || orgQuery.isPending || patchOrganization.isPending}
errors={website === ''}
/>
)}
</section>

{/*
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 && (
<section className={styles.fieldsRow}>
<KoboSelect
className={styles.fieldLong}
name='org-settings-type'
type='outline'
size='l'
isDisabled // always disabled
label={t('##team/org## type').replace('##team/org##', mmoLabel)}
options={[{
Akuukis marked this conversation as resolved.
Show resolved Hide resolved
value: 'orgType',
label: currentTypeLabel,
}]}
selectedOption='orgType'
onChange={() => null}
/>
</section>
)}

<section className={styles.fieldsRow}>
<Button
type='primary'
size='m'
label={t('Save')}
isDisabled={!isUserAdminOrOwner}
isPending={orgQuery.isPending || patchOrganization.isPending}
isSubmit
/>
</section>

<InlineMessage type='default' message={deletionMessage} />
</form>
);
}
86 changes: 65 additions & 21 deletions jsapp/js/account/organization/organizationQuery.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
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';
import {useSession} from 'jsapp/js/stores/useSession';

// Constants and types
import type {FailResponse} from 'js/dataInterface';
import {QueryKeys} from 'js/query/queryKeys';

// Comes from `kobo/apps/accounts/forms.py`
export 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;
Expand All @@ -25,6 +43,29 @@ export enum OrganizationUserRole {
owner = 'owner',
}

/**
* Mutation hook for updating organization. It ensures that all related queries
* refetch data (are invalidated).
*/
export function usePatchOrganization() {
const queryClient = useQueryClient();
const session = useSession();
const organizationUrl = session.currentLoggedAccount?.organization?.url;

return useMutation({
mutationFn: async (data: Partial<Organization>) => (
// 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<Organization>(organizationUrl!, data, {prependRootUrl: false})
),
onSettled: () => {
queryClient.invalidateQueries({queryKey: [QueryKeys.organization]});
},
});
}

/**
* Organization object is used globally.
* For convenience, errors are handled once at the top, see `RequireOrg`.
Expand All @@ -33,32 +74,34 @@ export enum OrganizationUserRole {
export const useOrganizationQuery = (options?: Omit<UndefinedInitialDataOptions<Organization, FailResponse, Organization, QueryKeys[]>, '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<Organization> => {
// 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<Organization>(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 &&
Expand All @@ -71,9 +114,10 @@ export const useOrganizationQuery = (options?: Omit<UndefinedInitialDataOptions<
enabled: isQueryEnabled && options?.enabled !== false,
});

// `organizationUrl` must exist, unless it's changed (e.g. user added/removed from organization).
// In such case, refetch organizationUrl to fetch the new `organizationUrl`.
// DEBT: don't throw toast within fetchGetUrl.
// `organizationUrl` must exist, unless it's changed (e.g. user added/removed
// from organization).
// In such case, refetch `organizationUrl` to fetch the new `organizationUrl`.
// DEBT: don't throw toast within `fetchGetUrl`.
// DEBT: don't retry the failing url 3-4 times before switching to the new url.
useEffect(() => {
if (query.error?.status === 404) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
@use 'scss/mixins';
@use 'scss/colors';
@use 'scss/breakpoints';
@use 'js/components/common/textBox.module';

$s-field-width: 285px;

.orgSettingsRoot {
padding: 20px;
overflow-y: auto;
height: 100%;
}

header.orgSettingsHeader {
@include mixins.centerRowFlex;
margin: 24px 0;

&:not(:first-child) {
margin-top: 44px;
}
}

h2.orgSettingsHeaderText {
color: colors.$kobo-storm;
text-transform: uppercase;
font-size: 18px;
font-weight: 700;
flex: 1;
margin: 0;
}

.fieldsRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 16px;

&:not(:first-child) {
margin-top: 16px;
}
}

.field {
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%;
}

@include breakpoints.breakpoint(mediumAndUp) {
.orgSettingsRoot {
padding: 50px;
}
}
Loading
Loading