Browse Source

Merge branch 'dev/5.0.x' into feat/86349-delete-operation-from-the-delete-modal

Shun Miyazawa 4 years ago
parent
commit
6d5fec1ce8

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

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

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

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

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

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

+ 2 - 2
packages/app/src/client/util/smooth-scroll.ts

@@ -1,6 +1,6 @@
 const WIKI_HEADER_LINK = 120;
 const WIKI_HEADER_LINK = 120;
 
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
   const targetElement = element || window.document.body;
   const targetElement = element || window.document.body;
 
 
   // get the distance to the target element top
   // get the distance to the target element top
@@ -8,7 +8,7 @@ export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void
 
 
   const top = window.pageYOffset + rectTop - offsetTop;
   const top = window.pageYOffset + rectTop - offsetTop;
 
 
-  window.scrollTo({
+  scrollElement.scrollTo({
     top,
     top,
     behavior: 'smooth',
     behavior: 'smooth',
   });
   });

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

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

@@ -1,9 +1,10 @@
 import React, {
 import React, {
-  FC, useState, useCallback, useEffect,
+  FC, useState, useCallback,
 } from 'react';
 } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupForm from '../UserGroup/UserGroupForm';
+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';
@@ -12,11 +13,13 @@ 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 { 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, IUserGroupRelationHasId,
+  IUserGroup, IUserGroupHasId, IUserGroupRelation,
 } from '~/interfaces/user';
 } from '~/interfaces/user';
+import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
+
 
 
 const UserGroupDetailPage: FC = () => {
 const UserGroupDetailPage: FC = () => {
   const rootElem = document.getElementById('admin-user-group-detail');
   const rootElem = document.getElementById('admin-user-group-detail');
@@ -26,7 +29,6 @@ const UserGroupDetailPage: FC = () => {
    * State (from AdminUserGroupDetailContainer)
    * State (from AdminUserGroupDetailContainer)
    */
    */
   const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
   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
   // 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 [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);
   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
   // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
     setAlsoMailSearched(prev => !prev);
@@ -107,22 +98,34 @@ const UserGroupDetailPage: FC = () => {
   // TODO 85062: will be used in UserGroupUserFormByInput
   // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
   const addUserByUsername = useCallback(async(username: string) => {
     await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
     await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
-
-    await sync();
-  }, [userGroup, sync]);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
 
 
   const removeUserByUsername = useCallback(async(username: string) => {
   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
    * Dependencies
@@ -150,6 +153,14 @@ const UserGroupDetailPage: FC = () => {
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <UserGroupUserTable />
       <UserGroupUserTable />
       <UserGroupUserModal />
       <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>
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
       <div className="page-list">
         <UserGroupPageList />
         <UserGroupPageList />

+ 19 - 12
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -31,25 +31,31 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const [inputText, setInputText] = useState(props.value);
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
 
 
-  const onChangeHandler = async(e) => {
-    if (props.inputValidator == null) { return }
+  const createValidation = async(inputText: string) => {
+    if (props.inputValidator != null) {
+      const alertInfo = await props.inputValidator(inputText);
+      setAlertInfo(alertInfo);
+    }
+  };
 
 
+  const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
     const inputText = e.target.value;
     const inputText = e.target.value;
-
-    const alertInfo = await props.inputValidator(inputText);
-
-    setAlertInfo(alertInfo);
+    createValidation(inputText);
     setInputText(inputText);
     setInputText(inputText);
   };
   };
 
 
+  const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    await createValidation(inputText);
+  };
+
   const onPressEnter = () => {
   const onPressEnter = () => {
-    if (props.onPressEnter == null) {
-      return;
+    if (props.onPressEnter != null) {
+      const text = inputText != null ? inputText.trim() : null;
+      if (currentAlertInfo == null) {
+        props.onPressEnter(text);
+      }
     }
     }
-
-    const text = inputText != null ? inputText.trim() : null;
-
-    props.onPressEnter(text);
   };
   };
 
 
   const onKeyDownHandler = (e) => {
   const onKeyDownHandler = (e) => {
@@ -107,6 +113,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         className="form-control"
         className="form-control"
         placeholder={props.placeholder}
         placeholder={props.placeholder}
         name="input"
         name="input"
+        onFocus={onFocusHandler}
         onChange={onChangeHandler}
         onChange={onChangeHandler}
         onKeyDown={onKeyDownHandler}
         onKeyDown={onKeyDownHandler}
         onBlur={onBlurHandler}
         onBlur={onBlurHandler}

+ 10 - 18
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -7,11 +7,11 @@ import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt } from '~/stores/context';
 import PutbackPageModal from '../PutbackPageModal';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import EmptyTrashModal from '../EmptyTrashModal';
-import PageDeleteModal from '../PageDeleteModal';
 
 
+import { useCurrentUpdatedAt } from '~/stores/context';
+import { usePageDeleteModalStatus } from '~/stores/ui';
 
 
 const TrashPageAlert = (props) => {
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const { t, pageContainer } = props;
@@ -21,7 +21,8 @@ const TrashPageAlert = (props) => {
   const { data: updatedAt } = useCurrentUpdatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
-  const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
+
+  const { open: openDeleteModal } = usePageDeleteModalStatus();
 
 
   function openEmptyTrashModalHandler() {
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
     setIsEmptyTrashModalShown(true);
@@ -40,11 +41,12 @@ const TrashPageAlert = (props) => {
   }
   }
 
 
   function openPageDeleteModalHandler() {
   function openPageDeleteModalHandler() {
-    setIsPageDeleteModalShown(true);
-  }
-
-  function opclosePageDeleteModalHandler() {
-    setIsPageDeleteModalShown(false);
+    const pageToDelete = {
+      pageId,
+      revisionId,
+      path,
+    };
+    openDeleteModal([pageToDelete]);
   }
   }
 
 
   function renderEmptyButton() {
   function renderEmptyButton() {
@@ -97,16 +99,6 @@ const TrashPageAlert = (props) => {
           pageId={pageId}
           pageId={pageId}
           path={path}
           path={path}
         />
         />
-        {/* TODO: show PageDeleteModal with usePageDeleteModal by 87567  */}
-        <PageDeleteModal
-          isOpen={isPageDeleteModalShown}
-          onClose={opclosePageDeleteModalHandler}
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
-          isDeleteCompletelyModal
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        />
       </>
       </>
     );
     );
   }
   }

+ 9 - 11
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -7,6 +7,9 @@ import { useTranslation } from 'react-i18next';
 import { useDrag, useDrop } from 'react-dnd';
 import { useDrag, useDrop } from 'react-dnd';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
+
+import { pathUtils } from '@growi/core';
+
 import { toastWarning, toastError } from '~/client/util/apiNotification';
 import { toastWarning, toastError } from '~/client/util/apiNotification';
 
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
@@ -19,7 +22,6 @@ import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTe
 import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { AsyncPageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 
 
-
 interface ItemProps {
 interface ItemProps {
   isEnableActions: boolean
   isEnableActions: boolean
   itemNode: ItemNode
   itemNode: ItemNode
@@ -151,11 +153,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   // }, []);
   // }, []);
 
 
   // const onPressEnterForRenameHandler = async(inputText: string) => {
   // const onPressEnterForRenameHandler = async(inputText: string) => {
-  //   if (inputText == null || inputText === '' || inputText.trim() === '' || inputText.includes('/')) {
-  //     return;
-  //   }
-
-  //   const parentPath = nodePath.dirname(page.path as string);
+  //   const parentPath = getParentPagePath(page.path as string)
   //   const newPagePath = `${parentPath}/${inputText}`;
   //   const newPagePath = `${parentPath}/${inputText}`;
 
 
   //   try {
   //   try {
@@ -185,7 +183,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickRenameMenuItem(pageId, revisionId as string, path);
     onClickRenameMenuItem(pageId, revisionId as string, path);
   }, [onClickRenameMenuItem, page]);
   }, [onClickRenameMenuItem, page]);
 
 
-
   const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
   const onClickDeleteButton = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDeleteMenuItem == null) {
     if (onClickDeleteMenuItem == null) {
       return;
       return;
@@ -206,11 +203,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickDeleteMenuItem(pageToDelete);
     onClickDeleteMenuItem(pageToDelete);
   }, [page, onClickDeleteMenuItem]);
   }, [page, onClickDeleteMenuItem]);
 
 
-
-  // TODO: go to create page page
-  const onPressEnterForCreateHandler = () => {
-    toastWarning(t('search_result.currently_not_implemented'));
+  const onPressEnterForCreateHandler = (inputText: string) => {
     setNewPageInputShown(false);
     setNewPageInputShown(false);
+    const parentPath = pathUtils.addTrailingSlash(page.path as string);
+    const newPagePath = `${parentPath}${inputText}`;
+    console.log(newPagePath);
+    // TODO: https://redmine.weseek.co.jp/issues/87943
   };
   };
 
 
   const inputValidator = (title: string | null): AlertInfo | null => {
   const inputValidator = (title: string | null): AlertInfo | null => {

+ 11 - 1
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect } from 'react';
 
 
 import { IPageHasId } from '../../../interfaces/page';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
@@ -9,6 +9,7 @@ import { toastError } from '~/client/util/apiNotification';
 import {
 import {
   IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
   IPageForPageDeleteModal, usePageDuplicateModalStatus, usePageRenameModalStatus, usePageDeleteModal,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 
 /*
 /*
  * Utility to generate initial node
  * Utility to generate initial node
@@ -96,6 +97,15 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { open: openRenameModal } = usePageRenameModalStatus();
   const { open: openRenameModal } = usePageRenameModalStatus();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
 
+  useEffect(() => {
+    const startFrom = document.getElementById('grw-sidebar-contents-scroll-target');
+    const targetElem = document.getElementsByClassName('grw-pagetree-is-target');
+    //  targetElem is HTML collection but only one HTML element in it all the time
+    if (targetElem[0] != null && startFrom != null) {
+      smoothScrollIntoView(targetElem[0] as HTMLElement, 0, startFrom);
+    }
+  }, [ancestorsChildrenData]);
+
   const onClickDuplicateMenuItem = (pageId: string, path: string) => {
   const onClickDuplicateMenuItem = (pageId: string, path: string) => {
     openDuplicateModal(pageId, path);
     openDuplicateModal(pageId, path);
   };
   };

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

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

+ 1 - 1
packages/app/src/server/models/obsolete-page.js

@@ -738,7 +738,7 @@ export const getPageSchema = (crowi) => {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
   };
 
 
-  pageSchema.statics.findListByPageIds = async function(ids, option, excludeRedirect = true) {
+  pageSchema.statics.findListByPageIds = async function(ids, option) {
     const User = crowi.model('User');
     const User = crowi.model('User');
 
 
     const opt = Object.assign({}, option);
     const opt = Object.assign({}, option);

+ 6 - 0
packages/app/src/server/models/page-redirect.ts

@@ -26,4 +26,10 @@ const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   toPath: { type: String, required: true },
   toPath: { type: String, required: true },
 });
 });
 
 
+schema.statics.removePageRedirectByToPath = async function(toPath: string): Promise<void> {
+  await this.deleteMany({ toPath });
+
+  return;
+};
+
 export default getOrCreateModel<PageRedirectDocument, PageRedirectModel>('PageRedirect', schema);
 export default getOrCreateModel<PageRedirectDocument, PageRedirectModel>('PageRedirect', schema);

+ 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 { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
 
 
-const validator = {};
-
 const { ObjectId } = mongoose.Types;
 const { ObjectId } = mongoose.Types;
 
 
 
 
@@ -41,10 +39,48 @@ module.exports = (crowi) => {
     Page,
     Page,
   } = crowi.models;
   } = 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
    * @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
    * @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
    * @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
    * @swagger
    *
    *
@@ -265,7 +335,7 @@ module.exports = (crowi) => {
     try {
     try {
       const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
       const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
 
 
-      res.apiv3({ userGroup });
+      return res.apiv3({ userGroup });
     }
     }
     catch (err) {
     catch (err) {
       const msg = 'Error occurred in updating a user group name';
       const msg = 'Error occurred in updating a user group name';
@@ -274,7 +344,6 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  validator.users = {};
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -387,10 +456,6 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  validator.users.post = [
-    param('id').trim().exists({ checkFalsy: true }),
-    param('username').trim().exists({ checkFalsy: true }),
-  ];
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -457,10 +522,6 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  validator.users.delete = [
-    param('id').trim().exists({ checkFalsy: true }),
-    param('username').trim().exists({ checkFalsy: true }),
-  ];
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -521,7 +582,6 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  validator.userGroupRelations = {};
 
 
   /**
   /**
    * @swagger
    * @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
    * @swagger

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -1352,7 +1352,7 @@ module.exports = function(crowi, app) {
     const path = req.body.path;
     const path = req.body.path;
 
 
     try {
     try {
-      await Page.removeRedirectOriginPageByPath(path);
+      await PageRedirect.removePageRedirectByToPath(path);
       logger.debug('Redirect Page deleted', path);
       logger.debug('Redirect Page deleted', path);
     }
     }
     catch (err) {
     catch (err) {

+ 1 - 1
packages/app/src/server/service/page.ts

@@ -1948,7 +1948,7 @@ class PageService {
 
 
     let result;
     let result;
     try {
     try {
-      result = await Page.findListByPageIds(pageIds, null, false);
+      result = await Page.findListByPageIds(pageIds, null);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to find pages by ids', err);
       logger.error('Failed to find pages by ids', err);

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

@@ -55,7 +55,7 @@ class UserGroupService {
     const parent = await UserGroup.findById(parentId);
     const parent = await UserGroup.findById(parentId);
 
 
     if (parent == null) { // it should not be null
     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 useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 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> => {
 export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
@@ -17,10 +21,11 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
 };
 };
 
 
 export const useSWRxChildUserGroupList = (
 export const useSWRxChildUserGroupList = (
-    parentIds: string[] | undefined, includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
+    parentIds?: string[], includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
 ): SWRResponse<IUserGroupHasId[], Error> => {
 ): SWRResponse<IUserGroupHasId[], Error> => {
+  const shouldFetch = parentIds != null && parentIds.length > 0;
   return useSWRImmutable<IUserGroupHasId[], Error>(
   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) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
       endpoint, { parentIds, includeGrandChildren },
     ).then(result => result.data.childUserGroups),
     ).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 = (
 export const useSWRxUserGroupRelationList = (
     groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
     groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
 ): SWRResponse<IUserGroupRelationHasId[], Error> => {
 ): 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),
+  );
+};