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

Merge branch 'dev/7.4.x' into fix/175827-omit-file-upload-restriction-feature-for-non-image-files

Shun Miyazawa 3 месяцев назад
Родитель
Сommit
65d7d65f22
92 измененных файлов с 2944 добавлено и 1692 удалено
  1. 2 1
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  2. 1 1
      .serena/memories/page-transition-and-rendering-flow.md
  3. 5 0
      apps/app/.eslintrc.js
  4. 1 0
      apps/app/config/logger/config.dev.js
  5. 46 21
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  6. 54 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  7. 19 14
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  8. 16 23
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  9. 0 4
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  10. 4 0
      apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx
  11. 36 40
      apps/app/src/client/components/PageControls/PageControls.tsx
  12. 1 1
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  13. 1 1
      apps/app/src/client/interfaces/clearable.ts
  14. 1 1
      apps/app/src/client/interfaces/focusable.ts
  15. 8 9
      apps/app/src/client/interfaces/global-notification.ts
  16. 3 3
      apps/app/src/client/interfaces/handsontable-modal.ts
  17. 1 1
      apps/app/src/client/interfaces/in-app-notification-openable.ts
  18. 4 4
      apps/app/src/client/interfaces/notification.ts
  19. 11 11
      apps/app/src/client/interfaces/react-bootstrap-typeahead.ts
  20. 5 5
      apps/app/src/client/interfaces/selectable-all.ts
  21. 11 8
      apps/app/src/client/models/BootstrapGrid.js
  22. 8 4
      apps/app/src/client/models/HotkeyStroke.js
  23. 0 5
      apps/app/src/client/services/AdminAppContainer.js
  24. 26 27
      apps/app/src/client/services/AdminCustomizeContainer.js
  25. 9 10
      apps/app/src/client/services/AdminExternalAccountsContainer.js
  26. 123 70
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  27. 27 18
      apps/app/src/client/services/AdminGitHubSecurityContainer.js
  28. 27 20
      apps/app/src/client/services/AdminGoogleSecurityContainer.js
  29. 4 8
      apps/app/src/client/services/AdminHomeContainer.js
  30. 0 2
      apps/app/src/client/services/AdminImportContainer.js
  31. 41 36
      apps/app/src/client/services/AdminLdapSecurityContainer.js
  32. 28 23
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  33. 8 9
      apps/app/src/client/services/AdminMarkDownContainer.js
  34. 49 24
      apps/app/src/client/services/AdminNotificationContainer.js
  35. 67 52
      apps/app/src/client/services/AdminOidcSecurityContainer.js
  36. 51 38
      apps/app/src/client/services/AdminSamlSecurityContainer.js
  37. 5 5
      apps/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  38. 0 1
      apps/app/src/client/services/AdminSocketIoContainer.js
  39. 20 16
      apps/app/src/client/services/AdminUsersContainer.js
  40. 7 2
      apps/app/src/client/services/create-page/create-page.ts
  41. 95 81
      apps/app/src/client/services/create-page/use-create-page.tsx
  42. 21 18
      apps/app/src/client/services/create-page/use-create-template-page.ts
  43. 8 3
      apps/app/src/client/services/g2g-transfer.ts
  44. 0 1
      apps/app/src/client/services/maintenance-mode.ts
  45. 91 49
      apps/app/src/client/services/page-operation.ts
  46. 111 83
      apps/app/src/client/services/renderer/renderer.tsx
  47. 107 62
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  48. 117 64
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  49. 4 6
      apps/app/src/client/services/side-effects/hash-changed.ts
  50. 65 41
      apps/app/src/client/services/side-effects/page-updated.ts
  51. 1 1
      apps/app/src/client/services/side-effects/use-sticky.ts
  52. 3 2
      apps/app/src/client/services/update-page/conflict.tsx
  53. 7 2
      apps/app/src/client/services/update-page/update-page.ts
  54. 16 10
      apps/app/src/client/services/update-page/use-update-page.tsx
  55. 25 12
      apps/app/src/client/services/upload-attachments/upload-attachments.ts
  56. 4 4
      apps/app/src/client/services/use-print-mode.ts
  57. 23 22
      apps/app/src/client/services/use-start-editing.tsx
  58. 13 10
      apps/app/src/client/services/use-toastr-on-error.tsx
  59. 16 5
      apps/app/src/client/services/user-ui-settings.ts
  60. 26 9
      apps/app/src/client/util/apiv1-client.ts
  61. 29 10
      apps/app/src/client/util/apiv3-client.ts
  62. 48 12
      apps/app/src/client/util/bookmark-utils.ts
  63. 14 7
      apps/app/src/client/util/scope-util.test.ts
  64. 26 16
      apps/app/src/client/util/scope-util.ts
  65. 10 9
      apps/app/src/client/util/t-with-opt.ts
  66. 13 5
      apps/app/src/client/util/toastr.ts
  67. 36 30
      apps/app/src/client/util/use-input-validator.ts
  68. 1 1
      apps/app/src/features/collaborative-editor/side-effects/index.ts
  69. 8 2
      apps/app/src/interfaces/bookmark-info.ts
  70. 11 15
      apps/app/src/pages/[[...path]]/page-data-props.ts
  71. 36 2
      apps/app/src/pages/[[...path]]/server-side-props.ts
  72. 0 122
      apps/app/src/server/models/bookmark.js
  73. 151 0
      apps/app/src/server/models/bookmark.ts
  74. 52 21
      apps/app/src/server/routes/apiv3/bookmarks.ts
  75. 24 15
      apps/app/src/server/routes/apiv3/page-listing.ts
  76. 11 18
      apps/app/src/server/routes/apiv3/page/index.ts
  77. 42 21
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  78. 36 25
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  79. 442 174
      apps/app/src/server/service/page/index.ts
  80. 180 42
      apps/app/src/server/service/page/page-service.ts
  81. 3 1
      apps/app/src/server/service/page/should-use-v4-process.ts
  82. 25 14
      apps/app/src/states/page/hooks.ts
  83. 9 9
      apps/app/src/states/page/hydrate.ts
  84. 6 4
      apps/app/src/states/page/internal-atoms.ts
  85. 170 26
      apps/app/src/states/page/use-fetch-current-page.spec.tsx
  86. 27 14
      apps/app/src/states/page/use-fetch-current-page.ts
  87. 1 1
      apps/app/src/states/socket-io/global-socket.ts
  88. 12 40
      apps/app/src/states/ui/page-abilities.ts
  89. 16 23
      apps/app/src/states/ui/sidebar/hydrate.ts
  90. 3 2
      apps/app/src/stores/bookmark.ts
  91. 1 4
      biome.json
  92. 18 2
      packages/core/src/interfaces/page.ts

+ 2 - 1
.serena/memories/page-state-hooks-useLatestRevision-degradation.md

@@ -305,7 +305,8 @@ export const useFetchCurrentPage = () => {
       const { page: newData } = data;
       const { page: newData } = data;
 
 
       set(currentPageDataAtom, newData);
       set(currentPageDataAtom, newData);
-      set(currentPageIdAtom, newData._id);
+      set(currentPageEntityIdAtom, newData._id);
+      set(currentPageEmptyIdAtom, undefined);
 
 
       // ✅ 追加: PageInfo を再フェッチ
       // ✅ 追加: PageInfo を再フェッチ
       mutatePageInfo();  // 引数なし = revalidate (再フェッチ)
       mutatePageInfo();  // 引数なし = revalidate (再フェッチ)

+ 1 - 1
.serena/memories/page-transition-and-rendering-flow.md

@@ -53,7 +53,7 @@
     - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
     - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
 4.  **アトミックな状態更新**:
 4.  **アトミックな状態更新**:
     - **API成功時**:
     - **API成功時**:
-        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
+        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
         - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
         - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
     - **APIエラー時 (例: 404 Not Found)**:
     - **APIエラー時 (例: 404 Not Found)**:
         - `pageErrorAtom` にエラーオブジェクトを設定します。
         - `pageErrorAtom` にエラーオブジェクトを設定します。

+ 5 - 0
apps/app/.eslintrc.js

@@ -85,6 +85,11 @@ module.exports = {
     'src/server/service/in-app-notification/**',
     'src/server/service/in-app-notification/**',
     'src/server/service/interfaces/**',
     'src/server/service/interfaces/**',
     'src/server/service/normalize-data/**',
     'src/server/service/normalize-data/**',
+    'src/server/service/page/**',
+    'src/client/interfaces/**',
+    'src/client/models/**',
+    'src/client/services/**',
+    'src/client/util/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 1 - 0
apps/app/config/logger/config.dev.js

@@ -15,6 +15,7 @@ module.exports = {
   'growi:routes:login': 'debug',
   'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
   'growi:middleware:safe-redirect': 'debug',
+  'growi:services:page': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
   'growi:service:yjs': 'debug',
   'growi:service:yjs': 'debug',

+ 46 - 21
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,8 +1,11 @@
-import React, { useCallback, useState, type JSX } from 'react';
+import React, {
+  useCallback, useMemo, useState, type JSX,
+} from 'react';
 
 
 import nodePath from 'path';
 import nodePath from 'path';
 
 
 import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -59,17 +62,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     ...bookmarkedPage, parentFolder,
     ...bookmarkedPage, parentFolder,
   };
   };
 
 
+  const bookmarkedPageId = bookmarkedPage?._id;
+  const bookmarkedPagePath = bookmarkedPage?.path;
+  const bookmarkedPageRevision = bookmarkedPage?.revision;
+
   const onClickMoveToRootHandler = useCallback(async() => {
   const onClickMoveToRootHandler = useCallback(async() => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
     try {
     try {
-      await addBookmarkToFolder(bookmarkedPage._id, null);
+      await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkFolderTreeMutation, bookmarkedPage]);
+  }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
 
   const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
   const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
     if (shouldBookmark) {
     if (shouldBookmark) {
@@ -91,23 +98,23 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   }, []);
   }, []);
 
 
   const rename = useCallback(async(inputText: string) => {
   const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
 
 
     if (inputText.trim() === '') {
     if (inputText.trim() === '') {
       return cancel();
       return cancel();
     }
     }
 
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText.trim());
     const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPage.path) {
+    if (newPagePath === bookmarkedPagePath) {
       setRenameInputShown(false);
       setRenameInputShown(false);
       return;
       return;
     }
     }
 
 
     try {
     try {
       setRenameInputShown(false);
       setRenameInputShown(false);
-      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
       mutatePageInfo();
       mutatePageInfo();
     }
     }
@@ -115,26 +122,26 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       setRenameInputShown(true);
       toastError(err);
       toastError(err);
     }
     }
-  }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
-    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
       throw Error('_id and path must not be null.');
       throw Error('_id and path must not be null.');
     }
     }
 
 
     const pageToDelete: IPageToDeleteWithMeta = {
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
       data: {
-        _id: bookmarkedPage._id,
-        revision: bookmarkedPage.revision as string,
-        path: bookmarkedPage.path,
+        _id: bookmarkedPageId,
+        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
+        path: bookmarkedPagePath,
       },
       },
       meta: pageInfo,
       meta: pageInfo,
     };
     };
 
 
     onClickDeleteMenuItemHandler(pageToDelete);
     onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPage, onClickDeleteMenuItemHandler]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
 
 
   const putBackClickHandler = useCallback(() => {
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
     if (bookmarkedPage == null) return;
@@ -156,15 +163,33 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
   }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
 
 
+  const {
+    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
+  } = useMemo(() => {
+    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+    if (bookmarkedPagePath == null) {
+      return {
+        pageTitle: '',
+        formerPagePath: '',
+        isFormerRoot: false,
+        bookmarkItemId,
+      };
+    }
+
+    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
+    return {
+      pageTitle: dPagePath.latter,
+      formerPagePath: dPagePath.former,
+      isFormerRoot: dPagePath.isFormerRoot,
+      bookmarkItemId,
+    };
+  }, [bookmarkedPagePath, bookmarkedPageId]);
+
   if (bookmarkedPage == null) {
   if (bookmarkedPage == null) {
     return <></>;
     return <></>;
   }
   }
 
 
-  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
-  const { latter: pageTitle, former: formerPagePath } = dPagePath;
-
-  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-
   return (
   return (
     <DragAndDropWrapper
     <DragAndDropWrapper
       item={dragItem}
       item={dragItem}
@@ -215,7 +240,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           target={bookmarkItemId}
           fade={false}
           fade={false}
         >
         >
-          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
+          {isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       </li>
       </li>
     </DragAndDropWrapper>
     </DragAndDropWrapper>

+ 54 - 2
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,4 +1,4 @@
-import { type IPageInfoForOperation } from '@growi/core/dist/interfaces';
+import { type IPageInfoForOperation, type IPageInfoForEmpty } from '@growi/core/dist/interfaces';
 import {
 import {
   fireEvent, screen, within,
   fireEvent, screen, within,
 } from '@testing-library/dom';
 } from '@testing-library/dom';
@@ -8,14 +8,16 @@ import { mock } from 'vitest-mock-extended';
 import { PageItemControl } from './PageItemControl';
 import { PageItemControl } from './PageItemControl';
 
 
 
 
-// mock for isIPageInfoForOperation
+// mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 
 const mocks = vi.hoisted(() => ({
 const mocks = vi.hoisted(() => ({
   isIPageInfoForOperationMock: vi.fn(),
   isIPageInfoForOperationMock: vi.fn(),
+  isIPageInfoForEmptyMock: vi.fn(),
 }));
 }));
 
 
 vi.mock('@growi/core/dist/interfaces', () => ({
 vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForOperation: mocks.isIPageInfoForOperationMock,
   isIPageInfoForOperation: mocks.isIPageInfoForOperationMock,
+  isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 }));
 
 
 
 
@@ -32,6 +34,8 @@ describe('PageItemControl.tsx', () => {
           return true;
           return true;
         }
         }
       });
       });
+      // return false for isIPageInfoForEmpty since we're using IPageInfoForOperation
+      mocks.isIPageInfoForEmptyMock.mockReturnValue(false);
 
 
       const props = {
       const props = {
         pageId: 'dummy-page-id',
         pageId: 'dummy-page-id',
@@ -51,5 +55,53 @@ describe('PageItemControl.tsx', () => {
       // then
       // then
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
     });
+
+    it('with empty page (IPageInfoForEmpty)', async() => {
+      // setup - Create an empty page mock with required properties
+      const pageInfo: IPageInfoForEmpty = {
+        emptyPageId: 'empty-page-id',
+        isNotFound: false,
+        isEmpty: true,
+        isV5Compatible: true,
+        isMovable: true, // Allow rename operation
+        isDeletable: true,
+        isAbleToDeleteCompletely: false,
+        isRevertible: false,
+        bookmarkCount: 0,
+        isBookmarked: false,
+      };
+
+      const onClickRenameMenuItemMock = vi.fn();
+
+      // return false for isIPageInfoForOperation since this is an empty page
+      mocks.isIPageInfoForOperationMock.mockReturnValue(false);
+
+      // return true when the argument is pageInfo (empty page)
+      mocks.isIPageInfoForEmptyMock.mockImplementation((arg) => {
+        if (arg === pageInfo) {
+          return true;
+        }
+        return false;
+      });
+
+      const props = {
+        pageId: 'dummy-page-id',
+        isEnableActions: true,
+        pageInfo,
+        onClickRenameMenuItem: onClickRenameMenuItemMock,
+      };
+
+      render(<PageItemControl {...props} />);
+
+      // when
+      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      fireEvent.click(button);
+      const renameMenuItem = await screen.findByTestId('rename-page-btn');
+      fireEvent.click(renameMenuItem);
+
+      // then
+      expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith('dummy-page-id', pageInfo);
+    });
   });
   });
 });
 });

+ 19 - 14
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import {
 import {
-  type IPageInfoExt, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -76,21 +76,24 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
   const bookmarkItemClickedHandler = useCallback(async() => {
-    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+    if (onClickBookmarkMenuItem == null) return;
+
+    if (!isIPageInfoForEmpty(pageInfo) && !isIPageInfoForOperation(pageInfo)) {
       return;
       return;
     }
     }
+
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
   const renameItemClickedHandler = useCallback(async() => {
-    if (onClickRenameMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isMovable) {
+    if (onClickRenameMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       logger.warn('This page could not be renamed.');
       return;
       return;
     }
     }
+
     await onClickRenameMenuItem(pageId, pageInfo);
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
 
@@ -111,10 +114,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
   const deleteItemClickedHandler = useCallback(async() => {
-    if (pageInfo == null || onClickDeleteMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isDeletable) {
+    if (onClickDeleteMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       logger.warn('This page could not be deleted.');
       return;
       return;
     }
     }
@@ -173,7 +175,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
         ) }
 
 
         {/* Bookmark */}
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
           <DropdownItem
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
@@ -186,7 +188,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* Move/Rename */}
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isMovable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isMovable && (
           <DropdownItem
           <DropdownItem
             onClick={renameItemClickedHandler}
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
             data-testid="rename-page-btn"
@@ -211,7 +214,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* Revert */}
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isRevertible && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isRevertible && (
           <DropdownItem
           <DropdownItem
             onClick={revertItemClickedHandler}
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
@@ -242,7 +246,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         {/* divider */}
         {/* divider */}
         {/* Delete */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isDeletable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isDeletable && (
           <>
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
             <DropdownItem

+ 16 - 23
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -270,7 +270,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
 
 
   const { editorMode } = useEditorMode();
   const { editorMode } = useEditorMode();
-  const pageId = useCurrentPageId();
+  const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -291,10 +291,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const [isStickyActive, setStickyActive] = useState(false);
   const [isStickyActive, setStickyActive] = useState(false);
 
 
-
   const path = currentPage?.path ?? currentPathname;
   const path = currentPage?.path ?? currentPathname;
-  // const grant = currentPage?.grant ?? grantData?.grant;
-  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
 
@@ -405,31 +402,27 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             id="grw-contextual-sub-nav"
             id="grw-contextual-sub-nav"
           >
           >
 
 
-            {pageId != null && (
-              <PageControls
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path ?? currentPathname} // If the page is empty, "path" is undefined
-                expandContentWidth={shouldExpandContent}
-                disableSeenUserInfoPopover={isSharedUser}
-                hideSubControls={hideSubControls}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-                onClickSwitchContentWidth={switchContentWidthHandler}
-              />
-            )}
+            <PageControls
+              pageId={pageId}
+              revisionId={revisionId}
+              shareLinkId={shareLinkId}
+              path={path ?? currentPathname} // If the page is empty, "path" is undefined
+              expandContentWidth={shouldExpandContent}
+              disableSeenUserInfoPopover={isSharedUser}
+              hideSubControls={hideSubControls}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={additionalMenuItemsRenderer}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
+              onClickSwitchContentWidth={switchContentWidthHandler}
+            />
 
 
             {isAbleToChangeEditorMode && (
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
               <PageEditorModeManager
                 editorMode={editorMode}
                 editorMode={editorMode}
                 isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 path={path}
                 path={path}
-              // grant={grant}
-              // grantUserGroupId={grantUserGroupId}
               />
               />
             )}
             )}
 
 

+ 0 - 4
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -18,8 +18,6 @@ import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 
 
-import { useAutoOpenModalByQueryParam } from './hooks';
-
 import styles from './PageAccessoriesModal.module.scss';
 import styles from './PageAccessoriesModal.module.scss';
 
 
 
 
@@ -45,8 +43,6 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
   const status = usePageAccessoriesModalStatus();
   const status = usePageAccessoriesModalStatus();
   const { close, selectContents } = usePageAccessoriesModalActions();
   const { close, selectContents } = usePageAccessoriesModalActions();
 
 
-  useAutoOpenModalByQueryParam();
-
   // Memoize heavy navTabMapping calculation
   // Memoize heavy navTabMapping calculation
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {

+ 4 - 0
apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx

@@ -3,11 +3,15 @@ import type { JSX } from 'react';
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
 import { usePageAccessoriesModalStatus } from '~/states/ui/modal/page-accessories';
 import { usePageAccessoriesModalStatus } from '~/states/ui/modal/page-accessories';
 
 
+import { useAutoOpenModalByQueryParam } from './hooks';
+
 type PageAccessoriesModalProps = Record<string, unknown>;
 type PageAccessoriesModalProps = Record<string, unknown>;
 
 
 export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
 export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
   const status = usePageAccessoriesModalStatus();
   const status = usePageAccessoriesModalStatus();
 
 
+  useAutoOpenModalByQueryParam();
+
   const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
   const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
     'page-accessories-modal',
     'page-accessories-modal',
     () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),
     () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),

+ 36 - 40
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -3,9 +3,11 @@ import React, {
 } from 'react';
 } from 'react';
 
 
 import type {
 import type {
-  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
 } from '@growi/core';
 } from '@growi/core';
 import {
 import {
+  isIPageInfoForEmpty,
+
   isIPageInfoForEntity, isIPageInfoForOperation,
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -104,7 +106,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 
 
 type CommonProps = {
 type CommonProps = {
-  pageId: string,
+  pageId?: string,
   shareLinkId?: string | null,
   shareLinkId?: string | null,
   revisionId?: string | null,
   revisionId?: string | null,
   path?: string | null,
   path?: string | null,
@@ -121,7 +123,7 @@ type CommonProps = {
 }
 }
 
 
 type PageControlsSubstanceProps = CommonProps & {
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfoForOperation,
+  pageInfo: IPageInfo | undefined,
   onClickEditTagsButton: () => void,
   onClickEditTagsButton: () => void,
 }
 }
 
 
@@ -167,10 +169,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
 
   const subscribeClickhandler = useCallback(async () => {
   const subscribeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot subscribe to pages');
       return;
       return;
     }
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
       return;
     }
     }
 
 
@@ -179,10 +183,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
 
   const likeClickhandler = useCallback(async () => {
   const likeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot like pages');
       return;
       return;
     }
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
       return;
     }
     }
 
 
@@ -191,7 +197,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDuplicateMenuItem == null || path == null) {
+    if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
       return;
       return;
     }
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -200,7 +207,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDuplicateMenuItem, pageId, path]);
   }, [onClickDuplicateMenuItem, pageId, path]);
 
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickRenameMenuItem == null || path == null) {
+    if (onClickRenameMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
       return;
       return;
     }
     }
 
 
@@ -217,7 +225,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDeleteMenuItem == null || path == null) {
+    if (onClickDeleteMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
       return;
       return;
     }
     }
 
 
@@ -234,22 +243,22 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
 
   const switchContentWidthClickHandler = useCallback(() => {
   const switchContentWidthClickHandler = useCallback(() => {
-    if (onClickSwitchContentWidth == null) {
+    if (isGuestUser || isReadOnlyUser) {
+      logger.warn('Guest or read-only users cannot switch content width');
       return;
       return;
     }
     }
 
 
-    const newValue = !expandContentWidth;
-    if ((isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
-      logger.warn('Could not switch content width', {
-        isGuestUser,
-        isReadOnlyUser,
-      });
+    if (onClickSwitchContentWidth == null || pageId == null) {
+      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
       return;
       return;
     }
     }
     if (!isIPageInfoForEntity(pageInfo)) {
     if (!isIPageInfoForEntity(pageInfo)) {
+      logger.warn('PageInfo is not for entity');
       return;
       return;
     }
     }
+
     try {
     try {
+      const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
       onClickSwitchContentWidth(pageId, newValue);
     }
     }
     catch (err) {
     catch (err) {
@@ -287,21 +296,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     return wideviewMenuItemRenderer;
     return wideviewMenuItemRenderer;
   }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
   }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
 
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
-  const {
-    sumOfLikers, sumOfSeenUsers, isLiked,
-  } = pageInfo;
-
   const forceHideMenuItemsWithAdditions = [
   const forceHideMenuItemsWithAdditions = [
     ...(forceHideMenuItems ?? []),
     ...(forceHideMenuItems ?? []),
     MenuItemType.BOOKMARK,
     MenuItemType.BOOKMARK,
     MenuItemType.REVERT,
     MenuItemType.REVERT,
   ];
   ];
 
 
-  const _isIPageInfoForOperation = isIPageInfoForOperation(pageInfo);
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
 
 
   return (
   return (
@@ -313,7 +313,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         </>
         </>
       )}
       )}
 
 
-      {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
+      {revisionId != null && !isViewMode && (
         <Tags
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
           onClickEditTagsButton={onClickEditTagsButton}
         />
         />
@@ -321,38 +321,38 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
 
       {!hideSubControls && (
       {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <SubscribeButton
             <SubscribeButton
               status={pageInfo.subscriptionStatus}
               status={pageInfo.subscriptionStatus}
               onClick={subscribeClickhandler}
               onClick={subscribeClickhandler}
             />
             />
           )}
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <LikeButtons
             <LikeButtons
               onLikeClicked={likeClickhandler}
               onLikeClicked={likeClickhandler}
-              sumOfLikers={sumOfLikers}
-              isLiked={isLiked}
+              sumOfLikers={pageInfo.sumOfLikers}
+              isLiked={pageInfo.isLiked}
               likers={likers}
               likers={likers}
             />
             />
           )}
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
             <BookmarkButtons
             <BookmarkButtons
               pageId={pageId}
               pageId={pageId}
               isBookmarked={pageInfo.isBookmarked}
               isBookmarked={pageInfo.isBookmarked}
               bookmarkCount={pageInfo.bookmarkCount}
               bookmarkCount={pageInfo.bookmarkCount}
             />
             />
           )}
           )}
-          {revisionId != null && !isSearchPage && (
+          {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
             <SeenUserInfo
               seenUsers={seenUsers}
               seenUsers={seenUsers}
-              sumOfSeenUsers={sumOfSeenUsers}
+              sumOfSeenUsers={pageInfo.sumOfSeenUsers}
               disabled={disableSeenUserInfoPopover}
               disabled={disableSeenUserInfoPopover}
             />
             />
           )}
           )}
         </div>
         </div>
       )}
       )}
 
 
-      {showPageControlDropdown && _isIPageInfoForOperation && (
+      {showPageControlDropdown && (
         <PageItemControl
         <PageItemControl
           pageId={pageId}
           pageId={pageId}
           pageInfo={pageInfo}
           pageInfo={pageInfo}
@@ -383,7 +383,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const { open: openTagEditModal } = useTagEditModalActions();
   const { open: openTagEditModal } = useTagEditModalActions();
 
 
   const onClickEditTagsButton = useCallback(() => {
   const onClickEditTagsButton = useCallback(() => {
-    if (tagsInfoData == null || revisionId == null) {
+    if (tagsInfoData == null || pageId == null || revisionId == null) {
       return;
       return;
     }
     }
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
@@ -393,10 +393,6 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
     return <></>;
     return <></>;
   }
   }
 
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
   return (
   return (
     <PageControlsSubstance
     <PageControlsSubstance
       pageInfo={pageInfo}
       pageInfo={pageInfo}

+ 1 - 1
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -9,11 +9,11 @@ import { DropdownToggle } from 'reactstrap';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 
 
 
 
 type UsePageItemControl = {
 type UsePageItemControl = {

+ 1 - 1
apps/app/src/client/interfaces/clearable.ts

@@ -1,3 +1,3 @@
 export interface IClearable {
 export interface IClearable {
-  clear: () => void,
+  clear: () => void;
 }
 }

+ 1 - 1
apps/app/src/client/interfaces/focusable.ts

@@ -1,3 +1,3 @@
 export interface IFocusable {
 export interface IFocusable {
-  focus: () => void,
+  focus: () => void;
 }
 }

+ 8 - 9
apps/app/src/client/interfaces/global-notification.ts

@@ -3,8 +3,7 @@ export const NotifyType = {
   SLACK: 'slack',
   SLACK: 'slack',
 } as const;
 } as const;
 
 
-export type NotifyType = typeof NotifyType[keyof typeof NotifyType]
-
+export type NotifyType = (typeof NotifyType)[keyof typeof NotifyType];
 
 
 export const TriggerEventType = {
 export const TriggerEventType = {
   CREATE: 'pageCreate',
   CREATE: 'pageCreate',
@@ -15,13 +14,13 @@ export const TriggerEventType = {
   POST: 'comment',
   POST: 'comment',
 } as const;
 } as const;
 
 
-type TriggerEventType = typeof TriggerEventType[keyof typeof TriggerEventType]
-
+type TriggerEventType =
+  (typeof TriggerEventType)[keyof typeof TriggerEventType];
 
 
 export type IGlobalNotification = {
 export type IGlobalNotification = {
-  triggerPath: string,
-  notifyType: NotifyType,
-  emailToSend: string,
-  slackChannelToSend: string,
-  triggerEvents: TriggerEventType[],
+  triggerPath: string;
+  notifyType: NotifyType;
+  emailToSend: string;
+  slackChannelToSend: string;
+  triggerEvents: TriggerEventType[];
 };
 };

+ 3 - 3
apps/app/src/client/interfaces/handsontable-modal.ts

@@ -1,4 +1,4 @@
 export type LaunchHandsonTableModalEventDetail = {
 export type LaunchHandsonTableModalEventDetail = {
-  bol: number,
-  eol: number,
-}
+  bol: number;
+  eol: number;
+};

+ 1 - 1
apps/app/src/client/interfaces/in-app-notification-openable.ts

@@ -1,3 +1,3 @@
 export interface IInAppNotificationOpenable {
 export interface IInAppNotificationOpenable {
-  open: () => void,
+  open: () => void;
 }
 }

+ 4 - 4
apps/app/src/client/interfaces/notification.ts

@@ -1,8 +1,8 @@
 import type { NotifyType } from './global-notification';
 import type { NotifyType } from './global-notification';
 
 
 export type INotificationType = {
 export type INotificationType = {
-  __t?: NotifyType
-  _id: string
+  __t?: NotifyType;
+  _id: string;
   // TOOD: Define the provider type
   // TOOD: Define the provider type
-  provider?: any
-}
+  provider?: any;
+};

+ 11 - 11
apps/app/src/client/interfaces/react-bootstrap-typeahead.ts

@@ -1,15 +1,15 @@
 // https://github.com/ericgio/react-bootstrap-typeahead/blob/5.x/docs/API.md
 // https://github.com/ericgio/react-bootstrap-typeahead/blob/5.x/docs/API.md
 export type TypeaheadProps = {
 export type TypeaheadProps = {
-  dropup?: boolean,
-  emptyLabel?: string,
-  placeholder?: string,
-  autoFocus?: boolean,
-  inputProps?: unknown,
+  dropup?: boolean;
+  emptyLabel?: string;
+  placeholder?: string;
+  autoFocus?: boolean;
+  inputProps?: unknown;
 
 
-  onChange?: (data: unknown[]) => void,
-  onBlur?: () => void,
-  onFocus?: () => void,
-  onSearch?: (text: string) => void,
-  onInputChange?: (text: string) => void,
-  onKeyDown?: (input: string) => void,
+  onChange?: (data: unknown[]) => void;
+  onBlur?: () => void;
+  onFocus?: () => void;
+  onSearch?: (text: string) => void;
+  onInputChange?: (text: string) => void;
+  onKeyDown?: (input: string) => void;
 };
 };

+ 5 - 5
apps/app/src/client/interfaces/selectable-all.ts

@@ -1,13 +1,13 @@
 export interface ISelectable {
 export interface ISelectable {
-  select: () => void,
-  deselect: () => void,
+  select: () => void;
+  deselect: () => void;
 }
 }
 
 
 export interface ISelectableAndIndeterminatable extends ISelectable {
 export interface ISelectableAndIndeterminatable extends ISelectable {
-  setIndeterminate: () => void,
+  setIndeterminate: () => void;
 }
 }
 
 
 export interface ISelectableAll {
 export interface ISelectableAll {
-  selectAll: () => void,
-  deselectAll: () => void,
+  selectAll: () => void;
+  deselectAll: () => void;
 }
 }

+ 11 - 8
apps/app/src/client/models/BootstrapGrid.js

@@ -1,20 +1,22 @@
 export default class BootstrapGrid {
 export default class BootstrapGrid {
-
   constructor(colsRatios, responsiveSize) {
   constructor(colsRatios, responsiveSize) {
     this.colsRatios = BootstrapGrid.validateColsRatios(colsRatios);
     this.colsRatios = BootstrapGrid.validateColsRatios(colsRatios);
     this.responsiveSize = BootstrapGrid.validateResponsiveSize(responsiveSize);
     this.responsiveSize = BootstrapGrid.validateResponsiveSize(responsiveSize);
   }
   }
 
 
   static ResponsiveSize = {
   static ResponsiveSize = {
-    XS_SIZE: 'xs', SM_SIZE: 'sm', MD_SIZE: 'md',
+    XS_SIZE: 'xs',
+    SM_SIZE: 'sm',
+    MD_SIZE: 'md',
   };
   };
 
 
   static validateColsRatios(colsRatios) {
   static validateColsRatios(colsRatios) {
-
     if (colsRatios.length < 2 || colsRatios.length > 4) {
     if (colsRatios.length < 2 || colsRatios.length > 4) {
       throw new Error('Incorrect array length of cols ratios');
       throw new Error('Incorrect array length of cols ratios');
     }
     }
-    const ratiosTotal = colsRatios.reduce((total, ratio) => { return total + ratio }, 0);
+    const ratiosTotal = colsRatios.reduce((total, ratio) => {
+      return total + ratio;
+    }, 0);
     if (ratiosTotal !== 12) {
     if (ratiosTotal !== 12) {
       throw new Error('Incorrect cols ratios value');
       throw new Error('Incorrect cols ratios value');
     }
     }
@@ -23,12 +25,13 @@ export default class BootstrapGrid {
   }
   }
 
 
   static validateResponsiveSize(responsiveSize) {
   static validateResponsiveSize(responsiveSize) {
-    if (responsiveSize === this.ResponsiveSize.XS_SIZE
-      || responsiveSize === this.ResponsiveSize.SM_SIZE
-      || responsiveSize === this.ResponsiveSize.MD_SIZE) {
+    if (
+      responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE ||
+      responsiveSize === BootstrapGrid.ResponsiveSize.SM_SIZE ||
+      responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE
+    ) {
       return responsiveSize;
       return responsiveSize;
     }
     }
     throw new Error('Incorrect responsive size');
     throw new Error('Incorrect responsive size');
   }
   }
-
 }
 }

+ 8 - 4
apps/app/src/client/models/HotkeyStroke.js

@@ -3,7 +3,6 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:cli:HotkeyStroke');
 const logger = loggerFactory('growi:cli:HotkeyStroke');
 
 
 export default class HotkeyStroke {
 export default class HotkeyStroke {
-
   constructor(stroke) {
   constructor(stroke) {
     this.stroke = stroke;
     this.stroke = stroke;
     this.activeIndices = [];
     this.activeIndices = [];
@@ -42,16 +41,21 @@ export default class HotkeyStroke {
         return nextIndex;
         return nextIndex;
       })
       })
       // exclude null
       // exclude null
-      .filter(index => index != null);
+      .filter((index) => index != null);
 
 
     // reset if completed
     // reset if completed
     if (isCompleted) {
     if (isCompleted) {
       this.activeIndices = [];
       this.activeIndices = [];
     }
     }
 
 
-    logger.debug('activeIndices for [', this.stroke, '] => [', this.activeIndices, ']');
+    logger.debug(
+      'activeIndices for [',
+      this.stroke,
+      '] => [',
+      this.activeIndices,
+      ']',
+    );
 
 
     return isCompleted;
     return isCompleted;
   }
   }
-
 }
 }

+ 0 - 5
apps/app/src/client/services/AdminAppContainer.js

@@ -8,7 +8,6 @@ import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminAppContainer extends Container {
 export default class AdminAppContainer extends Container {
-
   constructor() {
   constructor() {
     super();
     super();
 
 
@@ -41,7 +40,6 @@ export default class AdminAppContainer extends Container {
 
 
       isMaintenanceMode: false,
       isMaintenanceMode: false,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -124,7 +122,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ siteUrl });
     this.setState({ siteUrl });
   }
   }
 
 
-
   /**
   /**
    * Change from address
    * Change from address
    */
    */
@@ -197,7 +194,6 @@ export default class AdminAppContainer extends Container {
     return appSettingParams;
     return appSettingParams;
   }
   }
 
 
-
   /**
   /**
    * Update site url setting
    * Update site url setting
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
@@ -284,5 +280,4 @@ export default class AdminAppContainer extends Container {
   async endMaintenanceMode() {
   async endMaintenanceMode() {
     await apiv3Post('/app-settings/maintenance-mode', { flag: false });
     await apiv3Post('/app-settings/maintenance-mode', { flag: false });
   }
   }
-
 }
 }

+ 26 - 27
apps/app/src/client/services/AdminCustomizeContainer.js

@@ -14,7 +14,6 @@ const logger = loggerFactory('growi:services:AdminCustomizeContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminCustomizeContainer extends Container {
 export default class AdminCustomizeContainer extends Container {
-
   constructor() {
   constructor() {
     super();
     super();
 
 
@@ -45,9 +44,9 @@ export default class AdminCustomizeContainer extends Container {
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
-    this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
+    this.switchPageListLimitationXL =
+      this.switchPageListLimitationXL.bind(this);
     this.switchShowPageSideAuthors = this.switchShowPageSideAuthors.bind(this);
     this.switchShowPageSideAuthors = this.switchShowPageSideAuthors.bind(this);
-
   }
   }
 
 
   /**
   /**
@@ -74,7 +73,8 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: customizeParams.pageLimitationXL,
         pageLimitationXL: customizeParams.pageLimitationXL,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         isAllReplyShown: customizeParams.isAllReplyShown,
         isAllReplyShown: customizeParams.isAllReplyShown,
-        isSearchScopeChildrenAsDefault: customizeParams.isSearchScopeChildrenAsDefault,
+        isSearchScopeChildrenAsDefault:
+          customizeParams.isSearchScopeChildrenAsDefault,
         isEnabledMarp: customizeParams.isEnabledMarp,
         isEnabledMarp: customizeParams.isEnabledMarp,
         currentCustomizeTitle: customizeParams.customizeTitle,
         currentCustomizeTitle: customizeParams.customizeTitle,
         currentCustomizeNoscript: customizeParams.customizeNoscript,
         currentCustomizeNoscript: customizeParams.customizeNoscript,
@@ -82,30 +82,29 @@ export default class AdminCustomizeContainer extends Container {
         currentCustomizeScript: customizeParams.customizeScript,
         currentCustomizeScript: customizeParams.customizeScript,
         showPageSideAuthors: customizeParams.showPageSideAuthors,
         showPageSideAuthors: customizeParams.showPageSideAuthors,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
     }
     }
   }
   }
 
 
-
   /**
   /**
    * Switch enabledTimeLine
    * Switch enabledTimeLine
    */
    */
   switchEnableTimeline() {
   switchEnableTimeline() {
-    this.setState({ isEnabledTimeline:  !this.state.isEnabledTimeline });
+    this.setState({ isEnabledTimeline: !this.state.isEnabledTimeline });
   }
   }
 
 
   /**
   /**
    * Switch enabledAttachTitleHeader
    * Switch enabledAttachTitleHeader
    */
    */
   switchEnabledAttachTitleHeader() {
   switchEnabledAttachTitleHeader() {
-    this.setState({ isEnabledAttachTitleHeader:  !this.state.isEnabledAttachTitleHeader });
+    this.setState({
+      isEnabledAttachTitleHeader: !this.state.isEnabledAttachTitleHeader,
+    });
   }
   }
 
 
-
   /**
   /**
    * S: Switch pageListLimitationS
    * S: Switch pageListLimitationS
    */
    */
@@ -138,7 +137,9 @@ export default class AdminCustomizeContainer extends Container {
    * Switch enabledStaleNotification
    * Switch enabledStaleNotification
    */
    */
   switchEnableStaleNotification() {
   switchEnableStaleNotification() {
-    this.setState({ isEnabledStaleNotification:  !this.state.isEnabledStaleNotification });
+    this.setState({
+      isEnabledStaleNotification: !this.state.isEnabledStaleNotification,
+    });
   }
   }
 
 
   /**
   /**
@@ -152,7 +153,10 @@ export default class AdminCustomizeContainer extends Container {
    * Switch isSearchScopeChildrenAsDefault
    * Switch isSearchScopeChildrenAsDefault
    */
    */
   switchIsSearchScopeChildrenAsDefault() {
   switchIsSearchScopeChildrenAsDefault() {
-    this.setState({ isSearchScopeChildrenAsDefault: !this.state.isSearchScopeChildrenAsDefault });
+    this.setState({
+      isSearchScopeChildrenAsDefault:
+        !this.state.isSearchScopeChildrenAsDefault,
+    });
   }
   }
 
 
   /**
   /**
@@ -212,7 +216,8 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: this.state.pageLimitationXL,
         pageLimitationXL: this.state.pageLimitationXL,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
         isAllReplyShown: this.state.isAllReplyShown,
-        isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
+        isSearchScopeChildrenAsDefault:
+          this.state.isSearchScopeChildrenAsDefault,
         showPageSideAuthors: this.state.showPageSideAuthors,
         showPageSideAuthors: this.state.showPageSideAuthors,
       });
       });
       const { customizedParams } = response.data;
       const { customizedParams } = response.data;
@@ -225,11 +230,11 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: customizedParams.pageLimitationXL,
         pageLimitationXL: customizedParams.pageLimitationXL,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
         isAllReplyShown: customizedParams.isAllReplyShown,
-        isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
+        isSearchScopeChildrenAsDefault:
+          customizedParams.isSearchScopeChildrenAsDefault,
         showPageSideAuthors: customizedParams.showPageSideAuthors,
         showPageSideAuthors: customizedParams.showPageSideAuthors,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to update data');
       throw new Error('Failed to update data');
     }
     }
@@ -248,8 +253,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
       this.setState({
         isEnabledMarp: customizedParams.isEnabledMarp,
         isEnabledMarp: customizedParams.isEnabledMarp,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to update data');
       throw new Error('Failed to update data');
     }
     }
@@ -268,8 +272,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
       this.setState({
         customizeTitle: customizedParams.customizeTitle,
         customizeTitle: customizedParams.customizeTitle,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to update data');
       throw new Error('Failed to update data');
     }
     }
@@ -284,8 +287,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
       this.setState({
         currentCustomizeNoscript: customizedParams.customizeNoscript,
         currentCustomizeNoscript: customizedParams.customizeNoscript,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to update data');
       throw new Error('Failed to update data');
     }
     }
@@ -304,8 +306,7 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
       this.setState({
         currentCustomizeCss: customizedParams.customizeCss,
         currentCustomizeCss: customizedParams.customizeCss,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to update data');
       throw new Error('Failed to update data');
     }
     }
@@ -325,11 +326,9 @@ export default class AdminCustomizeContainer extends Container {
       this.setState({
       this.setState({
         currentCustomizeScript: customizedParams.customizeScript,
         currentCustomizeScript: customizedParams.customizeScript,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to update data');
       throw new Error('Failed to update data');
     }
     }
   }
   }
-
 }
 }

+ 9 - 10
apps/app/src/client/services/AdminExternalAccountsContainer.js

@@ -5,7 +5,6 @@ import loggerFactory from '~/utils/logger';
 
 
 import { apiv3Delete, apiv3Get } from '../util/apiv3-client';
 import { apiv3Delete, apiv3Get } from '../util/apiv3-client';
 
 
-
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
 const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
 
 
@@ -14,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminexternalaccountsContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminExternalAccountsContainer extends Container {
 export default class AdminExternalAccountsContainer extends Container {
-
   constructor() {
   constructor() {
     super();
     super();
 
 
@@ -28,7 +26,6 @@ export default class AdminExternalAccountsContainer extends Container {
       activePage: 1,
       activePage: 1,
       pagingLimit: Infinity,
       pagingLimit: Infinity,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -38,28 +35,29 @@ export default class AdminExternalAccountsContainer extends Container {
     return 'AdminExternalAccountsContainer';
     return 'AdminExternalAccountsContainer';
   }
   }
 
 
-
   /**
   /**
    * syncExternalAccounts of selectedPage
    * syncExternalAccounts of selectedPage
    * @memberOf AdminExternalAccountsContainer
    * @memberOf AdminExternalAccountsContainer
    * @param {number} selectedPage
    * @param {number} selectedPage
    */
    */
   async retrieveExternalAccountsByPagingNum(selectedPage) {
   async retrieveExternalAccountsByPagingNum(selectedPage) {
-
     const params = { page: selectedPage };
     const params = { page: selectedPage };
     const { data } = await apiv3Get('/users/external-accounts', params);
     const { data } = await apiv3Get('/users/external-accounts', params);
 
 
     if (data.paginateResult == null) {
     if (data.paginateResult == null) {
-      throw new Error('data must conclude \'paginateResult\' property.');
+      throw new Error("data must conclude 'paginateResult' property.");
     }
     }
-    const { docs: externalAccounts, totalDocs: totalAccounts, limit: pagingLimit } = data.paginateResult;
+    const {
+      docs: externalAccounts,
+      totalDocs: totalAccounts,
+      limit: pagingLimit,
+    } = data.paginateResult;
     this.setState({
     this.setState({
       externalAccounts,
       externalAccounts,
       totalAccounts,
       totalAccounts,
       pagingLimit,
       pagingLimit,
       activePage: selectedPage,
       activePage: selectedPage,
     });
     });
-
   }
   }
 
 
   /**
   /**
@@ -69,10 +67,11 @@ export default class AdminExternalAccountsContainer extends Container {
    * @param {string} externalAccountId id of the External Account to be removed
    * @param {string} externalAccountId id of the External Account to be removed
    */
    */
   async removeExternalAccountById(externalAccountId) {
   async removeExternalAccountById(externalAccountId) {
-    const res = await apiv3Delete(`/users/external-accounts/${externalAccountId}/remove`);
+    const res = await apiv3Delete(
+      `/users/external-accounts/${externalAccountId}/remove`,
+    );
     const deletedUserData = res.data.externalAccount;
     const deletedUserData = res.data.externalAccount;
     await this.retrieveExternalAccountsByPagingNum(this.state.activePage);
     await this.retrieveExternalAccountsByPagingNum(this.state.activePage);
     return deletedUserData.accountId;
     return deletedUserData.accountId;
   }
   }
-
 }
 }

+ 123 - 70
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -2,8 +2,10 @@ import { isServer } from '@growi/core/dist/utils';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import {
 import {
-  PageSingleDeleteConfigValue, PageSingleDeleteCompConfigValue,
-  PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
+  PageRecursiveDeleteCompConfigValue,
+  PageRecursiveDeleteConfigValue,
+  PageSingleDeleteCompConfigValue,
+  PageSingleDeleteConfigValue,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 
@@ -15,7 +17,6 @@ import { toastError } from '../util/toastr';
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminGeneralSecurityContainer extends Container {
 export default class AdminGeneralSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -29,9 +30,12 @@ export default class AdminGeneralSecurityContainer extends Container {
       wikiMode: '',
       wikiMode: '',
       currentRestrictGuestMode: '',
       currentRestrictGuestMode: '',
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
-      currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
-      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
-      currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      currentPageRecursiveDeletionAuthority:
+        PageRecursiveDeleteConfigValue.Inherit,
+      currentPageCompleteDeletionAuthority:
+        PageSingleDeleteCompConfigValue.AdminOnly,
+      currentPageRecursiveCompleteDeletionAuthority:
+        PageRecursiveDeleteCompConfigValue.Inherit,
       currentGroupRestrictionDisplayMode: 'Hidden',
       currentGroupRestrictionDisplayMode: 'Hidden',
       currentOwnerRestrictionDisplayMode: 'Hidden',
       currentOwnerRestrictionDisplayMode: 'Hidden',
       isAllGroupMembershipRequiredForPageCompleteDeletion: true,
       isAllGroupMembershipRequiredForPageCompleteDeletion: true,
@@ -57,33 +61,49 @@ export default class AdminGeneralSecurityContainer extends Container {
       shareLinksActivePage: 1,
       shareLinksActivePage: 1,
     };
     };
 
 
-    this.changeOwnerRestrictionDisplayMode = this.changeOwnerRestrictionDisplayMode.bind(this);
-    this.changeGroupRestrictionDisplayMode = this.changeGroupRestrictionDisplayMode.bind(this);
-    this.changePageDeletionAuthority = this.changePageDeletionAuthority.bind(this);
-    this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
-    this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
-    this.changePageRecursiveCompleteDeletionAuthority = this.changePageRecursiveCompleteDeletionAuthority.bind(this);
-    this.changePreviousPageRecursiveDeletionAuthority = this.changePreviousPageRecursiveDeletionAuthority.bind(this);
-    this.changePreviousPageRecursiveCompleteDeletionAuthority = this.changePreviousPageRecursiveCompleteDeletionAuthority.bind(this);
-
+    this.changeOwnerRestrictionDisplayMode =
+      this.changeOwnerRestrictionDisplayMode.bind(this);
+    this.changeGroupRestrictionDisplayMode =
+      this.changeGroupRestrictionDisplayMode.bind(this);
+    this.changePageDeletionAuthority =
+      this.changePageDeletionAuthority.bind(this);
+    this.changePageCompleteDeletionAuthority =
+      this.changePageCompleteDeletionAuthority.bind(this);
+    this.changePageRecursiveDeletionAuthority =
+      this.changePageRecursiveDeletionAuthority.bind(this);
+    this.changePageRecursiveCompleteDeletionAuthority =
+      this.changePageRecursiveCompleteDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveDeletionAuthority =
+      this.changePreviousPageRecursiveDeletionAuthority.bind(this);
+    this.changePreviousPageRecursiveCompleteDeletionAuthority =
+      this.changePreviousPageRecursiveCompleteDeletionAuthority.bind(this);
   }
   }
 
 
   async retrieveSecurityData() {
   async retrieveSecurityData() {
     await this.retrieveSetupStratedies();
     await this.retrieveSetupStratedies();
     const response = await apiv3Get('/security-setting/');
     const response = await apiv3Get('/security-setting/');
-    const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
+    const { generalSetting, shareLinkSetting, generalAuth } =
+      response.data.securityParams;
     this.setState({
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentPageDeletionAuthority: generalSetting.pageDeletionAuthority,
       currentPageDeletionAuthority: generalSetting.pageDeletionAuthority,
-      currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
-      currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
-      currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
-      isAllGroupMembershipRequiredForPageCompleteDeletion: generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
+      currentPageCompleteDeletionAuthority:
+        generalSetting.pageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority:
+        generalSetting.pageRecursiveDeletionAuthority,
+      currentPageRecursiveCompleteDeletionAuthority:
+        generalSetting.pageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion:
+        generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
       // Set display to 'Hidden' if hideRestrictedByOwner is anything but false.
       // Set display to 'Hidden' if hideRestrictedByOwner is anything but false.
-      currentOwnerRestrictionDisplayMode: generalSetting.hideRestrictedByOwner === false ? 'Displayed' : 'Hidden',
-      currentGroupRestrictionDisplayMode: generalSetting.hideRestrictedByGroup === false ? 'Displayed' : 'Hidden',
-      isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
-      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
+      currentOwnerRestrictionDisplayMode:
+        generalSetting.hideRestrictedByOwner === false ? 'Displayed' : 'Hidden',
+      currentGroupRestrictionDisplayMode:
+        generalSetting.hideRestrictedByGroup === false ? 'Displayed' : 'Hidden',
+      isUsersHomepageDeletionEnabled:
+        generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion:
+        generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
       isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
       sessionMaxAge: generalSetting.sessionMaxAge,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       wikiMode: generalSetting.wikiMode,
@@ -97,7 +117,6 @@ export default class AdminGeneralSecurityContainer extends Container {
     });
     });
   }
   }
 
 
-
   /**
   /**
    * Workaround for the mangling in production build to break constructor.name
    * Workaround for the mangling in production build to break constructor.name
    */
    */
@@ -110,7 +129,9 @@ export default class AdminGeneralSecurityContainer extends Container {
    * @return {bool} isWikiModeForced
    * @return {bool} isWikiModeForced
    */
    */
   get isWikiModeForced() {
   get isWikiModeForced() {
-    return this.state.wikiMode === 'public' || this.state.wikiMode === 'private';
+    return (
+      this.state.wikiMode === 'public' || this.state.wikiMode === 'private'
+    );
   }
   }
 
 
   /**
   /**
@@ -180,7 +201,10 @@ export default class AdminGeneralSecurityContainer extends Container {
    * Switch isAllGroupMembershipRequiredForPageCompleteDeletion
    * Switch isAllGroupMembershipRequiredForPageCompleteDeletion
    */
    */
   switchIsAllGroupMembershipRequiredForPageCompleteDeletion() {
   switchIsAllGroupMembershipRequiredForPageCompleteDeletion() {
-    this.setState({ isAllGroupMembershipRequiredForPageCompleteDeletion: !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion });
+    this.setState({
+      isAllGroupMembershipRequiredForPageCompleteDeletion:
+        !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
+    });
   }
   }
 
 
   /**
   /**
@@ -190,7 +214,6 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ previousPageRecursiveDeletionAuthority: val });
     this.setState({ previousPageRecursiveDeletionAuthority: val });
   }
   }
 
 
-
   /**
   /**
    * Change previousPageRecursiveCompleteDeletionAuthority
    * Change previousPageRecursiveCompleteDeletionAuthority
    */
    */
@@ -216,14 +239,20 @@ export default class AdminGeneralSecurityContainer extends Container {
    * Switch isUsersHomepageDeletionEnabled
    * Switch isUsersHomepageDeletionEnabled
    */
    */
   switchIsUsersHomepageDeletionEnabled() {
   switchIsUsersHomepageDeletionEnabled() {
-    this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
+    this.setState({
+      isUsersHomepageDeletionEnabled:
+        !this.state.isUsersHomepageDeletionEnabled,
+    });
   }
   }
 
 
   /**
   /**
    * Switch isForceDeleteUserHomepageOnUserDeletion
    * Switch isForceDeleteUserHomepageOnUserDeletion
    */
    */
   switchIsForceDeleteUserHomepageOnUserDeletion() {
   switchIsForceDeleteUserHomepageOnUserDeletion() {
-    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+    this.setState({
+      isForceDeleteUserHomepageOnUserDeletion:
+        !this.state.isForceDeleteUserHomepageOnUserDeletion,
+    });
   }
   }
 
 
   /**
   /**
@@ -233,44 +262,62 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isRomUserAllowedToComment: bool });
     this.setState({ isRomUserAllowedToComment: bool });
   }
   }
 
 
-
   /**
   /**
    * Update restrictGuestMode
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
    * @memberOf AdminGeneralSecuritySContainer
    * @return {string} Appearance
    * @return {string} Appearance
    */
    */
   async updateGeneralSecuritySetting(formData) {
   async updateGeneralSecuritySetting(formData) {
-
-    let requestParams = formData != null ? {
-      sessionMaxAge: formData.sessionMaxAge,
-      restrictGuestMode: formData.restrictGuestMode,
-      pageDeletionAuthority: formData.pageDeletionAuthority,
-      pageCompleteDeletionAuthority: formData.pageCompleteDeletionAuthority,
-      pageRecursiveDeletionAuthority: formData.pageRecursiveDeletionAuthority,
-      pageRecursiveCompleteDeletionAuthority: formData.pageRecursiveCompleteDeletionAuthority,
-      isAllGroupMembershipRequiredForPageCompleteDeletion: formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      hideRestrictedByGroup: formData.hideRestrictedByGroup,
-      hideRestrictedByOwner: formData.hideRestrictedByOwner,
-      isUsersHomepageDeletionEnabled: formData.isUsersHomepageDeletionEnabled,
-      isForceDeleteUserHomepageOnUserDeletion: formData.isForceDeleteUserHomepageOnUserDeletion,
-      isRomUserAllowedToComment: formData.isRomUserAllowedToComment,
-    } : {
-      sessionMaxAge: this.state.sessionMaxAge,
-      restrictGuestMode: this.state.currentRestrictGuestMode,
-      pageDeletionAuthority: this.state.currentPageDeletionAuthority,
-      pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
-      pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
-      pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
-      isAllGroupMembershipRequiredForPageCompleteDeletion: this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      hideRestrictedByGroup: this.state.currentGroupRestrictionDisplayMode === 'Hidden',
-      hideRestrictedByOwner: this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
-      isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
-      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
-      isRomUserAllowedToComment: this.state.isRomUserAllowedToComment,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            sessionMaxAge: formData.sessionMaxAge,
+            restrictGuestMode: formData.restrictGuestMode,
+            pageDeletionAuthority: formData.pageDeletionAuthority,
+            pageCompleteDeletionAuthority:
+              formData.pageCompleteDeletionAuthority,
+            pageRecursiveDeletionAuthority:
+              formData.pageRecursiveDeletionAuthority,
+            pageRecursiveCompleteDeletionAuthority:
+              formData.pageRecursiveCompleteDeletionAuthority,
+            isAllGroupMembershipRequiredForPageCompleteDeletion:
+              formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
+            hideRestrictedByGroup: formData.hideRestrictedByGroup,
+            hideRestrictedByOwner: formData.hideRestrictedByOwner,
+            isUsersHomepageDeletionEnabled:
+              formData.isUsersHomepageDeletionEnabled,
+            isForceDeleteUserHomepageOnUserDeletion:
+              formData.isForceDeleteUserHomepageOnUserDeletion,
+            isRomUserAllowedToComment: formData.isRomUserAllowedToComment,
+          }
+        : {
+            sessionMaxAge: this.state.sessionMaxAge,
+            restrictGuestMode: this.state.currentRestrictGuestMode,
+            pageDeletionAuthority: this.state.currentPageDeletionAuthority,
+            pageCompleteDeletionAuthority:
+              this.state.currentPageCompleteDeletionAuthority,
+            pageRecursiveDeletionAuthority:
+              this.state.currentPageRecursiveDeletionAuthority,
+            pageRecursiveCompleteDeletionAuthority:
+              this.state.currentPageRecursiveCompleteDeletionAuthority,
+            isAllGroupMembershipRequiredForPageCompleteDeletion:
+              this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
+            hideRestrictedByGroup:
+              this.state.currentGroupRestrictionDisplayMode === 'Hidden',
+            hideRestrictedByOwner:
+              this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
+            isUsersHomepageDeletionEnabled:
+              this.state.isUsersHomepageDeletionEnabled,
+            isForceDeleteUserHomepageOnUserDeletion:
+              this.state.isForceDeleteUserHomepageOnUserDeletion,
+            isRomUserAllowedToComment: this.state.isRomUserAllowedToComment,
+          };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/general-setting', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/general-setting',
+      requestParams,
+    );
     const { securitySettingParams } = response.data;
     const { securitySettingParams } = response.data;
     return securitySettingParams;
     return securitySettingParams;
   }
   }
@@ -282,7 +329,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     const requestParams = {
     const requestParams = {
       disableLinkSharing: !this.state.disableLinkSharing,
       disableLinkSharing: !this.state.disableLinkSharing,
     };
     };
-    const response = await apiv3Put('/security-setting/share-link-setting', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/share-link-setting',
+      requestParams,
+    );
     this.setDisableLinkSharing(!this.state.disableLinkSharing);
     this.setDisableLinkSharing(!this.state.disableLinkSharing);
     return response;
     return response;
   }
   }
@@ -299,8 +349,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       });
       });
       await this.retrieveSetupStratedies();
       await this.retrieveSetupStratedies();
       this.setState({ [stateVariableName]: isEnabled });
       this.setState({ [stateVariableName]: isEnabled });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }
   }
@@ -313,8 +362,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       const response = await apiv3Get('/security-setting/authentication');
       const response = await apiv3Get('/security-setting/authentication');
       const { setupStrategies } = response.data;
       const { setupStrategies } = response.data;
       this.setState({ setupStrategies });
       this.setState({ setupStrategies });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }
   }
@@ -323,18 +371,24 @@ export default class AdminGeneralSecurityContainer extends Container {
    * Retrieve All Sharelinks
    * Retrieve All Sharelinks
    */
    */
   async retrieveShareLinksByPagingNum(page) {
   async retrieveShareLinksByPagingNum(page) {
-
     const params = {
     const params = {
       page,
       page,
     };
     };
 
 
-    const { data } = await apiv3Get('/security-setting/all-share-links', params);
+    const { data } = await apiv3Get(
+      '/security-setting/all-share-links',
+      params,
+    );
 
 
     if (data.paginateResult == null) {
     if (data.paginateResult == null) {
-      throw new Error('data must conclude \'paginateResult\' property.');
+      throw new Error("data must conclude 'paginateResult' property.");
     }
     }
 
 
-    const { docs: shareLinks, totalDocs: totalshareLinks, limit: shareLinksPagingLimit } = data.paginateResult;
+    const {
+      docs: shareLinks,
+      totalDocs: totalshareLinks,
+      limit: shareLinksPagingLimit,
+    } = data.paginateResult;
 
 
     this.setState({
     this.setState({
       shareLinks,
       shareLinks,
@@ -385,5 +439,4 @@ export default class AdminGeneralSecurityContainer extends Container {
   async switchIsGitHubOAuthEnabled() {
   async switchIsGitHubOAuthEnabled() {
     this.switchAuthentication('isGitHubEnabled', 'github');
     this.switchAuthentication('isGitHubEnabled', 'github');
   }
   }
-
 }
 }

+ 27 - 18
apps/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:security:AdminGitHubSecurityContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminGitHubSecurityContainer extends Container {
 export default class AdminGitHubSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -31,7 +30,6 @@ export default class AdminGitHubSecurityContainer extends Container {
       githubClientSecret: '',
       githubClientSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameUsernameTreatedAsIdenticalUser: false,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -44,10 +42,10 @@ export default class AdminGitHubSecurityContainer extends Container {
       this.setState({
       this.setState({
         githubClientId: githubOAuth.githubClientId,
         githubClientId: githubOAuth.githubClientId,
         githubClientSecret: githubOAuth.githubClientSecret,
         githubClientSecret: githubOAuth.githubClientSecret,
-        isSameUsernameTreatedAsIdenticalUser: githubOAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          githubOAuth.isSameUsernameTreatedAsIdenticalUser,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
@@ -65,33 +63,44 @@ export default class AdminGitHubSecurityContainer extends Container {
    * Switch isSameUsernameTreatedAsIdenticalUser
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
   }
 
 
   /**
   /**
    * Update githubSetting
    * Update githubSetting
    */
    */
   async updateGitHubSetting(formData) {
   async updateGitHubSetting(formData) {
-    let requestParams = formData != null ? {
-      githubClientId: formData.githubClientId,
-      githubClientSecret: formData.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-    } : {
-      githubClientId: this.state.githubClientId,
-      githubClientSecret: this.state.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            githubClientId: formData.githubClientId,
+            githubClientSecret: formData.githubClientSecret,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+          }
+        : {
+            githubClientId: this.state.githubClientId,
+            githubClientSecret: this.state.githubClientSecret,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+          };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/github-oauth', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/github-oauth',
+      requestParams,
+    );
     const { securitySettingParams } = response.data;
     const { securitySettingParams } = response.data;
 
 
     this.setState({
     this.setState({
       githubClientId: securitySettingParams.githubClientId,
       githubClientId: securitySettingParams.githubClientId,
       githubClientSecret: securitySettingParams.githubClientSecret,
       githubClientSecret: securitySettingParams.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
     });
     });
     return response;
     return response;
   }
   }
-
 }
 }

+ 27 - 20
apps/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:security:AdminGoogleSecurityContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminGoogleSecurityContainer extends Container {
 export default class AdminGoogleSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -31,8 +30,6 @@ export default class AdminGoogleSecurityContainer extends Container {
       googleClientSecret: '',
       googleClientSecret: '',
       isSameEmailTreatedAsIdenticalUser: false,
       isSameEmailTreatedAsIdenticalUser: false,
     };
     };
-
-
   }
   }
 
 
   /**
   /**
@@ -45,10 +42,10 @@ export default class AdminGoogleSecurityContainer extends Container {
       this.setState({
       this.setState({
         googleClientId: googleOAuth.googleClientId,
         googleClientId: googleOAuth.googleClientId,
         googleClientSecret: googleOAuth.googleClientSecret,
         googleClientSecret: googleOAuth.googleClientSecret,
-        isSameEmailTreatedAsIdenticalUser: googleOAuth.isSameEmailTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser:
+          googleOAuth.isSameEmailTreatedAsIdenticalUser,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
@@ -66,34 +63,44 @@ export default class AdminGoogleSecurityContainer extends Container {
    * Switch isSameEmailTreatedAsIdenticalUser
    * Switch isSameEmailTreatedAsIdenticalUser
    */
    */
   switchIsSameEmailTreatedAsIdenticalUser() {
   switchIsSameEmailTreatedAsIdenticalUser() {
-    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+    this.setState({
+      isSameEmailTreatedAsIdenticalUser:
+        !this.state.isSameEmailTreatedAsIdenticalUser,
+    });
   }
   }
 
 
-
   /**
   /**
    * Update googleSetting
    * Update googleSetting
    */
    */
   async updateGoogleSetting(formData) {
   async updateGoogleSetting(formData) {
-    let requestParams = formData != null ? {
-      googleClientId: formData.googleClientId,
-      googleClientSecret: formData.googleClientSecret,
-      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
-    } : {
-      googleClientId: this.state.googleClientId,
-      googleClientSecret: this.state.googleClientSecret,
-      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            googleClientId: formData.googleClientId,
+            googleClientSecret: formData.googleClientSecret,
+            isSameEmailTreatedAsIdenticalUser:
+              formData.isSameEmailTreatedAsIdenticalUser,
+          }
+        : {
+            googleClientId: this.state.googleClientId,
+            googleClientSecret: this.state.googleClientSecret,
+            isSameEmailTreatedAsIdenticalUser:
+              this.state.isSameEmailTreatedAsIdenticalUser,
+          };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
-    const response = await apiv3Put('/security-setting/google-oauth', requestParams);
+    const response = await apiv3Put(
+      '/security-setting/google-oauth',
+      requestParams,
+    );
     const { securitySettingParams } = response.data;
     const { securitySettingParams } = response.data;
 
 
     this.setState({
     this.setState({
       googleClientId: securitySettingParams.googleClientId,
       googleClientId: securitySettingParams.googleClientId,
       googleClientSecret: securitySettingParams.googleClientSecret,
       googleClientSecret: securitySettingParams.googleClientSecret,
-      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser:
+        securitySettingParams.isSameEmailTreatedAsIdenticalUser,
     });
     });
     return response;
     return response;
   }
   }
-
 }
 }

+ 4 - 8
apps/app/src/client/services/AdminHomeContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminHomeContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminHomeContainer extends Container {
 export default class AdminHomeContainer extends Container {
-
   constructor() {
   constructor() {
     super();
     super();
 
 
@@ -37,7 +36,6 @@ export default class AdminHomeContainer extends Container {
       isV5Compatible: null,
       isV5Compatible: null,
       isMaintenanceMode: null,
       isMaintenanceMode: null,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -59,7 +57,7 @@ export default class AdminHomeContainer extends Container {
       const response = await apiv3Get('/admin-home/');
       const response = await apiv3Get('/admin-home/');
       const { adminHomeParams } = response.data;
       const { adminHomeParams } = response.data;
 
 
-      this.setState(prevState => ({
+      this.setState((prevState) => ({
         ...prevState,
         ...prevState,
         growiVersion: adminHomeParams.growiVersion,
         growiVersion: adminHomeParams.growiVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         nodeVersion: adminHomeParams.nodeVersion,
@@ -69,8 +67,7 @@ export default class AdminHomeContainer extends Container {
         isV5Compatible: adminHomeParams.isV5Compatible,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
       }));
       }));
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to retrive AdminHome data');
       throw new Error('Failed to retrive AdminHome data');
     }
     }
@@ -80,13 +77,13 @@ export default class AdminHomeContainer extends Container {
    * sets button text when copying system information
    * sets button text when copying system information
    */
    */
   onCopyPrefilledHostInformation() {
   onCopyPrefilledHostInformation() {
-    this.setState(prevState => ({
+    this.setState((prevState) => ({
       ...prevState,
       ...prevState,
       copyState: this.copyStateValues.DONE,
       copyState: this.copyStateValues.DONE,
     }));
     }));
 
 
     this.timer = setTimeout(() => {
     this.timer = setTimeout(() => {
-      this.setState(prevState => ({
+      this.setState((prevState) => ({
         ...prevState,
         ...prevState,
         copyState: this.copyStateValues.DEFAULT,
         copyState: this.copyStateValues.DEFAULT,
       }));
       }));
@@ -111,5 +108,4 @@ export default class AdminHomeContainer extends Container {
 
 
 *(Accessing https://{GROWI_HOST}/admin helps you to fill in above versions)*`;
 *(Accessing https://{GROWI_HOST}/admin helps you to fill in above versions)*`;
   }
   }
-
 }
 }

+ 0 - 2
apps/app/src/client/services/AdminImportContainer.js

@@ -6,7 +6,6 @@ import { Container } from 'unstated';
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminImportContainer extends Container {
 export default class AdminImportContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -27,5 +26,4 @@ export default class AdminImportContainer extends Container {
   static getClassName() {
   static getClassName() {
     return 'AdminImportContainer';
     return 'AdminImportContainer';
   }
   }
-
 }
 }

+ 41 - 36
apps/app/src/client/services/AdminLdapSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminLdapSecurityContainer extends Container {
 export default class AdminLdapSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -38,7 +37,6 @@ export default class AdminLdapSecurityContainer extends Container {
       ldapGroupSearchFilter: '',
       ldapGroupSearchFilter: '',
       ldapGroupDnProperty: '',
       ldapGroupDnProperty: '',
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -55,22 +53,21 @@ export default class AdminLdapSecurityContainer extends Container {
         ldapBindDNPassword: ldapAuth.ldapBindDNPassword,
         ldapBindDNPassword: ldapAuth.ldapBindDNPassword,
         ldapSearchFilter: ldapAuth.ldapSearchFilter,
         ldapSearchFilter: ldapAuth.ldapSearchFilter,
         ldapAttrMapUsername: ldapAuth.ldapAttrMapUsername,
         ldapAttrMapUsername: ldapAuth.ldapAttrMapUsername,
-        isSameUsernameTreatedAsIdenticalUser: ldapAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          ldapAuth.isSameUsernameTreatedAsIdenticalUser,
         ldapAttrMapMail: ldapAuth.ldapAttrMapMail,
         ldapAttrMapMail: ldapAuth.ldapAttrMapMail,
         ldapAttrMapName: ldapAuth.ldapAttrMapName,
         ldapAttrMapName: ldapAuth.ldapAttrMapName,
         ldapGroupSearchBase: ldapAuth.ldapGroupSearchBase,
         ldapGroupSearchBase: ldapAuth.ldapGroupSearchBase,
         ldapGroupSearchFilter: ldapAuth.ldapGroupSearchFilter,
         ldapGroupSearchFilter: ldapAuth.ldapGroupSearchFilter,
         ldapGroupDnProperty: ldapAuth.ldapGroupDnProperty,
         ldapGroupDnProperty: ldapAuth.ldapGroupDnProperty,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
     }
     }
   }
   }
 
 
-
   /**
   /**
    * Workaround for the mangling in production build to break constructor.name
    * Workaround for the mangling in production build to break constructor.name
    */
    */
@@ -90,40 +87,48 @@ export default class AdminLdapSecurityContainer extends Container {
    * Switch is same username treated as identical user
    * Switch is same username treated as identical user
    */
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
   }
 
 
   /**
   /**
    * Update ldap option
    * Update ldap option
    */
    */
   async updateLdapSetting(formData) {
   async updateLdapSetting(formData) {
-    let requestParams = formData != null ? {
-      serverUrl: formData.serverUrl,
-      isUserBind: formData.isUserBind,
-      ldapBindDN: formData.ldapBindDN,
-      ldapBindDNPassword: formData.ldapBindDNPassword,
-      ldapSearchFilter: formData.ldapSearchFilter,
-      ldapAttrMapUsername: formData.ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-      ldapAttrMapMail: formData.ldapAttrMapMail,
-      ldapAttrMapName: formData.ldapAttrMapName,
-      ldapGroupSearchBase: formData.ldapGroupSearchBase,
-      ldapGroupSearchFilter: formData.ldapGroupSearchFilter,
-      ldapGroupDnProperty: formData.ldapGroupDnProperty,
-    } : {
-      serverUrl: this.state.serverUrl,
-      isUserBind: this.state.isUserBind,
-      ldapBindDN: this.state.ldapBindDN,
-      ldapBindDNPassword: this.state.ldapBindDNPassword,
-      ldapSearchFilter: this.state.ldapSearchFilter,
-      ldapAttrMapUsername: this.state.ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-      ldapAttrMapMail: this.state.ldapAttrMapMail,
-      ldapAttrMapName: this.state.ldapAttrMapName,
-      ldapGroupSearchBase: this.state.ldapGroupSearchBase,
-      ldapGroupSearchFilter: this.state.ldapGroupSearchFilter,
-      ldapGroupDnProperty: this.state.ldapGroupDnProperty,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            serverUrl: formData.serverUrl,
+            isUserBind: formData.isUserBind,
+            ldapBindDN: formData.ldapBindDN,
+            ldapBindDNPassword: formData.ldapBindDNPassword,
+            ldapSearchFilter: formData.ldapSearchFilter,
+            ldapAttrMapUsername: formData.ldapAttrMapUsername,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+            ldapAttrMapMail: formData.ldapAttrMapMail,
+            ldapAttrMapName: formData.ldapAttrMapName,
+            ldapGroupSearchBase: formData.ldapGroupSearchBase,
+            ldapGroupSearchFilter: formData.ldapGroupSearchFilter,
+            ldapGroupDnProperty: formData.ldapGroupDnProperty,
+          }
+        : {
+            serverUrl: this.state.serverUrl,
+            isUserBind: this.state.isUserBind,
+            ldapBindDN: this.state.ldapBindDN,
+            ldapBindDNPassword: this.state.ldapBindDNPassword,
+            ldapSearchFilter: this.state.ldapSearchFilter,
+            ldapAttrMapUsername: this.state.ldapAttrMapUsername,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+            ldapAttrMapMail: this.state.ldapAttrMapMail,
+            ldapAttrMapName: this.state.ldapAttrMapName,
+            ldapGroupSearchBase: this.state.ldapGroupSearchBase,
+            ldapGroupSearchFilter: this.state.ldapGroupSearchFilter,
+            ldapGroupDnProperty: this.state.ldapGroupDnProperty,
+          };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/ldap', requestParams);
     const response = await apiv3Put('/security-setting/ldap', requestParams);
@@ -136,7 +141,8 @@ export default class AdminLdapSecurityContainer extends Container {
       ldapBindDNPassword: securitySettingParams.ldapBindDNPassword,
       ldapBindDNPassword: securitySettingParams.ldapBindDNPassword,
       ldapSearchFilter: securitySettingParams.ldapSearchFilter,
       ldapSearchFilter: securitySettingParams.ldapSearchFilter,
       ldapAttrMapUsername: securitySettingParams.ldapAttrMapUsername,
       ldapAttrMapUsername: securitySettingParams.ldapAttrMapUsername,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
       ldapAttrMapMail: securitySettingParams.ldapAttrMapMail,
       ldapAttrMapMail: securitySettingParams.ldapAttrMapMail,
       ldapAttrMapName: securitySettingParams.ldapAttrMapName,
       ldapAttrMapName: securitySettingParams.ldapAttrMapName,
       ldapGroupSearchBase: securitySettingParams.ldapGroupSearchBase,
       ldapGroupSearchBase: securitySettingParams.ldapGroupSearchBase,
@@ -145,5 +151,4 @@ export default class AdminLdapSecurityContainer extends Container {
     });
     });
     return response;
     return response;
   }
   }
-
 }
 }

+ 28 - 23
apps/app/src/client/services/AdminLocalSecurityContainer.js

@@ -12,7 +12,6 @@ const logger = loggerFactory('growi:services:AdminLocalSecurityContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminLocalSecurityContainer extends Container {
 export default class AdminLocalSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -33,7 +32,6 @@ export default class AdminLocalSecurityContainer extends Container {
       isPasswordResetEnabled: false,
       isPasswordResetEnabled: false,
       isEmailAuthenticationEnabled: false,
       isEmailAuthenticationEnabled: false,
     };
     };
-
   }
   }
 
 
   async retrieveSecurityData() {
   async retrieveSecurityData() {
@@ -47,13 +45,11 @@ export default class AdminLocalSecurityContainer extends Container {
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
         isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
     }
     }
-
   }
   }
 
 
   /**
   /**
@@ -63,7 +59,6 @@ export default class AdminLocalSecurityContainer extends Container {
     return 'AdminLocalSecurityContainer';
     return 'AdminLocalSecurityContainer';
   }
   }
 
 
-
   /**
   /**
    * Change registration mode
    * Change registration mode
    */
    */
@@ -75,32 +70,43 @@ export default class AdminLocalSecurityContainer extends Container {
    * Switch password reset enabled
    * Switch password reset enabled
    */
    */
   switchIsPasswordResetEnabled() {
   switchIsPasswordResetEnabled() {
-    this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
+    this.setState({
+      isPasswordResetEnabled: !this.state.isPasswordResetEnabled,
+    });
   }
   }
 
 
   /**
   /**
    * Switch email authentication enabled
    * Switch email authentication enabled
    */
    */
   switchIsEmailAuthenticationEnabled() {
   switchIsEmailAuthenticationEnabled() {
-    this.setState({ isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled });
+    this.setState({
+      isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled,
+    });
   }
   }
 
 
   /**
   /**
    * update local security setting
    * update local security setting
    */
    */
   async updateLocalSecuritySetting(formData) {
   async updateLocalSecuritySetting(formData) {
-    const requestParams = formData != null ? {
-      registrationMode: formData.registrationMode,
-      registrationWhitelist: formData.registrationWhitelist,
-      isPasswordResetEnabled: formData.isPasswordResetEnabled,
-      isEmailAuthenticationEnabled: formData.isEmailAuthenticationEnabled,
-    } : {
-      registrationMode: this.state.registrationMode,
-      registrationWhitelist: this.state.registrationWhitelist,
-      isPasswordResetEnabled: this.state.isPasswordResetEnabled,
-      isEmailAuthenticationEnabled: this.state.isEmailAuthenticationEnabled,
-    };
-    const response = await apiv3Put('/security-setting/local-setting', requestParams);
+    const requestParams =
+      formData != null
+        ? {
+            registrationMode: formData.registrationMode,
+            registrationWhitelist: formData.registrationWhitelist,
+            isPasswordResetEnabled: formData.isPasswordResetEnabled,
+            isEmailAuthenticationEnabled: formData.isEmailAuthenticationEnabled,
+          }
+        : {
+            registrationMode: this.state.registrationMode,
+            registrationWhitelist: this.state.registrationWhitelist,
+            isPasswordResetEnabled: this.state.isPasswordResetEnabled,
+            isEmailAuthenticationEnabled:
+              this.state.isEmailAuthenticationEnabled,
+          };
+    const response = await apiv3Put(
+      '/security-setting/local-setting',
+      requestParams,
+    );
 
 
     const { localSettingParams } = response.data;
     const { localSettingParams } = response.data;
 
 
@@ -108,11 +114,10 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: localSettingParams.registrationMode,
       registrationMode: localSettingParams.registrationMode,
       registrationWhitelist: localSettingParams.registrationWhitelist,
       registrationWhitelist: localSettingParams.registrationWhitelist,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
-      isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
+      isEmailAuthenticationEnabled:
+        localSettingParams.isEmailAuthenticationEnabled,
     });
     });
 
 
     return localSettingParams;
     return localSettingParams;
   }
   }
-
-
 }
 }

+ 8 - 9
apps/app/src/client/services/AdminMarkDownContainer.js

@@ -8,7 +8,6 @@ import { apiv3Get, apiv3Put } from '../util/apiv3-client';
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminMarkDownContainer extends Container {
 export default class AdminMarkDownContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -31,7 +30,8 @@ export default class AdminMarkDownContainer extends Container {
     };
     };
 
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
     this.switchEnableXss = this.switchEnableXss.bind(this);
-    this.setAdminPreferredIndentSize = this.setAdminPreferredIndentSize.bind(this);
+    this.setAdminPreferredIndentSize =
+      this.setAdminPreferredIndentSize.bind(this);
   }
   }
 
 
   /**
   /**
@@ -50,7 +50,8 @@ export default class AdminMarkDownContainer extends Container {
 
 
     this.setState({
     this.setState({
       isEnabledLinebreaks: markdownParams.isEnabledLinebreaks,
       isEnabledLinebreaks: markdownParams.isEnabledLinebreaks,
-      isEnabledLinebreaksInComments: markdownParams.isEnabledLinebreaksInComments,
+      isEnabledLinebreaksInComments:
+        markdownParams.isEnabledLinebreaksInComments,
       adminPreferredIndentSize: markdownParams.adminPreferredIndentSize,
       adminPreferredIndentSize: markdownParams.adminPreferredIndentSize,
       isIndentSizeForced: markdownParams.isIndentSizeForced,
       isIndentSizeForced: markdownParams.isIndentSizeForced,
       isEnabledXss: markdownParams.isEnabledXss,
       isEnabledXss: markdownParams.isEnabledXss,
@@ -75,7 +76,6 @@ export default class AdminMarkDownContainer extends Container {
    * Update LineBreak Setting
    * Update LineBreak Setting
    */
    */
   async updateLineBreakSetting() {
   async updateLineBreakSetting() {
-
     const response = await apiv3Put('/markdown-setting/lineBreak', {
     const response = await apiv3Put('/markdown-setting/lineBreak', {
       isEnabledLinebreaks: this.state.isEnabledLinebreaks,
       isEnabledLinebreaks: this.state.isEnabledLinebreaks,
       isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
       isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
@@ -88,7 +88,6 @@ export default class AdminMarkDownContainer extends Container {
    * Update
    * Update
    */
    */
   async updateIndentSetting() {
   async updateIndentSetting() {
-
     const response = await apiv3Put('/markdown-setting/indent', {
     const response = await apiv3Put('/markdown-setting/indent', {
       adminPreferredIndentSize: this.state.adminPreferredIndentSize,
       adminPreferredIndentSize: this.state.adminPreferredIndentSize,
       isIndentSizeForced: this.state.isIndentSizeForced,
       isIndentSizeForced: this.state.isIndentSizeForced,
@@ -104,13 +103,14 @@ export default class AdminMarkDownContainer extends Container {
     let { tagWhitelist = '' } = this.state;
     let { tagWhitelist = '' } = this.state;
     const { attrWhitelist = '{}' } = this.state;
     const { attrWhitelist = '{}' } = this.state;
 
 
-    tagWhitelist = Array.isArray(tagWhitelist) ? tagWhitelist : tagWhitelist.split(',');
+    tagWhitelist = Array.isArray(tagWhitelist)
+      ? tagWhitelist
+      : tagWhitelist.split(',');
 
 
     try {
     try {
       // Check if parsing is possible
       // Check if parsing is possible
       JSON.parse(attrWhitelist);
       JSON.parse(attrWhitelist);
-    }
-    catch (err) {
+    } catch (err) {
       throw Error(`attrWhitelist parsing error occured: ${err.message}`);
       throw Error(`attrWhitelist parsing error occured: ${err.message}`);
     }
     }
 
 
@@ -121,5 +121,4 @@ export default class AdminMarkDownContainer extends Container {
       attrWhitelist,
       attrWhitelist,
     });
     });
   }
   }
-
 }
 }

+ 49 - 24
apps/app/src/client/services/AdminNotificationContainer.js

@@ -2,7 +2,10 @@ import { isServer } from '@growi/core/dist/utils';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import {
 import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+  apiv3Delete,
+  apiv3Get,
+  apiv3Post,
+  apiv3Put,
 } from '../util/apiv3-client';
 } from '../util/apiv3-client';
 
 
 /**
 /**
@@ -10,7 +13,6 @@ import {
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminNotificationContainer extends Container {
 export default class AdminNotificationContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -32,7 +34,6 @@ export default class AdminNotificationContainer extends Container {
       isNotificationForGroupPageEnabled: false,
       isNotificationForGroupPageEnabled: false,
       globalNotifications: [],
       globalNotifications: [],
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -55,8 +56,10 @@ export default class AdminNotificationContainer extends Container {
       currentBotType: notificationParams.currentBotType,
       currentBotType: notificationParams.currentBotType,
 
 
       userNotifications: notificationParams.userNotifications,
       userNotifications: notificationParams.userNotifications,
-      isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
-      isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
+      isNotificationForOwnerPageEnabled:
+        notificationParams.isNotificationForOwnerPageEnabled,
+      isNotificationForGroupPageEnabled:
+        notificationParams.isNotificationForGroupPageEnabled,
       globalNotifications: notificationParams.globalNotifications,
       globalNotifications: notificationParams.globalNotifications,
     });
     });
   }
   }
@@ -66,11 +69,14 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    * @memberOf SlackAppConfiguration
    */
    */
   async updateSlackAppConfiguration() {
   async updateSlackAppConfiguration() {
-    const response = await apiv3Put('/notification-setting/slack-configuration', {
-      webhookUrl: this.state.webhookUrl,
-      isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
-      slackToken: this.state.slackToken,
-    });
+    const response = await apiv3Put(
+      '/notification-setting/slack-configuration',
+      {
+        webhookUrl: this.state.webhookUrl,
+        isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
+        slackToken: this.state.slackToken,
+      },
+    );
 
 
     return response;
     return response;
   }
   }
@@ -80,19 +86,26 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    * @memberOf SlackAppConfiguration
    */
    */
   async addNotificationPattern(pathPattern, channel) {
   async addNotificationPattern(pathPattern, channel) {
-    const response = await apiv3Post('/notification-setting/user-notification', {
-      pathPattern,
-      channel,
-    });
+    const response = await apiv3Post(
+      '/notification-setting/user-notification',
+      {
+        pathPattern,
+        channel,
+      },
+    );
 
 
-    this.setState({ userNotifications: response.data.responseParams.userNotifications });
+    this.setState({
+      userNotifications: response.data.responseParams.userNotifications,
+    });
   }
   }
 
 
   /**
   /**
    * Delete user trigger notification pattern
    * Delete user trigger notification pattern
    */
    */
   async deleteUserTriggerNotificationPattern(notificatiionId) {
   async deleteUserTriggerNotificationPattern(notificatiionId) {
-    const response = await apiv3Delete(`/notification-setting/user-notification/${notificatiionId}`);
+    const response = await apiv3Delete(
+      `/notification-setting/user-notification/${notificatiionId}`,
+    );
     const deletedNotificaton = response.data;
     const deletedNotificaton = response.data;
     await this.retrieveNotificationData();
     await this.retrieveNotificationData();
     return deletedNotificaton;
     return deletedNotificaton;
@@ -102,14 +115,20 @@ export default class AdminNotificationContainer extends Container {
    * Switch isNotificationForOwnerPageEnabled
    * Switch isNotificationForOwnerPageEnabled
    */
    */
   switchIsNotificationForOwnerPageEnabled() {
   switchIsNotificationForOwnerPageEnabled() {
-    this.setState({ isNotificationForOwnerPageEnabled: !this.state.isNotificationForOwnerPageEnabled });
+    this.setState({
+      isNotificationForOwnerPageEnabled:
+        !this.state.isNotificationForOwnerPageEnabled,
+    });
   }
   }
 
 
   /**
   /**
    * Switch isNotificationForGroupPageEnabled
    * Switch isNotificationForGroupPageEnabled
    */
    */
   switchIsNotificationForGroupPageEnabled() {
   switchIsNotificationForGroupPageEnabled() {
-    this.setState({ isNotificationForGroupPageEnabled: !this.state.isNotificationForGroupPageEnabled });
+    this.setState({
+      isNotificationForGroupPageEnabled:
+        !this.state.isNotificationForGroupPageEnabled,
+    });
   }
   }
 
 
   /**
   /**
@@ -117,10 +136,15 @@ export default class AdminNotificationContainer extends Container {
    * @memberOf SlackAppConfiguration
    * @memberOf SlackAppConfiguration
    */
    */
   async updateGlobalNotificationForPages() {
   async updateGlobalNotificationForPages() {
-    const response = await apiv3Put('/notification-setting/notify-for-page-grant/', {
-      isNotificationForOwnerPageEnabled: this.state.isNotificationForOwnerPageEnabled,
-      isNotificationForGroupPageEnabled: this.state.isNotificationForGroupPageEnabled,
-    });
+    const response = await apiv3Put(
+      '/notification-setting/notify-for-page-grant/',
+      {
+        isNotificationForOwnerPageEnabled:
+          this.state.isNotificationForOwnerPageEnabled,
+        isNotificationForGroupPageEnabled:
+          this.state.isNotificationForGroupPageEnabled,
+      },
+    );
 
 
     return response;
     return response;
   }
   }
@@ -129,10 +153,11 @@ export default class AdminNotificationContainer extends Container {
    * Delete global notification pattern
    * Delete global notification pattern
    */
    */
   async deleteGlobalNotificationPattern(notificatiionId) {
   async deleteGlobalNotificationPattern(notificatiionId) {
-    const response = await apiv3Delete(`/notification-setting/global-notification/${notificatiionId}`);
+    const response = await apiv3Delete(
+      `/notification-setting/global-notification/${notificatiionId}`,
+    );
     const deletedNotificaton = response.data;
     const deletedNotificaton = response.data;
     await this.retrieveNotificationData();
     await this.retrieveNotificationData();
     return deletedNotificaton;
     return deletedNotificaton;
   }
   }
-
 }
 }

+ 67 - 52
apps/app/src/client/services/AdminOidcSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminOidcSecurityContainer extends Container {
 export default class AdminOidcSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -44,7 +43,6 @@ export default class AdminOidcSecurityContainer extends Container {
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameEmailTreatedAsIdenticalUser: false,
       isSameEmailTreatedAsIdenticalUser: false,
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -71,11 +69,12 @@ export default class AdminOidcSecurityContainer extends Container {
         oidcAttrMapUserName: oidcAuth.oidcAttrMapUserName,
         oidcAttrMapUserName: oidcAuth.oidcAttrMapUserName,
         oidcAttrMapName: oidcAuth.oidcAttrMapName,
         oidcAttrMapName: oidcAuth.oidcAttrMapName,
         oidcAttrMapEmail: oidcAuth.oidcAttrMapEmail,
         oidcAttrMapEmail: oidcAuth.oidcAttrMapEmail,
-        isSameUsernameTreatedAsIdenticalUser: oidcAuth.isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser: oidcAuth.isSameEmailTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          oidcAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser:
+          oidcAuth.isSameEmailTreatedAsIdenticalUser,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
@@ -93,59 +92,72 @@ export default class AdminOidcSecurityContainer extends Container {
    * Switch sameUsernameTreatedAsIdenticalUser
    * Switch sameUsernameTreatedAsIdenticalUser
    */
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
   }
 
 
   /**
   /**
    * Switch sameEmailTreatedAsIdenticalUser
    * Switch sameEmailTreatedAsIdenticalUser
    */
    */
   switchIsSameEmailTreatedAsIdenticalUser() {
   switchIsSameEmailTreatedAsIdenticalUser() {
-    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+    this.setState({
+      isSameEmailTreatedAsIdenticalUser:
+        !this.state.isSameEmailTreatedAsIdenticalUser,
+    });
   }
   }
 
 
   /**
   /**
    * Update OpenID Connect
    * Update OpenID Connect
    */
    */
   async updateOidcSetting(formData) {
   async updateOidcSetting(formData) {
-    let requestParams = formData != null ? {
-      oidcProviderName: formData.oidcProviderName,
-      oidcIssuerHost: formData.oidcIssuerHost,
-      oidcAuthorizationEndpoint: formData.oidcAuthorizationEndpoint,
-      oidcTokenEndpoint: formData.oidcTokenEndpoint,
-      oidcRevocationEndpoint: formData.oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint: formData.oidcIntrospectionEndpoint,
-      oidcUserInfoEndpoint: formData.oidcUserInfoEndpoint,
-      oidcEndSessionEndpoint: formData.oidcEndSessionEndpoint,
-      oidcRegistrationEndpoint: formData.oidcRegistrationEndpoint,
-      oidcJWKSUri: formData.oidcJWKSUri,
-      oidcClientId: formData.oidcClientId,
-      oidcClientSecret: formData.oidcClientSecret,
-      oidcAttrMapId: formData.oidcAttrMapId,
-      oidcAttrMapUserName: formData.oidcAttrMapUserName,
-      oidcAttrMapName: formData.oidcAttrMapName,
-      oidcAttrMapEmail: formData.oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
-    } : {
-      oidcProviderName: this.state.oidcProviderName,
-      oidcIssuerHost: this.state.oidcIssuerHost,
-      oidcAuthorizationEndpoint: this.state.oidcAuthorizationEndpoint,
-      oidcTokenEndpoint: this.state.oidcTokenEndpoint,
-      oidcRevocationEndpoint: this.state.oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint: this.state.oidcIntrospectionEndpoint,
-      oidcUserInfoEndpoint: this.state.oidcUserInfoEndpoint,
-      oidcEndSessionEndpoint: this.state.oidcEndSessionEndpoint,
-      oidcRegistrationEndpoint: this.state.oidcRegistrationEndpoint,
-      oidcJWKSUri: this.state.oidcJWKSUri,
-      oidcClientId: this.state.oidcClientId,
-      oidcClientSecret: this.state.oidcClientSecret,
-      oidcAttrMapId: this.state.oidcAttrMapId,
-      oidcAttrMapUserName: this.state.oidcAttrMapUserName,
-      oidcAttrMapName: this.state.oidcAttrMapName,
-      oidcAttrMapEmail: this.state.oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            oidcProviderName: formData.oidcProviderName,
+            oidcIssuerHost: formData.oidcIssuerHost,
+            oidcAuthorizationEndpoint: formData.oidcAuthorizationEndpoint,
+            oidcTokenEndpoint: formData.oidcTokenEndpoint,
+            oidcRevocationEndpoint: formData.oidcRevocationEndpoint,
+            oidcIntrospectionEndpoint: formData.oidcIntrospectionEndpoint,
+            oidcUserInfoEndpoint: formData.oidcUserInfoEndpoint,
+            oidcEndSessionEndpoint: formData.oidcEndSessionEndpoint,
+            oidcRegistrationEndpoint: formData.oidcRegistrationEndpoint,
+            oidcJWKSUri: formData.oidcJWKSUri,
+            oidcClientId: formData.oidcClientId,
+            oidcClientSecret: formData.oidcClientSecret,
+            oidcAttrMapId: formData.oidcAttrMapId,
+            oidcAttrMapUserName: formData.oidcAttrMapUserName,
+            oidcAttrMapName: formData.oidcAttrMapName,
+            oidcAttrMapEmail: formData.oidcAttrMapEmail,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              formData.isSameEmailTreatedAsIdenticalUser,
+          }
+        : {
+            oidcProviderName: this.state.oidcProviderName,
+            oidcIssuerHost: this.state.oidcIssuerHost,
+            oidcAuthorizationEndpoint: this.state.oidcAuthorizationEndpoint,
+            oidcTokenEndpoint: this.state.oidcTokenEndpoint,
+            oidcRevocationEndpoint: this.state.oidcRevocationEndpoint,
+            oidcIntrospectionEndpoint: this.state.oidcIntrospectionEndpoint,
+            oidcUserInfoEndpoint: this.state.oidcUserInfoEndpoint,
+            oidcEndSessionEndpoint: this.state.oidcEndSessionEndpoint,
+            oidcRegistrationEndpoint: this.state.oidcRegistrationEndpoint,
+            oidcJWKSUri: this.state.oidcJWKSUri,
+            oidcClientId: this.state.oidcClientId,
+            oidcClientSecret: this.state.oidcClientSecret,
+            oidcAttrMapId: this.state.oidcAttrMapId,
+            oidcAttrMapUserName: this.state.oidcAttrMapUserName,
+            oidcAttrMapName: this.state.oidcAttrMapName,
+            oidcAttrMapEmail: this.state.oidcAttrMapEmail,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              this.state.isSameEmailTreatedAsIdenticalUser,
+          };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/oidc', requestParams);
     const response = await apiv3Put('/security-setting/oidc', requestParams);
@@ -154,10 +166,12 @@ export default class AdminOidcSecurityContainer extends Container {
     this.setState({
     this.setState({
       oidcProviderName: securitySettingParams.oidcProviderName,
       oidcProviderName: securitySettingParams.oidcProviderName,
       oidcIssuerHost: securitySettingParams.oidcIssuerHost,
       oidcIssuerHost: securitySettingParams.oidcIssuerHost,
-      oidcAuthorizationEndpoint: securitySettingParams.oidcAuthorizationEndpoint,
+      oidcAuthorizationEndpoint:
+        securitySettingParams.oidcAuthorizationEndpoint,
       oidcTokenEndpoint: securitySettingParams.oidcTokenEndpoint,
       oidcTokenEndpoint: securitySettingParams.oidcTokenEndpoint,
       oidcRevocationEndpoint: securitySettingParams.oidcRevocationEndpoint,
       oidcRevocationEndpoint: securitySettingParams.oidcRevocationEndpoint,
-      oidcIntrospectionEndpoint: securitySettingParams.oidcIntrospectionEndpoint,
+      oidcIntrospectionEndpoint:
+        securitySettingParams.oidcIntrospectionEndpoint,
       oidcUserInfoEndpoint: securitySettingParams.oidcUserInfoEndpoint,
       oidcUserInfoEndpoint: securitySettingParams.oidcUserInfoEndpoint,
       oidcEndSessionEndpoint: securitySettingParams.oidcEndSessionEndpoint,
       oidcEndSessionEndpoint: securitySettingParams.oidcEndSessionEndpoint,
       oidcRegistrationEndpoint: securitySettingParams.oidcRegistrationEndpoint,
       oidcRegistrationEndpoint: securitySettingParams.oidcRegistrationEndpoint,
@@ -168,10 +182,11 @@ export default class AdminOidcSecurityContainer extends Container {
       oidcAttrMapUserName: securitySettingParams.oidcAttrMapUserName,
       oidcAttrMapUserName: securitySettingParams.oidcAttrMapUserName,
       oidcAttrMapName: securitySettingParams.oidcAttrMapName,
       oidcAttrMapName: securitySettingParams.oidcAttrMapName,
       oidcAttrMapEmail: securitySettingParams.oidcAttrMapEmail,
       oidcAttrMapEmail: securitySettingParams.oidcAttrMapEmail,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser:
+        securitySettingParams.isSameEmailTreatedAsIdenticalUser,
     });
     });
     return response;
     return response;
   }
   }
-
 }
 }

+ 51 - 38
apps/app/src/client/services/AdminSamlSecurityContainer.js

@@ -13,7 +13,6 @@ const logger = loggerFactory('growi:security:AdminSamlSecurityContainer');
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminSamlSecurityContainer extends Container {
 export default class AdminSamlSecurityContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -49,7 +48,6 @@ export default class AdminSamlSecurityContainer extends Container {
       envAttrMapLastName: '',
       envAttrMapLastName: '',
       envABLCRule: '',
       envABLCRule: '',
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -70,8 +68,10 @@ export default class AdminSamlSecurityContainer extends Container {
         samlAttrMapMail: samlAuth.samlAttrMapMail,
         samlAttrMapMail: samlAuth.samlAttrMapMail,
         samlAttrMapFirstName: samlAuth.samlAttrMapFirstName,
         samlAttrMapFirstName: samlAuth.samlAttrMapFirstName,
         samlAttrMapLastName: samlAuth.samlAttrMapLastName,
         samlAttrMapLastName: samlAuth.samlAttrMapLastName,
-        isSameUsernameTreatedAsIdenticalUser: samlAuth.isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser: samlAuth.isSameEmailTreatedAsIdenticalUser,
+        isSameUsernameTreatedAsIdenticalUser:
+          samlAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser:
+          samlAuth.isSameEmailTreatedAsIdenticalUser,
         samlABLCRule: samlAuth.samlABLCRule,
         samlABLCRule: samlAuth.samlABLCRule,
         envEntryPoint: samlAuth.samlEnvVarEntryPoint,
         envEntryPoint: samlAuth.samlEnvVarEntryPoint,
         envIssuer: samlAuth.samlEnvVarIssuer,
         envIssuer: samlAuth.samlEnvVarIssuer,
@@ -83,8 +83,7 @@ export default class AdminSamlSecurityContainer extends Container {
         envAttrMapLastName: samlAuth.samlEnvVarAttrMapLastName,
         envAttrMapLastName: samlAuth.samlEnvVarAttrMapLastName,
         envABLCRule: samlAuth.samlEnvVarABLCRule,
         envABLCRule: samlAuth.samlEnvVarABLCRule,
       });
       });
-    }
-    catch (err) {
+    } catch (err) {
       this.setState({ retrieveError: err });
       this.setState({ retrieveError: err });
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to fetch data');
       throw new Error('Failed to fetch data');
@@ -102,53 +101,66 @@ export default class AdminSamlSecurityContainer extends Container {
    * Switch isSameUsernameTreatedAsIdenticalUser
    * Switch isSameUsernameTreatedAsIdenticalUser
    */
    */
   switchIsSameUsernameTreatedAsIdenticalUser() {
   switchIsSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser:
+        !this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
   }
   }
 
 
   /**
   /**
    * Switch isSameEmailTreatedAsIdenticalUser
    * Switch isSameEmailTreatedAsIdenticalUser
    */
    */
   switchIsSameEmailTreatedAsIdenticalUser() {
   switchIsSameEmailTreatedAsIdenticalUser() {
-    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+    this.setState({
+      isSameEmailTreatedAsIdenticalUser:
+        !this.state.isSameEmailTreatedAsIdenticalUser,
+    });
   }
   }
 
 
   /**
   /**
    * Update saml option
    * Update saml option
    */
    */
   async updateSamlSetting(formData) {
   async updateSamlSetting(formData) {
-
-    let requestParams = formData != null ? {
-      entryPoint: formData.samlEntryPoint,
-      issuer: formData.samlIssuer,
-      cert: formData.samlCert,
-      attrMapId: formData.samlAttrMapId,
-      attrMapUsername: formData.samlAttrMapUsername,
-      attrMapMail: formData.samlAttrMapMail,
-      attrMapFirstName: formData.samlAttrMapFirstName,
-      attrMapLastName: formData.samlAttrMapLastName,
-      isSameUsernameTreatedAsIdenticalUser: formData.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: formData.isSameEmailTreatedAsIdenticalUser,
-      ABLCRule: formData.samlABLCRule,
-    } : {
-      entryPoint: this.state.samlEntryPoint,
-      issuer: this.state.samlIssuer,
-      cert: this.state.samlCert,
-      attrMapId: this.state.samlAttrMapId,
-      attrMapUsername: this.state.samlAttrMapUsername,
-      attrMapMail: this.state.samlAttrMapMail,
-      attrMapFirstName: this.state.samlAttrMapFirstName,
-      attrMapLastName: this.state.samlAttrMapLastName,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
-      ABLCRule: this.state.samlABLCRule,
-    };
+    let requestParams =
+      formData != null
+        ? {
+            entryPoint: formData.samlEntryPoint,
+            issuer: formData.samlIssuer,
+            cert: formData.samlCert,
+            attrMapId: formData.samlAttrMapId,
+            attrMapUsername: formData.samlAttrMapUsername,
+            attrMapMail: formData.samlAttrMapMail,
+            attrMapFirstName: formData.samlAttrMapFirstName,
+            attrMapLastName: formData.samlAttrMapLastName,
+            isSameUsernameTreatedAsIdenticalUser:
+              formData.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              formData.isSameEmailTreatedAsIdenticalUser,
+            ABLCRule: formData.samlABLCRule,
+          }
+        : {
+            entryPoint: this.state.samlEntryPoint,
+            issuer: this.state.samlIssuer,
+            cert: this.state.samlCert,
+            attrMapId: this.state.samlAttrMapId,
+            attrMapUsername: this.state.samlAttrMapUsername,
+            attrMapMail: this.state.samlAttrMapMail,
+            attrMapFirstName: this.state.samlAttrMapFirstName,
+            attrMapLastName: this.state.samlAttrMapLastName,
+            isSameUsernameTreatedAsIdenticalUser:
+              this.state.isSameUsernameTreatedAsIdenticalUser,
+            isSameEmailTreatedAsIdenticalUser:
+              this.state.isSameEmailTreatedAsIdenticalUser,
+            ABLCRule: this.state.samlABLCRule,
+          };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);
     const response = await apiv3Put('/security-setting/saml', requestParams);
     const response = await apiv3Put('/security-setting/saml', requestParams);
     const { securitySettingParams } = response.data;
     const { securitySettingParams } = response.data;
 
 
     this.setState({
     this.setState({
-      missingMandatoryConfigKeys: securitySettingParams.missingMandatoryConfigKeys,
+      missingMandatoryConfigKeys:
+        securitySettingParams.missingMandatoryConfigKeys,
       samlEntryPoint: securitySettingParams.samlEntryPoint,
       samlEntryPoint: securitySettingParams.samlEntryPoint,
       samlIssuer: securitySettingParams.samlIssuer,
       samlIssuer: securitySettingParams.samlIssuer,
       samlCert: securitySettingParams.samlCert,
       samlCert: securitySettingParams.samlCert,
@@ -157,11 +169,12 @@ export default class AdminSamlSecurityContainer extends Container {
       samlAttrMapMail: securitySettingParams.samlAttrMapMail,
       samlAttrMapMail: securitySettingParams.samlAttrMapMail,
       samlAttrMapFirstName: securitySettingParams.samlAttrMapFirstName,
       samlAttrMapFirstName: securitySettingParams.samlAttrMapFirstName,
       samlAttrMapLastName: securitySettingParams.samlAttrMapLastName,
       samlAttrMapLastName: securitySettingParams.samlAttrMapLastName,
-      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
-      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      isSameUsernameTreatedAsIdenticalUser:
+        securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser:
+        securitySettingParams.isSameEmailTreatedAsIdenticalUser,
       samlABLCRule: securitySettingParams.samlABLCRule,
       samlABLCRule: securitySettingParams.samlABLCRule,
     });
     });
     return response;
     return response;
   }
   }
-
 }
 }

+ 5 - 5
apps/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -8,7 +8,6 @@ import { apiv3Get, apiv3Put } from '../util/apiv3-client';
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminSlackIntegrationLegacyContainer extends Container {
 export default class AdminSlackIntegrationLegacyContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -26,7 +25,6 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
       isIncomingWebhookPrioritized: false,
       isIncomingWebhookPrioritized: false,
       slackToken: '',
       slackToken: '',
     };
     };
-
   }
   }
 
 
   /**
   /**
@@ -46,7 +44,8 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
     this.setState({
     this.setState({
       isSlackbotConfigured: slackIntegrationParams.isSlackbotConfigured,
       isSlackbotConfigured: slackIntegrationParams.isSlackbotConfigured,
       webhookUrl: slackIntegrationParams.webhookUrl,
       webhookUrl: slackIntegrationParams.webhookUrl,
-      isIncomingWebhookPrioritized: slackIntegrationParams.isIncomingWebhookPrioritized,
+      isIncomingWebhookPrioritized:
+        slackIntegrationParams.isIncomingWebhookPrioritized,
       slackToken: slackIntegrationParams.slackToken,
       slackToken: slackIntegrationParams.slackToken,
     });
     });
   }
   }
@@ -69,7 +68,9 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
    * Switch incomingWebhookPrioritized
    * Switch incomingWebhookPrioritized
    */
    */
   switchIsIncomingWebhookPrioritized() {
   switchIsIncomingWebhookPrioritized() {
-    this.setState({ isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized });
+    this.setState({
+      isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized,
+    });
   }
   }
 
 
   /**
   /**
@@ -92,5 +93,4 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
 
 
     return response;
     return response;
   }
   }
-
 }
 }

+ 0 - 1
apps/app/src/client/services/AdminSocketIoContainer.js

@@ -1,2 +1 @@
-
 export default class AdminSocketIoContainer {}
 export default class AdminSocketIoContainer {}

+ 20 - 16
apps/app/src/client/services/AdminUsersContainer.js

@@ -3,16 +3,17 @@ import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import {
 import {
-  apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
+  apiv3Delete,
+  apiv3Get,
+  apiv3Post,
+  apiv3Put,
 } from '../util/apiv3-client';
 } from '../util/apiv3-client';
 
 
-
 /**
 /**
  * Service container for admin users page (Users.jsx)
  * Service container for admin users page (Users.jsx)
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminUsersContainer extends Container {
 export default class AdminUsersContainer extends Container {
-
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
@@ -41,7 +42,9 @@ export default class AdminUsersContainer extends Container {
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
 
 
-    this.handleChangeSearchTextDebouce = debounce(3000, () => this.retrieveUsersByPagingNum(1));
+    this.handleChangeSearchTextDebouce = debounce(3000, () =>
+      this.retrieveUsersByPagingNum(1),
+    );
   }
   }
 
 
   /**
   /**
@@ -62,12 +65,10 @@ export default class AdminUsersContainer extends Container {
     const all = 'all';
     const all = 'all';
     if (this.isSelected(statusType)) {
     if (this.isSelected(statusType)) {
       this.deleteStatusFromList(statusType);
       this.deleteStatusFromList(statusType);
-    }
-    else {
+    } else {
       if (statusType === all) {
       if (statusType === all) {
         this.clearStatusList();
         this.clearStatusList();
-      }
-      else {
+      } else {
         this.deleteStatusFromList(all);
         this.deleteStatusFromList(all);
       }
       }
       this.addStatusToList(statusType);
       this.addStatusToList(statusType);
@@ -132,7 +133,6 @@ export default class AdminUsersContainer extends Container {
    * @param {number} selectedPage
    * @param {number} selectedPage
    */
    */
   async retrieveUsersByPagingNum(selectedPage) {
   async retrieveUsersByPagingNum(selectedPage) {
-
     const params = {
     const params = {
       page: selectedPage,
       page: selectedPage,
       sort: this.state.sort,
       sort: this.state.sort,
@@ -145,10 +145,14 @@ export default class AdminUsersContainer extends Container {
     const { data } = await apiv3Get('/users', params);
     const { data } = await apiv3Get('/users', params);
 
 
     if (data.paginateResult == null) {
     if (data.paginateResult == null) {
-      throw new Error('data must conclude \'paginateResult\' property.');
+      throw new Error("data must conclude 'paginateResult' property.");
     }
     }
 
 
-    const { docs: users, totalDocs: totalUsers, limit: pagingLimit } = data.paginateResult;
+    const {
+      docs: users,
+      totalDocs: totalUsers,
+      limit: pagingLimit,
+    } = data.paginateResult;
 
 
     this.setState({
     this.setState({
       users,
       users,
@@ -156,12 +160,11 @@ export default class AdminUsersContainer extends Container {
       pagingLimit,
       pagingLimit,
       activePage: selectedPage,
       activePage: selectedPage,
     });
     });
-
   }
   }
 
 
   /**
   /**
- * retrieve user statistics
- */
+   * retrieve user statistics
+   */
   async retrieveUserStatistics() {
   async retrieveUserStatistics() {
     const statsRes = await apiv3Get('/statistics/user');
     const statsRes = await apiv3Get('/statistics/user');
     const userStatistics = statsRes.data.data;
     const userStatistics = statsRes.data.data;
@@ -211,7 +214,9 @@ export default class AdminUsersContainer extends Container {
    * @memberOf AdminUsersContainer
    * @memberOf AdminUsersContainer
    */
    */
   async toggleUserInviteModal() {
   async toggleUserInviteModal() {
-    await this.setState({ isUserInviteModalShown: !this.state.isUserInviteModalShown });
+    await this.setState({
+      isUserInviteModalShown: !this.state.isUserInviteModalShown,
+    });
   }
   }
 
 
   /**
   /**
@@ -304,5 +309,4 @@ export default class AdminUsersContainer extends Container {
     await this.retrieveUsersByPagingNum(this.state.activePage);
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return removedUserData;
     return removedUserData;
   }
   }
-
 }
 }

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

@@ -1,7 +1,12 @@
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import type { IApiv3PageCreateParams, IApiv3PageCreateResponse } from '~/interfaces/apiv3';
+import type {
+  IApiv3PageCreateParams,
+  IApiv3PageCreateResponse,
+} from '~/interfaces/apiv3';
 
 
-export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+export const createPage = async (
+  params: IApiv3PageCreateParams,
+): Promise<IApiv3PageCreateResponse> => {
   const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
   const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
   return res.data;
   return res.data;
 };
 };

+ 95 - 81
apps/app/src/client/services/create-page/use-create-page.tsx

@@ -1,13 +1,15 @@
 import { useCallback, useState } from 'react';
 import { useCallback, useState } from 'react';
-
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
+import {
+  exist,
+  getIsNonUserRelatedGroupsGranted,
+} from '~/client/services/page-operation';
 import { toastWarning } from '~/client/util/toastr';
 import { toastWarning } from '~/client/util/toastr';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { useCurrentPagePath, useSetIsUntitledPage } from '~/states/page';
 import { useCurrentPagePath, useSetIsUntitledPage } from '~/states/page';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import { useGrantedGroupsInheritanceSelectModalActions } from '~/states/ui/modal/granted-groups-inheritance-select';
 import { useGrantedGroupsInheritanceSelectModalActions } from '~/states/ui/modal/granted-groups-inheritance-select';
 
 
 import { createPage } from './create-page';
 import { createPage } from './create-page';
@@ -26,13 +28,13 @@ type OnAborted = () => void;
 type OnTerminated = () => void;
 type OnTerminated = () => void;
 
 
 export type CreatePageOpts = {
 export type CreatePageOpts = {
-  skipPageExistenceCheck?: boolean,
-  skipTransition?: boolean,
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onTerminated?: OnTerminated,
-}
+  skipPageExistenceCheck?: boolean;
+  skipTransition?: boolean;
+  onCreationStart?: OnCreated;
+  onCreated?: OnCreated;
+  onAborted?: OnAborted;
+  onTerminated?: OnTerminated;
+};
 
 
 type CreatePage = (
 type CreatePage = (
   params: IApiv3PageCreateParams,
   params: IApiv3PageCreateParams,
@@ -40,101 +42,113 @@ type CreatePage = (
 ) => Promise<void>;
 ) => Promise<void>;
 
 
 type UseCreatePage = () => {
 type UseCreatePage = () => {
-  isCreating: boolean,
-  create: CreatePage,
+  isCreating: boolean;
+  create: CreatePage;
 };
 };
 
 
 export const useCreatePage: UseCreatePage = () => {
 export const useCreatePage: UseCreatePage = () => {
-
   const router = useRouter();
   const router = useRouter();
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const currentPagePath = useCurrentPagePath();
   const currentPagePath = useCurrentPagePath();
   const { setEditorMode } = useEditorMode();
   const { setEditorMode } = useEditorMode();
   const setIsUntitledPage = useSetIsUntitledPage();
   const setIsUntitledPage = useSetIsUntitledPage();
-  const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModalActions();
+  const {
+    open: openGrantedGroupsInheritanceSelectModal,
+    close: closeGrantedGroupsInheritanceSelectModal,
+  } = useGrantedGroupsInheritanceSelectModalActions();
 
 
   const [isCreating, setCreating] = useState(false);
   const [isCreating, setCreating] = useState(false);
 
 
-  const create: CreatePage = useCallback(async (params, opts = {}) => {
-    const {
-      onCreationStart, onCreated, onAborted, onTerminated,
-    } = opts;
-    const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
-    const skipTransition = opts.skipTransition ?? false;
-
-    // check the page existence
-    if (!skipPageExistenceCheck && params.path != null) {
-      const pagePath = params.path;
-
-      try {
-        const { isExist } = await exist(pagePath);
-
-        if (isExist) {
-          if (!skipTransition) {
-            // routing
-            if (pagePath !== currentPagePath) {
-              await router.push(`${pagePath}#edit`);
+  const create: CreatePage = useCallback(
+    async (params, opts = {}) => {
+      const { onCreationStart, onCreated, onAborted, onTerminated } = opts;
+      const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
+      const skipTransition = opts.skipTransition ?? false;
+
+      // check the page existence
+      if (!skipPageExistenceCheck && params.path != null) {
+        const pagePath = params.path;
+
+        try {
+          const { isExist } = await exist(pagePath);
+
+          if (isExist) {
+            if (!skipTransition) {
+              // routing
+              if (pagePath !== currentPagePath) {
+                await router.push(`${pagePath}#edit`);
+              }
+              setEditorMode(EditorMode.Editor);
+            } else {
+              toastWarning(
+                t('duplicated_page_alert.same_page_name_exists', {
+                  pageName: pagePath,
+                }),
+              );
             }
             }
-            setEditorMode(EditorMode.Editor);
-          }
-          else {
-            toastWarning(t('duplicated_page_alert.same_page_name_exists', { pageName: pagePath }));
+            onAborted?.();
+            return;
           }
           }
-          onAborted?.();
-          return;
+        } catch (err) {
+          throw err;
+        } finally {
+          onTerminated?.();
         }
         }
       }
       }
-      catch (err) {
-        throw err;
-      }
-      finally {
-        onTerminated?.();
-      }
-    }
 
 
-    const _create = async (onlyInheritUserRelatedGrantedGroups?: boolean) => {
-      try {
-        setCreating(true);
-        onCreationStart?.();
+      const _create = async (onlyInheritUserRelatedGrantedGroups?: boolean) => {
+        try {
+          setCreating(true);
+          onCreationStart?.();
 
 
-        params.onlyInheritUserRelatedGrantedGroups = onlyInheritUserRelatedGrantedGroups;
-        const response = await createPage(params);
+          params.onlyInheritUserRelatedGrantedGroups =
+            onlyInheritUserRelatedGrantedGroups;
+          const response = await createPage(params);
 
 
-        closeGrantedGroupsInheritanceSelectModal();
+          closeGrantedGroupsInheritanceSelectModal();
 
 
-        if (!skipTransition) {
-          await router.push(`/${response.page._id}#edit`);
-          setEditorMode(EditorMode.Editor);
-        }
+          if (!skipTransition) {
+            await router.push(`/${response.page._id}#edit`);
+            setEditorMode(EditorMode.Editor);
+          }
 
 
-        if (params.path == null) {
-          setIsUntitledPage(true);
-        }
+          if (params.path == null) {
+            setIsUntitledPage(true);
+          }
 
 
-        onCreated?.();
-      }
-      catch (err) {
-        throw err;
-      }
-      finally {
-        onTerminated?.();
-        setCreating(false);
-      }
-    };
-
-    // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
-    if (params.parentPath != null) {
-      const { isNonUserRelatedGroupsGranted } = await getIsNonUserRelatedGroupsGranted(params.parentPath);
-      if (isNonUserRelatedGroupsGranted) {
-        // create and transit request will be made from modal
-        openGrantedGroupsInheritanceSelectModal(_create);
-        return;
+          onCreated?.();
+        } catch (err) {
+          throw err;
+        } finally {
+          onTerminated?.();
+          setCreating(false);
+        }
+      };
+
+      // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
+      if (params.parentPath != null) {
+        const { isNonUserRelatedGroupsGranted } =
+          await getIsNonUserRelatedGroupsGranted(params.parentPath);
+        if (isNonUserRelatedGroupsGranted) {
+          // create and transit request will be made from modal
+          openGrantedGroupsInheritanceSelectModal(_create);
+          return;
+        }
       }
       }
-    }
 
 
-    await _create();
-  }, [currentPagePath, setEditorMode, router, t, closeGrantedGroupsInheritanceSelectModal, setIsUntitledPage, openGrantedGroupsInheritanceSelectModal]);
+      await _create();
+    },
+    [
+      currentPagePath,
+      setEditorMode,
+      router,
+      t,
+      closeGrantedGroupsInheritanceSelectModal,
+      setIsUntitledPage,
+      openGrantedGroupsInheritanceSelectModal,
+    ],
+  );
 
 
   return {
   return {
     isCreating,
     isCreating,

+ 21 - 18
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -7,31 +6,35 @@ import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { LabelType } from '~/interfaces/template';
 import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/states/page';
 import { useCurrentPagePath } from '~/states/page';
 
 
-
 import { useCreatePage } from './use-create-page';
 import { useCreatePage } from './use-create-page';
 
 
 type UseCreateTemplatePage = () => {
 type UseCreateTemplatePage = () => {
-  isCreatable: boolean,
-  isCreating: boolean,
-  createTemplate?: (label: LabelType) => Promise<void>,
-}
+  isCreatable: boolean;
+  isCreating: boolean;
+  createTemplate?: (label: LabelType) => Promise<void>;
+};
 
 
 export const useCreateTemplatePage: UseCreateTemplatePage = () => {
 export const useCreateTemplatePage: UseCreateTemplatePage = () => {
-
   const currentPagePath = useCurrentPagePath();
   const currentPagePath = useCurrentPagePath();
 
 
   const { isCreating, create } = useCreatePage();
   const { isCreating, create } = useCreatePage();
-  const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
-
-  const createTemplate = useCallback(async(label: LabelType) => {
-    if (currentPagePath == null || !isCreatable) return;
-
-    return create(
-      {
-        path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
-      },
-    );
-  }, [currentPagePath, isCreatable, create]);
+  const isCreatable =
+    currentPagePath != null &&
+    isCreatablePage(normalizePath(`${currentPagePath}/_template`));
+
+  const createTemplate = useCallback(
+    async (label: LabelType) => {
+      if (currentPagePath == null || !isCreatable) return;
+
+      return create({
+        path: normalizePath(`${currentPagePath}/${label}`),
+        parentPath: currentPagePath,
+        wip: false,
+        origin: Origin.View,
+      });
+    },
+    [currentPagePath, isCreatable, create],
+  );
 
 
   return {
   return {
     isCreatable,
     isCreatable,

+ 8 - 3
apps/app/src/client/services/g2g-transfer.ts

@@ -2,11 +2,16 @@ import { useCallback, useState } from 'react';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
 
-export const useGenerateTransferKey = (): {transferKey: string, generateTransferKey: () => Promise<void>} => {
+export const useGenerateTransferKey = (): {
+  transferKey: string;
+  generateTransferKey: () => Promise<void>;
+} => {
   const [transferKey, setTransferKey] = useState('');
   const [transferKey, setTransferKey] = useState('');
 
 
-  const generateTransferKey = useCallback(async() => {
-    const response = await apiv3Post('/g2g-transfer/generate-key', { appSiteUrl: window.location.origin });
+  const generateTransferKey = useCallback(async () => {
+    const response = await apiv3Post('/g2g-transfer/generate-key', {
+      appSiteUrl: window.location.origin,
+    });
     const { transferKey } = response.data;
     const { transferKey } = response.data;
     setTransferKey(transferKey);
     setTransferKey(transferKey);
   }, []);
   }, []);

+ 0 - 1
apps/app/src/client/services/maintenance-mode.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { useSetAtom } from 'jotai';
 import { useSetAtom } from 'jotai';
 
 
 import { _atomsForMaintenanceMode } from '../../states/global';
 import { _atomsForMaintenanceMode } from '../../states/global';

+ 91 - 49
apps/app/src/client/services/page-operation.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import type { IPageHasId } from '@growi/core';
 import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
@@ -17,67 +16,81 @@ import { toastError } from '../util/toastr';
 
 
 const logger = loggerFactory('growi:services:page-operation');
 const logger = loggerFactory('growi:services:page-operation');
 
 
-
-export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+export const toggleSubscribe = async (
+  pageId: string,
+  currentStatus: SubscriptionStatusType | undefined,
+): Promise<void> => {
   try {
   try {
-    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
-      ? SubscriptionStatusType.UNSUBSCRIBE
-      : SubscriptionStatusType.SUBSCRIBE;
+    const newStatus =
+      currentStatus === SubscriptionStatusType.SUBSCRIBE
+        ? SubscriptionStatusType.UNSUBSCRIBE
+        : SubscriptionStatusType.SUBSCRIBE;
 
 
     await apiv3Put('/page/subscribe', { pageId, status: newStatus });
     await apiv3Put('/page/subscribe', { pageId, status: newStatus });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
     toastError(err);
   }
   }
 };
 };
 
 
-export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+export const toggleLike = async (
+  pageId: string,
+  currentValue?: boolean,
+): Promise<void> => {
   try {
   try {
     await apiv3Put('/page/likes', { pageId, bool: !currentValue });
     await apiv3Put('/page/likes', { pageId, bool: !currentValue });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
     toastError(err);
   }
   }
 };
 };
 
 
-export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+export const toggleBookmark = async (
+  pageId: string,
+  currentValue?: boolean,
+): Promise<void> => {
   try {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
     await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
     toastError(err);
   }
   }
 };
 };
 
 
-export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+export const updateContentWidth = async (
+  pageId: string,
+  newValue: boolean,
+): Promise<void> => {
   try {
   try {
-    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
-  }
-  catch (err) {
+    await apiv3Put(`/page/${pageId}/content-width`, {
+      expandContentWidth: newValue,
+    });
+  } catch (err) {
     toastError(err);
     toastError(err);
   }
   }
 };
 };
 
 
-export const bookmark = async(pageId: string): Promise<void> => {
+export const bookmark = async (pageId: string): Promise<void> => {
   try {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });
     await apiv3Put('/bookmarks', { pageId, bool: true });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
     toastError(err);
   }
   }
 };
 };
 
 
-export const unbookmark = async(pageId: string): Promise<void> => {
+export const unbookmark = async (pageId: string): Promise<void> => {
   try {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: false });
     await apiv3Put('/bookmarks', { pageId, bool: false });
-  }
-  catch (err) {
+  } catch (err) {
     toastError(err);
     toastError(err);
   }
   }
 };
 };
 
 
-export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
-  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+export const exportAsMarkdown = (
+  pageId: string,
+  revisionId: string,
+  format: string,
+): void => {
+  const url = new URL(
+    urljoin(window.location.origin, '_api/v3/page/export', pageId),
+  );
   url.searchParams.append('format', format);
   url.searchParams.append('format', format);
   url.searchParams.append('revisionId', revisionId);
   url.searchParams.append('revisionId', revisionId);
   window.location.href = url.href;
   window.location.href = url.href;
@@ -86,34 +99,46 @@ export const exportAsMarkdown = (pageId: string, revisionId: string, format: str
 /**
 /**
  * send request to fix broken paths caused by unexpected events such as server shutdown while renaming page paths
  * send request to fix broken paths caused by unexpected events such as server shutdown while renaming page paths
  */
  */
-export const resumeRenameOperation = async(pageId: string): Promise<void> => {
+export const resumeRenameOperation = async (pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 };
 
 
 export type UpdateStateAfterSaveOption = {
 export type UpdateStateAfterSaveOption = {
-  supressEditingMarkdownMutation: boolean,
-}
+  supressEditingMarkdownMutation: boolean;
+};
 
 
-export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: UpdateStateAfterSaveOption): (() => Promise<void>) | undefined => {
+export const useUpdateStateAfterSave = (
+  pageId: string | undefined | null,
+  opts?: UpdateStateAfterSaveOption,
+): (() => Promise<void>) | undefined => {
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
 
   const setEditingMarkdown = useSetEditingMarkdown();
   const setEditingMarkdown = useSetEditingMarkdown();
-  const { mutate: mutateCurrentGrantData } = useSWRxCurrentGrantData(isGuestUser ? null : pageId);
-  const { mutate: mutateApplicableGrant } = useSWRxApplicableGrant(isGuestUser ? null : pageId);
+  const { mutate: mutateCurrentGrantData } = useSWRxCurrentGrantData(
+    isGuestUser ? null : pageId,
+  );
+  const { mutate: mutateApplicableGrant } = useSWRxApplicableGrant(
+    isGuestUser ? null : pageId,
+  );
 
 
   // update swr 'currentPageId', 'currentPage', remote states
   // update swr 'currentPageId', 'currentPage', remote states
-  return useCallback(async() => {
-    if (pageId == null) { return }
+  return useCallback(async () => {
+    if (pageId == null) {
+      return;
+    }
 
 
     const updatedPage = await fetchCurrentPage({ pageId, force: true });
     const updatedPage = await fetchCurrentPage({ pageId, force: true });
 
 
-    if (updatedPage == null || updatedPage.revision == null) { return }
+    if (updatedPage == null || updatedPage.revision == null) {
+      return;
+    }
 
 
     // supress to mutate only when updated from built-in editor
     // supress to mutate only when updated from built-in editor
     // and see: https://github.com/growilabs/growi/pull/7118
     // and see: https://github.com/growilabs/growi/pull/7118
-    const supressEditingMarkdownMutation = opts?.supressEditingMarkdownMutation ?? false;
+    const supressEditingMarkdownMutation =
+      opts?.supressEditingMarkdownMutation ?? false;
     if (!supressEditingMarkdownMutation) {
     if (!supressEditingMarkdownMutation) {
       setEditingMarkdown(updatedPage.revision.body);
       setEditingMarkdown(updatedPage.revision.body);
     }
     }
@@ -129,44 +154,61 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
     };
     };
 
 
     setRemoteLatestPageData(remoterevisionData);
     setRemoteLatestPageData(remoterevisionData);
-  },
-  [pageId, fetchCurrentPage, opts?.supressEditingMarkdownMutation, mutateCurrentGrantData, mutateApplicableGrant, setRemoteLatestPageData, setEditingMarkdown]);
+  }, [
+    pageId,
+    fetchCurrentPage,
+    opts?.supressEditingMarkdownMutation,
+    mutateCurrentGrantData,
+    mutateApplicableGrant,
+    setRemoteLatestPageData,
+    setEditingMarkdown,
+  ]);
 };
 };
 
 
-export const unlink = async(path: string): Promise<void> => {
+export const unlink = async (path: string): Promise<void> => {
   await apiPost('/pages.unlink', { path });
   await apiPost('/pages.unlink', { path });
 };
 };
 
 
-
 interface PageExistResponse {
 interface PageExistResponse {
-  isExist: boolean,
+  isExist: boolean;
 }
 }
 
 
-export const exist = async(path: string): Promise<PageExistResponse> => {
+export const exist = async (path: string): Promise<PageExistResponse> => {
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
   return res.data;
   return res.data;
 };
 };
 
 
 interface NonUserRelatedGroupsGrantedResponse {
 interface NonUserRelatedGroupsGrantedResponse {
-  isNonUserRelatedGroupsGranted: boolean,
+  isNonUserRelatedGroupsGranted: boolean;
 }
 }
 
 
-export const getIsNonUserRelatedGroupsGranted = async(path: string): Promise<NonUserRelatedGroupsGrantedResponse> => {
-  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>('/page/non-user-related-groups-granted', { path });
+export const getIsNonUserRelatedGroupsGranted = async (
+  path: string,
+): Promise<NonUserRelatedGroupsGrantedResponse> => {
+  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>(
+    '/page/non-user-related-groups-granted',
+    { path },
+  );
   return res.data;
   return res.data;
 };
 };
 
 
-export const publish = async(pageId: string): Promise<IPageHasId> => {
+export const publish = async (pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/publish`);
   const res = await apiv3Put(`/page/${pageId}/publish`);
   return res.data;
   return res.data;
 };
 };
 
 
-export const unpublish = async(pageId: string): Promise<IPageHasId> => {
+export const unpublish = async (pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   const res = await apiv3Put(`/page/${pageId}/unpublish`);
   return res.data;
   return res.data;
 };
 };
 
 
-export const syncLatestRevisionBody = async(pageId: string, editingMarkdownLength?: number): Promise<SyncLatestRevisionBody> => {
-  const res = await apiv3Put(`/page/${pageId}/sync-latest-revision-body-to-yjs-draft`, { editingMarkdownLength });
+export const syncLatestRevisionBody = async (
+  pageId: string,
+  editingMarkdownLength?: number,
+): Promise<SyncLatestRevisionBody> => {
+  const res = await apiv3Put(
+    `/page/${pageId}/sync-latest-revision-body-to-yjs-draft`,
+    { editingMarkdownLength },
+  );
   return res.data;
   return res.data;
 };
 };

+ 111 - 83
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,10 +1,9 @@
-import assert from 'assert';
-
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import * as presentation from '@growi/presentation/dist/client/services/sanitize-option';
 import * as presentation from '@growi/presentation/dist/client/services/sanitize-option';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 import * as drawio from '@growi/remark-drawio';
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
+import assert from 'assert';
 import katex from 'rehype-katex';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
 import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
 import slug from 'rehype-slug';
@@ -24,7 +23,7 @@ import * as callout from '~/features/callout';
 import * as mermaid from '~/features/mermaid';
 import * as mermaid from '~/features/mermaid';
 import * as plantuml from '~/features/plantuml';
 import * as plantuml from '~/features/plantuml';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererOptions } from '~/interfaces/renderer-options';
-import { type RendererConfigExt } from '~/interfaces/services/renderer';
+import type { RendererConfigExt } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
@@ -32,29 +31,26 @@ import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as codeBlock from '~/services/renderer/remark-plugins/codeblock';
 import * as codeBlock from '~/services/renderer/remark-plugins/codeblock';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
 import {
-  getCommonSanitizeOption, generateCommonOptions, verifySanitizePlugin,
+  generateCommonOptions,
+  getCommonSanitizeOption,
+  verifySanitizePlugin,
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // import EasyGrid from './PreProcessor/EasyGrid';
 // import EasyGrid from './PreProcessor/EasyGrid';
 
 
-
 import '@growi/remark-lsx/dist/client/style.css';
 import '@growi/remark-lsx/dist/client/style.css';
 import '@growi/remark-attachment-refs/dist/client/style.css';
 import '@growi/remark-attachment-refs/dist/client/style.css';
 
 
-
 const logger = loggerFactory('growi:cli:services:renderer');
 const logger = loggerFactory('growi:cli:services:renderer');
 
 
-
 assert(isClient(), 'This module must be loaded only from client modules.');
 assert(isClient(), 'This module must be loaded only from client modules.');
 
 
-
 export const generateViewOptions = (
 export const generateViewOptions = (
-    pagePath: string,
-    config: RendererConfigExt,
-    storeTocNode: (toc: HtmlElementNode) => void,
+  pagePath: string,
+  config: RendererConfigExt,
+  storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
 ): RendererOptions => {
-
   const options = generateCommonOptions(pagePath);
   const options = generateCommonOptions(pagePath);
 
 
   const { remarkPlugins, rehypePlugins, components } = options;
   const { remarkPlugins, rehypePlugins, components } = options;
@@ -62,7 +58,10 @@ export const generateViewOptions = (
   // add remark plugins
   // add remark plugins
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
+    [
+      plantuml.remarkPlugin,
+      { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode },
+    ],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
@@ -76,24 +75,31 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      presentation.sanitizeOption,
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      callout.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(
+            getCommonSanitizeOption(config),
+            presentation.sanitizeOption,
+            drawio.sanitizeOption,
+            mermaid.sanitizeOption,
+            callout.sanitizeOption,
+            attachment.sanitizeOption,
+            lsxGrowiDirective.sanitizeOption,
+            refsGrowiDirective.sanitizeOption,
+            codeBlock.sanitizeOption,
+          ),
+        ]
+      : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
     slug,
     slug,
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [
+      lsxGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     rehypeSanitizePlugin,
     katex,
     katex,
@@ -128,8 +134,10 @@ export const generateViewOptions = (
   return options;
   return options;
 };
 };
 
 
-export const generateTocOptions = (config: RendererConfigExt, tocNode: HtmlElementNode | undefined): RendererOptions => {
-
+export const generateTocOptions = (
+  config: RendererConfigExt,
+  tocNode: HtmlElementNode | undefined,
+): RendererOptions => {
   const options = generateCommonOptions(undefined);
   const options = generateCommonOptions(undefined);
 
 
   const { rehypePlugins } = options;
   const { rehypePlugins } = options;
@@ -137,12 +145,13 @@ export const generateTocOptions = (config: RendererConfigExt, tocNode: HtmlEleme
   // add remark plugins
   // add remark plugins
   // remarkPlugins.push();
   // remarkPlugins.push();
 
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(getCommonSanitizeOption(config), codeBlock.sanitizeOption),
+        ]
+      : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
@@ -158,10 +167,10 @@ export const generateTocOptions = (config: RendererConfigExt, tocNode: HtmlEleme
 };
 };
 
 
 export const generateSimpleViewOptions = (
 export const generateSimpleViewOptions = (
-    config: RendererConfigExt,
-    pagePath: string,
-    highlightKeywords?: string | string[],
-    overrideIsEnabledLinebreaks?: boolean,
+  config: RendererConfigExt,
+  pagePath: string,
+  highlightKeywords?: string | string[],
+  overrideIsEnabledLinebreaks?: boolean,
 ): RendererOptions => {
 ): RendererOptions => {
   const options = generateCommonOptions(pagePath);
   const options = generateCommonOptions(pagePath);
 
 
@@ -170,7 +179,10 @@ export const generateSimpleViewOptions = (
   // add remark plugins
   // add remark plugins
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
+    [
+      plantuml.remarkPlugin,
+      { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode },
+    ],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
@@ -181,29 +193,37 @@ export const generateSimpleViewOptions = (
     refsGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
   );
 
 
-  const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
+  const isEnabledLinebreaks =
+    overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
 
 
   if (isEnabledLinebreaks) {
   if (isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      presentation.sanitizeOption,
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      callout.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(
+            getCommonSanitizeOption(config),
+            presentation.sanitizeOption,
+            drawio.sanitizeOption,
+            mermaid.sanitizeOption,
+            callout.sanitizeOption,
+            attachment.sanitizeOption,
+            lsxGrowiDirective.sanitizeOption,
+            refsGrowiDirective.sanitizeOption,
+            codeBlock.sanitizeOption,
+          ),
+        ]
+      : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [
+      lsxGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     rehypeSanitizePlugin,
@@ -232,26 +252,21 @@ export const generateSimpleViewOptions = (
 };
 };
 
 
 export const generatePresentationViewOptions = (
 export const generatePresentationViewOptions = (
-    config: RendererConfigExt,
-    pagePath: string,
+  config: RendererConfigExt,
+  pagePath: string,
 ): RendererOptions => {
 ): RendererOptions => {
   // based on simple view options
   // based on simple view options
   const options = generateSimpleViewOptions(config, pagePath);
   const options = generateSimpleViewOptions(config, pagePath);
 
 
   const { rehypePlugins } = options;
   const { rehypePlugins } = options;
 
 
-
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      addLineNumberAttribute.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [sanitize, deepmerge(addLineNumberAttribute.sanitizeOption)]
+      : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
-  rehypePlugins.push(
-    addLineNumberAttribute.rehypePlugin,
-    rehypeSanitizePlugin,
-  );
+  rehypePlugins.push(addLineNumberAttribute.rehypePlugin, rehypeSanitizePlugin);
 
 
   if (config.isEnabledXssPrevention) {
   if (config.isEnabledXssPrevention) {
     verifySanitizePlugin(options, false);
     verifySanitizePlugin(options, false);
@@ -259,7 +274,10 @@ export const generatePresentationViewOptions = (
   return options;
   return options;
 };
 };
 
 
-export const generatePreviewOptions = (config: RendererConfigExt, pagePath: string): RendererOptions => {
+export const generatePreviewOptions = (
+  config: RendererConfigExt,
+  pagePath: string,
+): RendererOptions => {
   const options = generateCommonOptions(pagePath);
   const options = generateCommonOptions(pagePath);
 
 
   const { remarkPlugins, rehypePlugins, components } = options;
   const { remarkPlugins, rehypePlugins, components } = options;
@@ -267,7 +285,10 @@ export const generatePreviewOptions = (config: RendererConfigExt, pagePath: stri
   // add remark plugins
   // add remark plugins
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
+    [
+      plantuml.remarkPlugin,
+      { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode },
+    ],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
@@ -281,23 +302,30 @@ export const generatePreviewOptions = (config: RendererConfigExt, pagePath: stri
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      getCommonSanitizeOption(config),
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      callout.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      addLineNumberAttribute.sanitizeOption,
-      codeBlock.sanitizeOption,
-    )]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [
+          sanitize,
+          deepmerge(
+            getCommonSanitizeOption(config),
+            drawio.sanitizeOption,
+            mermaid.sanitizeOption,
+            callout.sanitizeOption,
+            attachment.sanitizeOption,
+            lsxGrowiDirective.sanitizeOption,
+            refsGrowiDirective.sanitizeOption,
+            addLineNumberAttribute.sanitizeOption,
+            codeBlock.sanitizeOption,
+          ),
+        ]
+      : () => {};
 
 
   // add rehype plugins
   // add rehype plugins
   rehypePlugins.push(
   rehypePlugins.push(
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [
+      lsxGrowiDirective.rehypePlugin,
+      { pagePath, isSharedPage: config.isSharedPage },
+    ],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     [refsGrowiDirective.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     rehypeSanitizePlugin,

+ 107 - 62
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -1,98 +1,141 @@
 import { useCallback, useEffect } from 'react';
 import { useCallback, useEffect } from 'react';
-
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
 
 import { replaceDrawioInMarkdown } from '~/client/components/Page/markdown-drawio-util-for-view';
 import { replaceDrawioInMarkdown } from '~/client/components/Page/markdown-drawio-util-for-view';
-import { extractRemoteRevisionDataFromErrorObj, useUpdatePage } from '~/client/services/update-page';
-import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
+import {
+  extractRemoteRevisionDataFromErrorObj,
+  useUpdatePage,
+} from '~/client/services/update-page';
 import type { RemoteRevisionData } from '~/states/page';
 import type { RemoteRevisionData } from '~/states/page';
+import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { useDrawioModalActions } from '~/states/ui/modal/drawio';
 import { useDrawioModalActions } from '~/states/ui/modal/drawio';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
-const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
-
+const logger = loggerFactory(
+  'growi:cli:side-effects:useDrawioModalLauncherForView',
+);
 
 
 export const useDrawioModalLauncherForView = (opts?: {
 export const useDrawioModalLauncherForView = (opts?: {
-  onSaveSuccess?: () => void,
-  onSaveError?: (error: any) => void,
+  onSaveSuccess?: () => void;
+  onSaveError?: (error: any) => void;
 }): void => {
 }): void => {
-
   const shareLinkId = useShareLinkId();
   const shareLinkId = useShareLinkId();
 
 
   const currentPage = useCurrentPageData();
   const currentPage = useCurrentPageData();
 
 
   const { open: openDrawioModal } = useDrawioModalActions();
   const { open: openDrawioModal } = useDrawioModalActions();
 
 
-  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModalActions();
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } =
+    useConflictDiffModalActions();
 
 
   const _updatePage = useUpdatePage();
   const _updatePage = useUpdatePage();
 
 
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
-      return;
-    }
-
-    // There are cases where "revisionId" is not required for revision updates
-    // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-    try {
-      await _updatePage({
-        pageId: currentPage._id,
-        revisionId,
-        body: newMarkdown,
-        origin: Origin.View,
-      });
-
-      closeConflictDiffModal();
-      opts?.onSaveSuccess?.();
-    }
-    catch (error) {
-      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
-      if (remoteRevidsionData != null) {
-        onConflict(remoteRevidsionData, newMarkdown);
+  const updatePage = useCallback(
+    async (
+      revisionId: string,
+      newMarkdown: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      if (
+        currentPage == null ||
+        currentPage.revision == null ||
+        shareLinkId != null
+      ) {
+        return;
       }
       }
 
 
-      logger.error('failed to save', error);
-      opts?.onSaveError?.(error);
-    }
-  }, [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId]);
+      // There are cases where "revisionId" is not required for revision updates
+      // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
+      try {
+        await _updatePage({
+          pageId: currentPage._id,
+          revisionId,
+          body: newMarkdown,
+          origin: Origin.View,
+        });
+
+        closeConflictDiffModal();
+        opts?.onSaveSuccess?.();
+      } catch (error) {
+        const remoteRevidsionData =
+          extractRemoteRevisionDataFromErrorObj(error);
+        if (remoteRevidsionData != null) {
+          onConflict(remoteRevidsionData, newMarkdown);
+        }
+
+        logger.error('failed to save', error);
+        opts?.onSaveError?.(error);
+      }
+    },
+    [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId],
+  );
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    return async(newMarkdown: string) => {
-      await updatePage(revisionId, newMarkdown, onConflict);
-    };
-  }, [updatePage]);
-
-  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
-    setRemoteLatestPageData(remoteRevidsionData);
-
-    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
-    if (resolveConflictHandler == null) {
-      return;
-    }
-
-    openConflictDiffModal(newMarkdown, resolveConflictHandler);
-  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+  const generateResolveConflictHandler = useCallback(
+    (
+      revisionId: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      return async (newMarkdown: string) => {
+        await updatePage(revisionId, newMarkdown, onConflict);
+      };
+    },
+    [updatePage],
+  );
+
+  const onConflictHandler = useCallback(
+    (remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+      setRemoteLatestPageData(remoteRevidsionData);
+
+      const resolveConflictHandler = generateResolveConflictHandler(
+        remoteRevidsionData.remoteRevisionId,
+        onConflictHandler,
+      );
+      if (resolveConflictHandler == null) {
+        return;
+      }
 
 
-  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || currentPage.revision == null) {
-      return;
-    }
+      openConflictDiffModal(newMarkdown, resolveConflictHandler);
+    },
+    [
+      generateResolveConflictHandler,
+      openConflictDiffModal,
+      setRemoteLatestPageData,
+    ],
+  );
+
+  const saveByDrawioModal = useCallback(
+    async (drawioMxFile: string, bol: number, eol: number) => {
+      if (currentPage == null || currentPage.revision == null) {
+        return;
+      }
 
 
-    const currentRevisionId = currentPage.revision._id;
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+      const currentRevisionId = currentPage.revision._id;
+      const currentMarkdown = currentPage.revision.body;
+      const newMarkdown = replaceDrawioInMarkdown(
+        drawioMxFile,
+        currentMarkdown,
+        bol,
+        eol,
+      );
 
 
-    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
-  }, [currentPage, onConflictHandler, updatePage]);
+      await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+    },
+    [currentPage, onConflictHandler, updatePage],
+  );
 
 
   // set handler to open DrawioModal
   // set handler to open DrawioModal
   useEffect(() => {
   useEffect(() => {
@@ -103,7 +146,9 @@ export const useDrawioModalLauncherForView = (opts?: {
 
 
     const handler = (evt: CustomEvent<DrawioEditByViewerProps>) => {
     const handler = (evt: CustomEvent<DrawioEditByViewerProps>) => {
       const data = evt.detail;
       const data = evt.detail;
-      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+      openDrawioModal(data.drawioMxFile, (drawioMxFile) =>
+        saveByDrawioModal(drawioMxFile, data.bol, data.eol),
+      );
     };
     };
     globalEventTarget.addEventListener('launchDrawioModal', handler);
     globalEventTarget.addEventListener('launchDrawioModal', handler);
 
 

+ 117 - 64
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -1,99 +1,145 @@
 import { useCallback, useEffect } from 'react';
 import { useCallback, useEffect } from 'react';
-
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import { globalEventTarget } from '@growi/core/dist/utils';
 import type { MarkdownTable } from '@growi/editor';
 import type { MarkdownTable } from '@growi/editor';
 
 
-import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/client/components/Page/markdown-table-util-for-view';
+import {
+  getMarkdownTableFromLine,
+  replaceMarkdownTableInMarkdown,
+} from '~/client/components/Page/markdown-table-util-for-view';
 import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal';
 import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal';
-import { extractRemoteRevisionDataFromErrorObj, useUpdatePage } from '~/client/services/update-page';
-import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
+import {
+  extractRemoteRevisionDataFromErrorObj,
+  useUpdatePage,
+} from '~/client/services/update-page';
 import type { RemoteRevisionData } from '~/states/page';
 import type { RemoteRevisionData } from '~/states/page';
+import { useCurrentPageData, useSetRemoteLatestPageData } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { useHandsontableModalActions } from '~/states/ui/modal/handsontable';
 import { useHandsontableModalActions } from '~/states/ui/modal/handsontable';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
-const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
-
+const logger = loggerFactory(
+  'growi:cli:side-effects:useHandsontableModalLauncherForView',
+);
 
 
 export const useHandsontableModalLauncherForView = (opts?: {
 export const useHandsontableModalLauncherForView = (opts?: {
-  onSaveSuccess?: () => void,
-  onSaveError?: (error: any) => void,
+  onSaveSuccess?: () => void;
+  onSaveError?: (error: any) => void;
 }): void => {
 }): void => {
-
   const shareLinkId = useShareLinkId();
   const shareLinkId = useShareLinkId();
 
 
   const currentPage = useCurrentPageData();
   const currentPage = useCurrentPageData();
 
 
   const { open: openHandsontableModal } = useHandsontableModalActions();
   const { open: openHandsontableModal } = useHandsontableModalActions();
 
 
-  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModalActions();
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } =
+    useConflictDiffModalActions();
 
 
   const _updatePage = useUpdatePage();
   const _updatePage = useUpdatePage();
 
 
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
-      return;
-    }
-
-    try {
-      // There are cases where "revisionId" is not required for revision updates
-      // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
-      await _updatePage({
-        pageId: currentPage._id,
-        revisionId,
-        body: newMarkdown,
-        origin: Origin.View,
-      });
-
-      closeConflictDiffModal();
-      opts?.onSaveSuccess?.();
-    }
-    catch (error) {
-      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
-      if (remoteRevidsionData != null) {
-        onConflict?.(remoteRevidsionData, newMarkdown);
+  const updatePage = useCallback(
+    async (
+      revisionId: string,
+      newMarkdown: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      if (
+        currentPage == null ||
+        currentPage.revision == null ||
+        shareLinkId != null
+      ) {
+        return;
       }
       }
 
 
-      logger.error('failed to save', error);
-      opts?.onSaveError?.(error);
-    }
-  }, [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId]);
+      try {
+        // There are cases where "revisionId" is not required for revision updates
+        // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
+        await _updatePage({
+          pageId: currentPage._id,
+          revisionId,
+          body: newMarkdown,
+          origin: Origin.View,
+        });
+
+        closeConflictDiffModal();
+        opts?.onSaveSuccess?.();
+      } catch (error) {
+        const remoteRevidsionData =
+          extractRemoteRevisionDataFromErrorObj(error);
+        if (remoteRevidsionData != null) {
+          onConflict?.(remoteRevidsionData, newMarkdown);
+        }
+
+        logger.error('failed to save', error);
+        opts?.onSaveError?.(error);
+      }
+    },
+    [_updatePage, closeConflictDiffModal, currentPage, opts, shareLinkId],
+  );
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
-    return async(newMarkdown: string) => {
-      await updatePage(revisionId, newMarkdown, onConflict);
-    };
-  }, [updatePage]);
-
-  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
-    setRemoteLatestPageData(remoteRevidsionData);
-
-    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
-    if (resolveConflictHandler == null) {
-      return;
-    }
-
-    openConflictDiffModal(newMarkdown, resolveConflictHandler);
-  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+  const generateResolveConflictHandler = useCallback(
+    (
+      revisionId: string,
+      onConflict: (
+        conflictData: RemoteRevisionData,
+        newMarkdown: string,
+      ) => void,
+    ) => {
+      return async (newMarkdown: string) => {
+        await updatePage(revisionId, newMarkdown, onConflict);
+      };
+    },
+    [updatePage],
+  );
+
+  const onConflictHandler = useCallback(
+    (remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+      setRemoteLatestPageData(remoteRevidsionData);
+
+      const resolveConflictHandler = generateResolveConflictHandler(
+        remoteRevidsionData.remoteRevisionId,
+        onConflictHandler,
+      );
+      if (resolveConflictHandler == null) {
+        return;
+      }
 
 
-  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || currentPage.revision == null) {
-      return;
-    }
+      openConflictDiffModal(newMarkdown, resolveConflictHandler);
+    },
+    [
+      generateResolveConflictHandler,
+      openConflictDiffModal,
+      setRemoteLatestPageData,
+    ],
+  );
+
+  const saveByHandsontableModal = useCallback(
+    async (table: MarkdownTable, bol: number, eol: number) => {
+      if (currentPage == null || currentPage.revision == null) {
+        return;
+      }
 
 
-    const currentRevisionId = currentPage.revision._id;
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+      const currentRevisionId = currentPage.revision._id;
+      const currentMarkdown = currentPage.revision.body;
+      const newMarkdown = replaceMarkdownTableInMarkdown(
+        table,
+        currentMarkdown,
+        bol,
+        eol,
+      );
 
 
-    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
-  }, [currentPage, onConflictHandler, updatePage]);
+      await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+    },
+    [currentPage, onConflictHandler, updatePage],
+  );
 
 
   // set handler to open HandsonTableModal
   // set handler to open HandsonTableModal
   useEffect(() => {
   useEffect(() => {
@@ -107,12 +153,19 @@ export const useHandsontableModalLauncherForView = (opts?: {
       const markdown = currentPage.revision.body;
       const markdown = currentPage.revision.body;
       const { bol, eol } = evt.detail;
       const { bol, eol } = evt.detail;
       const currentMarkdownTable = getMarkdownTableFromLine(markdown, bol, eol);
       const currentMarkdownTable = getMarkdownTableFromLine(markdown, bol, eol);
-      openHandsontableModal(currentMarkdownTable, false, table => saveByHandsontableModal(table, bol, eol));
+      openHandsontableModal(currentMarkdownTable, false, (table) =>
+        saveByHandsontableModal(table, bol, eol),
+      );
     };
     };
     globalEventTarget.addEventListener('launchHandsonTableModal', handler);
     globalEventTarget.addEventListener('launchHandsonTableModal', handler);
 
 
     return function cleanup() {
     return function cleanup() {
       globalEventTarget.removeEventListener('launchHandsonTableModal', handler);
       globalEventTarget.removeEventListener('launchHandsonTableModal', handler);
     };
     };
-  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
+  }, [
+    currentPage,
+    openHandsontableModal,
+    saveByHandsontableModal,
+    shareLinkId,
+  ]);
 };
 };

+ 4 - 6
apps/app/src/client/services/side-effects/hash-changed.ts

@@ -1,9 +1,8 @@
 import { useCallback, useEffect } from 'react';
 import { useCallback, useEffect } from 'react';
-
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { useIsEditable } from '~/states/page';
 import { useIsEditable } from '~/states/page';
-import { useEditorMode, determineEditorModeByHash } from '~/states/ui/editor';
+import { determineEditorModeByHash, useEditorMode } from '~/states/ui/editor';
 
 
 /**
 /**
  * Change editorMode by browser forward/back operation
  * Change editorMode by browser forward/back operation
@@ -34,13 +33,12 @@ export const useHashChangedEffect = (): void => {
     return function cleanup() {
     return function cleanup() {
       window.removeEventListener('hashchange', hashchangeHandler);
       window.removeEventListener('hashchange', hashchangeHandler);
     };
     };
-
   }, [hashchangeHandler, isEditable]);
   }, [hashchangeHandler, isEditable]);
 
 
   /*
   /*
-  * Route changes by Next Router
-  * https://nextjs.org/docs/api-reference/next/router
-  */
+   * Route changes by Next Router
+   * https://nextjs.org/docs/api-reference/next/router
+   */
   useEffect(() => {
   useEffect(() => {
     router.events.on('routeChangeComplete', hashchangeHandler);
     router.events.on('routeChangeComplete', hashchangeHandler);
 
 

+ 65 - 41
apps/app/src/client/services/side-effects/page-updated.ts

@@ -1,71 +1,95 @@
 import { useCallback, useEffect } from 'react';
 import { useCallback, useEffect } from 'react';
 
 
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
-import { useCurrentPageData, useFetchCurrentPage, useSetRemoteLatestPageData } from '~/states/page';
 import type { RemoteRevisionData } from '~/states/page';
 import type { RemoteRevisionData } from '~/states/page';
+import {
+  useCurrentPageData,
+  useFetchCurrentPage,
+  useSetRemoteLatestPageData,
+} from '~/states/page';
 import { useGlobalSocket } from '~/states/socket-io';
 import { useGlobalSocket } from '~/states/socket-io';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import { usePageStatusAlertActions } from '~/states/ui/modal/page-status-alert';
 import { usePageStatusAlertActions } from '~/states/ui/modal/page-status-alert';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 
 
-
 export const usePageUpdatedEffect = (): void => {
 export const usePageUpdatedEffect = (): void => {
-
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
   const setRemoteLatestPageData = useSetRemoteLatestPageData();
 
 
   const socket = useGlobalSocket();
   const socket = useGlobalSocket();
   const { editorMode } = useEditorMode();
   const { editorMode } = useEditorMode();
   const currentPage = useCurrentPageData();
   const currentPage = useCurrentPageData();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const { fetchCurrentPage } = useFetchCurrentPage();
-  const { open: openPageStatusAlert, close: closePageStatusAlert } = usePageStatusAlertActions();
+  const { open: openPageStatusAlert, close: closePageStatusAlert } =
+    usePageStatusAlertActions();
 
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
 
 
-  const remotePageDataUpdateHandler = useCallback((data) => {
-    // Set remote page data
-    const { s2cMessagePageUpdated } = data;
-
-    const remoteData: RemoteRevisionData = {
-      remoteRevisionId: s2cMessagePageUpdated.revisionId,
-      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
-      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
-      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
-    };
-
-    if (currentPage?._id != null && currentPage._id === s2cMessagePageUpdated.pageId) {
-      setRemoteLatestPageData(remoteData);
-
-      // Update PageInfo cache
-      mutatePageInfo();
-
-      // Open PageStatusAlert
-      const currentRevisionId = currentPage?.revision?._id;
-      const remoteRevisionId = s2cMessagePageUpdated.revisionId;
-      const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
-
-      // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
-      if (isRevisionOutdated && editorMode === EditorMode.View) {
-        openPageStatusAlert({ hideEditorMode: EditorMode.Editor, onRefleshPage: () => fetchCurrentPage({ force: true }) });
-      }
-
-      // Clear cache
-      if (!isRevisionOutdated) {
-        closePageStatusAlert();
+  const remotePageDataUpdateHandler = useCallback(
+    (data) => {
+      // Set remote page data
+      const { s2cMessagePageUpdated } = data;
+
+      const remoteData: RemoteRevisionData = {
+        remoteRevisionId: s2cMessagePageUpdated.revisionId,
+        remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+        remoteRevisionLastUpdateUser:
+          s2cMessagePageUpdated.remoteLastUpdateUser,
+        remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+      };
+
+      if (
+        currentPage?._id != null &&
+        currentPage._id === s2cMessagePageUpdated.pageId
+      ) {
+        setRemoteLatestPageData(remoteData);
+
+        // Update PageInfo cache
+        mutatePageInfo();
+
+        // Open PageStatusAlert
+        const currentRevisionId = currentPage?.revision?._id;
+        const remoteRevisionId = s2cMessagePageUpdated.revisionId;
+        const isRevisionOutdated =
+          (currentRevisionId != null || remoteRevisionId != null) &&
+          currentRevisionId !== remoteRevisionId;
+
+        // !!CAUTION!! Timing of calling openPageStatusAlert may clash with components/PageEditor/conflict.tsx
+        if (isRevisionOutdated && editorMode === EditorMode.View) {
+          openPageStatusAlert({
+            hideEditorMode: EditorMode.Editor,
+            onRefleshPage: () => fetchCurrentPage({ force: true }),
+          });
+        }
+
+        // Clear cache
+        if (!isRevisionOutdated) {
+          closePageStatusAlert();
+        }
       }
       }
-    }
-  // eslint-disable-next-line max-len
-  }, [currentPage?._id, currentPage?.revision?._id, setRemoteLatestPageData, mutatePageInfo, editorMode, openPageStatusAlert, fetchCurrentPage, closePageStatusAlert]);
+      // eslint-disable-next-line max-len
+    },
+    [
+      currentPage?._id,
+      currentPage?.revision?._id,
+      setRemoteLatestPageData,
+      mutatePageInfo,
+      editorMode,
+      openPageStatusAlert,
+      fetchCurrentPage,
+      closePageStatusAlert,
+    ],
+  );
 
 
   // listen socket for someone updating this page
   // listen socket for someone updating this page
   useEffect(() => {
   useEffect(() => {
-
-    if (socket == null) { return }
+    if (socket == null) {
+      return;
+    }
 
 
     socket.on(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
     socket.on(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
 
 
     return () => {
     return () => {
       socket.off(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
       socket.off(SocketEventName.PageUpdated, remotePageDataUpdateHandler);
     };
     };
-
   }, [remotePageDataUpdateHandler, socket]);
   }, [remotePageDataUpdateHandler, socket]);
 };
 };

+ 1 - 1
apps/app/src/client/services/side-effects/use-sticky.ts

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useEffect, useState } from 'react';
 
 
 // Custom hook that accepts a selector string as an argument
 // Custom hook that accepts a selector string as an argument
 // and returns a boolean indicating whether the selected element is currently sticky.
 // and returns a boolean indicating whether the selected element is currently sticky.

+ 3 - 2
apps/app/src/client/services/update-page/conflict.tsx

@@ -3,10 +3,11 @@ import type { ErrorV3 } from '@growi/core/dist/models';
 import { PageUpdateErrorCode } from '~/interfaces/apiv3';
 import { PageUpdateErrorCode } from '~/interfaces/apiv3';
 import type { RemoteRevisionData } from '~/states/page';
 import type { RemoteRevisionData } from '~/states/page';
 
 
-export const extractRemoteRevisionDataFromErrorObj = (errors: Array<ErrorV3>): RemoteRevisionData | undefined => {
+export const extractRemoteRevisionDataFromErrorObj = (
+  errors: Array<ErrorV3>,
+): RemoteRevisionData | undefined => {
   for (const error of errors) {
   for (const error of errors) {
     if (error.code === PageUpdateErrorCode.CONFLICT) {
     if (error.code === PageUpdateErrorCode.CONFLICT) {
-
       const latestRevision = error.args.returnLatestRevision;
       const latestRevision = error.args.returnLatestRevision;
 
 
       const remoteRevidsionData = {
       const remoteRevidsionData = {

+ 7 - 2
apps/app/src/client/services/update-page/update-page.ts

@@ -1,7 +1,12 @@
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+import type {
+  IApiv3PageUpdateParams,
+  IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
 
 
-export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+export const updatePage = async (
+  params: IApiv3PageUpdateParams,
+): Promise<IApiv3PageUpdateResponse> => {
   const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
   const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
   return res.data;
   return res.data;
 };
 };

+ 16 - 10
apps/app/src/client/services/update-page/use-update-page.tsx

@@ -1,25 +1,31 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
-import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+import type {
+  IApiv3PageUpdateParams,
+  IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
 import { useSetIsUntitledPage } from '~/states/page';
 import { useSetIsUntitledPage } from '~/states/page';
 
 
 import { updatePage } from './update-page';
 import { updatePage } from './update-page';
 
 
-
-type UseUpdatePage = (params: IApiv3PageUpdateParams) => Promise<IApiv3PageUpdateResponse>;
-
+type UseUpdatePage = (
+  params: IApiv3PageUpdateParams,
+) => Promise<IApiv3PageUpdateResponse>;
 
 
 export const useUpdatePage = (): UseUpdatePage => {
 export const useUpdatePage = (): UseUpdatePage => {
   const setIsUntitledPage = useSetIsUntitledPage();
   const setIsUntitledPage = useSetIsUntitledPage();
 
 
-  const updatePageExt: UseUpdatePage = useCallback(async (params) => {
-    const result = await updatePage(params);
+  const updatePageExt: UseUpdatePage = useCallback(
+    async (params) => {
+      const result = await updatePage(params);
 
 
-    // set false to isUntitledPage
-    setIsUntitledPage(false);
+      // set false to isUntitledPage
+      setIsUntitledPage(false);
 
 
-    return result;
-  }, [setIsUntitledPage]);
+      return result;
+    },
+    [setIsUntitledPage],
+  );
 
 
   return updatePageExt;
   return updatePageExt;
 };
 };

+ 25 - 12
apps/app/src/client/services/upload-attachments/upload-attachments.ts

@@ -1,23 +1,33 @@
 import type { IAttachment } from '@growi/core';
 import type { IAttachment } from '@growi/core';
 
 
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
-import type { IApiv3GetAttachmentLimitParams, IApiv3GetAttachmentLimitResponse, IApiv3PostAttachmentResponse } from '~/interfaces/apiv3/attachment';
+import type {
+  IApiv3GetAttachmentLimitParams,
+  IApiv3GetAttachmentLimitResponse,
+  IApiv3PostAttachmentResponse,
+} from '~/interfaces/apiv3/attachment';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:client:services:upload-attachment');
 const logger = loggerFactory('growi:client:services:upload-attachment');
 
 
-
 type UploadOpts = {
 type UploadOpts = {
-  onUploaded?: (attachment: IAttachment) => void,
-  onError?: (error: Error, file: File) => void,
-}
+  onUploaded?: (attachment: IAttachment) => void;
+  onError?: (error: Error, file: File) => void;
+};
 
 
-export const uploadAttachments = async(pageId: string, files: File[], opts?: UploadOpts): Promise<void> => {
-  files.forEach(async(file) => {
+export const uploadAttachments = async (
+  pageId: string,
+  files: File[],
+  opts?: UploadOpts,
+): Promise<void> => {
+  files.forEach(async (file) => {
     try {
     try {
       const params: IApiv3GetAttachmentLimitParams = { fileSize: file.size };
       const params: IApiv3GetAttachmentLimitParams = { fileSize: file.size };
-      const { data: resLimit } = await apiv3Get<IApiv3GetAttachmentLimitResponse>('/attachment/limit', params);
+      const { data: resLimit } =
+        await apiv3Get<IApiv3GetAttachmentLimitResponse>(
+          '/attachment/limit',
+          params,
+        );
 
 
       if (!resLimit.isUploadable) {
       if (!resLimit.isUploadable) {
         throw new Error(resLimit.errorMessage);
         throw new Error(resLimit.errorMessage);
@@ -27,11 +37,14 @@ export const uploadAttachments = async(pageId: string, files: File[], opts?: Upl
       formData.append('file', file);
       formData.append('file', file);
       formData.append('page_id', pageId);
       formData.append('page_id', pageId);
 
 
-      const { data: resAdd } = await apiv3PostForm<IApiv3PostAttachmentResponse>('/attachment', formData);
+      const { data: resAdd } =
+        await apiv3PostForm<IApiv3PostAttachmentResponse>(
+          '/attachment',
+          formData,
+        );
 
 
       opts?.onUploaded?.(resAdd.attachment);
       opts?.onUploaded?.(resAdd.attachment);
-    }
-    catch (e) {
+    } catch (e) {
       logger.error('failed to upload', e);
       logger.error('failed to upload', e);
       opts?.onError?.(e, file);
       opts?.onError?.(e, file);
     }
     }

+ 4 - 4
apps/app/src/client/services/use-print-mode.ts

@@ -1,5 +1,4 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
-
 import { flushSync } from 'react-dom';
 import { flushSync } from 'react-dom';
 
 
 export const usePrintMode = (): boolean => {
 export const usePrintMode = (): boolean => {
@@ -7,9 +6,10 @@ export const usePrintMode = (): boolean => {
 
 
   useEffect(() => {
   useEffect(() => {
     // force re-render on beforeprint
     // force re-render on beforeprint
-    const handleBeforePrint = () => flushSync(() => {
-      setIsPrinting(true);
-    });
+    const handleBeforePrint = () =>
+      flushSync(() => {
+        setIsPrinting(true);
+      });
 
 
     const handleAfterPrint = () => {
     const handleAfterPrint = () => {
       setIsPrinting(false);
       setIsPrinting(false);

+ 23 - 22
apps/app/src/client/services/use-start-editing.tsx

@@ -1,11 +1,10 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { getParentPath } from '@growi/core/dist/utils/path-utils';
 import { getParentPath } from '@growi/core/dist/utils/path-utils';
 
 
 import { useCreatePage } from '~/client/services/create-page';
 import { useCreatePage } from '~/client/services/create-page';
 import { usePageNotFound } from '~/states/page';
 import { usePageNotFound } from '~/states/page';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 
 import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
 import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
 
 
@@ -14,25 +13,27 @@ export const useStartEditing = (): ((path?: string) => Promise<void>) => {
   const { setEditorMode } = useEditorMode();
   const { setEditorMode } = useEditorMode();
   const { create } = useCreatePage();
   const { create } = useCreatePage();
 
 
-  return useCallback(async (path?: string) => {
-    if (!isNotFound) {
-      setEditorMode(EditorMode.Editor);
-      return;
-    }
-    // Create a new page if it does not exist and transit to the editor mode
-    try {
-      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
-      await create(
-        {
-          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
-        },
-      );
-
-      setEditorMode(EditorMode.Editor);
-    }
-    catch (err) {
-      throw new Error(err);
-    }
-  }, [create, isNotFound, setEditorMode]);
+  return useCallback(
+    async (path?: string) => {
+      if (!isNotFound) {
+        setEditorMode(EditorMode.Editor);
+        return;
+      }
+      // Create a new page if it does not exist and transit to the editor mode
+      try {
+        const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
+        await create({
+          path,
+          parentPath,
+          wip: shouldCreateWipPage(path),
+          origin: Origin.View,
+        });
 
 
+        setEditorMode(EditorMode.Editor);
+      } catch (err) {
+        throw new Error(err);
+      }
+    },
+    [create, isNotFound, setEditorMode],
+  );
 };
 };

+ 13 - 10
apps/app/src/client/services/use-toastr-on-error.tsx

@@ -1,18 +1,21 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 
 
-export const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
+export const useToastrOnError = <P, R>(
+  method?: (param?: P) => Promise<R | undefined>,
+): ((param?: P) => Promise<R | undefined>) => {
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
 
 
-  return useCallback(async(param) => {
-    try {
-      return await method?.(param);
-    }
-    catch (err) {
-      toastError(t('toaster.create_failed', { target: 'a page' }));
-    }
-  }, [method, t]);
+  return useCallback(
+    async (param) => {
+      try {
+        return await method?.(param);
+      } catch (err) {
+        toastError(t('toaster.create_failed', { target: 'a page' }));
+      }
+    },
+    [method, t],
+  );
 };
 };

+ 16 - 5
apps/app/src/client/services/user-ui-settings.ts

@@ -6,8 +6,12 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 
 let settingsForBulk: Partial<IUserUISettings> = {};
 let settingsForBulk: Partial<IUserUISettings> = {};
-const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
-  const result = apiv3Put<IUserUISettings>('/user-ui-settings', { settings: settingsForBulk });
+const _putUserUISettingsInBulk = (): Promise<
+  AxiosResponse<IUserUISettings>
+> => {
+  const result = apiv3Put<IUserUISettings>('/user-ui-settings', {
+    settings: settingsForBulk,
+  });
 
 
   // clear partial
   // clear partial
   settingsForBulk = {};
   settingsForBulk = {};
@@ -15,7 +19,10 @@ const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> =>
   return result;
   return result;
 };
 };
 
 
-const _putUserUISettingsInBulkDebounced = debounce(1500, _putUserUISettingsInBulk);
+const _putUserUISettingsInBulkDebounced = debounce(
+  1500,
+  _putUserUISettingsInBulk,
+);
 
 
 export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
 export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
   settingsForBulk = {
   settingsForBulk = {
@@ -26,8 +33,12 @@ export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
   _putUserUISettingsInBulkDebounced();
   _putUserUISettingsInBulkDebounced();
 };
 };
 
 
-export const updateUserUISettings = async(settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
-  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', { settings });
+export const updateUserUISettings = async (
+  settings: Partial<IUserUISettings>,
+): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', {
+    settings,
+  });
 
 
   return result;
   return result;
 };
 };

+ 26 - 9
apps/app/src/client/util/apiv1-client.ts

@@ -5,7 +5,6 @@ import axios from '~/utils/axios';
 const apiv1Root = '/_api';
 const apiv1Root = '/_api';
 
 
 class Apiv1ErrorHandler extends Error {
 class Apiv1ErrorHandler extends Error {
-
   code;
   code;
 
 
   data;
   data;
@@ -16,12 +15,14 @@ class Apiv1ErrorHandler extends Error {
     this.message = message;
     this.message = message;
     this.code = code;
     this.code = code;
     this.data = data;
     this.data = data;
-
   }
   }
-
 }
 }
 
 
-export async function apiRequest<T>(method: string, path: string, params: unknown): Promise<T> {
+export async function apiRequest<T>(
+  method: string,
+  path: string,
+  params: unknown,
+): Promise<T> {
   const res = await axios[method](urljoin(apiv1Root, path), params);
   const res = await axios[method](urljoin(apiv1Root, path), params);
 
 
   if (res.data.ok) {
   if (res.data.ok) {
@@ -30,25 +31,41 @@ export async function apiRequest<T>(method: string, path: string, params: unknow
 
 
   // Return error code if code is exist
   // Return error code if code is exist
   if (res.data.code != null) {
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
+    const error = new Apiv1ErrorHandler(
+      res.data.error,
+      res.data.code,
+      res.data.data,
+    );
     throw error;
     throw error;
   }
   }
 
 
   throw new Error(res.data.error);
   throw new Error(res.data.error);
 }
 }
 
 
-export async function apiGet<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiGet<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('get', path, { params });
   return apiRequest<T>('get', path, { params });
 }
 }
 
 
-export async function apiPost<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiPost<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('post', path, params);
   return apiRequest<T>('post', path, params);
 }
 }
 
 
-export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
+export async function apiPostForm<T>(
+  path: string,
+  formData: FormData,
+): Promise<T> {
   return apiRequest<T>('postForm', path, formData);
   return apiRequest<T>('postForm', path, formData);
 }
 }
 
 
-export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {
+export async function apiDelete<T>(
+  path: string,
+  params: unknown = {},
+): Promise<T> {
   return apiRequest<T>('delete', path, { data: params });
   return apiRequest<T>('delete', path, { data: params });
 }
 }

+ 29 - 10
apps/app/src/client/util/apiv3-client.ts

@@ -10,12 +10,13 @@ const apiv3Root = '/_api/v3';
 
 
 const logger = loggerFactory('growi:apiv3');
 const logger = loggerFactory('growi:apiv3');
 
 
-
 const apiv3ErrorHandler = (_err: any): any[] => {
 const apiv3ErrorHandler = (_err: any): any[] => {
   // extract api errors from general 400 err
   // extract api errors from general 400 err
   const err = axios.isAxiosError(_err) ? _err.response?.data.errors : _err;
   const err = axios.isAxiosError(_err) ? _err.response?.data.errors : _err;
   const errs = toArrayIfNot(err);
   const errs = toArrayIfNot(err);
-  const errorInfo = axios.isAxiosError(_err) ? _err.response?.data.info : undefined;
+  const errorInfo = axios.isAxiosError(_err)
+    ? _err.response?.data.info
+    : undefined;
 
 
   for (const err of errs) {
   for (const err of errs) {
     logger.error(err.message);
     logger.error(err.message);
@@ -28,33 +29,51 @@ const apiv3ErrorHandler = (_err: any): any[] => {
 };
 };
 
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function apiv3Request<T = any>(method: string, path: string, params: unknown): Promise<AxiosResponse<T>> {
+export async function apiv3Request<T = any>(
+  method: string,
+  path: string,
+  params: unknown,
+): Promise<AxiosResponse<T>> {
   try {
   try {
     const res = await axios[method](urljoin(apiv3Root, path), params);
     const res = await axios[method](urljoin(apiv3Root, path), params);
     return res;
     return res;
-  }
-  catch (err) {
+  } catch (err) {
     const errors = apiv3ErrorHandler(err);
     const errors = apiv3ErrorHandler(err);
     throw errors;
     throw errors;
   }
   }
 }
 }
 
 
-export async function apiv3Get<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Get<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('get', path, { params });
   return apiv3Request('get', path, { params });
 }
 }
 
 
-export async function apiv3Post<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Post<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('post', path, params);
   return apiv3Request('post', path, params);
 }
 }
 
 
-export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
+export async function apiv3PostForm<T = any>(
+  path: string,
+  formData: FormData,
+): Promise<AxiosResponse<T>> {
   return apiv3Request('postForm', path, formData);
   return apiv3Request('postForm', path, formData);
 }
 }
 
 
-export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Put<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('put', path, params);
   return apiv3Request('put', path, params);
 }
 }
 
 
-export async function apiv3Delete<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
+export async function apiv3Delete<T = any>(
+  path: string,
+  params: unknown = {},
+): Promise<AxiosResponse<T>> {
   return apiv3Request('delete', path, { params });
   return apiv3Request('delete', path, { params });
 }
 }

+ 48 - 12
apps/app/src/client/util/bookmark-utils.ts

@@ -1,44 +1,80 @@
 import type { IRevision, Ref } from '@growi/core';
 import type { IRevision, Ref } from '@growi/core';
 
 
-import type { BookmarkFolderItems, BookmarkedPage } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkedPage,
+  BookmarkFolderItems,
+} from '~/interfaces/bookmark-info';
 
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
 
 // Check if bookmark folder item has childFolder or bookmarks
 // Check if bookmark folder item has childFolder or bookmarks
-export const hasChildren = ({ childFolder, bookmarks }: { childFolder?: BookmarkFolderItems[], bookmarks?: BookmarkedPage[] }): boolean => {
-  return !!((childFolder && childFolder.length > 0) || (bookmarks && bookmarks.length > 0));
+export const hasChildren = ({
+  childFolder,
+  bookmarks,
+}: {
+  childFolder?: BookmarkFolderItems[];
+  bookmarks?: BookmarkedPage[];
+}): boolean => {
+  return !!(
+    (childFolder && childFolder.length > 0) ||
+    (bookmarks && bookmarks.length > 0)
+  );
 };
 };
 
 
 // Add new folder helper
 // Add new folder helper
-export const addNewFolder = async(name: string, parent: string | null): Promise<void> => {
+export const addNewFolder = async (
+  name: string,
+  parent: string | null,
+): Promise<void> => {
   await apiv3Post('/bookmark-folder', { name, parent });
   await apiv3Post('/bookmark-folder', { name, parent });
 };
 };
 
 
 // Put bookmark to a folder
 // Put bookmark to a folder
-export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
-  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', { pageId, folderId });
+export const addBookmarkToFolder = async (
+  pageId: string,
+  folderId: string | null,
+): Promise<void> => {
+  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', {
+    pageId,
+    folderId,
+  });
 };
 };
 
 
 // Delete bookmark folder
 // Delete bookmark folder
-export const deleteBookmarkFolder = async(bookmarkFolderId: string): Promise<void> => {
+export const deleteBookmarkFolder = async (
+  bookmarkFolderId: string,
+): Promise<void> => {
   await apiv3Delete(`/bookmark-folder/${bookmarkFolderId}`);
   await apiv3Delete(`/bookmark-folder/${bookmarkFolderId}`);
 };
 };
 
 
 // Rename page from bookmark item control
 // Rename page from bookmark item control
-export const renamePage = async(pageId: string, revisionId: Ref<IRevision> | undefined, newPagePath: string): Promise<void> => {
+export const renamePage = async (
+  pageId: string,
+  revisionId: Ref<IRevision> | undefined,
+  newPagePath: string,
+): Promise<void> => {
   await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
   await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
 };
 };
 
 
 // Update bookmark by isBookmarked status
 // Update bookmark by isBookmarked status
-export const toggleBookmark = async(pageId: string, status: boolean): Promise<void> => {
+export const toggleBookmark = async (
+  pageId: string,
+  status: boolean,
+): Promise<void> => {
   await apiv3Put('/bookmark-folder/update-bookmark', { pageId, status });
   await apiv3Put('/bookmark-folder/update-bookmark', { pageId, status });
 };
 };
 
 
 // Update Bookmark folder
 // Update Bookmark folder
-export const updateBookmarkFolder = async(
-    bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[],
+export const updateBookmarkFolder = async (
+  bookmarkFolderId: string,
+  name: string,
+  parent: string | null,
+  childFolder: BookmarkFolderItems[],
 ): Promise<void> => {
 ): Promise<void> => {
   await apiv3Put('/bookmark-folder', {
   await apiv3Put('/bookmark-folder', {
-    bookmarkFolderId, name, parent, childFolder,
+    bookmarkFolderId,
+    name,
+    parent,
+    childFolder,
   });
   });
 };
 };

+ 14 - 7
apps/app/src/client/util/scope-util.test.ts

@@ -1,10 +1,9 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 
-import { parseScopes, getDisabledScopes, extractScopes } from './scope-util';
+import { extractScopes, getDisabledScopes, parseScopes } from './scope-util';
 
 
 describe('scope-util', () => {
 describe('scope-util', () => {
-
   const mockScopes = {
   const mockScopes = {
     READ: {
     READ: {
       USER: 'read:user',
       USER: 'read:user',
@@ -45,8 +44,12 @@ describe('scope-util', () => {
     expect(result.ALL).toBeDefined();
     expect(result.ALL).toBeDefined();
 
 
     // Check admin settings
     // Check admin settings
-    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe('read:admin:setting');
-    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe('write:admin:setting');
+    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe(
+      'read:admin:setting',
+    );
+    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe(
+      'write:admin:setting',
+    );
 
 
     // Check ALL category
     // Check ALL category
     expect(result.ALL['read:all']).toBe('read:all');
     expect(result.ALL['read:all']).toBe('read:all');
@@ -79,8 +82,12 @@ describe('scope-util', () => {
   it('should handle multiple wildcard selections', () => {
   it('should handle multiple wildcard selections', () => {
     const selectedScopes = [SCOPE.READ.ALL, SCOPE.WRITE.ALL];
     const selectedScopes = [SCOPE.READ.ALL, SCOPE.WRITE.ALL];
     const availableScopes = [
     const availableScopes = [
-      SCOPE.READ.FEATURES.PAGE, SCOPE.READ.FEATURES.ATTACHMENT, SCOPE.READ.ALL,
-      SCOPE.WRITE.FEATURES.PAGE, SCOPE.WRITE.FEATURES.ATTACHMENT, SCOPE.WRITE.ALL,
+      SCOPE.READ.FEATURES.PAGE,
+      SCOPE.READ.FEATURES.ATTACHMENT,
+      SCOPE.READ.ALL,
+      SCOPE.WRITE.FEATURES.PAGE,
+      SCOPE.WRITE.FEATURES.ATTACHMENT,
+      SCOPE.WRITE.ALL,
     ];
     ];
 
 
     const result = getDisabledScopes(selectedScopes, availableScopes);
     const result = getDisabledScopes(selectedScopes, availableScopes);

+ 26 - 16
apps/app/src/client/util/scope-util.ts

@@ -1,6 +1,5 @@
 import { ALL_SIGN, type Scope } from '@growi/core/dist/interfaces';
 import { ALL_SIGN, type Scope } from '@growi/core/dist/interfaces';
 
 
-
 // Data structure for the final merged scopes
 // Data structure for the final merged scopes
 interface ScopeMap {
 interface ScopeMap {
   [key: string]: Scope | ScopeMap;
   [key: string]: Scope | ScopeMap;
@@ -9,17 +8,17 @@ interface ScopeMap {
 // Input object with arbitrary action keys (e.g., READ, WRITE)
 // Input object with arbitrary action keys (e.g., READ, WRITE)
 type ScopesInput = Record<string, any>;
 type ScopesInput = Record<string, any>;
 
 
-
 function parseSubScope(
 function parseSubScope(
-    parentKey: string,
-    subObjForActions: Record<string, any>,
-    actions: string[],
+  parentKey: string,
+  subObjForActions: Record<string, any>,
+  actions: string[],
 ): ScopeMap {
 ): ScopeMap {
   const result: ScopeMap = {};
   const result: ScopeMap = {};
 
 
   for (const action of actions) {
   for (const action of actions) {
     if (typeof subObjForActions[action] === 'string') {
     if (typeof subObjForActions[action] === 'string') {
-      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] = subObjForActions[action];
+      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] =
+        subObjForActions[action];
       subObjForActions[action] = undefined;
       subObjForActions[action] = undefined;
     }
     }
   }
   }
@@ -28,7 +27,9 @@ function parseSubScope(
   for (const action of actions) {
   for (const action of actions) {
     const obj = subObjForActions[action];
     const obj = subObjForActions[action];
     if (obj && typeof obj === 'object') {
     if (obj && typeof obj === 'object') {
-      Object.keys(obj).forEach(k => childKeys.add(k));
+      Object.keys(obj).forEach((k) => {
+        childKeys.add(k);
+      });
     }
     }
   }
   }
 
 
@@ -37,7 +38,8 @@ function parseSubScope(
       for (const action of actions) {
       for (const action of actions) {
         const val = subObjForActions[action]?.[ck];
         const val = subObjForActions[action]?.[ck];
         if (typeof val === 'string') {
         if (typeof val === 'string') {
-          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] = val as Scope;
+          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] =
+            val as Scope;
         }
         }
       }
       }
       continue;
       continue;
@@ -55,13 +57,21 @@ function parseSubScope(
   return result;
   return result;
 }
 }
 
 
-export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ; isAdmin?: boolean }): ScopeMap {
+export function parseScopes({
+  scopes,
+  isAdmin = false,
+}: {
+  scopes: ScopesInput;
+  isAdmin?: boolean;
+}): ScopeMap {
   const actions = Object.keys(scopes);
   const actions = Object.keys(scopes);
   const topKeys = new Set<string>();
   const topKeys = new Set<string>();
 
 
   // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
   // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
   for (const action of actions) {
   for (const action of actions) {
-    Object.keys(scopes[action] || {}).forEach(k => topKeys.add(k));
+    Object.keys(scopes[action] || {}).forEach((k) => {
+      topKeys.add(k);
+    });
   }
   }
 
 
   const result: ScopeMap = {};
   const result: ScopeMap = {};
@@ -81,8 +91,7 @@ export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ;
         }
         }
       }
       }
       result.ALL = allObj;
       result.ALL = allObj;
-    }
-    else {
+    } else {
       const subObjForActions: Record<string, any> = {};
       const subObjForActions: Record<string, any> = {};
       for (const action of actions) {
       for (const action of actions) {
         subObjForActions[action] = scopes[action]?.[key];
         subObjForActions[action] = scopes[action]?.[key];
@@ -97,10 +106,12 @@ export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ;
 /**
 /**
  * Determines which scopes should be disabled based on wildcard selections
  * Determines which scopes should be disabled based on wildcard selections
  */
  */
-export function getDisabledScopes(selectedScopes: Scope[], availableScopes: string[]): Set<Scope> {
+export function getDisabledScopes(
+  selectedScopes: Scope[],
+  availableScopes: string[],
+): Set<Scope> {
   const disabledSet = new Set<Scope>();
   const disabledSet = new Set<Scope>();
 
 
-
   // If no selected scopes, return empty set
   // If no selected scopes, return empty set
   if (!selectedScopes || selectedScopes.length === 0) {
   if (!selectedScopes || selectedScopes.length === 0) {
     return disabledSet;
     return disabledSet;
@@ -133,8 +144,7 @@ export function extractScopes(obj: Record<string, any>): string[] {
   Object.values(obj).forEach((value) => {
   Object.values(obj).forEach((value) => {
     if (typeof value === 'string') {
     if (typeof value === 'string') {
       result.push(value);
       result.push(value);
-    }
-    else if (typeof value === 'object' && !Array.isArray(value)) {
+    } else if (typeof value === 'object' && !Array.isArray(value)) {
       result = result.concat(extractScopes(value));
       result = result.concat(extractScopes(value));
     }
     }
   });
   });

+ 10 - 9
apps/app/src/client/util/t-with-opt.ts

@@ -1,15 +1,16 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-export const useTWithOpt = (): (key: string, opt?: any) => string => {
-
+export const useTWithOpt = (): ((key: string, opt?: any) => string) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  return useCallback((key, opt) => {
-    if (typeof opt === 'object') {
-      return t(key, opt).toString();
-    }
-    return t(key);
-  }, [t]);
+  return useCallback(
+    (key, opt) => {
+      if (typeof opt === 'object') {
+        return t(key, opt).toString();
+      }
+      return t(key);
+    },
+    [t],
+  );
 };
 };

+ 13 - 5
apps/app/src/client/util/toastr.ts

@@ -3,12 +3,14 @@ import { toast } from 'react-toastify';
 
 
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 
-
 export const toastErrorOption: ToastOptions = {
 export const toastErrorOption: ToastOptions = {
   autoClose: false,
   autoClose: false,
   closeButton: true,
   closeButton: true,
 };
 };
-export const toastError = (err: string | Error | Error[], option: ToastOptions = toastErrorOption): void => {
+export const toastError = (
+  err: string | Error | Error[],
+  option: ToastOptions = toastErrorOption,
+): void => {
   const errs = toArrayIfNot(err);
   const errs = toArrayIfNot(err);
 
 
   if (errs.length === 0) {
   if (errs.length === 0) {
@@ -16,7 +18,7 @@ export const toastError = (err: string | Error | Error[], option: ToastOptions =
   }
   }
 
 
   for (const err of errs) {
   for (const err of errs) {
-    const message = (typeof err === 'string') ? err : err.message;
+    const message = typeof err === 'string' ? err : err.message;
     toast.error(message, option);
     toast.error(message, option);
   }
   }
 };
 };
@@ -25,7 +27,10 @@ export const toastSuccessOption: ToastOptions = {
   autoClose: 2000,
   autoClose: 2000,
   closeButton: true,
   closeButton: true,
 };
 };
-export const toastSuccess = (content: ToastContent, option: ToastOptions = toastSuccessOption): void => {
+export const toastSuccess = (
+  content: ToastContent,
+  option: ToastOptions = toastSuccessOption,
+): void => {
   toast.success(content, option);
   toast.success(content, option);
 };
 };
 
 
@@ -33,6 +38,9 @@ export const toastWarningOption: ToastOptions = {
   autoClose: 5000,
   autoClose: 5000,
   closeButton: true,
   closeButton: true,
 };
 };
-export const toastWarning = (content: ToastContent, option: ToastOptions = toastWarningOption): void => {
+export const toastWarning = (
+  content: ToastContent,
+  option: ToastOptions = toastWarningOption,
+): void => {
   toast.warning(content, option);
   toast.warning(content, option);
 };
 };

+ 36 - 30
apps/app/src/client/util/use-input-validator.ts

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 export const AlertType = {
 export const AlertType = {
@@ -7,7 +6,7 @@ export const AlertType = {
   ERROR: 'Error',
   ERROR: 'Error',
 } as const;
 } as const;
 
 
-export type AlertType = typeof AlertType[keyof typeof AlertType];
+export type AlertType = (typeof AlertType)[keyof typeof AlertType];
 
 
 export const ValidationTarget = {
 export const ValidationTarget = {
   FOLDER: 'folder_name',
   FOLDER: 'folder_name',
@@ -15,42 +14,49 @@ export const ValidationTarget = {
   DEFAULT: 'field',
   DEFAULT: 'field',
 };
 };
 
 
-export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+export type ValidationTarget =
+  (typeof ValidationTarget)[keyof typeof ValidationTarget];
 
 
 export type AlertInfo = {
 export type AlertInfo = {
-  type?: AlertType
-  message?: string,
-  target?: string
-}
-
+  type?: AlertType;
+  message?: string;
+  target?: string;
+};
 
 
 export type InputValidationResult = {
 export type InputValidationResult = {
-  type: AlertType
-  typeLabel: string,
-  message: string,
-  target: string
-}
+  type: AlertType;
+  typeLabel: string;
+  message: string;
+  target: string;
+};
 
 
-export type InputValidator = (input?: string, alertType?: AlertType) => InputValidationResult | void;
+export type InputValidator = (
+  input?: string,
+  alertType?: AlertType,
+) => InputValidationResult | void;
 
 
-export const useInputValidator = (validationTarget: ValidationTarget = ValidationTarget.DEFAULT): InputValidator => {
+export const useInputValidator = (
+  validationTarget: ValidationTarget = ValidationTarget.DEFAULT,
+): InputValidator => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const inputValidator: InputValidator = useCallback((input?, alertType = AlertType.WARNING) => {
-    if ((input ?? '').trim() === '') {
-      return {
-        target: validationTarget,
-        type: alertType,
-        typeLabel: t(alertType),
-        message: t(
-          'input_validation.message.field_required',
-          { target: t(`input_validation.target.${validationTarget}`) },
-        ),
-      };
-    }
-
-    return;
-  }, [t, validationTarget]);
+  const inputValidator: InputValidator = useCallback(
+    (input?, alertType = AlertType.WARNING) => {
+      if ((input ?? '').trim() === '') {
+        return {
+          target: validationTarget,
+          type: alertType,
+          typeLabel: t(alertType),
+          message: t('input_validation.message.field_required', {
+            target: t(`input_validation.target.${validationTarget}`),
+          }),
+        };
+      }
+
+      return;
+    },
+    [t, validationTarget],
+  );
 
 
   return inputValidator;
   return inputValidator;
 };
 };

+ 1 - 1
apps/app/src/features/collaborative-editor/side-effects/index.ts

@@ -15,7 +15,7 @@ export const useCurrentPageYjsDataAutoLoadEffect = (): void => {
   const pageId = useCurrentPageId();
   const pageId = useCurrentPageId();
   const currentPage = useCurrentPageData();
   const currentPage = useCurrentPageData();
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
-  const isNotFound = usePageNotFound();
+  const isNotFound = usePageNotFound(false);
 
 
   // Optimized effects with minimal dependencies
   // Optimized effects with minimal dependencies
   useEffect(() => {
   useEffect(() => {

+ 8 - 2
apps/app/src/interfaces/bookmark-info.ts

@@ -1,9 +1,15 @@
-import type { IPageHasId, IUser, Ref } from '@growi/core';
+import type { IPageHasId, IUser, Ref } from '@growi/core/dist/interfaces';
+import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializers';
+
+export interface IBookmark {
+  page: Ref<IPageHasId>;
+  user: Ref<IUser>;
+}
 
 
 export interface IBookmarkInfo {
 export interface IBookmarkInfo {
   sumOfBookmarks: number;
   sumOfBookmarks: number;
   isBookmarked: boolean;
   isBookmarked: boolean;
-  bookmarkedUsers: IUser[];
+  bookmarkedUsers: IUserSerializedSecurely<IUser>[];
   pageId: string;
   pageId: string;
 }
 }
 
 

+ 11 - 15
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -15,7 +15,7 @@ import assert from 'assert';
 import type { HydratedDocument, model } from 'mongoose';
 import type { HydratedDocument, model } from 'mongoose';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { PageModel } from '~/server/models/page';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import type {
 import type {
   IPageRedirect,
   IPageRedirect,
   PageRedirectModel,
   PageRedirectModel,
@@ -193,11 +193,6 @@ export async function getPageDataForInitial(
       };
       };
     }
     }
 
 
-    // Add user to seen users
-    if (user != null) {
-      await page.seen(user);
-    }
-
     // Handle existing page with valid meta that is not IPageNotFoundInfo
     // Handle existing page with valid meta that is not IPageNotFoundInfo
     page.initLatestRevisionField(revisionId);
     page.initLatestRevisionField(revisionId);
     const ssrMaxRevisionBodyLength = configManager.getConfig(
     const ssrMaxRevisionBodyLength = configManager.getConfig(
@@ -250,15 +245,13 @@ export async function getPageDataForInitial(
 // Page data retrieval for same-route navigation
 // Page data retrieval for same-route navigation
 export async function getPageDataForSameRoute(
 export async function getPageDataForSameRoute(
   context: GetServerSidePropsContext,
   context: GetServerSidePropsContext,
-): Promise<
-  GetServerSidePropsResult<
-    Pick<CommonEachProps, 'currentPathname'> &
-      Pick<
-        EachProps,
-        'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'
-      >
-  >
-> {
+): Promise<{
+  props: Pick<CommonEachProps, 'currentPathname'> &
+    Pick<EachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>;
+  internalProps?: {
+    pageId?: string;
+  };
+}> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
 
 
@@ -298,5 +291,8 @@ export async function getPageDataForSameRoute(
       isIdenticalPathPage: false,
       isIdenticalPathPage: false,
       redirectFrom,
       redirectFrom,
     },
     },
+    internalProps: {
+      pageId: basicPageInfo?._id?.toString(),
+    },
   };
   };
 }
 }

+ 36 - 2
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -1,5 +1,7 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
 import {
 import {
   getServerSideCommonInitialProps,
   getServerSideCommonInitialProps,
@@ -26,6 +28,30 @@ const nextjsRoutingProps = {
   },
   },
 };
 };
 
 
+/**
+ * Emit page seen event
+ * @param context - Next.js server-side context
+ * @param pageId - Page ID to mark as seen
+ */
+function emitPageSeenEvent(
+  context: GetServerSidePropsContext,
+  pageId?: string,
+): void {
+  if (pageId == null) {
+    return;
+  }
+
+  const req = context.req as CrowiRequest;
+  const { user, crowi } = req;
+
+  if (user == null) {
+    return;
+  }
+
+  const pageEvent = crowi.event('page');
+  pageEvent.emit('seen', pageId, user);
+}
+
 export async function getServerSidePropsForInitial(
 export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
@@ -75,6 +101,9 @@ export async function getServerSidePropsForInitial(
     throw new Error('Invalid merged props structure');
     throw new Error('Invalid merged props structure');
   }
   }
 
 
+  // Add user to seen users
+  emitPageSeenEvent(context, mergedProps.pageWithMeta?.data?._id);
+
   // -- TODO: persist activity
   // -- TODO: persist activity
   // await addActivity(context, getActivityAction(mergedProps));
   // await addActivity(context, getActivityAction(mergedProps));
   return mergedResult;
   return mergedResult;
@@ -85,16 +114,21 @@ export async function getServerSidePropsForSameRoute(
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
 ): Promise<GetServerSidePropsResult<Stage2EachProps>> {
   // -- TODO: :https://redmine.weseek.co.jp/issues/174725
   // -- TODO: :https://redmine.weseek.co.jp/issues/174725
   // Remove getServerSideI18nProps from getServerSidePropsForSameRoute for performance improvement
   // Remove getServerSideI18nProps from getServerSidePropsForSameRoute for performance improvement
-  const [i18nPropsResult, pageDataResult] = await Promise.all([
+  const [i18nPropsResult, pageDataForSameRouteResult] = await Promise.all([
     getServerSideI18nProps(context, ['translation']),
     getServerSideI18nProps(context, ['translation']),
     getPageDataForSameRoute(context),
     getPageDataForSameRoute(context),
   ]);
   ]);
 
 
+  const { props: pageDataProps, internalProps } = pageDataForSameRouteResult;
+
+  // Add user to seen users
+  emitPageSeenEvent(context, internalProps?.pageId);
+
   // -- TODO: persist activity
   // -- TODO: persist activity
   // const mergedProps = await mergedResult.props;
   // const mergedProps = await mergedResult.props;
   // await addActivity(context, getActivityAction(mergedProps));
   // await addActivity(context, getActivityAction(mergedProps));
   const mergedResult = mergeGetServerSidePropsResults(
   const mergedResult = mergeGetServerSidePropsResults(
-    pageDataResult,
+    { props: pageDataProps },
     i18nPropsResult,
     i18nPropsResult,
   );
   );
 
 

+ 0 - 122
apps/app/src/server/models/bookmark.js

@@ -1,122 +0,0 @@
-/* eslint-disable no-return-await */
-
-import mongoose from 'mongoose';
-import mongoosePaginate from 'mongoose-paginate-v2';
-import uniqueValidator from 'mongoose-unique-validator';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:models:bookmark');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-const factory = (crowi) => {
-  const bookmarkEvent = crowi.event('bookmark');
-
-  let bookmarkSchema = null;
-
-  bookmarkSchema = new mongoose.Schema(
-    {
-      page: { type: ObjectId, ref: 'Page', index: true },
-      user: { type: ObjectId, ref: 'User', index: true },
-    },
-    {
-      timestamps: { createdAt: true, updatedAt: false },
-    },
-  );
-  bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
-  bookmarkSchema.plugin(mongoosePaginate);
-  bookmarkSchema.plugin(uniqueValidator);
-
-  bookmarkSchema.statics.countByPageId = async function (pageId) {
-    return await this.count({ page: pageId });
-  };
-
-  /**
-   * @return {object} key: page._id, value: bookmark count
-   */
-  bookmarkSchema.statics.getPageIdToCountMap = async function (pageIds) {
-    const results = await this.aggregate()
-      .match({ page: { $in: pageIds } })
-      .group({ _id: '$page', count: { $sum: 1 } });
-
-    // convert to map
-    const idToCountMap = {};
-    results.forEach((result) => {
-      idToCountMap[result._id] = result.count;
-    });
-
-    return idToCountMap;
-  };
-
-  // bookmark チェック用
-  bookmarkSchema.statics.findByPageIdAndUserId = function (pageId, userId) {
-    return new Promise((resolve, reject) => {
-      return this.findOne({ page: pageId, user: userId }, (err, doc) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(doc);
-      });
-    });
-  };
-
-  bookmarkSchema.statics.add = async function (page, user) {
-    // biome-ignore lint/complexity/noUselessThisAlias: ignore
-    const Bookmark = this;
-
-    const newBookmark = new Bookmark({ page, user });
-
-    try {
-      const bookmark = await newBookmark.save();
-      bookmarkEvent.emit('create', page._id);
-      return bookmark;
-    } catch (err) {
-      if (err.code === 11000) {
-        // duplicate key (dummy response of new object)
-        return newBookmark;
-      }
-      logger.debug('Bookmark.save failed', err);
-      throw err;
-    }
-  };
-
-  /**
-   * Remove bookmark
-   * used only when removing the page
-   * @param {string} pageId
-   */
-  bookmarkSchema.statics.removeBookmarksByPageId = async function (pageId) {
-    // biome-ignore lint/complexity/noUselessThisAlias: ignore
-    const Bookmark = this;
-
-    try {
-      const data = await Bookmark.remove({ page: pageId });
-      bookmarkEvent.emit('delete', pageId);
-      return data;
-    } catch (err) {
-      logger.debug('Bookmark.remove failed (removeBookmarkByPage)', err);
-      throw err;
-    }
-  };
-
-  bookmarkSchema.statics.removeBookmark = async function (pageId, user) {
-    // biome-ignore lint/complexity/noUselessThisAlias: ignore
-    const Bookmark = this;
-
-    try {
-      const data = await Bookmark.findOneAndRemove({ page: pageId, user });
-      bookmarkEvent.emit('delete', pageId);
-      return data;
-    } catch (err) {
-      logger.debug('Bookmark.findOneAndRemove failed', err);
-      throw err;
-    }
-  };
-
-  return mongoose.model('Bookmark', bookmarkSchema);
-};
-
-export default factory;

+ 151 - 0
apps/app/src/server/models/bookmark.ts

@@ -0,0 +1,151 @@
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IBookmark } from '~/interfaces/bookmark-info';
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../crowi';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+const logger = loggerFactory('growi:models:bookmark');
+
+export interface BookmarkDocument extends IBookmark, Document {
+  _id: Types.ObjectId;
+  page: Types.ObjectId;
+  user: Types.ObjectId;
+  createdAt: Date;
+}
+
+export interface BookmarkModel extends Model<BookmarkDocument> {
+  countByPageId(pageId: Types.ObjectId | string): Promise<number>;
+  getPageIdToCountMap(
+    pageIds: Types.ObjectId[],
+  ): Promise<{ [key: string]: number }>;
+  findByPageIdAndUserId(
+    pageId: Types.ObjectId | string,
+    userId: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null>;
+  add(
+    page: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument>;
+  removeBookmarksByPageId(
+    pageId: Types.ObjectId | string,
+  ): Promise<{ deletedCount: number }>;
+  removeBookmark(
+    pageId: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null>;
+}
+
+const factory = (crowi: Crowi) => {
+  const bookmarkEvent = crowi.event('bookmark');
+
+  const bookmarkSchema = new Schema<BookmarkDocument, BookmarkModel>(
+    {
+      page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+      user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+    },
+    {
+      timestamps: { createdAt: true, updatedAt: false },
+    },
+  );
+
+  bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(mongoosePaginate);
+  bookmarkSchema.plugin(uniqueValidator);
+
+  bookmarkSchema.statics.countByPageId = async function (
+    pageId: Types.ObjectId | string,
+  ): Promise<number> {
+    return await this.countDocuments({ page: pageId });
+  };
+
+  /**
+   * @return {object} key: page._id, value: bookmark count
+   */
+  bookmarkSchema.statics.getPageIdToCountMap = async function (
+    pageIds: Types.ObjectId[],
+  ): Promise<{ [key: string]: number }> {
+    const results = await this.aggregate()
+      .match({ page: { $in: pageIds } })
+      .group({ _id: '$page', count: { $sum: 1 } });
+
+    // convert to map
+    const idToCountMap: { [key: string]: number } = {};
+    results.forEach((result) => {
+      idToCountMap[result._id] = result.count;
+    });
+
+    return idToCountMap;
+  };
+
+  // bookmark チェック用
+  bookmarkSchema.statics.findByPageIdAndUserId = async function (
+    pageId: Types.ObjectId | string,
+    userId: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null> {
+    return await this.findOne({ page: pageId, user: userId });
+  };
+
+  bookmarkSchema.statics.add = async function (
+    page: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument> {
+    const newBookmark = new this({ page, user });
+
+    try {
+      const bookmark = await newBookmark.save();
+      bookmarkEvent.emit('create', page);
+      return bookmark;
+    } catch (err: any) {
+      if (err.code === 11000) {
+        // duplicate key (dummy response of new object)
+        return newBookmark;
+      }
+      logger.debug('Bookmark.save failed', err);
+      throw err;
+    }
+  };
+
+  /**
+   * Remove bookmark
+   * used only when removing the page
+   * @param {string} pageId
+   */
+  bookmarkSchema.statics.removeBookmarksByPageId = async function (
+    pageId: Types.ObjectId | string,
+  ): Promise<{ deletedCount: number }> {
+    try {
+      const result = await this.deleteMany({ page: pageId });
+      bookmarkEvent.emit('delete', pageId);
+      return { deletedCount: result.deletedCount ?? 0 };
+    } catch (err) {
+      logger.debug('Bookmark.remove failed (removeBookmarkByPage)', err);
+      throw err;
+    }
+  };
+
+  bookmarkSchema.statics.removeBookmark = async function (
+    pageId: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null> {
+    try {
+      const data = await this.findOneAndDelete({ page: pageId, user });
+      bookmarkEvent.emit('delete', pageId);
+      return data;
+    } catch (err) {
+      logger.debug('Bookmark.findOneAndRemove failed', err);
+      throw err;
+    }
+  };
+
+  return getOrCreateModel<BookmarkDocument, BookmarkModel>(
+    'Bookmark',
+    bookmarkSchema,
+  );
+};
+
+export default factory;

+ 52 - 21
apps/app/src/server/routes/apiv3/bookmarks.js → apps/app/src/server/routes/apiv3/bookmarks.ts

@@ -1,9 +1,14 @@
+import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import mongoose, { type HydratedDocument } from 'mongoose';
 
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import type { BookmarkDocument, BookmarkModel } from '~/server/models/bookmark';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -91,15 +96,16 @@ module.exports = (crowi) => {
     crowi,
     crowi,
     true,
     true,
   );
   );
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
-  const { Page, Bookmark } = crowi.models;
-
   const validator = {
   const validator = {
     bookmarks: [body('pageId').isString(), body('bool').isBoolean()],
     bookmarks: [body('pageId').isString(), body('bool').isBoolean()],
     bookmarkInfo: [query('pageId').isMongoId()],
     bookmarkInfo: [query('pageId').isMongoId()],
+    userBookmarkList: [
+      param('userId').isMongoId().withMessage('userId is required'),
+    ],
   };
   };
 
 
   /**
   /**
@@ -134,18 +140,32 @@ module.exports = (crowi) => {
       const { user } = req;
       const { user } = req;
       const { pageId } = req.query;
       const { pageId } = req.query;
 
 
-      const responsesParams = {};
+      // Prevent NoSQL injection - ensure pageId is a string
+      if (typeof pageId !== 'string') {
+        return res.status(400).apiv3Err('Invalid pageId parameter', 400);
+      }
+
+      const responsesParams: IBookmarkInfo = {
+        sumOfBookmarks: 0,
+        isBookmarked: false,
+        bookmarkedUsers: [],
+        pageId: '',
+      };
+
+      const Bookmark: BookmarkModel = mongoose.model<
+        HydratedDocument<BookmarkDocument>,
+        BookmarkModel
+      >('Bookmark');
 
 
       try {
       try {
-        const bookmarks = await Bookmark.find({ page: pageId }).populate(
-          'user',
+        const bookmarks = await Bookmark.find({
+          page: { $eq: pageId },
+        }).populate<{
+          user: IUserHasId;
+        }>('user');
+        const users = bookmarks.map((bookmark) =>
+          serializeUserSecurely(bookmark.user),
         );
         );
-        let users = [];
-        if (bookmarks.length > 0) {
-          users = bookmarks.map((bookmark) =>
-            serializeUserSecurely(bookmark.user),
-          );
-        }
         responsesParams.sumOfBookmarks = bookmarks.length;
         responsesParams.sumOfBookmarks = bookmarks.length;
         responsesParams.bookmarkedUsers = users;
         responsesParams.bookmarkedUsers = users;
         responsesParams.pageId = pageId;
         responsesParams.pageId = pageId;
@@ -194,10 +214,6 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Bookmarks'
    *                  $ref: '#/components/schemas/Bookmarks'
    */
    */
-  validator.userBookmarkList = [
-    param('userId').isMongoId().withMessage('userId is required'),
-  ];
-
   router.get(
   router.get(
     '/:userId',
     '/:userId',
     accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
     accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
@@ -210,6 +226,12 @@ module.exports = (crowi) => {
       if (userId == null) {
       if (userId == null) {
         return res.apiv3Err('User id is not found or forbidden', 400);
         return res.apiv3Err('User id is not found or forbidden', 400);
       }
       }
+
+      const Bookmark: BookmarkModel = mongoose.model<
+        HydratedDocument<BookmarkDocument>,
+        BookmarkModel
+      >('Bookmark');
+
       try {
       try {
         const bookmarkIdsInFolders = await BookmarkFolder.distinct(
         const bookmarkIdsInFolders = await BookmarkFolder.distinct(
           'bookmarks',
           'bookmarks',
@@ -281,10 +303,19 @@ module.exports = (crowi) => {
         return res.apiv3Err('A logged in user is required.');
         return res.apiv3Err('A logged in user is required.');
       }
       }
 
 
-      let page;
-      let bookmark;
+      const Page: PageModel = mongoose.model<
+        HydratedDocument<PageDocument>,
+        PageModel
+      >('Page');
+      const Bookmark: BookmarkModel = mongoose.model<
+        HydratedDocument<BookmarkDocument>,
+        BookmarkModel
+      >('Bookmark');
+
+      let page: HydratedDocument<PageDocument> | null;
+      let bookmark: HydratedDocument<BookmarkDocument> | null;
       try {
       try {
-        page = await Page.findByIdAndViewer(pageId, req.user);
+        page = await Page.findByIdAndViewer(pageId, req.user, undefined, true);
         if (page == null) {
         if (page == null) {
           return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
           return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         }
         }
@@ -293,7 +324,7 @@ module.exports = (crowi) => {
 
 
         if (bookmark == null) {
         if (bookmark == null) {
           if (bool) {
           if (bool) {
-            bookmark = await Bookmark.add(page, req.user);
+            bookmark = await Bookmark.add(page._id, req.user);
           } else {
           } else {
             logger.warn(
             logger.warn(
               `Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`,
               `Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`,
@@ -306,7 +337,7 @@ module.exports = (crowi) => {
               `Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`,
               `Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`,
             );
             );
           } else {
           } else {
-            bookmark = await Bookmark.removeBookmark(page, req.user);
+            bookmark = await Bookmark.removeBookmark(page._id, req.user);
           }
           }
         }
         }
       } catch (err) {
       } catch (err) {

+ 24 - 15
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,6 +1,6 @@
-import type { IPageInfo, IPageInfoForListing, IUserHasId } from '@growi/core';
+import type { IPageInfoForListing, IUserHasId } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
-import { SCOPE } from '@growi/core/dist/interfaces';
+import { type IPageInfoForEmpty, SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
@@ -275,8 +275,10 @@ const routerFactory = (crowi: Crowi): Router => {
           )) as Record<string, number>;
           )) as Record<string, number>;
         }
         }
 
 
-        const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> =
-          {};
+        const idToPageInfoMap: Record<
+          string,
+          IPageInfoForEmpty | IPageInfoForListing
+        > = {};
 
 
         const isGuestUser = req.user == null;
         const isGuestUser = req.user == null;
 
 
@@ -285,16 +287,14 @@ const routerFactory = (crowi: Crowi): Router => {
         );
         );
 
 
         for (const page of pages) {
         for (const page of pages) {
-          const basicPageInfo = {
-            ...pageService.constructBasicPageInfo(page, isGuestUser),
-            bookmarkCount:
-              bookmarkCountMap != null
-                ? (bookmarkCountMap[page._id.toString()] ?? 0)
-                : 0,
-          };
-
           // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
           // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
-          const canDeleteCompletely = pageService.canDeleteCompletely(
+          const isDeletable = pageService.canDelete(
+            page,
+            page.creator == null ? null : getIdForRef(page.creator),
+            req.user,
+            false,
+          );
+          const isAbleToDeleteCompletely = pageService.canDeleteCompletely(
             page,
             page,
             page.creator == null ? null : getIdForRef(page.creator),
             page.creator == null ? null : getIdForRef(page.creator),
             req.user,
             req.user,
@@ -302,11 +302,20 @@ const routerFactory = (crowi: Crowi): Router => {
             userRelatedGroups,
             userRelatedGroups,
           ); // use normal delete config
           ); // use normal delete config
 
 
+          const basicPageInfo = {
+            ...pageService.constructBasicPageInfo(page, isGuestUser),
+            isDeletable,
+            isAbleToDeleteCompletely,
+            bookmarkCount:
+              bookmarkCountMap != null
+                ? (bookmarkCountMap[page._id.toString()] ?? 0)
+                : 0,
+          };
+
           const pageInfo = !isIPageInfoForEntity(basicPageInfo)
           const pageInfo = !isIPageInfoForEntity(basicPageInfo)
-            ? basicPageInfo
+            ? (basicPageInfo satisfies IPageInfoForEmpty)
             : ({
             : ({
                 ...basicPageInfo,
                 ...basicPageInfo,
-                isAbleToDeleteCompletely: canDeleteCompletely,
                 revisionShortBody:
                 revisionShortBody:
                   shortBodiesMap != null
                   shortBodiesMap != null
                     ? (shortBodiesMap[page._id.toString()] ?? undefined)
                     ? (shortBodiesMap[page._id.toString()] ?? undefined)

+ 11 - 18
apps/app/src/server/routes/apiv3/page/index.ts

@@ -1,3 +1,5 @@
+import type { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
 import type {
 import type {
   IDataWithMeta,
   IDataWithMeta,
   IPage,
   IPage,
@@ -19,10 +21,8 @@ import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-ut
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
-import path from 'path';
+import path from 'pathe';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
-import type { Readable } from 'stream';
-import { pipeline } from 'stream/promises';
 
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
@@ -592,7 +592,6 @@ module.exports = (crowi: Crowi) => {
 
 
         if (isIPageNotFoundInfo(meta)) {
         if (isIPageNotFoundInfo(meta)) {
           // Return error only when the page is forbidden
           // Return error only when the page is forbidden
-          // Empty pages (isEmpty: true) should return page info for UI operations
           if (meta.isForbidden) {
           if (meta.isForbidden) {
             return res.apiv3Err(
             return res.apiv3Err(
               new ErrorV3(
               new ErrorV3(
@@ -604,9 +603,9 @@ module.exports = (crowi: Crowi) => {
               403,
               403,
             );
             );
           }
           }
-          // For not found but not forbidden pages (isEmpty: true), return the meta info
         }
         }
 
 
+        // Empty pages (isEmpty: true) should return page info for UI operations
         return res.apiv3(meta);
         return res.apiv3(meta);
       } catch (err) {
       } catch (err) {
         logger.error('get-page-info', err);
         logger.error('get-page-info', err);
@@ -640,7 +639,7 @@ module.exports = (crowi: Crowi) => {
    *                    isGrantNormalized:
    *                    isGrantNormalized:
    *                      type: boolean
    *                      type: boolean
    *          400:
    *          400:
-   *            description: Bad request. Page is unreachable or empty.
+   *            description: Bad request. Page is unreachable.
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
@@ -656,15 +655,12 @@ module.exports = (crowi: Crowi) => {
       const Page = mongoose.model<IPage, PageModel>('Page');
       const Page = mongoose.model<IPage, PageModel>('Page');
       const pageGrantService = crowi.pageGrantService as IPageGrantService;
       const pageGrantService = crowi.pageGrantService as IPageGrantService;
 
 
-      const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
       if (page == null) {
       if (page == null) {
         // Empty page should not be related to grant API
         // Empty page should not be related to grant API
         return res.apiv3Err(
         return res.apiv3Err(
-          new ErrorV3(
-            'Page is unreachable or empty.',
-            'page_unreachable_or_empty',
-          ),
+          new ErrorV3('Page is unreachable', 'page_unreachable'),
           400,
           400,
         );
         );
       }
       }
@@ -708,7 +704,7 @@ module.exports = (crowi: Crowi) => {
         getIdForRef(page.parent),
         getIdForRef(page.parent),
         req.user,
         req.user,
         null,
         null,
-        false,
+        true,
       );
       );
 
 
       // user isn't allowed to see parent's grant
       // user isn't allowed to see parent's grant
@@ -866,7 +862,7 @@ module.exports = (crowi: Crowi) => {
    *                     items:
    *                     items:
    *                       type: string
    *                       type: string
    *         400:
    *         400:
-   *           description: Bad request. Page is unreachable or empty.
+   *           description: Bad request. Page is unreachable.
    *         500:
    *         500:
    *           description: Internal server error.
    *           description: Internal server error.
    */
    */
@@ -880,15 +876,12 @@ module.exports = (crowi: Crowi) => {
       const { pageId } = req.query;
       const { pageId } = req.query;
 
 
       const Page = mongoose.model<IPage, PageModel>('Page');
       const Page = mongoose.model<IPage, PageModel>('Page');
-      const page = await Page.findByIdAndViewer(pageId, req.user, null);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
       if (page == null) {
       if (page == null) {
         // Empty page should not be related to grant API
         // Empty page should not be related to grant API
         return res.apiv3Err(
         return res.apiv3Err(
-          new ErrorV3(
-            'Page is unreachable or empty.',
-            'page_unreachable_or_empty',
-          ),
+          new ErrorV3('Page is unreachable', 'page_unreachable'),
           400,
           400,
         );
         );
       }
       }

+ 42 - 21
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -1,41 +1,45 @@
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
-
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import { vi } from 'vitest';
 import { vi } from 'vitest';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
+import type { IPage } from '^/../../packages/core/dist';
+
 import { getPageSchema } from '~/server/models/obsolete-page';
 import { getPageSchema } from '~/server/models/obsolete-page';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 
 
+import type { PageModel } from '../../models/page';
 import pageModel from '../../models/page';
 import pageModel from '../../models/page';
-
 import { deleteCompletelyUserHomeBySystem } from './delete-completely-user-home-by-system';
 import { deleteCompletelyUserHomeBySystem } from './delete-completely-user-home-by-system';
 import type { IPageService } from './page-service';
 import type { IPageService } from './page-service';
 
 
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // ref: https://github.com/vitest-dev/vitest/issues/846
 // ref: https://github.com/vitest-dev/vitest/issues/846
-const userSchema = new mongoose.Schema({
-  name: { type: String },
-  username: { type: String, required: true, unique: true },
-  email: { type: String, unique: true, sparse: true },
-}, {
-  timestamps: true,
-});
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 const User = mongoose.model('User', userSchema);
 const User = mongoose.model('User', userSchema);
 
 
 describe('delete-completely-user-home-by-system test', () => {
 describe('delete-completely-user-home-by-system test', () => {
-  let Page;
+  let Page: PageModel;
 
 
   const initialEnv = process.env;
   const initialEnv = process.env;
 
 
   const userId1 = new mongoose.Types.ObjectId();
   const userId1 = new mongoose.Types.ObjectId();
   const user1HomepageId = new mongoose.Types.ObjectId();
   const user1HomepageId = new mongoose.Types.ObjectId();
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     // setup page model
     // setup page model
     getPageSchema(null);
     getPageSchema(null);
     pageModel(null);
     pageModel(null);
-    Page = mongoose.model('Page');
+    Page = mongoose.model<IPage, PageModel>('Page');
 
 
     // setup config
     // setup config
     await configManager.loadConfigs();
     await configManager.loadConfigs();
@@ -45,7 +49,10 @@ describe('delete-completely-user-home-by-system test', () => {
 
 
     // setup user documents
     // setup user documents
     const user1 = await User.create({
     const user1 = await User.create({
-      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+      _id: userId1,
+      name: 'user1',
+      username: 'user1',
+      email: 'user1@example.com',
     });
     });
 
 
     // setup page documents
     // setup page documents
@@ -85,10 +92,16 @@ describe('delete-completely-user-home-by-system test', () => {
 
 
   describe('deleteCompletelyUserHomeBySystem()', () => {
   describe('deleteCompletelyUserHomeBySystem()', () => {
     // setup
     // setup
-    const mockUpdateDescendantCountOfAncestors = vi.fn().mockImplementation(() => Promise.resolve());
-    const mockDeleteCompletelyOperation = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockUpdateDescendantCountOfAncestors = vi
+      .fn()
+      .mockImplementation(() => Promise.resolve());
+    const mockDeleteCompletelyOperation = vi
+      .fn()
+      .mockImplementation(() => Promise.resolve());
     const mockPageEvent = mock<EventEmitter>();
     const mockPageEvent = mock<EventEmitter>();
-    const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockDeleteMultipleCompletely = vi
+      .fn()
+      .mockImplementation(() => Promise.resolve());
 
 
     const mockPageService = mock<IPageService>({
     const mockPageService = mock<IPageService>({
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
@@ -97,10 +110,13 @@ describe('delete-completely-user-home-by-system test', () => {
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
     });
     });
 
 
-    it('should call used page service functions', async() => {
+    it('should call used page service functions', async () => {
       // when
       // when
       const existsUserHomepagePath = '/user/user1';
       const existsUserHomepagePath = '/user/user1';
-      await deleteCompletelyUserHomeBySystem(existsUserHomepagePath, mockPageService);
+      await deleteCompletelyUserHomeBySystem(
+        existsUserHomepagePath,
+        mockPageService,
+      );
 
 
       // then
       // then
       expect(mockUpdateDescendantCountOfAncestors).toHaveBeenCalled();
       expect(mockUpdateDescendantCountOfAncestors).toHaveBeenCalled();
@@ -109,13 +125,18 @@ describe('delete-completely-user-home-by-system test', () => {
       expect(mockDeleteMultipleCompletely).toHaveBeenCalled();
       expect(mockDeleteMultipleCompletely).toHaveBeenCalled();
     });
     });
 
 
-    it('should throw error if userHomepage is not exists', async() => {
+    it('should throw error if userHomepage is not exists', async () => {
       // when
       // when
       const notExistsUserHomepagePath = '/user/not_exists_user';
       const notExistsUserHomepagePath = '/user/not_exists_user';
-      const deleteUserHomepageFunction = deleteCompletelyUserHomeBySystem(notExistsUserHomepagePath, mockPageService);
+      const deleteUserHomepageFunction = deleteCompletelyUserHomeBySystem(
+        notExistsUserHomepagePath,
+        mockPageService,
+      );
 
 
       // then
       // then
-      expect(deleteUserHomepageFunction).rejects.toThrow('user homepage is not found.');
+      expect(deleteUserHomepageFunction).rejects.toThrow(
+        'user homepage is not found.',
+      );
     });
     });
   });
   });
 });
 });

+ 36 - 25
apps/app/src/server/service/page/delete-completely-user-home-by-system.ts

@@ -1,11 +1,10 @@
-import { Writable } from 'stream';
-import { pipeline } from 'stream/promises';
-
-import { getIdForRef } from '@growi/core';
 import type { IPage, Ref } from '@growi/core';
 import type { IPage, Ref } from '@growi/core';
+import { getIdForRef } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
+import { Writable } from 'stream';
+import { pipeline } from 'stream/promises';
 
 
 import type { PageDocument, PageModel } from '~/server/models/page';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
@@ -17,29 +16,33 @@ import { shouldUseV4Process } from './should-use-v4-process';
 
 
 const logger = loggerFactory('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 
 
-
-type IPageUnderV5 = Omit<IPage, 'parent'> & { parent: Ref<IPage> }
+type IPageUnderV5 = Omit<IPage, 'parent'> & { parent: Ref<IPage> };
 
 
 const _shouldUseV5Process = (page: IPage): page is IPageUnderV5 => {
 const _shouldUseV5Process = (page: IPage): page is IPageUnderV5 => {
   return !shouldUseV4Process(page);
   return !shouldUseV4Process(page);
 };
 };
 
 
 /**
 /**
-   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
-   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
-   *
-   * @param {string} userHomepagePath - The path of the user's homepage.
-   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
-   * @throws {Error} - If an error occurs during the deletion process.
-   */
-export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string, pageService: IPageService): Promise<void> => {
+ * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
+ * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
+ *
+ * @param {string} userHomepagePath - The path of the user's homepage.
+ * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
+ * @throws {Error} - If an error occurs during the deletion process.
+ */
+export const deleteCompletelyUserHomeBySystem = async (
+  userHomepagePath: string,
+  pageService: IPageService,
+): Promise<void> => {
   if (!isUsersHomepage(userHomepagePath)) {
   if (!isUsersHomepage(userHomepagePath)) {
     const msg = 'input value is not user homepage path.';
     const msg = 'input value is not user homepage path.';
     logger.error(msg);
     logger.error(msg);
     throw new Error(msg);
     throw new Error(msg);
   }
   }
 
 
-  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+    'Page',
+  );
   const userHomepage = await Page.findByPath(userHomepagePath, true);
   const userHomepage = await Page.findByPath(userHomepagePath, true);
 
 
   if (userHomepage == null) {
   if (userHomepage == null) {
@@ -56,8 +59,14 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
   try {
   try {
     if (shouldUseV5Process) {
     if (shouldUseV5Process) {
       // Ensure consistency of ancestors
       // Ensure consistency of ancestors
-      const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
-      await pageService.updateDescendantCountOfAncestors(getIdForRef(userHomepage.parent), inc, true);
+      const inc = userHomepage.isEmpty
+        ? -userHomepage.descendantCount
+        : -(userHomepage.descendantCount + 1);
+      await pageService.updateDescendantCountOfAncestors(
+        getIdForRef(userHomepage.parent),
+        inc,
+        true,
+      );
     }
     }
 
 
     // Delete the user's homepage
     // Delete the user's homepage
@@ -65,7 +74,9 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
 
 
     if (shouldUseV5Process) {
     if (shouldUseV5Process) {
       // Remove leaf empty pages
       // Remove leaf empty pages
-      await Page.removeLeafEmptyPagesRecursively(getIdForRef(userHomepage.parent));
+      await Page.removeLeafEmptyPagesRecursively(
+        getIdForRef(userHomepage.parent),
+      );
     }
     }
 
 
     if (!userHomepage.isEmpty) {
     if (!userHomepage.isEmpty) {
@@ -82,8 +93,7 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
 
 
     // Stream processing to delete descendant pages
     // Stream processing to delete descendant pages
     // ────────┤ start │─────────
     // ────────┤ start │─────────
-    const readStream = await builder
-      .query
+    const readStream = await builder.query
       .lean()
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });
       .cursor({ batchSize: BULK_REINDEX_SIZE });
 
 
@@ -98,8 +108,7 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
           // Delete multiple pages completely
           // Delete multiple pages completely
           await pageService.deleteMultipleCompletely(batch, undefined);
           await pageService.deleteMultipleCompletely(batch, undefined);
           logger.debug(`Adding pages progressing: (count=${count})`);
           logger.debug(`Adding pages progressing: (count=${count})`);
-        }
-        catch (err) {
+        } catch (err) {
           logger.error('addAllPages error on add anyway: ', err);
           logger.error('addAllPages error on add anyway: ', err);
         }
         }
         callback();
         callback();
@@ -112,9 +121,11 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
 
 
     await pipeline(readStream, batchStream, writeStream);
     await pipeline(readStream, batchStream, writeStream);
     // ────────┤ end │─────────
     // ────────┤ end │─────────
-  }
-  catch (err) {
-    logger.error('Error occurred while deleting user homepage and subpages.', err);
+  } catch (err) {
+    logger.error(
+      'Error occurred while deleting user homepage and subpages.',
+      err,
+    );
     throw err;
     throw err;
   }
   }
 };
 };

Разница между файлами не показана из-за своего большого размера
+ 442 - 174
apps/app/src/server/service/page/index.ts


+ 180 - 42
apps/app/src/server/service/page/page-service.ts

@@ -1,11 +1,17 @@
-import type EventEmitter from 'events';
-
+import type { EventEmitter } from 'node:events';
 import type {
 import type {
   HasObjectId,
   HasObjectId,
   IDataWithRequiredMeta,
   IDataWithRequiredMeta,
   IGrantedGroup,
   IGrantedGroup,
-  IPageInfo, IPageInfoForEntity, IPageNotFoundInfo, IUser, IPageInfoExt, IPage, PageGrant, IUserHasId,
-} from '@growi/core';
+  IPage,
+  IPageInfoExt,
+  IPageInfoForEmpty,
+  IPageInfoForEntity,
+  IPageNotFoundInfo,
+  IUser,
+  IUserHasId,
+  PageGrant,
+} from '@growi/core/dist/interfaces';
 import type { HydratedDocument, Types } from 'mongoose';
 import type { HydratedDocument, Types } from 'mongoose';
 
 
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
@@ -19,59 +25,191 @@ import type { PageOperationDocument } from '~/server/models/page-operation';
 import type { UserGroupDocument } from '~/server/models/user-group';
 import type { UserGroupDocument } from '~/server/models/user-group';
 
 
 export interface IPageService {
 export interface IPageService {
-  create(path: string, body: string, user: HasObjectId, options: IOptionsForCreate): Promise<HydratedDocument<PageDocument>>,
-  forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
+  create(
+    path: string,
+    body: string,
+    user: HasObjectId,
+    options: IOptionsForCreate,
+  ): Promise<HydratedDocument<PageDocument>>;
+  forceCreateBySystem(
+    path: string,
+    body: string,
+    options: IOptionsForCreate,
+  ): Promise<PageDocument>;
   updatePage(
   updatePage(
-    pageData: HydratedDocument<PageDocument>, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate
-  ): Promise<HydratedDocument<PageDocument>>,
-  updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
+    pageData: HydratedDocument<PageDocument>,
+    body: string | null,
+    previousBody: string | null,
+    user: IUser,
+    options: IOptionsForUpdate,
+  ): Promise<HydratedDocument<PageDocument>>;
+  updateDescendantCountOfAncestors: (
+    pageId: ObjectIdLike,
+    inc: number,
+    shouldIncludeTarget: boolean,
+  ) => Promise<void>;
   updateGrant(
   updateGrant(
-    page: HydratedDocument<PageDocument>, user: IUserHasId, grantData: {grant: PageGrant, userRelatedGrantedGroups: IGrantedGroup[]},
-  ): Promise<PageDocument>,
-  deleteCompletelyOperation: (pageIds: ObjectIdLike[], pagePaths: string[]) => Promise<void>,
-  getEventEmitter: () => EventEmitter,
-  deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+    page: HydratedDocument<PageDocument>,
+    user: IUserHasId,
+    grantData: { grant: PageGrant; userRelatedGrantedGroups: IGrantedGroup[] },
+  ): Promise<PageDocument>;
+  deleteCompletelyOperation: (
+    pageIds: ObjectIdLike[],
+    pagePaths: string[],
+  ) => Promise<void>;
+  getEventEmitter: () => EventEmitter;
+  deleteMultipleCompletely: (
+    pages: ObjectIdLike[],
+    user: IUser | undefined,
+  ) => Promise<void>;
   findPageAndMetaDataByViewer(
   findPageAndMetaDataByViewer(
-      pageId: string, path: string | null, user?: HydratedDocument<IUser>, isSharedPage?: boolean,
-  ): Promise<IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithRequiredMeta<null, IPageNotFoundInfo>>
+    pageId: string,
+    path: string | null,
+    user?: HydratedDocument<IUser>,
+    isSharedPage?: boolean,
+  ): Promise<
+    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
+  >;
   findPageAndMetaDataByViewer(
   findPageAndMetaDataByViewer(
-      pageId: string | null, path: string, user?: HydratedDocument<IUser>, isSharedPage?: boolean,
-  ): Promise<IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithRequiredMeta<null, IPageNotFoundInfo>>
-  resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
+    pageId: string | null,
+    path: string,
+    user?: HydratedDocument<IUser>,
+    isSharedPage?: boolean,
+  ): Promise<
+    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
+  >;
+  resumeRenameSubOperation(
+    renamedPage: PageDocument,
+    pageOp: PageOperationDocument,
+    activity?,
+  ): Promise<void>;
   handlePrivatePagesForGroupsToDelete(
   handlePrivatePagesForGroupsToDelete(
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
     action: PageActionOnGroupDelete,
     action: PageActionOnGroupDelete,
     transferToUserGroup: IGrantedGroup | undefined,
     transferToUserGroup: IGrantedGroup | undefined,
     user: IUser,
     user: IUser,
-): Promise<void>
-  shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): Omit<IPageInfo | IPageInfoForEntity, 'bookmarkCount'>,
-  normalizeAllPublicPages(): Promise<void>,
-  canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
+  ): Promise<void>;
+  shortBodiesMapByPageIds(
+    pageIds?: Types.ObjectId[],
+    user?,
+  ): Promise<Record<string, string | null>>;
+  constructBasicPageInfo(
+    page: HydratedDocument<PageDocument>,
+    isGuestUser?: boolean,
+  ):
+    | Omit<
+        IPageInfoForEmpty,
+        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
+      >
+    | Omit<
+        IPageInfoForEntity,
+        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
+      >;
+  normalizeAllPublicPages(): Promise<void>;
+  canDelete(
+    page: PageDocument,
+    creatorId: ObjectIdLike | null,
+    operator: any | null,
+    isRecursively: boolean,
+  ): boolean;
   canDeleteCompletely(
   canDeleteCompletely(
-    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
-  ): boolean,
+    page: PageDocument,
+    creatorId: ObjectIdLike | null,
+    operator: any | null,
+    isRecursively: boolean,
+    userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean;
   canDeleteCompletelyAsMultiGroupGrantedPage(
   canDeleteCompletelyAsMultiGroupGrantedPage(
-    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
-  ): boolean,
-  getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
-  updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void>,
-  revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  revertDeletedPage(page, user, options, isRecursively: boolean, activityParameters?),
-  deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  deleteCompletely(page, user, options, isRecursively: boolean, preventEmitting: boolean, activityParameters),
-  deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  deletePage(page, user, options, isRecursively: boolean, activityParameters),
+    page: PageDocument,
+    creatorId: ObjectIdLike | null,
+    operator: any | null,
+    userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean;
+  getYjsData(
+    pageId: string,
+    revisionBody?: string,
+  ): Promise<CurrentPageYjsData>;
+  updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void>;
+  revertRecursivelyMainOperation(
+    page,
+    user,
+    options,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  revertDeletedPage(
+    page,
+    user,
+    options,
+    isRecursively: boolean,
+    activityParameters?,
+  );
+  deleteCompletelyRecursivelyMainOperation(
+    page,
+    user,
+    options,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  deleteCompletely(
+    page,
+    user,
+    options,
+    isRecursively: boolean,
+    preventEmitting: boolean,
+    activityParameters,
+  );
+  deleteRecursivelyMainOperation(
+    page,
+    user,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  deletePage(page, user, options, isRecursively: boolean, activityParameters);
   duplicateRecursivelyMainOperation(
   duplicateRecursivelyMainOperation(
     page: PageDocument,
     page: PageDocument,
     newPagePath: string,
     newPagePath: string,
     user,
     user,
     pageOpId: ObjectIdLike,
     pageOpId: ObjectIdLike,
     onlyDuplicateUserRelatedResources: boolean,
     onlyDuplicateUserRelatedResources: boolean,
-  ): Promise<void>,
-  duplicate(page: PageDocument, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean),
-  renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null>,
-  renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null>,
-  createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void>,
+  ): Promise<void>;
+  duplicate(
+    page: PageDocument,
+    newPagePath: string,
+    user,
+    isRecursively: boolean,
+    onlyDuplicateUserRelatedResources: boolean,
+  );
+  renameSubOperation(
+    page,
+    newPagePath: string,
+    user,
+    options,
+    renamedPage,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  renamePage(
+    page: IPage,
+    newPagePath,
+    user,
+    options,
+    activityParameters,
+  ): Promise<PageDocument | null>;
+  renameMainOperation(
+    page,
+    newPagePath: string,
+    user,
+    options,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<PageDocument | null>;
+  createSubOperation(
+    page,
+    user,
+    options: IOptionsForCreate,
+    pageOpId: ObjectIdLike,
+  ): Promise<void>;
 }
 }

+ 3 - 1
apps/app/src/server/service/page/should-use-v4-process.ts

@@ -14,7 +14,9 @@ export const shouldUseV4Process = (page: IPage): boolean => {
   const isRoot = isTopPage(page.path);
   const isRoot = isTopPage(page.path);
   const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
   const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
 
-  const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
+  const shouldUseV4Process =
+    !isRoot &&
+    (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
 
 
   return shouldUseV4Process;
   return shouldUseV4Process;
 };
 };

+ 25 - 14
apps/app/src/states/page/hooks.ts

@@ -10,7 +10,8 @@ import { useIsGuestUser, useIsReadOnlyUser } from '../context';
 import { useCurrentPathname } from '../global';
 import { useCurrentPathname } from '../global';
 import {
 import {
   currentPageDataAtom,
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   currentPagePathAtom,
   currentPagePathAtom,
   isForbiddenAtom,
   isForbiddenAtom,
   isIdenticalPathAtom,
   isIdenticalPathAtom,
@@ -33,11 +34,21 @@ import {
  */
  */
 
 
 // Read-only hooks for page state
 // Read-only hooks for page state
-export const useCurrentPageId = () => useAtomValue(currentPageIdAtom);
+export const useCurrentPageId = (includeEmpty: boolean = false) => {
+  const entityPageId = useAtomValue(currentPageEntityIdAtom);
+  const emptyPageId = useAtomValue(currentPageEmptyIdAtom);
+
+  return includeEmpty ? (entityPageId ?? emptyPageId) : entityPageId;
+};
 
 
 export const useCurrentPageData = () => useAtomValue(currentPageDataAtom);
 export const useCurrentPageData = () => useAtomValue(currentPageDataAtom);
 
 
-export const usePageNotFound = () => useAtomValue(pageNotFoundAtom);
+export const usePageNotFound = (includeEmpty: boolean = true) => {
+  const isPageNotFound = useAtomValue(pageNotFoundAtom);
+  const emptyPageId = useAtomValue(currentPageEmptyIdAtom);
+
+  return includeEmpty ? isPageNotFound || emptyPageId != null : isPageNotFound;
+};
 
 
 export const useIsIdenticalPath = () => useAtomValue(isIdenticalPathAtom);
 export const useIsIdenticalPath = () => useAtomValue(isIdenticalPathAtom);
 
 
@@ -110,24 +121,24 @@ export const useIsEditable = () => {
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isNotCreatable = useIsNotCreatable();
   const isNotCreatable = useIsNotCreatable();
-
-  const getCombinedConditions = useAtomCallback(
-    useCallback((get) => {
-      const isForbidden = get(isForbiddenAtom);
-      const isIdenticalPath = get(isIdenticalPathAtom);
-
-      return !isForbidden && !isIdenticalPath;
-    }, []),
-  );
+  const isForbidden = useAtomValue(isForbiddenAtom);
+  const isIdenticalPath = useAtomValue(isIdenticalPathAtom);
 
 
   return useMemo(() => {
   return useMemo(() => {
     return (
     return (
       !isGuestUser &&
       !isGuestUser &&
       !isReadOnlyUser &&
       !isReadOnlyUser &&
       !isNotCreatable &&
       !isNotCreatable &&
-      getCombinedConditions()
+      !isForbidden &&
+      !isIdenticalPath
     );
     );
-  }, [getCombinedConditions, isGuestUser, isReadOnlyUser, isNotCreatable]);
+  }, [
+    isGuestUser,
+    isReadOnlyUser,
+    isNotCreatable,
+    isForbidden,
+    isIdenticalPath,
+  ]);
 };
 };
 
 
 /**
 /**

+ 9 - 9
apps/app/src/states/page/hydrate.ts

@@ -2,14 +2,15 @@ import {
   type IPageInfo,
   type IPageInfo,
   type IPageNotFoundInfo,
   type IPageNotFoundInfo,
   type IPagePopulatedToShowRevision,
   type IPagePopulatedToShowRevision,
-  isIPageInfo,
+  isIPageInfoForEmpty,
   isIPageNotFoundInfo,
   isIPageNotFoundInfo,
 } from '@growi/core';
 } from '@growi/core';
 import { useHydrateAtoms } from 'jotai/utils';
 import { useHydrateAtoms } from 'jotai/utils';
 
 
 import {
 import {
   currentPageDataAtom,
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   isForbiddenAtom,
   isIdenticalPathAtom,
   isIdenticalPathAtom,
   pageNotFoundAtom,
   pageNotFoundAtom,
@@ -56,18 +57,17 @@ export const useHydratePageAtoms = (
 ): void => {
 ): void => {
   useHydrateAtoms([
   useHydrateAtoms([
     // Core page state - automatically extract from page object
     // Core page state - automatically extract from page object
-    [currentPageIdAtom, page?._id],
+    [currentPageEntityIdAtom, page?._id],
     [currentPageDataAtom, page ?? undefined],
     [currentPageDataAtom, page ?? undefined],
-    [
-      pageNotFoundAtom,
-      isIPageInfo(pageMeta)
-        ? pageMeta.isNotFound
-        : page == null || page.isEmpty,
-    ],
+    [pageNotFoundAtom, isIPageNotFoundInfo(pageMeta)],
     [
     [
       isForbiddenAtom,
       isForbiddenAtom,
       isIPageNotFoundInfo(pageMeta) ? pageMeta.isForbidden : false,
       isIPageNotFoundInfo(pageMeta) ? pageMeta.isForbidden : false,
     ],
     ],
+    [
+      currentPageEmptyIdAtom,
+      isIPageInfoForEmpty(pageMeta) ? pageMeta.emptyPageId : undefined,
+    ],
 
 
     // Remote revision data - used by ConflictDiffModal
     // Remote revision data - used by ConflictDiffModal
     [remoteRevisionBodyAtom, page?.revision?.body],
     [remoteRevisionBodyAtom, page?.revision?.body],

+ 6 - 4
apps/app/src/states/page/internal-atoms.ts

@@ -8,7 +8,8 @@ import { atom } from 'jotai';
  */
  */
 
 
 // Core page state atoms (internal)
 // Core page state atoms (internal)
-export const currentPageIdAtom = atom<string>();
+export const currentPageEntityIdAtom = atom<string>();
+export const currentPageEmptyIdAtom = atom<string>();
 export const currentPageDataAtom = atom<IPagePopulatedToShowRevision>();
 export const currentPageDataAtom = atom<IPagePopulatedToShowRevision>();
 export const pageNotFoundAtom = atom(false);
 export const pageNotFoundAtom = atom(false);
 export const isIdenticalPathAtom = atom<boolean>(false);
 export const isIdenticalPathAtom = atom<boolean>(false);
@@ -46,7 +47,7 @@ const untitledPageStateAtom = atom<boolean>(false);
 // Derived atom for untitled page state with currentPageId dependency
 // Derived atom for untitled page state with currentPageId dependency
 export const isUntitledPageAtom = atom(
 export const isUntitledPageAtom = atom(
   (get) => {
   (get) => {
-    const currentPageId = get(currentPageIdAtom);
+    const currentPageId = get(currentPageEntityIdAtom);
     // If no current page ID exists, return false (no page loaded)
     // If no current page ID exists, return false (no page loaded)
     if (currentPageId == null) {
     if (currentPageId == null) {
       return false;
       return false;
@@ -55,7 +56,7 @@ export const isUntitledPageAtom = atom(
     return get(untitledPageStateAtom);
     return get(untitledPageStateAtom);
   },
   },
   (get, set, newValue: boolean) => {
   (get, set, newValue: boolean) => {
-    const currentPageId = get(currentPageIdAtom);
+    const currentPageId = get(currentPageEntityIdAtom);
     // Only update state if current page ID exists
     // Only update state if current page ID exists
     if (currentPageId != null) {
     if (currentPageId != null) {
       set(untitledPageStateAtom, newValue);
       set(untitledPageStateAtom, newValue);
@@ -124,7 +125,8 @@ export const _atomsForDerivedAbilities = {
   currentPagePathAtom,
   currentPagePathAtom,
   isIdenticalPathAtom,
   isIdenticalPathAtom,
   shareLinkIdAtom,
   shareLinkIdAtom,
-  currentPageIdAtom,
+  currentPageEntityIdAtom,
+  currentPageEmptyIdAtom,
   isTrashPageAtom,
   isTrashPageAtom,
 } as const;
 } as const;
 
 

+ 170 - 26
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -7,7 +7,7 @@ import type {
   Lang,
   Lang,
   PageGrant,
   PageGrant,
   PageStatus,
   PageStatus,
-} from '@growi/core';
+} from '@growi/core/dist/interfaces';
 import { renderHook, waitFor } from '@testing-library/react';
 import { renderHook, waitFor } from '@testing-library/react';
 // biome-ignore lint/style/noRestrictedImports: import only types
 // biome-ignore lint/style/noRestrictedImports: import only types
 import type { AxiosResponse } from 'axios';
 import type { AxiosResponse } from 'axios';
@@ -19,7 +19,8 @@ import * as apiv3Client from '~/client/util/apiv3-client';
 import { useFetchCurrentPage } from '~/states/page';
 import { useFetchCurrentPage } from '~/states/page';
 import {
 import {
   currentPageDataAtom,
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   isForbiddenAtom,
   pageErrorAtom,
   pageErrorAtom,
   pageLoadingAtom,
   pageLoadingAtom,
@@ -120,9 +121,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
 
   const mockApiResponse = (
   const mockApiResponse = (
     page: IPagePopulatedToShowRevision,
     page: IPagePopulatedToShowRevision,
-  ): AxiosResponse<{ page: IPagePopulatedToShowRevision }> => {
+  ): AxiosResponse<{ page: IPagePopulatedToShowRevision; meta: unknown }> => {
     return {
     return {
-      data: { page },
+      data: { page, meta: {} },
       status: 200,
       status: 200,
       statusText: 'OK',
       statusText: 'OK',
       headers: {},
       headers: {},
@@ -167,7 +168,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/initial/path',
       '/initial/path',
       'initial content',
       'initial content',
     );
     );
-    store.set(currentPageIdAtom, initialPageData._id);
+    store.set(currentPageEntityIdAtom, initialPageData._id);
     store.set(currentPageDataAtom, initialPageData);
     store.set(currentPageDataAtom, initialPageData);
 
 
     // Arrange: Navigate to a new page
     // Arrange: Navigate to a new page
@@ -191,7 +192,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       );
 
 
       // 2. Atoms were updated
       // 2. Atoms were updated
-      expect(store.get(currentPageIdAtom)).toBe(newPageData._id);
+      expect(store.get(currentPageEntityIdAtom)).toBe(newPageData._id);
       expect(store.get(currentPageDataAtom)).toEqual(newPageData);
       expect(store.get(currentPageDataAtom)).toEqual(newPageData);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
@@ -206,7 +207,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       '/same/path',
       'current content',
       'current content',
     );
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Act
     // Act
@@ -226,7 +227,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/some/path',
       '/some/path',
       'current content',
       'current content',
     );
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Act
     // Act
@@ -245,7 +246,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       '/same/path',
       'current content',
       'current content',
     );
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Arrange: API returns different revision
     // Arrange: API returns different revision
@@ -289,7 +290,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       'current content',
       'current content',
     );
     );
     const currentRevisionId = currentPageData.revision?._id;
     const currentRevisionId = currentPageData.revision?._id;
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Act
     // Act
@@ -311,7 +312,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       '/same/path',
       'old content',
       'old content',
     );
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Arrange: API returns old revision
     // Arrange: API returns old revision
@@ -354,7 +355,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       '/same/path',
       'old content',
       'old content',
     );
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Arrange: API returns updated data
     // Arrange: API returns updated data
@@ -392,7 +393,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/some/path',
       '/some/path',
       'old content',
       'old content',
     );
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Arrange: API returns updated data
     // Arrange: API returns updated data
@@ -431,7 +432,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/actual/path',
       '/actual/path',
       'old content',
       'old content',
     );
     );
-    store.set(currentPageIdAtom, permalinkId);
+    store.set(currentPageEntityIdAtom, permalinkId);
     store.set(currentPageDataAtom, currentPageData);
     store.set(currentPageDataAtom, currentPageData);
 
 
     // Arrange: API returns updated data
     // Arrange: API returns updated data
@@ -478,7 +479,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
     await result.current.fetchCurrentPage({ path: '/some/page' });
     await result.current.fetchCurrentPage({ path: '/some/page' });
 
 
     await waitFor(() => {
     await waitFor(() => {
-      expect(store.get(currentPageIdAtom)).toBe('regularPageId');
+      expect(store.get(currentPageEntityIdAtom)).toBe('regularPageId');
     });
     });
 
 
     // Arrange: Navigate to the root page
     // Arrange: Navigate to the root page
@@ -499,7 +500,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         '/page',
         expect.objectContaining({ path: '/' }),
         expect.objectContaining({ path: '/' }),
       );
       );
-      expect(store.get(currentPageIdAtom)).toBe('rootPageId');
+      expect(store.get(currentPageEntityIdAtom)).toBe('rootPageId');
     });
     });
   });
   });
 
 
@@ -524,7 +525,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         '/page',
         expect.objectContaining({ path: decodedPath }),
         expect.objectContaining({ path: decodedPath }),
       );
       );
-      expect(store.get(currentPageIdAtom)).toBe('encodedPageId');
+      expect(store.get(currentPageEntityIdAtom)).toBe('encodedPageId');
     });
     });
   });
   });
 
 
@@ -548,7 +549,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         '/page',
         expect.objectContaining({ pageId: '65d4e0a0f7b7b2e5a8652e86' }),
         expect.objectContaining({ pageId: '65d4e0a0f7b7b2e5a8652e86' }),
       );
       );
-      expect(store.get(currentPageIdAtom)).toBe('65d4e0a0f7b7b2e5a8652e86');
+      expect(store.get(currentPageEntityIdAtom)).toBe(
+        '65d4e0a0f7b7b2e5a8652e86',
+      );
     });
     });
   });
   });
 
 
@@ -574,14 +577,14 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         '/page',
         expect.objectContaining({ pageId: expectedPageId }),
         expect.objectContaining({ pageId: expectedPageId }),
       );
       );
-      // 2. API should NOT be called with path
+      // 2. API should NOT use the permalink from path
       expect(mockedApiv3Get).toHaveBeenCalledWith(
       expect(mockedApiv3Get).toHaveBeenCalledWith(
         '/page',
         '/page',
         expect.not.objectContaining({ path: expect.anything() }),
         expect.not.objectContaining({ path: expect.anything() }),
       );
       );
 
 
       // 3. State should be updated correctly
       // 3. State should be updated correctly
-      expect(store.get(currentPageIdAtom)).toBe(expectedPageId);
+      expect(store.get(currentPageEntityIdAtom)).toBe(expectedPageId);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
@@ -621,7 +624,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       );
 
 
       // 3. State should be updated with explicit pageId
       // 3. State should be updated with explicit pageId
-      expect(store.get(currentPageIdAtom)).toBe(explicitPageId);
+      expect(store.get(currentPageEntityIdAtom)).toBe(explicitPageId);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
     });
     });
   });
   });
@@ -654,7 +657,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       );
 
 
       // 3. State should be updated correctly
       // 3. State should be updated correctly
-      expect(store.get(currentPageIdAtom)).toBe('regularPageId123');
+      expect(store.get(currentPageEntityIdAtom)).toBe('regularPageId123');
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
     });
     });
   });
   });
@@ -688,7 +691,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
       );
 
 
       // 3. State should be updated correctly
       // 3. State should be updated correctly
-      expect(store.get(currentPageIdAtom)).toBe(expectedPageId);
+      expect(store.get(currentPageEntityIdAtom)).toBe(expectedPageId);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
@@ -739,7 +742,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
           '/page',
           '/page',
           expect.objectContaining({ pageId: testCase.expectedPageId }),
           expect.objectContaining({ pageId: testCase.expectedPageId }),
         );
         );
-        expect(store.get(currentPageIdAtom)).toBe(testCase.expectedPageId);
+        expect(store.get(currentPageEntityIdAtom)).toBe(
+          testCase.expectedPageId,
+        );
       });
       });
     }
     }
   });
   });
@@ -751,7 +756,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/some/existing',
       '/some/existing',
       'existing body',
       'existing body',
     );
     );
-    store.set(currentPageIdAtom, existingPage._id);
+    store.set(currentPageEntityIdAtom, existingPage._id);
     store.set(currentPageDataAtom, existingPage);
     store.set(currentPageDataAtom, existingPage);
     store.set(remoteRevisionBodyAtom, 'remote body');
     store.set(remoteRevisionBodyAtom, 'remote body');
 
 
@@ -776,7 +781,8 @@ describe('useFetchCurrentPage - Integration Test', () => {
         message: 'Page not found',
         message: 'Page not found',
       });
       });
       expect(store.get(currentPageDataAtom)).toBeUndefined();
       expect(store.get(currentPageDataAtom)).toBeUndefined();
-      expect(store.get(currentPageIdAtom)).toBeUndefined();
+      expect(store.get(currentPageEntityIdAtom)).toBeUndefined();
+      expect(store.get(currentPageEmptyIdAtom)).toBeUndefined();
       expect(store.get(remoteRevisionBodyAtom)).toBeUndefined();
       expect(store.get(remoteRevisionBodyAtom)).toBeUndefined();
     });
     });
   });
   });
@@ -843,4 +849,142 @@ describe('useFetchCurrentPage - Integration Test', () => {
       expect(store.get(isForbiddenAtom)).toBe(false);
       expect(store.get(isForbiddenAtom)).toBe(false);
     });
     });
   });
   });
+
+  it('should set emptyPageId when page not found with IPageInfoForEmpty in meta', async () => {
+    // Arrange: Mock API response with null page and IPageInfoForEmpty meta
+    const emptyPageId = 'empty123';
+    const notFoundResponseWithEmptyPage = {
+      data: {
+        page: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: false,
+          isEmpty: true, // Required for isIPageInfoForEmpty check
+          emptyPageId,
+        },
+      },
+      status: 200,
+      statusText: 'OK',
+      headers: {},
+      config: {} as AxiosResponse['config'],
+    };
+    mockedApiv3Get.mockResolvedValueOnce(notFoundResponseWithEmptyPage);
+
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/empty/page' });
+
+    // Assert: emptyPageId should be set from meta
+    await waitFor(() => {
+      expect(store.get(pageLoadingAtom)).toBe(false);
+      expect(store.get(pageNotFoundAtom)).toBe(true);
+      expect(store.get(isForbiddenAtom)).toBe(false);
+      expect(store.get(currentPageEmptyIdAtom)).toBe(emptyPageId);
+      expect(store.get(currentPageDataAtom)).toBeUndefined();
+      expect(store.get(currentPageEntityIdAtom)).toBeUndefined();
+    });
+  });
+
+  it('should not set emptyPageId when page not found without IPageInfoForEmpty', async () => {
+    // Arrange: Mock API response with null page and IPageNotFoundInfo meta without emptyPageId
+    const notFoundResponseWithoutEmptyPage = {
+      data: {
+        page: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: false,
+          // No emptyPageId property - not IPageInfoForEmpty
+        },
+      },
+      status: 200,
+      statusText: 'OK',
+      headers: {},
+      config: {} as AxiosResponse['config'],
+    };
+    mockedApiv3Get.mockResolvedValueOnce(notFoundResponseWithoutEmptyPage);
+
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/regular/not/found' });
+
+    // Assert: emptyPageId should be undefined
+    await waitFor(() => {
+      expect(store.get(pageLoadingAtom)).toBe(false);
+      expect(store.get(pageNotFoundAtom)).toBe(true);
+      expect(store.get(isForbiddenAtom)).toBe(false);
+      expect(store.get(currentPageEmptyIdAtom)).toBeUndefined();
+      expect(store.get(currentPageDataAtom)).toBeUndefined();
+      expect(store.get(currentPageEntityIdAtom)).toBeUndefined();
+    });
+  });
+
+  it('should reset emptyPageId to undefined on successful fetch', async () => {
+    // Arrange: Set emptyPageId from a previous failed fetch
+    store.set(currentPageEmptyIdAtom, 'previousEmptyPageId');
+    store.set(pageNotFoundAtom, true);
+
+    // Arrange: API returns successful page data
+    const successPageData = createPageDataMock(
+      'newPageId',
+      '/success/path',
+      'success content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(successPageData));
+
+    // Act
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/success/path' });
+
+    // Assert: emptyPageId should be reset to undefined
+    await waitFor(() => {
+      expect(store.get(pageLoadingAtom)).toBe(false);
+      expect(store.get(pageNotFoundAtom)).toBe(false);
+      expect(store.get(currentPageEmptyIdAtom)).toBeUndefined();
+      expect(store.get(currentPageDataAtom)).toEqual(successPageData);
+      expect(store.get(currentPageEntityIdAtom)).toBe('newPageId');
+    });
+  });
+
+  it('should handle path with encoded Japanese characters', async () => {
+    // Arrange: Path with Japanese characters
+    const japanesePath = '/日本語/ページ';
+    const encodedPath = encodeURIComponent(japanesePath);
+    const pageData = createPageDataMock(
+      'japanesePageId',
+      japanesePath,
+      'Japanese content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(pageData));
+
+    // Act
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: japanesePath });
+
+    // Assert: Path should be properly decoded and sent to API
+    await waitFor(() => {
+      expect(mockedApiv3Get).toHaveBeenCalledWith(
+        '/page',
+        expect.objectContaining({ path: japanesePath }),
+      );
+      expect(store.get(currentPageEntityIdAtom)).toBe('japanesePageId');
+    });
+  });
+
+  it('should call mutatePageInfo after successful fetch', async () => {
+    // Arrange
+    const pageData = createPageDataMock(
+      'pageId123',
+      '/test/path',
+      'test content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(pageData));
+
+    // Act
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/test/path' });
+
+    // Assert: mutatePageInfo should be called to refetch metadata
+    await waitFor(() => {
+      expect(mockMutatePageInfo).toHaveBeenCalled();
+      expect(store.get(currentPageEntityIdAtom)).toBe('pageId123');
+    });
+  });
 });
 });

+ 27 - 14
apps/app/src/states/page/use-fetch-current-page.ts

@@ -1,8 +1,10 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 import {
 import {
+  type IPageNotFoundInfo,
   type IPagePopulatedToShowRevision,
   type IPagePopulatedToShowRevision,
+  isIPageInfoForEmpty,
   isIPageNotFoundInfo,
   isIPageNotFoundInfo,
-} from '@growi/core';
+} from '@growi/core/dist/interfaces';
 import { isErrorV3 } from '@growi/core/dist/models';
 import { isErrorV3 } from '@growi/core/dist/models';
 import { isClient } from '@growi/core/dist/utils';
 import { isClient } from '@growi/core/dist/utils';
 import { isPermalink } from '@growi/core/dist/utils/page-path-utils';
 import { isPermalink } from '@growi/core/dist/utils/page-path-utils';
@@ -16,7 +18,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
   currentPageDataAtom,
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   isForbiddenAtom,
   pageErrorAtom,
   pageErrorAtom,
   pageLoadingAtom,
   pageLoadingAtom,
@@ -35,6 +38,10 @@ type FetchPageArgs = {
   force?: true;
   force?: true;
 };
 };
 
 
+type FetchedPageResult =
+  | { page: IPagePopulatedToShowRevision; meta: unknown }
+  | { page: null; meta: IPageNotFoundInfo };
+
 /**
 /**
  * Process path to handle URL decoding and hash fragment removal
  * Process path to handle URL decoding and hash fragment removal
  *
  *
@@ -176,7 +183,7 @@ export const useFetchCurrentPage = (): {
   error: Error | null;
   error: Error | null;
 } => {
 } => {
   const shareLinkId = useAtomValue(shareLinkIdAtom);
   const shareLinkId = useAtomValue(shareLinkIdAtom);
-  const currentPageId = useAtomValue(currentPageIdAtom);
+  const currentPageId = useAtomValue(currentPageEntityIdAtom);
 
 
   const isLoading = useAtomValue(pageLoadingAtom);
   const isLoading = useAtomValue(pageLoadingAtom);
   const error = useAtomValue(pageErrorAtom);
   const error = useAtomValue(pageErrorAtom);
@@ -193,7 +200,7 @@ export const useFetchCurrentPage = (): {
         set,
         set,
         args?: FetchPageArgs,
         args?: FetchPageArgs,
       ): Promise<IPagePopulatedToShowRevision | null> => {
       ): Promise<IPagePopulatedToShowRevision | null> => {
-        const currentPageId = get(currentPageIdAtom);
+        const currentPageId = get(currentPageEntityIdAtom);
         const currentPageData = get(currentPageDataAtom);
         const currentPageData = get(currentPageDataAtom);
         const revisionIdFromUrl = get(revisionIdFromUrlAtom);
         const revisionIdFromUrl = get(revisionIdFromUrlAtom);
 
 
@@ -231,15 +238,20 @@ export const useFetchCurrentPage = (): {
         }
         }
 
 
         try {
         try {
-          const { data } = await apiv3Get<{
-            page: IPagePopulatedToShowRevision;
-          }>('/page', params);
-          const { page: newData } = data;
-
-          set(currentPageDataAtom, newData);
-          set(currentPageIdAtom, newData._id);
-          set(pageNotFoundAtom, false);
-          set(isForbiddenAtom, false);
+          const { data } = await apiv3Get<FetchedPageResult>('/page', params);
+          const { page: newData, meta } = data;
+
+          set(currentPageDataAtom, newData ?? undefined);
+          set(currentPageEntityIdAtom, newData?._id);
+          set(
+            currentPageEmptyIdAtom,
+            isIPageInfoForEmpty(meta) ? meta.emptyPageId : undefined,
+          );
+          set(pageNotFoundAtom, isIPageNotFoundInfo(meta));
+          set(
+            isForbiddenAtom,
+            isIPageNotFoundInfo(meta) ? (meta.isForbidden ?? false) : false,
+          );
 
 
           // Mutate PageInfo to refetch latest metadata including latestRevisionId
           // Mutate PageInfo to refetch latest metadata including latestRevisionId
           mutatePageInfo();
           mutatePageInfo();
@@ -260,7 +272,8 @@ export const useFetchCurrentPage = (): {
               set(pageNotFoundAtom, true);
               set(pageNotFoundAtom, true);
               set(isForbiddenAtom, error.args.isForbidden ?? false);
               set(isForbiddenAtom, error.args.isForbidden ?? false);
               set(currentPageDataAtom, undefined);
               set(currentPageDataAtom, undefined);
-              set(currentPageIdAtom, undefined);
+              set(currentPageEntityIdAtom, undefined);
+              set(currentPageEmptyIdAtom, undefined);
               set(remoteRevisionBodyAtom, undefined);
               set(remoteRevisionBodyAtom, undefined);
             }
             }
           }
           }

+ 1 - 1
apps/app/src/states/socket-io/global-socket.ts

@@ -68,7 +68,7 @@ export const useSetupGlobalSocket = (): void => {
  */
  */
 export const useSetupGlobalSocketForPage = (): void => {
 export const useSetupGlobalSocketForPage = (): void => {
   const socket = useAtomValue(globalSocketAtom);
   const socket = useAtomValue(globalSocketAtom);
-  const pageId = useCurrentPageId();
+  const pageId = useCurrentPageId(true);
 
 
   useEffect(() => {
   useEffect(() => {
     if (socket == null || pageId == null) {
     if (socket == null || pageId == null) {

+ 12 - 40
apps/app/src/states/ui/page-abilities.ts

@@ -42,7 +42,9 @@ const isAbleToShowTagLabelAtom = atom((get) => {
 
 
   // "/trash" page does not exist on page collection and unable to add tags
   // "/trash" page does not exist on page collection and unable to add tags
   return (
   return (
+    // biome-ignore lint/style/noNonNullAssertion: currentPagePath should be defined here
     !isUsersTopPage(currentPagePath!) &&
     !isUsersTopPage(currentPagePath!) &&
+    // biome-ignore lint/style/noNonNullAssertion: currentPagePath should be defined here
     !isTrashTopPage(currentPagePath!) &&
     !isTrashTopPage(currentPagePath!) &&
     shareLinkId == null &&
     shareLinkId == null &&
     !isIdenticalPath &&
     !isIdenticalPath &&
@@ -60,22 +62,13 @@ export const useIsAbleToShowTagLabel = (): boolean => {
 // Derived atom for TrashPageManagementButtons display ability
 // Derived atom for TrashPageManagementButtons display ability
 const isAbleToShowTrashPageManagementButtonsAtom = atom((get) => {
 const isAbleToShowTrashPageManagementButtonsAtom = atom((get) => {
   const currentUser = get(globalAtoms.currentUserAtom);
   const currentUser = get(globalAtoms.currentUserAtom);
-  const currentPageId = get(pageAtoms.currentPageIdAtom);
-  const isNotFound = get(pageAtoms.pageNotFoundAtom);
+  const currentPageEntityId = get(pageAtoms.currentPageEntityIdAtom);
+  const currentPageEmptyId = get(pageAtoms.currentPageEmptyIdAtom);
   const isTrashPage = get(pageAtoms.isTrashPageAtom);
   const isTrashPage = get(pageAtoms.isTrashPageAtom);
   const isReadOnlyUser = get(contextAtoms.isReadOnlyUserAtom);
   const isReadOnlyUser = get(contextAtoms.isReadOnlyUserAtom);
 
 
-  // Return false if any dependency is undefined
-  if (
-    [currentUser, currentPageId, isNotFound, isReadOnlyUser, isTrashPage].some(
-      (v) => v === undefined,
-    )
-  ) {
-    return false;
-  }
-
   const isCurrentUserExist = currentUser != null;
   const isCurrentUserExist = currentUser != null;
-  const isPageExist = currentPageId != null && isNotFound === false;
+  const isPageExist = currentPageEntityId != null || currentPageEmptyId != null;
   const isTrashPageCondition = isPageExist && isTrashPage === true;
   const isTrashPageCondition = isPageExist && isTrashPage === true;
   const isReadOnlyUserCondition = isPageExist && isReadOnlyUser === true;
   const isReadOnlyUserCondition = isPageExist && isReadOnlyUser === true;
 
 
@@ -91,29 +84,16 @@ export const useIsAbleToShowTrashPageManagementButtons = (): boolean => {
 
 
 // Derived atom for PageManagement display ability
 // Derived atom for PageManagement display ability
 const isAbleToShowPageManagementAtom = atom((get) => {
 const isAbleToShowPageManagementAtom = atom((get) => {
-  const currentPageId = get(pageAtoms.currentPageIdAtom);
-  const isNotFound = get(pageAtoms.pageNotFoundAtom);
+  const currentPageEntityId = get(pageAtoms.currentPageEntityIdAtom);
+  const currentPageEmptyId = get(pageAtoms.currentPageEmptyIdAtom);
   const isTrashPage = get(pageAtoms.isTrashPageAtom);
   const isTrashPage = get(pageAtoms.isTrashPageAtom);
   const isSharedUser = get(contextAtoms.isSharedUserAtom);
   const isSharedUser = get(contextAtoms.isSharedUserAtom);
 
 
-  const pageId = currentPageId;
-
-  // Return false if any dependency is undefined
-  if (
-    [pageId, isTrashPage, isSharedUser, isNotFound].some((v) => v === undefined)
-  ) {
-    return false;
-  }
-
-  const isPageExist = pageId != null && isNotFound === false;
-  const isEmptyPage = pageId != null && isNotFound === true;
+  const isPageExist = currentPageEntityId != null || currentPageEmptyId != null;
   const isTrashPageCondition = isPageExist && isTrashPage === true;
   const isTrashPageCondition = isPageExist && isTrashPage === true;
   const isSharedUserCondition = isPageExist && isSharedUser === true;
   const isSharedUserCondition = isPageExist && isSharedUser === true;
 
 
-  return (
-    (isPageExist && !isTrashPageCondition && !isSharedUserCondition) ||
-    isEmptyPage
-  );
+  return isPageExist && !isTrashPageCondition && !isSharedUserCondition;
 });
 });
 
 
 /**
 /**
@@ -130,12 +110,7 @@ export const useIsAbleToChangeEditorMode = (): boolean => {
   const isEditable = useIsEditable();
   const isEditable = useIsEditable();
   const isSharedUser = useIsSharedUser();
   const isSharedUser = useIsSharedUser();
 
 
-  const includesUndefined = [isEditable, isSharedUser].some(
-    (v) => v === undefined,
-  );
-  if (includesUndefined) return false;
-
-  return !!isEditable && !isSharedUser;
+  return isEditable && !isSharedUser;
 };
 };
 
 
 /**
 /**
@@ -143,15 +118,12 @@ export const useIsAbleToChangeEditorMode = (): boolean => {
  */
  */
 export const useIsAbleToShowPageAuthors = (): boolean => {
 export const useIsAbleToShowPageAuthors = (): boolean => {
   const pageId = useCurrentPageId();
   const pageId = useCurrentPageId();
-  const isNotFound = usePageNotFound();
   const pagePath = useCurrentPagePath();
   const pagePath = useCurrentPagePath();
 
 
-  const includesUndefined = [pageId, pagePath, isNotFound].some(
-    (v) => v === undefined,
-  );
+  const includesUndefined = [pageId, pagePath].some((v) => v === undefined);
   if (includesUndefined) return false;
   if (includesUndefined) return false;
 
 
-  const isPageExist = pageId != null && !isNotFound;
+  const isPageExist = pageId != null;
   const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
   const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
 
 
   return isPageExist && !isUsersTopPagePath;
   return isPageExist && !isUsersTopPagePath;

+ 16 - 23
apps/app/src/states/ui/sidebar/hydrate.ts

@@ -20,29 +20,22 @@ export const useHydrateSidebarAtoms = (
   sidebarConfig?: ISidebarConfig,
   sidebarConfig?: ISidebarConfig,
   userUISettings?: IUserUISettings,
   userUISettings?: IUserUISettings,
 ): void => {
 ): void => {
-  useHydrateAtoms(
-    sidebarConfig == null || userUISettings == null
-      ? []
-      : [
-          // Use user preference from DB if available, otherwise use system default
-          [
-            preferCollapsedModeAtom,
-            userUISettings?.preferCollapsedModeByUser ??
-              sidebarConfig?.isSidebarCollapsedMode ??
-              false,
-          ],
+  useHydrateAtoms([
+    // Use user preference from DB if available, otherwise use system default
+    [
+      preferCollapsedModeAtom,
+      userUISettings?.preferCollapsedModeByUser ??
+        sidebarConfig?.isSidebarCollapsedMode ??
+        false,
+    ],
 
 
-          // Sidebar contents type (with default fallback)
-          [
-            currentSidebarContentsAtom,
-            userUISettings?.currentSidebarContents ?? SidebarContentsType.TREE,
-          ],
+    // Sidebar contents type (with default fallback)
+    [
+      currentSidebarContentsAtom,
+      userUISettings?.currentSidebarContents ?? SidebarContentsType.TREE,
+    ],
 
 
-          // Product navigation width (with default fallback)
-          [
-            currentProductNavWidthAtom,
-            userUISettings?.currentProductNavWidth ?? 320,
-          ],
-        ],
-  );
+    // Product navigation width (with default fallback)
+    [currentProductNavWidthAtom, userUISettings?.currentProductNavWidth ?? 320],
+  ]);
 };
 };

+ 3 - 2
apps/app/src/stores/bookmark.ts

@@ -1,4 +1,5 @@
-import type { IPageHasId, IUser } from '@growi/core';
+import type { IPageHasId, IUser } from '@growi/core/dist/interfaces';
+import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializers';
 import type { SWRResponse } from 'swr';
 import type { SWRResponse } from 'swr';
 import useSWR from 'swr';
 import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
@@ -11,7 +12,7 @@ import type { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
 export const useSWRxBookmarkedUsers = (
 export const useSWRxBookmarkedUsers = (
   pageId: string | null,
   pageId: string | null,
-): SWRResponse<IUser[], Error> => {
+): SWRResponse<IUserSerializedSecurely<IUser>[], Error> => {
   return useSWR(
   return useSWR(
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
     (endpoint) =>
     (endpoint) =>

+ 1 - 4
biome.json

@@ -28,10 +28,7 @@
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!apps/slackbot-proxy/src/public/bootstrap",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
       "!packages/pdf-converter-client/specs",
-      "!apps/app/src/client",
-      "!apps/app/src/server/middlewares",
-      "!apps/app/src/server/routes/apiv3/*.js",
-      "!apps/app/src/server/service/page"
+      "!apps/app/src/client/components"
     ]
     ]
   },
   },
   "formatter": {
   "formatter": {

+ 18 - 2
packages/core/src/interfaces/page.ts

@@ -83,10 +83,10 @@ export type PageStatus = (typeof PageStatus)[keyof typeof PageStatus];
 
 
 export type IPageHasId = IPage & HasObjectId;
 export type IPageHasId = IPage & HasObjectId;
 
 
+// Special type to represent page is an empty page or not found or forbidden status
 export type IPageNotFoundInfo = {
 export type IPageNotFoundInfo = {
   isNotFound: true;
   isNotFound: true;
   isForbidden: boolean;
   isForbidden: boolean;
-  isEmpty?: true;
 };
 };
 
 
 export type IPageInfo = {
 export type IPageInfo = {
@@ -100,6 +100,13 @@ export type IPageInfo = {
   bookmarkCount: number;
   bookmarkCount: number;
 };
 };
 
 
+export type IPageInfoForEmpty = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
+  emptyPageId: string;
+  isNotFound: false;
+  isEmpty: true;
+  isBookmarked?: boolean;
+};
+
 export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
 export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
   isNotFound: false;
   isNotFound: false;
   isEmpty: false;
   isEmpty: false;
@@ -123,6 +130,7 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
 
 export type IPageInfoExt =
 export type IPageInfoExt =
   | IPageInfo
   | IPageInfo
+  | IPageInfoForEmpty
   | IPageInfoForEntity
   | IPageInfoForEntity
   | IPageInfoForOperation
   | IPageInfoForOperation
   | IPageInfoForListing;
   | IPageInfoForListing;
@@ -134,7 +142,8 @@ export const isIPageNotFoundInfo = (
   return (
   return (
     pageInfo != null &&
     pageInfo != null &&
     pageInfo instanceof Object &&
     pageInfo instanceof Object &&
-    pageInfo.isNotFound === true
+    pageInfo.isNotFound === true &&
+    'isForbidden' in pageInfo
   );
   );
 };
 };
 
 
@@ -147,6 +156,13 @@ export const isIPageInfo = (
   );
   );
 };
 };
 
 
+export const isIPageInfoForEmpty = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageInfoForEmpty => {
+  return isIPageInfo(pageInfo) && pageInfo.isEmpty === true;
+};
+
 export const isIPageInfoForEntity = (
 export const isIPageInfoForEntity = (
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   pageInfo: any | undefined,
   pageInfo: any | undefined,

Некоторые файлы не были показаны из-за большого количества измененных файлов