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

Merge branch 'master' into support/108172-ForUpdatedAlert

Yuken Tezuka 3 лет назад
Родитель
Сommit
845f9dafe4
72 измененных файлов с 1679 добавлено и 757 удалено
  1. 15 1
      packages/app/public/static/locales/en_US/commons.json
  2. 0 15
      packages/app/public/static/locales/en_US/translation.json
  3. 15 1
      packages/app/public/static/locales/ja_JP/commons.json
  4. 0 15
      packages/app/public/static/locales/ja_JP/translation.json
  5. 15 1
      packages/app/public/static/locales/zh_CN/commons.json
  6. 0 15
      packages/app/public/static/locales/zh_CN/translation.json
  7. 2 20
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  8. 5 5
      packages/app/src/components/Comments.tsx
  9. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  10. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  11. 1 1
      packages/app/src/components/InAppNotification/InAppNotificationPage.tsx
  12. 1 1
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  13. 15 5
      packages/app/src/components/Navbar/GlobalSearch.tsx
  14. 1 2
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  15. 6 3
      packages/app/src/components/PageAlert/OldRevisionAlert.tsx
  16. 9 11
      packages/app/src/components/PageComment.tsx
  17. 20 13
      packages/app/src/components/PageComment/Comment.tsx
  18. 5 1
      packages/app/src/components/PageComment/ReplyComments.tsx
  19. 0 58
      packages/app/src/components/PageCommentSkeleton.tsx
  20. 1 5
      packages/app/src/components/PageContentFooter.tsx
  21. 3 2
      packages/app/src/components/PageCreateModal.jsx
  22. 34 13
      packages/app/src/components/PageEditor.tsx
  23. 34 21
      packages/app/src/components/PageEditorByHackmd.tsx
  24. 6 2
      packages/app/src/components/PageHistory.tsx
  25. 6 1
      packages/app/src/components/PageHistory/PageRevisionTable.tsx
  26. 8 3
      packages/app/src/components/PageHistory/Revision.tsx
  27. 9 4
      packages/app/src/components/PageHistory/RevisionDiff.tsx
  28. 4 2
      packages/app/src/components/PageList/PageListItemL.tsx
  29. 3 0
      packages/app/src/components/PutbackPageModal.jsx
  30. 4 4
      packages/app/src/components/RevisionComparer/RevisionComparer.tsx
  31. 6 11
      packages/app/src/components/SavePageControls.tsx
  32. 1 0
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  33. 7 6
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  34. 4 2
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  35. 2 2
      packages/app/src/components/UnsavedAlertDialog.tsx
  36. 2 0
      packages/app/src/interfaces/page-operation.ts
  37. 4 8
      packages/app/src/pages/[[...path]].page.tsx
  38. 0 1
      packages/app/src/pages/admin/[...path].page.tsx
  39. 0 1
      packages/app/src/pages/admin/security.page.tsx
  40. 1 1
      packages/app/src/pages/login.page.tsx
  41. 2 2
      packages/app/src/pages/me/[[...path]].page.tsx
  42. 0 1
      packages/app/src/pages/utils/commons.ts
  43. 18 1
      packages/app/src/server/models/interfaces/page-operation.ts
  44. 14 117
      packages/app/src/server/models/obsolete-page.js
  45. 7 0
      packages/app/src/server/models/page-operation.ts
  46. 5 165
      packages/app/src/server/models/page.ts
  47. 9 0
      packages/app/src/server/models/user-group-relation.js
  48. 1 0
      packages/app/src/server/models/user-group.ts
  49. 1 1
      packages/app/src/server/routes/apiv3/page.js
  50. 8 8
      packages/app/src/server/routes/apiv3/pages.js
  51. 3 13
      packages/app/src/server/routes/page.js
  52. 1 1
      packages/app/src/server/routes/tag.js
  53. 1 1
      packages/app/src/server/service/installer.ts
  54. 187 16
      packages/app/src/server/service/page-grant.ts
  55. 2 1
      packages/app/src/server/service/page-operation.ts
  56. 394 16
      packages/app/src/server/service/page.ts
  57. 4 8
      packages/app/src/stores/context.tsx
  58. 2 2
      packages/app/src/stores/editor.tsx
  59. 31 8
      packages/app/src/stores/page.tsx
  60. 5 6
      packages/app/src/stores/ui.tsx
  61. 16 3
      packages/app/src/stores/use-context-swr.tsx
  62. 11 10
      packages/app/src/stores/use-static-swr.tsx
  63. 6 2
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  64. 58 23
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  65. 1 0
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  66. 1 0
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  67. 554 68
      packages/app/test/integration/models/v5.page.test.js
  68. 24 19
      packages/app/test/integration/service/page-grant.test.js
  69. 16 2
      packages/app/test/integration/service/v5.non-public-page.test.ts
  70. 17 3
      packages/app/test/integration/service/v5.public-page.test.ts
  71. 13 1
      packages/core/src/interfaces/page.ts
  72. 16 0
      packages/core/src/utils/path-utils.js

+ 15 - 1
packages/app/public/static/locales/en_US/commons.json

@@ -3,6 +3,7 @@
   "Hide": "Hide",
   "Add": "Add",
   "Reset": "Reset",
+  "Sign out": "Logout",
 
   "meta": {
     "display_name": "English"
@@ -42,9 +43,22 @@
     "description": "Description"
   },
 
+  "in_app_notification": {
+    "notification_list": "In-App Notification List",
+    "see_all": "See All",
+    "no_notification": "You don't have any notificatios.",
+    "all": "All",
+    "unopend": "Unread",
+    "mark_all_as_read": "Mark all as read"
+  },
+
   "personal_dropdown": {
     "home": "Home",
-    "settings": "Settings"
+    "settings": "Settings",
+    "color_mode": "Color mode",
+    "sidebar_mode": "Sidebar mode",
+    "sidebar_mode_editor": "Sidebar mode on editor",
+    "use_os_settings": "Use OS settings"
   },
 
   "copy_to_clipboard": {

+ 0 - 15
packages/app/public/static/locales/en_US/translation.json

@@ -138,7 +138,6 @@
   "edited this page": "edited this page.",
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
-  "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
@@ -155,12 +154,6 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
-  "personal_dropdown": {
-    "color_mode": "Color mode",
-    "sidebar_mode": "Sidebar mode",
-    "sidebar_mode_editor": "Sidebar mode on editor",
-    "use_os_settings": "Use OS settings"
-  },
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
@@ -243,14 +236,6 @@
   "API Token Settings": "API token settings",
   "Current API Token": "Current API token",
   "Update API Token": "Update API token",
-  "in_app_notification": {
-    "notification_list": "In-App Notification List",
-    "see_all": "See All",
-    "no_notification": "You don't have any notificatios.",
-    "all": "All",
-    "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
-  },
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 15 - 1
packages/app/public/static/locales/ja_JP/commons.json

@@ -3,6 +3,7 @@
   "Hide": "非公開",
   "Add": "追加",
   "Reset": "リセット",
+  "Sign out": "ログアウト",
 
   "meta": {
     "display_name": "日本語"
@@ -42,9 +43,22 @@
     "description": "概要"
   },
 
+  "in_app_notification": {
+    "notification_list": "アプリ内通知一覧",
+    "see_all": "通知一覧を見る",
+    "no_notification": "通知はありません",
+    "all": "全て",
+    "unopend": "未読",
+    "mark_all_as_read": "全て既読にする"
+  },
+
   "personal_dropdown": {
     "home": "ホーム",
-    "settings": "設定"
+    "settings": "設定",
+    "color_mode": "カラーモード",
+    "sidebar_mode": "サイドバーモード",
+    "sidebar_mode_editor": "サイドバーモード(編集時)",
+    "use_os_settings": "OS設定を利用する"
   },
 
   "copy_to_clipboard": {

+ 0 - 15
packages/app/public/static/locales/ja_JP/translation.json

@@ -133,7 +133,6 @@
   "edited this page": "さんがこのページを編集しました。",
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
-  "Sign out": "ログアウト",
   "Disassociate": "連携解除",
   "Color mode": "カラーモード",
   "Sidebar mode": "サイドバーモード",
@@ -153,12 +152,6 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
-  "personal_dropdown": {
-    "color_mode": "カラーモード",
-    "sidebar_mode": "サイドバーモード",
-    "sidebar_mode_editor": "サイドバーモード(編集時)",
-    "use_os_settings": "OS設定を利用する"
-  },
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
@@ -241,14 +234,6 @@
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
-  "in_app_notification": {
-    "notification_list": "アプリ内通知一覧",
-    "see_all": "通知一覧を見る",
-    "no_notification": "通知はありません",
-    "all": "全て",
-    "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
-  },
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",

+ 15 - 1
packages/app/public/static/locales/zh_CN/commons.json

@@ -3,6 +3,7 @@
 	"Hide": "隐藏",
   "Add": "添加",
   "Reset": "重启",
+	"Sign out": "退出",
 
   "meta": {
     "display_name": "简体中文"
@@ -42,9 +43,22 @@
     "description": "Description"
   },
 
+  "in_app_notification": {
+    "notification_list": "应用内通知列表",
+    "see_all": "查看通知列表",
+    "no_notification": "您没有任何通知",
+    "all": "全部",
+    "unopend": "未读",
+    "mark_all_as_read" : "标记为已读"
+  },
+
   "personal_dropdown": {
     "home": "家",
-    "settings": "设置"
+    "settings": "设置",
+		"color_mode": "颜色模式",
+		"sidebar_mode": "边栏模式",
+		"sidebar_mode_editor": "编辑器上的边栏模式",
+		"use_os_settings": "使用操作系统设置"
   },
 
 	"copy_to_clipboard": {

+ 0 - 15
packages/app/public/static/locales/zh_CN/translation.json

@@ -145,7 +145,6 @@
 	"edited this page": "edited this page.",
 	"List Drafts": "草稿",
 	"Deleted Pages": "已删除页",
-	"Sign out": "退出",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
@@ -228,14 +227,6 @@
 	"API Token Settings": "API token 设置",
 	"Current API Token": "当前 API token",
 	"Update API Token": "更新 API token",
-  "in_app_notification": {
-    "notification_list": "应用内通知列表",
-    "see_all": "查看通知列表",
-    "no_notification": "您没有任何通知",
-    "all": "全部",
-    "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
-  },
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
@@ -566,12 +557,6 @@
     "Invalid_Number_of_Date" : "You entered invalid value",
     "link_sharing_is_disabled": "链接共享已被禁用"
   },
-	"personal_dropdown": {
-		"color_mode": "颜色模式",
-		"sidebar_mode": "边栏模式",
-		"sidebar_mode_editor": "编辑器上的边栏模式",
-		"use_os_settings": "使用操作系统设置"
-	},
 	"search_result": {
 		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",

+ 2 - 20
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -43,26 +43,8 @@ class XssForm extends React.Component {
     return (
       <div className="form-group col-12 my-3">
         <div className="row">
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
-            <div className="custom-control custom-radio ">
-              <input
-                type="radio"
-                className="custom-control-input"
-                id="xssOption1"
-                name="XssOption"
-                checked={xssOption === 1}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
-              />
-              <label className="custom-control-label w-100" htmlFor="xssOption1">
-                <p className="font-weight-bold">{t('markdown_settings.xss_options.remove_all_tags')}</p>
-                <div className="mt-4">
-                  {t('markdown_settings.xss_options.remove_all_tags_desc')}
-                </div>
-              </label>
-            </div>
-          </div>
 
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
+          <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
               <input
                 type="radio"
@@ -104,7 +86,7 @@ class XssForm extends React.Component {
             </div>
           </div>
 
-          <div className="col-md-4 col-sm-12 align-self-start mb-4">
+          <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
               <input
                 type="radio"

+ 5 - 5
packages/app/src/components/Comments.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { IRevisionHasId } from '@growi/core';
 import dynamic from 'next/dynamic';
 
-import { PageComment } from '~/components/PageComment';
+import { PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage } from '~/stores/page';
 
@@ -11,18 +11,18 @@ import { useCurrentUser } from '../stores/context';
 
 import { CommentEditorProps } from './PageComment/CommentEditor';
 
-
+const PageComment = dynamic<PageCommentProps>(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
 const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 
-
 type CommentsProps = {
   pageId: string,
+  pagePath: string,
   revision: IRevisionHasId,
 }
 
 export const Comments = (props: CommentsProps): JSX.Element => {
 
-  const { pageId, revision } = props;
+  const { pageId, pagePath, revision } = props;
 
   const { mutate } = useSWRxPageComment(pageId);
   const { data: isDeleted } = useIsTrashPage();
@@ -33,13 +33,13 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   }
 
   return (
-    // TODO: Check and refactor CSS import
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
       <div className="container-lg">
         <div className="page-comments">
           <div id="page-comments-list" className="page-comments-list">
             <PageComment
               pageId={pageId}
+              pagePath={pagePath}
               revision={revision}
               currentUser={currentUser}
               isReadOnly={false}

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationDropdown');
 
 
 export const InAppNotificationDropdown = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
   const [isOpen, setIsOpen] = useState(false);
   const limit = 6;

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -97,7 +97,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       break;
     case 'PAGE_UPDATE':
       actionMsg = 'updated on';
-      actionIcon = 'ti-agenda';
+      actionIcon = 'ti ti-agenda';
       break;
     case 'PAGE_RENAME':
       actionMsg = 'renamed';

+ 1 - 1
packages/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -20,7 +20,7 @@ const logger = loggerFactory('growi:InAppNotificationPage');
 
 
 export const InAppNotificationPage: FC = () => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
   const { mutate } = useSWRxInAppNotificationStatus();
 
   const { data: showPageLimitationXL } = useShowPageLimitationXL();

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

@@ -20,7 +20,7 @@ type AppearanceModeDropdownProps = {
 }
 export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: AppearanceModeDropdownProps) => {
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('commons');
 
   const { isAuthenticated } = props;
 

+ 15 - 5
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -1,7 +1,10 @@
-import React, { useState, useCallback, useRef } from 'react';
+import React, {
+  useState, useCallback, useRef, useEffect,
+} from 'react';
 
 import assert from 'assert';
 
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
@@ -27,6 +30,8 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
   const { dropup } = props;
 
+  const { returnPathForURL } = pathUtils;
+
   const router = useRouter();
 
   const globalSearchFormRef = useRef<IFocusable>(null);
@@ -38,9 +43,13 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
 
   const [text, setText] = useState('');
-  const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault);
+  const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault ?? false);
   const [isFocused, setFocused] = useState<boolean>(false);
 
+  useEffect(() => {
+    setScopeChildren(isSearchScopeChildrenAsDefault);
+  }, [isSearchScopeChildrenAsDefault]);
+
 
   const gotoPage = useCallback((data: IPageWithSearchMeta[]) => {
     assert(data.length > 0);
@@ -49,9 +58,9 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
     // navigate to page
     if (page != null) {
-      router.push(`/${page._id}`);
+      router.push(returnPathForURL(page.path, page._id));
     }
-  }, [router]);
+  }, [returnPathForURL, router]);
 
   const search = useCallback(() => {
     const url = new URL(window.location.href);
@@ -73,6 +82,7 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
   const isIndicatorShown = !isFocused && (text.length === 0);
 
+
   if (isScopeChildren == null || isSearchServiceReachable == null) {
     return <></>;
   }
@@ -116,7 +126,7 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
         </div>
         <SearchForm
           ref={globalSearchFormRef}
-          isSearchServiceReachable={isSearchServiceReachable}
+          isSearchServiceReachable={isSearchServiceReachable || false}
           dropup={dropup}
           onChange={gotoPage}
           onBlur={() => setFocused(false)}

+ 1 - 2
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -15,8 +15,7 @@ import {
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
-  useCurrentPageId, useCurrentPathname,
-  useIsNotFound,
+  useCurrentPageId, useCurrentPathname, useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData, useIsContainerFluid,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';

+ 6 - 3
packages/app/src/components/PageAlert/OldRevisionAlert.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { pathUtils } from '@growi/core';
 import Link from 'next/link';
 import { useTranslation } from 'react-i18next';
 
@@ -12,15 +13,17 @@ export const OldRevisionAlert = (): JSX.Element => {
   const { data: isLatestRevision } = useIsLatestRevision();
   const { data: page } = useSWRxCurrentPage();
 
+  const { returnPathForURL } = pathUtils;
+
   if (page == null || isLatestRevision == null || isLatestRevision) {
     return <></>;
   }
 
   return (
     <div className="alert alert-warning">
-      <strong>{ t('Warning') }: </strong> { t('page_page.notice.version') }
-      <Link href={`/${page._id}`}>
-        <a><i className="icon-fw icon-arrow-right-circle"></i>{ t('Show latest') }</a>
+      <strong>{t('Warning')}: </strong> {t('page_page.notice.version')}
+      <Link href={returnPathForURL(page.path, page._id)}>
+        <a><i className="icon-fw icon-arrow-right-circle"></i>{t('Show latest')}</a>
       </Link>
     </div>
   );

+ 9 - 11
packages/app/src/components/PageComment.tsx

@@ -3,7 +3,6 @@ import React, {
 } from 'react';
 
 import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
-import dynamic from 'next/dynamic';
 import { Button } from 'reactstrap';
 
 import { toastError } from '~/client/util/apiNotification';
@@ -15,18 +14,12 @@ import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 import { Comment } from './PageComment/Comment';
-import { CommentEditorProps } from './PageComment/CommentEditor';
-import { DeleteCommentModalProps } from './PageComment/DeleteCommentModal';
+import { CommentEditor } from './PageComment/CommentEditor';
+import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
 import { ReplyComments } from './PageComment/ReplyComments';
-import { PageCommentSkeleton } from './PageCommentSkeleton';
 
 import styles from './PageComment.module.scss';
 
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
-const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
-  () => import('./PageComment/DeleteCommentModal').then(mod => mod.DeleteCommentModal), { ssr: false },
-);
-
 export const ROOT_ELEM_ID = 'page-comments' as const;
 
 // Always render '#page-comments' for MutationObserver of SearchResultContent
@@ -38,6 +31,7 @@ const PageCommentRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Eleme
 export type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
+  pagePath: string,
   revision: string | IRevisionHasId,
   currentUser: any,
   isReadOnly: boolean,
@@ -49,7 +43,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const {
     rendererOptions: rendererOptionsByProps,
-    pageId, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -123,7 +117,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       return <PageCommentRoot />;
     }
     return (
-      <PageCommentSkeleton commentTitleClasses={commentTitleClasses}/>
+      <></>
     );
   }
 
@@ -138,6 +132,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       isReadOnly={isReadOnly}
+      pageId={pageId}
+      pagePath={pagePath}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
     />
@@ -151,6 +147,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       revisionCreatedAt={revisionCreatedAt as Date}
       currentUser={currentUser}
       replyList={replyComments}
+      pageId={pageId}
+      pagePath={pagePath}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
     />

+ 20 - 13
packages/app/src/components/PageComment/Comment.tsx

@@ -1,11 +1,12 @@
 import React, { useEffect, useMemo, useState } from 'react';
 
-import { IUser } from '@growi/core';
+import { IUser, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
+import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
+import urljoin from 'url-join';
 
 import { RendererOptions } from '~/services/renderer/renderer';
 
@@ -16,12 +17,10 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 import { Username } from '../User/Username';
 
 import { CommentControl } from './CommentControl';
-import { CommentEditorProps } from './CommentEditor';
+import { CommentEditor } from './CommentEditor';
 
 import styles from './Comment.module.scss';
 
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
-
 type CommentProps = {
   comment: ICommentHasId,
   rendererOptions: RendererOptions,
@@ -29,6 +28,8 @@ type CommentProps = {
   revisionCreatedAt: Date,
   currentUser: IUser,
   isReadOnly: boolean,
+  pageId: string,
+  pagePath: string,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }
@@ -37,9 +38,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
 
   const {
     comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
-    deleteBtnClicked, onComment,
+    pageId, pagePath, deleteBtnClicked, onComment,
   } = props;
 
+  const { returnPathForURL } = pathUtils;
+
   const { t } = useTranslation();
 
   const [markdown, setMarkdown] = useState('');
@@ -121,7 +124,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
   }, [comment, isMarkdown, markdown, rendererOptions]);
 
   const rootClassName = getRootClassName(comment);
-  const revHref = `?revision=${comment.revision}`;
+  const revHref = `?revisionId=${comment.revision}`;
   const editedDateId = `editedDate-${comment._id}`;
   const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
 
@@ -151,9 +154,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
             </div>
             <div className="page-comment-body">{commentBody}</div>
             <div className="page-comment-meta">
-              <a href={`#${commentId}`}>
-                <FormattedDistanceDate id={commentId} date={comment.createdAt} />
-              </a>
+              <Link href={`#${commentId}`} prefetch={false}>
+                <a>
+                  <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+                </a>
+              </Link>
               { isEdited && (
                 <>
                   <span id={editedDateId}>&nbsp;(edited)</span>
@@ -161,9 +166,11 @@ export const Comment = (props: CommentProps): JSX.Element => {
                 </>
               ) }
               <span className="ml-2">
-                <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
-                  <HistoryIcon />
-                </a>
+                <Link href={urljoin(returnPathForURL(pagePath, pageId), revHref)} prefetch={false}>
+                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision">
+                    <HistoryIcon />
+                  </a>
+                </Link>
                 <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
                   {t('page_comment.display_the_page_when_posting_this_comment')}
                 </UncontrolledTooltip>

+ 5 - 1
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -21,6 +21,8 @@ type ReplycommentsProps = {
   revisionCreatedAt: Date,
   currentUser: IUser,
   replyList: ICommentHasIdList,
+  pageId: string,
+  pagePath: string,
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
 }
@@ -29,7 +31,7 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const {
     rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
-    deleteBtnClicked, onComment,
+    pageId, pagePath, deleteBtnClicked, onComment,
   } = props;
 
   const { data: isAllReplyShown } = useIsAllReplyShown();
@@ -46,6 +48,8 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
           revisionCreatedAt={revisionCreatedAt}
           currentUser={currentUser}
           isReadOnly={isReadOnly}
+          pageId={pageId}
+          pagePath={pagePath}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
         />

+ 0 - 58
packages/app/src/components/PageCommentSkeleton.tsx

@@ -1,58 +0,0 @@
-import React from 'react';
-
-import { Skeleton } from './Skeleton';
-
-import styles from './PageComment.module.scss';
-import CommentStyles from './PageComment/Comment.module.scss';
-import CommentEditorStyles from './PageComment/CommentEditor.module.scss';
-
-type PageCommentSkeletonProps = {
-  commentTitleClasses?: string,
-  roundedPill?: boolean,
-}
-
-export const PageCommentSkeleton = (props: PageCommentSkeletonProps): JSX.Element => {
-  const {
-    commentTitleClasses,
-  } = props;
-
-  return (
-    <>
-      {/* TODO: Check the comment.html CSS */}
-      <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-        <div className="container-lg">
-          <div className="page-comments">
-            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
-            <div className="page-comments-list" id="page-comments-list">
-              <div className={`${CommentStyles['comment-styles']} page-comment-thread pb-5  page-comment-thread-no-replies`}>
-                <div className='page-comment flex-column'>
-                  <div className='page-commnet-writer'>
-                    <Skeleton additionalClass='rounded-circle picture' roundedPill />
-                  </div>
-                  <Skeleton additionalClass="page-comment-comment-body-skeleton grw-skeleton" />
-                </div>
-                <div className='page-comment flex-column ml-4 ml-sm-5 mr-3'>
-                  <div className='page-commnet-writer mt-3'>
-                    <Skeleton additionalClass='rounded-circle picture' roundedPill />
-                  </div>
-                  <Skeleton additionalClass="page-comment-comment-body-skeleton grw-skeleton mt-3" />
-                </div>
-                <div className="text-right">
-                  <Skeleton additionalClass="page-comment-button-skeleton btn btn-outline-secondary btn-sm grw-skeleton" />
-                </div>
-              </div>
-            </div>
-            <div className={`${CommentEditorStyles['comment-editor-styles']} form page-comment-form`}>
-              <div className='comment-form'>
-                <div className='comment-form-user'>
-                  <Skeleton additionalClass='rounded-circle picture' roundedPill />
-                </div>
-                <Skeleton additionalClass="page-comment-commenteditorlazyrenderer-body-skeleton grw-skeleton" />
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </>
-  );
-};

+ 1 - 5
packages/app/src/components/PageContentFooter.tsx

@@ -6,14 +6,10 @@ import dynamic from 'next/dynamic';
 import { useSWRxCurrentPage } from '~/stores/page';
 
 import type { AuthorInfoProps } from './Navbar/AuthorInfo';
-import { Skeleton } from './Skeleton';
 
 import styles from './PageContentFooter.module.scss';
 
-const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), {
-  ssr: false,
-  loading: () => <Skeleton additionalClass={`${styles['page-content-footer-skeleton']} mb-3`} />,
-});
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
 
 export type PageContentFooterProps = {
   page: IPage,

+ 3 - 2
packages/app/src/components/PageCreateModal.jsx

@@ -267,13 +267,13 @@ const PageCreateModal = () => {
 
           <h3 className="grw-modal-head pb-2">
             {t('template.modal_label.Create template under')}<br />
-            <code className="h6">{pathname}</code>
+            <code className="h6" data-testid="grw-page-create-modal-path-name">{pathname}</code>
           </h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
 
             <UncontrolledButtonDropdown id="dd-template-type" className='flex-fill text-center'>
-              <DropdownToggle caret>
+              <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'decendants' && t('template.decendants.label')}
@@ -292,6 +292,7 @@ const PageCreateModal = () => {
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
               <button
+                data-testid="grw-btn-edit-page"
                 type="button"
                 className='grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3'
                 onClick={createTemplatePage}

+ 34 - 13
packages/app/src/components/PageEditor.tsx

@@ -4,9 +4,10 @@ import React, {
 
 import EventEmitter from 'events';
 
-import { envUtils, PageGrant } from '@growi/core';
+import { envUtils, IPageHasId, PageGrant } from '@growi/core';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
 import { saveOrUpdate } from '~/client/services/page-operation';
@@ -17,7 +18,7 @@ import { IEditorMethods } from '~/interfaces/editor-methods';
 import { SocketEventName } from '~/interfaces/websocket';
 import {
   useCurrentPathname, useCurrentPageId,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useIsNotFound,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -54,7 +55,10 @@ let isOriginOfScrollSyncPreview = false;
 const PageEditor = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
-  const { data: pageId } = useCurrentPageId();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
@@ -68,7 +72,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
@@ -137,7 +141,7 @@ const PageEditor = React.memo((): JSX.Element => {
   }, [setMarkdownWithDebounce]);
 
   // return true if the save succeeds, otherwise false.
-  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<boolean> => {
+  const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       throw new Error('Some materials to save are invalid');
@@ -152,10 +156,13 @@ const PageEditor = React.memo((): JSX.Element => {
     );
 
     try {
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId }, markdownToSave.current);
-      await mutateCurrentPage();
-      mutateIsEnabledUnsavedWarning(false);
-      return true;
+      const { page } = await saveOrUpdate(
+        optionsToSave,
+        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
+        markdownToSave.current,
+      );
+
+      return page;
     }
     catch (error) {
       logger.error('failed to save', error);
@@ -168,20 +175,34 @@ const PageEditor = React.memo((): JSX.Element => {
         //   lastUpdateUser: error.data.user,
         // });
       }
-      return false;
+      return null;
     }
 
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId, mutateCurrentPage, mutateIsEnabledUnsavedWarning]);
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
       return;
     }
 
-    await save(opts);
+    const page = await save(opts);
+    if (page == null) {
+      return;
+    }
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+    if (isNotFound) {
+      await router.push(`/${page._id}`);
+    }
+    else {
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
+    }
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, mutateEditorMode]);
+  }, [editorMode, save, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
 
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {

+ 34 - 21
packages/app/src/components/PageEditorByHackmd.tsx

@@ -4,8 +4,11 @@ import React, {
 
 import EventEmitter from 'events';
 
+import { pathUtils } from '@growi/core';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
-
+import urljoin from 'url-join';
 
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
@@ -13,7 +16,7 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
 import {
-  useCurrentPageId, useCurrentPathname, useHackmdUri,
+  useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -42,17 +45,22 @@ type HackEditorRef = {
 export const PageEditorByHackmd = (): JSX.Element => {
 
   const { t } = useTranslation();
+  const router = useRouter();
+
+  const { data: isNotFound } = useIsNotFound();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId } = useCurrentPageId();
+  const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
 
+  const { returnPathForURL } = pathUtils;
+
   // pageData
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
@@ -101,30 +109,32 @@ export const PageEditorByHackmd = (): JSX.Element => {
 
       const markdown = await hackmdEditorRef.current.getValue();
 
-      await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      const { page } = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
       await mutatePageData();
       await mutateTagsInfo();
+
+      if (page == null) {
+        return;
+      }
+      // The updateFn should be a promise or asynchronous function to handle the remote mutation
+      // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+      // Moreover, `async() => false` does not work since it's too fast to be calculated.
+      await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+      if (isNotFound) {
+        await router.push(`/${page._id}`);
+      }
+      else {
+        await mutateCurrentPageId(page._id);
+        await mutatePageData();
+      }
       mutateEditorMode(EditorMode.View);
-      mutateIsEnabledUnsavedWarning(false);
     }
     catch (error) {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [editorMode,
-      isSlackEnabled,
-      currentPathname,
-      slackChannels,
-      grant,
-      revision,
-      pageTags,
-      pageId,
-      currentPagePath,
-      mutatePageData,
-      mutateEditorMode,
-      mutateTagsInfo,
-      mutateIsEnabledUnsavedWarning,
-  ]);
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, pageId,
+      currentPagePath, mutatePageData, mutateTagsInfo, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -348,8 +358,11 @@ export const PageEditorByHackmd = (): JSX.Element => {
               <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
               <div className="card-body text-center">
                 {t('hackmd.based_on_revision')}&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="badge badge-secondary">{revisionIdHackmdSynced?.substr(-8)}</span></a>
-
+                { pageData != null && (
+                  <Link href={urljoin(returnPathForURL(pageData.path, pageData._id), `?revisionId=${revisionIdHackmdSynced}`)} prefetch={false}>
+                    <a><span className="badge badge-secondary">{revisionIdHackmdSynced?.substr(-8)}</span></a>
+                  </Link>
+                )}
                 <div className="text-center mt-3">
                   <button
                     className="btn btn-link btn-view-outdated-draft p-0"

+ 6 - 2
packages/app/src/components/PageHistory.tsx

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
 import { IRevisionHasPageId } from '@growi/core';
 
 import { useCurrentPageId } from '~/stores/context';
-import { useSWRxPageRevisions } from '~/stores/page';
+import { useSWRxPageRevisions, useCurrentPagePath } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import { PageRevisionTable } from './PageHistory/PageRevisionTable';
@@ -17,6 +17,7 @@ export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
   const [activePage, setActivePage] = useState(1);
 
   const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPagePath } = useCurrentPagePath();
 
   const { data: revisionsData, mutate: mutatePageRevisions } = useSWRxPageRevisions(activePage, 10, currentPageId);
 
@@ -36,7 +37,7 @@ export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
 
   const pagingLimit = 10;
 
-  if (revisionsData == null || sourceRevision == null || targetRevision == null || currentPageId == null) {
+  if (revisionsData == null || sourceRevision == null || targetRevision == null || currentPageId == null || currentPagePath == null) {
     return (
       <div className="text-muted text-center">
         <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
@@ -63,6 +64,8 @@ export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
         pagingLimit={pagingLimit}
         sourceRevision={sourceRevision}
         targetRevision={targetRevision}
+        currentPageId={currentPageId}
+        currentPagePath={currentPagePath}
         onChangeSourceInvoked={setSourceRevision}
         onChangeTargetInvoked={setTargetRevision}
         onClose={onClose}
@@ -74,6 +77,7 @@ export const PageHistory: React.FC<{ onClose: () => void }> = ({ onClose }) => {
         sourceRevision={sourceRevision}
         targetRevision={targetRevision}
         currentPageId={currentPageId}
+        currentPagePath={currentPagePath}
         onClose={onClose}
       />
     </div>

+ 6 - 1
packages/app/src/components/PageHistory/PageRevisionTable.tsx

@@ -12,6 +12,8 @@ type PageRevisionTAble = {
   pagingLimit: number,
   sourceRevision: IRevisionHasId,
   targetRevision: IRevisionHasId,
+  currentPageId: string,
+  currentPagePath: string,
   onChangeSourceInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
   onChangeTargetInvoked: React.Dispatch<React.SetStateAction<IRevisionHasId | undefined>>,
   onClose: () => void,
@@ -21,7 +23,8 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    revisions, pagingLimit, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked, onClose,
+    revisions, pagingLimit, sourceRevision, targetRevision, currentPageId, currentPagePath,
+    onChangeSourceInvoked, onChangeTargetInvoked, onClose,
   } = props;
 
   const revisionCount = revisions.length;
@@ -49,6 +52,8 @@ export const PageRevisionTable = (props: PageRevisionTAble): JSX.Element => {
           <div className="d-lg-flex">
             <Revision
               revision={revision}
+              currentPageId={currentPageId}
+              currentPagePath={currentPagePath}
               isLatestRevision={revision === latestRevision}
               hasDiff={hasDiff}
               key={`revision-history-rev-${revisionId}`}

+ 8 - 3
packages/app/src/components/PageHistory/Revision.tsx

@@ -1,9 +1,10 @@
 import React from 'react';
 
-import { IRevisionHasId } from '@growi/core';
+import { IRevisionHasId, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
+import urljoin from 'url-join';
 
 import UserDate from '../User/UserDate';
 import { Username } from '../User/Username';
@@ -12,6 +13,8 @@ import styles from './Revision.module.scss';
 
 type RevisionProps = {
   revision: IRevisionHasId,
+  currentPageId: string,
+  currentPagePath: string,
   isLatestRevision: boolean,
   hasDiff: boolean,
   onClose: () => void,
@@ -21,9 +24,11 @@ export const Revision = (props: RevisionProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    revision, isLatestRevision, hasDiff, onClose,
+    revision, currentPageId, currentPagePath, isLatestRevision, hasDiff, onClose,
   } = props;
 
+  const { returnPathForURL } = pathUtils;
+
   const renderSimplifiedNodiff = (revision: IRevisionHasId) => {
 
     const author = revision.author;
@@ -64,7 +69,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
           <div className="mb-1">
             <UserDate dateTime={revision.createdAt} />
             <br className="d-xl-none d-block" />
-            <Link href={`?revisionId=${revision._id}`} prefetch={false}>
+            <Link href={urljoin(returnPathForURL(currentPagePath, currentPageId), `?revisionId=${revision._id}`)} prefetch={false}>
               <a className="ml-xl-3" onClick={onClose}>
                 <i className="icon-login"></i> {t('Go to this version')}
               </a>

+ 9 - 4
packages/app/src/components/PageHistory/RevisionDiff.tsx

@@ -1,10 +1,11 @@
 import React from 'react';
 
-import { IRevisionHasPageId } from '@growi/core';
+import { IRevisionHasPageId, pathUtils } from '@growi/core';
 import { createPatch } from 'diff';
 import { html, Diff2HtmlConfig } from 'diff2html';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
+import urljoin from 'url-join';
 
 import UserDate from '../User/UserDate';
 
@@ -16,6 +17,8 @@ type RevisioinDiffProps = {
   currentRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
   revisionDiffOpened: boolean,
+  currentPageId: string,
+  currentPagePath: string,
   onClose: () => void,
 }
 
@@ -23,9 +26,11 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    currentRevision, previousRevision, revisionDiffOpened, onClose,
+    currentRevision, previousRevision, revisionDiffOpened, currentPageId, currentPagePath, onClose,
   } = props;
 
+  const { returnPathForURL } = pathUtils;
+
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
 
   const patch = createPatch(
@@ -50,7 +55,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
           <div className="row">
             <div className="col comparison-source-wrapper pt-1 px-0">
               <span className="comparison-source pr-3">{t('page_history.comparing_source')}</span><UserDate dateTime={previousRevision.createdAt} />
-              <Link href={`?revisionId=${previousRevision._id}`}>
+              <Link href={urljoin(returnPathForURL(currentPagePath, currentPageId), `?revisionId=${previousRevision._id}`)}>
                 <a className="ml-3" onClick={onClose}>
                   <i className="icon-login"></i>
                 </a>
@@ -58,7 +63,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
             </div>
             <div className="col comparison-target-wrapper pt-1">
               <span className="comparison-target pr-3">{t('page_history.comparing_target')}</span><UserDate dateTime={currentRevision.createdAt} />
-              <Link href={`?revisionId=${currentRevision._id}`}>
+              <Link href={urljoin(returnPathForURL(currentPagePath, currentPageId), `?revisionId=${currentRevision._id}`)}>
                 <a className="ml-3" onClick={onClose}>
                   <i className="icon-login"></i>
                 </a>

+ 4 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -4,7 +4,7 @@ import React, {
 } from 'react';
 
 
-import { DevidedPagePath } from '@growi/core';
+import { DevidedPagePath, pathUtils } from '@growi/core';
 import { UserPicture, PageListMeta } from '@growi/ui';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
@@ -55,6 +55,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const { returnPathForURL } = pathUtils;
+
   const [likerCount, setLikerCount] = useState(pageData.liker.length);
   const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
 
@@ -203,7 +205,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                 <span className="h5 mb-0">
                   {/* Use permanent links to care for pages with the same name (Cannot use page path url) */}
                   <span className="grw-page-path-hierarchical-link text-break">
-                    <Link href={encodeURI(urljoin('/', pageData._id))} prefetch={false}>
+                    <Link href={returnPathForURL(pageData.path, pageData._id)} prefetch={false}>
                       {shouldDangerouslySetInnerHTMLForPaths
                         ? (
                           <a

+ 3 - 0
packages/app/src/components/PutbackPageModal.jsx

@@ -9,6 +9,7 @@ import {
 import { apiPost } from '~/client/util/apiv1-client';
 import { PathAlreadyExistsError } from '~/server/models/errors';
 import { usePutBackPageModal } from '~/stores/modal';
+import { usePageInfoTermManager } from '~/stores/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -16,6 +17,7 @@ const PutBackPageModal = () => {
   const { t } = useTranslation();
 
   const { data: pageDataToRevert, close: closePutBackPageModal } = usePutBackPageModal();
+  const { advance: advancePi } = usePageInfoTermManager();
   const { isOpened, page } = pageDataToRevert;
   const { pageId, path } = page;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
@@ -41,6 +43,7 @@ const PutBackPageModal = () => {
         page_id: pageId,
         recursively,
       });
+      advancePi();
 
       if (onPutBacked != null) {
         onPutBacked(response.page.path);

+ 4 - 4
packages/app/src/components/RevisionComparer/RevisionComparer.tsx

@@ -7,8 +7,6 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import { useCurrentPagePath } from '~/stores/page';
-
 import { RevisionDiff } from '../PageHistory/RevisionDiff';
 
 import styles from './RevisionComparer.module.scss';
@@ -26,6 +24,7 @@ type RevisionComparerProps = {
   sourceRevision: IRevisionHasPageId
   targetRevision: IRevisionHasPageId
   currentPageId?: string
+  currentPagePath: string
   onClose: () => void
 }
 
@@ -33,10 +32,9 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
   const { t } = useTranslation(['translation', 'commons']);
 
   const {
-    sourceRevision, targetRevision, currentPageId, onClose,
+    sourceRevision, targetRevision, currentPageId, currentPagePath, onClose,
   } = props;
 
-  const { data: currentPagePath } = useCurrentPagePath();
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
   const toggleDropdown = () => {
@@ -105,6 +103,8 @@ export const RevisionComparer = (props: RevisionComparerProps): JSX.Element => {
               revisionDiffOpened
               previousRevision={sourceRevision}
               currentRevision={targetRevision}
+              currentPageId={currentPageId}
+              currentPagePath={currentPagePath}
               onClose={onClose}
             />
           )

+ 6 - 11
packages/app/src/components/SavePageControls.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback } from 'react';
 
+import EventEmitter from 'events';
+
 import { pagePathUtils, PageGrant } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
@@ -7,30 +9,23 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-// import PageContainer from '~/client/services/PageContainer';
-import { CustomWindow } from '~/interfaces/global';
 import { IPageGrantData } from '~/interfaces/page';
 import {
   useIsEditable, useCurrentPageId, useIsAclEnabled,
 } from '~/stores/context';
-import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import GrantSelector from './SavePageControls/GrantSelector';
 
-// import { withUnstatedContainers } from './UnstatedUtils';
+declare const globalEmitter: EventEmitter;
 
 const logger = loggerFactory('growi:SavePageControls');
 
-type Props = {
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-}
-
 const { isTopPage } = pagePathUtils;
 
-export const SavePageControls = (props: Props): JSX.Element | null => {
+export const SavePageControls = (): JSX.Element | null => {
   const { t } = useTranslation();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isEditable } = useIsEditable();
@@ -45,12 +40,12 @@ export const SavePageControls = (props: Props): JSX.Element | null => {
 
   const save = useCallback(async(): Promise<void> => {
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView');
+    globalEmitter.emit('saveAndReturnToView');
   }, []);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
-    (window as CustomWindow).globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
+    globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true });
   }, []);
 
 

+ 1 - 0
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -262,6 +262,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         <PageComment
           rendererOptions={rendererOptions}
           pageId={page._id}
+          pagePath={page.path}
           revision={page.revision}
           currentUser={currentUser}
           isReadOnly

+ 7 - 6
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -467,12 +467,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                   </UncontrolledTooltip>
                 </>
               )}
-
-              <Link href={`/${page._id}`} prefetch={false}>
-                <a className="grw-pagetree-title-anchor flex-grow-1">
-                  <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-                </a>
-              </Link>
+              { page != null && page.path != null && page._id != null && (
+                <Link href={pathUtils.returnPathForURL(page.path, page._id)} prefetch={false}>
+                  <a className="grw-pagetree-title-anchor flex-grow-1">
+                    <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                  </a>
+                </Link>
+              )}
             </>
           )}
         {descendantCount > 0 && !isRenameInputShown && (

+ 4 - 2
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -15,7 +15,7 @@ import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useCurrentPagePath, usePageInfoTermManager, useSWRxCurrentPage } from '~/stores/page';
 import {
   usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page-listing';
@@ -117,6 +117,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
+  const { advance: advancePi } = usePageInfoTermManager();
 
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
@@ -186,6 +187,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
       advancePt();
       advanceFts();
       advanceDpl();
+      advancePi();
 
       if (currentPagePath === pathOrPathsToDelete) {
         mutateCurrentPage();
@@ -193,7 +195,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     };
 
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [advanceDpl, advanceFts, advancePt, currentPagePath, mutateCurrentPage, openDeleteModal, t]);
+  }, [advanceDpl, advanceFts, advancePi, advancePt, currentPagePath, mutateCurrentPage, openDeleteModal, t]);
 
   // ***************************  Scroll on init ***************************
   const scrollOnInit = useCallback(() => {

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

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect, memo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
@@ -56,4 +56,4 @@ const UnsavedAlertDialog = (): JSX.Element => {
   return <></>;
 };
 
-export default UnsavedAlertDialog;
+export default memo(UnsavedAlertDialog);

+ 2 - 0
packages/app/src/interfaces/page-operation.ts

@@ -1,4 +1,6 @@
 export const PageActionType = {
+  Create: 'Create',
+  Update: 'Update',
   Rename: 'Rename',
   Duplicate: 'Duplicate',
   Delete: 'Delete',

+ 4 - 8
packages/app/src/pages/[[...path]].page.tsx

@@ -58,7 +58,7 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
 import {
   useCurrentUser,
-  useIsLatestRevision, useCurrentRevisionId,
+  useIsLatestRevision,
   useIsForbidden, useIsNotFound, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
@@ -138,7 +138,6 @@ type Props = CommonProps & {
 
   // shareLinkId?: string;
   isLatestRevision?: boolean,
-  currentRevisionId?: string,
 
   isIdenticalPathPage?: boolean,
   isForbidden: boolean,
@@ -250,7 +249,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useCurrentPageId(pageId ?? null);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
-  useCurrentRevisionId(props.currentRevisionId);
 
   const { data: currentPage } = useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
 
@@ -341,7 +339,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           { !props.isIdenticalPathPage && !props.isNotFound && (
             <footer className="footer d-edit-none">
-              { pageWithMeta != null && !isTopPagePath && (<Comments pageId={pageId} revision={pageWithMeta.data.revision} />) }
+              { pageWithMeta != null && pagePath != null && !isTopPagePath && (
+                <Comments pageId={pageId} pagePath={pagePath} revision={pageWithMeta.data.revision} />
+              ) }
               { pageWithMeta != null && isUsersHomePage(pageWithMeta.data.path) && (
                 <UsersHomePageFooter creatorId={pageWithMeta.data.creator._id}/>
               ) }
@@ -426,10 +426,6 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     props.isLatestRevision = page.isLatestRevision();
   }
 
-  if (typeof revisionId === 'string' || typeof revisionId === 'undefined') {
-    props.currentRevisionId = props.isLatestRevision && page.latestRevision != null ? page.latestRevision.toString() : revisionId;
-  }
-
   if (page == null && user != null) {
     const templateData = await Page.findTemplate(props.currentPathname);
     if (templateData != null) {

+ 0 - 1
packages/app/src/pages/admin/[...path].page.tsx

@@ -17,7 +17,6 @@ const AdminAppPage: NextPage<CommonProps> = (props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
 
-
   return (
     <AdminLayout>
       <AdminNotFoundPage />

+ 0 - 1
packages/app/src/pages/admin/security.page.tsx

@@ -38,7 +38,6 @@ const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
   useSiteUrl(props.siteUrl);
   useIsMailerSetup(props.isMailerSetup);
 
-
   const title = t('security_settings.security_settings');
   const adminSecurityContainers: Container<any>[] = [];
 

+ 1 - 1
packages/app/src/pages/login.page.tsx

@@ -82,7 +82,7 @@ function injectEnabledStrategies(context: GetServerSidePropsContext, props: Prop
     github: configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
     facebook: false,
     twitter: configManager.getConfig('crowi', 'security:passport-twitter:isEnabled'),
-    smal: configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
+    saml: configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
     oidc: configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
     basic: configManager.getConfig('crowi', 'security:passport-basic:isEnabled'),
   };

+ 2 - 2
packages/app/src/pages/me/[[...path]].page.tsx

@@ -53,7 +53,7 @@ const InAppNotificationPage = dynamic(
 
 const MePage: NextPage<Props> = (props: Props) => {
   const router = useRouter();
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
   const { path } = router.query;
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['personal-settings'];
 
@@ -68,7 +68,7 @@ const MePage: NextPage<Props> = (props: Props) => {
       //   component: <MyDraftList />,
       // },
       'all-in-app-notifications': {
-        title: t('in_app_notification.notification_list'),
+        title: t('commons:in_app_notification.notification_list'),
         component: <InAppNotificationPage />,
       },
     };

+ 0 - 1
packages/app/src/pages/utils/commons.ts

@@ -6,7 +6,6 @@ import { SSRConfig, UserConfig } from 'next-i18next';
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
-import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { GrowiThemes } from '~/interfaces/theme';
 

+ 18 - 1
packages/app/src/server/models/interfaces/page-operation.ts

@@ -1,3 +1,5 @@
+import { PageGrant } from '~/interfaces/page';
+
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 export type IPageForResuming = {
@@ -19,8 +21,23 @@ export type IUserForResuming = {
   _id: ObjectIdLike,
 };
 
+export type IOptionsForUpdate = {
+  grant?: PageGrant,
+  grantUserGroupId?: ObjectIdLike,
+  isSyncRevisionToHackmd?: boolean,
+  overwriteScopesOfDescendants?: boolean,
+};
+
+export type IOptionsForCreate = {
+  format?: string,
+  grantUserGroupId?: ObjectIdLike,
+  grant?: PageGrant,
+  overwriteScopesOfDescendants?: boolean,
+  isSynchronously?: boolean,
+};
+
 export type IOptionsForResuming = {
   updateMetadata?: boolean,
   createRedirectPage?: boolean,
   prevDescendantCount?: number,
-};
+} & IOptionsForUpdate & IOptionsForCreate;

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

@@ -1,5 +1,6 @@
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core';
 
+import { PageGrant } from '~/interfaces/page';
 import loggerFactory from '~/utils/logger';
 
 
@@ -647,132 +648,28 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
   };
 
-  async function pushRevision(pageData, newRevision, user) {
-    await newRevision.save();
-    debug('Successfully saved new revision', newRevision);
-
-    pageData.revision = newRevision;
-    pageData.lastUpdateUser = user;
-    pageData.updatedAt = Date.now();
-
-    return pageData.save();
-  }
-
-  async function validateAppliedScope(user, grant, grantUserGroupId) {
-    if (grant === GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
-    }
-
-    if (grant === GRANT_USER_GROUP) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
-
-      if (count === 0) {
-        throw new Error('no relations were exist for group and user.');
-      }
-    }
-  }
-
-  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
-    /*
-     * v4 compatible process
-     */
-    validateCrowi();
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const format = options.format || 'markdown';
-    const grantUserGroupId = options.grantUserGroupId || null;
-    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-
-    let grant = options.grant;
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    const isExist = await this.count({ path });
-
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
+  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
+    const builder = new this.PageQueryBuilder(this.find());
+    builder.addConditionToListOnlyDescendants(parentPage.path);
 
-    const page = new Page();
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-    if (expandContentWidth != null) {
-      page.expandContentWidth = expandContentWidth;
+    if (isV4) {
+      builder.addConditionAsRootOrNotOnTree();
     }
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.updatePageV4 = async function(pageData, body, previousBody, user, options = {}) {
-    validateCrowi();
-
-    const Revision = crowi.model('Revision');
-    const grant = options.grant || pageData.grant; //                                  use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-
-    await validateAppliedScope(user, grant, grantUserGroupId);
-    pageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await pageData.save();
-
-    // Update revision
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
+    else {
+      builder.addConditionAsOnTree();
     }
 
-
-    pageEvent.emit('update', savedPage, user);
-
-    return savedPage;
-  };
-
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListWithDescendants(parentPage.path);
-
     // add grant conditions
     await addConditionToFilteringByViewerToEdit(builder, user);
 
-    // get all pages that the specified user can update
-    const pages = await builder.query.exec();
+    const grant = parentPage.grant;
 
-    for (const page of pages) {
-      // skip parentPage
-      if (page.id === parentPage.id) {
-        continue;
-      }
+    await builder.query.updateMany({}, {
+      grant,
+      grantedGroup: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroup : null,
+      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
+    });
 
-      page.applyScope(user, parentPage.grant, parentPage.grantedGroup);
-      page.save();
-    }
   };
 
   pageSchema.statics.removeByPath = function(path) {

+ 7 - 0
packages/app/src/server/models/page-operation.ts

@@ -33,6 +33,7 @@ export interface IPageOperation {
   options?: IOptionsForResuming,
   incForUpdatingDescendantCount?: number,
   unprocessableExpiryDate: Date,
+  exPage?: IPageForResuming,
 
   isProcessable(): boolean
 }
@@ -71,6 +72,11 @@ const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   createRedirectPage: { type: Boolean },
   updateMetadata: { type: Boolean },
   prevDescendantCount: { type: Number },
+  grant: { type: Number },
+  grantUserGroupId: { type: ObjectId, ref: 'UserGroup' },
+  format: { type: String },
+  isSyncRevisionToHackmd: { type: Boolean },
+  overwriteScopesOfDescendants: { type: Boolean },
 }, { _id: false });
 
 const schema = new Schema<PageOperationDocument, PageOperationModel>({
@@ -89,6 +95,7 @@ const schema = new Schema<PageOperationDocument, PageOperationModel>({
   fromPath: { type: String, required: true, index: true },
   toPath: { type: String, index: true },
   page: { type: pageSchemaForResuming, required: true },
+  exPage: { type: pageSchemaForResuming, required: false },
   user: { type: userSchemaForResuming, required: true },
   options: { type: optionsSchemaForResuming },
   incForUpdatingDescendantCount: { type: Number },

+ 5 - 165
packages/app/src/server/models/page.ts

@@ -340,7 +340,7 @@ export class PageQueryBuilder {
           { grant: { $ne: GRANT_SPECIFIED } },
         ],
       });
-    this.addConditionAsNotMigrated();
+    this.addConditionAsRootOrNotOnTree();
     this.addConditionAsNonRootPage();
     this.addConditionToExcludeTrashed();
     await this.addConditionForParentNormalization(user);
@@ -384,7 +384,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionAsNotMigrated(): PageQueryBuilder {
+  addConditionAsRootOrNotOnTree(): PageQueryBuilder {
     this.query = this.query
       .and({ parent: null });
 
@@ -956,177 +956,17 @@ export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike
   grant?: number
+  overwriteScopesOfDescendants?: boolean
 }
 
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
-// remove type for crowi to prevent 'import/no-cycle'
-// eslint-disable-next-line import/no-anonymous-default-export
-export default (crowi): any => {
-  let pageEvent;
-  if (crowi != null) {
-    pageEvent = crowi.event('page');
-  }
-
-  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
-    const isRestricted = grant === GRANT_RESTRICTED;
-    return !isRestricted && (!isV5Compatible || !isOnTree);
-  };
-
-  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
-    pageEvent.emit('update', page, user);
-  };
-
-  /**
-   * A wrapper method of schema.statics.updatePage for updating grant only.
-   * @param {PageDocument} page
-   * @param {UserDocument} user
-   * @param options
-   */
-  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
-    const { grant, grantedGroup } = grantData;
-
-    const options = {
-      grant,
-      grantUserGroupId: grantedGroup,
-      isSyncRevisionToHackmd: false,
-    };
-
-    return this.updatePage(page, null, null, user, options);
-  };
-
-  schema.statics.updatePage = async function(
-      pageData,
-      body: string | null,
-      previousBody: string | null,
-      user,
-      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
-  ) {
-    if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
-      throw Error('Crowi is not set up');
-    }
-
-    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
-    const exParent = pageData.parent;
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    const shouldUseV4Process = shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
-    if (shouldUseV4Process) {
-      // v4 compatible process
-      return this.updatePageV4(pageData, body, previousBody, user, options);
-    }
-
-    const grant = options.grant ?? pageData.grant; // use the previous data if absence
-    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-
-    const grantedUserIds = pageData.grantedUserIds || [user._id];
-    const shouldBeOnTree = grant !== GRANT_RESTRICTED;
-    const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
-
-    const newPageData = pageData;
-
-    if (shouldBeOnTree) {
-      let isGrantNormalized = false;
-      try {
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, pageData.path, grant, grantedUserIds, grantUserGroupId, true);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-
-      if (!wasOnTree) {
-        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
-        newPageData.parent = newParent._id;
-      }
-    }
-    else {
-      if (wasOnTree && isChildrenExist) {
-        // Update children's parent with new parent
-        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
-        await this.updateMany(
-          { parent: pageData._id },
-          { parent: newParentForChildren._id },
-        );
-      }
-
-      newPageData.parent = null;
-      newPageData.descendantCount = 0;
-    }
-
-    newPageData.applyScope(user, grant, grantUserGroupId);
-
-    // update existing page
-    let savedPage = await newPageData.save();
-
-    // Update body
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-    const isBodyPresent = body != null && previousBody != null;
-    const shouldUpdateBody = isBodyPresent;
-    if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-      savedPage = await pushRevision(savedPage, newRevision, user);
-      await savedPage.populateDataToShowRevision();
-
-      if (isSyncRevisionToHackmd) {
-        savedPage = await this.syncRevisionToHackmd(savedPage);
-      }
-    }
-
-
-    this.emitPageEventUpdate(savedPage, user);
-
-    // Update ex children's parent
-    if (!wasOnTree && shouldBeOnTree) {
-      const emptyPageAtSamePath = await this.findOne({ path: pageData.path, isEmpty: true }); // this page is necessary to find children
-
-      if (isChildrenExist) {
-        if (emptyPageAtSamePath != null) {
-          // Update children's parent with new parent
-          await this.updateMany(
-            { parent: emptyPageAtSamePath._id },
-            { parent: savedPage._id },
-          );
-        }
-      }
-
-      await this.findOneAndDelete({ path: pageData.path, isEmpty: true }); // delete here
-    }
-
-    // Sub operation
-    // 1. Update descendantCount
-    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
-    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
-    if (shouldPlusDescCount) {
-      await crowi.pageService.updateDescendantCountOfAncestors(newPageData._id, 1, false);
-      const newDescendantCount = await this.recountDescendantCount(newPageData._id);
-      await this.updateOne({ _id: newPageData._id }, { descendantCount: newDescendantCount });
-    }
-    else if (shouldMinusDescCount) {
-      // Update from parent. Parent is null if newPageData.grant is RESTRECTED.
-      if (newPageData.grant === GRANT_RESTRICTED) {
-        await crowi.pageService.updateDescendantCountOfAncestors(exParent, -1, true);
-      }
-    }
-
-    // 2. Delete unnecessary empty pages
-    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
-    if (shouldRemoveLeafEmpPages) {
-      await this.removeLeafEmptyPagesRecursively(exParent);
-    }
-
-    return savedPage;
-  };
-
+export default function PageModel(crowi): any {
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.statics = { ...pageSchema.statics, ...schema.statics };
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema as any); // TODO: improve type
-};
+}

+ 9 - 0
packages/app/src/server/models/user-group-relation.js

@@ -93,6 +93,15 @@ class UserGroupRelation {
       .exec();
   }
 
+  static async findAllUserIdsForUserGroup(userGroup) {
+    const relations = await this
+      .find({ relatedGroup: userGroup })
+      .select('relatedUser')
+      .exec();
+
+    return relations.map(r => r.relatedUser);
+  }
+
   /**
    * find all user and group relation of UserGroups
    *

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

@@ -109,6 +109,7 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
 };
 
 /**
+ * TODO: use $graphLookup
  * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
  * Set "descendants" as "[]" if the initial groups are unnecessary as result.
  * @param groups UserGroupDocument[] including at least one UserGroup

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

@@ -559,7 +559,7 @@ module.exports = (crowi) => {
     try {
       const shouldUseV4Process = false;
       const grantData = { grant, grantedGroup };
-      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      data = await this.crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     catch (err) {
       logger.error('Error occurred while processing calcApplicableGrantData.', err);

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

@@ -1,4 +1,3 @@
-import { ErrorV3 } from '@growi/core';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
@@ -8,6 +7,8 @@ import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
+import { ErrorV3 } from '@growi/core';
+
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const { pathUtils, pagePathUtils } = require('@growi/core');
 const express = require('express');
@@ -298,7 +299,7 @@ module.exports = (crowi) => {
     // check whether path starts slash
     path = pathUtils.addHeadingSlash(path);
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -323,11 +324,6 @@ module.exports = (crowi) => {
       revision: serializeRevisionSecurely(createdPage.revision),
     };
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
       target: createdPage,
@@ -632,7 +628,11 @@ module.exports = (crowi) => {
     // when all pages are deletable
     else {
       try {
-        const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+        const activityParameters = {
+          ip: req.ip,
+          endpoint: req.originalUrl,
+        };
+        const pages = await crowi.pageService.emptyTrashPage(req.user, options, activityParameters);
 
         activityEvent.emit('update', res.locals.activity._id, parameters);
 

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

@@ -870,7 +870,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
-    const options = {};
+    const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -891,11 +891,6 @@ module.exports = function(crowi, app) {
     };
     res.json(ApiResponse.success(result));
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
-    }
-
     // global notification
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
@@ -1014,7 +1009,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
 
-    const options = { isSyncRevisionToHackmd };
+    const options = { isSyncRevisionToHackmd, overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -1022,7 +1017,7 @@ module.exports = function(crowi, app) {
 
     const previousRevision = await Revision.findById(revisionId);
     try {
-      page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
+      page = await crowi.pageService.updatePage(page, pageBody, previousRevision.body, req.user, options);
     }
     catch (err) {
       logger.error('error on _api/pages.update', err);
@@ -1044,11 +1039,6 @@ module.exports = function(crowi, app) {
     };
     res.json(ApiResponse.success(result));
 
-    // update scopes for descendants
-    if (overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(page, req.user);
-    }
-
     // global notification
     try {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);

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

@@ -159,7 +159,7 @@ module.exports = function(crowi, app) {
       }
 
       const previousRevision = await Revision.findById(revisionId);
-      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
+      result.savedPage = await crowi.pageService.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 

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

@@ -52,7 +52,7 @@ export class InstallerService {
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
     try {
       const markdown = fs.readFileSync(filePath);
-      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, { isSynchronously: true }) as IPage;
     }
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);

+ 187 - 16
packages/app/src/server/service/page-grant.ts

@@ -1,4 +1,7 @@
-import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
+import {
+  pagePathUtils, pathUtils, pageUtils,
+  PageGrant, PageGrantCanBeOnTree,
+} from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
@@ -35,6 +38,39 @@ type ComparableDescendants = {
   grantedGroupIds: ObjectIdLike[],
 };
 
+/**
+ * @param grantedUserGroupInfo This parameter has info to calculate whether the update operation is allowed.
+ *   - See the `calcCanOverwriteDescendants` private method for detail.
+ */
+type UpdateGrantInfo = {
+  grant: typeof PageGrant.GRANT_PUBLIC,
+} | {
+  grant: typeof PageGrant.GRANT_OWNER,
+  grantedUserId: ObjectIdLike,
+} | {
+  grant: typeof PageGrant.GRANT_USER_GROUP,
+  grantedUserGroupInfo: {
+    groupId: ObjectIdLike,
+    userIds: Set<ObjectIdLike>,
+    childrenOrItselfGroupIds: Set<ObjectIdLike>,
+  },
+};
+
+type DescendantPagesGrantInfo = {
+  grantSet: Set<number>,
+  grantedUserIds: Set<ObjectIdLike>, // all only me users of descendant pages
+  grantedUserGroupIds: Set<ObjectIdLike>, // all user groups of descendant pages
+};
+
+/**
+ * @param {ObjectIdLike} userId The _id of the operator.
+ * @param {Set<ObjectIdLike>} userGroupIds The Set of the _id of the user groups that the operator belongs.
+ */
+type OperatorGrantInfo = {
+  userId: ObjectIdLike,
+  userGroupIds: Set<ObjectIdLike>,
+};
+
 class PageGrantService {
 
   crowi!: any;
@@ -260,7 +296,7 @@ class PageGrantService {
    * @param targetPath string of the target path
    * @returns ComparableDescendants
    */
-  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages: boolean): Promise<ComparableDescendants> {
+  private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
@@ -412,22 +448,22 @@ class PageGrantService {
     const isOnlyPublicApplicable = isTopPage(page.path);
     if (isOnlyPublicApplicable) {
       return {
-        [Page.GRANT_PUBLIC]: null,
+        [PageGrant.GRANT_PUBLIC]: null,
       };
     }
 
     // Increment an object (type IRecordApplicableGrant)
     // grant is never public, anyone with the link, nor specified
     const data: IRecordApplicableGrant = {
-      [Page.GRANT_RESTRICTED]: null, // any page can be restricted
+      [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
 
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
       return data;
     }
 
@@ -440,21 +476,21 @@ class PageGrantService {
       grant, grantedUsers, grantedGroup,
     } = parent;
 
-    if (grant === Page.GRANT_PUBLIC) {
-      data[Page.GRANT_PUBLIC] = null;
-      data[Page.GRANT_OWNER] = null;
-      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    if (grant === PageGrant.GRANT_PUBLIC) {
+      data[PageGrant.GRANT_PUBLIC] = null;
+      data[PageGrant.GRANT_OWNER] = null;
+      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
-    else if (grant === Page.GRANT_OWNER) {
+    else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
 
       const isUserApplicable = grantedUser.toString() === user._id.toString();
 
       if (isUserApplicable) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
     }
-    else if (grant === Page.GRANT_USER_GROUP) {
+    else if (grant === PageGrant.GRANT_USER_GROUP) {
       const group = await UserGroup.findById(grantedGroup);
       if (group == null) {
         throw Error('Group not found to calculate grant data.');
@@ -465,14 +501,149 @@ class PageGrantService {
       const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
 
       if (isUserExistInGroup) {
-        data[Page.GRANT_OWNER] = null;
+        data[PageGrant.GRANT_OWNER] = null;
       }
-      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups };
     }
 
     return data;
   }
 
+  /**
+   * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
+   * @param {string} targetPath
+   * @param operator
+   * @param {UpdateGrantInfo} updateGrantInfo
+   * @returns {Promise<boolean>}
+   */
+  async canOverwriteDescendants(targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo): Promise<boolean> {
+    const UserGroupRelationModel = mongoose.model('UserGroupRelation') as any; // TODO: TypeScriptize model
+
+    const relatedGroupIds = await UserGroupRelationModel.findAllUserGroupIdsRelatedToUser(operator);
+    const operatorGrantInfo = {
+      userId: operator._id,
+      userGroupIds: new Set<ObjectIdLike>(relatedGroupIds),
+    };
+
+    const comparableDescendants = await this.generateComparableDescendants(targetPath, operator);
+
+    const grantSet = new Set<PageGrant>();
+    if (comparableDescendants.isPublicExist) {
+      grantSet.add(PageGrant.GRANT_PUBLIC);
+    }
+    if (comparableDescendants.grantedUserIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_OWNER);
+    }
+    if (comparableDescendants.grantedGroupIds.length > 0) {
+      grantSet.add(PageGrant.GRANT_USER_GROUP);
+    }
+    const descendantPagesGrantInfo = {
+      grantSet,
+      grantedUserIds: new Set(comparableDescendants.grantedUserIds), // all only me users of descendant pages
+      grantedUserGroupIds: new Set(comparableDescendants.grantedGroupIds), // all user groups of descendant pages
+    };
+
+    return this.calcCanOverwriteDescendants(operatorGrantInfo, updateGrantInfo, descendantPagesGrantInfo);
+  }
+
+  async generateUpdateGrantInfoToOverwriteDescendants(operator, updateGrant: PageGrantCanBeOnTree, grantUserGroupId?: ObjectIdLike): Promise<UpdateGrantInfo> {
+    let updateGrantInfo: UpdateGrantInfo | null = null;
+
+    if (updateGrant === PageGrant.GRANT_PUBLIC) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_PUBLIC,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_OWNER) {
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_OWNER,
+        grantedUserId: operator._id,
+      };
+    }
+    else if (updateGrant === PageGrant.GRANT_USER_GROUP) {
+      if (grantUserGroupId == null) {
+        throw Error('The parameter `grantUserGroupId` is required.');
+      }
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+      const userIds = await UserGroupRelation.findAllUserIdsForUserGroup(grantUserGroupId);
+      const childrenOrItselfGroups = await UserGroup.findGroupsWithDescendantsById(grantUserGroupId);
+      const childrenOrItselfGroupIds = childrenOrItselfGroups.map(d => d._id);
+
+      updateGrantInfo = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserGroupInfo: {
+          groupId: grantUserGroupId,
+          userIds: new Set<ObjectIdLike>(userIds),
+          childrenOrItselfGroupIds: new Set<ObjectIdLike>(childrenOrItselfGroupIds),
+        },
+      };
+    }
+
+    if (updateGrantInfo == null) {
+      throw Error('The parameter `updateGrant` must be 1, 4, or 5');
+    }
+
+    return updateGrantInfo;
+  }
+
+  private calcIsAllDescendantsGrantedByOperator(operatorGrantInfo: OperatorGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo): boolean {
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_OWNER)) {
+      const isNonApplicableOwnerExist = descendantPagesGrantInfo.grantedUserIds.size >= 2
+        || !isIncludesObjectId([...descendantPagesGrantInfo.grantedUserIds], operatorGrantInfo.userId);
+      if (isNonApplicableOwnerExist) {
+        return false;
+      }
+    }
+
+    if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_USER_GROUP)) {
+      const isNonApplicableGroupExist = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...operatorGrantInfo.userGroupIds],
+      ).length > 0;
+
+      if (isNonApplicableGroupExist) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private calcCanOverwriteDescendants(
+      operatorGrantInfo: OperatorGrantInfo, updateGrantInfo: UpdateGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo,
+  ): boolean {
+    // 1. check is tree GRANTED and it returns true when GRANTED
+    //   - GRANTED is the tree with all pages granted by the operator
+    const isAllDescendantsGranted = this.calcIsAllDescendantsGrantedByOperator(operatorGrantInfo, descendantPagesGrantInfo);
+    if (isAllDescendantsGranted) {
+      return true;
+    }
+
+    // 2. if not 1. then,
+    //   - when update grant is PUBLIC, return true
+    if (updateGrantInfo.grant === PageGrant.GRANT_PUBLIC) {
+      return true;
+    }
+    //   - when update grant is ONLYME, return false
+    if (updateGrantInfo.grant === PageGrant.GRANT_OWNER) {
+      return false;
+    }
+    //   - when update grant is USER_GROUP, return true if meets 2 conditions below
+    //      a. if all descendants user groups are children or itself of update user group
+    //      b. if all descendants grantedUsers belong to update user group
+    if (updateGrantInfo.grant === PageGrant.GRANT_USER_GROUP) {
+      const isAllDescendantGroupsChildrenOrItselfOfUpdateGroup = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserGroupIds], [...updateGrantInfo.grantedUserGroupInfo.childrenOrItselfGroupIds],
+      ).length === 0; // a.
+      const isUpdateGroupUsersIncludeAllDescendantsOwners = excludeTestIdsFromTargetIds(
+        [...descendantPagesGrantInfo.grantedUserIds], [...updateGrantInfo.grantedUserGroupInfo.userIds],
+      ).length === 0; // b.
+
+      return isAllDescendantGroupsChildrenOrItselfOfUpdateGroup && isUpdateGroupUsersIncludeAllDescendantsOwners;
+    }
+
+    return false;
+  }
+
 }
 
 export default PageGrantService;

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

@@ -16,6 +16,7 @@ const {
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
+  Create, Update,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 
@@ -29,7 +30,7 @@ class PageOperationService {
 
   async init(): Promise<void> {
     // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
-    const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
+    const types = [Create, Update, Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }

+ 394 - 16
packages/app/src/server/service/page.ts

@@ -3,8 +3,8 @@ import { Readable, Writable } from 'stream';
 
 import {
   pagePathUtils, pathUtils, Ref, HasObjectId,
-  IUserHasId,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
+  IUserHasId, PageStatus,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, PageGrant,
 } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
@@ -28,6 +28,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
+import { IOptionsForCreate, IOptionsForUpdate } from '../models/interfaces/page-operation';
 import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
@@ -340,7 +341,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
 
     const builder = new PageQueryBuilder(Page.find(), true)
-      .addConditionAsNotMigrated() // to avoid affecting v5 pages
+      .addConditionAsRootOrNotOnTree() // to avoid affecting v5 pages
       .addConditionToListOnlyDescendants(targetPagePath);
 
     await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
@@ -1856,8 +1857,28 @@ class PageService {
     return;
   }
 
-  async emptyTrashPage(user, options = {}) {
-    return this.deleteCompletelyDescendantsWithStream({ path: '/trash' }, user, options);
+  async emptyTrashPage(user, options = {}, activityParameters) {
+    const page = { path: '/trash' };
+
+    const parameters = {
+      ...activityParameters,
+      action: SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+      user,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
+    const descendantsSubscribedSets = new Set();
+    const pages = await this.deleteCompletelyDescendantsWithStream(page, user, options, true, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+
+    return pages;
   }
 
   /**
@@ -2376,7 +2397,7 @@ class PageService {
 
     // This validation is not 100% correct since it ignores user to count
     const builder = new PageQueryBuilder(Page.find());
-    builder.addConditionAsNotMigrated();
+    builder.addConditionAsRootOrNotOnTree();
     builder.addConditionToListWithDescendants(path);
     const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
     if (nEstimatedNormalizationTarget === 0) {
@@ -3406,6 +3427,21 @@ class PageService {
     pageDocument.status = Page.STATUS_PUBLISHED;
   }
 
+  private async validateAppliedScope(user, grant, grantUserGroupId) {
+    if (grant === PageGrant.GRANT_USER_GROUP && grantUserGroupId == null) {
+      throw new Error('grant userGroupId is not specified');
+    }
+
+    if (grant === PageGrant.GRANT_USER_GROUP) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
+
+      if (count === 0) {
+        throw new Error('no relations were exist for group and user.');
+      }
+    }
+  }
+
   private async canProcessCreate(
       path: string,
       grantData: {
@@ -3415,6 +3451,7 @@ class PageService {
       },
       shouldValidateGrant: boolean,
       user?,
+      options?: Partial<PageCreateOptions>,
   ): Promise<boolean> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -3443,7 +3480,7 @@ class PageService {
       try {
         // It must check descendants as well if emptyTarget is not null
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
-        const shouldCheckDescendants = isEmptyPageAlreadyExist;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
         isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
       }
@@ -3454,18 +3491,31 @@ class PageService {
       if (!isGrantNormalized) {
         throw Error('The selected grant or grantedGroup is not assignable to this page.');
       }
+
+      if (options?.overwriteScopesOfDescendants) {
+        const updateGrantInfo = await this.crowi.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const canOverwriteDescendants = await this.crowi.pageGrantService.canOverwriteDescendants(path, user, updateGrantInfo);
+
+        if (!canOverwriteDescendants) {
+          throw Error('Cannot overwrite scopes of descendants.');
+        }
+      }
     }
 
     return true;
   }
 
-  async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
+  /**
+   * Create a page
+   * Set options.isSynchronously to true to await all process when you want to run this method multiple times at short intervals.
+   */
+  async create(path: string, body: string, user, options: IOptionsForCreate = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
-      return Page.createV4(path, body, user, options);
+      return this.createV4(path, body, user, options);
     }
 
     // Values
@@ -3485,7 +3535,7 @@ class PageService {
 
     // Validate
     const shouldValidateGrant = !isGrantRestricted;
-    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user);
+    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user, options);
     if (!canProcessCreate) {
       throw Error('Cannnot process create');
     }
@@ -3517,23 +3567,113 @@ class PageService {
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
-    // Update descendantCount
-    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
     // Emit create event
     this.pageEvent.emit('create', savedPage, user);
 
-    // Delete PageRedirect if exists
+    // Directly run sub operation for now since it might be complex to handle main operation for creating pages -- Taichi Masuyama 2022.11.08
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.Create,
+        actionStage: PageActionStage.Sub,
+        page: savedPage,
+        user,
+        fromPath: path,
+        options,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    if (options.isSynchronously) {
+      await this.createSubOperation(savedPage, user, options, pageOp._id);
+    }
+    else {
+      this.createSubOperation(savedPage, user, options, pageOp._id);
+    }
+
+    return savedPage;
+  }
+
+  /**
+   * Used to run sub operation in create method
+   */
+  async createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
     const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(page._id, 1, false);
+
+    // Delete PageRedirect if exists
     try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+      await PageRedirect.deleteOne({ fromPath: page.path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${page.path}".`);
     }
     catch (err) {
       // no throw
       logger.error('Failed to delete PageRedirect');
     }
 
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      await Page.applyScopesToDescendantsAsyncronously(page, user);
+    }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
+  }
+
+  /**
+   * V4 compatible create method
+   */
+  private async createV4(path, body, user, options: any = {}) {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
+
+    const format = options.format || 'markdown';
+    const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+    // sanitize path
+    path = this.crowi.xss.process(path); // eslint-disable-line no-param-reassign
+
+    let grant = options.grant;
+    // force public
+    if (isTopPage(path)) {
+      grant = PageGrant.GRANT_PUBLIC;
+    }
+
+    const isExist = await Page.count({ path });
+
+    if (isExist) {
+      throw new Error('Cannot create new page to existed path');
+    }
+
+    const page = new Page();
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.status = PageStatus.STATUS_PUBLISHED;
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
+    await this.validateAppliedScope(user, grant, grantUserGroupId);
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    this.pageEvent.emit('create', savedPage, user);
+
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+    }
+
     return savedPage;
   }
 
@@ -3628,6 +3768,244 @@ class PageService {
     return savedPage;
   }
 
+  private shouldUseUpdatePageV4(grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean {
+    const isRestricted = grant === PageGrant.GRANT_RESTRICTED;
+    return !isRestricted && (!isV5Compatible || !isOnTree);
+  }
+
+  /**
+   * A wrapper method of updatePage for updating grant only.
+   * @param {PageDocument} page
+   * @param {UserDocument} user
+   * @param options
+   */
+  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}): Promise<PageDocument> {
+    const { grant, grantedGroup } = grantData;
+
+    const options = {
+      grant,
+      grantUserGroupId: grantedGroup,
+      isSyncRevisionToHackmd: false,
+    };
+
+    return this.updatePage(page, null, null, user, options);
+  }
+
+  async updatePageSubOperation(page, user, exPage, options: IOptionsForUpdate, pageOpId: ObjectIdLike): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const currentPage = page;
+
+    const exParent = exPage.parent;
+    const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
+    const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
+    const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(currentPage.path))}`), parent: { $ne: null } });
+
+    // 1. Update descendantCount
+    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
+    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
+    if (shouldPlusDescCount) {
+      await this.updateDescendantCountOfAncestors(currentPage._id, 1, false);
+      const newDescendantCount = await Page.recountDescendantCount(currentPage._id);
+      await Page.updateOne({ _id: currentPage._id }, { descendantCount: newDescendantCount });
+    }
+    else if (shouldMinusDescCount) {
+      // Update from parent. Parent is null if currentPage.grant is RESTRECTED.
+      if (currentPage.grant === PageGrant.GRANT_RESTRICTED) {
+        await this.updateDescendantCountOfAncestors(exParent, -1, true);
+      }
+    }
+
+    // 2. Delete unnecessary empty pages
+    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
+    if (shouldRemoveLeafEmpPages) {
+      await Page.removeLeafEmptyPagesRecursively(exParent);
+    }
+
+    // 3. Update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      await Page.applyScopesToDescendantsAsyncronously(currentPage, user);
+    }
+
+    await PageOperation.findByIdAndDelete(pageOpId);
+  }
+
+  async updatePage(
+      pageData,
+      body: string | null,
+      previousBody: string | null,
+      user,
+      options: IOptionsForUpdate = {},
+  ): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+
+    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    const shouldUseV4Process = this.shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
+    if (shouldUseV4Process) {
+      // v4 compatible process
+      return this.updatePageV4(pageData, body, previousBody, user, options);
+    }
+
+    // Clone page document
+    const clonedPageData = Page.hydrate(pageData.toObject());
+    const newPageData = pageData;
+
+    const grant = options.grant ?? clonedPageData.grant; // use the previous data if absence
+    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? clonedPageData.grantedGroup?._id.toString();
+
+    const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
+    const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
+    const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(clonedPageData.path))}`), parent: { $ne: null } });
+
+    const { pageService, pageGrantService } = this.crowi;
+
+    if (shouldBeOnTree) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = !options.overwriteScopesOfDescendants;
+        // eslint-disable-next-line max-len
+        isGrantNormalized = await pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${clonedPageData.path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+
+      if (options.overwriteScopesOfDescendants) {
+        const updateGrantInfo = await pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const canOverwriteDescendants = await pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
+
+        if (!canOverwriteDescendants) {
+          throw Error('Cannot overwrite scopes of descendants.');
+        }
+      }
+
+      if (!wasOnTree) {
+        const newParent = await pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
+        newPageData.parent = newParent._id;
+      }
+    }
+    else {
+      if (wasOnTree && isChildrenExist) {
+        // Update children's parent with new parent
+        const newParentForChildren = await Page.createEmptyPage(clonedPageData.path, clonedPageData.parent, clonedPageData.descendantCount);
+        await Page.updateMany(
+          { parent: clonedPageData._id },
+          { parent: newParentForChildren._id },
+        );
+      }
+
+      newPageData.parent = null;
+      newPageData.descendantCount = 0;
+    }
+
+    newPageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await newPageData.save();
+
+    // Update body
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await Page.syncRevisionToHackmd(savedPage);
+      }
+    }
+
+
+    this.pageEvent.emit('update', savedPage, user);
+
+    // Update ex children's parent
+    if (!wasOnTree && shouldBeOnTree) {
+      const emptyPageAtSamePath = await Page.findOne({ path: clonedPageData.path, isEmpty: true }); // this page is necessary to find children
+
+      if (isChildrenExist) {
+        if (emptyPageAtSamePath != null) {
+          // Update children's parent with new parent
+          await Page.updateMany(
+            { parent: emptyPageAtSamePath._id },
+            { parent: savedPage._id },
+          );
+        }
+      }
+
+      await Page.findOneAndDelete({ path: clonedPageData.path, isEmpty: true }); // delete here
+    }
+
+    // Directly run sub operation for now since it might be complex to handle main operation for updating pages -- Taichi Masuyama 2022.11.08
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.Update,
+        actionStage: PageActionStage.Sub,
+        page: savedPage,
+        exPage: clonedPageData,
+        user,
+        fromPath: clonedPageData.path,
+        options,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    this.updatePageSubOperation(savedPage, user, clonedPageData, options, pageOp._id);
+
+    return savedPage;
+  }
+
+
+  async updatePageV4(pageData, body, previousBody, user, options: any = {}): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
+
+    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+
+    await this.validateAppliedScope(user, grant, grantUserGroupId);
+    pageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await pageData.save();
+
+    // Update revision
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await Page.syncRevisionToHackmd(savedPage);
+      }
+    }
+
+    // update scopes for descendants
+    if (options.overwriteScopesOfDescendants) {
+      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+    }
+
+
+    this.pageEvent.emit('update', savedPage, user);
+
+    return savedPage;
+  }
+
   /*
    * Find all children by parent's path or id. Using id should be prioritized
    */

+ 4 - 8
packages/app/src/stores/context.tsx

@@ -1,6 +1,6 @@
 import { IUser, pagePathUtils } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
-import { Key, SWRResponse } from 'swr';
+import { Key, SWRResponse, useSWRConfig } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -48,16 +48,12 @@ export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nulla
   return useContextSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };
 
-export const useCurrentRevisionId = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('currentRevisionId', initialData);
-};
-
 export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {
   return useContextSWR('currentPathname', initialData);
 };
 
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useContextSWR<Nullable<string>, Error>('currentPageId', initialData);
+  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
@@ -100,7 +96,7 @@ export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullab
   return useContextSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };
 
-export const useIsSearchPage = (initialData?: Nullable<any>) : SWRResponse<Nullable<any>, Error> => {
+export const useIsSearchPage = (initialData?: Nullable<boolean>) : SWRResponse<Nullable<boolean>, Error> => {
   return useContextSWR<Nullable<any>, Error>('isSearchPage', initialData);
 };
 
@@ -125,7 +121,7 @@ export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, an
 };
 
 export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData);
+  return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData, { fallbackData: false });
 };
 
 export const useIsSlackConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {

+ 2 - 2
packages/app/src/stores/editor.tsx

@@ -1,5 +1,5 @@
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import useSWR, { MutatorOptions, SWRResponse, useSWRConfig } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
@@ -115,7 +115,7 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 };
 
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning', undefined, { fallbackData: false });
+  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
 };
 
 export const useIsConflict = (): SWRResponse<boolean, Error> => {

+ 31 - 8
packages/app/src/stores/page.tsx

@@ -1,7 +1,9 @@
+import { useEffect } from 'react';
+
 import type {
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable,
 } from '@growi/core';
-import { pagePathUtils } from '@growi/core';
+import { isClient, pagePathUtils } from '@growi/core';
 import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
@@ -15,8 +17,8 @@ import { IRevisionsForPagination } from '~/interfaces/revision';
 
 import { IPageTagsInfo } from '../interfaces/tag';
 
-import { useCurrentPageId, useCurrentPathname, useCurrentRevisionId } from './context';
-import { useStaticSWR } from './use-static-swr';
+import { useCurrentPageId, useCurrentPathname } from './context';
+import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
 const { isPermalink: _isPermalink } = pagePathUtils;
 
@@ -27,7 +29,7 @@ export const useSWRxPage = (
     revisionId?: string,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
-  return useSWR<IPagePopulatedToShowRevision|null, Error>(
+  const swrResponse = useSWR<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .then(result => result.data.page)
@@ -40,8 +42,16 @@ export const useSWRxPage = (
         }
         throw Error('failed to get page');
       }),
-    { fallbackData: initialData },
   );
+
+  useEffect(() => {
+    if (initialData !== undefined) {
+      swrResponse.mutate(initialData);
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [initialData]); // Only depends on `initialData`
+
+  return swrResponse;
 };
 
 export const useSWRxPageByPath = (path?: string): SWRResponse<IPagePopulatedToShowRevision, Error> => {
@@ -55,9 +65,16 @@ export const useSWRxCurrentPage = (
     shareLinkId?: string, initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
   const { data: currentPageId } = useCurrentPageId();
-  const { data: currentRevisionId } = useCurrentRevisionId();
 
-  const swrResult = useSWRxPage(currentPageId, shareLinkId, currentRevisionId, initialData);
+  // Get URL parameter for specific revisionId
+  let revisionId: string|undefined;
+  if (isClient()) {
+    const urlParams = new URLSearchParams(window.location.search);
+    const requestRevisionId = urlParams.get('revisionId');
+    revisionId = requestRevisionId != null ? requestRevisionId : undefined;
+  }
+
+  const swrResult = useSWRxPage(currentPageId, shareLinkId, revisionId, initialData);
 
   return swrResult;
 };
@@ -82,17 +99,23 @@ export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTags
   return useSWRImmutable(key, fetcher);
 };
 
+export const usePageInfoTermManager = (isDisabled?: boolean) : SWRResponse<number, Error> & ITermNumberManagerUtil => {
+  return useTermNumberManager(isDisabled === true ? null : 'pageInfoTermNumber');
+};
+
 export const useSWRxPageInfo = (
     pageId: string | null | undefined,
     shareLinkId?: string | null,
     initialData?: IPageInfoForEntity,
 ): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
 
+  const { data: termNumber } = usePageInfoTermManager();
+
   // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
   const fixedShareLinkId = shareLinkId ?? null;
 
   const swrResult = useSWRImmutable<IPageInfo | IPageInfoForOperation, Error>(
-    pageId != null ? ['/page/info', pageId, fixedShareLinkId] : null,
+    pageId != null && termNumber != null ? ['/page/info', pageId, fixedShareLinkId, termNumber] : null,
     (endpoint, pageId, shareLinkId) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
     { fallbackData: initialData },
   );

+ 5 - 6
packages/app/src/stores/ui.tsx

@@ -23,7 +23,7 @@ import loggerFactory from '~/utils/logger';
 
 import {
   useCurrentPageId, useIsEditable, useIsGuestUser,
-  useIsSharedUser, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
+  useIsSharedUser, useIsIdenticalPath, useCurrentUser, useShareLinkId, useIsNotFound,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useCurrentPagePath, useIsTrashPage } from './page';
@@ -97,17 +97,17 @@ const getClassNamesByEditorMode = (editorMode: EditorMode | undefined, isSidebar
 };
 
 const updateHashByEditorMode = (newEditorMode: EditorMode) => {
-  const { pathname } = window.location;
+  const { pathname, search } = window.location;
 
   switch (newEditorMode) {
     case EditorMode.View:
-      window.history.replaceState(null, '', pathname);
+      window.history.replaceState(null, '', `${pathname}${search}`);
       break;
     case EditorMode.Editor:
-      window.history.replaceState(null, '', `${pathname}#edit`);
+      window.history.replaceState(null, '', `${pathname}${search}#edit`);
       break;
     case EditorMode.HackMD:
-      window.history.replaceState(null, '', `${pathname}#hackmd`);
+      window.history.replaceState(null, '', `${pathname}${search}#hackmd`);
       break;
   }
 };
@@ -396,7 +396,6 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
   };
 };
 
-
 /** **********************************************************
  *                          SWR Hooks
  *                Determined value by context

+ 16 - 3
packages/app/src/stores/use-context-swr.tsx

@@ -1,8 +1,10 @@
+import assert from 'assert';
+
 import {
-  Key, SWRConfiguration, SWRResponse,
+  Key, SWRConfiguration, SWRResponse, useSWRConfig,
 } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
-import { useStaticSWR } from './use-static-swr';
 
 export function useContextSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
 export function useContextSWR<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
@@ -16,7 +18,18 @@ export function useContextSWR<Data, Error>(
 ): SWRResponse<Data, Error> {
   const [key, data, configuration] = args;
 
-  const swrResponse = useStaticSWR<Data, Error>(key, data, configuration);
+  assert.notStrictEqual(configuration?.fetcher, null, 'useContextSWR does not support \'configuration.fetcher\'');
+
+  const { cache } = useSWRConfig();
+  const swrResponse = useSWRImmutable(key, null, {
+    ...configuration,
+    fallbackData: configuration?.fallbackData ?? cache.get(key),
+  });
+
+  // write data to cache directly
+  if (data !== undefined) {
+    cache.set(key, data);
+  }
 
   const result = Object.assign(swrResponse, { mutate: () => { throw Error('mutate can not be used in context') } });
 

+ 11 - 10
packages/app/src/stores/use-static-swr.tsx

@@ -1,7 +1,9 @@
+import { useEffect } from 'react';
+
 import assert from 'assert';
 
 import {
-  Key, SWRConfiguration, SWRResponse, useSWRConfig,
+  Key, SWRConfiguration, SWRResponse,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
@@ -20,16 +22,15 @@ export function useStaticSWR<Data, Error>(
 
   assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
 
-  const { cache } = useSWRConfig();
-  const swrResponse = useSWRImmutable(key, null, {
-    ...configuration,
-    fallbackData: configuration?.fallbackData ?? cache.get(key),
-  });
+  const swrResponse = useSWRImmutable(key, null, configuration);
 
-  // write data to cache directly
-  if (data !== undefined) {
-    cache.set(key, data);
-  }
+  // Do mutate with `data` from args
+  useEffect(() => {
+    if (data !== undefined) {
+      swrResponse.mutate(data);
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [data]); // Only depends on `data`
 
   return swrResponse;
 }

+ 6 - 2
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -19,6 +19,9 @@ context('Access to page', () => {
     cy.visit('/Sandbox#Headers');
     cy.waitUntilSkeletonDisappear();
 
+    // for check download toc data
+    cy.get('.toc-link').should('be.visible');
+
     // hide fab // disable fab for sticky-events warning
     // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
@@ -32,9 +35,10 @@ context('Access to page', () => {
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
+    cy.waitUntilSkeletonDisappear();
 
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(2000); // wait for 2 seconds for MathJax.typesetPromise();
+    // for check download toc data
+    cy.get('.toc-link').should('be.visible');
 
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });

+ 58 - 23
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -80,21 +80,28 @@ context('Modal for page operation', () => {
 
   it('Trying to create template page under the root page fail', () => {
     cy.visit('/');
+
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+
+      cy.getByTestid('grw-page-create-modal-path-name').should('have.text', '/');
+
       cy.get('#template-type').click();
       cy.get('#template-type').next().find('button:eq(0)').click({force: true});
-      cy.get('#dd-template-type').next().find('button').click({force: true});
+      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.screenshot(`${ssPrefix}create-template-for-children-error`, {capture: 'viewport'});
     cy.get('.toast-error').should('be.visible').click();
+    cy.get('.toast-error').should('not.exist');
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
       cy.get('#template-type').click();
       cy.get('#template-type').next().find('button:eq(1)').click({force: true});
-      cy.get('#dd-template-type').next().find('button').click({force: true});
+      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.screenshot(`${ssPrefix}create-template-for-descendants-error`, {capture: 'viewport'});
@@ -231,9 +238,13 @@ context('Tag Oprations', () =>{
   it('Successfully add new tag', () => {
     const ssPrefix = 'tag-operations-add-new-tag-'
     const tag = 'we';
-    cy.visit('/');
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
 
-    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').click({force: true});
+    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').should('be.visible');
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(200);
+    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').click();
     cy.get('#edit-tag-modal').should('be.visible').screenshot(`${ssPrefix}1-edit-tag-input`);
 
     cy.get('#edit-tag-modal').within(() => {
@@ -266,8 +277,16 @@ context('Tag Oprations', () =>{
     const ssPrefix = 'tag-operations-page-duplicate-';
     const tag = 'we';
     const newPageName = 'our';
-    cy.visit('/');
-    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).click();
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+
+    cy.get('.grw-taglabels-container').within(()=>{
+      cy.get('.grw-tag-labels').within(()=>{
+        cy.get('a').then(($el)=>{
+          cy.wrap($el).contains(tag).click();
+        });
+      });
+    });
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
@@ -275,34 +294,44 @@ context('Tag Oprations', () =>{
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
     cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+    cy.getByTestid('search-result-list').should('be.visible').then(($el)=>{
+      cy.wrap($el).within(()=>{
+        cy.getByTestid('open-page-item-control-btn').first().click();
+      });
 
-    cy.getByTestid('open-page-item-control-btn').first().click({force: true});
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1500); // for wait rendering pagelist info
-    cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
+      // eslint-disable-next-line cypress/no-unnecessary-waiting
+      cy.wait(1500); // for wait rendering pagelist info
+      cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
 
-    cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
-    cy.getByTestid('page-duplicate-modal').should('be.visible');
-    cy.getByTestid('page-duplicate-modal').within(() => {
-      cy.get('.rbt-input-main').type(newPageName, {force: true});
+      cy.wrap($el).within(()=>{
+        cy.getByTestid('open-page-item-control-btn').first().within(()=>{
+          cy.getByTestid('open-page-duplicate-modal-btn').click();
+        })
+      });
+    })
+
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').type(`-${newPageName}`, {force: true});
     }).screenshot(`${ssPrefix}3-duplicate-page`, {capture: 'viewport'});
 
     cy.getByTestid('page-duplicate-modal').within(() => {
       cy.get('.modal-footer > button.btn').click();
     });
-    cy.visit(`/${newPageName}`);
-    // cy.getByTestid('wiki').should('exist');
+    cy.visit(`Sandbox-${newPageName}`);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
   });
 
   it('Successfully rename page from generated tag', () => {
     const ssPrefix = 'tag-operations-page-rename-';
     const tag = 'we';
-    const oldPageName = '/our';
-    const newPageName = '/ourus';
+    const oldPageName = '/Sandbox-our';
+    const newPageName = '/Sandbox-us';
 
-    cy.visit('/');
-    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).click();
+    cy.visit('/Sandbox-our');
+    cy.waitUntilSkeletonDisappear();
+    cy.get('.grw-tag-label').should('be.visible').contains(tag).click();
+    cy.waitUntilSkeletonDisappear();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
@@ -315,9 +344,13 @@ context('Tag Oprations', () =>{
         if($row.find('a').text() === oldPageName){
           cy.wrap($row).within(() => {
             cy.getByTestid('open-page-item-control-btn').first().click();
-            cy.getByTestid('page-item-control-menu').should('have.class', 'show').first().within(() => {
+            cy.getByTestid('page-item-control-menu').should('have.class', 'show').then(() => {
               // empty sentence in page list empty: https://github.com/weseek/growi/pull/6880
               cy.getByTestid('revision-short-body-in-page-list-item-L').invoke('text', '');
+            });
+
+            cy.getByTestid('page-item-control-menu').within(()=>{
+              cy.getByTestid('open-page-delete-modal-btn');
               cy.screenshot(`${ssPrefix}2-open-page-item-control-menu`);
             })
           });
@@ -343,8 +376,10 @@ context('Tag Oprations', () =>{
       cy.get('.modal-footer > button').click();
     });
 
-    cy.visit(`/${newPageName}`);
-    cy.getByTestid('grw-tag-labels').should('be.visible');
+    cy.visit(`${newPageName}`);
+    cy.waitUntilSkeletonDisappear();
+
+    cy.getByTestid('grw-tag-labels').should('be.visible')
     cy.screenshot(`${ssPrefix}4-new-page-name-applied`, {capture: 'viewport'});
   });
 

+ 1 - 0
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -11,6 +11,7 @@ context('Access to page by guest', () => {
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
+    cy.getByTestid('grw-pagetree-item-container').should('be.visible');
     cy.collapseSidebar(true, true);
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting

+ 1 - 0
packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts

@@ -45,6 +45,7 @@ context('Access to Admin page', () => {
   it('/admin/markdown is successfully loaded', () => {
     cy.visit('/admin/markdown');
     cy.getByTestid('admin-markdown').should('be.visible');
+    cy.get('#isEnabledLinebreaksInComments').should('be.checked')
     cy.screenshot(`${ssPrefix}-admin-markdown`);
   });
 

+ 554 - 68
packages/app/test/integration/models/v5.page.test.js

@@ -1,10 +1,14 @@
 import mongoose from 'mongoose';
 
+import { PageGrant } from '~/interfaces/page';
 
 import { getInstance } from '../setup-crowi';
 
 describe('Page', () => {
   let crowi;
+  let pageGrantService;
+  let pageService;
+
   let Page;
   let Revision;
   let User;
@@ -22,13 +26,313 @@ describe('Page', () => {
   let pModelUser1;
   let pModelUser2;
   let pModelUser3;
-  let groupIdIsolate;
-  let groupIdA;
-  let groupIdB;
-  let groupIdC;
+  let userGroupIdPModelIsolate;
+  let userGroupIdPModelA;
+  let userGroupIdPModelB;
+  let userGroupIdPModelC;
+
+  // To test updatePage overwriting descendants (prefix `upod`)
+  let upodUserA;
+  let upodUserB;
+  let upodUserC;
+  let upodGroupAB;
+  let upodGroupA;
+  let upodGroupAIsolated;
+  let upodGroupB;
+  let upodGroupC;
+  const upodUserGroupIdA = new mongoose.Types.ObjectId();
+  const upodUserGroupIdAIsolated = new mongoose.Types.ObjectId();
+  const upodUserGroupIdB = new mongoose.Types.ObjectId();
+  const upodUserGroupIdC = new mongoose.Types.ObjectId();
+  const upodUserGroupIdAB = new mongoose.Types.ObjectId();
+  const upodPageIdgAB1 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic2 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic3 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic4 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic5 = new mongoose.Types.ObjectId();
+  const upodPageIdPublic6 = new mongoose.Types.ObjectId();
+
+  const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
+    const mockedUpdatePageSubOperation = jest.spyOn(pageService, 'updatePageSubOperation').mockReturnValue(null);
+
+    const savedPage = await pageService.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
+
+    const argsForUpdatePageSubOperation = mockedUpdatePageSubOperation.mock.calls[0];
+
+    mockedUpdatePageSubOperation.mockRestore();
+
+    await pageService.updatePageSubOperation(...argsForUpdatePageSubOperation);
+
+    return savedPage;
+  };
+
+  const createDocumentsToTestUpdatePageOverwritingDescendants = async() => {
+    // Users
+    await User.insertMany([
+      { name: 'upodUserA', username: 'upodUserA', email: 'upoduserA@example.com' },
+      { name: 'upodUserB', username: 'upodUserB', email: 'upoduserB@example.com' },
+      { name: 'upodUserC', username: 'upodUserC', email: 'upodUserC@example.com' },
+    ]);
+
+    upodUserA = await User.findOne({ username: 'upodUserA' });
+    upodUserB = await User.findOne({ username: 'upodUserB' });
+    upodUserC = await User.findOne({ username: 'upodUserC' });
+
+    await UserGroup.insertMany([
+      {
+        _id: upodUserGroupIdAB,
+        name: 'upodGroupAB',
+        parent: null,
+      },
+      {
+        _id: upodUserGroupIdA,
+        name: 'upodGroupA',
+        parent: upodUserGroupIdAB,
+      },
+      {
+        _id: upodUserGroupIdAIsolated,
+        name: 'upodGroupAIsolated',
+        parent: null,
+      },
+      {
+        _id: upodUserGroupIdB,
+        name: 'upodGroupB',
+        parent: upodUserGroupIdAB,
+      },
+      {
+        _id: upodUserGroupIdC,
+        name: 'upodGroupC',
+        parent: null,
+      },
+    ]);
+
+    upodGroupAB = await UserGroup.findOne({ name: 'upodGroupAB' });
+    upodGroupA = await UserGroup.findOne({ name: 'upodGroupA' });
+    upodGroupAIsolated = await UserGroup.findOne({ name: 'upodGroupAIsolated' });
+    upodGroupB = await UserGroup.findOne({ name: 'upodGroupB' });
+    upodGroupC = await UserGroup.findOne({ name: 'upodGroupC' });
+
+    // UserGroupRelations
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: upodUserGroupIdAB,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdAB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdA,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdAIsolated,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodUserGroupIdC,
+        relatedUser: upodUserC._id,
+      },
+    ]);
+
+    // Pages
+    await Page.insertMany([
+      // case 1
+      {
+        _id: upodPageIdgAB1,
+        path: '/gAB_upod_1', // to GRANT_PUBLIC
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAB,
+        parent: rootPage._id,
+      },
+      {
+        path: '/gAB_upod_1/gB_upod_1',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdB,
+        parent: upodPageIdgAB1,
+      },
+      {
+        path: '/gAB_upod_1/onlyB_upod_1',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: [upodUserB._id],
+        grantedGroup: null,
+        parent: upodPageIdgAB1,
+      },
+      // case 2
+      {
+        _id: upodPageIdPublic2,
+        path: '/public_upod_2', // to Anything
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_2/gA_upod_2',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic2,
+      },
+      {
+        path: '/public_upod_2/gAIsolated_upod_2',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAIsolated,
+        parent: upodPageIdPublic2,
+      },
+      {
+        path: '/public_upod_2/onlyA_upod_2',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: [upodUserA._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic2,
+      },
+      // case 3
+      {
+        _id: upodPageIdPublic3,
+        path: '/public_upod_3', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_3/gAB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdAB,
+        parent: upodPageIdPublic3,
+      },
+      {
+        path: '/public_upod_3/gB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdB,
+        parent: upodPageIdPublic3,
+      },
+      {
+        path: '/public_upod_3/onlyB_upod_3',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserB,
+        lastUpdateUser: upodUserB,
+        grantedUsers: [upodUserB._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic3,
+      },
+      // case 4
+      {
+        _id: upodPageIdPublic4,
+        path: '/public_upod_4', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_4/gA_upod_4',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic4,
+      },
+      {
+        path: '/public_upod_4/gC_upod_4',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdC,
+        parent: upodPageIdPublic4,
+      },
+      // case 5
+      {
+        _id: upodPageIdPublic5,
+        path: '/public_upod_5', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_5/gA_upod_5',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: upodUserGroupIdA,
+        parent: upodPageIdPublic5,
+      },
+      {
+        path: '/public_upod_5/onlyC_upod_5',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: [upodUserC._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic5,
+      },
+      // case 6
+      {
+        _id: upodPageIdPublic6,
+        path: '/public_upod_6', // to GRANT_USER_GROUP with upodGroupAB
+        grant: PageGrant.GRANT_PUBLIC,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroup: null,
+        parent: rootPage._id,
+      },
+      {
+        path: '/public_upod_6/onlyC_upod_6',
+        grant: PageGrant.GRANT_OWNER,
+        creator: upodUserC,
+        lastUpdateUser: upodUserC,
+        grantedUsers: [upodUserC._id],
+        grantedGroup: null,
+        parent: upodPageIdPublic6,
+      },
+    ]);
+  };
 
   beforeAll(async() => {
     crowi = await getInstance();
+    pageGrantService = crowi.pageGrantService;
+    pageService = crowi.pageService;
+
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
     jest.restoreAllMocks();
@@ -75,69 +379,69 @@ describe('Page', () => {
     pModelUser3 = await User.findOne({ _id: pModelUserId3 });
 
 
-    groupIdIsolate = new mongoose.Types.ObjectId();
-    groupIdA = new mongoose.Types.ObjectId();
-    groupIdB = new mongoose.Types.ObjectId();
-    groupIdC = new mongoose.Types.ObjectId();
+    userGroupIdPModelIsolate = new mongoose.Types.ObjectId();
+    userGroupIdPModelA = new mongoose.Types.ObjectId();
+    userGroupIdPModelB = new mongoose.Types.ObjectId();
+    userGroupIdPModelC = new mongoose.Types.ObjectId();
     await UserGroup.insertMany([
       {
-        _id: groupIdIsolate,
+        _id: userGroupIdPModelIsolate,
         name: 'pModel_groupIsolate',
       },
       {
-        _id: groupIdA,
+        _id: userGroupIdPModelA,
         name: 'pModel_groupA',
       },
       {
-        _id: groupIdB,
+        _id: userGroupIdPModelB,
         name: 'pModel_groupB',
-        parent: groupIdA,
+        parent: userGroupIdPModelA,
       },
       {
-        _id: groupIdC,
+        _id: userGroupIdPModelC,
         name: 'pModel_groupC',
-        parent: groupIdB,
+        parent: userGroupIdPModelB,
       },
     ]);
 
     await UserGroupRelation.insertMany([
       {
-        relatedGroup: groupIdIsolate,
+        relatedGroup: userGroupIdPModelIsolate,
         relatedUser: pModelUserId1,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdIsolate,
+        relatedGroup: userGroupIdPModelIsolate,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId1,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdA,
+        relatedGroup: userGroupIdPModelA,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdB,
+        relatedGroup: userGroupIdPModelB,
         relatedUser: pModelUserId2,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdB,
+        relatedGroup: userGroupIdPModelB,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
       },
       {
-        relatedGroup: groupIdC,
+        relatedGroup: userGroupIdPModelC,
         relatedUser: pModelUserId3,
         createdAt: new Date(),
       },
@@ -307,7 +611,7 @@ describe('Page', () => {
       {
         path: '/mup20',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -335,7 +639,7 @@ describe('Page', () => {
       {
         path: '/mup22/mup23',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -393,7 +697,7 @@ describe('Page', () => {
         _id: pageIdUpd16,
         path: '/mup29_A',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -414,7 +718,7 @@ describe('Page', () => {
         _id: pageIdUpd17,
         path: '/mup31_A',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroup: userGroupIdPModelA,
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -435,7 +739,7 @@ describe('Page', () => {
         _id: pageIdUpd18,
         path: '/mup33_C',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroup: userGroupIdPModelC,
         creator: pModelUserId3,
         lastUpdateUser: pModelUserId3,
         isEmpty: false,
@@ -485,17 +789,10 @@ describe('Page', () => {
       },
     ]);
 
+    await createDocumentsToTestUpdatePageOverwritingDescendants();
   });
 
   describe('update', () => {
-
-    const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
-      const mockedRenameSubOperation = jest.spyOn(Page, 'emitPageEventUpdate').mockReturnValue(null);
-      const savedPage = await Page.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
-      mockedRenameSubOperation.mockRestore();
-      return savedPage;
-    };
-
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
@@ -509,7 +806,7 @@ describe('Page', () => {
         expect(page2).toBeTruthy();
 
         const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
-        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
+        await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1 });
@@ -530,7 +827,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
 
-        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _top = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -557,7 +854,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(count).toBe(1);
 
-        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -599,7 +896,7 @@ describe('Page', () => {
         expect(page1).toBeNull();
         expect(page2).toBeNull();
 
-        await Page.updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true });
@@ -626,7 +923,7 @@ describe('Page', () => {
         expect(page2).toBeTruthy();
         expect(page3).toBeTruthy();
 
-        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, isEmpty: true }); // should be replaced
@@ -658,7 +955,7 @@ describe('Page', () => {
       });
       test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
         const path = '/mup20';
-        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
         expect(_page).toBeTruthy();
 
         await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
@@ -683,7 +980,7 @@ describe('Page', () => {
         const path1 = '/mup22';
         const path2 = '/mup22/mup23';
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
         expect(_page1).toBeTruthy();
         expect(_page2).toBeTruthy();
 
@@ -708,8 +1005,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
@@ -720,7 +1017,7 @@ describe('Page', () => {
 
           // check page2 grant and group
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
         });
 
         test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
@@ -730,8 +1027,8 @@ describe('Page', () => {
           const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
           expect(_page1).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
           expect(page1).toBeTruthy();
@@ -740,7 +1037,7 @@ describe('Page', () => {
 
           // updated page
           expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page1.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page1.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
 
           // parent's grant check
           const parent = await Page.findById(page1.parent);
@@ -760,8 +1057,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
@@ -772,7 +1069,7 @@ describe('Page', () => {
 
           // grant check
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
           expect(page2.grantedUsers.length).toBe(0);
         });
       });
@@ -782,18 +1079,18 @@ describe('Page', () => {
           const _path1 = '/mup29_A';
           const _path2 = '/mup29_A/mup30_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // out of update scope
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA }); // out of update scope
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdB };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelB };
 
           // First round
-          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
-          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(groupIdB)
+          // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
+          const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
           const page1 = await Page.findById(_page1._id);
           const page2 = await Page.findById(_page2._id);
@@ -803,24 +1100,24 @@ describe('Page', () => {
           expect(updatedPage._id).toStrictEqual(page2._id);
 
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(groupIdB);
+          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelB);
           expect(page2.grantedUsers.length).toBe(0);
 
           // Second round
           // Update group to groupC which is a grandchild from pageA's point of view
-          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdC }; // from GRANT_USER_GROUP(groupIdB) to GRANT_USER_GROUP(groupIdC)
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelC }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
           const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
 
           expect(secondRoundUpdatedPage).toBeTruthy();
           expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(groupIdC);
+          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(userGroupIdPModelC);
         });
         test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async() => {
           // path
           const _path1 = '/mup31_A';
           const _path2 = '/mup31_A/mup32_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
           });
@@ -828,13 +1125,13 @@ describe('Page', () => {
           expect(_page2).toBeTruthy();
 
           // group
-          const _groupIsolated = await UserGroup.findById(groupIdIsolate);
+          const _groupIsolated = await UserGroup.findById(userGroupIdPModelIsolate);
           expect(_groupIsolated).toBeTruthy();
           // group parent check
           expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdIsolate };
-          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdIsolate)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelIsolate };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelIsolate)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
           const page1 = await Page.findById(_page1._id);
@@ -851,18 +1148,18 @@ describe('Page', () => {
           const _path1 = '/mup33_C';
           const _path2 = '/mup33_C/mup34_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // groupC
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelC }); // groupC
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
 
-          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
           // this should fail because the groupC is a descendant of groupA
-          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          await expect(updatePage(_page2, 'new', 'old', pModelUser3, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
           const page1 = await Page.findById(_page1._id);
@@ -888,8 +1185,8 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: groupIdA };
-          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
           const page1 = await Page.findById(_page1.id);
@@ -905,4 +1202,193 @@ describe('Page', () => {
     });
 
   });
+
+
+  // see: https://dev.growi.org/635a314eac6bcd85cbf359fc about the specification
+  describe('updatePage with overwriteScopesOfDescendants true', () => {
+    test('(case 1) it should update all granted descendant pages when update grant is GRANT_PUBLIC', async() => {
+      const upodPagegAB = await Page.findOne({ path: '/gAB_upod_1' });
+      const upodPagegB = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
+      const upodPageonlyB = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+
+      expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegB).not.toBeNull();
+      expect(upodPageonlyB).not.toBeNull();
+
+      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_PUBLIC,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagegAB, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegBUpdated = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
+      const upodPageonlyBUpdated = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_PUBLIC;
+      expect(updatedPage.grant).toBe(newGrant);
+      // Not changed
+      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated.grantedGroup).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+    });
+    test('(case 2) it should update all granted descendant pages when all descendant pages are granted by the operator', async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_2' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
+      const upodPagegAIsolated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
+      const upodPageonlyA = await Page.findOne({ path: '/public_upod_2/onlyA_upod_2' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      expect(upodPagegAIsolated).not.toBeNull();
+      expect(upodPageonlyA).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAIsolated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyA.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_OWNER,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegAUpdated = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
+      const upodPagegAIsolatedUpdated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
+      const upodPageonlyAUpdated = await Page.findOne({ path: '/public_upod_2/onlyA_upod_2' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_OWNER;
+      const newGrantedUsers = [upodUserA._id];
+      expect(updatedPage.grant).toBe(newGrant);
+      expect(updatedPage.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPagegAUpdated.grant).toBe(newGrant);
+      expect(upodPagegAUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPagegAIsolatedUpdated.grant).toBe(newGrant);
+      expect(upodPagegAIsolatedUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+      expect(upodPageonlyAUpdated.grant).toBe(newGrant);
+      expect(upodPageonlyAUpdated.grantedUsers).toStrictEqual(newGrantedUsers);
+    });
+    test(`(case 3) it should update all granted descendant pages when update grant is GRANT_USER_GROUP
+    , all user groups of descendants are the children or itself of the update user group
+    , and all users of descendants belong to the update user group`, async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_3' });
+      const upodPagegAB = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegB = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
+      const upodPageonlyB = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegB).not.toBeNull();
+      expect(upodPageonlyB).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      const upodPagegABUpdated = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegBUpdated = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
+      const upodPageonlyBUpdated = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
+
+      // Changed
+      const newGrant = PageGrant.GRANT_USER_GROUP;
+      const newGrantedGroup = upodUserGroupIdAB;
+      expect(updatedPage.grant).toBe(newGrant);
+      expect(updatedPage.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      expect(upodPagegABUpdated.grant).toBe(newGrant);
+      expect(upodPagegABUpdated.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      // Not changed
+      expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegBUpdated.grantedGroup._id).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+    });
+    test(`(case 4) it should throw when some of descendants is not granted
+    , update grant is GRANT_USER_GROUP
+    , and some of user groups of descendants are not children or itself of the update user group`, async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_4' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_4/gA_upod_4' });
+      const upodPagegC = await Page.findOne({ path: '/public_upod_4/gC_upod_4' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      expect(upodPagegC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegC.grant).toBe(PageGrant.GRANT_USER_GROUP);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+    test(`(case 5) it should throw when some of descendants is not granted
+    , update grant is GRANT_USER_GROUP
+    , and some of users of descendants does NOT belong to the update user group`, async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_5' });
+      const upodPagegA = await Page.findOne({ path: '/public_upod_5/gA_upod_5' });
+      const upodPageonlyC = await Page.findOne({ path: '/public_upod_5/onlyC_upod_5' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPagegA).not.toBeNull();
+      expect(upodPageonlyC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPagegA.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+    test('(case 6) it should throw when some of descendants is not granted and update grant is GRANT_OWNER', async() => {
+      const upodPagePublic = await Page.findOne({ path: '/public_upod_6' });
+      const upodPageonlyC = await Page.findOne({ path: '/public_upod_6/onlyC_upod_6' });
+
+      expect(upodPagePublic).not.toBeNull();
+      expect(upodPageonlyC).not.toBeNull();
+
+      expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
+      expect(upodPageonlyC.grant).toBe(PageGrant.GRANT_OWNER);
+
+      // Update
+      const options = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantUserGroupId: upodUserGroupIdAB,
+        overwriteScopesOfDescendants: true,
+      };
+      const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
+
+      await expect(updatedPagePromise).rejects.toThrowError();
+    });
+  });
 });

+ 24 - 19
packages/app/test/integration/service/page-grant.test.js

@@ -30,6 +30,8 @@ describe('PageGrantService', () => {
   let groupParent;
   let groupChild;
 
+  const userGroupIdParent = new mongoose.Types.ObjectId();
+
   let rootPage;
   let rootPublicPage;
   let rootOnlyMePage;
@@ -72,18 +74,7 @@ describe('PageGrantService', () => {
   const pageE3GroupChildPath = '/E3/GroupChild';
   const pageE3User1Path = '/E3/User1';
 
-  /*
-   * prepare before all tests
-   */
-  beforeAll(async() => {
-    crowi = await getInstance();
-
-    pageGrantService = crowi.pageGrantService;
-
-    User = mongoose.model('User');
-    Page = mongoose.model('Page');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
-
+  const createDocumentsToTestIsGrantNormalized = async() => {
     // Users
     await User.insertMany([
       { name: 'User1', username: 'User1', email: 'user1@example.com' },
@@ -93,22 +84,19 @@ describe('PageGrantService', () => {
     user1 = await User.findOne({ username: 'User1' });
     user2 = await User.findOne({ username: 'User2' });
 
-    // Parent user groups
     await UserGroup.insertMany([
       {
+        _id: userGroupIdParent,
         name: 'GroupParent',
         parent: null,
       },
-    ]);
-    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
-
-    // Child user groups
-    await UserGroup.insertMany([
       {
         name: 'GroupChild',
-        parent: groupParent._id,
+        parent: userGroupIdParent,
       },
     ]);
+
+    groupParent = await UserGroup.findOne({ name: 'GroupParent' });
     groupChild = await UserGroup.findOne({ name: 'GroupChild' });
 
     // UserGroupRelations
@@ -319,6 +307,23 @@ describe('PageGrantService', () => {
     pageE3GroupParent = await Page.findOne({ path: pageE3GroupParentPath });
     pageE3GroupChild = await Page.findOne({ path: pageE3GroupChildPath });
     pageE3User1 = await Page.findOne({ path: pageE3User1Path });
+  };
+
+  /*
+   * prepare before all tests
+   */
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    pageGrantService = crowi.pageGrantService;
+
+    User = mongoose.model('User');
+    Page = mongoose.model('Page');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+
+    rootPage = await Page.findOne({ path: '/' });
+
+    await createDocumentsToTestIsGrantNormalized();
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });

+ 16 - 2
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -83,6 +83,20 @@ describe('PageService page operations with non-public pages', () => {
   const tagIdRevert1 = new mongoose.Types.ObjectId();
   const tagIdRevert2 = new mongoose.Types.ObjectId();
 
+  const create = async(path, body, user, options = {}) => {
+    const mockedCreateSubOperation = jest.spyOn(crowi.pageService, 'createSubOperation').mockReturnValue(null);
+
+    const createdPage = await crowi.pageService.create(path, body, user, options);
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+
+    mockedCreateSubOperation.mockRestore();
+
+    await crowi.pageService.createSubOperation(...argsForCreateSubOperation);
+
+    return createdPage;
+  };
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -746,7 +760,7 @@ describe('PageService page operations with non-public pages', () => {
         expect(page3).toBeNull();
 
         // use existing path
-        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
@@ -774,7 +788,7 @@ describe('PageService page operations with non-public pages', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
 
-        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });

+ 17 - 3
packages/app/test/integration/service/v5.public-page.test.ts

@@ -28,6 +28,20 @@ describe('PageService page operations with only public pages', () => {
   // page operation ids
   let pageOpId1;
 
+  const create = async(path, body, user, options = {}) => {
+    const mockedCreateSubOperation = jest.spyOn(crowi.pageService, 'createSubOperation').mockReturnValue(null);
+
+    const createdPage = await crowi.pageService.create(path, body, user, options);
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+
+    mockedCreateSubOperation.mockRestore();
+
+    await crowi.pageService.createSubOperation(...argsForCreateSubOperation);
+
+    return createdPage;
+  };
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
@@ -1055,7 +1069,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should create single page', async() => {
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
-      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
       // isGrantNormalized is called when GRANT PUBLIC
@@ -1064,7 +1078,7 @@ describe('PageService page operations with only public pages', () => {
 
     test('Should create empty-child and non-empty grandchild', async() => {
       const isGrantNormalizedSpy = jest.spyOn(crowi.pageGrantService, 'isGrantNormalized');
-      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
 
       expect(childPage.isEmpty).toBe(true);
@@ -1081,7 +1095,7 @@ describe('PageService page operations with only public pages', () => {
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       expect(beforeCreatePage.isEmpty).toBe(true);
 
-      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await create('/v5_empty_create_4', 'body', dummyUser1, {});
       const grandchildPage = await Page.findOne({ parent: childPage._id });
 
       expect(childPage).toBeTruthy();

+ 13 - 1
packages/core/src/interfaces/page.ts

@@ -54,7 +54,19 @@ export const PageGrant = {
   GRANT_OWNER: 4,
   GRANT_USER_GROUP: 5,
 } as const;
-export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
+type UnionPageGrantKeys = keyof typeof PageGrant;
+export type PageGrant = typeof PageGrant[UnionPageGrantKeys];
+
+/**
+ * Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
+ */
+export type PageGrantCanBeOnTree = typeof PageGrant[Exclude<UnionPageGrantKeys, 'GRANT_RESTRICTED' | 'GRANT_SPECIFIED'>];
+
+export const PageStatus = {
+  STATUS_PUBLISHED: 'published',
+  STATUS_DELETED: 'deleted',
+} as const;
+export type PageStatus = typeof PageStatus[keyof typeof PageStatus];
 
 export type IPageHasId = IPage & HasObjectId;
 

+ 16 - 0
packages/core/src/utils/path-utils.js

@@ -133,3 +133,19 @@ export function normalizePath(path) {
 export function attachTitleHeader(path) {
   return `# ${path}`;
 }
+
+/**
+ * If the pagePath is top page path, eliminate the pageId from the url path.
+ *
+ * @param {string} path
+ * @param {string} id
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function returnPathForURL(path, id) {
+  if (path === '/') {
+    return path;
+  }
+
+  return addHeadingSlash(id);
+}