Browse Source

Merge pull request #8799 from weseek/feat/144301-144728-select-group-inheritance-on-create-page-from-tree

Feat/144301 144728 select group inheritance on create page from tree
Yuki Takei 1 year ago
parent
commit
b88028d66e

+ 1 - 2
apps/app/src/client/services/create-page/index.ts

@@ -1,3 +1,2 @@
-export * from './create-page';
-export * from './use-create-page-and-transit';
+export * from './use-create-page';
 export * from './use-create-template-page';

+ 33 - 24
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx → apps/app/src/client/services/create-page/use-create-page.tsx

@@ -1,18 +1,17 @@
 import { useCallback, useState } from 'react';
 
 import { useRouter } from 'next/router';
+import { useTranslation } from 'react-i18next';
 
 import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
+import { toastWarning } from '~/client/util/toastr';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
 
 import { createPage } from './create-page';
 
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
 /**
  * Invoked when creation and transition has finished
  */
@@ -26,27 +25,29 @@ type OnAborted = () => void;
  */
 type OnTerminated = () => void;
 
-export type CreatePageAndTransitOpts = {
-  shouldCheckPageExists?: boolean,
+export type CreatePageOpts = {
+  skipPageExistenceCheck?: boolean,
+  skipTransition?: boolean,
   onCreationStart?: OnCreated,
   onCreated?: OnCreated,
   onAborted?: OnAborted,
   onTerminated?: OnTerminated,
 }
 
-type CreatePageAndTransit = (
+type CreatePage = (
   params: IApiv3PageCreateParams,
-  opts?: CreatePageAndTransitOpts,
+  opts?: CreatePageOpts,
 ) => Promise<void>;
 
-type UseCreatePageAndTransit = () => {
+type UseCreatePage = () => {
   isCreating: boolean,
-  createAndTransit: CreatePageAndTransit,
+  create: CreatePage,
 };
 
-export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
+export const useCreatePage: UseCreatePage = () => {
 
   const router = useRouter();
+  const { t } = useTranslation();
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { mutate: mutateEditorMode } = useEditorMode();
@@ -54,25 +55,31 @@ export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
 
   const [isCreating, setCreating] = useState(false);
 
-  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
+  const create: CreatePage = useCallback(async(params, opts = {}) => {
     const {
-      shouldCheckPageExists,
       onCreationStart, onCreated, onAborted, onTerminated,
     } = opts;
+    const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
+    const skipTransition = opts.skipTransition ?? false;
 
     // check the page existence
-    if (shouldCheckPageExists && params.path != null) {
+    if (!skipPageExistenceCheck && params.path != null) {
       const pagePath = params.path;
 
       try {
         const { isExist } = await exist(pagePath);
 
         if (isExist) {
-          // routing
-          if (pagePath !== currentPagePath) {
-            await router.push(`${pagePath}#edit`);
+          if (!skipTransition) {
+            // routing
+            if (pagePath !== currentPagePath) {
+              await router.push(`${pagePath}#edit`);
+            }
+            mutateEditorMode(EditorMode.Editor);
+          }
+          else {
+            toastWarning(t('duplicated_page_alert.same_page_name_exists', { pageName: pagePath }));
           }
-          mutateEditorMode(EditorMode.Editor);
           onAborted?.();
           return;
         }
@@ -85,7 +92,7 @@ export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
       }
     }
 
-    const _createAndTransit = async(onlyInheritUserRelatedGrantedGroups?: boolean) => {
+    const _create = async(onlyInheritUserRelatedGrantedGroups?: boolean) => {
       try {
         setCreating(true);
         onCreationStart?.();
@@ -95,8 +102,10 @@ export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
 
         closeGrantedGroupsInheritanceSelectModal();
 
-        await router.push(`/${response.page._id}#edit`);
-        mutateEditorMode(EditorMode.Editor);
+        if (!skipTransition) {
+          await router.push(`/${response.page._id}#edit`);
+          mutateEditorMode(EditorMode.Editor);
+        }
 
         onCreated?.();
       }
@@ -114,16 +123,16 @@ export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
       const { isNonUserRelatedGroupsGranted } = await getIsNonUserRelatedGroupsGranted(params.parentPath);
       if (isNonUserRelatedGroupsGranted) {
         // create and transit request will be made from modal
-        openGrantedGroupsInheritanceSelectModal(_createAndTransit);
+        openGrantedGroupsInheritanceSelectModal(_create);
         return;
       }
     }
 
-    await _createAndTransit();
-  }, [currentPagePath, mutateEditorMode, router, openGrantedGroupsInheritanceSelectModal, closeGrantedGroupsInheritanceSelectModal]);
+    await _create();
+  }, [currentPagePath, mutateEditorMode, router, openGrantedGroupsInheritanceSelectModal, closeGrantedGroupsInheritanceSelectModal, t]);
 
   return {
     isCreating,
-    createAndTransit,
+    create,
   };
 };

+ 4 - 5
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -8,7 +8,7 @@ import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/stores/page';
 
 
-import { useCreatePageAndTransit } from './use-create-page-and-transit';
+import { useCreatePage } from './use-create-page';
 
 type UseCreateTemplatePage = () => {
   isCreatable: boolean,
@@ -20,19 +20,18 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
 
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
   const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
 
   const createTemplate = useCallback(async(label: LabelType) => {
     if (isLoadingPagePath || !isCreatable) return;
 
-    return createAndTransit(
+    return create(
       {
         path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
       },
-      { shouldCheckPageExists: true },
     );
-  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+  }, [currentPagePath, isCreatable, isLoadingPagePath, create]);
 
   return {
     isCreatable,

+ 6 - 8
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,10 +1,10 @@
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 import { Origin } from '@growi/core';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
-import { apiv3Get } from '~/client/util/apiv3-client';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
@@ -68,7 +68,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
   const editButtonClickedHandler = useCallback(async() => {
     if (isNotFound == null || isNotFound === false) {
@@ -77,19 +77,17 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
 
     try {
-      let parentPath = path?.split('/').slice(0, -1).join('/'); // does not have to exist
-      parentPath = parentPath === '' ? '/' : parentPath;
-      await createAndTransit(
+      const parentPath = path != null ? normalizePath(path.split('/').slice(0, -1).join('/')) : undefined; // does not have to exist
+      await create(
         {
           path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
         },
-        { shouldCheckPageExists: true },
       );
     }
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
+  }, [create, isNotFound, mutateEditorMode, path, t]);
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 

+ 8 - 9
apps/app/src/components/PageCreateModal.tsx

@@ -12,9 +12,8 @@ import {
 import { debounce } from 'throttle-debounce';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
-import { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
+import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 
@@ -37,7 +36,7 @@ const PageCreateModal: React.FC = () => {
   const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
 
   const { data: isReachable } = useIsSearchServiceReachable();
@@ -95,28 +94,28 @@ const PageCreateModal: React.FC = () => {
    */
   const createTodayPage = useCallback(async() => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
-    return createAndTransit(
+    return create(
       {
         path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
       },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+      { onTerminated: closeCreateModal },
     );
-  }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
+  }, [closeCreateModal, create, todayInput, todaysParentPath]);
 
   /**
    * access input page
    */
   const createInputPage = useCallback(async() => {
-    return createAndTransit(
+    return create(
       {
         path: pageNameInput,
         parentPath: pathname,
         wip: true,
         origin: Origin.View,
       },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+      { onTerminated: closeCreateModal },
     );
-  }, [closeCreateModal, createAndTransit, pageNameInput, pathname]);
+  }, [closeCreateModal, create, pageNameInput, pathname]);
 
   /**
    * access template page

+ 4 - 4
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -3,16 +3,16 @@ import { useCallback } from 'react';
 import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 
 export const SidebarNotFound = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
 
   const clickCreateButtonHandler = useCallback(async() => {
-    createAndTransit({ path: '/Sidebar', wip: false, origin: Origin.View });
-  }, [createAndTransit]);
+    create({ path: '/Sidebar', wip: false, origin: Origin.View }, { skipPageExistenceCheck: true });
+  }, [create]);
 
   return (
     <div>

+ 7 - 4
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
 
 import { Origin } from '@growi/core';
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/stores/page';
 
 
@@ -14,20 +14,23 @@ type UseCreateNewPage = () => {
 export const useCreateNewPage: UseCreateNewPage = () => {
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
   const createNewPage = useCallback(async() => {
     if (isLoadingPagePath) return;
 
-    return createAndTransit(
+    return create(
       {
         parentPath: currentPagePath,
         optionalParentPath: '/',
         wip: true,
         origin: Origin.View,
       },
+      {
+        skipPageExistenceCheck: true,
+      },
     );
-  }, [createAndTransit, currentPagePath, isLoadingPagePath]);
+  }, [create, currentPagePath, isLoadingPagePath]);
 
   return {
     isCreating,

+ 4 - 6
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -5,8 +5,7 @@ import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'react-i18next';
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
-import { apiv3Get } from '~/client/util/apiv3-client';
+import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentUser } from '~/stores/context';
 
 
@@ -20,7 +19,7 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const { t } = useTranslation('commons');
 
   const { data: currentUser } = useCurrentUser();
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
   const isCreatable = currentUser != null;
 
@@ -34,13 +33,12 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const createTodaysMemo = useCallback(async() => {
     if (!isCreatable || todaysPath == null) return;
 
-    return createAndTransit(
+    return create(
       {
         path: todaysPath, parentPath, wip: true, origin: Origin.View,
       },
-      { shouldCheckPageExists: true },
     );
-  }, [createAndTransit, isCreatable, todaysPath, parentPath]);
+  }, [create, isCreatable, todaysPath, parentPath]);
 
   return {
     isCreating,

+ 27 - 19
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -11,7 +11,7 @@ import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 
-import { createPage } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
@@ -60,6 +60,7 @@ export const useNewPageInput = (): UseNewPageInput => {
   const Input: FC<TreeItemToolProps> = (props) => {
 
     const { t } = useTranslation();
+    const { create: createPage } = useCreatePage();
 
     const { itemNode, stateHandlers, isEnableActions } = props;
     const { page, children } = itemNode;
@@ -107,23 +108,30 @@ export const useNewPageInput = (): UseNewPageInput => {
       setShowInput(false);
 
       try {
-        await createPage({
-          path: newPagePath,
-          body: undefined,
-          // keep grant info undefined to inherit from parent
-          grant: undefined,
-          grantUserGroupIds: undefined,
-          origin: Origin.View,
-          wip: shouldCreateWipPage(newPagePath),
-        });
-
-        mutatePageTree();
-
-        if (!hasDescendants) {
-          stateHandlers?.setIsOpen(true);
-        }
-
-        toastSuccess(t('successfully_saved_the_page'));
+        await createPage(
+          {
+            path: newPagePath,
+            parentPath,
+            body: undefined,
+            // keep grant info undefined to inherit from parent
+            grant: undefined,
+            grantUserGroupIds: undefined,
+            origin: Origin.View,
+            wip: shouldCreateWipPage(newPagePath),
+          },
+          {
+            skipTransition: true,
+            onCreated: () => {
+              mutatePageTree();
+
+              if (!hasDescendants) {
+                stateHandlers?.setIsOpen(true);
+              }
+
+              toastSuccess(t('successfully_saved_the_page'));
+            },
+          },
+        );
       }
       catch (err) {
         toastError(err);
@@ -131,7 +139,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       finally {
         setProcessingSubmission(false);
       }
-    }, [cancel, hasDescendants, page.path, stateHandlers, t]);
+    }, [cancel, hasDescendants, page.path, stateHandlers, t, createPage]);
 
     const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
     const isInvalid = validationResult != null;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/index.ts

@@ -6,6 +6,7 @@ import {
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
@@ -645,7 +646,7 @@ module.exports = (crowi) => {
   router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
       const { user } = req;
-      const { path } = req.query;
+      const path = normalizePath(req.query.path);
       const pageGrantService = crowi.pageGrantService as IPageGrantService;
       try {
         const page = await Page.findByPath(path, true) ?? await Page.findNonEmptyClosestAncestor(path);

+ 0 - 2
apps/app/src/stores/modal.tsx

@@ -8,8 +8,6 @@ import type { SWRResponse } from 'swr';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
-import type { CreatePageAndTransitOpts } from '~/client/services/create-page';
-import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,

+ 57 - 0
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -358,6 +358,19 @@ describe('PageService page operations with non-public pages', () => {
         parent: rootPage._id,
         descendantCount: 0,
       },
+      {
+        path: '/mc6_top',
+        grant: Page.GRANT_USER_GROUP,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+        grantedGroups: [
+          { item: groupIdIsolate, type: GroupType.userGroup },
+          { item: groupIdB, type: GroupType.userGroup },
+        ],
+      },
     ]);
 
     /**
@@ -958,6 +971,50 @@ describe('PageService page operations with non-public pages', () => {
         expect(isGrantNormalizedSpy).toBeCalledTimes(1);
       });
     });
+    describe('Creating a page under a page with grant USER_GROUP', () => {
+      describe('When onlyInheritUserRelatedGrantedGroups is true', () => {
+        test('Only user related groups should be inherited', async() => {
+          const pathT = '/mc6_top';
+          const pageT = await Page.findOne({ path: pathT });
+          expect(pageT).toBeTruthy();
+
+          const pathN = '/mc6_top/onlyRelatedGroupsInherited'; // path to create
+          await create(pathN, 'new body', npDummyUser1, { grant: Page.GRANT_USER_GROUP, onlyInheritUserRelatedGrantedGroups: true });
+
+          const _pageT = await Page.findOne({ path: pathT });
+          const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_USER_GROUP }); // newly crated
+          expect(_pageT).toBeTruthy();
+          expect(_pageN).toBeTruthy();
+          expect(_pageN.parent).toStrictEqual(_pageT._id);
+          expect(_pageT.descendantCount).toStrictEqual(1);
+          expect(normalizeGrantedGroups(_pageN.grantedGroups)).toStrictEqual([
+            { item: groupIdIsolate, type: GroupType.userGroup },
+          ]);
+        });
+      });
+
+      describe('When onlyInheritUserRelatedGrantedGroups is false', () => {
+        test('All groups should be inherited', async() => {
+          const pathT = '/mc6_top';
+          const pageT = await Page.findOne({ path: pathT });
+          expect(pageT).toBeTruthy();
+
+          const pathN = '/mc6_top/allGroupsInherited'; // path to create
+          await create(pathN, 'new body', npDummyUser1, { grant: Page.GRANT_USER_GROUP, onlyInheritUserRelatedGrantedGroups: false });
+
+          const _pageT = await Page.findOne({ path: pathT });
+          const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_USER_GROUP }); // newly crated
+          expect(_pageT).toBeTruthy();
+          expect(_pageN).toBeTruthy();
+          expect(_pageN.parent).toStrictEqual(_pageT._id);
+          expect(_pageT.descendantCount).toStrictEqual(2);
+          expect(normalizeGrantedGroups(_pageN.grantedGroups)).toStrictEqual([
+            { item: groupIdIsolate, type: GroupType.userGroup },
+            { item: groupIdB, type: GroupType.userGroup },
+          ]);
+        });
+      });
+    });
 
   });