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>