Преглед изворни кода

Merge pull request #5273 from weseek/feat/user-group-v5

feat: User group v5 WIP
Haku Mizuki пре 4 година
родитељ
комит
df4a1005a8

+ 2 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -454,6 +454,7 @@
   },
   "user_group_management": {
     "create_group": "Create new group",
+    "add_child_group": "Add child group",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
@@ -466,6 +467,7 @@
       "backward_match": "Backward match"
     },
     "group_list": "Group list",
+    "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "basic_info": "Basic info",
     "user_list": "User list",

+ 2 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -453,6 +453,7 @@
   },
   "user_group_management": {
     "create_group": "新規グループの作成",
+    "add_child_group": "子グループの追加",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
@@ -465,6 +466,7 @@
       "backward_match": "後方一致"
     },
     "group_list": "グループ一覧",
+    "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",

+ 2 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -463,6 +463,7 @@
   },
   "user_group_management": {
     "create_group": "创建新组",
+    "add_child_group": "添加一个子组",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
@@ -475,6 +476,7 @@
       "backward_match": "向后匹配"
     },
     "group_list": "组列表",
+    "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "basic_info": "基本信息",
     "user_list": "用户列表",

+ 70 - 0
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -0,0 +1,70 @@
+import React, { FC, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+
+type Props = {
+  selectableUserGroups?: IUserGroupHasId[]
+  onClickAddExistingUserGroupButtonHandler?(userGroup: IUserGroupHasId | null): void
+  onClickCreateUserGroupButtonHandler?(): void
+};
+
+const UserGroupDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const { selectableUserGroups, onClickAddExistingUserGroupButtonHandler, onClickCreateUserGroupButtonHandler } = props;
+
+  const onClickAddExistingUserGroupButton = useCallback((userGroup: IUserGroupHasId) => {
+    if (onClickAddExistingUserGroupButtonHandler != null) {
+      onClickAddExistingUserGroupButtonHandler(userGroup);
+    }
+  }, [onClickAddExistingUserGroupButtonHandler]);
+
+  const onClickCreateUserGroupButton = useCallback(() => {
+    if (onClickCreateUserGroupButtonHandler != null) {
+      onClickCreateUserGroupButtonHandler();
+    }
+  }, [onClickCreateUserGroupButtonHandler]);
+
+  return (
+    <>
+      <div className="dropdown">
+        <button className="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+          {t('admin:user_group_management.add_child_group')}
+        </button>
+
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+
+          {
+            (selectableUserGroups != null && selectableUserGroups.length > 0) && (
+              <>
+                {
+                  selectableUserGroups.map(userGroup => (
+                    <button
+                      key={userGroup._id}
+                      type="button"
+                      className="dropdown-item"
+                      onClick={() => onClickAddExistingUserGroupButton(userGroup)}
+                    >
+                      {userGroup.name}
+                    </button>
+                  ))
+                }
+                <div className="dropdown-divider"></div>
+              </>
+            )
+          }
+
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => onClickCreateUserGroupButton()}
+          >{t('admin:user_group_management.create_group')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default UserGroupDropdown;

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

@@ -44,7 +44,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
    */
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
-      await mutateUserGroups(undefined, true);
+      await mutateUserGroups();
     }
     catch (err) {
       toastError(err);
@@ -77,7 +77,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       });
 
       // sync
-      await mutateUserGroups(undefined, true);
+      await mutateUserGroups();
     }
     catch (err) {
       toastError(err);
@@ -92,7 +92,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
       });
 
       // sync
-      await mutateUserGroups(undefined, true);
+      await mutateUserGroups();
 
       setSelectedUserGroup(undefined);
       setDeleteModalShown(false);

+ 45 - 34
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,9 +1,10 @@
 import React, {
-  FC, useState, useCallback, useEffect,
+  FC, useState, useCallback,
 } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupPageList from './UserGroupPageList';
@@ -12,11 +13,13 @@ import AppContainer from '~/client/services/AppContainer';
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IPageHasId } from '~/interfaces/page';
 import {
-  IUserGroup, IUserGroupHasId, IUserGroupRelation, IUserGroupRelationHasId,
+  IUserGroup, IUserGroupHasId, IUserGroupRelation,
 } from '~/interfaces/user';
+import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
+
 
 const UserGroupDetailPage: FC = () => {
   const rootElem = document.getElementById('admin-user-group-detail');
@@ -26,7 +29,6 @@ const UserGroupDetailPage: FC = () => {
    * State (from AdminUserGroupDetailContainer)
    */
   const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
-  const [userGroupRelations, setUserGroupRelations] = useState<IUserGroupRelationHasId[]>([]); // For user list
 
   // 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
@@ -40,26 +42,15 @@ const UserGroupDetailPage: FC = () => {
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
 
   /*
-   * Function
+   * Fetch
    */
-  const sync = useCallback(async() => {
-    try {
-      const [
-        userGroupRelations,
-        relatedPages,
-      ] = await Promise.all([
-        apiv3Get(`/user-groups/${userGroup._id}/user-group-relations`).then(res => res.data.userGroupRelations),
-        apiv3Get(`/user-groups/${userGroup._id}/pages`).then(res => res.data.pages),
-      ]);
-
-      setUserGroupRelations(userGroupRelations);
-      setRelatedPages(relatedPages);
-    }
-    catch (err) {
-      toastError(new Error('Failed to fetch data'));
-    }
-  }, [userGroup]);
+  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
 
+  /*
+   * Function
+   */
   // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
@@ -107,22 +98,34 @@ const UserGroupDetailPage: FC = () => {
   // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
     await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
-
-    await sync();
-  }, [userGroup, sync]);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
 
   const removeUserByUsername = useCallback(async(username: string) => {
-    const res = await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
 
-    setUserGroupRelations(prev => prev.filter(u => u._id !== res.data.userGroupRelation._id)); // TODO 85062: use swr to sync
-  }, [userGroup]);
+  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
+        name: selectedUserGroup.name,
+        description: selectedUserGroup.description,
+        parentId: userGroup._id,
+        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+      });
+      mutateSelectableUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
 
-  /*
-   * componentDidMount
-   */
-  useEffect(() => {
-    sync();
-  }, []);
+  // TODO 87614: UserGroup New creation form can be displayed in modal
+  const onClickCreateChildGroupButtonHandler = () => {
+    console.log('button clicked!');
+  };
 
   /*
    * Dependencies
@@ -150,6 +153,14 @@ const UserGroupDetailPage: FC = () => {
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <UserGroupUserTable />
       <UserGroupUserModal />
+
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
+      <UserGroupDropdown
+        selectableUserGroups={selectableUserGroups}
+        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
+        onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
+      />
+
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
         <UserGroupPageList />

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

@@ -1,4 +1,5 @@
 import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
+import { IPageHasId } from './page';
 
 export type UserGroupListResult = {
   userGroups: IUserGroupHasId[],
@@ -11,3 +12,11 @@ export type ChildUserGroupListResult = {
 export type UserGroupRelationListResult = {
   userGroupRelations: IUserGroupRelationHasId[],
 };
+
+export type UserGroupPagesResult = {
+  pages: IPageHasId[],
+}
+
+export type SelectableUserGroupsResult = {
+  selectableUserGroups: IUserGroupHasId[],
+}

+ 94 - 41
packages/app/src/server/routes/apiv3/user-group.js

@@ -18,8 +18,6 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
-const validator = {};
-
 const { ObjectId } = mongoose.Types;
 
 
@@ -41,10 +39,48 @@ module.exports = (crowi) => {
     Page,
   } = crowi.models;
 
-  validator.listChildren = [
-    query('parentIds', 'parentIds must be an array').optional().isArray(),
-    query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
-  ];
+  const validator = {
+    create: [
+      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('description', 'Description must be a string').optional().isString(),
+      body('parentId', 'ParentId must be a string').optional().isString(),
+    ],
+    update: [
+      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('description', 'Group description must be a string').optional().isString(),
+      body('parentId', 'parentId must be a string').optional().isString(),
+      body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
+    ],
+    delete: [
+      param('id').trim().exists({ checkFalsy: true }),
+      query('actionName').trim().exists({ checkFalsy: true }),
+      query('transferToUserGroupId').trim(),
+    ],
+    listChildren: [
+      query('parentIds', 'parentIds must be an array').optional().isArray(),
+      query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
+    ],
+    selectableGroups: [
+      query('groupId', 'groupId must be a string').optional().isString(),
+    ],
+    users: {
+      post: [
+        param('id').trim().exists({ checkFalsy: true }),
+        param('username').trim().exists({ checkFalsy: true }),
+      ],
+      delete: [
+        param('id').trim().exists({ checkFalsy: true }),
+        param('username').trim().exists({ checkFalsy: true }),
+      ],
+    },
+    pages: {
+      get: [
+        param('id').trim().exists({ checkFalsy: true }),
+        sanitizeQuery('limit').customSanitizer(toPagingLimit),
+        sanitizeQuery('offset').customSanitizer(toPagingOffset),
+      ],
+    },
+  };
 
   /**
    * @swagger
@@ -108,11 +144,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.create = [
-    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
-    body('description', 'Description must be a string').optional().isString(),
-    body('parentId', 'ParentId must be a string').optional().isString(),
-  ];
 
   /**
    * @swagger
@@ -161,11 +192,57 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.delete = [
-    param('id').trim().exists({ checkFalsy: true }),
-    query('actionName').trim().exists({ checkFalsy: true }),
-    query('transferToUserGroupId').trim(),
-  ];
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /selectable-groups:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getSelectableGroups
+   *        summary: /selectable-groups
+   *        description: Get selectable user groups.
+   *        parameters:
+   *          - name: groupId
+   *            in: query
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroup objects
+   */
+  router.get('/selectable-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+
+      const [ancestorGroups, descendantGroups] = await Promise.all([
+        UserGroup.findGroupsWithAncestorsRecursively(userGroup, []),
+        UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
+      ]);
+
+      const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
+      const selectableUserGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+      return res.apiv3({ selectableUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+    }
+  });
 
   /**
    * @swagger
@@ -221,13 +298,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.update = [
-    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
-    body('description', 'Group description must be a string').optional().isString(),
-    body('parentId', 'parentId must be a string').optional().isString(),
-    body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
-  ];
-
   /**
    * @swagger
    *
@@ -265,7 +335,7 @@ module.exports = (crowi) => {
     try {
       const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
 
-      res.apiv3({ userGroup });
+      return res.apiv3({ userGroup });
     }
     catch (err) {
       const msg = 'Error occurred in updating a user group name';
@@ -274,7 +344,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.users = {};
 
   /**
    * @swagger
@@ -387,10 +456,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.users.post = [
-    param('id').trim().exists({ checkFalsy: true }),
-    param('username').trim().exists({ checkFalsy: true }),
-  ];
 
   /**
    * @swagger
@@ -457,10 +522,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.users.delete = [
-    param('id').trim().exists({ checkFalsy: true }),
-    param('username').trim().exists({ checkFalsy: true }),
-  ];
 
   /**
    * @swagger
@@ -521,7 +582,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.userGroupRelations = {};
 
   /**
    * @swagger
@@ -569,13 +629,6 @@ module.exports = (crowi) => {
     }
   });
 
-  validator.pages = {};
-
-  validator.pages.get = [
-    param('id').trim().exists({ checkFalsy: true }),
-    sanitizeQuery('limit').customSanitizer(toPagingLimit),
-    sanitizeQuery('offset').customSanitizer(toPagingOffset),
-  ];
 
   /**
    * @swagger

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

@@ -55,7 +55,7 @@ class UserGroupService {
     const parent = await UserGroup.findById(parentId);
 
     if (parent == null) { // it should not be null
-      throw Error('parent does not exist.');
+      throw Error('Parent group does not exist.');
     }
 
 

+ 29 - 3
packages/app/src/stores/user-group.tsx

@@ -2,8 +2,12 @@ import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
-import { UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult } from '~/interfaces/user-group-response';
+import {
+  UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupPagesResult, SelectableUserGroupsResult,
+} from '~/interfaces/user-group-response';
 
 
 export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
@@ -17,10 +21,11 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
 };
 
 export const useSWRxChildUserGroupList = (
-    parentIds: string[] | undefined, includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
+    parentIds?: string[], includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
 ): SWRResponse<IUserGroupHasId[], Error> => {
+  const shouldFetch = parentIds != null && parentIds.length > 0;
   return useSWRImmutable<IUserGroupHasId[], Error>(
-    parentIds != null ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
+    shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     (endpoint, parentIds, includeGrandChildren) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
     ).then(result => result.data.childUserGroups),
@@ -30,6 +35,13 @@ export const useSWRxChildUserGroupList = (
   );
 };
 
+export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
+    endpoint => apiv3Get<UserGroupRelationListResult>(endpoint).then(result => result.data.userGroupRelations),
+  );
+};
+
 export const useSWRxUserGroupRelationList = (
     groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
 ): SWRResponse<IUserGroupRelationHasId[], Error> => {
@@ -43,3 +55,17 @@ export const useSWRxUserGroupRelationList = (
     },
   );
 };
+
+export const useSWRxUserGroupPages = (groupId: string | undefined, limit: number, offset: number): SWRResponse<IPageHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? [`/user-groups/${groupId}/pages`, limit, offset] : null,
+    endpoint => apiv3Get<UserGroupPagesResult>(endpoint, { limit, offset }).then(result => result.data.pages),
+  );
+};
+
+export const useSWRxSelectableUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/user-groups/selectable-groups'] : null,
+    endpoint => apiv3Get<SelectableUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableUserGroups),
+  );
+};