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

Merge branch 'master' into support/98033-omit-personal-container

kaori 3 лет назад
Родитель
Сommit
6716de286a
85 измененных файлов с 2064 добавлено и 1163 удалено
  1. 30 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/docker/README.md
  5. 7 7
      packages/app/package.json
  6. 32 0
      packages/app/public/images/customize-settings/dock-dark.svg
  7. 32 0
      packages/app/public/images/customize-settings/dock-light.svg
  8. 31 0
      packages/app/public/images/customize-settings/drawer-dark.svg
  9. 31 0
      packages/app/public/images/customize-settings/drawer-light.svg
  10. 7 0
      packages/app/public/static/locales/en_US/admin/admin.json
  11. 7 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  12. 7 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  13. 5 12
      packages/app/src/client/app.jsx
  14. 3 0
      packages/app/src/client/base.jsx
  15. 0 167
      packages/app/src/client/services/CommentContainer.js
  16. 12 8
      packages/app/src/client/services/ContextExtractor.tsx
  17. 1 1
      packages/app/src/client/services/PageContainer.js
  18. 34 0
      packages/app/src/client/services/ShowPageAccessoriesModal.tsx
  19. 12 7
      packages/app/src/components/Admin/Customize/Customize.jsx
  20. 118 0
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  21. 1 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  22. 9 9
      packages/app/src/components/Drawio.tsx
  23. 2 1
      packages/app/src/components/Me/PersonalSettings.jsx
  24. 8 1
      packages/app/src/components/Navbar/GlobalSearch.tsx
  25. 28 15
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  26. 29 24
      packages/app/src/components/Navbar/SubNavButtons.tsx
  27. 28 3
      packages/app/src/components/NotFoundPage.tsx
  28. 3 3
      packages/app/src/components/Page/DisplaySwitcher.tsx
  29. 0 4
      packages/app/src/components/Page/RevisionBody.jsx
  30. 2 3
      packages/app/src/components/Page/TagsInput.tsx
  31. 4 3
      packages/app/src/components/Page/TrashPageAlert.jsx
  32. 7 7
      packages/app/src/components/PageAccessoriesModal.tsx
  33. 0 464
      packages/app/src/components/PageComment/CommentEditor.jsx
  34. 385 0
      packages/app/src/components/PageComment/CommentEditor.tsx
  35. 3 3
      packages/app/src/components/PageContentFooter.tsx
  36. 1 2
      packages/app/src/components/PageEditor.tsx
  37. 8 11
      packages/app/src/components/PageEditor/Preview.tsx
  38. 1 1
      packages/app/src/components/PageRenameModal.tsx
  39. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  40. 14 2
      packages/app/src/interfaces/comment.ts
  41. 11 1
      packages/app/src/interfaces/global.ts
  42. 3 0
      packages/app/src/interfaces/graph-viewer.ts
  43. 15 0
      packages/app/src/interfaces/interceptor-manager.ts
  44. 4 4
      packages/app/src/interfaces/page.ts
  45. 5 0
      packages/app/src/interfaces/sidebar-config.ts
  46. 1 2
      packages/app/src/next-i18next.config.ts
  47. 2 2
      packages/app/src/server/crowi/dev.js
  48. 2 9
      packages/app/src/server/crowi/express-init.js
  49. 4 0
      packages/app/src/server/models/config.ts
  50. 2 13
      packages/app/src/server/models/obsolete-page.js
  51. 18 0
      packages/app/src/server/models/page.ts
  52. 2 2
      packages/app/src/server/models/user.js
  53. 2 2
      packages/app/src/server/routes/apiv3/app-settings.js
  54. 41 0
      packages/app/src/server/routes/apiv3/customize-setting.js
  55. 3 2
      packages/app/src/server/routes/apiv3/page-listing.ts
  56. 2 2
      packages/app/src/server/routes/apiv3/page.js
  57. 4 3
      packages/app/src/server/routes/apiv3/pages.js
  58. 2 2
      packages/app/src/server/routes/apiv3/personal-setting.js
  59. 2 2
      packages/app/src/server/routes/comment.js
  60. 41 27
      packages/app/src/server/routes/page.js
  61. 25 11
      packages/app/src/server/service/page-operation.ts
  62. 63 35
      packages/app/src/server/service/page.ts
  63. 4 0
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  64. 0 1
      packages/app/src/server/views/layout-growi/identical-path-page.html
  65. 1 0
      packages/app/src/server/views/layout-growi/not_found.html
  66. 2 0
      packages/app/src/server/views/layout/layout.html
  67. 1 0
      packages/app/src/server/views/widget/not_found_content.html
  68. 2 2
      packages/app/src/server/views/widget/page_alerts.html
  69. 0 2
      packages/app/src/server/views/widget/page_content.html
  70. 49 4
      packages/app/src/stores/comment.tsx
  71. 7 4
      packages/app/src/stores/context.tsx
  72. 88 18
      packages/app/src/stores/ui.tsx
  73. 7 0
      packages/app/src/styles/_admin.scss
  74. 90 0
      packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts
  75. 174 0
      packages/app/test/cypress/integration/30-search/search.spec.ts
  76. 346 2
      packages/app/test/integration/models/v5.page.test.js
  77. 122 237
      packages/app/test/integration/service/v5.page.test.ts
  78. 1 1
      packages/codemirror-textlint/package.json
  79. 1 1
      packages/core/package.json
  80. 1 1
      packages/plugin-attachment-refs/package.json
  81. 1 1
      packages/plugin-lsx/package.json
  82. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  83. 1 1
      packages/slack/package.json
  84. 2 2
      packages/slackbot-proxy/package.json
  85. 1 1
      packages/ui/package.json

+ 30 - 1
CHANGELOG.md

@@ -1,9 +1,38 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.9...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.10...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
+
+### 💎 Features
+
+- feat: Sidebar default mode settings (#6111) @yukendev
+- feat: Get GCS instance that uses Application Default Credentials for v5 (#6051) @Yohei-Shiina
+- feat: Resume rename on server boot (#5862)(#6014) @Yohei-Shiina
+- feat: Show page item control menu on empty page (#6070)(#6103) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Show page control on subnavigation at existing empty page  (#5638) @Yohei-Shiina
+- imprv: Remove toc and page authors in empty page (#5661) @Yohei-Shiina
+- imprv: SWRize apiGet /tag.search (#6062) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Scrolling preview (#6148) @yuki-takei
+- fix: Show page history comparation modal on init (#6072) @hirokei-camel
+- fix: Ensure backword compatibility for ES6 when using max_analyzed_offset (#6121) @hakumizuki
+- fix: Set max_analyzed_offset to elasticsearch querying options (#6115) @hakumizuki
+- fix: Revision err when updating tags (#6073) @kaoritokashiki
+- fix: Support 3 types of syntax for OpenID Connect Issuer Host (#6061) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Omit comment container (#6147) @yuki-takei
+- support: Upgrade typescript to ^4.6.0 (#6082) @hakumizuki
+
 ## [v5.0.9](https://github.com/weseek/growi/compare/v5.0.8...v5.0.9) - 2022-06-13
 
 ### 🚀 Improvement

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.9`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
-* [`5.0.9-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
+* [`5.0.10`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
+* [`5.0.10-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -64,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.10-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.10-RC.0",
-    "@growi/plugin-lsx": "^5.0.10-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.10-RC.0",
-    "@growi/slack": "^5.0.10-RC.0",
+    "@growi/codemirror-textlint": "^5.0.11-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.11-RC.0",
+    "@growi/plugin-lsx": "^5.0.11-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.11-RC.0",
+    "@growi/slack": "^5.0.11-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -169,7 +169,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.10-RC.0",
+    "@growi/ui": "^5.0.11-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

Разница между файлами не показана из-за своего большого размера
+ 32 - 0
packages/app/public/images/customize-settings/dock-dark.svg


Разница между файлами не показана из-за своего большого размера
+ 32 - 0
packages/app/public/images/customize-settings/dock-light.svg


+ 31 - 0
packages/app/public/images/customize-settings/drawer-dark.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#16171d" opacity="0.586"/>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+  </g>
+</svg>

+ 31 - 0
packages/app/public/images/customize-settings/drawer-light.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#fff"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#abb4bd"/>
+      <rect width="42.646" height="5" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#abb4bd"/>
+      <rect width="42.646" height="5" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
+    </g>
+    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#25272f" opacity="0.271"/>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#f3f7fc"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#abb4bd"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
+    </g>
+  </g>
+</svg>

+ 7 - 0
packages/app/public/static/locales/en_US/admin/admin.json

@@ -149,6 +149,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "Default sidebar mode",
+      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
+      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
+      "dock_mode_default_open": "Open the page as it was opened from the beginning",
+      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+    },
     "layout": "Layout",
     "layout_options": {
       "default": "Default content width",

+ 7 - 0
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -149,6 +149,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "デフォルトのサイドバーモード",
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
+      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
+      "dock_mode_default_open": "初めから開いた状態でページを開く",
+      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+    },
     "layout": "レイアウト",
     "layout_options": {
       "default": "デフォルトのコンテンツ幅",

+ 7 - 0
packages/app/public/static/locales/zh_CN/admin/admin.json

@@ -148,6 +148,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "默认的侧边栏模式",
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
+      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
+      "dock_mode_default_open": "从头开始翻页",
+      "dock_mode_default_close": "从头开始打开关闭的页面"
+    },
     "layout": "布局",
     "layout_options": {
       "default": "默认内容宽度 ",

+ 5 - 12
packages/app/src/client/app.jsx

@@ -7,7 +7,6 @@ import { I18nextProvider } from 'react-i18next';
 import { SWRConfig } from 'swr';
 import { Provider } from 'unstated';
 
-import CommentContainer from '~/client/services/CommentContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -48,8 +47,6 @@ import TagPage from '../components/TagPage';
 import TrashPageList from '../components/TrashPageList';
 
 import { appContainer, componentMappings } from './base';
-import { toastError } from './util/apiNotification';
-
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -62,11 +59,10 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
-const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer,
+  editorContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -94,8 +90,6 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
-  'fix-page-grant-alert': <FixPageGrantAlert />,
-
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
@@ -127,11 +121,10 @@ if (pageContainer.state.pageId != null) {
 
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
-
-  // show the Page accessory modal when query of "compare" is requested
-  if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
-  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+  if (!pageContainer.state.isEmpty) {
+    Object.assign(componentMappings, {
+      'fix-page-grant-alert': <FixPageGrantAlert />,
+    });
   }
 }
 if (pageContainer.state.creator != null) {

+ 3 - 0
packages/app/src/client/base.jsx

@@ -23,6 +23,8 @@ import PageDuplicateModal from '../components/PageDuplicateModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PageRenameModal from '../components/PageRenameModal';
 
+import ShowPageAccessoriesModal from './services/ShowPageAccessoriesModal';
+
 const logger = loggerFactory('growi:cli:app');
 
 if (!window) {
@@ -69,6 +71,7 @@ const componentMappings = {
   'system-version': <SystemVersion />,
 
 
+  'show-page-accessories-modal': <ShowPageAccessoriesModal />,
 };
 
 export { appContainer, componentMappings };

+ 0 - 167
packages/app/src/client/services/CommentContainer.js

@@ -1,167 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiGet, apiPost, apiPostForm } from '../util/apiv1-client';
-import { apiv3Put } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:services:CommentContainer');
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {Container} unstated Container
- */
-export default class CommentContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const mainContent = document.querySelector('#content-main');
-
-    if (mainContent == null) {
-      logger.debug('#content-main element is not exists');
-      return;
-    }
-
-    this.state = {
-      comments: [],
-    };
-
-    this.retrieveComments = this.retrieveComments.bind(this);
-    this.checkAndUpdateImageOfCommentAuthers = this.checkAndUpdateImageOfCommentAuthers.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'CommentContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  async retrieveComments() {
-    const { pageId } = this.getPageContainer().state;
-
-    // get data (desc order array)
-    const res = await apiGet('/comments.get', { page_id: pageId });
-    if (res.ok) {
-      const comments = res.comments;
-      this.setState({ comments });
-
-      this.checkAndUpdateImageOfCommentAuthers(comments);
-    }
-  }
-
-  async checkAndUpdateImageOfCommentAuthers(comments) {
-    const noImageCacheUserIds = comments.filter((comment) => {
-      const { creator } = comment;
-      return creator != null && creator.imageUrlCached == null;
-    }).map((comment) => {
-      return comment.creator._id;
-    });
-
-    if (noImageCacheUserIds.length === 0) {
-      return;
-    }
-
-    try {
-      await apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
-    const { pageId, revisionId } = this.getPageContainer().state;
-
-    return apiPost('/comments.add', {
-      commentForm: {
-        comment,
-        page_id: pageId,
-        revision_id: revisionId,
-        is_markdown: isMarkdown,
-        replyTo,
-      },
-      slackNotificationForm: {
-        isSlackEnabled,
-        slackChannels,
-      },
-    })
-      .then((res) => {
-        if (res.ok) {
-          return this.retrieveComments();
-        }
-      });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  putComment(comment, isMarkdown, commentId, author) {
-    const { pageId, revisionId } = this.getPageContainer().state;
-
-    return apiPost('/comments.update', {
-      commentForm: {
-        comment,
-        is_markdown: isMarkdown,
-        revision_id: revisionId,
-        comment_id: commentId,
-      },
-    })
-      .then((res) => {
-        if (res.ok) {
-          return this.retrieveComments();
-        }
-      });
-  }
-
-  deleteComment(comment) {
-    return apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
-      });
-  }
-
-  uploadAttachment(file) {
-    const { pageId, pagePath } = this.getPageContainer().state;
-
-    const endpoint = '/attachments.add';
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('path', pagePath);
-    formData.append('page_id', pageId);
-
-    return apiPostForm(endpoint, formData);
-  }
-
-}

+ 12 - 8
packages/app/src/client/services/ContextExtractor.tsx

@@ -13,12 +13,12 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 import {
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
-  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useGrowiVersion,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -29,7 +29,7 @@ const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
   const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
-  const notFoundContent = document.getElementById('growi-not-found-context');
+  const notFoundContext = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
   // get csrf token from body element
@@ -57,7 +57,10 @@ const ContextExtractorOnce: FC = () => {
    */
   const revisionId = mainContent?.getAttribute('data-page-revision-id');
   const path = decodeURI(mainContent?.getAttribute('data-path') || '');
+  // assign `null` to avoid returning empty string
   const pageId = mainContent?.getAttribute('data-page-id') || null;
+  const emptyPageId = notFoundContext?.getAttribute('data-page-id') || null;
+
   const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
 
   // createdAt
@@ -71,7 +74,6 @@ const ContextExtractorOnce: FC = () => {
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
@@ -89,8 +91,9 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
+  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
+  const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
@@ -105,9 +108,9 @@ const ContextExtractorOnce: FC = () => {
   useCurrentUser(currentUser);
 
   // UserUISettings
-  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? configByContextHydrate.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
-  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed ?? configByContextHydrate.isSidebarClosedAtDockMode);
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
@@ -128,13 +131,13 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
-  useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);
+  useEmptyPageId(emptyPageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -151,6 +154,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
+  useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
 
   // Navigation

+ 1 - 1
packages/app/src/client/services/PageContainer.js

@@ -52,6 +52,7 @@ export default class PageContainer extends Container {
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
+      isEmpty: mainContent.getAttribute('data-page-is-empty'),
 
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
@@ -60,7 +61,6 @@ export default class PageContainer extends Container {
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
-      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 

+ 34 - 0
packages/app/src/client/services/ShowPageAccessoriesModal.tsx

@@ -0,0 +1,34 @@
+import React, { useEffect, useState } from 'react';
+
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+
+function getURLQueryParamValue(key: string) {
+// window.location.href is page URL;
+  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
+  return queryStr.get(key);
+}
+
+const queryCompareFormat = new RegExp(/([a-z0-9]){24}...([a-z0-9]){24}/);
+
+const ShowPageAccessoriesModal = (): JSX.Element => {
+  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+  useEffect(() => {
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (status == null || status.isOpened === true) {
+      return;
+    }
+    if (isArleadyMounted === true) {
+      return;
+    }
+    if (pageIdParams != null) {
+      if (queryCompareFormat.test(pageIdParams)) {
+        openPageAccessories(PageAccessoriesModalContents.PageHistory);
+      }
+    }
+    setIsArleadyMounted(true);
+  }, [openPageAccessories, status, isArleadyMounted]);
+  return <></>;
+};
+
+export default ShowPageAccessoriesModal;

+ 12 - 7
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -1,23 +1,25 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 
-import loggerFactory from '~/utils/logger';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toArrayIfNot } from '~/utils/array-utils';
+import loggerFactory from '~/utils/logger';
+
 import { withLoadingSppiner } from '../../SuspenseUtils';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import CustomizeLayoutSetting from './CustomizeLayoutSetting';
-import CustomizeThemeSetting from './CustomizeThemeSetting';
+import CustomizeCssSetting from './CustomizeCssSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
+import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
-import CustomizeCssSetting from './CustomizeCssSetting';
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
-import CustomizeHeaderSetting from './CustomizeHeaderSetting';
+import CustomizeSidebarSetting from './CustomizeSidebarSetting';
+import CustomizeThemeSetting from './CustomizeThemeSetting';
 import CustomizeTitle from './CustomizeTitle';
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
@@ -53,6 +55,9 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeThemeSetting />
       </div>
+      <div className="mb-5">
+        <CustomizeSidebarSetting />
+      </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />
       </div>

+ 118 - 0
packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -0,0 +1,118 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
+import { useSWRxSidebarConfig } from '~/stores/ui';
+
+const CustomizeSidebarsetting = (): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    update, isSidebarDrawerMode, isSidebarClosedAtDockMode, setIsSidebarDrawerMode, setIsSidebarClosedAtDockMode,
+  } = useSWRxSidebarConfig();
+
+  const isDarkMode = isDarkModeByUtil();
+  const colorText = isDarkMode ? 'dark' : 'light';
+  const drawerIconFileName = `/images/customize-settings/drawer-${colorText}.svg`;
+  const dockIconFileName = `/images/customize-settings/dock-${colorText}.svg`;
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await update();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.default_sidebar_mode.title') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, update]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+
+          <h2 className="admin-setting-header">{t('admin:customize_setting.default_sidebar_mode.title')}</h2>
+
+          <Card className="card well my-3">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.default_sidebar_mode.desc')}
+            </CardBody>
+          </Card>
+
+          <div className="d-flex justify-content-around mt-5">
+            <div id="layoutOptions" className="card-deck">
+              <div
+                className={`card customize-layout-card ${isSidebarDrawerMode ? 'border-active' : ''}`}
+                onClick={() => setIsSidebarDrawerMode(true)}
+                role="button"
+              >
+                <img src={drawerIconFileName} />
+                <div className="card-body text-center">
+                  Drawer Mode
+                </div>
+              </div>
+              <div
+                className={`card customize-layout-card ${!isSidebarDrawerMode ? 'border-active' : ''}`}
+                onClick={() => setIsSidebarDrawerMode(false)}
+                role="button"
+              >
+                <img src={dockIconFileName} />
+                <div className="card-body  text-center">
+                  Dock Mode
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <Card className="card well my-5">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_desc')}
+            </CardBody>
+          </Card>
+
+          <div className="px-3">
+            <div className="custom-control custom-radio my-3">
+              <input
+                type="radio"
+                id="is-open"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={!isSidebarDrawerMode && !isSidebarClosedAtDockMode}
+                disabled={isSidebarDrawerMode}
+                onChange={() => setIsSidebarClosedAtDockMode(false)}
+              />
+              <label className="custom-control-label" htmlFor="is-open">
+                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_open')}
+              </label>
+            </div>
+            <div className="custom-control custom-radio my-3">
+              <input
+                type="radio"
+                id="is-closed"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={!isSidebarDrawerMode && isSidebarClosedAtDockMode}
+                disabled={isSidebarDrawerMode}
+                onChange={() => setIsSidebarClosedAtDockMode(true)}
+              />
+              <label className="custom-control-label" htmlFor="is-closed">
+                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_close')}
+              </label>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="mx-auto">
+              <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>
+            </div>
+          </div>
+
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+export default CustomizeSidebarsetting;

+ 1 - 0
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -153,6 +153,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
+            data-testid="add-remove-bookmark-btn"
           >
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }

+ 9 - 9
packages/app/src/components/Drawio.tsx

@@ -7,13 +7,10 @@ import EventEmitter from 'events';
 import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 
-import NotAvailableForGuest from './NotAvailableForGuest';
-
+import { CustomWindow } from '~/interfaces/global';
+import { IGraphViewer } from '~/interfaces/graph-viewer';
 
-declare const globalEmitter: EventEmitter;
-declare const GraphViewer: {
-  createViewerForElement: (Element) => void,
-};
+import NotAvailableForGuest from './NotAvailableForGuest';
 
 type Props = {
   drawioContent: string,
@@ -31,10 +28,13 @@ const Drawio = (props: Props): JSX.Element => {
 
   const drawioContainerRef = useRef<HTMLDivElement>(null);
 
+  const globalEmitter: EventEmitter = useMemo(() => (window as CustomWindow).globalEmitter, []);
+  const GraphViewer: IGraphViewer = useMemo(() => (window as CustomWindow).GraphViewer, []);
+
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
-  }, [rangeLineNumberOfMarkdown]);
+  }, [rangeLineNumberOfMarkdown, globalEmitter]);
 
   const renderDrawio = useCallback(() => {
     if (drawioContainerRef.current == null) {
@@ -51,7 +51,7 @@ const Drawio = (props: Props): JSX.Element => {
         GraphViewer.createViewerForElement(div);
       }
     }
-  }, []);
+  }, [GraphViewer]);
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
@@ -61,7 +61,7 @@ const Drawio = (props: Props): JSX.Element => {
     }
 
     renderDrawioWithDebounce();
-  }, [renderDrawioWithDebounce]);
+  }, [renderDrawioWithDebounce, GraphViewer]);
 
   return (
     <div className="editable-with-drawio position-relative">

+ 2 - 1
packages/app/src/components/Me/PersonalSettings.jsx

@@ -58,10 +58,11 @@ const PersonalSettings = () => {
     };
   }, [t]);
 
+  const onPasswordSettings = window.location.hash === '#password';
 
   return (
     <div data-testid="grw-personal-settings">
-      <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+      <CustomNavAndContents defaultTabIndex={onPasswordSettings && 2} navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
     </div>
   );
 

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

@@ -73,7 +73,13 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     <div className={`form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
       <div className="input-group flex-nowrap">
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
-          <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
+          <button
+            className="btn btn-secondary dropdown-toggle py-0"
+            type="button"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            data-testid="select-search-scope"
+          >
             {scopeLabel}
           </button>
           <div className="dropdown-menu">
@@ -88,6 +94,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
               { t('header_search_box.item_label.All pages') }
             </button>
             <button
+              data-tesid="search-current-tree"
               className="dropdown-item"
               type="button"
               onClick={() => {

+ 28 - 15
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -10,12 +10,14 @@ import { exportAsMarkdown } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getIdForRef } from '~/interfaces/common';
-import { IPageHasId, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
+import {
+  IPageHasId, IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
+} from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
-  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
+  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useEmptyPageId,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
@@ -155,6 +157,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: createdAt } = useCurrentCreatedAt();
   const { data: updatedAt } = useCurrentUpdatedAt();
   const { data: pageId } = useCurrentPageId();
+  const { data: emptyPageId } = useEmptyPageId();
   const { data: revisionId } = useRevisionId();
   const { data: path } = useCurrentPagePath();
   const { data: creator } = useCreator();
@@ -222,8 +225,12 @@ const GrowiContextualSubNavigation = (props) => {
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
   }, [openDuplicateModal]);
 
-  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta) => {
+  const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
+      if (page.data._id !== null) {
+        window.location.href = `/${page.data._id}`;
+        return;
+      }
       window.location.reload();
     };
     openRenameModal(page, { onRenamed: renamedHandler });
@@ -255,32 +262,38 @@ const GrowiContextualSubNavigation = (props) => {
 
 
   const ControlComponents = useCallback(() => {
+    const pageIdForSubNavButtons = pageId ?? emptyPageId; // for SubNavButtons
+
     function onPageEditorModeButtonClicked(viewType) {
       mutateEditorMode(viewType);
     }
 
+    let additionalMenuItemsRenderer;
+    if (revisionId != null) {
+      additionalMenuItemsRenderer = props => (
+        <AdditionalMenuItems
+          {...props}
+          pageId={pageId}
+          revisionId={revisionId}
+          isLinkSharingDisabled={isLinkSharingDisabled}
+          onClickTemplateMenuItem={templateMenuItemClickHandler}
+        />
+      );
+    }
     return (
       <>
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-          { pageId != null && isViewMode && (
+          { pageIdForSubNavButtons != null && isViewMode && (
             <div className="h-50">
               <SubNavButtons
                 isCompactMode={isCompactMode}
-                pageId={pageId}
+                pageId={pageIdForSubNavButtons}
                 shareLinkId={shareLinkId}
                 revisionId={revisionId}
                 path={path}
                 disableSeenUserInfoPopover={isSharedUser}
                 showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={props => (
-                  <AdditionalMenuItems
-                    {...props}
-                    pageId={pageId}
-                    revisionId={revisionId}
-                    isLinkSharingDisabled={isLinkSharingDisabled}
-                    onClickTemplateMenuItem={templateMenuItemClickHandler}
-                  />
-                )}
+                additionalMenuItemRenderer={additionalMenuItemsRenderer}
                 onClickDuplicateMenuItem={duplicateItemClickedHandler}
                 onClickRenameMenuItem={renameItemClickedHandler}
                 onClickDeleteMenuItem={deleteItemClickedHandler}
@@ -306,7 +319,7 @@ const GrowiContextualSubNavigation = (props) => {
       </>
     );
   }, [
-    pageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
+    pageId, emptyPageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
     isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,

+ 29 - 24
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -33,7 +33,7 @@ type CommonProps = {
 type SubNavButtonsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId: string,
+  revisionId: string | null,
   path?: string | null,
   pageInfo: IPageInfoAll,
 }
@@ -154,27 +154,33 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
-      <span>
-        <SubscribeButton
-          status={pageInfo.subscriptionStatus}
-          onClick={subscribeClickhandler}
+      {revisionId != null && (
+        <span>
+          <SubscribeButton
+            status={pageInfo.subscriptionStatus}
+            onClick={subscribeClickhandler}
+          />
+        </span>
+      )}
+      {revisionId != null && (
+        <LikeButtons
+          hideTotalNumber={isCompactMode}
+          onLikeClicked={likeClickhandler}
+          sumOfLikers={sumOfLikers}
+          isLiked={isLiked}
+          likers={likers}
+        />
+      )}
+      {revisionId != null && (
+        <BookmarkButtons
+          hideTotalNumber={isCompactMode}
+          bookmarkCount={bookmarkCount}
+          isBookmarked={isBookmarked}
+          bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
+          onBookMarkClicked={bookmarkClickHandler}
         />
-      </span>
-      <LikeButtons
-        hideTotalNumber={isCompactMode}
-        onLikeClicked={likeClickhandler}
-        sumOfLikers={sumOfLikers}
-        isLiked={isLiked}
-        likers={likers}
-      />
-      <BookmarkButtons
-        hideTotalNumber={isCompactMode}
-        bookmarkCount={bookmarkCount}
-        isBookmarked={isBookmarked}
-        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-        onBookMarkClicked={bookmarkClickHandler}
-      />
-      { !isCompactMode && (
+      )}
+      {revisionId != null && !isCompactMode && (
         <SeenUserInfo
           seenUsers={seenUsers}
           sumOfSeenUsers={sumOfSeenUsers}
@@ -212,7 +218,7 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
-  if (revisionId == null || error != null) {
+  if (error != null) {
     return <></>;
   }
 
@@ -220,13 +226,12 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
     return <></>;
   }
 
-
   return (
     <SubNavButtonsSubstance
       {...props}
       pageInfo={pageInfo}
       pageId={pageId}
-      revisionId={revisionId}
+      revisionId={revisionId ?? null}
       path={path}
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}

+ 28 - 3
packages/app/src/components/NotFoundPage.tsx

@@ -1,15 +1,40 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useEffect } from 'react';
+
 import { useTranslation } from 'react-i18next';
+import urljoin from 'url-join';
+
+import { useCurrentPagePath, useIsEmptyPage, useNotFoundTargetPathOrId } from '~/stores/context';
 
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
 import PageTimeline from './PageTimeline';
 
+/**
+ * Replace url in address bar with new path and query parameters
+ */
+const replaceURLHistory = (path: string) => {
+  const queryParameters = window.location.search;
+  window.history.replaceState(null, '', urljoin(path, queryParameters));
+};
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
+  const { data: isEmptyPage } = useIsEmptyPage();
+  const { data: path } = useCurrentPagePath();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
+
+  // replace url in address bar with path when accessing empty page by permalink
+  useEffect(() => {
+    if (path == null) {
+      return;
+    }
+    const isPermalink = !notFoundTargetPathOrId?.includes('/');
+    if (isEmptyPage && isPermalink) {
+      replaceURLHistory(path);
+    }
+  }, [path, isEmptyPage, notFoundTargetPathOrId]);
 
   const navTabMapping = useMemo(() => {
     return {

+ 3 - 3
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,7 +7,7 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId, useIsEmptyPage,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -36,7 +36,7 @@ const DisplaySwitcher = (): JSX.Element => {
   // get element for smoothScroll
   const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
-
+  const { data: isEmptyPage } = useIsEmptyPage();
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
@@ -60,7 +60,7 @@ const DisplaySwitcher = (): JSX.Element => {
         <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
-            { isPageExist && (
+            { isPageExist && !isEmptyPage && (
               <div className="grw-side-contents-container">
                 <div className="grw-side-contents-sticky-container">
 

+ 0 - 4
packages/app/src/components/Page/RevisionBody.jsx

@@ -60,9 +60,6 @@ export default class RevisionBody extends React.PureComponent {
       <div
         ref={(elem) => {
           this.element = elem;
-          if (this.props.inputRef != null) {
-            this.props.inputRef.current = elem;
-          }
         }}
         id="wiki"
         className={`wiki ${additionalClassName}`}
@@ -76,7 +73,6 @@ export default class RevisionBody extends React.PureComponent {
 
 RevisionBody.propTypes = {
   html: PropTypes.string,
-  inputRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,

+ 2 - 3
packages/app/src/components/Page/TagsInput.tsx

@@ -38,11 +38,10 @@ const TagsInput: FC<Props> = (props: Props) => {
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
-
-    tagsSearchData.unshift(searchQuery);
+    tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
 
-  }, [searchQuery, tagsSearch?.tags]);
+  }, [tagsSearch?.tags]);
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {

+ 4 - 3
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt, useShareLinkId } from '~/stores/context';
+import { useCurrentUpdatedAt, useIsTrashPage, useShareLinkId } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -24,7 +24,7 @@ const TrashPageAlert = (props) => {
   const { t } = useTranslation();
   const { pageContainer } = props;
   const {
-    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt,
+    pageId, revisionId, path, lastUpdateUsername, deletedUserName, deletedAt,
   } = pageContainer.state;
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
@@ -38,6 +38,7 @@ const TrashPageAlert = (props) => {
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
   const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: isTrashPage } = useIsTrashPage();
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
@@ -89,7 +90,7 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isDeleted && (
+          {isTrashPage && (
             <>
               <br />
               <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />

+ 7 - 7
packages/app/src/components/PageAccessoriesModal.tsx

@@ -1,25 +1,25 @@
 import React, { useEffect, useMemo, useState } from 'react';
 
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalBody, ModalHeader,
 } from 'reactstrap';
 
-import { useTranslation } from 'react-i18next';
 
+import AppContainer from '~/client/services/AppContainer';
 import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
-import AppContainer from '~/client/services/AppContainer';
 
-import HistoryIcon from './Icons/HistoryIcon';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import ExpandOrContractButton from './ExpandOrContractButton';
 import AttachmentIcon from './Icons/AttachmentIcon';
+import HistoryIcon from './Icons/HistoryIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
 import PageAttachment from './PageAttachment';
 import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 type Props = {

+ 0 - 464
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,464 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { UserPicture } from '@growi/ui';
-import PropTypes from 'prop-types';
-import {
-  Button,
-  TabContent, TabPane,
-} from 'reactstrap';
-import * as toastr from 'toastr';
-
-import AppContainer from '~/client/services/AppContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
-import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useIsMobile } from '~/stores/ui';
-
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
-import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
-import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import CommentPreview from './CommentPreview';
-
-
-const navTabMapping = {
-  comment_editor: {
-    Icon: () => <i className="icon-settings" />,
-    i18n: 'Write',
-    index: 0,
-  },
-  comment_preview: {
-    Icon: () => <i className="icon-settings" />,
-    i18n: 'Preview',
-    index: 1,
-  },
-};
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {React.Component}
- */
-
-class CommentEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const config = this.props.appContainer.getConfig();
-    const isUploadable = config.upload.image || config.upload.file;
-    const isUploadableFile = config.upload.file;
-
-    this.state = {
-      isReadyToUse: !this.props.isForNewComment,
-      comment: this.props.commentBody || '',
-      isMarkdown: true,
-      html: '',
-      activeTab: 'comment_editor',
-      isUploadable,
-      isUploadableFile,
-      errorMessage: undefined,
-      isSlackConfigured: config.isSlackConfigured,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-
-    this.cancelButtonClickedHandler = this.cancelButtonClickedHandler.bind(this);
-    this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
-    this.ctrlEnterHandler = this.ctrlEnterHandler.bind(this);
-    this.postComment = this.postComment.bind(this);
-    this.uploadHandler = this.uploadHandler.bind(this);
-
-    this.renderHtml = this.renderHtml.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
-    this.fetchSlackChannels = this.fetchSlackChannels.bind(this);
-  }
-
-  updateState(value) {
-    this.setState({ comment: value });
-  }
-
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isMarkdown: value });
-    // changeMode
-    this.editor.setGfmMode(value);
-  }
-
-  handleSelect(activeTab) {
-    this.setState({ activeTab });
-    this.renderHtml(this.state.comment);
-  }
-
-  fetchSlackChannels(slackChannels) {
-    this.setState({ slackChannels });
-  }
-
-  componentDidUpdate(prevProps) {
-    if (this.props.slackChannels !== prevProps.slackChannels) {
-      this.fetchSlackChannels(this.props.slackChannels);
-    }
-  }
-
-  onSlackChannelsChange(slackChannels) {
-    this.setState({ slackChannels });
-  }
-
-  initializeEditor() {
-    this.setState({
-      comment: '',
-      isMarkdown: true,
-      html: '',
-      activeTab: 'comment_editor',
-      errorMessage: undefined,
-    });
-    // reset value
-    this.editor.setValue('');
-  }
-
-  cancelButtonClickedHandler() {
-    const { isForNewComment, onCancelButtonClicked } = this.props;
-
-    // change state to not ready
-    // when this editor is for the new comment mode
-    if (isForNewComment) {
-      this.setState({ isReadyToUse: false });
-    }
-
-    if (onCancelButtonClicked != null) {
-      const { replyTo, currentCommentId } = this.props;
-      onCancelButtonClicked(replyTo || currentCommentId);
-    }
-  }
-
-  commentButtonClickedHandler() {
-    this.postComment();
-  }
-
-  ctrlEnterHandler(event) {
-    if (event != null) {
-      event.preventDefault();
-    }
-
-    this.postComment();
-  }
-
-  /**
-   * Post comment with CommentContainer and update state
-   */
-  async postComment() {
-    const {
-      commentContainer, replyTo, currentCommentId, commentCreator, onCommentButtonClicked,
-    } = this.props;
-    try {
-      if (currentCommentId != null) {
-        await commentContainer.putComment(
-          this.state.comment,
-          this.state.isMarkdown,
-          currentCommentId,
-          commentCreator,
-        );
-      }
-      else {
-        await this.props.commentContainer.postComment(
-          this.state.comment,
-          this.state.isMarkdown,
-          replyTo,
-          this.props.isSlackEnabled,
-          this.state.slackChannels,
-        );
-      }
-      this.initializeEditor();
-
-      if (onCommentButtonClicked != null) {
-        onCommentButtonClicked();
-      }
-    }
-    catch (err) {
-      const errorMessage = err.message || 'An unknown error occured when posting comment';
-      this.setState({ errorMessage });
-    }
-  }
-
-  uploadHandler(file) {
-    this.props.commentContainer.uploadAttachment(file)
-      .then((res) => {
-        const attachment = res.attachment;
-        const fileName = attachment.originalName;
-
-        let insertText = `[${fileName}](${attachment.filePathProxied})`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
-        this.editor.insertText(insertText);
-      })
-      .catch(this.apiErrorHandler)
-      // finally
-      .then(() => {
-        this.editor.terminateUploadingState();
-      });
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  getCommentHtml() {
-    return (
-      <CommentPreview
-        html={this.state.html}
-      />
-    );
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const { growiRenderer } = this.props;
-    const { interceptorManager } = window;
-    interceptorManager.process('preRenderCommnetPreview', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown, context);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown, context);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }
-
-  generateInnerHtml(html) {
-    return { __html: html };
-  }
-
-  renderBeforeReady() {
-    return (
-      <div className="text-center">
-        <NotAvailableForGuest>
-          <button
-            type="button"
-            className="btn btn-lg btn-link"
-            onClick={() => this.setState({ isReadyToUse: true })}
-          >
-            <i className="icon-bubble"></i> Add Comment
-          </button>
-        </NotAvailableForGuest>
-      </div>
-    );
-  }
-
-  renderReady() {
-    const { isMobile } = this.props;
-    const { activeTab } = this.state;
-
-    const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
-
-    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
-    const cancelButton = (
-      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={this.cancelButtonClickedHandler}>
-        Cancel
-      </Button>
-    );
-    const submitButton = (
-      <Button
-        outline
-        color="primary"
-        className="btn btn-outline-primary rounded-pill"
-        onClick={this.commentButtonClickedHandler}
-      >
-        Comment
-      </Button>
-    );
-
-
-    return (
-      <>
-        <div className="comment-write">
-          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={this.handleSelect} hideBorderBottom />
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="comment_editor">
-              <Editor
-                ref={(c) => { this.editor = c }}
-                value={this.state.comment}
-                isGfmMode={this.state.isMarkdown}
-                lineNumbers={false}
-                isMobile={isMobile}
-                isUploadable={this.state.isUploadable}
-                isUploadableFile={this.state.isUploadableFile}
-                onChange={this.updateState}
-                onUpload={this.uploadHandler}
-                onCtrlEnter={this.ctrlEnterHandler}
-                isComment
-              />
-              {/*
-                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
-                See a review comment in https://github.com/weseek/growi/pull/3473
-              */}
-            </TabPane>
-            <TabPane tabId="comment_preview">
-              <div className="comment-form-preview">
-                {commentPreview}
-              </div>
-            </TabPane>
-          </TabContent>
-        </div>
-
-        <div className="comment-submit">
-          <div className="d-flex">
-            <label className="mr-2">
-              {activeTab === 'comment_editor' && (
-                <span className="custom-control custom-checkbox">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="comment-form-is-markdown"
-                    name="isMarkdown"
-                    checked={this.state.isMarkdown}
-                    value="1"
-                    onChange={this.updateStateCheckbox}
-                  />
-                  <label
-                    className="ml-2 custom-control-label"
-                    htmlFor="comment-form-is-markdown"
-                  >
-                    Markdown
-                  </label>
-                </span>
-              ) }
-            </label>
-            <span className="flex-grow-1" />
-            <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
-
-            { this.state.isSlackConfigured
-              && (
-                <div className="form-inline align-self-center mr-md-2">
-                  <SlackNotification
-                    isSlackEnabled={this.props.isSlackEnabled}
-                    slackChannels={this.state.slackChannels}
-                    onEnabledFlagChange={this.props.onSlackEnabledFlagChange}
-                    onChannelChange={this.onSlackChannelsChange}
-                    id="idForComment"
-                  />
-                </div>
-              )
-            }
-            <div className="d-none d-sm-block">
-              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
-          </div>
-          <div className="d-block d-sm-none mt-2">
-            <div className="d-flex justify-content-end">
-              { this.state.errorMessage && errorMessage }
-              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
-          </div>
-        </div>
-      </>
-    );
-  }
-
-  render() {
-    const { currentUser } = this.props;
-    const { isReadyToUse } = this.state;
-
-    return (
-      <div className="form page-comment-form">
-        <div className="comment-form">
-          <div className="comment-form-user">
-            <UserPicture user={currentUser} noLink noTooltip />
-          </div>
-          <div className="comment-form-main">
-            { !isReadyToUse
-              ? this.renderBeforeReady()
-              : this.renderReady()
-            }
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorHOCWrapper = withUnstatedContainers(CommentEditor, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
-
-CommentEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
-
-  slackChannels: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
-  currentUser: PropTypes.instanceOf(Object),
-  isMobile: PropTypes.bool,
-  isForNewComment: PropTypes.bool,
-  replyTo: PropTypes.string,
-  currentCommentId: PropTypes.string,
-  commentBody: PropTypes.string,
-  commentCreator: PropTypes.string,
-  onCancelButtonClicked: PropTypes.func,
-  onCommentButtonClicked: PropTypes.func,
-  onSlackEnabledFlagChange: PropTypes.func,
-};
-
-const CommentEditorWrapper = (props) => {
-  const { data: isMobile } = useIsMobile();
-  const { data: currentUser } = useCurrentUser();
-  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-
-  const onSlackEnabledFlagChange = useCallback((isSlackEnabled) => {
-    mutateIsSlackEnabled(isSlackEnabled, false);
-  }, [mutateIsSlackEnabled]);
-
-  return (
-    <CommentEditorHOCWrapper
-      {...props}
-      onSlackEnabledFlagChange={onSlackEnabledFlagChange}
-      slackChannels={slackChannelsData.toString()}
-      isSlackEnabled={isSlackEnabled}
-      currentUser={currentUser}
-      isMobile={isMobile}
-    />
-  );
-};
-
-export default CommentEditorWrapper;

+ 385 - 0
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -0,0 +1,385 @@
+import React, {
+  useCallback, useState, useRef, useEffect,
+} from 'react';
+
+import { UserPicture } from '@growi/ui';
+import {
+  Button,
+  TabContent, TabPane,
+} from 'reactstrap';
+import * as toastr from 'toastr';
+
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { apiPostForm } from '~/client/util/apiv1-client';
+import { CustomWindow } from '~/interfaces/global';
+import { IInterceptorManager } from '~/interfaces/interceptor-manager';
+import { useSWRxPageComment } from '~/stores/comment';
+import {
+  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,
+} from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import NotAvailableForGuest from '../NotAvailableForGuest';
+import Editor from '../PageEditor/Editor';
+import { SlackNotification } from '../SlackNotification';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import CommentPreview from './CommentPreview';
+
+
+const navTabMapping = {
+  comment_editor: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Write',
+    index: 0,
+  },
+  comment_preview: {
+    Icon: () => <i className="icon-settings" />,
+    i18n: 'Preview',
+    index: 1,
+  },
+};
+
+type PropsType = {
+  appContainer: AppContainer,
+
+  growiRenderer: GrowiRenderer,
+  isForNewComment?: boolean,
+  replyTo?: string,
+  currentCommentId?: string,
+  commentBody?: string,
+  commentCreator?: string,
+  onCancelButtonClicked?: () => void,
+  onCommentButtonClicked?: () => void,
+}
+
+type EditorRef = {
+  setValue: (value: string) => void,
+  insertText: (text: string) => void,
+  terminateUploadingState: () => void,
+}
+
+const CommentEditor = (props: PropsType): JSX.Element => {
+
+  const {
+    appContainer, growiRenderer, isForNewComment, replyTo,
+    currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
+  } = props;
+  const { data: currentUser } = useCurrentUser();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPageId } = useCurrentPageId();
+  const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
+  const { data: revisionId } = useRevisionId();
+  const { data: isMobile } = useIsMobile();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
+  const config = appContainer.getConfig();
+  const isUploadable = config.upload.image || config.upload.file;
+  const isUploadableFile = config.upload.file;
+  const isSlackConfigured = config.isSlackConfigured;
+
+  const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
+  const [comment, setComment] = useState(commentBody ?? '');
+  const [html, setHtml] = useState('');
+  const [activeTab, setActiveTab] = useState('comment_editor');
+  const [error, setError] = useState();
+  const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
+
+  const editorRef = useRef<EditorRef>(null);
+
+  const renderHtml = useCallback((markdown: string) => {
+    const context = {
+      markdown,
+      parsedHTML: '',
+    };
+
+    const interceptorManager: IInterceptorManager = (window as CustomWindow).interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', context)
+      .then(() => { return interceptorManager.process('prePreProcess', context) })
+      .then(() => {
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
+      })
+      .then(() => { return interceptorManager.process('postPreProcess', context) })
+      .then(() => {
+        const parsedHTML = growiRenderer.process(context.markdown, context);
+        context.parsedHTML = parsedHTML;
+      })
+      .then(() => { return interceptorManager.process('prePostProcess', context) })
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+      })
+      .then(() => { return interceptorManager.process('postPostProcess', context) })
+      .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
+      .then(() => {
+        setHtml(context.parsedHTML);
+      })
+      // process interceptors for post rendering
+      .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
+  }, [growiRenderer]);
+
+  const handleSelect = useCallback((activeTab: string) => {
+    setActiveTab(activeTab);
+    renderHtml(comment);
+  }, [comment, renderHtml]);
+
+  useEffect(() => {
+    if (slackChannels === undefined) { return }
+    setSlackChannels(slackChannelsData?.toString());
+  }, [slackChannelsData, slackChannels]);
+
+  const initializeEditor = useCallback(() => {
+    setComment('');
+    setHtml('');
+    setActiveTab('comment_editor');
+    setError(undefined);
+    // reset value
+    if (editorRef.current == null) { return }
+    editorRef.current.setValue('');
+  }, []);
+
+  const cancelButtonClickedHandler = useCallback(() => {
+    // change state to not ready
+    // when this editor is for the new comment mode
+    if (isForNewComment) {
+      setIsReadyToUse(false);
+    }
+
+    if (onCancelButtonClicked != null) {
+      onCancelButtonClicked();
+    }
+  }, [isForNewComment, onCancelButtonClicked]);
+
+  const postCommentHandler = useCallback(async() => {
+    try {
+      if (currentCommentId != null) {
+        // update current comment
+        await updateComment(comment, revisionId, currentCommentId);
+      }
+      else {
+        // post new comment
+        const postCommentArgs = {
+          commentForm: {
+            comment,
+            revisionId,
+            replyTo,
+          },
+          slackNotificationForm: {
+            isSlackEnabled,
+            slackChannels,
+          },
+        };
+        await postComment(postCommentArgs);
+      }
+
+      initializeEditor();
+
+      if (onCommentButtonClicked != null) {
+        onCommentButtonClicked();
+      }
+    }
+    catch (err) {
+      const errorMessage = err.message || 'An unknown error occured when posting comment';
+      setError(errorMessage);
+    }
+  }, [
+    comment, currentCommentId, initializeEditor,
+    isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels,
+    postComment, revisionId, updateComment,
+  ]);
+
+  const ctrlEnterHandler = useCallback((event) => {
+    if (event != null) {
+      event.preventDefault();
+    }
+
+    postCommentHandler();
+  }, [postCommentHandler]);
+
+  const apiErrorHandler = useCallback((error: Error) => {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }, []);
+
+  const uploadHandler = useCallback(async(file) => {
+
+    if (editorRef.current == null) { return }
+
+    const pagePath = currentPagePath;
+    const pageId = currentPageId;
+    const endpoint = '/attachments.add';
+    const formData = new FormData();
+    formData.append('file', file);
+    formData.append('path', pagePath ?? '');
+    formData.append('page_id', pageId ?? '');
+    try {
+      // TODO: typescriptize res
+      const res = await apiPostForm(endpoint, formData) as any;
+      const attachment = res.attachment;
+      const fileName = attachment.originalName;
+      let insertText = `[${fileName}](${attachment.filePathProxied})`;
+      // when image
+      if (attachment.fileFormat.startsWith('image/')) {
+        // modify to "![fileName](url)" syntax
+        insertText = `!${insertText}`;
+      }
+      editorRef.current.insertText(insertText);
+    }
+    catch (err) {
+      apiErrorHandler(err);
+    }
+    finally {
+      editorRef.current.terminateUploadingState();
+    }
+  }, [apiErrorHandler, currentPageId, currentPagePath]);
+
+  const getCommentHtml = useCallback(() => {
+    return (
+      <CommentPreview
+        html={html}
+      />
+    );
+  }, [html]);
+
+  const renderBeforeReady = useCallback((): JSX.Element => {
+    return (
+      <div className="text-center">
+        <NotAvailableForGuest>
+          <button
+            type="button"
+            className="btn btn-lg btn-link"
+            onClick={() => setIsReadyToUse(true)}
+          >
+            <i className="icon-bubble"></i> Add Comment
+          </button>
+        </NotAvailableForGuest>
+      </div>
+    );
+  }, []);
+
+  const renderReady = () => {
+
+    const commentPreview = getCommentHtml();
+
+    const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
+    const cancelButton = (
+      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={cancelButtonClickedHandler}>
+        Cancel
+      </Button>
+    );
+    const submitButton = (
+      <Button
+        outline
+        color="primary"
+        className="btn btn-outline-primary rounded-pill"
+        onClick={postCommentHandler}
+      >
+        Comment
+      </Button>
+    );
+
+    // TODO: typescriptize Editor
+    const AnyEditor = Editor as any;
+
+    return (
+      <>
+        <div className="comment-write">
+          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
+          <TabContent activeTab={activeTab}>
+            <TabPane tabId="comment_editor">
+              <AnyEditor
+                ref={editorRef}
+                value={comment}
+                lineNumbers={false}
+                isMobile={isMobile}
+                isUploadable={isUploadable}
+                isUploadableFile={isUploadableFile}
+                onChange={setComment}
+                onUpload={uploadHandler}
+                onCtrlEnter={ctrlEnterHandler}
+                isComment
+              />
+              {/*
+                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
+                See a review comment in https://github.com/weseek/growi/pull/3473
+              */}
+            </TabPane>
+            <TabPane tabId="comment_preview">
+              <div className="comment-form-preview">
+                {commentPreview}
+              </div>
+            </TabPane>
+          </TabContent>
+        </div>
+
+        <div className="comment-submit">
+          <div className="d-flex">
+            <span className="flex-grow-1" />
+            <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
+
+            { isSlackConfigured
+              && (
+                <div className="form-inline align-self-center mr-md-2">
+                  <SlackNotification
+                    isSlackEnabled
+                    slackChannels={slackChannelsData?.toString() ?? ''}
+                    onEnabledFlagChange={isSlackEnabled => mutateIsSlackEnabled(isSlackEnabled, false)}
+                    onChannelChange={setSlackChannels}
+                    id="idForComment"
+                  />
+                </div>
+              )
+            }
+            <div className="d-none d-sm-block">
+              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
+            </div>
+          </div>
+          <div className="d-block d-sm-none mt-2">
+            <div className="d-flex justify-content-end">
+              { error && errorMessage }
+              <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
+            </div>
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  return (
+    <div className="form page-comment-form">
+      <div className="comment-form">
+        <div className="comment-form-user">
+          <UserPicture user={currentUser} noLink noTooltip />
+        </div>
+        <div className="comment-form-main">
+          { isReadyToUse
+            ? renderReady()
+            : renderBeforeReady()
+          }
+        </div>
+      </div>
+    </div>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorWrapper = withUnstatedContainers<unknown, Partial<PropsType>>(
+  CommentEditor, [AppContainer, PageContainer, EditorContainer],
+);
+
+export default CommentEditorWrapper;

+ 3 - 3
packages/app/src/components/PageContentFooter.tsx

@@ -1,14 +1,14 @@
 import React, { FC, memo } from 'react';
 
-import AuthorInfo from './Navbar/AuthorInfo';
-
 import { Ref } from '../interfaces/common';
 import { IUser } from '../interfaces/user';
 
+import AuthorInfo from './Navbar/AuthorInfo';
+
 type Props = {
   createdAt: Date,
   updatedAt: Date,
-  creator: Ref<IUser>,
+  creator: any,
   revisionAuthor: Ref<IUser>,
 }
 

+ 1 - 2
packages/app/src/components/PageEditor.tsx

@@ -411,8 +411,7 @@ const PageEditor = (props: Props): JSX.Element => {
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
         <Preview
           markdown={markdown}
-          // eslint-disable-next-line no-return-assign
-          inputRef={previewRef}
+          ref={previewRef}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={false}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}

+ 8 - 11
packages/app/src/components/PageEditor/Preview.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useCallback, useEffect, useMemo, useState, SyntheticEvent,
+  useCallback, useEffect, useMemo, useState, SyntheticEvent, RefObject,
 } from 'react';
 
 
@@ -15,23 +15,20 @@ declare const interceptorManager: InterceptorManager;
 
 
 type Props = {
-  appContainer: AppContainer,
-
   markdown?: string,
   pagePath?: string,
-  inputRef?: React.RefObject<HTMLDivElement>,
   isMathJaxEnabled?: boolean,
   renderMathJaxOnInit?: boolean,
   onScroll?: (scrollTop: number) => void,
 }
 
+type UnstatedProps = Props & { appContainer: AppContainer };
 
-const Preview = (props: Props): JSX.Element => {
+const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivElement>): JSX.Element => {
 
   const {
     appContainer,
     markdown, pagePath,
-    inputRef,
   } = props;
 
   const [html, setHtml] = useState('');
@@ -90,7 +87,7 @@ const Preview = (props: Props): JSX.Element => {
   return (
     <div
       className="page-editor-preview-body"
-      ref={inputRef}
+      ref={ref}
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
         if (props.onScroll != null) {
           props.onScroll(event.currentTarget.scrollTop);
@@ -105,7 +102,7 @@ const Preview = (props: Props): JSX.Element => {
     </div>
   );
 
-};
+});
 
 /**
  * Wrapper component for using unstated
@@ -113,8 +110,8 @@ const Preview = (props: Props): JSX.Element => {
 const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PreviewWrapper2 = (props): JSX.Element => {
-  return <PreviewWrapper {...props} />;
-};
+const PreviewWrapper2 = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
+  return <PreviewWrapper ref={ref} {...props} />;
+});
 
 export default PreviewWrapper2;

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

@@ -94,7 +94,7 @@ const PageRenameModal = (): JSX.Element => {
     try {
       const response = await apiv3Put('/pages/rename', {
         pageId: _id,
-        revisionId: revision,
+        revisionId: revision ?? null,
         isRecursively: !_isV5Compatible ? isRenameRecursively : undefined,
         isRenameRedirect,
         updateMetadata: !isRemainMetadata,

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

@@ -292,8 +292,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       return;
     }
 
-    if (page._id == null || page.revision == null || page.path == null) {
-      throw Error('Any of _id, revision, and path must not be null.');
+    if (page._id == null || page.path == null) {
+      throw Error('_id and path must not be null.');
     }
 
     const pageToDelete: IPageToDeleteWithMeta = {

+ 14 - 2
packages/app/src/interfaces/comment.ts

@@ -1,8 +1,8 @@
 import { Nullable, Ref } from './common';
+import { HasObjectId } from './has-object-id';
 import { IPage } from './page';
-import { IUser } from './user';
 import { IRevision } from './revision';
-import { HasObjectId } from './has-object-id';
+import { IUser } from './user';
 
 export type IComment = {
   comment: string;
@@ -16,5 +16,17 @@ export type IComment = {
   creator: IUser,
 };
 
+export interface ICommentPostArgs {
+  commentForm: {
+    comment: string,
+    revisionId: string,
+    replyTo: string|undefined
+  },
+  slackNotificationForm: {
+    isSlackEnabled: boolean|undefined,
+    slackChannels: string|undefined,
+  },
+}
+
 export type ICommentHasId = IComment & HasObjectId;
 export type ICommentHasIdList = ICommentHasId[];

+ 11 - 1
packages/app/src/interfaces/global.ts

@@ -1,3 +1,13 @@
+import EventEmitter from 'events';
+
 import Xss from '~/services/xss';
 
-export type CustomWindow = Window & typeof globalThis & { xss: Xss };
+import { IGraphViewer } from './graph-viewer';
+import { IInterceptorManager } from './interceptor-manager';
+
+export type CustomWindow = Window
+                         & typeof globalThis
+                         & { xss: Xss }
+                         & { interceptorManager: IInterceptorManager }
+                         & { globalEmitter: EventEmitter }
+                         & { GraphViewer: IGraphViewer };

+ 3 - 0
packages/app/src/interfaces/graph-viewer.ts

@@ -0,0 +1,3 @@
+export interface IGraphViewer {
+  createViewerForElement: (Element) => void,
+}

+ 15 - 0
packages/app/src/interfaces/interceptor-manager.ts

@@ -0,0 +1,15 @@
+interface BasicInterceptor {
+  getId: () => string,
+  isInterceptWhen: (contextName: string) => boolean,
+  isProcessableParallel: () => boolean,
+  process: (contextName: string, args: any) => Promise<any>
+}
+
+export interface IInterceptorManager {
+  interceptorAndOrders: {interceptor: BasicInterceptor, order: number}[],
+  interceptors: BasicInterceptor[],
+  addInterceptor: (inerceptor: BasicInterceptor, order: number) => void,
+  addInterceptors: (inerceptors: BasicInterceptor[], order: number) => void,
+  process: (contextName: string, args: any) => Promise<void>,
+  doProcess: (inerceptor: BasicInterceptor, contextName: string, args: any) => Promise<void>
+}

+ 4 - 4
packages/app/src/interfaces/page.ts

@@ -12,7 +12,7 @@ export interface IPage {
   status: string,
   revision: Ref<IRevision>,
   tags: Ref<ITag>[],
-  creator: Ref<IUser>,
+  creator: any,
   createdAt: Date,
   updatedAt: Date,
   seenUsers: Ref<IUser>[],
@@ -75,7 +75,7 @@ export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperatio
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
-  return pageInfo != null && ('isEmpty' in pageInfo) && pageInfo.isEmpty === false;
+  return pageInfo != null;
 };
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -115,8 +115,8 @@ export type IDataWithMeta<D = unknown, M = unknown> = {
 
 export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 
-export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
-export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
+export type IPageToDeleteWithMeta<T = IPageInfoForEntity | unknown> = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string | null}), T>;
+export type IPageToRenameWithMeta<T = IPageInfoForEntity | unknown> = IPageToDeleteWithMeta<T>;
 
 export type IPageGrantData = {
   grant: number,

+ 5 - 0
packages/app/src/interfaces/sidebar-config.ts

@@ -0,0 +1,5 @@
+
+export interface ISidebarConfig {
+  isSidebarDrawerMode: boolean,
+  isSidebarClosedAtDockMode: boolean
+}

+ 1 - 2
packages/app/src/next-i18next.config.ts

@@ -3,8 +3,7 @@ import path from 'path';
 export const
   i18n = {
     defaultLocale: 'en_US',
-    locales: ['ja_JP', 'zh_CN'],
+    locales: ['en_US', 'ja_JP', 'zh_CN'],
   };
 export const defaultNS = 'translation';
 export const localePath = path.resolve('./public/static/locales');
-export const allLocales = [i18n.defaultLocale].concat(i18n.locales);

+ 2 - 2
packages/app/src/server/crowi/dev.js

@@ -1,6 +1,6 @@
 import path from 'path';
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 const onHeaders = require('on-headers');
@@ -42,7 +42,7 @@ class CrowiDev {
    */
   requireForAutoReloadServer() {
     // load all json files for live reloading
-    allLocales
+    i18n.locales
       .forEach((localeId) => {
         require(path.join(this.crowi.publicDir, 'static/locales', localeId, 'translation.json'));
       });

+ 2 - 9
packages/app/src/server/crowi/express-init.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 
-import { allLocales, localePath } from '~/next-i18next.config';
+import { i18n, localePath } from '~/next-i18next.config';
 
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
@@ -42,7 +42,7 @@ module.exports = function(crowi, app) {
     .init({
       // debug: true,
       fallbackLng: ['en_US'],
-      whitelist: allLocales,
+      whitelist: i18n.locales,
       backend: {
         loadPath: `${localePath}/{{lng}}/translation.json`,
       },
@@ -79,13 +79,6 @@ module.exports = function(crowi, app) {
     res.locals.baseUrl = crowi.appService.getSiteUrl();
     res.locals.env = env;
     res.locals.now = now;
-    res.locals.consts = {
-      pageGrants: Page.getGrantLabels(),
-      userStatus: User.getUserStatusLabels(),
-      language:   allLocales,
-      restrictGuestMode: crowi.aclService.getRestrictGuestModeLabels(),
-      registrationMode: crowi.aclService.getRegistrationModeLabels(),
-    };
     res.locals.local_config = Config.getLocalconfig(crowi); // config for browser context
 
     next();

+ 4 - 0
packages/app/src/server/models/config.ts

@@ -135,6 +135,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:isEnabledStaleNotification': false,
   'customize:isAllReplyShown': false,
   'customize:isSearchScopeChildrenAsDefault': false,
+  'customize:isSidebarDrawerMode': false,
+  'customize:isSidebarClosedAtDockMode': false,
 
   'notification:owner-page:isEnabled': false,
   'notification:group-page:isEnabled': false,
@@ -243,6 +245,8 @@ schema.statics.getLocalconfig = function(crowi) {
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
+    isSidebarDrawerMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
 
   return localConfig;

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

@@ -98,7 +98,7 @@ export const getPageSchema = (crowi) => {
   }
 
   pageSchema.methods.isDeleted = function() {
-    return (this.status === STATUS_DELETED) || isTrashPage(this.path);
+    return isTrashPage(this.path);
   };
 
   pageSchema.methods.isPublic = function() {
@@ -286,17 +286,6 @@ export const getPageSchema = (crowi) => {
       });
   };
 
-  pageSchema.statics.getGrantLabels = function() {
-    const grantLabels = {};
-    grantLabels[GRANT_PUBLIC] = 'Public'; // 公開
-    grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
-    // grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
-    grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
-    grantLabels[GRANT_OWNER] = 'Only me'; // 自分のみ
-
-    return grantLabels;
-  };
-
   pageSchema.statics.getUserPagePath = function(user) {
     return `/user/${user.username}`;
   };
@@ -421,7 +410,7 @@ export const getPageSchema = (crowi) => {
   };
 
   /**
-   * find pages that is match with `path` and its descendants whitch user is able to manage
+   * find pages that is match with `path` and its descendants which user is able to manage
    */
   pageSchema.statics.findManageableListWithDescendants = async function(page, user, option = {}, includeEmpty = false) {
     if (user == null) {

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

@@ -915,6 +915,24 @@ export function generateGrantCondition(
 
 schema.statics.generateGrantCondition = generateGrantCondition;
 
+// find ancestor page with isEmpty: false. If parameter path is '/', return undefined
+schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
+  if (path === '/') {
+    return;
+  }
+
+  const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
+
+  const ancestors = await builderForAncestors
+    .addConditionToListOnlyAncestors(path) // only ancestor paths
+    .addConditionToSortPagesByDescPath() // sort by path in Desc. Long to Short.
+    .query
+    .exec();
+
+  return ancestors[0];
+};
+
+
 export type PageCreateOptions = {
   format?: string
   grantUserGroupId?: ObjectIdLike

+ 2 - 2
packages/app/src/server/models/user.js

@@ -1,5 +1,5 @@
 /* eslint-disable no-use-before-define */
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import { generateGravatarSrc } from '~/utils/gravatar';
 import loggerFactory from '~/utils/logger';
 
@@ -59,7 +59,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     lang: {
       type: String,
-      enum: allLocales,
+      enum: i18n.locales,
       default: 'en_US',
     },
     status: {

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

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -156,7 +156,7 @@ module.exports = (crowi) => {
     appSetting: [
       body('title').trim(),
       body('confidential'),
-      body('globalLang').isIn(allLocales),
+      body('globalLang').isIn(i18n.locales),
       body('isEmailPublishedForNewUser').isBoolean(),
       body('fileUpload').isBoolean(),
     ],

+ 41 - 0
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -10,6 +10,7 @@ const express = require('express');
 const router = express.Router();
 
 const { body, query } = require('express-validator');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -105,6 +106,10 @@ module.exports = (crowi) => {
     theme: [
       body('themeType').isString(),
     ],
+    sidebar: [
+      body('isSidebarDrawerMode').isBoolean(),
+      body('isSidebarClosedAtDockMode').isBoolean(),
+    ],
     function: [
       body('isEnabledTimeline').isBoolean(),
       body('isSavedStatesOfTabChanges').isBoolean(),
@@ -334,6 +339,42 @@ module.exports = (crowi) => {
     }
   });
 
+  // sidebar
+  router.get('/sidebar', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    try {
+      const isSidebarDrawerMode = await crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode');
+      const isSidebarClosedAtDockMode = await crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode');
+      return res.apiv3({ isSidebarDrawerMode, isSidebarClosedAtDockMode });
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting sidebar';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-sidebar-failed'));
+    }
+  });
+
+  router.put('/sidebar', loginRequiredStrictly, adminRequired, csrf, validator.sidebar, apiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:isSidebarDrawerMode': req.body.isSidebarDrawerMode,
+      'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        isSidebarDrawerMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+        isSidebarClosedAtDockMode: await crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating sidebar';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

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

@@ -129,16 +129,17 @@ export default (crowi: Crowi): Router => {
 
       const idToPageInfoMap: Record<string, IPageInfoAll> = {};
 
+      const isGuestUser = req.user == null;
       for (const page of pages) {
         // construct isIPageInfoForListing
-        const basicPageInfo = pageService.constructBasicPageInfo(page);
+        const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely((page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
+            isAbleToDeleteCompletely: pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;

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

@@ -640,12 +640,12 @@ module.exports = (crowi) => {
     const { fromPath, toPath } = req.query;
 
     try {
-      const fromPage = await Page.findByPath(fromPath);
+      const fromPage = await Page.findByPath(fromPath, true);
       if (fromPage == null) {
         return res.apiv3Err(new ErrorV3('fromPage is Null'), 400);
       }
 
-      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
+      const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user, {}, true);
 
       const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {
         return convertToNewAffiliationPath(fromPath, toPath, subordinatedPage.path);

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

@@ -173,7 +173,7 @@ module.exports = (crowi) => {
     ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').optional().isMongoId().withMessage('revisionId is required'), // required when v4
+      body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
@@ -571,7 +571,8 @@ module.exports = (crowi) => {
     }
 
     try {
-      await crowi.pageService.resumeRenameSubOperation(page);
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {
       logger.error(err);
@@ -594,7 +595,7 @@ module.exports = (crowi) => {
   router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
 
-    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+    const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
 
     const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
 

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

@@ -1,6 +1,6 @@
 import { body } from 'express-validator';
 
-import { allLocales } from '~/next-i18next.config';
+import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 
 
@@ -83,7 +83,7 @@ module.exports = (crowi) => {
           if (!User.isEmailValid(email)) throw new Error('email is not included in whitelist');
           return true;
         }),
-      body('lang').isString().isIn(allLocales),
+      body('lang').isString().isIn(i18n.locales),
       body('isEmailPublished').isBoolean(),
       body('slackMemberId').optional().isString(),
     ],

+ 2 - 2
packages/app/src/server/routes/comment.js

@@ -229,7 +229,7 @@ module.exports = function(crowi, app) {
     const revisionId = commentForm.revision_id;
     const comment = commentForm.comment;
     const position = commentForm.comment_position || -1;
-    const isMarkdown = commentForm.is_markdown;
+    const isMarkdown = commentForm.is_markdown ?? true; // comment is always markdown (https://github.com/weseek/growi/pull/6096)
     const replyTo = commentForm.replyTo;
     const commentEvent = crowi.event('comment');
 
@@ -343,7 +343,7 @@ module.exports = function(crowi, app) {
     const { commentForm } = req.body;
 
     const commentStr = commentForm.comment;
-    const isMarkdown = commentForm.is_markdown;
+    const isMarkdown = commentForm.is_markdown ?? true; // comment is always markdown (https://github.com/weseek/growi/pull/6096)
     const commentId = commentForm.comment_id;
     const revision = commentForm.revision_id;
 

+ 41 - 27
packages/app/src/server/routes/page.js

@@ -279,6 +279,12 @@ module.exports = function(crowi, app) {
     renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
 
+  async function addRenderVarsWhenEmptyPage(renderVars, isEmpty, pageId) {
+    if (!isEmpty) return;
+    renderVars.pageId = pageId;
+    renderVars.isEmpty = isEmpty;
+  }
+
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -333,9 +339,8 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
-
     await addRenderVarsWhenNotFound(renderVars, pathOrId);
-
+    await addRenderVarsWhenEmptyPage(renderVars, req.isEmpty, req.pageId);
     return res.render(view, renderVars);
   }
 
@@ -420,13 +425,10 @@ module.exports = function(crowi, app) {
 
     // empty page
     if (page.isEmpty) {
-      // redirect to page (path) url
-      const url = new URL('https://dummy.origin');
-      url.pathname = page.path;
-      Object.entries(req.query).forEach(([key, value], i) => {
-        url.searchParams.append(key, value);
-      });
-      return res.safeRedirect(urljoin(url.pathname, url.search));
+      req.pageId = page._id;
+      req.pagePath = page.path;
+      req.isEmpty = page.isEmpty;
+      return _notFound(req, res);
     }
 
     const { path } = page; // this must exist
@@ -596,40 +598,42 @@ module.exports = function(crowi, app) {
   async function redirector(req, res, next, path) {
     const { redirectFrom } = req.query;
 
-    const builder = new PageQueryBuilder(Page.find({ path }));
-    await Page.addConditionToFilteringByViewerForList(builder, req.user, true);
-
-    const pages = await builder.query.lean().clone().exec('find');
+    const includeEmpty = true;
+    const builder = new PageQueryBuilder(Page.find({ path }), includeEmpty);
 
-    if (pages.length >= 2) {
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
 
-      // populate to list
-      builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-      const identicalPathPages = await builder.query.lean().exec('find');
+    await Page.addConditionToFilteringByViewerForList(builder, req.user, true);
+    const pages = await builder.query.lean().clone().exec('find');
+    const nonEmptyPages = pages.filter(p => !p.isEmpty);
 
+    if (nonEmptyPages.length >= 2) {
       return res.render('layout-growi/identical-path-page', {
-        identicalPathPages,
+        identicalPathPages: nonEmptyPages,
         redirectFrom,
         path,
       });
     }
 
-    if (pages.length === 1) {
+    if (nonEmptyPages.length === 1) {
+      const nonEmptyPage = nonEmptyPages[0];
       const url = new URL('https://dummy.origin');
-      url.pathname = `/${pages[0]._id}`;
+
+      url.pathname = `/${nonEmptyPage._id}`;
       Object.entries(req.query).forEach(([key, value], i) => {
         url.searchParams.append(key, value);
       });
       return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    // Exclude isEmpty page to handle _notFound or forbidden
-    const isForbidden = await Page.exists({ path, isEmpty: false });
-    if (isForbidden) {
-      req.isForbidden = true;
+    // Processing of nonEmptyPage is finished by the time this code is read
+    // If any pages exist then they should be empty
+    const emptyPage = pages[0];
+    if (emptyPage != null) {
+      req.pageId = emptyPage._id;
+      req.isEmpty = emptyPage.isEmpty;
       return _notFound(req, res);
     }
-
     // redirect by PageRedirect
     const pageRedirect = await PageRedirect.findOne({ fromPath: path });
     if (pageRedirect != null) {
@@ -1181,11 +1185,21 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
+    let creator;
+    if (page.isEmpty) {
+      // If empty, the creator is inherited from the closest non-empty ancestor page.
+      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+      creator = notEmptyClosestAncestor.creator;
+    }
+    else {
+      creator = page.creator;
+    }
+
     debug('Delete page', page._id, page.path);
 
     try {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user, isRecursively)) {
+        if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
@@ -1201,7 +1215,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
-        if (!crowi.pageService.canDelete(page.creator, req.user, isRecursively)) {
+        if (!crowi.pageService.canDelete(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 

+ 25 - 11
packages/app/src/server/service/page-operation.ts

@@ -8,7 +8,9 @@ import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 const logger = loggerFactory('growi:services:page-operation');
 
-const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const {
+  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 const {
@@ -34,8 +36,10 @@ class PageOperationService {
    */
   async afterExpressServerReady(): Promise<void> {
     try {
+      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
+        .sort({ createdAt: 'asc' });
       // execute rename operation
-      await this.executeAllRenameOperationBySystem();
+      await this.executeAllRenameOperationBySystem(pageOps);
     }
     catch (err) {
       logger.error(err);
@@ -45,17 +49,12 @@ class PageOperationService {
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
-  private async executeAllRenameOperationBySystem(): Promise<void> {
-    const Page = this.crowi.model('Page');
-
-    const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-      .sort({ createdAt: 'asc' });
+  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
     if (pageOps.length === 0) return;
 
+    const Page = this.crowi.model('Page');
+
     for await (const pageOp of pageOps) {
-      const {
-        page, toPath, options, user,
-      } = pageOp;
 
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
@@ -64,7 +63,7 @@ class PageOperationService {
       }
 
       // rename
-      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
     }
   }
 
@@ -169,6 +168,21 @@ class PageOperationService {
     clearInterval(timerObj);
   }
 
+  /**
+   * Get ancestor's paths using fromPath and toPath. Merge same paths if any.
+   */
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[] {
+    const fromAncestorsPaths = collectAncestorPaths(fromPath);
+    const toAncestorsPaths = collectAncestorPaths(toPath);
+    // merge duplicate paths and return paths of ancestors
+    return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
+  }
+
+  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
+    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+    return PageOperation.findOne(filter);
+  }
+
 }
 
 export default PageOperationService;

+ 63 - 35
packages/app/src/server/service/page.ts

@@ -29,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -40,7 +40,7 @@ const debug = require('debug')('growi:services:page');
 const logger = loggerFactory('growi:services:page');
 const {
   isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  collectAncestorPaths, isMovablePage, canMoveByPath, hasSlash, generateChildrenRegExp,
+  collectAncestorPaths, isMovablePage, canMoveByPath, isUsersProtectedPages, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -238,7 +238,9 @@ class PageService {
     });
   }
 
-  canDeleteCompletely(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
+  canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
+    if (operator == null || isTopPage(path) || isUsersProtectedPages(path)) return false;
+
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
@@ -247,7 +249,9 @@ class PageService {
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
-  canDelete(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
+  canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
+    if (operator == null || isUsersProtectedPages(path) || isTopPage(path)) return false;
+
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
 
@@ -289,11 +293,11 @@ class PageService {
   }
 
   filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user, isRecursively));
+    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.path, p.creator, user, isRecursively));
   }
 
   filterPagesByCanDelete(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDelete(p.creator, user, isRecursively));
+    return pages.filter(p => p.isEmpty || this.canDelete(p.path, p.creator, user, isRecursively));
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -347,14 +351,24 @@ class PageService {
 
     const isBookmarked: boolean = (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
     const isLiked: boolean = page.isLiked(user);
-    const isAbleToDeleteCompletely: boolean = this.canDeleteCompletely((page.creator as IUserHasId)?._id, user, false); // use normal delete config
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 
+    let creatorId = page.creator;
+    if (page.isEmpty) {
+      // Need non-empty ancestor page to get its creatorId because empty page does NOT have it.
+      // Use creatorId of ancestor page to determine whether the empty page is deletable
+      const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
+      creatorId = notEmptyClosestAncestor.creator;
+    }
+    const isDeletable = this.canDelete(page.path, creatorId, user, false);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely(page.path, creatorId, user, false); // use normal delete config
+
     return {
       data: page,
       meta: {
         ...metadataForGuest,
+        isDeletable,
         isAbleToDeleteCompletely,
         isBookmarked,
         isLiked,
@@ -604,30 +618,31 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async resumeRenameSubOperation(renamedPage: PageDocument): Promise<void> {
-
-    // findOne PageOperation
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': renamedPage._id };
-    const pageOp = await PageOperation.findOne(filter);
-    if (pageOp == null) {
-      throw Error('There is nothing to be processed right now');
-    }
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
     }
+    if (pageOp.toPath == null) {
+      throw Error(`Property toPath is missing which is needed to resume rename operation(${pageOp._id})`);
+    }
 
     const {
-      page, toPath, options, user,
+      page, fromPath, toPath, options, user,
     } = pageOp;
 
-    // check property
-    if (toPath == null) {
-      throw Error(`Property toPath is missing which is needed to resume page operation(${pageOp._id})`);
-    }
-
-    this.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+  }
 
+  /**
+   * Renaming paths and fixing descendantCount of ancestors. It shoud be run synchronously.
+   * `renameSubOperation` to restart rename operation
+   * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
+   */
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+    const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
+    await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
@@ -1414,14 +1429,14 @@ class PageService {
       await Page.replaceTargetWithPage(page, null, true);
     }
 
-    // Delete target
+    // Delete target (only updating an existing document's properties )
     let deletedPage;
     if (!page.isEmpty) {
       deletedPage = await this.deleteNonEmptyTarget(page, user);
     }
     else { // always recursive
       deletedPage = page;
-      await this.deleteEmptyTarget(page);
+      await Page.deleteOne({ _id: page._id, isEmpty: true });
     }
 
     // 1. Update descendantCount
@@ -1488,15 +1503,6 @@ class PageService {
     return deletedPage;
   }
 
-  private async deleteEmptyTarget(page): Promise<void> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    await Page.deleteOne({ _id: page._id, isEmpty: true });
-
-    // update descendantCount of ancestors' before removeLeafEmptyPages
-    await this.updateDescendantCountOfAncestors(page._id, -page.descendantCount, false);
-  }
-
   async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
     await this.deleteDescendantsWithStream(page, user, false);
 
@@ -3061,8 +3067,30 @@ class PageService {
     builder.addConditionToSortPagesByDescPath();
 
     const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
+
+  /**
+   * update descendantCount of the pages sequentially from longer path to shorter path
+   */
+  async updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void> {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToListByPathsArray(paths); // find by paths
+    builder.addConditionToSortPagesByDescPath(); // sort in DESC
 
+    const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
 
+  /**
+   * Recount descendantCount of pages one by one
+   */
+  async recountAndUpdateDescendantCountOfPages(pageCursor: QueryCursor<any>, batchSize:number): Promise<void> {
+    const Page = this.crowi.model('Page');
     const recountWriteStream = new Writable({
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
@@ -3076,8 +3104,8 @@ class PageService {
         callback();
       },
     });
-    aggregatedPages
-      .pipe(createBatchStream(BATCH_SIZE))
+    pageCursor
+      .pipe(createBatchStream(batchSize))
       .pipe(recountWriteStream);
 
     await streamToPromise(recountWriteStream);

+ 4 - 0
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -952,6 +952,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         },
       },
     };
+
+    if (!this.isElasticsearchV6) {
+      query.body.highlight.max_analyzed_offset = 1000000 - 1; // Set the query parameter [max_analyzed_offset] to a value less than index setting [1000000] and this will tolerate long field values by truncating them.
+    }
   }
 
   async search(data: SearchableData<ESQueryTerms>, user, userGroups, option): Promise<ISearchResult<unknown>> {

+ 0 - 1
packages/app/src/server/views/layout-growi/identical-path-page.html

@@ -11,7 +11,6 @@
       data-path="{{ encodeURI(path) }}"
       data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
       data-page-is-not-creatable="true"
-      data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
       data-identical-path="true"
     >
       <div class="flex-grow-1 flex-basis-0 mw-0">

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

@@ -11,6 +11,7 @@
   <div
     id="growi-not-found-context"
     data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
+    data-page-id="{%if pageId %}{{pageId.toString()}}{% endif %}"
   >
   </div>
   <div class="grw-container-convertible">

+ 2 - 0
packages/app/src/server/views/layout/layout.html

@@ -113,8 +113,10 @@
 <div id="page-accessories-modal"></div>
 <div id="descendants-page-list-modal"></div>
 <div id="page-put-back-modal"></div>
+<div id="show-page-accessories-modal"></div>
 <div id="shortcuts-modal"></div>
 
+
 {% block body_end %}
 {% endblock %}
 </body>

+ 1 - 0
packages/app/src/server/views/widget/not_found_content.html

@@ -4,6 +4,7 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
+  data-page-is-empty="{{ isEmpty }}"
   {% if templateTags %}
     data-template-tags="{{ templateTags }}"
   {% endif %}

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

@@ -4,9 +4,9 @@
 
       <p class="alert alert-primary py-3 px-4">
       {% if page.grant == 2 %}
-        <i class="icon-fw icon-link"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-link"></i><strong>{{ t('Anyone with the link') }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 4 %}
-        <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+        <i class="icon-fw icon-lock"></i><strong>{{ t('Only me') }}</strong> ({{ t('Browsing of this page is restricted') }})
       {% elseif page.grant == 5 %}
         <i class="icon-fw icon-organization"></i><strong>'{{ page.grantedGroup.name | preventXss }}' only</strong> ({{ t('Browsing of this page is restricted') }})
       {% endif %}

+ 0 - 2
packages/app/src/server/views/widget/page_content.html

@@ -12,7 +12,6 @@
   data-page-grant="{{ grant }}"
   data-page-grant-group="{{ grantedGroupId }}"
   data-page-grant-group-name="{{ grantedGroupName }}"
-  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-is-not-creatable="false"
   data-page-created-at="{{ page.createdAt|datetz('Y/m/d H:i:s') }}"
   data-page-creator="{% if page && page.creator %}{{ page.creator|json }}{% endif %}"
@@ -30,7 +29,6 @@
 <div id="content-main" class="content-main d-flex"
   data-path="{{ encodeURI(path) }}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-is-deleted="{% if page.isDeleted() %}true{% else %}false{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   >
 {% endif %}

+ 49 - 4
packages/app/src/stores/comment.tsx

@@ -1,8 +1,8 @@
 import useSWR, { SWRResponse } from 'swr';
 
-import { apiGet } from '~/client/util/apiv1-client';
+import { apiGet, apiPost } from '~/client/util/apiv1-client';
 
-import { ICommentHasIdList } from '../interfaces/comment';
+import { ICommentHasIdList, ICommentPostArgs } from '../interfaces/comment';
 import { Nullable } from '../interfaces/common';
 
 type IResponseComment = {
@@ -10,10 +10,55 @@ type IResponseComment = {
   ok: boolean,
 }
 
-export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<ICommentHasIdList, Error> => {
+type CommentOperation = {
+  update(comment: string, revisionId: string, commentId: string): Promise<void>,
+  post(args: ICommentPostArgs): Promise<void>
+}
+
+export const useSWRxPageComment = (pageId: Nullable<string>): SWRResponse<ICommentHasIdList, Error> & CommentOperation => {
   const shouldFetch: boolean = pageId != null;
-  return useSWR(
+
+  const swrResponse = useSWR(
     shouldFetch ? ['/comments.get', pageId] : null,
     (endpoint, pageId) => apiGet(endpoint, { page_id: pageId }).then((response:IResponseComment) => response.comments),
   );
+
+  const update = async(comment: string, revisionId: string, commentId: string) => {
+    const { mutate } = swrResponse;
+    await apiPost('/comments.update', {
+      commentForm: {
+        comment,
+        revision_id: revisionId,
+        comment_id: commentId,
+      },
+    });
+    mutate();
+  };
+
+  const post = async(args: ICommentPostArgs) => {
+    const { mutate } = swrResponse;
+    const { commentForm, slackNotificationForm } = args;
+    const { comment, revisionId, replyTo } = commentForm;
+    const { isSlackEnabled, slackChannels } = slackNotificationForm;
+
+    await apiPost('/comments.add', {
+      commentForm: {
+        comment,
+        page_id: pageId,
+        revision_id: revisionId,
+        replyTo,
+      },
+      slackNotificationForm: {
+        isSlackEnabled,
+        slackChannels,
+      },
+    });
+    mutate();
+  };
+
+  return {
+    ...swrResponse,
+    update,
+    post,
+  };
 };

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

@@ -36,6 +36,10 @@ export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nu
   return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 
+export const useEmptyPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('emptyPageId', initialData);
+};
+
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData);
 };
@@ -64,10 +68,6 @@ export const useIsTrashPage = (initialData?: boolean): SWRResponse<boolean, Erro
   return useStaticSWR<boolean, Error>('isTrashPage', initialData, { fallbackData: false });
 };
 
-export const useIsDeleted = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isDeleted', initialData, { fallbackData: false });
-};
-
 export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 };
@@ -156,6 +156,9 @@ export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRRespon
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 
+export const useIsEmptyPage = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isEmptyPage', initialData);
+};
 export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('hasParent', initialData);
 };

+ 88 - 18
packages/app/src/stores/ui.tsx

@@ -10,17 +10,20 @@ import useSWRImmutable from 'swr/immutable';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { Nullable } from '~/interfaces/common';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
-  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser, useIsDeleted,
+  useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
+  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser,
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
+import { constants } from 'zlib';
 
 const { isSharedPage } = pagePathUtils;
 
@@ -68,27 +71,26 @@ export const useIsMobile = (): SWRResponse<boolean, Error> => {
 };
 
 const updateBodyClassesByEditorMode = (newEditorMode: EditorMode, isSidebar = false) => {
+  const bodyElement = document.getElementsByTagName('body')[0];
+  if (bodyElement == null) {
+    logger.warn('The body tag was not successfully obtained');
+    return;
+  }
   switch (newEditorMode) {
     case EditorMode.View:
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('editing-sidebar');
+      bodyElement.classList.remove('on-edit', 'builtin-editor', 'hackmd', 'editing-sidebar');
       break;
     case EditorMode.Editor:
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
+      bodyElement.classList.add('on-edit', 'builtin-editor');
+      bodyElement.classList.remove('hackmd');
       // editing /Sidebar
       if (isSidebar) {
-        $('body').addClass('editing-sidebar');
+        bodyElement.classList.add('editing-sidebar');
       }
       break;
     case EditorMode.HackMD:
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('editing-sidebar');
+      bodyElement.classList.add('on-edit', 'hackmd');
+      bodyElement.classList.remove('builtin-editor', 'editing-sidebar');
       break;
   }
 };
@@ -278,6 +280,72 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
   );
 };
 
+type SidebarConfigOption = {
+  update: () => Promise<void>,
+  isSidebarDrawerMode: boolean|undefined,
+  isSidebarClosedAtDockMode: boolean|undefined,
+  setIsSidebarDrawerMode: (isSidebarDrawerMode: boolean) => void,
+  setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode: boolean) => void
+}
+
+export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> & SidebarConfigOption => {
+  const swrResponse = useSWRImmutable<ISidebarConfig>(
+    '/customize-setting/sidebar',
+    endpoint => apiv3Get(endpoint).then(result => result.data),
+  );
+  return {
+    ...swrResponse,
+    update: async() => {
+      const { data } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const { isSidebarDrawerMode, isSidebarClosedAtDockMode } = data;
+
+      const updateData = {
+        isSidebarDrawerMode,
+        isSidebarClosedAtDockMode,
+      };
+
+      // invoke API
+      await apiv3Put('/customize-setting/sidebar', updateData);
+    },
+    isSidebarDrawerMode: swrResponse.data?.isSidebarDrawerMode,
+    isSidebarClosedAtDockMode: swrResponse.data?.isSidebarClosedAtDockMode,
+    setIsSidebarDrawerMode: (isSidebarDrawerMode) => {
+      const { data, mutate } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const updateData = {
+        isSidebarDrawerMode,
+      };
+
+      // update isSidebarDrawerMode in cache, not revalidate
+      mutate({ ...data, ...updateData }, false);
+
+    },
+    setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode) => {
+      const { data, mutate } = swrResponse;
+
+      if (data == null) {
+        return;
+      }
+
+      const updateData = {
+        isSidebarClosedAtDockMode,
+      };
+
+      // update isSidebarClosedAtDockMode in cache, not revalidate
+      mutate({ ...data, ...updateData }, false);
+    },
+  };
+};
+
 export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
 };
@@ -328,19 +396,21 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
 
 export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
   const { data: currentUser } = useCurrentUser();
-  const { data: isDeleted } = useIsDeleted();
+  const { data: isTrashPage } = useIsTrashPage();
 
-  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isDeleted && currentUser != null);
+  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null);
 };
 
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
+  const { data: emptyPageId } = useEmptyPageId();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
 
-  const includesUndefined = [currentPageId, isTrashPage, isSharedUser].some(v => v === undefined);
-  const isPageExist = currentPageId != null;
+  const pageId = currentPageId ?? emptyPageId;
+  const includesUndefined = [pageId, isTrashPage, isSharedUser].some(v => v === undefined);
+  const isPageExist = pageId != null;
 
   return useSWRImmutable(
     includesUndefined ? null : key,

+ 7 - 0
packages/app/src/styles/_admin.scss

@@ -59,6 +59,13 @@ $slack-work-space-name-card-border: #efc1f6;
     }
   }
 
+  .admin-customize-sidebar-icon {
+    svg {
+      width: 20px;
+      height: 20px;
+    }
+  }
+
   .admin-notification {
     table .admin-notif-list {
       td {

+ 90 - 0
packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts

@@ -0,0 +1,90 @@
+context('Click page icons button', () => {
+  const ssPrefix = 'click-page-icon-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('Successfully subscribe/unsubscribe a page', () => {
+    cy.visit('/Sandbox');
+    cy.get('#grw-subnav-container').within(() => {
+      // Subscribe
+      cy.get('#subscribe-button').eq(0).click({force: true});
+      cy.get('#subscribe-button').eq(0).should('have.class', 'active');
+      cy.screenshot(`${ssPrefix}1-subscribe-page`);
+
+      // Unsubscribe
+      cy.get('#subscribe-button.active').eq(0).click({force: true});
+      cy.get('#subscribe-button').eq(0).should('not.have.class', 'active');
+      cy.screenshot(`${ssPrefix}2-unsubscribe-page`);
+    });
+  });
+
+  it('Successfully Like / Dislike a page', () => {
+    cy.visit('/Sandbox');
+    cy.get('#grw-subnav-container').within(() => {
+      cy.get('#like-button').click({force: true});
+      cy.get('#like-button').should('have.class', 'active');
+      cy.screenshot(`${ssPrefix}3-like-page`);
+      cy.get('#po-total-likes').click({force: true});
+    });
+    cy.get('.user-list-popover').should('be.visible');
+
+    cy.get('#grw-subnav-container').within(() => {
+      cy.screenshot(`${ssPrefix}4-likes-counter`);
+      cy.get('#like-button.active').click({force: true});
+      cy.get('#like-button').should('not.have.class', 'active');
+      cy.screenshot(`${ssPrefix}5-dislike-page`);
+      cy.get('#po-total-likes').click({force: true});
+    });
+
+    cy.get('.user-list-popover').should('be.visible');
+
+    cy.get('#grw-subnav-container').within(() => {
+      cy.screenshot(`${ssPrefix}6-likes-counter`);
+    });
+  });
+
+  it('Successfully Bookmark / Unbookmark a page', () => {
+    cy.visit('/Sandbox');
+    cy.get('#grw-subnav-container').within(() => {
+      cy.get('#bookmark-button').click({force: true});
+      cy.get('#bookmark-button').should('have.class', 'active');
+      cy.screenshot(`${ssPrefix}7-bookmark-page`);
+      cy.get('#po-total-bookmarks').click({force: true});
+    });
+    cy.get('.user-list-popover').should('be.visible');
+
+    cy.get('#grw-subnav-container').within(() => {
+      cy.screenshot(`${ssPrefix}8-bookmarks-counter`);
+      cy.get('#bookmark-button.active').click({force: true});
+      cy.get('#bookmark-button').should('not.have.class', 'active');
+      cy.screenshot(`${ssPrefix}9-unbookmark-page`);
+      cy.get('#po-total-bookmarks').click({force: true});
+    });
+
+    cy.get('.user-list-popover').should('be.visible');
+
+    cy.get('#grw-subnav-container').within(() => {
+      cy.screenshot(`${ssPrefix}10-bookmarks-counter`);
+    });
+  });
+
+  it('Successfully display list of "seen by user"', () => {
+    cy.visit('/Sandbox');
+    cy.get('#grw-subnav-container').within(() => {
+      cy.get('#btn-seen-user').click({force: true});
+    });
+    cy.get('.user-list-popover').should('be.visible');
+
+    cy.get('#grw-subnav-container').within(() => {
+      cy.screenshot(`${ssPrefix}11-seen-user-list`);
+    });
+  });
+
+});

+ 174 - 0
packages/app/test/cypress/integration/30-search/search.spec.ts

@@ -68,3 +68,177 @@ context('Access to legacy private pages', () => {
   });
 
 });
+
+context('Search all pages', () => {
+  const ssPrefix = 'search-all-pages-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it(`Search all pages by word is successfully loaded`, () => {
+    const searchText = 'help';
+
+    cy.visit('/');
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-menu.dropdown-menu.show').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}1-search-input-focused`);
+    })
+
+    cy.get('.rbt-input-main').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
+    cy.get('.rbt-input-main').type('{enter}');
+
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').eq(1).click();
+    cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
+
+    //Add bookmark
+    cy.getByTestid('add-remove-bookmark-btn').click({force: true});
+    cy.get('.btn-bookmark.active').should('be.visible');
+    cy.screenshot(`${ssPrefix}5-add-bookmark`, {capture: 'viewport'});
+
+    // Duplicate page
+    cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
+    cy.getByTestid('page-duplicate-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}6-duplicate-page`, {capture: 'viewport'});
+
+    // Close Modal
+    cy.get('body').type('{esc}');
+
+    // Move / Rename Page
+    cy.getByTestid('open-page-move-rename-modal-btn').first().click({force: true});
+    cy.getByTestid('page-rename-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}7-move-rename-page`, {capture: 'viewport'});
+
+    // Close Modal
+    cy.get('body').type('{esc}');
+
+    // Delete page
+    cy.getByTestid('open-page-delete-modal-btn').first().click({ force: true});
+    cy.getByTestid('page-delete-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}8-delete-page`, {capture: 'viewport'});
+  });
+
+  it(`Search all pages by tag is successfully loaded `, () => {
+    const tag = 'help';
+    const searchText = `tag:${tag}`;
+    cy.visit('/');
+    // Add tag
+    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').click({force: true});
+    cy.get('#edit-tag-modal').should('be.visible');
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('.rbt-input-main').type(tag);
+      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
+      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
+      cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
+    });
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('div.modal-footer > button').click();
+    });
+
+    cy.visit('/');
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-input-main').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}1-insert-search-text-with-tag`, { capture: 'viewport'});
+    cy.get('.rbt-input-main').type('{enter}');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}2-search-with-tag-result`, {capture: 'viewport'});
+    cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.screenshot(`${ssPrefix}3-click-three-dots-menu-search-with-tag`, {capture: 'viewport'});
+
+  });
+  it('Successfully order page search results by tag', () => {
+    const tag = 'help';
+
+    cy.visit('/');
+    cy.get('.grw-taglabels-container > form > a').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');
+    cy.screenshot(`${ssPrefix}1-tag-order-click-tag-name`, {capture: 'viewport'});
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(1)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}2-tag-order-by-relevance`);
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(2)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-tag-order-by-creation-date`);
+
+    cy.get('.grw-search-page-nav').within(() => {
+      cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.dropdown-menu-right').should('be.visible');
+      cy.get('.dropdown-menu-right > button:nth-child(3)').click({force: true});
+    });
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}4-tag-order-by-last-update-date`);
+  });
+
+});
+
+context('Search current tree with "prefix":', () => {
+  const ssPrefix = 'search-current-tree-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it(`Search current tree by word is successfully loaded`, () => {
+    const searchText = 'help';
+    cy.visit('/');
+    cy.getByTestid('select-search-scope').first().click({force: true});
+    cy.get('.input-group-prepend.show > div > button:nth-child(2)').click({force: true});
+    cy.get('.rbt-input').click();
+    cy.get('.rbt-menu.dropdown-menu.show').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}1-search-input-focused`);
+    })
+    cy.get('.rbt-input').type(`${searchText}`);
+    cy.screenshot(`${ssPrefix}2-insert-search-text`, { capture: 'viewport'});
+    cy.get('.rbt-input').type('{enter}');
+
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}3-search-page-results`, { capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').first().click();
+    cy.screenshot(`${ssPrefix}4-click-three-dots-menu`, {capture: 'viewport'});
+  });
+
+});

+ 346 - 2
packages/app/test/integration/models/v5.page.test.js

@@ -163,6 +163,12 @@ describe('Page', () => {
     const pageIdUpd11 = new mongoose.Types.ObjectId();
     const pageIdUpd12 = new mongoose.Types.ObjectId();
     const pageIdUpd13 = new mongoose.Types.ObjectId();
+    const pageIdUpd14 = new mongoose.Types.ObjectId();
+    const pageIdUpd15 = new mongoose.Types.ObjectId();
+    const pageIdUpd16 = new mongoose.Types.ObjectId();
+    const pageIdUpd17 = new mongoose.Types.ObjectId();
+    const pageIdUpd18 = new mongoose.Types.ObjectId();
+    const pageIdUpd19 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -337,7 +343,138 @@ describe('Page', () => {
         descendantCount: 0,
       },
       {
-        path: '/mup24',
+        _id: pageIdUpd14,
+        path: '/mup24_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup24_pub/mup25_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd14,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup26_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd15,
+        path: '/mup27_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup27_pub/mup28_owner',
+        grant: Page.GRANT_OWNER,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd15,
+        grantedUsers: [pModelUserId1],
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd16,
+        path: '/mup29_A',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup29_A/mup30_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd16,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd17,
+        path: '/mup31_A',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup31_A/mup32_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd17,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd18,
+        path: '/mup33_C',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdC,
+        creator: pModelUserId3,
+        lastUpdateUser: pModelUserId3,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup33_C/mup34_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId3],
+        creator: pModelUserId3,
+        lastUpdateUser: pModelUserId3,
+        isEmpty: false,
+        parent: pageIdUpd18,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd19,
+        path: '/mup35_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup35_owner/mup36_owner',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [pModelUserId1],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd19,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup40', // used this number to resolve conflict
         grant: Page.GRANT_OWNER,
         grantedUsers: [dummyUser1._id],
         creator: dummyUser1,
@@ -434,7 +571,7 @@ describe('Page', () => {
 
     describe('Changing grant to GRANT_RESTRICTED', () => {
       test('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async() => {
-        const path = '/mup24';
+        const path = '/mup40';
         const _page = await Page.findOne({ path, grant: Page.GRANT_OWNER, grantedUsers: [dummyUser1._id] });
         expect(_page).toBeTruthy();
 
@@ -559,6 +696,213 @@ describe('Page', () => {
         expect(page1.grantedUsers).not.toStrictEqual([dummyUser1._id]);
       });
     });
+    describe('Changing grant to GRANT_USER_GROUP', () => {
+      describe('update grant of a page under a page with GRANT_PUBLIC', () => {
+        test('successfully change to GRANT_USER_GROUP from GRANT_PUBLIC if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const path1 = '/mup24_pub';
+          const path2 = '/mup24_pub/mup25_pub';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC }); // out of update scope
+          const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC, parent: _page1._id }); // update target
+          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 page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          // check page2 grant and group
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+        });
+
+        test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const _path1 = '/mup26_awl';
+          // 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 page1 = await Page.findById(_page1._id);
+          expect(page1).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page1._id);
+
+          // updated page
+          expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page1.grantedGroup._id).toStrictEqual(groupIdA);
+
+          // parent's grant check
+          const parent = await Page.findById(page1.parent);
+          expect(parent.grant).toBe(Page.GRANT_PUBLIC);
+
+        });
+
+        test('successfully change to GRANT_USER_GROUP from GRANT_OWNER if parent page is GRANT_PUBLIC', async() => {
+          // path
+          const path1 = '/mup27_pub';
+          const path2 = '/mup27_pub/mup28_owner';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC }); // out of update scope
+          const _page2 = await Page.findOne({
+            path: path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
+          }); // update target
+          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 page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          // grant check
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdA);
+          expect(page2.grantedUsers.length).toBe(0);
+        });
+      });
+      describe('update grant of a page under a page with GRANT_USER_GROUP', () => {
+        test('successfully change to GRANT_USER_GROUP if the group to set is the child or descendant of the parent page group', async() => {
+          // path
+          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 _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 };
+
+          // 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)
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page2._id);
+
+          expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(page2.grantedGroup._id).toStrictEqual(groupIdB);
+          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 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);
+        });
+        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 _page2 = await Page.findOne({ // update target
+            path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
+          });
+          expect(_page1).toBeTruthy();
+          expect(_page2).toBeTruthy();
+
+          // group
+          const _groupIsolated = await UserGroup.findById(groupIdIsolate);
+          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)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page1).toBeTruthy();
+
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+        test('Fail to change to GRANT_USER_GROUP if the group to set is an ancestor of the parent page group', async() => {
+          // path
+          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 _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 };
+
+          // Group relation(parent -> child): groupIdA -> groupIdB -> groupIdC
+          // 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)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1._id);
+          const page2 = await Page.findById(_page2._id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser3._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+      });
+      describe('update grant of a page under a page with GRANT_OWNER', () => {
+        test('Fail to change from GRNAT_OWNER', async() => {
+          // path
+          const path1 = '/mup35_owner';
+          const path2 = '/mup35_owner/mup36_owner';
+          // page
+          const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1] });
+          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: groupIdA };
+          await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(groupIdA)
+            .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+          const page1 = await Page.findById(_page1.id);
+          const page2 = await Page.findById(_page2.id);
+          expect(page1).toBeTruthy();
+          expect(page2).toBeTruthy();
+          expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
+          expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
+          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+        });
+      });
+
+    });
 
   });
 });

+ 122 - 237
packages/app/test/integration/service/v5.page.test.ts

@@ -103,6 +103,9 @@ describe('Test page service methods', () => {
     const pageId15 = new mongoose.Types.ObjectId();
     const pageId16 = new mongoose.Types.ObjectId();
     const pageId17 = new mongoose.Types.ObjectId();
+    const pageId18 = new mongoose.Types.ObjectId();
+    const pageId19 = new mongoose.Types.ObjectId();
+    const pageId20 = new mongoose.Types.ObjectId();
 
     await Page.insertMany([
       {
@@ -158,7 +161,7 @@ describe('Test page service methods', () => {
       {
         _id: pageId5,
         path: '/resume_rename_4/resume_rename_5',
-        parent: pageId0,
+        parent: pageId4,
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
@@ -303,6 +306,54 @@ describe('Test page service methods', () => {
         descendantCount: 0,
         isEmpty: false,
       },
+      {
+        _id: pageId18,
+        path: '/fix_descendantCount_1',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        _id: pageId19,
+        path: '/fix_descendantCount_1/fix_descendantCount_2',
+        parent: pageId18,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: true,
+      },
+      {
+        path: '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3',
+        parent: pageId19,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        _id: pageId20,
+        path: '/fix_descendantCount_4',
+        parent: rootPage._id,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
+      {
+        path: '/fix_descendantCount_4/fix_descendantCount_5',
+        parent: pageId20,
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        descendantCount: 100, // broken
+        isEmpty: false,
+      },
     ]);
 
     /**
@@ -312,14 +363,10 @@ describe('Test page service methods', () => {
     pageOpId2 = new mongoose.Types.ObjectId();
     pageOpId3 = new mongoose.Types.ObjectId();
     pageOpId4 = new mongoose.Types.ObjectId();
-    pageOpId5 = new mongoose.Types.ObjectId();
-    pageOpId6 = new mongoose.Types.ObjectId();
     const pageOpRevisionId1 = new mongoose.Types.ObjectId();
     const pageOpRevisionId2 = new mongoose.Types.ObjectId();
     const pageOpRevisionId3 = new mongoose.Types.ObjectId();
     const pageOpRevisionId4 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId5 = new mongoose.Types.ObjectId();
-    const pageOpRevisionId6 = new mongoose.Types.ObjectId();
 
     await PageOperation.insertMany([
       {
@@ -438,76 +485,18 @@ describe('Test page service methods', () => {
         },
         unprocessableExpiryDate: null,
       },
-      {
-        _id: pageOpId5,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_11/resume_rename_13',
-        toPath: '/resume_rename_11/resume_rename_12/resume_rename_13',
-        page: {
-          _id: pageId12,
-          parent: pageId10,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_11/resume_rename_13',
-          revision: pageOpRevisionId5,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
-      {
-        _id: pageOpId6,
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/resume_rename_15/resume_rename_16/resume_rename_18',
-        toPath: '/resume_rename_15/resume_rename_17/resume_rename_18',
-        page: {
-          _id: pageId16,
-          parent: pageId14,
-          descendantCount: 1,
-          isEmpty: false,
-          path: '/resume_rename_15/resume_rename_16/resume_rename_18',
-          revision: pageOpRevisionId6,
-          status: 'published',
-          grant: Page.GRANT_PUBLIC,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: true,
-        },
-        unprocessableExpiryDate: new Date(),
-      },
     ]);
   });
 
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(page) => {
-      const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(page);
+    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+      const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
 
-      const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
+      const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
-      mockedRenameSubOperation.mockRestore();
-      await crowi.pageService.renameSubOperation(...argsForRenameSubOperation);
+      mockedPathsAndDescendantCountOfAncestors.mockRestore();
+      await crowi.pageService.fixPathsAndDescendantCountOfAncestors(...argsForRenameSubOperation);
     };
 
     test('it should successfully restart rename operation', async() => {
@@ -547,7 +536,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -605,7 +594,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
 
       // rename
-      await resumeRenameSubOperation(_page1);
+      await resumeRenameSubOperation(_page1, _pageOperation);
 
       // page
       const page0 = await Page.findById(_page0._id);
@@ -630,43 +619,6 @@ describe('Test page service methods', () => {
       expect(page1.descendantCount).toBe(1);
       expect(page2.descendantCount).toBe(0);
     });
-
-    test('it should fail and throw error if PageOperation is not found', async() => {
-      // create dummy page operation data not stored in DB
-      const notExistPageOp = {
-        _id: new mongoose.Types.ObjectId(),
-        actionType: 'Rename',
-        actionStage: 'Sub',
-        fromPath: '/FROM_NOT_EXIST',
-        toPath: 'TO_NOT_EXIST',
-        page: {
-          _id: new mongoose.Types.ObjectId(),
-          parent: rootPage._id,
-          descendantCount: 2,
-          isEmpty: false,
-          path: '/NOT_EXIST_PAGE',
-          revision: new mongoose.Types.ObjectId(),
-          status: 'published',
-          grant: 1,
-          grantedUsers: [],
-          grantedGroup: null,
-          creator: dummyUser1._id,
-          lastUpdateUser: dummyUser1._id,
-        },
-        user: {
-          _id: dummyUser1._id,
-        },
-        options: {
-          createRedirectPage: false,
-          updateMetadata: false,
-        },
-        unprocessableExpiryDate: new Date(),
-      };
-
-      await expect(resumeRenameSubOperation(notExistPageOp))
-        .rejects.toThrow(new Error('There is nothing to be processed right now'));
-    });
-
     test('it should fail and throw error if the current time is behind unprocessableExpiryDate', async() => {
       // path before renaming
       const _path0 = '/resume_rename_4'; // out of renaming scope
@@ -683,21 +635,21 @@ describe('Test page service methods', () => {
       // page operation
       const fromPath = '/resume_rename_5';
       const toPath = '/resume_rename_4/resume_rename_5';
-      const pageOperation = await PageOperation.findOne({
+      const _pageOperation = await PageOperation.findOne({
         _id: pageOpId2, fromPath, toPath, 'page._id': _page1._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
       });
-      expect(pageOperation).toBeTruthy();
+      expect(_pageOperation).toBeTruthy();
 
       // Make `unprocessableExpiryDate` 15 seconds ahead of current time.
       // The number 15 seconds has no meaning other than placing time in the furue.
-      await PageOperation.findByIdAndUpdate(pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) });
+      const pageOperation = await PageOperation.findByIdAndUpdate(_pageOperation._id, { unprocessableExpiryDate: addSeconds(new Date(), 15) }, { new: true });
+      expect(pageOperation).toBeTruthy();
 
-      await expect(resumeRenameSubOperation(_page1)).rejects.toThrow(new Error('This page operation is currently being processed'));
+      await expect(resumeRenameSubOperation(_page1, pageOperation)).rejects.toThrow(new Error('This page operation is currently being processed'));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
     test('Missing property(toPath) for PageOperation should throw error', async() => {
       // page
       const _path1 = '/resume_rename_7';
@@ -710,152 +662,85 @@ describe('Test page service methods', () => {
       });
       expect(pageOperation).toBeTruthy();
 
-      const promise = resumeRenameSubOperation(_page1);
-      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume page operation(${pageOperation._id})`));
+      const promise = resumeRenameSubOperation(_page1, pageOperation);
+      await expect(promise).rejects.toThrow(new Error(`Property toPath is missing which is needed to resume rename operation(${pageOperation._id})`));
 
       // cleanup
       await PageOperation.findByIdAndDelete(pageOperation._id);
     });
-
-    test(`it should succeed but 2 extra descendantCount should be added
-    if the page operation was interrupted right after increasing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_11'; // out of renaming scope
-      const _path1 = '/resume_rename_11/resume_rename_12'; // out of renaming scope
-      const _path2 = '/resume_rename_11/resume_rename_12/resume_rename_13'; // renamed already
-      const _path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_11';
-      const path1 = '/resume_rename_11/resume_rename_12';
-      const path2 = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const path3 = '/resume_rename_11/resume_rename_12/resume_rename_13/resume_rename_14';
-
-      // page
-      const _page0 = await Page.findOne({ path: _path0 });
-      const _page1 = await Page.findOne({ path: _path1 });
-      const _page2 = await Page.findOne({ path: _path2 });
-      const _page3 = await Page.findOne({ path: _path3 });
-      expect(_page0).toBeTruthy();
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(3);
-      expect(_page1.descendantCount).toBe(2);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_11/resume_rename_13';
-      const toPath = '/resume_rename_11/resume_rename_12/resume_rename_13';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId5, fromPath, toPath, 'page._id': _page2._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page2);
-
-      // page
-      const page0 = await Page.findById(_page0._id);
-      const page1 = await Page.findById(_page1._id);
-      const page2 = await Page.findById(_page2._id);
-      const page3 = await Page.findById(_page3._id);
-      expect(page0).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be added to page1
-      expect(page0.descendantCount).toBe(3);
-      expect(page1.descendantCount).toBe(3); // originally 2, +1 in Main, -1 in Sub, +2 for new descendants
-      expect(page2.descendantCount).toBe(1);
-      expect(page3.descendantCount).toBe(0);
-    });
-
-    test(`it should succeed but 2 extra descendantCount should be subtracted from ex parent page
-    if the page operation was interrupted right after reducing ancestor's descendantCount in renameSubOperation`, async() => {
-      // paths before renaming
-      const _path0 = '/resume_rename_15'; // out of renaming scope
-      const _path1 = '/resume_rename_15/resume_rename_16'; // out of renaming scope
-      const _path2 = '/resume_rename_15/resume_rename_17'; // out of renaming scope
-      const _path3 = '/resume_rename_15/resume_rename_17/resume_rename_18'; // renamed already
-      const _path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19'; // renamed already
-
-      // paths after renaming
-      const path0 = '/resume_rename_15';
-      const path1 = '/resume_rename_15/resume_rename_16';
-      const path2 = '/resume_rename_15/resume_rename_17';
-      const path3 = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const path4 = '/resume_rename_15/resume_rename_17/resume_rename_18/resume_rename_19';
-
+  });
+  describe('updateDescendantCountOfPagesWithPaths', () => {
+    test('should fix descendantCount of pages with one of the given paths', async() => {
+      // path
+      const _path1 = '/fix_descendantCount_1';
+      const _path2 = '/fix_descendantCount_1/fix_descendantCount_2'; // empty
+      const _path3 = '/fix_descendantCount_1/fix_descendantCount_2/fix_descendantCount_3';
+      const _path4 = '/fix_descendantCount_4';
+      const _path5 = '/fix_descendantCount_4/fix_descendantCount_5';
       // page
-      const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
       const _page2 = await Page.findOne({ path: _path2 });
       const _page3 = await Page.findOne({ path: _path3 });
       const _page4 = await Page.findOne({ path: _path4 });
-      expect(_page0).toBeTruthy();
+      const _page5 = await Page.findOne({ path: _path5 });
+      // check existance
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
       expect(_page3).toBeTruthy();
       expect(_page4).toBeTruthy();
-
-      // descendantCount
-      expect(_page0.descendantCount).toBe(2);
-      expect(_page1.descendantCount).toBe(0);
-      expect(_page2.descendantCount).toBe(1);
-      expect(_page3.descendantCount).toBe(1);
-      expect(_page4.descendantCount).toBe(0);
-
-      // page operation
-      const fromPath = '/resume_rename_15/resume_rename_16/resume_rename_18';
-      const toPath = '/resume_rename_15/resume_rename_17/resume_rename_18';
-      const _pageOperation = await PageOperation.findOne({
-        _id: pageOpId6, fromPath, toPath, 'page._id': _page3._id, actionType: PageActionType.Rename, actionStage: PageActionStage.Sub,
-      });
-      expect(_pageOperation).toBeTruthy();
-
-      // rename
-      await resumeRenameSubOperation(_page3);
+      expect(_page5).toBeTruthy();
+      // check descendantCount (all broken)
+      expect(_page1.descendantCount).toBe(100);
+      expect(_page2.descendantCount).toBe(100);
+      expect(_page3.descendantCount).toBe(100);
+      expect(_page4.descendantCount).toBe(100);
+      expect(_page5.descendantCount).toBe(100);
+      // check isEmpty
+      expect(_page1.isEmpty).toBe(false);
+      expect(_page2.isEmpty).toBe(true);
+      expect(_page3.isEmpty).toBe(false);
+      expect(_page4.isEmpty).toBe(false);
+      expect(_page5.isEmpty).toBe(false);
+      // check parent
+      expect(_page1.parent).toStrictEqual(rootPage._id);
+      expect(_page2.parent).toStrictEqual(_page1._id);
+      expect(_page3.parent).toStrictEqual(_page2._id);
+      expect(_page4.parent).toStrictEqual(rootPage._id);
+      expect(_page5.parent).toStrictEqual(_page4._id);
+
+      await crowi.pageService.updateDescendantCountOfPagesWithPaths([_path1, _path2, _path3, _path4, _path5]);
 
       // page
-      const page0 = await Page.findById(_page0._id);
       const page1 = await Page.findById(_page1._id);
       const page2 = await Page.findById(_page2._id);
       const page3 = await Page.findById(_page3._id);
       const page4 = await Page.findById(_page4._id);
-      expect(page0).toBeTruthy();
+      const page5 = await Page.findById(_page5._id);
+
+      // check existance
       expect(page1).toBeTruthy();
       expect(page2).toBeTruthy();
       expect(page3).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page0.path).toBe(path0);
-      expect(page1.path).toBe(path1);
-      expect(page2.path).toBe(path2);
-      expect(page3.path).toBe(path3);
-      expect(page4.path).toBe(path4);
-
-      // page operation
-      const pageOperation = await PageOperation.findById(_pageOperation._id);
-      expect(pageOperation).toBeNull(); // should not exist
-
-      // 2 extra descendants should be subtracted from page1
-      expect(page0.descendantCount).toBe(2);
-      expect(page1.descendantCount).toBe(-2); // originally 0, -2 for old descendants
-      expect(page2.descendantCount).toBe(2); // originally 1, -1 in Sub, +2 for new descendants
-      expect(page3.descendantCount).toBe(1);
-      expect(page4.descendantCount).toBe(0);
+      expect(page4).toBeTruthy();
+      expect(page5).toBeTruthy();
+      // check descendantCount (all fixed)
+      expect(page1.descendantCount).toBe(1);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0);
+      expect(page4.descendantCount).toBe(1);
+      expect(page5.descendantCount).toBe(0);
+      // check isEmpty
+      expect(page1.isEmpty).toBe(false);
+      expect(page2.isEmpty).toBe(true);
+      expect(page3.isEmpty).toBe(false);
+      expect(page4.isEmpty).toBe(false);
+      expect(page5.isEmpty).toBe(false);
+      // check parent
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+      expect(page3.parent).toStrictEqual(page2._id);
+      expect(page4.parent).toStrictEqual(rootPage._id);
+      expect(page5.parent).toStrictEqual(page4._id);
     });
   });
 });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.10-slackbot-proxy.0",
+  "version": "5.0.11-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.10-RC.0",
+    "@growi/slack": "^5.0.11-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.10-RC.0",
+  "version": "5.0.11-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

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