Taichi Masuyama 4 лет назад
Родитель
Сommit
2c7dde251f
67 измененных файлов с 557 добавлено и 390 удалено
  1. 3 3
      .github/workflows/reusable-app-prod.yml
  2. 4 7
      packages/app/resource/locales/en_US/translation.json
  3. 4 7
      packages/app/resource/locales/ja_JP/translation.json
  4. 5 6
      packages/app/resource/locales/zh_CN/translation.json
  5. 0 4
      packages/app/src/client/app.jsx
  6. 6 3
      packages/app/src/client/services/ContextExtractor.tsx
  7. 12 32
      packages/app/src/components/DescendantsPageList.tsx
  8. 6 10
      packages/app/src/components/IdenticalPathPage.tsx
  9. 1 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  10. 13 7
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  11. 13 9
      packages/app/src/components/Navbar/SubNavButtons.tsx
  12. 5 4
      packages/app/src/components/Page.jsx
  13. 0 24
      packages/app/src/components/Page/DuplicatedAlert.jsx
  14. 17 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  15. 0 22
      packages/app/src/components/Page/RenamedAlert.jsx
  16. 2 0
      packages/app/src/components/Page/RevisionLoader.jsx
  17. 9 4
      packages/app/src/components/Page/RevisionRenderer.jsx
  18. 8 6
      packages/app/src/components/Page/TrashPageAlert.jsx
  19. 59 17
      packages/app/src/components/PageDeleteModal.tsx
  20. 9 2
      packages/app/src/components/PageDuplicateModal.jsx
  21. 3 4
      packages/app/src/components/PageList/PageList.tsx
  22. 14 19
      packages/app/src/components/PageList/PageListItemL.tsx
  23. 5 2
      packages/app/src/components/PageRenameModal.jsx
  24. 1 0
      packages/app/src/components/PageTimeline.jsx
  25. 2 2
      packages/app/src/components/PrivateLegacyPages.tsx
  26. 45 7
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  27. 19 16
      packages/app/src/components/SearchPage/SearchResultList.tsx
  28. 5 10
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  29. 1 1
      packages/app/src/components/SearchTypeahead.tsx
  30. 62 18
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  31. 26 9
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  32. 4 0
      packages/app/src/interfaces/page-listing-results.ts
  33. 15 8
      packages/app/src/interfaces/page.ts
  34. 3 2
      packages/app/src/interfaces/search.ts
  35. 2 0
      packages/app/src/interfaces/ui.ts
  36. 2 1
      packages/app/src/server/middlewares/apiv3-form-validator.ts
  37. 1 2
      packages/app/src/server/models/page.ts
  38. 2 1
      packages/app/src/server/routes/apiv3/app-settings.js
  39. 2 1
      packages/app/src/server/routes/apiv3/attachment.js
  40. 2 1
      packages/app/src/server/routes/apiv3/bookmarks.js
  41. 2 1
      packages/app/src/server/routes/apiv3/customize-setting.js
  42. 2 1
      packages/app/src/server/routes/apiv3/export.js
  43. 1 1
      packages/app/src/server/routes/apiv3/forgot-password.js
  44. 2 1
      packages/app/src/server/routes/apiv3/markdown-setting.js
  45. 1 1
      packages/app/src/server/routes/apiv3/notification-setting.js
  46. 27 28
      packages/app/src/server/routes/apiv3/page-listing.ts
  47. 3 2
      packages/app/src/server/routes/apiv3/page.js
  48. 2 1
      packages/app/src/server/routes/apiv3/pages.js
  49. 1 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  50. 2 1
      packages/app/src/server/routes/apiv3/revisions.js
  51. 2 1
      packages/app/src/server/routes/apiv3/search.js
  52. 2 1
      packages/app/src/server/routes/apiv3/security-setting.js
  53. 2 1
      packages/app/src/server/routes/apiv3/share-links.js
  54. 2 1
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  55. 2 1
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  56. 2 1
      packages/app/src/server/routes/apiv3/user-group.js
  57. 2 1
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  58. 2 1
      packages/app/src/server/routes/apiv3/users.js
  59. 25 13
      packages/app/src/server/routes/page.js
  60. 6 6
      packages/app/src/server/service/page.ts
  61. 13 6
      packages/app/src/server/service/search.ts
  62. 5 0
      packages/app/src/server/views/layout-growi/not_found.html
  63. 2 9
      packages/app/src/server/views/widget/page_alerts.html
  64. 5 1
      packages/app/src/stores/context.tsx
  65. 8 17
      packages/app/src/stores/modal.tsx
  66. 38 5
      packages/app/src/stores/page.tsx
  67. 4 3
      packages/app/src/stores/ui.tsx

+ 3 - 3
.github/workflows/reusable-app-prod.yml

@@ -37,10 +37,10 @@ jobs:
       with:
         path: |
           **/node_modules
-        key: node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
+        key: node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-          node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
+          node_modules-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: lerna bootstrap
       run: |

+ 4 - 7
packages/app/resource/locales/en_US/translation.json

@@ -13,7 +13,6 @@
   "Click to copy": "Click to copy",
   "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
-  "Moved": "Moved",
   "Redirected": "Redirected",
   "Unlinked": "Unlinked",
   "Like!": "Like!",
@@ -369,12 +368,8 @@
   "page_page": {
     "notice": {
       "version": "This is not the current version.",
-      "moved": "This page was moved from",
-      "moved_period": ".",
       "redirected": "You are redirected from",
       "redirected_period": ".",
-      "duplicated": "This page was duplicated from",
-      "duplicated_period": ".",
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
@@ -438,8 +433,9 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
-  "deleted_pages": "Page(s) has been deleted",
-  "deleted_pages_completely": "Page(s) has been deleted completely",
+  "deleted_pages": "{{path}} has been deleted",
+  "deleted_pages_completely": "{{path}} has been deleted completely",
+  "renamed_pages": "{{path}} has been renamed",
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "notice": "The pages deleted completely are unrecoverable."
@@ -458,6 +454,7 @@
       "recursive": "Duplicate children of under this path recursively"
     }
   },
+  "duplicated_pages": "{{fromPath}} has been duplicated",
   "modal_putback": {
     "label": {
       "Put Back Page": "Put back page",

+ 4 - 7
packages/app/resource/locales/ja_JP/translation.json

@@ -13,7 +13,6 @@
   "Click to copy": "クリックでコピー",
   "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
-  "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
   "Unlinked": "リダイレクト削除",
   "Like!": "いいね!",
@@ -369,12 +368,8 @@
   "page_page": {
     "notice": {
       "version": "これは現在の版ではありません。",
-      "moved": "このページは",
-      "moved_period":"から移動しました。",
       "redirected": "リダイレクト元 >>",
       "redirected_period":"",
-      "duplicated": "このページは",
-      "duplicated_period": "から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
@@ -437,8 +432,9 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
-  "deleted_pages": "ページをゴミ箱に入れました",
-  "deleted_pages_completely": "ページを完全に削除しました",
+  "deleted_pages": "{{path}} をゴミ箱に入れました",
+  "deleted_pages_completely": "{{path}} を完全に削除しました",
+  "renamed_pages": "{{path}} を移動/名前変更しました",
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "notice": "完全削除したページは元に戻すことができません"
@@ -457,6 +453,7 @@
       "recursive": "配下のページも複製します"
     }
   },
+  "duplicated_pages": "{{fromPath}} を複製しました",
   "modal_putback": {
     "label": {
       "Put Back Page": "ページを元に戻す",

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

@@ -14,7 +14,6 @@
 	"Click to copy": "点击复制",
   "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
-	"Moved": "移动",
 	"Redirected": "重定向",
 	"Unlinked": "Unlinked",
 	"Like!": "Like!",
@@ -348,12 +347,8 @@
 	"page_page": {
 		"notice": {
 			"version": "这不是当前版本。",
-			"moved": "此页已从",
-      "moved_period": "",
 			"redirected": "您将从",
       "redirected_period": "",
-			"duplicated": "此页来自",
-      "duplicated_period": "",
 			"unlinked": "将网页重定向到此网页已被删除。",
 			"restricted": "访问此页受到限制",
 			"stale": "自上次更新以来,已超过{{count}年。",
@@ -416,6 +411,9 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_pages": "将 {{path}} 放入垃圾箱",
+  "deleted_pages_completely": "{{path}} 已被完全删除",
+  "renamed_pages": "移动/重命名 {{path}}",
 	"modal_empty": {
 		"empty_the_trash": "Empty The Trash",
 		"notice": "完全删除的页面是不可恢复的。"
@@ -433,7 +431,8 @@
     "help": {
       "recursive": "Duplicate children of under this path recursively"
     }
-	},
+  },
+  "duplicated_pages": "{{fromPath}} 已重复",
 	"modal_putback": {
 		"label": {
 			"Put Back Page": "Put back page",

+ 0 - 4
packages/app/src/client/app.jsx

@@ -23,9 +23,7 @@ import PageContentFooter from '../components/PageContentFooter';
 import PageTimeline from '../components/PageTimeline';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import DuplicatedAlert from '../components/Page/DuplicatedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
-import RenamedAlert from '../components/Page/RenamedAlert';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageAlert from '../components/Page/TrashPageAlert';
 import NotFoundPage from '../components/NotFoundPage';
@@ -113,9 +111,7 @@ Object.assign(componentMappings, {
   'grw-fab-container': <Fab />,
 
   'share-link-alert': <ShareLinkAlert />,
-  'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
-  'renamed-alert': <RenamedAlert />,
   'not-found-alert': <NotFoundAlert
     isGuestUserMode={appContainer.isGuestUser}
   />,

+ 6 - 3
packages/app/src/client/services/ContextExtractor.tsx

@@ -7,7 +7,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
 } from '../../stores/context';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
@@ -24,7 +24,8 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
-  const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
+  const notFoundContent = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
@@ -77,7 +78,8 @@ const ContextExtractorOnce: FC = () => {
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
-  const notFoundTargetPathOrId = JSON.parse(notFoundContent?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
+  const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
+  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
   const isSearchPage = document.getElementById('search-page') != null;
 
@@ -133,6 +135,7 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
 
   // Navigation

+ 12 - 32
packages/app/src/components/DescendantsPageList.tsx

@@ -2,7 +2,9 @@ import React, { useCallback, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
-  IPageHasId, IPageWithMeta,
+  IDataWithMeta,
+  IPageHasId,
+  IPageInfoForOperation,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction } from '~/interfaces/ui';
@@ -22,8 +24,8 @@ type SubstanceProps = {
   onPagesDeleted?: OnDeletedFunction,
 }
 
-const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
-  return { pageData: page };
+const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
+  return { data: page };
 };
 
 export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
@@ -37,41 +39,19 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   const { data: isGuestUser } = useIsGuestUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
 
-  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+  let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
 
   // initial data
   if (pagingResult != null) {
-    const pages = pagingResult.items;
-
     // convert without meta at first
-    pagingResultWithMeta = {
-      ...pagingResult,
-      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
-    };
-  }
-
-  // inject data for listing
-  if (pagingResult != null) {
-    const pages = pagingResult.items;
-
-    const pageWithMetas = pages.map((page) => {
-      const pageInfo = (idToPageInfo ?? {})[page._id];
-
-      return {
-        pageData: page,
-        pageMeta: pageInfo,
-      } as IPageWithMeta;
-    });
-
-    pagingResultWithMeta = {
-      ...pagingResult,
-      items: pageWithMetas,
-    };
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    // inject data for listing
+    pageWithMetas = injectTo(dataWithMetas);
   }
 
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
@@ -88,7 +68,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
     setActivePage(selectedPageNumber);
   }
 
-  if (pagingResult == null || pagingResultWithMeta == null) {
+  if (pagingResult == null) {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
@@ -103,7 +83,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   return (
     <>
       <PageList
-        pages={pagingResultWithMeta}
+        pages={pageWithMetas}
         isEnableActions={!isGuestUser}
         onPagesDeleted={pageDeletedHandler}
       />

+ 6 - 10
packages/app/src/components/IdenticalPathPage.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 
 import { DevidedPagePath } from '@growi/core';
 
-import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList } from '~/stores/page';
@@ -66,10 +66,12 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
   const { data: currentPath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
+  const injectedPages = injectTo(pages);
+
   return (
     <div className="d-flex flex-column flex-lg-row-reverse">
 
@@ -95,14 +97,8 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
 
         <div className="page-list">
           <ul className="page-list-ul list-group list-group-flush">
-            {pages.map((page) => {
-              const pageId = page._id;
-              const pageInfo = (idToPageInfoMap ?? {})[pageId];
-
-              const pageWithMeta: IPageWithMeta = {
-                pageData: page,
-                pageMeta: pageInfo,
-              };
+            {injectedPages.map((pageWithMeta) => {
+              const pageId = pageWithMeta.data._id;
 
               return (
                 <PageListItemL

+ 1 - 1
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -36,7 +36,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
-    const page = data[0].pageData; // should be single page selected
+    const page = data[0].data; // should be single page selected
 
     // navigate to page
     if (page != null) {

+ 13 - 7
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -5,8 +5,8 @@ import PropTypes from 'prop-types';
 
 import { DropdownItem } from 'reactstrap';
 
-import { OnDeletedFunction } from '~/interfaces/ui';
-import { IPageHasId } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -16,7 +16,7 @@ import {
 } from '~/stores/ui';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
-  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal, IPageForPageDeleteModal,
+  usePageDuplicateModal, usePageRenameModal, IPageForPageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 
 
@@ -184,11 +184,17 @@ const GrowiContextualSubNavigation = (props) => {
   }, [pageId]);
 
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
-    openDuplicateModal(page);
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      window.location.href = toPath;
+    };
+    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
 
   const renameItemClickedHandler = useCallback(async(page: IPageForPageRenameModal) => {
-    openRenameModal(page);
+    const renamedHandler: OnRenamedFunction = () => {
+      window.location.reload();
+    };
+    openRenameModal(page, { onRenamed: renamedHandler });
   }, [openRenameModal]);
 
   const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -207,8 +213,8 @@ const GrowiContextualSubNavigation = (props) => {
     }
   }, []);
 
-  const deleteItemClickedHandler = useCallback((pageToDelete: IPageForPageDeleteModal) => {
-    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
+  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
+    openDeleteModal([pageWithMeta], { onDeleted: onDeletedHandler });
   }, [onDeletedHandler, openDeleteModal]);
 
   const templateMenuItemClickHandler = useCallback(() => {

+ 13 - 9
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,12 +1,14 @@
 import React, { useCallback } from 'react';
 
-import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+import {
+  IPageInfoAll, IPageToDeleteWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+} from '~/interfaces/page';
 
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxUsersList } from '../../stores/user';
 import { useIsGuestUser } from '~/stores/context';
-import { IPageForPageDeleteModal, IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
 
 import SubscribeButton from '../SubscribeButton';
 import LikeButtons from '../LikeButtons';
@@ -26,7 +28,7 @@ type CommonProps = {
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
   onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 }
 
 type SubNavButtonsSubstanceProps = CommonProps & {
@@ -118,15 +120,17 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       return;
     }
 
-    const pageToDelete: IPageForPageDeleteModal = {
-      pageId,
-      revisionId,
-      path,
-      isAbleToDeleteCompletely: pageInfo.isAbleToDeleteCompletely,
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
+      meta: pageInfo,
     };
 
     onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, pageId, pageInfo.isAbleToDeleteCompletely, path, revisionId]);
+  }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;

+ 5 - 4
packages/app/src/components/Page.jsx

@@ -21,7 +21,7 @@ import { getOptionsToSave } from '~/client/util/editor';
 
 // TODO: remove this when omitting unstated is completed
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  EditorMode, useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
 import { useSlackChannels } from '~/stores/context';
@@ -143,14 +143,15 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer } = this.props;
+    const { appContainer, pageContainer, editorMode } = this.props;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
-    const { markdown } = pageContainer.state;
+    const { markdown, revisionId } = pageContainer.state;
+    const isRenderable = !(editorMode === EditorMode.View && revisionId == null);
 
     return (
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} isRenderable={isRenderable} />
 
         { isLoggedIn && (
           <>

+ 0 - 24
packages/app/src/components/Page/DuplicatedAlert.jsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-const DuplicatedAlert = (props) => {
-  const { t } = props;
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('duplicated');
-
-  return (
-    <div className="alert alert-success py-3 px-4">
-      <strong>
-        { t('Duplicated') }: {t('page_page.notice.duplicated')} <code>{fromPath}</code> {t('page_page.notice.duplicated_period')}
-      </strong>
-    </div>
-  );
-};
-
-DuplicatedAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(DuplicatedAlert);

+ 17 - 12
packages/app/src/components/Page/NotFoundAlert.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+import { useIsNotFoundPermalink } from '~/stores/context';
 
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
@@ -14,6 +15,7 @@ const NotFoundAlert = (props: Props): JSX.Element => {
   const { isGuestUserMode } = props;
 
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: isNotFoundPermalink } = useIsNotFoundPermalink(); // TODO: Remove this when renaming on editor is implemented
 
   const isEditorMode = editorMode !== EditorMode.View;
 
@@ -41,19 +43,22 @@ const NotFoundAlert = (props: Props): JSX.Element => {
           <i className="icon-info pr-2 font-weight-bold" aria-hidden="true"></i>
           {t('not_found_page.page_not_exist_alert')}
         </h2>
-        <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
-          <button
-            type="button"
-            className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={clickHandler}
-          >
-            <i className="icon-note icon-fw" />
-            {t('not_found_page.Create Page')}
-          </button>
-        </div>
+        {
+          !isNotFoundPermalink && (
+            <div id="create-page-btn-wrapper-for-tooltip" className="d-inline-block">
+              <button
+                type="button"
+                className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
+                onClick={clickHandler}
+              >
+                <i className="icon-note icon-fw" />
+                {t('not_found_page.Create Page')}
+              </button>
+            </div>
+          )
+        }
 
-
-        {isGuestUserMode && (
+        {!isNotFoundPermalink && isGuestUserMode && (
           <UncontrolledTooltip placement="bottom" target="create-page-btn-wrapper-for-tooltip" fade={false}>
             {t('Not available for guest')}
           </UncontrolledTooltip>

+ 0 - 22
packages/app/src/components/Page/RenamedAlert.jsx

@@ -1,22 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-
-const RenamedAlert = (props) => {
-  const { t } = props;
-  const urlParams = new URLSearchParams(window.location.search);
-  const fromPath = urlParams.get('renamedFrom');
-
-  return (
-    <>
-      <strong>{ t('Moved') }:</strong> {t('page_page.notice.moved')} <code>{fromPath}</code> {t('page_page.notice.moved_period')}
-    </>
-  );
-};
-
-RenamedAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-};
-
-export default withTranslation()(RenamedAlert);

+ 2 - 0
packages/app/src/components/Page/RevisionLoader.jsx

@@ -107,6 +107,7 @@ class LegacyRevisionLoader extends React.Component {
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
+        isRenderable={this.props.isRenderable}
       />
     );
   }
@@ -127,6 +128,7 @@ LegacyRevisionLoader.propTypes = {
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
+  isRenderable: PropTypes.bool,
 };
 
 const RevisionLoader = (props) => {

+ 9 - 4
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,16 +33,20 @@ class LegacyRevisionRenderer extends React.PureComponent {
   }
 
   componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderHtml();
+    const { isRenderable } = this.props;
+
+    if (isRenderable) {
+      this.initCurrentRenderingContext();
+      this.renderHtml();
+    }
   }
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords } = this.props;
+    const { markdown, isRenderable, highlightKeywords } = this.props;
 
     // render only when props.markdown is updated
-    if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
+    if ((markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) && isRenderable) {
       this.initCurrentRenderingContext();
       this.renderHtml();
       return;
@@ -172,6 +176,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
+  isRenderable: PropTypes.bool,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };

+ 8 - 6
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -8,12 +8,12 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
-import EmptyTrashModal from '../EmptyTrashModal';
-
 import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 
+import EmptyTrashModal from '../EmptyTrashModal';
+
 const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
@@ -56,9 +56,11 @@ const TrashPageAlert = (props) => {
 
   function openPageDeleteModalHandler() {
     const pageToDelete = {
-      pageId,
-      revisionId,
-      path,
+      pageData: {
+        _id: pageId,
+        revision: revisionId,
+        path,
+      },
     };
     openDeleteModal(
       [pageToDelete],

+ 59 - 17
packages/app/src/components/PageDeleteModal.tsx

@@ -7,11 +7,19 @@ import { useTranslation } from 'react-i18next';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { usePageDeleteModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
 
-import { IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result } from '~/interfaces/page';
+import {
+  IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, isIPageInfoForOperation, IPageToDeleteWithMeta, IDataWithMeta, IPageInfoForOperation,
+} from '~/interfaces/page';
+import { HasObjectId } from '~/interfaces/has-object-id';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+import { useSWRxPageInfoForList } from '~/stores/page';
+
+
+const logger = loggerFactory('growi:cli:PageDeleteModal');
 
 
 const deleteIconAndKey = {
@@ -34,16 +42,32 @@ const PageDeleteModal: FC = () => {
 
   const isOpened = deleteModalData?.isOpened ?? false;
 
-  const isAbleToDeleteCompletely = useMemo(() => {
-    if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
-      return deleteModalData.pages.every(page => page.isAbleToDeleteCompletely);
+  const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
+    .filter(p => !isIPageInfoForOperation(p.meta));
+  const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
+
+  const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
+
+  // inject IPageInfo to operate
+  let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForOperation>[] | null = null;
+  if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
+    injectedPages = injectTo(deleteModalData?.pages);
+  }
+
+  // calculate conditions to delete
+  const [isDeletable, isAbleToDeleteCompletely] = useMemo(() => {
+    if (injectedPages != null && injectedPages.length > 0) {
+      const isDeletable = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isDeletable);
+      const isAbleToDeleteCompletely = injectedPages.every(pageWithMeta => pageWithMeta.meta?.isAbleToDeleteCompletely);
+      return [isDeletable, isAbleToDeleteCompletely];
     }
-    return true;
-  }, [deleteModalData]);
+    return [true, true];
+  }, [injectedPages]);
 
+  // calculate condition to determine modal status
   const forceDeleteCompletelyMode = useMemo(() => {
     if (deleteModalData != null && deleteModalData.pages != null && deleteModalData.pages.length > 0) {
-      return deleteModalData.pages.every(page => isTrashPage(page.path));
+      return deleteModalData.pages.every(pageWithMeta => isTrashPage(pageWithMeta.data?.path ?? ''));
     }
     return false;
   }, [deleteModalData]);
@@ -71,6 +95,11 @@ const PageDeleteModal: FC = () => {
       return;
     }
 
+    if (!isDeletable) {
+      logger.error('At least one page is not deletable.');
+      return;
+    }
+
     /*
      * When multiple pages
      */
@@ -80,7 +109,7 @@ const PageDeleteModal: FC = () => {
         const isCompletely = isDeleteCompletely === true ? true : undefined;
 
         const pageIdToRevisionIdMap = {};
-        deleteModalData.pages.forEach((p) => { pageIdToRevisionIdMap[p.pageId] = p.revisionId });
+        deleteModalData.pages.forEach((p) => { pageIdToRevisionIdMap[p.data._id] = p.data.revision as string });
 
         const { data } = await apiv3Post<IDeleteManyPageApiv3Result>('/pages/delete', {
           pageIdToRevisionIdMap,
@@ -92,6 +121,8 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
+
+        closeDeleteModal();
       }
       catch (err) {
         setErrs([err]);
@@ -105,11 +136,11 @@ const PageDeleteModal: FC = () => {
         const recursively = isDeleteRecursively === true ? true : undefined;
         const completely = forceDeleteCompletelyMode || isDeleteCompletely ? true : undefined;
 
-        const page = deleteModalData.pages[0];
+        const page = deleteModalData.pages[0].data;
 
         const { path, isRecursively, isCompletely } = await apiPost('/pages.remove', {
-          page_id: page.pageId,
-          revision_id: page.revisionId,
+          page_id: page._id,
+          revision_id: page.revision,
           recursively,
           completely,
         }) as IDeleteSinglePageApiv1Result;
@@ -118,6 +149,8 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
           onDeleted(path, isRecursively, isCompletely);
         }
+
+        closeDeleteModal();
       }
       catch (err) {
         setErrs([err]);
@@ -126,7 +159,6 @@ const PageDeleteModal: FC = () => {
   }
 
   async function deleteButtonHandler() {
-    await closeDeleteModal();
     await deletePage();
   }
 
@@ -176,8 +208,13 @@ const PageDeleteModal: FC = () => {
   }
 
   const renderPagePathsToDelete = () => {
-    if (deleteModalData != null && deleteModalData.pages != null) {
-      return deleteModalData.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+    if (injectedPages != null && injectedPages != null) {
+      return injectedPages.map(page => (
+        <div key={page.data._id}>
+          <code>{ page.data.path }</code>
+          { !page.meta?.isDeletable && <span className="ml-3 text-danger"><strong>(CAN NOT TO DELETE)</strong></span> }
+        </div>
+      ));
     }
     return <></>;
   };
@@ -195,12 +232,17 @@ const PageDeleteModal: FC = () => {
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
         </div>
-        {renderDeleteRecursivelyForm()}
-        { !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
+        { isDeletable && renderDeleteRecursivelyForm()}
+        { isDeletable && !forceDeleteCompletelyMode && renderDeleteCompletelyForm() }
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
-        <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
+        <button
+          type="button"
+          className={`btn btn-${deleteIconAndKey[deleteMode].color}`}
+          disabled={!isDeletable}
+          onClick={deleteButtonHandler}
+        >
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>

+ 9 - 2
packages/app/src/components/PageDuplicateModal.jsx

@@ -112,8 +112,15 @@ const PageDuplicateModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
-      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
+      const { data } = await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const onDuplicated = duplicateModalData.opts?.onDuplicated;
+      const fromPath = path;
+      const toPath = data.page.path;
+
+      if (onDuplicated != null) {
+        onDuplicated(fromPath, toPath);
+      }
+      closeDuplicateModal();
     }
     catch (err) {
       setErrs(err);

+ 3 - 4
packages/app/src/components/PageList/PageList.tsx

@@ -2,14 +2,13 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { IPageWithMeta } from '~/interfaces/page';
-import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction } from '~/interfaces/ui';
 
 import { PageListItemL } from './PageListItemL';
 
 
 type Props = {
-  pages: IPagingResult<IPageWithMeta>,
+  pages: IPageWithMeta[],
   isEnableActions?: boolean,
   onPagesDeleted?: OnDeletedFunction,
 }
@@ -28,9 +27,9 @@ const PageList = (props: Props): JSX.Element => {
     );
   }
 
-  const pageList = pages.items.map(page => (
+  const pageList = pages.map(page => (
     <PageListItemL
-      key={page.pageData._id}
+      key={page.data._id}
       page={page}
       isEnableActions={isEnableActions}
       onPageDeleted={onPagesDeleted}

+ 14 - 19
packages/app/src/components/PageList/PageListItemL.tsx

@@ -20,33 +20,34 @@ import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import {
-  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+  IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
+  page: IPageWithMeta<IPageInfoForEntity> | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
   onClickItem?: (pageId: string) => void,
+  onPageDuplicated?: OnDuplicatedFunction,
   onPageDeleted?: OnDeletedFunction,
 }
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, isEnableActions,
+    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     showPageUpdatedTime,
-    onClickItem, onCheckboxChanged, onPageDeleted,
+    onClickItem, onCheckboxChanged, onPageDuplicated, onPageDeleted,
   } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -105,8 +106,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
       pageId: pageData._id,
       path: pageData.path,
     };
-    openDuplicateModal(page);
-  }, [openDuplicateModal, pageData]);
+    openDuplicateModal(page, { onDuplicated: onPageDuplicated });
+  }, [onPageDuplicated, openDuplicateModal, pageData._id, pageData.path]);
 
   const renameMenuItemClickHandler = useCallback(() => {
     const page = {
@@ -118,12 +119,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   }, [openRenameModal, pageData]);
 
 
-  const deleteMenuItemClickHandler = useCallback((_id, pageInfo) => {
-    const { _id: pageId, revision: revisionId, path } = pageData;
-    const isAbleToDeleteCompletely = pageInfo.isAbleToDeleteCompletely;
-    const pageToDelete = {
-      pageId, revisionId: revisionId as string, path, isAbleToDeleteCompletely,
-    };
+  const deleteMenuItemClickHandler = useCallback((_id: string, pageInfo: IPageInfoAll | undefined) => {
+    const pageToDelete = { data: pageData, meta: pageInfo };
 
     // open modal
     openDeleteModal([pageToDelete], { onDeleted: onPageDeleted });
@@ -201,17 +198,15 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </Clamp>
 
               {/* page meta */}
-              { isIPageInfoForEntity(pageMeta) && (
-                <div className="d-none d-md-flex py-0 px-1">
-                  <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} shouldSpaceOutIcon />
-                </div>
-              ) }
+              <div className="d-none d-md-flex py-0 px-1">
+                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
+              </div>
 
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
                 <PageItemControl
                   pageId={pageData._id}
-                  pageInfo={pageMeta}
+                  pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
                   isEnableActions={isEnableActions}
                   forceHideMenuItems={forceHideMenuItems}
                   onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}

+ 5 - 2
packages/app/src/components/PageRenameModal.jsx

@@ -129,12 +129,15 @@ const PageRenameModal = (props) => {
 
       const { page } = response.data;
       const url = new URL(page.path, 'https://dummy');
-      url.searchParams.append('renamedFrom', path);
       if (isRenameRedirect) {
         url.searchParams.append('withRedirect', true);
       }
 
-      window.location.href = `${url.pathname}${url.search}`;
+      const onRenamed = renameModalData.opts?.onRenamed;
+      if (onRenamed != null) {
+        onRenamed(path);
+      }
+      closeRenameModal();
     }
     catch (err) {
       setErrs(err);

+ 1 - 0
packages/app/src/components/PageTimeline.jsx

@@ -85,6 +85,7 @@ class PageTimeline extends React.Component {
                     growiRenderer={this.growiRenderer}
                     pageId={page._id}
                     revisionId={page.revision}
+                    isRenderable
                   />
                 </div>
               </div>

+ 2 - 2
packages/app/src/components/PrivateLegacyPages.tsx

@@ -198,8 +198,8 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     }
 
     const selectedPages = data.data
-      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
-      .map(pageWithMeta => ({ pageId: pageWithMeta.pageData._id, path: pageWithMeta.pageData.path } as ILegacyPrivatePage));
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.data._id))
+      .map(pageWithMeta => ({ pageId: pageWithMeta.data._id, path: pageWithMeta.data.path } as ILegacyPrivatePage));
 
     openModal(
       selectedPages,

+ 45 - 7
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -5,10 +5,15 @@ import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
 
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageToDeleteWithMeta, IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useFullTextSearchTermManager } from '~/stores/search';
+import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
+import { toastSuccess } from '~/client/util/apiNotification';
 
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
@@ -74,6 +79,11 @@ const generateObserverCallback = (doScroll: ()=>void) => {
 export const SearchResultContent: FC<Props> = (props: Props) => {
   const scrollElementRef = useRef(null);
 
+  // for mutation
+  const { advance: advancePt } = usePageTreeTermManager();
+  const { advance: advanceFts } = useFullTextSearchTermManager();
+  const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
+
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
     const scrollElement = scrollElementRef.current as HTMLElement | null;
@@ -99,7 +109,9 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     forceHideMenuItems,
   } = props;
 
-  const page = pageWithMeta?.pageData;
+  const { t } = useTranslation();
+
+  const page = pageWithMeta?.data;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -108,16 +120,41 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
-    openDuplicateModal(pageToDuplicate);
-  }, [openDuplicateModal]);
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
+
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
+  }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
 
   const renameItemClickedHandler = useCallback(async(pageToRename) => {
     openRenameModal(pageToRename);
   }, [openRenameModal]);
 
-  const deleteItemClickedHandler = useCallback((pageToDelete) => {
-    openDeleteModal([pageToDelete]);
-  }, [openDeleteModal]);
+  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
+    if (typeof pathOrPathsToDelete !== 'string') {
+      return;
+    }
+    const path = pathOrPathsToDelete;
+
+    if (isCompletely) {
+      toastSuccess(t('deleted_pages_completely', { path }));
+    }
+    else {
+      toastSuccess(t('deleted_pages', { path }));
+    }
+    advancePt();
+    advanceFts();
+    advanceDpl();
+  }, [advanceDpl, advanceFts, advancePt, t]);
+
+  const deleteItemClickedHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
+  }, [onDeletedHandler, openDeleteModal]);
 
   const ControlComponents = useCallback(() => {
     if (page == null) {
@@ -170,6 +207,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           pagePath={page.path}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
+          isRenderable
         />
       </div>
     </div>

+ 19 - 16
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -3,7 +3,9 @@ import React, {
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
-import { IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
+import {
+  IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
+} from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 import { useIsGuestUser } from '~/stores/context';
 import { useSWRxPageInfoForList } from '~/stores/page';
@@ -30,11 +32,11 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   } = props;
 
   const pageIdsWithNoSnippet = pages
-    .filter(page => (page.pageMeta?.elasticSearchResult?.snippet.length ?? 0) === 0)
-    .map(page => page.pageData._id);
+    .filter(page => (page.meta?.elasticSearchResult?.snippet.length ?? 0) === 0)
+    .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, true, true);
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
@@ -60,16 +62,16 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
 
   const clickItemHandler = useCallback((pageId: string) => {
     if (onPageSelected != null) {
-      const selectedPage = pages.find(page => page.pageData._id === pageId);
+      const selectedPage = pages.find(page => page.data._id === pageId);
       onPageSelected(selectedPage);
     }
   }, [onPageSelected, pages]);
 
-  let injectedPage;
+  let injectedPages: (IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>)[] | undefined;
   // inject data to list
   if (idToPageInfo != null) {
-    injectedPage = pages.map((page) => {
-      const pageInfo = idToPageInfo[page.pageData._id];
+    injectedPages = pages.map((page) => {
+      const pageInfo = idToPageInfo[page.data._id];
 
       if (!isIPageInfoForListing(pageInfo)) {
         // return as is
@@ -77,30 +79,31 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
       }
 
       return {
-        pageData: page.pageData,
-        pageMeta: {
-          ...page.pageMeta,
-          revisionShortBody: pageInfo.revisionShortBody,
+        data: page.data,
+        meta: {
+          ...page.meta,
+          ...pageInfo,
         },
-      };
+      } as IPageWithMeta<IPageInfoForListing & IPageSearchMeta>;
     });
   }
 
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
-      { (injectedPage ?? pages).map((page, i) => {
+      { (injectedPages ?? pages).map((page, i) => {
         return (
           <PageListItemL
-            key={page.pageData._id}
+            key={page.data._id}
             // eslint-disable-next-line no-return-assign
             ref={c => itemsRef.current[i] = c}
             page={page}
             isEnableActions={!isGuestUser}
-            isSelected={page.pageData._id === selectedPageId}
+            isSelected={page.data._id === selectedPageId}
             forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}
             onCheckboxChanged={props.onCheckboxChanged}
             onPageDeleted={() => { advancePt(); advanceFts() }}
+            onPageDuplicated={() => { advancePt(); advanceFts() }}
           />
         );
       })}

+ 5 - 10
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,5 +1,5 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction, useCallback, useEffect, useImperativeHandle, useRef, useState,
+  forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
@@ -9,7 +9,7 @@ import { IPageWithMeta } from '~/interfaces/page';
 import { IFormattedSearchResult, IPageSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
-import { IPageForPageDeleteModal, usePageDeleteModal } from '~/stores/modal';
+import { usePageDeleteModal } from '~/stores/modal';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
@@ -68,7 +68,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
       }
 
       if (pages != null) {
-        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.pageData._id));
+        pages.forEach(page => selectedPageIdsByCheckboxes.add(page.data._id));
       }
     },
     deselectAll: () => {
@@ -182,7 +182,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                       ref={searchResultListRef}
                       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                       pages={pages!}
-                      selectedPageId={selectedPageWithMeta?.pageData._id}
+                      selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}
                       onCheckboxChanged={checkboxChangedHandler}
@@ -249,12 +249,7 @@ export const usePageDeleteModalForBulkDeletion = (
     }
 
     const selectedPages = data.data
-      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.pageData._id))
-      .map(pageWithMeta => ({
-        pageId: pageWithMeta.pageData._id,
-        path: pageWithMeta.pageData.path,
-        revisionId: pageWithMeta.pageData.revision as string,
-      } as IPageForPageDeleteModal));
+      .filter(pageWithMeta => selectedPageIds.has(pageWithMeta.data._id));
 
     openDeleteModal(selectedPages, {
       onDeleted: (...args) => {

+ 1 - 1
packages/app/src/components/SearchTypeahead.tsx

@@ -190,7 +190,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   }
 
   const renderMenuItemChildren = (option: IPageWithMeta<IPageSearchMeta>) => {
-    const { pageData } = option;
+    const { data: pageData } = option;
     return (
       <span>
         <UserPicture user={pageData.lastUpdateUser} size="sm" noLink />

+ 62 - 18
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -10,11 +10,13 @@ import nodePath from 'path';
 
 import { pathUtils, pagePathUtils } from '@growi/core';
 
+import loggerFactory from '~/utils/logger';
+
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { IPageForPageRenameModal, IPageForPageDuplicateModal, IPageForPageDeleteModal } from '~/stores/modal';
+import { IPageForPageRenameModal, IPageForPageDuplicateModal } from '~/stores/modal';
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
@@ -22,6 +24,11 @@ import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTe
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 import { ItemNode } from './ItemNode';
 import { usePageTreeDescCountMap } from '~/stores/ui';
+import { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '~/interfaces/page';
+
+
+const logger = loggerFactory('growi:cli:Item');
+
 
 interface ItemProps {
   isEnableActions: boolean
@@ -32,7 +39,7 @@ interface ItemProps {
   isEnabledAttachTitleHeader?: boolean
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
   onClickRenameMenuItem?(pageToRename: IPageForPageRenameModal): void
-  onClickDeleteMenuItem?(pageToDelete: IPageForPageDeleteModal): void
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
 }
 
 // Utility to mark target
@@ -56,6 +63,37 @@ const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean):
 };
 
 
+/**
+ * Return new page path after the droppedPagePath is moved under the newParentPagePath
+ * @param droppedPagePath
+ * @param newParentPagePath
+ * @returns
+ */
+const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
+  const pageTitle = nodePath.basename(droppedPagePath);
+  return nodePath.join(newParentPagePath, pageTitle);
+};
+
+/**
+ * Return whether the fromPage could be moved under the newParentPage
+ * @param fromPage
+ * @param newParentPage
+ * @param printLog
+ * @returns
+ */
+const canMoveUnderNewParent = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
+  if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
+    if (printLog) {
+      logger.warn('Any of page, page.path or droppedPage.path is null');
+    }
+    return false;
+  }
+
+  const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
+  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved);
+};
+
+
 type ItemCountProps = {
   descendantCount: number
 }
@@ -125,16 +163,18 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }),
   }));
 
-  const pageItemDropHandler = async(item, monitor) => {
-    if (page == null || page.path == null) {
+  const pageItemDropHandler = async(item: ItemNode) => {
+    const { page: droppedPage } = item;
+
+    if (!canMoveUnderNewParent(droppedPage, page, true)) {
       return;
     }
 
-    const { page: droppedPage } = item;
+    if (droppedPage.path == null || page.path == null) {
+      return;
+    }
 
-    const pageTitle = nodePath.basename(droppedPage.path);
-    const newParentPath = page.path;
-    const newPagePath = nodePath.join(newParentPath, pageTitle);
+    const newPagePath = getNewPathAfterMoved(droppedPage.path, page.path);
 
     try {
       await apiv3Put('/pages/rename', {
@@ -163,7 +203,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     }
   };
 
-  const [{ isOver }, drop] = useDrop(() => ({
+  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(() => ({
     accept: 'PAGE_TREE',
     drop: pageItemDropHandler,
     hover: (item, monitor) => {
@@ -176,6 +216,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         }, 1000);
       }
     },
+    canDrop: (item) => {
+      const { page: droppedPage } = item;
+      return canMoveUnderNewParent(droppedPage, page);
+    },
     collect: monitor => ({
       isOver: monitor.isOver(),
     }),
@@ -259,18 +303,18 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     onClickRenameMenuItem(pageToRename);
   }, [onClickRenameMenuItem, page]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo): Promise<void> => {
-    const { _id: pageId, revision: revisionId, path } = page;
-
-    if (pageId == null || revisionId == null || path == null) {
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (page._id == null || page.revision == null || page.path == null) {
       throw Error('Any of _id, revision, and path must not be null.');
     }
 
-    const pageToDelete: IPageForPageDeleteModal = {
-      pageId,
-      revisionId: revisionId as string,
-      path,
-      isAbleToDeleteCompletely: pageInfo?.isAbleToDeleteCompletely,
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: page._id,
+        revision: page.revision as string,
+        path: page.path,
+      },
+      meta: pageInfo,
     };
 
     if (onClickDeleteMenuItem != null) {

+ 26 - 9
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,16 +1,14 @@
 import React, { FC, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { IPageHasId } from '../../../interfaces/page';
-import { ItemNode } from './ItemNode';
-import Item from './Item';
 import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
 import { TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { IPageHasId, IPageToDeleteWithMeta } from '~/interfaces/page';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
-  IPageForPageDeleteModal, IPageForPageDuplicateModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
+  IPageForPageDuplicateModal, usePageDuplicateModal, IPageForPageRenameModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
@@ -20,6 +18,10 @@ import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { useGlobalSocket } from '~/stores/websocket';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
+import { ItemNode } from './ItemNode';
+import Item from './Item';
+
+
 /*
  * Utility to generate initial node
  */
@@ -74,7 +76,7 @@ const renderByInitialNode = (
     isEnabledAttachTitleHeader?: boolean,
     onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
     onClickRenameMenuItem?: (pageToRename: IPageForPageRenameModal) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal) => void,
+    onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
 ): JSX.Element => {
 
   return (
@@ -163,14 +165,29 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   }, [socket, ptDescCountMap, updatePtDescCountMap]);
 
   const onClickDuplicateMenuItem = (pageToDuplicate: IPageForPageDuplicateModal) => {
-    openDuplicateModal(pageToDuplicate);
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
+
+      advancePt();
+      advanceFts();
+      advanceDpl();
+    };
+
+    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
   };
 
   const onClickRenameMenuItem = (pageToRename: IPageForPageRenameModal) => {
-    openRenameModal(pageToRename);
+    const renamedHandler: OnRenamedFunction = (path) => {
+      toastSuccess(t('renamed_pages', { path }));
+
+      // TODO: revalidation by https://redmine.weseek.co.jp/issues/89258
+    };
+
+    openRenameModal(pageToRename, { onRenamed: renamedHandler });
   };
 
-  const onClickDeleteMenuItem = (pageToDelete: IPageForPageDeleteModal) => {
+  const onClickDeleteMenuItem = (pageToDelete: IPageToDeleteWithMeta) => {
     const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') {
         return;

+ 4 - 0
packages/app/src/interfaces/page-listing-results.ts

@@ -27,6 +27,10 @@ export interface NotFoundTargetPathOrId {
   notFoundTargetPathOrId: string
 }
 
+export interface IsNotFoundPermalink {
+  isNotFoundPermalink: boolean
+}
+
 
 export interface V5MigrationStatus {
   isV5Compatible : boolean,

+ 15 - 8
packages/app/src/interfaces/page.ts

@@ -62,17 +62,20 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
 export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
 
-export const isIPageInfoForEntity = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForEntity => {
-  return pageInfo != null && !pageInfo.isEmpty;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return pageInfo != null && ('isEmpty' in pageInfo) && pageInfo.isEmpty === false;
 };
 
-export const isIPageInfoForOperation = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForOperation => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForOperation = (pageInfo: any | undefined): pageInfo is IPageInfoForOperation => {
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
     && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
 };
 
-export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForListing => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const isIPageInfoForListing = (pageInfo: any | undefined): pageInfo is IPageInfoForListing => {
   return pageInfo != null
     && isIPageInfoForEntity(pageInfo)
     && 'revisionShortBody' in pageInfo;
@@ -94,10 +97,14 @@ export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageI
 //   return <IPageInfoTypeResolver<T>>pageInfo;
 // };
 
-export type IPageWithMeta<M = IPageInfoAll> = {
-  pageData: IPageHasId,
-  pageMeta?: M,
-};
+export type IDataWithMeta<D = unknown, M = unknown> = {
+  data: D,
+  meta?: M,
+}
+
+export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
+
+export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForOperation | unknown>;
 
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean

+ 3 - 2
packages/app/src/interfaces/search.ts

@@ -1,4 +1,4 @@
-import { IPageInfoAll, IPageWithMeta } from './page';
+import { IPageWithMeta } from './page';
 
 export type IPageSearchMeta = {
   bookmarkCount?: number,
@@ -9,7 +9,8 @@ export type IPageSearchMeta = {
   };
 }
 
-export const isIPageSearchMeta = (meta: IPageInfoAll | (IPageInfoAll & IPageSearchMeta) | undefined): meta is IPageInfoAll & IPageSearchMeta => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
+export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
   return meta != null && 'elasticSearchResult' in meta;
 };
 

+ 2 - 0
packages/app/src/interfaces/ui.ts

@@ -22,3 +22,5 @@ export type ICustomNavTabMappings = { [key: string]: ICustomTabContent };
 
 
 export type OnDeletedFunction = (idOrPaths: string | string[], isRecursively: Nullable<true>, isCompletely: Nullable<true>) => void;
+export type OnRenamedFunction = (path: string) => void;
+export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;

+ 2 - 1
packages/app/src/server/middlewares/apiv3-form-validator.js → packages/app/src/server/middlewares/apiv3-form-validator.ts

@@ -1,3 +1,4 @@
+import { NextFunction, Request, Response } from 'express';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:middlewares:ApiV3FormValidator');
@@ -5,7 +6,7 @@ const { validationResult } = require('express-validator');
 
 const ErrorV3 = require('../models/vo/error-apiv3');
 
-module.exports = () => (req, res, next) => {
+export const apiV3FormValidator = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
   logger.debug('req.query', req.query);
   logger.debug('req.params', req.params);
   logger.debug('req.body', req.body);

+ 1 - 2
packages/app/src/server/models/page.ts

@@ -325,9 +325,8 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
     const page = await this.findOne({ _id });
-    if (page == null) throw new Error('Page not found.');
 
-    path = page.path;
+    path = page == null ? '/' : page.path;
   }
   else {
     path = pathOrId;

+ 2 - 1
packages/app/src/server/routes/apiv3/app-settings.js

@@ -1,6 +1,8 @@
 import { body } from 'express-validator';
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 const debug = require('debug')('growi:routes:admin');
@@ -150,7 +152,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validator = {
     appSetting: [

+ 2 - 1
packages/app/src/server/routes/apiv3/attachment.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -22,7 +24,6 @@ module.exports = (crowi) => {
   const Page = crowi.model('Page');
   const User = crowi.model('User');
   const Attachment = crowi.model('Attachment');
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validator = {
     retrieveAttachments: [

+ 2 - 1
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -69,7 +71,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const { Page, Bookmark, User } = crowi.models;
 

+ 2 - 1
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -1,6 +1,8 @@
 /* eslint-disable no-unused-vars */
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
 const express = require('express');
@@ -90,7 +92,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const { customizeService } = crowi;
 

+ 2 - 1
packages/app/src/server/routes/apiv3/export.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:export');
 const fs = require('fs');
 
@@ -42,7 +44,6 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
 
   const { exportService, socketIoService } = crowi;

+ 1 - 1
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -7,6 +7,7 @@ import loggerFactory from '~/utils/logger';
 
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 import httpErrorHandler from '../../middlewares/http-error-handler';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
@@ -21,7 +22,6 @@ module.exports = (crowi) => {
   const User = crowi.model('User');
   const path = require('path');
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validator = {
     password: [

+ 2 - 1
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
 const express = require('express');
@@ -89,7 +91,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   /**
    * @swagger

+ 1 - 1
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -2,6 +2,7 @@ import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 import UpdatePost from '../../models/update-post';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
@@ -90,7 +91,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 

+ 27 - 28
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -3,13 +3,16 @@ import { query, oneOf } from 'express-validator';
 
 import mongoose from 'mongoose';
 
+import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
+import loggerFactory from '~/utils/logger';
+
 import { PageModel } from '../../models/page';
 import ErrorV3 from '../../models/vo/error-apiv3';
-import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
-import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
 import PageService from '../../service/page';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { IUserHasId } from '~/interfaces/user';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
 
@@ -31,8 +34,10 @@ const validator = {
     query('id').isMongoId(),
     query('path').isString(),
   ], 'id or path is required'),
-  pageIdsRequired: [
+  infoParams: [
     query('pageIds').isArray().withMessage('pageIds is required'),
+    query('attachBookmarkCount').isBoolean().optional(),
+    query('attachShortBody').isBoolean().optional(),
   ],
 };
 
@@ -42,7 +47,6 @@ const validator = {
 export default (crowi: Crowi): Router => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const router = express.Router();
 
@@ -82,7 +86,7 @@ export default (crowi: Crowi): Router => {
    * In most cases, using id should be prioritized
    */
   // eslint-disable-next-line max-len
-  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
     const Page: PageModel = crowi.model('Page');
@@ -98,8 +102,11 @@ export default (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/info', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { pageIds } = req.query;
+  router.get('/info', accessTokenParser, loginRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { pageIds, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam } = req.query;
+
+    const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
+    const attachShortBody: boolean = attachShortBodyParam === 'true';
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
@@ -111,8 +118,15 @@ export default (crowi: Crowi): Router => {
 
       const foundIds = pages.map(page => page._id);
 
-      const shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
-      const bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+      let shortBodiesMap;
+      if (attachShortBody) {
+        shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
+      }
+
+      let bookmarkCountMap;
+      if (attachBookmarkCount) {
+        bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+      }
 
       const idToPageInfoMap: Record<string, IPageInfoAll> = {};
 
@@ -122,11 +136,12 @@ export default (crowi: Crowi): Router => {
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
-          // create IPageInfoForList
+          // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            bookmarkCount: bookmarkCountMap[page._id],
-            revisionShortBody: shortBodiesMap[page._id],
+            isAbleToDeleteCompletely: pageService.canDeleteCompletely((page.creator as IUserHasId)?._id, req.user),
+            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
+            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;
 
         idToPageInfoMap[page._id] = pageInfo;
@@ -140,21 +155,5 @@ export default (crowi: Crowi): Router => {
     }
   });
 
-  // eslint-disable-next-line max-len
-  router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { pageIds } = req.query;
-
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      // const shortBodiesMap = await crowi.pageService!.shortBodiesMapByPageIds(pageIds as string[], req.user);
-      // return res.apiv3({ shortBodiesMap });
-      return res.apiv3();
-    }
-    catch (err) {
-      logger.error('Error occurred while fetching shortBodiesMap.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while fetching shortBodiesMap.'));
-    }
-  });
-
   return router;
 };

+ 3 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -4,6 +4,8 @@ import loggerFactory from '~/utils/logger';
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -158,7 +160,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
@@ -369,7 +370,7 @@ module.exports = (crowi) => {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
 
-      return res.apiv3(pageWithMeta.pageMeta);
+      return res.apiv3(pageWithMeta.meta);
     }
     catch (err) {
       logger.error('get-page-info', err);

+ 2 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -2,6 +2,8 @@ import loggerFactory from '~/utils/logger';
 
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
@@ -142,7 +144,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');

+ 1 - 1
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 
 import { listLocaleIds } from '~/utils/locale-utils';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../models/editor-settings';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 
@@ -69,7 +70,6 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const { User, ExternalAccount } = crowi.models;
 

+ 2 - 1
packages/app/src/server/routes/apiv3/revisions.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:pages');
 
 const express = require('express');
@@ -59,7 +61,6 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
     Revision,

+ 2 - 1
packages/app/src/server/routes/apiv3/search.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -21,7 +23,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   /**
    * @swagger

+ 2 - 1
packages/app/src/server/routes/apiv3/security-setting.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
 const express = require('express');
@@ -332,7 +334,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   async function updateAndReloadStrategySettings(authId, params) {
     const { configManager, passportService } = crowi;

+ 2 - 1
packages/app/src/server/routes/apiv3/share-links.js

@@ -2,6 +2,8 @@
 /* eslint-disable no-unused-vars */
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
 const express = require('express');
@@ -26,7 +28,6 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 

+ 2 - 1
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
 
@@ -47,7 +49,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   /**
    * @swagger

+ 2 - 1
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -2,6 +2,8 @@ import { SlackbotType, defaultSupportedSlackEventActions } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const mongoose = require('mongoose');
 const express = require('express');
 const { body, query, param } = require('express-validator');
@@ -52,7 +54,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const SlackAppIntegration = crowi.model('SlackAppIntegration');
 
   const validator = {

+ 2 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -2,6 +2,8 @@ import loggerFactory from '~/utils/logger';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import UserGroup from '~/server/models/user-group';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -31,7 +33,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
     UserGroupRelation,

+ 2 - 1
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -4,6 +4,8 @@ import { AllSidebarContentsType } from '~/interfaces/ui';
 
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 import UserUISettings from '../../models/user-ui-settings';
 import ErrorV3 from '../../models/vo/error-apiv3';
 
@@ -14,7 +16,6 @@ const router = express.Router();
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const validatorForPut = [
     body('settings').exists().withMessage('The body param \'settings\' is required'),

+ 2 - 1
packages/app/src/server/routes/apiv3/users.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 
 const express = require('express');
@@ -74,7 +76,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
-  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const {
     User,

+ 25 - 13
packages/app/src/server/routes/page.js

@@ -266,12 +266,15 @@ module.exports = function(crowi, app) {
     renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
   }
 
-  function addRenderVarsWhenNotFound(renderVars, pathOrId) {
+  async function addRenderVarsWhenNotFound(renderVars, pathOrId) {
     if (pathOrId == null) {
       return;
     }
 
     renderVars.notFoundTargetPathOrId = pathOrId;
+
+    const isPath = pathOrId.includes('/');
+    renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
 
   function replacePlaceholdersOfTemplate(template, req) {
@@ -329,7 +332,7 @@ module.exports = function(crowi, app) {
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
 
-    addRenderVarsWhenNotFound(renderVars, pathOrId);
+    await addRenderVarsWhenNotFound(renderVars, pathOrId);
 
     return res.render(view, renderVars);
   }
@@ -344,9 +347,16 @@ module.exports = function(crowi, app) {
       next();
     }
 
+    // empty page
     if (page.isEmpty) {
-      req.pagePath = page.path;
-      return next();
+      // redirect to page (path) url
+      const url = new URL('https://dummy.origin');
+      url.pathname = page.path;
+      Object.entries(req.query).forEach(([key, value], i) => {
+        url.searchParams.append(key, value);
+      });
+      return res.safeRedirect(urljoin(url.pathname, url.search));
+
     }
 
     const renderVars = {};
@@ -409,8 +419,13 @@ module.exports = function(crowi, app) {
 
     // empty page
     if (page.isEmpty) {
-      req.pagePath = page.path;
-      return _notFound(req, res);
+      // redirect to page (path) url
+      const url = new URL('https://dummy.origin');
+      url.pathname = page.path;
+      Object.entries(req.query).forEach(([key, value], i) => {
+        url.searchParams.append(key, value);
+      });
+      return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
     const { path } = page; // this must exist
@@ -484,8 +499,8 @@ module.exports = function(crowi, app) {
 
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
 
-    if (shareLink == null || shareLink.relatedPage == null) {
-      // page or sharelink are not found
+    if (shareLink == null || shareLink.relatedPage == null || shareLink.relatedPage.isEmpty) {
+      // page or sharelink are not found (or page is empty: abnormaly)
       return res.render('layout-growi/not_found_shared_page');
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
@@ -601,10 +616,6 @@ module.exports = function(crowi, app) {
     }
 
     if (pages.length === 1) {
-      if (pages[0].isEmpty) {
-        return _notFound(req, res);
-      }
-
       const url = new URL('https://dummy.origin');
       url.pathname = `/${pages[0]._id}`;
       Object.entries(req.query).forEach(([key, value], i) => {
@@ -613,7 +624,8 @@ module.exports = function(crowi, app) {
       return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    const isForbidden = await Page.exists({ path });
+    // Exclude isEmpty page to handle _notFound or forbidden
+    const isForbidden = await Page.exists({ path, isEmpty: false });
     if (isForbidden) {
       req.isForbidden = true;
       return _notFound(req, res);

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

@@ -238,8 +238,8 @@ class PageService {
 
     if (isSharedPage) {
       return {
-        pageData: page,
-        pageMeta: {
+        data: page,
+        meta: {
           isEmpty: page.isEmpty,
           isMovable: false,
           isDeletable: false,
@@ -262,8 +262,8 @@ class PageService {
 
     if (isGuestUser) {
       return {
-        pageData: page,
-        pageMeta: metadataForGuest,
+        data: page,
+        meta: metadataForGuest,
       };
     }
 
@@ -274,8 +274,8 @@ class PageService {
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
     return {
-      pageData: page,
-      pageMeta: {
+      data: page,
+      meta: {
         ...metadataForGuest,
         isAbleToDeleteCompletely,
         isBookmarked,

+ 13 - 6
packages/app/src/server/service/search.ts

@@ -1,7 +1,8 @@
 import xss from 'xss';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { IFormattedSearchResult, ISearchResult, ISearchResultMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
+import { IFormattedSearchResult, IPageSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
 import NamedQuery from '../models/named-query';
@@ -17,6 +18,8 @@ import { serializeUserSecurely } from '../models/serializers/user-serializer';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 
+const nonNullable = <T>(value: T): value is NonNullable<T> => value != null;
+
 // options for filtering xss
 const filterXssOptions = {
   whiteList: {
@@ -350,10 +353,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
    */
   async formatSearchResult(searchResult: ISearchResult<any>, delegatorName): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
-      const data = searchResult.data.map((page) => {
+      const data: IPageWithMeta<IPageSearchMeta>[] = searchResult.data.map((page) => {
         return {
-          pageData: page,
-          pageMeta: {},
+          data: page,
         };
       });
 
@@ -378,11 +380,15 @@ class SearchService implements SearchQueryParser, SearchResolver {
     result.meta = searchResult.meta;
 
     // set search result page data
-    result.data = searchResult.data.map((data) => {
+    const pages: (IPageWithMeta<IPageSearchMeta> | null)[] = searchResult.data.map((data) => {
       const pageData = findPageResult.pages.find((pageData) => {
         return pageData.id === data._id;
       });
 
+      if (pageData == null) {
+        return null;
+      }
+
       // add tags and seenUserCount to pageData
       pageData._doc.tags = data._source.tag_names;
       pageData._doc.seenUserCount = (pageData.seenUsers && pageData.seenUsers.length) || 0;
@@ -417,9 +423,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
         elasticSearchResult,
       };
 
-      return { pageData, pageMeta };
+      return { data: pageData, meta: pageMeta };
     });
 
+    result.data = pages.filter(nonNullable);
     return result;
   }
 

+ 5 - 0
packages/app/src/server/views/layout-growi/not_found.html

@@ -8,6 +8,11 @@
     data-not-found-target-path-or-id="{% if notFoundTargetPathOrId %}{{notFoundTargetPathOrId|json}}{% endif %}"
   >
   </div>
+  <div
+    id="growi-not-found-context"
+    data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
+  >
+  </div>
   <div class="grw-container-convertible">
     {% include '../widget/page_alerts.html' %}
     <div id="not-found-alert"></div>

+ 2 - 9
packages/app/src/server/views/widget/page_alerts.html

@@ -28,16 +28,13 @@
       {% endif %}
     {% endif %}
 
-    {% if redirectFrom or req.query.renamedFrom or req.query.redirectFrom %}
+    {% if redirectFrom or req.query.redirectFrom %}
     <div class="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
       <span>
-        {% set fromPath = req.query.renamedFrom or req.query.redirectFrom %}
+        {% set fromPath = req.query.redirectFrom %}
         {% if redirectFrom or req.query.redirectFrom %}
         <div id="redirected-alert"></div>
         {% endif %}
-        {% if req.query.renamedFrom %}
-        <div id="renamed-alert"></div>
-        {% endif %}
       </span>
       {% set hasRedirectLink = redirectFrom or req.query.redirectFrom or req.query.withRedirect %}
       {% if user and not page.isDeleted() and hasRedirectLink %}
@@ -49,10 +46,6 @@
     </div>
     {% endif %}
 
-    {% if req.query.duplicated and not page.isDeleted() %}
-    <div id="duplicated-alert"></div>
-    {% endif %}
-
     {% if req.query.unlinked %}
     <div class="alert alert-info d-edit-none py-3 px-4">
       <strong>{{ t('Unlinked') }}: </strong> {{ t('page_page.notice.unlinked') }}

+ 5 - 1
packages/app/src/stores/context.tsx

@@ -7,7 +7,7 @@ import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-import { TargetAndAncestors, NotFoundTargetPathOrId } from '../interfaces/page-listing-results';
+import { TargetAndAncestors, NotFoundTargetPathOrId, IsNotFoundPermalink } from '../interfaces/page-listing-results';
 
 type Nullable<T> = T | null;
 
@@ -131,6 +131,10 @@ export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetP
   return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
 };
 
+export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {
+  return useStaticSWR<Nullable<IsNotFoundPermalink>, Error>('isNotFoundPermalink', initialData);
+};
+
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
 };

+ 8 - 17
packages/app/src/stores/modal.tsx

@@ -1,6 +1,7 @@
 import { SWRResponse } from 'swr';
 import { useStaticSWR } from './use-static-swr';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IPageToDeleteWithMeta } from '~/interfaces/page';
 
 
 /*
@@ -27,29 +28,19 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
 };
 
-/*
-* PageDeleteModal
-*/
-export type IPageForPageDeleteModal = {
-  pageId: string,
-  revisionId?: string,
-  path: string
-  isAbleToDeleteCompletely?: boolean,
-}
-
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
 }
 
 type DeleteModalStatus = {
   isOpened: boolean,
-  pages?: IPageForPageDeleteModal[],
+  pages?: IPageToDeleteWithMeta[],
   opts?: IDeleteModalOption,
 }
 
 type DeleteModalStatusUtils = {
   open(
-    pages?: IPageForPageDeleteModal[],
+    pages?: IPageToDeleteWithMeta[],
     opts?: IDeleteModalOption,
   ): Promise<DeleteModalStatus | undefined>,
   close(): Promise<DeleteModalStatus | undefined>,
@@ -65,7 +56,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
   return {
     ...swrResponse,
     open: (
-        pages?: IPageForPageDeleteModal[],
+        pages?: IPageToDeleteWithMeta[],
         opts?: IDeleteModalOption,
     ) => swrResponse.mutate({
       isOpened: true, pages, opts,
@@ -83,7 +74,7 @@ export type IPageForPageDuplicateModal = {
 }
 
 export type IDuplicateModalOption = {
-  onDeleted?: OnDeletedFunction,
+  onDuplicated?: OnDuplicatedFunction,
 }
 
 type DuplicateModalStatus = {
@@ -95,7 +86,7 @@ type DuplicateModalStatus = {
 type DuplicateModalStatusUtils = {
   open(
     page?: IPageForPageDuplicateModal,
-    opts?: IRenameModalOption
+    opts?: IDuplicateModalOption
   ): Promise<DuplicateModalStatus | undefined>
   close(): Promise<DuplicateModalStatus | undefined>
 }
@@ -125,7 +116,7 @@ export type IPageForPageRenameModal = {
 }
 
 export type IRenameModalOption = {
-  onDeleted?: OnDeletedFunction,
+  onRenamed?: OnRenamedFunction,
 }
 
 type RenameModalStatus = {

+ 38 - 5
packages/app/src/stores/page.tsx

@@ -2,9 +2,10 @@ import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { HasObjectId } from '~/interfaces/has-object-id';
 
 import {
-  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing,
+  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
@@ -91,12 +92,44 @@ export const useSWRxPageInfo = (
   );
 };
 
-export const useSWRxPageInfoForList = (pageIds: string[] | null | undefined): SWRResponse<Record<string, IPageInfo | IPageInfoForListing>, Error> => {
+type PageInfoInjector = {
+  injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => IDataWithMeta<D, IPageInfoForOperation>[],
+}
+
+const isIDataWithMeta = (item: HasObjectId | IDataWithMeta): item is IDataWithMeta => {
+  return 'data' in item;
+};
+
+export const useSWRxPageInfoForList = (
+    pageIds: string[] | null | undefined,
+    attachBookmarkCount = false,
+    attachShortBody = false,
+): SWRResponse<Record<string, IPageInfoForListing>, Error> & PageInfoInjector => {
 
   const shouldFetch = pageIds != null && pageIds.length > 0;
 
-  return useSWRImmutable(
-    shouldFetch ? ['/page-listing/info', pageIds] : null,
-    (endpoint, pageIds) => apiv3Get(endpoint, { pageIds }).then(response => response.data),
+  const swrResult = useSWRImmutable<Record<string, IPageInfoForListing>>(
+    shouldFetch ? ['/page-listing/info', pageIds, attachBookmarkCount, attachShortBody] : null,
+    (endpoint, pageIds, attachBookmarkCount, attachShortBody) => {
+      return apiv3Get(endpoint, { pageIds, attachBookmarkCount, attachShortBody }).then(response => response.data);
+    },
   );
+
+  return {
+    ...swrResult,
+    injectTo: <D extends HasObjectId>(pages: (D | IDataWithMeta<D>)[]) => {
+      return pages.map((item) => {
+        const page = isIDataWithMeta(item) ? item.data : item;
+        const orgPageMeta = isIDataWithMeta(item) ? item.meta : undefined;
+
+        // get an applicable IPageInfo
+        const applicablePageInfo = (swrResult.data ?? {})[page._id];
+
+        return {
+          data: page,
+          meta: applicablePageInfo ?? orgPageMeta,
+        };
+      });
+    },
+  };
 };

+ 4 - 3
packages/app/src/stores/ui.tsx

@@ -13,7 +13,7 @@ import loggerFactory from '~/utils/logger';
 import { useStaticSWR } from './use-static-swr';
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
 } from './context';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { Nullable } from '~/interfaces/common';
@@ -312,12 +312,13 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
   const { data: isForbidden } = useIsForbidden();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: isNotFoundPermalink } = useIsNotFoundPermalink();
 
-  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
+  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser, isNotFoundPermalink].some(v => v === undefined);
 
   return useSWRImmutable(
     includesUndefined ? null : key,
-    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
+    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser && !isNotFoundPermalink,
   );
 };