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

Merge pull request #5053 from weseek/imprv/commonize-user-group-form

imprv: Commonize user group form
Yuki Takei 4 лет назад
Родитель
Сommit
a25027b999

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

@@ -21,6 +21,7 @@
   "Done": "Done",
   "Cancel": "Cancel",
   "Create": "Create",
+  "Description": "Description",
   "Admin": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
@@ -119,6 +120,7 @@
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
+  "UserGroup": "UserGroup",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
@@ -243,7 +245,7 @@
     "expire": "Expiration",
     "Days": "Days",
     "Custom": "Custom",
-    "description": "description",
+    "description": "Description",
     "enter_desc": "Enter description",
     "Unlimited": "unlimited",
     "Issue": "Issue",
@@ -501,7 +503,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   "toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

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

@@ -21,6 +21,7 @@
   "Done": "完了",
   "Cancel": "キャンセル",
   "Create": "作成",
+  "Description": "説明",
   "Admin": "管理",
   "administrator": "管理者",
   "Tag": "タグ",
@@ -119,6 +120,7 @@
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
+  "UserGroup": "グループ",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
@@ -501,7 +503,10 @@
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
   },
   "toaster": {
+    "create_succeeded": "新しい{{target}}が作成されました",
+    "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
+    "update_failed": "{{target}}の更新に失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

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

@@ -22,6 +22,7 @@
   "Done": "Done",
   "Cancel": "取消",
 	"Create": "创建",
+  "Description": "描述",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"Tag": "标签",
@@ -127,6 +128,7 @@
   "Legacy_Slack_Integration": "旧版Slack一体化",
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
+  "UserGroup": "用户组",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
@@ -479,7 +481,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
 	"toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 15 - 7
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -1,9 +1,17 @@
+/*
+ * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
+ */
+
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
 
+import {
+  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
+} from '~/client/util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
@@ -33,7 +41,7 @@ export default class AdminUserGroupDetailContainer extends Container {
       childUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
       grandChildUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
 
-      childUserGroupUsers: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list
+      childUserGroupRelations: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list users
       relatedPages: [], // For page list
       isUserGroupUserModalOpen: false,
       searchType: 'partial',
@@ -67,8 +75,8 @@ export default class AdminUserGroupDetailContainer extends Container {
         userGroupRelations,
         relatedPages,
       ] = await Promise.all([
-        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
-        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
+        apiv3Get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
+        apiv3Get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
       ]);
 
       await this.setState({
@@ -111,7 +119,7 @@ export default class AdminUserGroupDetailContainer extends Container {
    * @return {object} response object
    */
   async updateUserGroup(param) {
-    const res = await this.appContainer.apiv3.put(`/user-groups/${this.state.userGroup._id}`, param);
+    const res = await apiv3Put(`/user-groups/${this.state.userGroup._id}`, param);
     const { userGroup } = res.data;
 
     await this.setState({ userGroup });
@@ -142,7 +150,7 @@ export default class AdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be searched
    */
   async fetchApplicableUsers(searchWord) {
-    const res = await this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
+    const res = await apiv3Get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
       searchWord,
       searchType: this.state.searchType,
       isAlsoMailSearched: this.state.isAlsoMailSearched,
@@ -162,7 +170,7 @@ export default class AdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be added to the group
    */
   async addUserByUsername(username) {
-    const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const res = await apiv3Post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
 
     // do not add users for ducaplicate
     if (res.data.userGroupRelation == null) { return }
@@ -177,7 +185,7 @@ export default class AdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be removed from the group
    */
   async removeUserByUsername(username) {
-    const res = await this.appContainer.apiv3.delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const res = await apiv3Delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
 
     this.setState((prevState) => {
       return {

+ 0 - 118
packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx

@@ -1,118 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupCreateForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      name: '',
-    };
-
-    this.xss = window.xss;
-
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  handleChange(event) {
-    const target = event.target;
-    const value = target.type === 'checkbox' ? target.checked : target.value;
-    const name = target.name;
-
-    this.setState({
-      [name]: value,
-    });
-  }
-
-  async handleSubmit(e) {
-    e.preventDefault();
-
-    try {
-      const res = await this.props.appContainer.apiv3.post('/user-groups', {
-        name: this.state.name,
-      });
-
-      const userGroup = res.data.userGroup;
-      const userGroupId = userGroup._id;
-
-      const res2 = await this.props.appContainer.apiv3.get(`/user-groups/${userGroupId}/users`); // TODO 85062: fetch userGroupRelationsById instead
-
-      const { users } = res2.data;
-
-      this.props.onCreate(userGroup, users); // TODO 85062: pass userGroupRelations instead of users
-
-      this.setState({ name: '' });
-
-      toastSuccess(`Created a user group "${this.xss.process(userGroup.name)}"`);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  validateForm() {
-    return this.state.name !== '';
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div>
-        <p>
-          {this.props.isAclEnabled
-            ? (
-              <button type="button" data-toggle="collapse" className="btn btn-outline-secondary" href="#createGroupForm">
-                {t('admin:user_group_management.create_group')}
-              </button>
-            )
-            : (
-              t('admin:user_group_management.deny_create_group')
-            )
-          }
-        </p>
-        <form onSubmit={this.handleSubmit}>
-          <div id="createGroupForm" className="collapse">
-            <div className="form-group">
-              <label htmlFor="name">{t('admin:user_group_management.group_name')}</label>
-              <textarea
-                id="name"
-                name="name"
-                className="form-control"
-                placeholder={t('admin:user_group_management.group_example')}
-                value={this.state.name}
-                onChange={this.handleChange}
-              >
-              </textarea>
-            </div>
-            <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{t('Create')}</button>
-          </div>
-        </form>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupCreateFormWrapper = withUnstatedContainers(withTranslation()(UserGroupCreateForm), [AppContainer]);
-
-UserGroupCreateForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isAclEnabled: PropTypes.bool.isRequired,
-  onCreate: PropTypes.func.isRequired,
-};
-
-export default UserGroupCreateFormWrapper;

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

@@ -1,14 +1,14 @@
 import React, {
   FC, useCallback, useState, useMemo,
 } from 'react';
-import { TFunction } from 'i18next';
+import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
-import { IUserGroupHasObjectId } from '~/interfaces/user';
+import { IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 
@@ -22,11 +22,11 @@ import Xss from '~/services/xss';
 type Props = {
   appContainer: AppContainer,
 
-  userGroups: IUserGroupHasObjectId[],
-  deleteUserGroup?: IUserGroupHasObjectId,
+  userGroups: IUserGroupHasId[],
+  deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
-  onShow?: (group: IUserGroupHasObjectId) => Promise<void> | void,
+  onShow?: (group: IUserGroupHasId) => Promise<void> | void,
   onHide?: () => Promise<void> | void,
 };
 
@@ -35,7 +35,7 @@ type AvailableOption = {
   actionForPages: string,
   iconClass: string,
   styleClass: string,
-  label: ReturnType<TFunction>,
+  label: TFunctionResult,
 };
 
 // actionName master constants

+ 119 - 0
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -0,0 +1,119 @@
+import React, { FC, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+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 { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup?: IUserGroupHasId,
+  successedMessage: TFunctionResult;
+  failedMessage: TFunctionResult;
+  submitButtonLabel: TFunctionResult;
+  onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+};
+
+const UserGroupForm: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  /*
+   * 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 : '');
+
+  /*
+   * Function
+   */
+  const onChangeNameHandler = useCallback((e) => {
+    setName(e.target.value);
+  }, []);
+
+  const onChangeDescriptionHandler = useCallback((e) => {
+    setDescription(e.target.value);
+  }, []);
+
+  const onSubmitHandler = useCallback(async(e) => {
+    e.preventDefault(); // no reload
+
+    if (props.onSubmit == null) {
+      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]);
+
+  return (
+    <form onSubmit={onSubmitHandler}>
+      {/* TODO 85062: improve style */}
+      {
+        props.userGroup != null && (
+          <div className="row mb-2">
+            <p className="col-md-4">{t('Created')}</p>
+            <p className="col">{dateFnsFormat(new Date(props.userGroup.createdAt), 'yyyy-MM-dd')}</p>
+          </div>
+        )
+      }
+
+      <fieldset>
+        <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
+        <div className="form-group row">
+          <label htmlFor="name" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.group_name')}
+          </label>
+          <div className="col-md-4">
+            <input
+              className="form-control"
+              type="text"
+              name="name"
+              placeholder={t('admin:user_group_management.group_example')}
+              value={currentName}
+              onChange={onChangeNameHandler}
+              required
+            />
+          </div>
+        </div>
+        <div className="form-group row">
+          <label htmlFor="description" className="col-md-2 col-form-label">
+            {t('Description')}
+          </label>
+          <div className="col-md-4">
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} required />
+          </div>
+        </div>
+
+        {/* TODO 85062: select parent dropdown */}
+
+        <div className="form-group row">
+          <div className="offset-md-2 col-md-10">
+            <button type="submit" className="btn btn-primary">
+              {props.submitButtonLabel}
+            </button>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupFormWrapper = withUnstatedContainers<unknown, Props>(UserGroupForm, [AppContainer]);
+
+export default UserGroupFormWrapper;

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

@@ -1,18 +1,19 @@
 import React, {
   FC, Fragment, useState, useCallback, useEffect,
 } from 'react';
+import { useTranslation } from 'react-i18next';
 
 import UserGroupTable from './UserGroupTable';
-import UserGroupCreateForm from './UserGroupCreateForm';
+import UserGroupForm from './UserGroupForm';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IUserGroupHasObjectId, IUserGroupRelation } from '~/interfaces/user';
+import { IUserGroup, IUserGroupHasId, IUserGroupRelation } from '~/interfaces/user';
 import Xss from '~/services/xss';
 import { CustomWindow } from '~/interfaces/global';
-import { apiv3Get, apiv3Delete } from '~/client/util/apiv3-client';
+import { apiv3Get, apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
 
 type Props = {
   appContainer: AppContainer,
@@ -20,14 +21,15 @@ type Props = {
 
 const UserGroupPage: FC<Props> = (props: Props) => {
   const xss: Xss = (window as CustomWindow).xss;
+  const { t } = useTranslation();
   const { isAclEnabled } = props.appContainer.config;
 
   /*
    * State
    */
-  const [userGroups, setUserGroups] = useState<IUserGroupHasObjectId[]>([]);
+  const [userGroups, setUserGroups] = useState<IUserGroupHasId[]>([]);
   const [userGroupRelations, setUserGroupRelations] = useState<IUserGroupRelation[]>([]);
-  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasObjectId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
@@ -46,7 +48,7 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     }
   }, []);
 
-  const showDeleteModal = useCallback(async(group: IUserGroupHasObjectId) => {
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     try {
       await syncUserGroupAndRelations();
 
@@ -63,9 +65,20 @@ const UserGroupPage: FC<Props> = (props: Props) => {
     setDeleteModalShown(false);
   }, []);
 
-  const addUserGroup = useCallback((userGroup: IUserGroupHasObjectId, userGroupRelations: IUserGroupRelation[]) => {
-    setUserGroups(prev => [...prev, userGroup]);
-    setUserGroupRelations(prev => ([...prev, ...userGroupRelations]));
+  const addUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      const res = await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parent: userGroupData.parent,
+      });
+
+      const newUserGroup = res.data.userGroup;
+      setUserGroups(prev => [...prev, newUserGroup]);
+    }
+    catch (err) {
+      toastError(err);
+    }
   }, []);
 
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
@@ -96,10 +109,25 @@ const UserGroupPage: FC<Props> = (props: Props) => {
 
   return (
     <Fragment>
-      <UserGroupCreateForm
-        isAclEnabled={isAclEnabled}
-        onCreate={addUserGroup}
-      />
+      {
+        isAclEnabled ? (
+          <div className="mb-2">
+            <button type="button" className="btn btn-outline-secondary" data-toggle="collapse" data-target="#createGroupForm">
+              {t('admin:user_group_management.create_group')}
+            </button>
+            <div id="createGroupForm" className="collapse">
+              <UserGroupForm
+                successedMessage={t('toaster.create_succeeded', { target: t('UserGroup') })}
+                failedMessage={t('toaster.create_failed', { target: t('UserGroup') })}
+                submitButtonLabel={t('Create')}
+                onSubmit={addUserGroup}
+              />
+            </div>
+          </div>
+        ) : (
+          t('admin:user_group_management.deny_create_group')
+        )
+      }
       <UserGroupTable
         userGroups={userGroups}
         isAclEnabled={isAclEnabled}

+ 131 - 3
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,16 +1,138 @@
-import React, { FC } from 'react';
+import React, {
+  FC, useState, useCallback, useEffect,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 
-import UserGroupEditForm from './UserGroupEditForm';
+import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
 import UserGroupPageList from './UserGroupPageList';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
+import {
+  apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
+} from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/apiNotification';
+import { IPageHasId } from '~/interfaces/page';
+import {
+  IUserGroup, IUserGroupHasId, IUserGroupRelation, IUserGroupRelationHasId,
+} from '~/interfaces/user';
 
 const UserGroupDetailPage: FC = () => {
+  const rootElem = document.getElementById('admin-user-group-detail');
   const { t } = useTranslation();
 
+  /*
+   * 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
+  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 [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
+  const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
+  const [searchType, setSearchType] = useState<string>('partial');
+  const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
+  const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+
+  /*
+   * Function
+   */
+  const init = 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),
+      ]);
+
+      setUserGroup(userGroupRelations);
+      setUserGroupRelations(relatedPages);
+    }
+    catch (err) {
+      toastError(new Error('Failed to fetch data'));
+    }
+  }, []); // no deps
+
+  // TODO 85062: old name: switchIsAlsoMailSearched
+  const toggleIsAlsoMailSearched = useCallback(() => {
+    setAlsoMailSearched(prev => !prev);
+  }, []);
+
+  // TODO 85062: old name: switchIsAlsoNameSearched
+  const toggleAlsoNameSearched = useCallback(() => {
+    setAlsoNameSearched(prev => !prev);
+  }, []);
+
+  const switchSearchType = useCallback((searchType) => {
+    setSearchType(searchType);
+  }, []);
+
+  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]);
+
+  const openUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(true);
+  }, []);
+
+  const closeUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(false);
+  }, []);
+
+  const fetchApplicableUsers = useCallback(async(searchWord) => {
+    const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
+      searchWord,
+      searchType,
+      isAlsoMailSearched,
+      isAlsoNameSearched,
+    });
+
+    const { users } = res.data;
+
+    return users;
+  }, [searchType, isAlsoMailSearched, isAlsoNameSearched]);
+
+  const addUserByUsername = useCallback(async(username: string) => {
+    const res = await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
+
+    // do not add users for ducaplicate
+    if (res.data.userGroupRelation == null) { return }
+
+    await init();
+  }, [userGroup]);
+
+  const removeUserByUsername = useCallback(async(username: string) => {
+    const res = await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+
+    setUserGroupRelations(prev => prev.filter(u => u._id !== res.data.userGroupRelation._id));
+  }, [userGroup]);
+
+  /*
+   * componentDidMount
+   */
+  useEffect(() => {
+    init();
+  }, []);
+
+  /*
+   * Dependencies
+   */
+  if (userGroup == null) {
+    return <></>;
+  }
+
   return (
     <div>
       <a href="/admin/user-groups" className="btn btn-outline-secondary">
@@ -19,7 +141,13 @@ const UserGroupDetailPage: FC = () => {
       </a>
       {/* TODO 85062: Link to the ancestors group */}
       <div className="mt-4 form-box">
-        <UserGroupEditForm />
+        <UserGroupForm
+          userGroup={userGroup}
+          successedMessage={t('toaster.update_successed', { target: t('UserGroup') })}
+          failedMessage={t('toaster.update_failed', { target: t('UserGroup') })}
+          submitButtonLabel={t('Update')}
+          onSubmit={updateUserGroup}
+        />
       </div>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <UserGroupUserTable />

+ 0 - 111
packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -1,111 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupEditForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { adminUserGroupDetailContainer } = props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
-
-    this.state = {
-      name: userGroup.name,
-      nameCache: userGroup.name, // cache for name. update every submit
-    };
-
-    this.xss = window.xss;
-
-    this.changeUserGroupName = this.changeUserGroupName.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  changeUserGroupName(event) {
-    this.setState({
-      name: event.target.value,
-    });
-  }
-
-  async handleSubmit(e) {
-    e.preventDefault();
-
-    try {
-      const res = await this.props.adminUserGroupDetailContainer.updateUserGroup({
-        name: this.state.name,
-      });
-
-      toastSuccess(`Updated the group name to "${this.xss.process(res.data.userGroup.name)}"`);
-      this.setState({ nameCache: this.state.name });
-    }
-    catch (err) {
-      toastError(new Error('Unable to update the group name'));
-    }
-  }
-
-  validateForm() {
-    return (
-      this.state.name !== this.state.nameCache
-      && this.state.name !== ''
-    );
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <form onSubmit={this.handleSubmit}>
-        <fieldset>
-          <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
-          <div className="form-group row">
-            <label htmlFor="name" className="col-md-2 col-form-label">
-              {t('Name')}
-            </label>
-            <div className="col-md-4">
-              <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.changeUserGroupName} />
-            </div>
-          </div>
-          <div className="form-group row">
-            <label className="col-md-2 col-form-label">{t('Created')}</label>
-            <div className="col-md-4">
-              <input
-                type="text"
-                className="form-control"
-                value={dateFnsFormat(new Date(adminUserGroupDetailContainer.state.userGroup.createdAt), 'yyyy-MM-dd')}
-                disabled
-              />
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="offset-md-2 col-md-10">
-              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
-                {t('Update')}
-              </button>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-    );
-  }
-
-}
-
-UserGroupEditForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupEditFormWrapper = withUnstatedContainers(UserGroupEditForm, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default withTranslation()(UserGroupEditFormWrapper);

+ 2 - 1
packages/app/src/interfaces/user.ts

@@ -21,4 +21,5 @@ export type IUserGroup = {
   parent: Ref<IUserGroup>;
 }
 
-export type IUserGroupHasObjectId = IUserGroup & HasObjectId;
+export type IUserGroupHasId = IUserGroup & HasObjectId;
+export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;

+ 0 - 4
packages/app/src/server/routes/apiv3/user-group.js

@@ -202,10 +202,6 @@ module.exports = (crowi) => {
     }
   });
 
-  // return one group with the id
-  // router.get('/:id', async(req, res) => {
-  // });
-
   validator.update = [
     body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
   ];