From d2bfe186888a8633d24646e20bccfd967acb4928 Mon Sep 17 00:00:00 2001 From: Charles Zhao <charleszhao@silverhand.io> Date: Fri, 17 Nov 2023 11:27:28 +0800 Subject: [PATCH] refactor(console): organization permissions and roles are split into two isolated steps (#4901) --- .../Guide/Introduction/index.tsx | 6 +- .../Guide/OrganizationInfo/index.tsx | 6 +- .../Guide/OrganizationPermissions/index.tsx | 143 ++++++++++++++++++ .../index.tsx | 122 +++------------ .../src/pages/Organizations/Guide/const.ts | 5 +- .../src/pages/Organizations/Guide/index.tsx | 6 +- 6 files changed, 177 insertions(+), 111 deletions(-) create mode 100644 packages/console/src/pages/Organizations/Guide/OrganizationPermissions/index.tsx rename packages/console/src/pages/Organizations/Guide/{PermissionsAndRoles => OrganizationRoles}/index.tsx (55%) diff --git a/packages/console/src/pages/Organizations/Guide/Introduction/index.tsx b/packages/console/src/pages/Organizations/Guide/Introduction/index.tsx index 596371c46..b685588f2 100644 --- a/packages/console/src/pages/Organizations/Guide/Introduction/index.tsx +++ b/packages/console/src/pages/Organizations/Guide/Introduction/index.tsx @@ -11,7 +11,7 @@ import OverlayScrollbar from '@/ds-components/OverlayScrollbar'; import useTenantPathname from '@/hooks/use-tenant-pathname'; import useTheme from '@/hooks/use-theme'; -import { steps } from '../const'; +import { steps, totalStepCount } from '../const'; import * as parentStyles from '../index.module.scss'; import FlexBox from './components/FlexBox'; @@ -114,12 +114,12 @@ function Introduction({ isReadonly }: Props) { </div> </OverlayScrollbar> {!isReadonly && ( - <ActionBar step={1} totalSteps={3}> + <ActionBar step={1} totalSteps={totalStepCount}> <Button title="general.next" type="primary" onClick={() => { - navigate(`../${steps.permissionsAndRoles}`); + navigate(`../${steps.permissions}`); }} /> </ActionBar> diff --git a/packages/console/src/pages/Organizations/Guide/OrganizationInfo/index.tsx b/packages/console/src/pages/Organizations/Guide/OrganizationInfo/index.tsx index c61232e25..8bd59dd24 100644 --- a/packages/console/src/pages/Organizations/Guide/OrganizationInfo/index.tsx +++ b/packages/console/src/pages/Organizations/Guide/OrganizationInfo/index.tsx @@ -18,7 +18,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname'; import useTheme from '@/hooks/use-theme'; import { trySubmitSafe } from '@/utils/form'; -import { steps } from '../const'; +import { steps, totalStepCount } from '../const'; import * as styles from '../index.module.scss'; type OrganizationForm = { @@ -52,7 +52,7 @@ function OrganizationInfo() { const onNavigateBack = () => { reset(); - navigate(`../${steps.permissionsAndRoles}`); + navigate(`../${steps.roles}`); }; return ( @@ -108,7 +108,7 @@ function OrganizationInfo() { </Card> </div> </OverlayScrollbar> - <ActionBar step={3} totalSteps={3}> + <ActionBar step={4} totalSteps={totalStepCount}> <Button isLoading={isSubmitting} title="general.done" type="primary" onClick={onSubmit} /> <Button title="general.back" onClick={onNavigateBack} /> </ActionBar> diff --git a/packages/console/src/pages/Organizations/Guide/OrganizationPermissions/index.tsx b/packages/console/src/pages/Organizations/Guide/OrganizationPermissions/index.tsx new file mode 100644 index 000000000..45ff6019a --- /dev/null +++ b/packages/console/src/pages/Organizations/Guide/OrganizationPermissions/index.tsx @@ -0,0 +1,143 @@ +import { Theme, type OrganizationScope } from '@logto/schemas'; +import classNames from 'classnames'; +import { useEffect } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import useSWR from 'swr'; + +import PermissionFeatureDark from '@/assets/icons/permission-feature-dark.svg'; +import PermissionFeature from '@/assets/icons/permission-feature.svg'; +import ActionBar from '@/components/ActionBar'; +import Button from '@/ds-components/Button'; +import Card from '@/ds-components/Card'; +import FormField from '@/ds-components/FormField'; +import OverlayScrollbar from '@/ds-components/OverlayScrollbar'; +import TextInput from '@/ds-components/TextInput'; +import useApi, { type RequestError } from '@/hooks/use-api'; +import useTenantPathname from '@/hooks/use-tenant-pathname'; +import useTheme from '@/hooks/use-theme'; +import { trySubmitSafe } from '@/utils/form'; + +import { organizationScopesPath } from '../../PermissionModal'; +import DynamicFormFields from '../DynamicFormFields'; +import { steps, totalStepCount } from '../const'; +import * as styles from '../index.module.scss'; + +type Form = { + /* Organization permissions, a.k.a organization scopes */ + permissions: Array<Omit<OrganizationScope, 'id' | 'tenantId'>>; +}; + +const defaultValue = { name: '', description: '' }; + +function OrganizationPermissions() { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations' }); + const theme = useTheme(); + const PermissionIcon = theme === Theme.Light ? PermissionFeature : PermissionFeatureDark; + const { navigate } = useTenantPathname(); + const api = useApi(); + const { data, error } = useSWR<OrganizationScope[], RequestError>('api/organization-scopes'); + + const { + control, + register, + handleSubmit, + reset, + formState: { errors, isSubmitting, isDirty }, + } = useForm<Form>({ + defaultValues: { + permissions: [defaultValue], + }, + }); + + useEffect(() => { + if (data?.length) { + reset({ + permissions: data.map(({ name, description }) => ({ name, description })), + }); + } + }, [data, reset]); + + const permissionFields = useFieldArray({ control, name: 'permissions' }); + + const onSubmit = handleSubmit( + trySubmitSafe(async ({ permissions }) => { + // If the form is pristine then skip the submit and go directly to the next step + if (!isDirty) { + navigate(`../${steps.roles}`); + return; + } + + // If there's pre-saved permissions, remove them first + if (data?.length) { + await Promise.all( + data.map(async ({ id }) => api.delete(`${organizationScopesPath}/${id}`)) + ); + } + // Create new permissions + if (permissions.length > 0) { + await Promise.all( + permissions + .filter(({ name }) => name) + .map(async ({ name, description }) => { + await api.post(organizationScopesPath, { json: { name, description } }); + }) + ); + } + + navigate(`../${steps.roles}`); + }) + ); + + const onNavigateBack = () => { + reset(); + navigate(`../${steps.introduction}`); + }; + + return ( + <> + <OverlayScrollbar className={styles.stepContainer}> + <div className={classNames(styles.content)}> + <Card className={styles.card}> + <PermissionIcon className={styles.icon} /> + <div className={styles.title}>{t('guide.step_1')}</div> + <form> + <DynamicFormFields + isLoading={!data && !error} + title="organizations.guide.organization_permissions" + fields={permissionFields.fields} + render={(index) => ( + <div className={styles.fieldGroup}> + <FormField title="organizations.guide.permission_name"> + <TextInput + {...register(`permissions.${index}.name`)} + error={Boolean(errors.permissions?.[index]?.name)} + placeholder="read:appointment" + /> + </FormField> + <FormField title="general.description"> + <TextInput + {...register(`permissions.${index}.description`)} + placeholder={t('create_permission_placeholder')} + /> + </FormField> + </div> + )} + onAdd={() => { + permissionFields.append(defaultValue); + }} + onRemove={permissionFields.remove} + /> + </form> + </Card> + </div> + </OverlayScrollbar> + <ActionBar step={2} totalSteps={totalStepCount}> + <Button isLoading={isSubmitting} title="general.next" type="primary" onClick={onSubmit} /> + <Button title="general.back" onClick={onNavigateBack} /> + </ActionBar> + </> + ); +} + +export default OrganizationPermissions; diff --git a/packages/console/src/pages/Organizations/Guide/PermissionsAndRoles/index.tsx b/packages/console/src/pages/Organizations/Guide/OrganizationRoles/index.tsx similarity index 55% rename from packages/console/src/pages/Organizations/Guide/PermissionsAndRoles/index.tsx rename to packages/console/src/pages/Organizations/Guide/OrganizationRoles/index.tsx index eca4df94f..8e34312ed 100644 --- a/packages/console/src/pages/Organizations/Guide/PermissionsAndRoles/index.tsx +++ b/packages/console/src/pages/Organizations/Guide/OrganizationRoles/index.tsx @@ -1,17 +1,10 @@ -import { - type OrganizationRoleWithScopes, - Theme, - type OrganizationRole, - type OrganizationScope, -} from '@logto/schemas'; +import { type OrganizationRoleWithScopes, Theme, type OrganizationRole } from '@logto/schemas'; import classNames from 'classnames'; import { useEffect, useState } from 'react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; -import PermissionFeatureDark from '@/assets/icons/permission-feature-dark.svg'; -import PermissionFeature from '@/assets/icons/permission-feature.svg'; import RbacFeatureDark from '@/assets/icons/rbac-feature-dark.svg'; import RbacFeature from '@/assets/icons/rbac-feature.svg'; import ActionBar from '@/components/ActionBar'; @@ -27,37 +20,24 @@ import useTenantPathname from '@/hooks/use-tenant-pathname'; import useTheme from '@/hooks/use-theme'; import { trySubmitSafe } from '@/utils/form'; -import { organizationScopesPath } from '../../PermissionModal'; import { organizationRolePath } from '../../RoleModal'; import DynamicFormFields from '../DynamicFormFields'; -import { steps } from '../const'; +import { steps, totalStepCount } from '../const'; import * as styles from '../index.module.scss'; type Form = { - /* Organization permissions, a.k.a organization scopes */ - permissions: Array<Omit<OrganizationScope, 'id' | 'tenantId'>>; roles: Array<Omit<OrganizationRole, 'tenantId' | 'id'> & { scopes: Array<Option<string>> }>; }; -const icons = { - [Theme.Light]: { PermissionIcon: PermissionFeature, RbacIcon: RbacFeature }, - [Theme.Dark]: { PermissionIcon: PermissionFeatureDark, RbacIcon: RbacFeatureDark }, -}; +const defaultValue = { name: '', description: '', scopes: [] }; -const defaultPermission = { name: '', description: '' }; -const defaultRoles = { name: '', description: '', scopes: [] }; - -function PermissionsAndRoles() { +function OrganizationRoles() { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.organizations' }); const theme = useTheme(); - const { PermissionIcon, RbacIcon } = icons[theme]; + const RbacIcon = theme === Theme.Light ? RbacFeature : RbacFeatureDark; const { navigate } = useTenantPathname(); const api = useApi(); - const { data: permissionsData, error: permissionsError } = useSWR< - OrganizationScope[], - RequestError - >('api/organization-scopes'); - const { data: rolesData, error: rolesError } = useSWR<OrganizationRoleWithScopes[], RequestError>( + const { data, error } = useSWR<OrganizationRoleWithScopes[], RequestError>( 'api/organization-roles' ); const [keyword, setKeyword] = useState(''); @@ -70,65 +50,35 @@ function PermissionsAndRoles() { formState: { errors, isSubmitting, isDirty }, } = useForm<Form>({ defaultValues: { - permissions: [defaultPermission], - roles: [defaultRoles], + roles: [defaultValue], }, }); useEffect(() => { - if (permissionsData?.length) { + if (data?.length) { reset({ - permissions: permissionsData.map(({ name, description }) => ({ name, description })), - }); - } - }, [permissionsData, reset]); - - useEffect(() => { - if (rolesData?.length) { - reset({ - roles: rolesData.map(({ name, description, scopes }) => ({ + roles: data.map(({ name, description, scopes }) => ({ name, description, scopes: scopes.map(({ id, name }) => ({ value: id, title: name })), })), }); } - }, [rolesData, reset]); + }, [data, reset]); - const permissionFields = useFieldArray({ control, name: 'permissions' }); const roleFields = useFieldArray({ control, name: 'roles' }); const onSubmit = handleSubmit( - trySubmitSafe(async ({ permissions, roles }) => { - // If user has pre-saved data but with no changes made this time, - // skip form submit and go directly to the next step. - if ((Boolean(permissionsData?.length) || Boolean(rolesData?.length)) && !isDirty) { + trySubmitSafe(async ({ roles }) => { + // If the form is pristine then skip the submit and go directly to the next step + if (!isDirty) { navigate(`../${steps.organizationInfo}`); return; } - // If there's pre-saved permissions, remove them first - if (permissionsData?.length) { - await Promise.all( - permissionsData.map(async ({ id }) => api.delete(`${organizationScopesPath}/${id}`)) - ); - } - // Create new permissions - if (permissions.length > 0) { - await Promise.all( - permissions - .filter(({ name }) => name) - .map(async ({ name, description }) => { - await api.post(organizationScopesPath, { json: { name, description } }); - }) - ); - } - // Remove pre-saved roles - if (rolesData?.length) { - await Promise.all( - rolesData.map(async ({ id }) => api.delete(`${organizationRolePath}/${id}`)) - ); + if (data?.length) { + await Promise.all(data.map(async ({ id }) => api.delete(`${organizationRolePath}/${id}`))); } // Create new roles if (roles.length > 0) { @@ -156,45 +106,13 @@ function PermissionsAndRoles() { const onNavigateBack = () => { reset(); setKeyword(''); - navigate(`../${steps.introduction}`); + navigate(`../${steps.permissions}`); }; return ( <> <OverlayScrollbar className={styles.stepContainer}> <div className={classNames(styles.content)}> - <Card className={styles.card}> - <PermissionIcon className={styles.icon} /> - <div className={styles.title}>{t('guide.step_1')}</div> - <form> - <DynamicFormFields - isLoading={!permissionsData && !permissionsError} - title="organizations.guide.organization_permissions" - fields={permissionFields.fields} - render={(index) => ( - <div className={styles.fieldGroup}> - <FormField title="organizations.guide.permission_name"> - <TextInput - {...register(`permissions.${index}.name`)} - error={Boolean(errors.permissions?.[index]?.name)} - placeholder="read:appointment" - /> - </FormField> - <FormField title="general.description"> - <TextInput - {...register(`permissions.${index}.description`)} - placeholder={t('create_permission_placeholder')} - /> - </FormField> - </div> - )} - onAdd={() => { - permissionFields.append(defaultPermission); - }} - onRemove={permissionFields.remove} - /> - </form> - </Card> <Card className={styles.card}> <RbacIcon className={styles.icon} /> <div className={styles.section}> @@ -203,7 +121,7 @@ function PermissionsAndRoles() { </div> <form> <DynamicFormFields - isLoading={!rolesData && !rolesError} + isLoading={!data && !error} title="organizations.guide.organization_roles" fields={roleFields.fields} render={(index) => ( @@ -238,7 +156,7 @@ function PermissionsAndRoles() { </div> )} onAdd={() => { - roleFields.append(defaultRoles); + roleFields.append(defaultValue); }} onRemove={roleFields.remove} /> @@ -246,7 +164,7 @@ function PermissionsAndRoles() { </Card> </div> </OverlayScrollbar> - <ActionBar step={2} totalSteps={3}> + <ActionBar step={3} totalSteps={totalStepCount}> <Button isLoading={isSubmitting} title="general.next" type="primary" onClick={onSubmit} /> <Button title="general.back" onClick={onNavigateBack} /> </ActionBar> @@ -254,4 +172,4 @@ function PermissionsAndRoles() { ); } -export default PermissionsAndRoles; +export default OrganizationRoles; diff --git a/packages/console/src/pages/Organizations/Guide/const.ts b/packages/console/src/pages/Organizations/Guide/const.ts index e3ca8aff7..cb7fbd56f 100644 --- a/packages/console/src/pages/Organizations/Guide/const.ts +++ b/packages/console/src/pages/Organizations/Guide/const.ts @@ -1,5 +1,8 @@ export const steps = Object.freeze({ introduction: 'introduction', - permissionsAndRoles: 'permissions-and-roles', + permissions: 'permissions', + roles: 'roles', organizationInfo: 'organization-info', }); + +export const totalStepCount = Object.keys(steps).length; diff --git a/packages/console/src/pages/Organizations/Guide/index.tsx b/packages/console/src/pages/Organizations/Guide/index.tsx index b28fb64e4..ba92548a3 100644 --- a/packages/console/src/pages/Organizations/Guide/index.tsx +++ b/packages/console/src/pages/Organizations/Guide/index.tsx @@ -8,7 +8,8 @@ import * as modalStyles from '@/scss/modal.module.scss'; import Introduction from './Introduction'; import OrganizationInfo from './OrganizationInfo'; -import PermissionsAndRoles from './PermissionsAndRoles'; +import OrganizationPermissions from './OrganizationPermissions'; +import OrganizationRoles from './OrganizationRoles'; import { steps } from './const'; import * as styles from './index.module.scss'; @@ -30,7 +31,8 @@ function Guide() { <Routes> <Route index element={<Navigate replace to={steps.introduction} />} /> <Route path={steps.introduction} element={<Introduction />} /> - <Route path={steps.permissionsAndRoles} element={<PermissionsAndRoles />} /> + <Route path={steps.permissions} element={<OrganizationPermissions />} /> + <Route path={steps.roles} element={<OrganizationRoles />} /> <Route path={steps.organizationInfo} element={<OrganizationInfo />} /> </Routes> </div>