Просмотр исходного кода

Merge pull request #5295 from weseek/feat/87970-list-child-groups

feat: 87970 List child groups
Yuki Takei 4 лет назад
Родитель
Сommit
d257471e34

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -125,6 +125,7 @@
   "User_Management": "User Management",
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
   "UserGroup": "UserGroup",
+  "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Import Data": "Import Data",

+ 1 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -125,6 +125,7 @@
   "User_Management": "ユーザー管理",
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
   "UserGroup": "グループ",
+  "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Import Data": "データインポート",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -133,6 +133,7 @@
 	"User_Management": "用户管理",
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
   "UserGroup": "用户组",
+  "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Import Data": "导入数据",

+ 36 - 15
packages/app/src/client/admin.jsx

@@ -3,7 +3,10 @@ import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 
 
+import { SWRConfig } from 'swr';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import ErrorBoundary from '../components/ErrorBoudary';
 import ErrorBoundary from '../components/ErrorBoudary';
 
 
@@ -46,6 +49,8 @@ import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurit
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 
 
+import ContextExtractor from '~/client/services/ContextExtractor';
+
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 
 
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
@@ -109,22 +114,38 @@ Object.assign(componentMappings, {
   'admin-navigation': <AdminNavigation />,
   'admin-navigation': <AdminNavigation />,
 });
 });
 
 
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <ErrorBoundary>
+            <Provider inject={injectableContainers}>
+              {componentMappings[key]}
+            </Provider>
+          </ErrorBoundary>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
 
 
-Object.keys(componentMappings).forEach((key) => {
-  const elem = document.getElementById(key);
-  if (elem) {
-    ReactDOM.render(
-      <I18nextProvider i18n={i18n}>
-        <ErrorBoundary>
-          <Provider inject={injectableContainers}>
-            {componentMappings[key]}
-          </Provider>
-        </ErrorBoundary>
-      </I18nextProvider>,
-      elem,
-    );
-  }
-});
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}
 
 
 const adminSecuritySettingElem = document.getElementById('admin-security-setting');
 const adminSecuritySettingElem = document.getElementById('admin-security-setting');
 if (adminSecuritySettingElem != null) {
 if (adminSecuritySettingElem != null) {

+ 9 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -6,7 +6,7 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useIsAclEnabled,
 } from '../../stores/context';
 } from '../../stores/context';
 import {
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -30,6 +30,11 @@ const ContextExtractorOnce: FC = () => {
    */
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
 
+  /*
+   * AdminSettings from DOM
+   */
+  const adminSettings = JSON.parse(document.getElementById('growi-context-hydrate')?.textContent || jsonNull);
+
   /*
   /*
    * UserUISettings from DOM
    * UserUISettings from DOM
    */
    */
@@ -84,6 +89,9 @@ const ContextExtractorOnce: FC = () => {
   // App
   // App
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 
+  // AppSetting
+  useIsAclEnabled(adminSettings?.isAclEnabled);
+
   // UserUISettings
   // UserUISettings
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);

+ 0 - 3
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -7,7 +7,6 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
@@ -20,8 +19,6 @@ import Xss from '~/services/xss';
  * @extends {React.Component}
  * @extends {React.Component}
  */
  */
 type Props = {
 type Props = {
-  appContainer: AppContainer,
-
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
   deleteUserGroup?: IUserGroupHasId,
   deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,

+ 12 - 27
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -3,17 +3,12 @@ import { useTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 import { TFunctionResult } from 'i18next';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 
 
 type Props = {
 type Props = {
   userGroup?: IUserGroupHasId,
   userGroup?: IUserGroupHasId,
-  successedMessage: TFunctionResult;
-  failedMessage: TFunctionResult;
   submitButtonLabel: TFunctionResult;
   submitButtonLabel: TFunctionResult;
   onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
   onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
 };
 };
@@ -23,12 +18,14 @@ const UserGroupForm: FC<Props> = (props: Props) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { userGroup, submitButtonLabel, onSubmit } = props;
+
   /*
   /*
    * State
    * State
    */
    */
-  const [currentName, setName] = useState(props.userGroup != null ? props.userGroup.name : '');
-  const [currentDescription, setDescription] = useState(props.userGroup != null ? props.userGroup.description : '');
-  const [currentParent, setParent] = useState(props.userGroup != null ? props.userGroup.parent : '');
+  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
+  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
+  const [currentParent, setParent] = useState(userGroup != null ? userGroup.parent : '');
 
 
   /*
   /*
    * Function
    * Function
@@ -44,19 +41,12 @@ const UserGroupForm: FC<Props> = (props: Props) => {
   const onSubmitHandler = useCallback(async(e) => {
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
     e.preventDefault(); // no reload
 
 
-    if (props.onSubmit == null) {
+    if (onSubmit == null) {
       return;
       return;
     }
     }
 
 
-    try {
-      await props.onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
-
-      toastSuccess(props.successedMessage);
-    }
-    catch (err) {
-      toastError(props.failedMessage);
-    }
-  }, [currentName, currentDescription, currentParent, props.onSubmit, props.successedMessage, props.failedMessage]);
+    await onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
+  }, [currentName, currentDescription, currentParent, onSubmit]);
 
 
   return (
   return (
     <form onSubmit={onSubmitHandler}>
     <form onSubmit={onSubmitHandler}>
@@ -65,10 +55,10 @@ const UserGroupForm: FC<Props> = (props: Props) => {
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
         {/* TODO 85062: improve style */}
         {/* TODO 85062: improve style */}
         {
         {
-          props.userGroup?.createdAt != null && (
+          userGroup?.createdAt != null && (
             <div className="form-group row">
             <div className="form-group row">
               <p className="col-md-2 col-form-label">{t('Created')}</p>
               <p className="col-md-2 col-form-label">{t('Created')}</p>
-              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(props.userGroup.createdAt), 'yyyy-MM-dd')}</p>
+              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(userGroup.createdAt), 'yyyy-MM-dd')}</p>
             </div>
             </div>
           )
           )
         }
         }
@@ -102,7 +92,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
         <div className="form-group row">
         <div className="form-group row">
           <div className="offset-md-2 col-md-10">
           <div className="offset-md-2 col-md-10">
             <button type="submit" className="btn btn-primary">
             <button type="submit" className="btn btn-primary">
-              {props.submitButtonLabel}
+              {submitButtonLabel}
             </button>
             </button>
           </div>
           </div>
         </div>
         </div>
@@ -111,9 +101,4 @@ const UserGroupForm: FC<Props> = (props: Props) => {
   );
   );
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupFormWrapper = withUnstatedContainers<unknown, Props>(UserGroupForm, [AppContainer]);
-
-export default UserGroupFormWrapper;
+export default UserGroupForm;

+ 32 - 33
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -28,10 +28,15 @@ const UserGroupPage: FC<Props> = (props: Props) => {
   /*
   /*
    * Fetch
    * Fetch
    */
    */
-  const { data: userGroups, mutate: mutateUserGroups } = useSWRxUserGroupList();
-  const userGroupIds = userGroups?.map(group => group._id);
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(userGroupIds);
-  const { data: childUserGroups } = useSWRxChildUserGroupList(userGroupIds);
+  const { data: userGroupList, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const userGroups = userGroupList != null ? userGroupList : [];
+  const userGroupIds = userGroups.map(group => group._id);
+
+  const { data: userGroupRelationList } = useSWRxUserGroupRelationList(userGroupIds);
+  const userGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
+  const { data: childUserGroupsList } = useSWRxChildUserGroupList(userGroupIds);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
 
 
   /*
   /*
    * State
    * State
@@ -68,21 +73,20 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     setDeleteModalShown(false);
     setDeleteModalShown(false);
   }, []);
   }, []);
 
 
-  const addUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+  const createUserGroup = useCallback(async(userGroupData: IUserGroup) => {
     try {
     try {
       await apiv3Post('/user-groups', {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         name: userGroupData.name,
         description: userGroupData.description,
         description: userGroupData.description,
         parent: userGroupData.parent,
         parent: userGroupData.parent,
       });
       });
-
-      // sync
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       await mutateUserGroups();
       await mutateUserGroups();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [mutateUserGroups]);
+  }, [t, mutateUserGroups]);
 
 
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
     try {
     try {
@@ -102,11 +106,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     catch (err) {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
       toastError(new Error('Unable to delete the groups'));
     }
     }
-  }, [mutateUserGroups, mutateUserGroupRelations]);
-
-  if (userGroups == null || userGroupRelations == null || childUserGroups == null) {
-    return <></>;
-  }
+  }, [mutateUserGroups]);
 
 
   return (
   return (
     <Fragment>
     <Fragment>
@@ -118,10 +118,8 @@ const UserGroupPage: FC<Props> = (props: Props) => {
             </button>
             </button>
             <div id="createGroupForm" className="collapse">
             <div id="createGroupForm" className="collapse">
               <UserGroupForm
               <UserGroupForm
-                successedMessage={t('toaster.create_succeeded', { target: t('UserGroup') })}
-                failedMessage={t('toaster.create_failed', { target: t('UserGroup') })}
                 submitButtonLabel={t('Create')}
                 submitButtonLabel={t('Create')}
-                onSubmit={addUserGroup}
+                onSubmit={createUserGroup}
               />
               />
             </div>
             </div>
           </div>
           </div>
@@ -129,23 +127,24 @@ const UserGroupPage: FC<Props> = (props: Props) => {
           t('admin:user_group_management.deny_create_group')
           t('admin:user_group_management.deny_create_group')
         )
         )
       }
       }
-      <UserGroupTable
-        appContainer={props.appContainer}
-        userGroups={userGroups}
-        childUserGroups={childUserGroups}
-        isAclEnabled={isAclEnabled}
-        onDelete={showDeleteModal}
-        userGroupRelations={userGroupRelations}
-      />
-      <UserGroupDeleteModal
-        appContainer={props.appContainer}
-        userGroups={userGroups}
-        deleteUserGroup={selectedUserGroup}
-        onDelete={deleteUserGroupById}
-        isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
-        onHide={hideDeleteModal}
-      />
+      <>
+        <UserGroupTable
+          headerLabel={t('admin:user_group_management.group_list')}
+          userGroups={userGroups}
+          childUserGroups={childUserGroups}
+          isAclEnabled={isAclEnabled}
+          onDelete={showDeleteModal}
+          userGroupRelations={userGroupRelations}
+        />
+        <UserGroupDeleteModal
+          userGroups={userGroups}
+          deleteUserGroup={selectedUserGroup}
+          onDelete={deleteUserGroupById}
+          isShow={isDeleteModalShown}
+          onShow={showDeleteModal}
+          onHide={hideDeleteModal}
+        />
+      </>
     </Fragment>
     </Fragment>
   );
   );
 };
 };

+ 4 - 6
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,17 +2,15 @@ import React, {
   FC, useState, useCallback, useEffect,
   FC, useState, useCallback, useEffect,
 } from 'react';
 } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 
 
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
-import AppContainer from '~/client/services/AppContainer';
 import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import { CustomWindow } from '~/interfaces/global';
 
 
-
 type Props = {
 type Props = {
-  appContainer: AppContainer,
-
+  headerLabel?: TFunctionResult,
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
   userGroupRelations: IUserGroupRelation[],
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],
   childUserGroups: IUserGroupHasId[],
@@ -94,7 +92,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <>
     <>
-      <h2>{t('admin:user_group_management.group_list')}</h2>
+      <h2>{props.headerLabel}</h2>
 
 
       <table className="table table-bordered table-user-list">
       <table className="table table-bordered table-user-list">
         <thead>
         <thead>
@@ -102,7 +100,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
             <th>{t('Name')}</th>
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
             <th>{t('User')}</th>
-            <th>{t('Child groups')}</th>
+            <th>{t('ChildUserGroup')}</th>
             <th style={{ width: 100 }}>{t('Created')}</th>
             <th style={{ width: 100 }}>{t('Created')}</th>
             <th style={{ width: 70 }}></th>
             <th style={{ width: 70 }}></th>
           </tr>
           </tr>

+ 84 - 32
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -4,50 +4,59 @@ import React, {
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupTable from '../UserGroup/UserGroupTable';
+import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupPageList from './UserGroupPageList';
 import UserGroupPageList from './UserGroupPageList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
+
 import {
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import {
 import {
-  IUserGroup, IUserGroupHasId, IUserGroupRelation,
+  IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
 } from '~/interfaces/user';
-import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
-
+import {
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups,
+} from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores/context';
 
 
 const UserGroupDetailPage: FC = () => {
 const UserGroupDetailPage: FC = () => {
-  const rootElem = document.getElementById('admin-user-group-detail');
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 
 
   /*
   /*
    * State (from AdminUserGroupDetailContainer)
    * State (from AdminUserGroupDetailContainer)
    */
    */
-  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
-
-  // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
-  const [childUserGroups, setChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-  const [grandChildUserGroups, setGrandChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-
-  const [childUserGroupRelations, setChildUserGroupRelations] = useState<IUserGroupRelation[]>([]); // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list
+  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
   const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
   const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
   const [searchType, setSearchType] = useState<string>('partial');
   const [searchType, setSearchType] = useState<string>('partial');
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
 
   /*
   /*
    * Fetch
    * Fetch
    */
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
   const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([userGroup._id], true);
+  const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
+  const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
+  const childUserGroupIds = childUserGroups.map(group => group._id);
+
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
+
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
   const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
 
+  const { data: isAclEnabled } = useIsAclEnabled();
+
   /*
   /*
    * Function
    * Function
    */
    */
@@ -66,13 +75,16 @@ const UserGroupDetailPage: FC = () => {
   }, []);
   }, []);
 
 
   const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
   const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
-    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
-    const { userGroup: newUserGroup } = res.data;
-
-    setUserGroup(newUserGroup);
-
-    return newUserGroup;
-  }, [userGroup]);
+    try {
+      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+      const { userGroup: newUserGroup } = res.data;
+      setUserGroup(newUserGroup);
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, userGroup._id, setUserGroup]);
 
 
   const openUserGroupUserModal = useCallback(() => {
   const openUserGroupUserModal = useCallback(() => {
     setUserGroupUserModalOpen(true);
     setUserGroupUserModalOpen(true);
@@ -112,9 +124,10 @@ const UserGroupDetailPage: FC = () => {
         name: selectedUserGroup.name,
         name: selectedUserGroup.name,
         description: selectedUserGroup.description,
         description: selectedUserGroup.description,
         parentId: userGroup._id,
         parentId: userGroup._id,
-        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+        forceUpdateParents: false,
       });
       });
       mutateSelectableUserGroups();
       mutateSelectableUserGroups();
+      mutateChildUserGroups();
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     }
     catch (err) {
     catch (err) {
@@ -127,6 +140,36 @@ const UserGroupDetailPage: FC = () => {
     console.log('button clicked!');
     console.log('button clicked!');
   };
   };
 
 
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+    setSelectedUserGroup(group);
+    setDeleteModalShown(true);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, [setSelectedUserGroup, setDeleteModalShown]);
+
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateChildUserGroups();
+
+      setSelectedUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+
   /*
   /*
    * Dependencies
    * Dependencies
    */
    */
@@ -144,8 +187,6 @@ const UserGroupDetailPage: FC = () => {
       <div className="mt-4 form-box">
       <div className="mt-4 form-box">
         <UserGroupForm
         <UserGroupForm
           userGroup={userGroup}
           userGroup={userGroup}
-          successedMessage={t('toaster.update_successed', { target: t('UserGroup') })}
-          failedMessage={t('toaster.update_failed', { target: t('UserGroup') })}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={updateUserGroup}
           onSubmit={updateUserGroup}
         />
         />
@@ -161,19 +202,30 @@ const UserGroupDetailPage: FC = () => {
         onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
         onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
       />
       />
 
 
+      <>
+        <UserGroupTable
+          userGroups={childUserGroups}
+          childUserGroups={grandChildUserGroups}
+          isAclEnabled={isAclEnabled}
+          onDelete={showDeleteModal}
+          userGroupRelations={childUserGroupRelations}
+        />
+        <UserGroupDeleteModal
+          userGroups={childUserGroups}
+          deleteUserGroup={selectedUserGroup}
+          onDelete={deleteChildUserGroupById}
+          isShow={isDeleteModalShown}
+          onShow={showDeleteModal}
+          onHide={hideDeleteModal}
+        />
+      </>
+
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
       <div className="page-list">
         <UserGroupPageList />
         <UserGroupPageList />
       </div>
       </div>
     </div>
     </div>
   );
   );
-
 };
 };
 
 
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
-
-export default UserGroupDetailPageWrapper;
+export default UserGroupDetailPage;

+ 1 - 0
packages/app/src/interfaces/user-group-response.ts

@@ -7,6 +7,7 @@ export type UserGroupListResult = {
 
 
 export type ChildUserGroupListResult = {
 export type ChildUserGroupListResult = {
   childUserGroups: IUserGroupHasId[],
   childUserGroups: IUserGroupHasId[],
+  grandChildUserGroups: IUserGroupHasId[],
 };
 };
 
 
 export type UserGroupRelationListResult = {
 export type UserGroupRelationListResult = {

+ 0 - 2
packages/app/src/server/routes/admin.js

@@ -239,12 +239,10 @@ module.exports = function(crowi, app) {
   actions.userGroup = {};
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {
   actions.userGroup.index = function(req, res) {
     const page = parseInt(req.query.page) || 1;
     const page = parseInt(req.query.page) || 1;
-    const isAclEnabled = aclService.isAclEnabled();
     const renderVar = {
     const renderVar = {
       userGroups: [],
       userGroups: [],
       userGroupRelations: new Map(),
       userGroupRelations: new Map(),
       pager: null,
       pager: null,
-      isAclEnabled,
     };
     };
 
 
     UserGroup.findUserGroupsWithPagination({ page })
     UserGroup.findUserGroupsWithPagination({ page })

+ 2 - 2
packages/app/src/server/service/user-group.ts

@@ -2,7 +2,7 @@ import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
 import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
-import { isIncludesObjectId } from '~/server/util/compare-objectId';
+import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
 
 
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 
@@ -72,7 +72,7 @@ class UserGroupService {
     const [targetGroupUsers, parentGroupUsers] = await Promise.all(
     const [targetGroupUsers, parentGroupUsers] = await Promise.all(
       [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
       [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent._id)],
     );
     );
-    const usersBelongsToTargetButNotParent = targetGroupUsers.filter(user => !parentGroupUsers.includes(user));
+    const usersBelongsToTargetButNotParent = excludeTestIdsFromTargetIds(targetGroupUsers, parentGroupUsers);
 
 
     // save if no users exist in both target and parent groups
     // save if no users exist in both target and parent groups
     if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {
     if (targetGroupUsers.length === 0 && parentGroupUsers.length === 0) {

+ 4 - 0
packages/app/src/stores/context.tsx

@@ -131,6 +131,10 @@ export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetP
   return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
   return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
 };
 };
 
 
+export const useIsAclEnabled = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('isAclEnabled', initialData);
+};
+
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

+ 4 - 7
packages/app/src/stores/user-group.tsx

@@ -21,17 +21,14 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
 };
 };
 
 
 export const useSWRxChildUserGroupList = (
 export const useSWRxChildUserGroupList = (
-    parentIds?: string[], includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
-): SWRResponse<IUserGroupHasId[], Error> => {
+    parentIds?: string[], includeGrandChildren?: boolean,
+): SWRResponse<ChildUserGroupListResult, Error> => {
   const shouldFetch = parentIds != null && parentIds.length > 0;
   const shouldFetch = parentIds != null && parentIds.length > 0;
-  return useSWRImmutable<IUserGroupHasId[], Error>(
+  return useSWRImmutable<ChildUserGroupListResult, Error>(
     shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     (endpoint, parentIds, includeGrandChildren) => apiv3Get<ChildUserGroupListResult>(
     (endpoint, parentIds, includeGrandChildren) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
       endpoint, { parentIds, includeGrandChildren },
-    ).then(result => result.data.childUserGroups),
-    {
-      fallbackData: initialData,
-    },
+    ).then((result => result.data)),
   );
   );
 };
 };