Taichi Masuyama 4 лет назад
Родитель
Сommit
973611f97b
100 измененных файлов с 3417 добавлено и 1428 удалено
  1. 0 2
      packages/app/config/webpack.dev.dll.js
  2. 2 2
      packages/app/package.json
  3. 9 0
      packages/app/resource/locales/en_US/admin/admin.json
  4. 2 0
      packages/app/resource/locales/en_US/translation.json
  5. 9 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  6. 2 0
      packages/app/resource/locales/ja_JP/translation.json
  7. 9 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  8. 2 0
      packages/app/resource/locales/zh_CN/translation.json
  9. 2 6
      packages/app/src/client/app.jsx
  10. 31 31
      packages/app/src/client/legacy/crowi.js
  11. 19 0
      packages/app/src/client/services/AdminAppContainer.js
  12. 2 0
      packages/app/src/client/services/AdminHomeContainer.js
  13. 37 8
      packages/app/src/client/services/ContextExtractor.tsx
  14. 55 137
      packages/app/src/client/services/NavigationContainer.js
  15. 6 5
      packages/app/src/client/services/PageContainer.js
  16. 28 0
      packages/app/src/client/services/user-ui-settings.ts
  17. 18 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  18. 25 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  19. 57 0
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  20. 61 0
      packages/app/src/components/Admin/App/V5PageMigrationModal.tsx
  21. 5 1
      packages/app/src/components/Fab.jsx
  22. 9 11
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  23. 4 2
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  24. 3 3
      packages/app/src/components/Icons/GrowiLogo.jsx
  25. 0 46
      packages/app/src/components/Navbar/DrawerToggler.jsx
  26. 28 0
      packages/app/src/components/Navbar/DrawerToggler.tsx
  27. 0 115
      packages/app/src/components/Navbar/GrowiNavbar.jsx
  28. 128 0
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  29. 8 4
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  30. 13 12
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  31. 17 15
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  32. 21 20
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  33. 3 3
      packages/app/src/components/Navbar/SubNavButtons.jsx
  34. 13 9
      packages/app/src/components/Page/DisplaySwitcher.jsx
  35. 13 13
      packages/app/src/components/Page/NotFoundAlert.jsx
  36. 18 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  37. 1 0
      packages/app/src/components/PageContentFooter.jsx
  38. 8 7
      packages/app/src/components/PageCreateModal.jsx
  39. 15 9
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  40. 2 1
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  41. 0 242
      packages/app/src/components/Sidebar.jsx
  42. 341 0
      packages/app/src/components/Sidebar.tsx
  43. 17 39
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  44. 20 0
      packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx
  45. 36 0
      packages/app/src/components/Sidebar/PageTree.tsx
  46. 110 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  47. 18 0
      packages/app/src/components/Sidebar/PageTree/ItemNode.ts
  48. 95 0
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  49. 16 0
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx
  50. 6 100
      packages/app/src/components/Sidebar/RecentChanges.tsx
  51. 0 49
      packages/app/src/components/Sidebar/SidebarContents.jsx
  52. 33 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  53. 0 94
      packages/app/src/components/Sidebar/SidebarNav.jsx
  54. 99 0
      packages/app/src/components/Sidebar/SidebarNav.tsx
  55. 6 9
      packages/app/src/components/StickyStretchableScroller.jsx
  56. 7 0
      packages/app/src/interfaces/common.ts
  57. 3 0
      packages/app/src/interfaces/has-object-id.ts
  58. 13 0
      packages/app/src/interfaces/named-query.ts
  59. 23 0
      packages/app/src/interfaces/page-listing-results.ts
  60. 24 11
      packages/app/src/interfaces/page.ts
  61. 7 0
      packages/app/src/interfaces/ui.ts
  62. 13 0
      packages/app/src/interfaces/user-ui-settings.ts
  63. 3 2
      packages/app/src/migrations/20181019114028-abolish-page-group-relation.js
  64. 2 1
      packages/app/src/migrations/20190619055421-adjust-page-grant.js
  65. 2 1
      packages/app/src/migrations/20190624110950-fill-last-update-user.js
  66. 2 1
      packages/app/src/migrations/20190629193445-make-root-page-public.js
  67. 2 2
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  68. 2 1
      packages/app/src/migrations/20210420160380-convert-double-to-date.js
  69. 47 0
      packages/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js
  70. 1 1
      packages/app/src/server/crowi/index.js
  71. 42 0
      packages/app/src/server/interfaces/search.ts
  72. 1 1
      packages/app/src/server/middlewares/auto-reconnect-to-search.js
  73. 1 0
      packages/app/src/server/models/config.ts
  74. 3 1
      packages/app/src/server/models/index.js
  75. 36 0
      packages/app/src/server/models/named-query.ts
  76. 72 74
      packages/app/src/server/models/obsolete-page.js
  77. 348 0
      packages/app/src/server/models/page.ts
  78. 28 0
      packages/app/src/server/models/user-ui-settings.ts
  79. 1 0
      packages/app/src/server/routes/apiv3/admin-home.js
  80. 1 0
      packages/app/src/server/routes/apiv3/app-settings.js
  81. 6 0
      packages/app/src/server/routes/apiv3/index.js
  82. 6 0
      packages/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  83. 81 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  84. 69 1
      packages/app/src/server/routes/apiv3/page.js
  85. 54 10
      packages/app/src/server/routes/apiv3/pages.js
  86. 86 0
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  87. 8 5
      packages/app/src/server/routes/index.js
  88. 20 12
      packages/app/src/server/routes/installer.js
  89. 113 137
      packages/app/src/server/routes/page.js
  90. 7 0
      packages/app/src/server/routes/private-legacy-pages.ts
  91. 6 6
      packages/app/src/server/service/config-loader.ts
  92. 286 3
      packages/app/src/server/service/page.js
  93. 60 107
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  94. 53 0
      packages/app/src/server/service/search-delegator/private-legacy-pages.ts
  95. 0 49
      packages/app/src/server/service/search-delegator/searchbox.js
  96. 354 0
      packages/app/src/server/service/search.ts
  97. 2 0
      packages/app/src/server/views/layout-growi/base/layout.html
  98. 22 0
      packages/app/src/server/views/layout-growi/select-go-to-page.html
  99. 16 0
      packages/app/src/server/views/private-legacy-pages.html
  100. 2 0
      packages/app/src/server/views/widget/page_content.html

+ 0 - 2
packages/app/config/webpack.dev.dll.js

@@ -10,8 +10,6 @@ module.exports = {
   entry: {
     dlls: [
       // Libraries
-      '@atlaskit/drawer',
-      '@atlaskit/navigation-next',
       'axios',
       'browser-bunyan', 'bunyan-format',
       'codemirror', 'react-codemirror2',

+ 2 - 2
packages/app/package.json

@@ -131,6 +131,7 @@
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
+    "re2": "^1.16.0",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
@@ -157,12 +158,11 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "@atlaskit/drawer": "^5.3.7",
-    "@atlaskit/navigation-next": "^8.0.5",
     "@growi/ui": "^4.4.14-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
+    "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",

+ 9 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "Submitting a bug report",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
+  "v5_page_migration": {
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",

+ 2 - 0
packages/app/resource/locales/en_US/translation.json

@@ -107,6 +107,7 @@
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
+  "V5 Page Migration": "V5 Page Migration",
   "Site URL settings": "Site URL settings",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
@@ -151,6 +152,7 @@
   "Add to bookmark": "Add to bookmark",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
+  "Page Tree": "Page Tree",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",

+ 9 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "バグを報告する",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
+  "v5_page_migration": {
+    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_note": "注意: ページパスからユニーク制約が失われます。",
+    "upgrade_to_v5": "V5 にアップグレード",
+    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "アップグレードを開始",
+    "successfully_started": "正常にマイグレーションが開始されました",
+    "already_upgraded": "アップグレードは既に完了しています"
+  },
   "app_setting": {
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",

+ 2 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -107,6 +107,7 @@
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
+  "V5 Page Migration": "V5 ページマイグレーション",
   "Site URL settings": "サイトURL設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
@@ -153,6 +154,7 @@
   "Add to bookmark": "ブックマークに追加",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
+  "Page Tree": "ページツリー",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",

+ 9 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -19,6 +19,15 @@
     "bug_report": "提交一个错误报告",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
+  "v5_page_migration": {
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",

+ 2 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -115,6 +115,7 @@
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
+  "V5 Page Migration": "V5 Page Migration",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Customize": "页面定制",
@@ -159,6 +160,7 @@
   "Add to bookmark": "添加到书签",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
+  "Page Tree": "页面树",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",

+ 2 - 6
packages/app/src/client/app.jsx

@@ -41,6 +41,7 @@ import PersonalSettings from '../components/Me/PersonalSettings';
 import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 
+import ContextExtractor from '~/client/services/ContextExtractor';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageHistoryContainer from '~/client/services/PageHistoryContainer';
@@ -50,7 +51,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import ContextExtractor from '~/client/services/ContextExtractor';
 
 import { appContainer, componentMappings } from './base';
 
@@ -99,7 +99,6 @@ Object.assign(componentMappings, {
 
   'not-found-page': <NotFoundPage />,
   'not-found-alert': <NotFoundAlert
-    onPageCreateClicked={navigationContainer.setEditorMode}
     isGuestUserMode={appContainer.isGuestUser}
     isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
   />,
@@ -118,8 +117,6 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
-
-  'growi-context-extractor': <ContextExtractor />, // use static swr
 });
 
 // additional definitions if data exists
@@ -181,7 +178,7 @@ const elem = document.getElementById('growi-context-extractor');
 if (elem != null) {
   ReactDOM.render(
     <SWRConfig value={swrGlobalConfiguration}>
-      {componentMappings['growi-context-extractor']}
+      <ContextExtractor></ContextExtractor>
     </SWRConfig>,
     elem,
     renderMainComponents,
@@ -191,6 +188,5 @@ else {
   renderMainComponents();
 }
 
-
 // initialize scrollpos-styler
 ScrollPosStyler.init();

+ 31 - 31
packages/app/src/client/legacy/crowi.js

@@ -17,7 +17,7 @@ window.Crowi = Crowi;
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
   const navigationContainer = appContainer.getContainer('NavigationContainer');
-  navigationContainer.setEditorMode('edit');
+  // navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
 };
@@ -154,32 +154,32 @@ Crowi.blinkSelectedSection = function(hash) {
   }
 };
 
-window.addEventListener('load', () => {
-  const { appContainer } = window;
-  const pageContainer = appContainer.getContainer('PageContainer');
-
-  // Do nothing if the page does not exist
-  // ex.) admin page,login page
-  if (pageContainer == null) {
-    return null;
-  }
-  const { isAbleToOpenPageEditor } = pageContainer;
-
-  // hash on page
-  if (window.location.hash) {
-    const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-    if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-      navigationContainer.setEditorMode('edit');
-
-      // focus
-      Crowi.setCaretLineAndFocusToEditor();
-    }
-    else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
-    }
-  }
-});
+// window.addEventListener('load', () => {
+//   const { appContainer } = window;
+//   const pageContainer = appContainer.getContainer('PageContainer');
+
+//   // Do nothing if the page does not exist
+//   // ex.) admin page,login page
+//   if (pageContainer == null) {
+//     return null;
+//   }
+//   const { isAbleToOpenPageEditor } = pageContainer;
+
+//   // hash on page
+//   if (window.location.hash) {
+//     const navigationContainer = appContainer.getContainer('NavigationContainer');
+
+//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
+//       navigationContainer.setEditorMode('edit');
+
+//       // focus
+//       Crowi.setCaretLineAndFocusToEditor();
+//     }
+//     else if (window.location.hash === '#hackmd') {
+//       navigationContainer.setEditorMode('hackmd');
+//     }
+//   }
+// });
 
 window.addEventListener('load', () => {
   const crowi = window.crowi;
@@ -228,18 +228,18 @@ window.addEventListener('hashchange', (e) => {
   Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
   Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
-  const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
+  // const { appContainer } = window;
+  // const navigationContainer = appContainer.getContainer('NavigationContainer');
 
 
   // hash on page
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
-      navigationContainer.setEditorMode('edit');
+      // navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
     }
     else if (window.location.hash === '#hackmd') {
-      navigationContainer.setEditorMode('hackmd');
+      // navigationContainer.setEditorMode('hackmd');
     }
   }
 });

+ 19 - 0
packages/app/src/client/services/AdminAppContainer.js

@@ -22,6 +22,7 @@ export default class AdminAppContainer extends Container {
       isEmailPublishedForNewUser: true,
       fileUpload: '',
 
+      isV5Compatible: null,
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
@@ -81,6 +82,7 @@ export default class AdminAppContainer extends Container {
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
       fileUpload: appSettingsParams.fileUpload,
+      isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
@@ -160,6 +162,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fileUpload });
   }
 
+  /**
+   * Change site url
+   */
+  changeIsV5Compatible(isV5Compatible) {
+    this.setState({ isV5Compatible });
+  }
+
   /**
    * Change site url
    */
@@ -440,5 +449,15 @@ export default class AdminAppContainer extends Container {
     return pluginSettingParams;
   }
 
+  /**
+   * Start v5 page migration
+   * @memberOf AdminAppContainer
+   * @property action takes only 'initialMigration' for now. 'initialMigration' will start or resume migration
+   */
+  async v5PageMigrationHandler(action) {
+    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration', { action });
+    const { isV5Compatible } = response.data;
+    return { isV5Compatible };
+  }
 
 }

+ 2 - 0
packages/app/src/client/services/AdminHomeContainer.js

@@ -32,6 +32,7 @@ export default class AdminHomeContainer extends Container {
       yarnVersion: '',
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
+      isV5Compatible: null,
     };
 
   }
@@ -63,6 +64,7 @@ export default class AdminHomeContainer extends Container {
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
+        isV5Compatible: adminHomeParams.isV5Compatible,
       }));
     }
     catch (err) {

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

@@ -1,18 +1,33 @@
-import React, { FC } from 'react';
+import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 import {
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
 } from '../../stores/context';
 
+import {
+  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+} from '~/stores/ui';
+
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 const jsonNull = 'null';
 
-const ContextExtractor: FC = () => {
+const getInitialEditorMode = (): EditorMode => {
+  switch (window.location.hash) {
+    case '#edit':
+      return EditorMode.Editor;
+    case '#hackmd':
+      return EditorMode.HackMD;
+    default:
+      return EditorMode.View;
+  }
+};
+
+const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
 
@@ -50,6 +65,7 @@ const ContextExtractor: FC = () => {
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
+  const targetAndAncestors = JSON.parse(mainContent?.getAttribute('data-target-and-ancestors') || jsonNull);
 
   /*
    * use static swr
@@ -57,6 +73,12 @@ const ContextExtractor: FC = () => {
   // App
   useCurrentUser(currentUser);
 
+  // Navigation
+  useEditorMode(getInitialEditorMode());
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
   // Page
   useCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
@@ -84,12 +106,19 @@ const ContextExtractor: FC = () => {
   useUpdatedAt(updatedAt);
   useCreator(creator);
   useRevisionAuthor(revisionAuthor);
+  useTargetAndAncestors(targetAndAncestors);
 
-  return (
-    <div>
-      {/* Render nothing */}
-    </div>
-  );
+  return null;
 };
 
+const ContextExtractor: FC = React.memo(() => {
+  const [isRunOnce, setRunOnce] = useState(false);
+
+  useEffect(() => {
+    setRunOnce(true);
+  }, []);
+
+  return isRunOnce ? null : <ContextExtractorOnce></ContextExtractorOnce>;
+});
+
 export default ContextExtractor;

+ 55 - 137
packages/app/src/client/services/NavigationContainer.js

@@ -22,26 +22,12 @@ export default class NavigationContainer extends Container {
     const { localStorage } = window;
 
     this.state = {
-      editorMode: 'view',
-
-      isDeviceSmallerThanMd: null,
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      sidebarContentsId: localStorage.sidebarContentsId || 'recent',
+      // editorMode: 'view',
 
       isScrollTop: true,
-
-      isPageCreateModalShown: false,
     };
 
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-    this.setEditorMode = this.setEditorMode.bind(this);
-    this.initDeviceSize();
+    // this.setEditorMode = this.setEditorMode.bind(this);
     this.initScrollEvent();
   }
 
@@ -56,26 +42,6 @@ export default class NavigationContainer extends Container {
     return this.appContainer.getContainer('PageContainer');
   }
 
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
-
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
-
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
-
-    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
-  }
-
   initScrollEvent() {
     window.addEventListener('scroll', () => {
       const currentYOffset = window.pageYOffset;
@@ -91,80 +57,49 @@ export default class NavigationContainer extends Container {
     });
   }
 
-  setEditorMode(editorMode) {
-    const { isNotCreatable } = this.getPageContainer().state;
-
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to edit the page or use hackmd.');
-      return;
-    }
-
-    if (isNotCreatable) {
-      logger.warn('This page could not edit.');
-      return;
-    }
-
-    this.setState({ editorMode });
-    if (editorMode === 'view') {
-      $('body').removeClass('on-edit');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
-    }
-
-    if (editorMode === 'edit') {
-      $('body').addClass('on-edit');
-      $('body').addClass('builtin-editor');
-      $('body').removeClass('hackmd');
-      // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
-      }
-      window.location.hash = '#edit';
-    }
-
-    if (editorMode === 'hackmd') {
-      $('body').addClass('on-edit');
-      $('body').addClass('hackmd');
-      $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
-    }
-
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
+  // setEditorMode(editorMode) {
+  //   const { isNotCreatable } = this.getPageContainer().state;
+
+  //   if (this.appContainer.currentUser == null) {
+  //     logger.warn('Please login or signup to edit the page or use hackmd.');
+  //     return;
+  //   }
+
+  //   if (isNotCreatable) {
+  //     logger.warn('This page could not edit.');
+  //     return;
+  //   }
+
+  //   this.setState({ editorMode });
+  //   if (editorMode === 'view') {
+  //     $('body').removeClass('on-edit');
+  //     $('body').removeClass('builtin-editor');
+  //     $('body').removeClass('hackmd');
+  //     $('body').removeClass('pathname-sidebar');
+  //     window.history.replaceState(null, '', window.location.pathname);
+  //   }
+
+  //   if (editorMode === 'edit') {
+  //     $('body').addClass('on-edit');
+  //     $('body').addClass('builtin-editor');
+  //     $('body').removeClass('hackmd');
+  //     // editing /Sidebar
+  //     if (window.location.pathname === '/Sidebar') {
+  //       $('body').addClass('pathname-sidebar');
+  //     }
+  //     window.location.hash = '#edit';
+  //   }
+
+  //   if (editorMode === 'hackmd') {
+  //     $('body').addClass('on-edit');
+  //     $('body').addClass('hackmd');
+  //     $('body').removeClass('builtin-editor');
+  //     $('body').removeClass('pathname-sidebar');
+  //     window.location.hash = '#hackmd';
+  //   }
+
+  //   this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
+  // }
 
   /**
    * Update drawer related state by specified 'newState' object
@@ -176,36 +111,19 @@ export default class NavigationContainer extends Container {
    *
    * because updating state of unstated container will be delayed unless you use await
    */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
+  // updateDrawerMode(newState) {
+  //   const {
+  //     editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+  //   } = newState;
 
-  selectSidebarContents(contentsId) {
-    window.localStorage.setItem('sidebarContentsId', contentsId);
-    this.setState({ sidebarContentsId: contentsId });
-  }
+  //   // get preference on view or edit
+  //   const preferDrawerMode = editorMode !== 'view' ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
 
-  openPageCreateModal() {
-    if (this.appContainer.currentUser == null) {
-      logger.warn('Please login or signup to create a new page.');
-      return;
-    }
-    this.setState({ isPageCreateModalShown: true });
-  }
+  //   const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
+  //   const isDrawerOpened = false; // close Drawer anyway
 
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
+  //   this.setState({ isDrawerMode, isDrawerOpened });
+  // }
 
   /**
    * Function that implements the click event for realizing smooth scroll

+ 6 - 5
packages/app/src/client/services/PageContainer.js

@@ -82,6 +82,7 @@ export default class PageContainer extends Container {
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
       shareLinksNumber: mainContent.getAttribute('data-share-links-number'),
       shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null),
+      targetAndAncestors: JSON.parse(mainContent.getAttribute('data-target-and-ancestors') || null),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
@@ -161,12 +162,12 @@ export default class PageContainer extends Container {
   }
 
 
-  get isAbleToOpenPageEditor() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isGuestUser } = this.appContainer;
+  // get isAbleToOpenPageEditor() {
+  //   const { isNotCreatable, isTrashPage } = this.state;
+  //   const { isGuestUser } = this.appContainer;
 
-    return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  }
+  //   return (!isNotCreatable && !isTrashPage && !isGuestUser);
+  // }
 
   /**
    * whether to display reaction buttons

+ 28 - 0
packages/app/src/client/services/user-ui-settings.ts

@@ -0,0 +1,28 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
+
+import { debounce } from 'throttle-debounce';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+let settingsForBulk: Partial<IUserUISettings> = {};
+const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = apiv3Put<IUserUISettings>('/user-ui-settings', { settings: settingsForBulk });
+
+  // clear partial
+  settingsForBulk = {};
+
+  return result;
+};
+
+const _putUserUISettingsInBulkDebounced = debounce(1500, false, _putUserUISettingsInBulk);
+
+export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  settingsForBulk = {
+    ...settingsForBulk,
+    ...settings,
+  };
+
+  return _putUserUISettingsInBulkDebounced();
+};

+ 18 - 0
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -10,6 +10,7 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
@@ -33,9 +34,26 @@ class AdminHome extends React.Component {
 
   render() {
     const { t, adminHomeContainer } = this.props;
+    const { isV5Compatible } = adminHomeContainer.state;
+
+    let alertStyle = 'alert-info';
+    if (isV5Compatible == null) alertStyle = 'alert-warning';
 
     return (
       <Fragment>
+        {
+          // not show if true
+          !isV5Compatible
+          && (
+            <div className={`alert ${alertStyle}`}>
+              {t('admin:v5_page_migration.migration_desc')}
+              <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+                <i className="fa fa-link ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+              </a>
+            </div>
+          )
+        }
         <p>
           {t('admin:admin_top.wiki_administrator')}
           <br></br>

+ 25 - 2
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -2,19 +2,36 @@ import React, { Fragment } from 'react';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppSetting from './AppSetting';
 import SiteUrlSetting from './SiteUrlSetting';
 import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
+import V5PageMigration from './V5PageMigration';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 class AppSettingsPageContents extends React.Component {
 
   render() {
-    const { t } = this.props;
+    const { t, adminAppContainer } = this.props;
+    const { isV5Compatible } = adminAppContainer.state;
 
     return (
       <Fragment>
+        {
+          !isV5Compatible
+          && (
+            <div className="row">
+              <div className="col-lg-12">
+                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <V5PageMigration />
+              </div>
+            </div>
+          )
+        }
+
         <div className="row">
           <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
@@ -55,8 +72,14 @@ class AppSettingsPageContents extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+
 AppSettingsPageContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-export default withTranslation()(AppSettingsPageContents);
+export default withTranslation()(AppSettingsPageContentsWrapper);

+ 57 - 0
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -0,0 +1,57 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { V5PageMigrationModal } from './V5PageMigrationModal';
+import AdminAppContainer from '../../../client/services/AdminAppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+type Props = {
+  adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: (action: string) => Promise<{ isV5Compatible: boolean }> },
+}
+
+const V5PageMigration: FC<Props> = (props: Props) => {
+  const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
+  const { adminAppContainer } = props;
+  const { t } = useTranslation();
+
+  const onConfirm = async() => {
+    setIsV5PageMigrationModalShown(false);
+    try {
+      const { isV5Compatible } = await adminAppContainer.v5PageMigrationHandler('initialMigration');
+      if (isV5Compatible) {
+
+        return toastSuccess(t('admin:v5_page_migration.already_upgraded'));
+      }
+      toastSuccess(t('admin:v5_page_migration.successfully_started'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <V5PageMigrationModal
+        isModalOpen={isV5PageMigrationModalShown}
+        onConfirm={onConfirm}
+        onCancel={() => setIsV5PageMigrationModalShown(false)}
+      />
+      <p className="card well">
+        {t('admin:v5_page_migration.migration_desc')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>Upgrade to v5</button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default withUnstatedContainers(V5PageMigration, [AdminAppContainer]);

+ 61 - 0
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx

@@ -0,0 +1,61 @@
+import React, { FC } from 'react';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+type V5PageMigrationModalProps = {
+  isModalOpen: boolean
+  onConfirm?: () => Promise<void>;
+  onCancel?: () => void;
+};
+
+export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+  const { t } = useTranslation();
+
+  const onCancel = () => {
+    if (props.onCancel != null) {
+      props.onCancel();
+    }
+  };
+
+  const onConfirm = () => {
+    if (props.onConfirm != null) {
+      props.onConfirm();
+    }
+  };
+
+  return (
+    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+        <i className="icon-fw icon-question" />
+        Warning
+      </ModalHeader>
+      <ModalBody>
+        {t('admin:v5_page_migration.modal_migration_warning')}
+        <br />
+        <br />
+        <span className="text-danger">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:v5_page_migration.migration_note')}
+        </span>
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onCancel}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-outline-primary ml-3"
+          onClick={onConfirm}
+        >
+          {t('admin:v5_page_migration.start_upgrading')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 5 - 1
packages/app/src/components/Fab.jsx

@@ -6,6 +6,8 @@ import loggerFactory from '~/utils/logger';
 
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { usePageCreateModalOpened } from '~/stores/ui';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
@@ -16,6 +18,8 @@ const Fab = (props) => {
   const { navigationContainer, appContainer } = props;
   const { currentUser } = appContainer;
 
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
 
@@ -52,7 +56,7 @@ const Fab = (props) => {
           <button
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
-            onClick={navigationContainer.openPageCreateModal}
+            onClick={() => mutatePageCreateModalOpened(true)}
           >
             <CreatePageIcon />
           </button>

+ 9 - 11
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,31 +1,29 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
-const CreatePage = (props) => {
+const CreatePage = React.memo((props) => {
+
+  const { mutate } = usePageCreateModalOpened();
 
   // setup effect
   useEffect(() => {
-    props.navigationContainer.openPageCreateModal();
+    mutate(true);
 
     // remove this
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutate, props]);
 
   return <></>;
-};
+});
 
 CreatePage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-const CreatePageWrapper = withUnstatedContainers(CreatePage, [NavigationContainer]);
-
-CreatePageWrapper.getHotkeyStrokes = () => {
+CreatePage.getHotkeyStrokes = () => {
   return [['c']];
 };
 
-export default CreatePageWrapper;
+export default CreatePage;

+ 4 - 2
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -3,8 +3,10 @@ import PropTypes from 'prop-types';
 
 import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 const EditPage = (props) => {
+  const { mutate: mutateEditorMode } = useEditorMode();
 
   // setup effect
   useEffect(() => {
@@ -13,11 +15,11 @@ const EditPage = (props) => {
       return;
     }
 
-    props.navigationContainer.setEditorMode('edit');
+    mutateEditorMode(EditorMode.Editor);
 
     // remove this
     props.onDeleteRender(this);
-  }, [props]);
+  }, [mutateEditorMode, props]);
 
   return <></>;
 };

+ 3 - 3
packages/app/src/components/Icons/GrowiLogo.jsx

@@ -1,6 +1,6 @@
-import React from 'react';
+import React, { memo } from 'react';
 
-const GrowiLogo = () => (
+const GrowiLogo = memo(() => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     width="32"
@@ -29,6 +29,6 @@ const GrowiLogo = () => (
     >
     </path>
   </svg>
-);
+));
 
 export default GrowiLogo;

+ 0 - 46
packages/app/src/components/Navbar/DrawerToggler.jsx

@@ -1,46 +0,0 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-const DrawerToggler = (props) => {
-
-  const { navigationContainer } = props;
-
-  const clickHandler = useCallback(() => {
-    navigationContainer.toggleDrawer();
-  }, [navigationContainer]);
-
-  const iconClass = props.iconClass || 'icon-menu';
-
-  return (
-    <button
-      className="grw-drawer-toggler btn btn-secondary"
-      type="button"
-      aria-expanded="false"
-      aria-label="Toggle navigation"
-      onClick={clickHandler}
-    >
-      <i className={iconClass}></i>
-    </button>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DrawerTogglerWrapper = withUnstatedContainers(DrawerToggler, [NavigationContainer]);
-
-
-DrawerToggler.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  iconClass: PropTypes.string,
-};
-
-export default withTranslation()(DrawerTogglerWrapper);

+ 28 - 0
packages/app/src/components/Navbar/DrawerToggler.tsx

@@ -0,0 +1,28 @@
+import React, { FC } from 'react';
+import { useDrawerOpened } from '~/stores/ui';
+
+type Props = {
+  iconClass?: string,
+}
+
+const DrawerToggler: FC<Props> = (props: Props) => {
+
+  const { data: isOpened, mutate } = useDrawerOpened();
+
+  const iconClass = props.iconClass || 'icon-menu';
+
+  return (
+    <button
+      className="grw-drawer-toggler btn btn-secondary"
+      type="button"
+      aria-expanded="false"
+      aria-label="Toggle navigation"
+      onClick={() => mutate(!isOpened)}
+    >
+      <i className={iconClass}></i>
+    </button>
+  );
+
+};
+
+export default DrawerToggler;

+ 0 - 115
packages/app/src/components/Navbar/GrowiNavbar.jsx

@@ -1,115 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { UncontrolledTooltip } from 'reactstrap';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import AppContainer from '~/client/services/AppContainer';
-
-
-import GrowiLogo from '../Icons/GrowiLogo';
-
-import PersonalDropdown from './PersonalDropdown';
-import GlobalSearch from './GlobalSearch';
-
-class GrowiNavbar extends React.Component {
-
-  renderNavbarRight() {
-    const { t, appContainer, navigationContainer } = this.props;
-    const { currentUser } = appContainer;
-
-    // render login button
-    if (currentUser == null) {
-      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
-    }
-
-    return (
-      <>
-        <li className="nav-item d-none d-md-block">
-          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
-            <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('New') }</span>
-          </button>
-        </li>
-
-        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
-          <PersonalDropdown />
-        </li>
-      </>
-    );
-  }
-
-  renderConfidential() {
-    const { appContainer } = this.props;
-    const { crowi } = appContainer.config;
-
-    return (
-      <li className="nav-item confidential text-light">
-        <i id="confidentialTooltip" className="icon-info d-md-none" />
-        <span className="d-none d-md-inline">
-          {crowi.confidential}
-        </span>
-        <UncontrolledTooltip
-          placement="bottom"
-          target="confidentialTooltip"
-          className="d-md-none"
-        >
-          {crowi.confidential}
-        </UncontrolledTooltip>
-      </li>
-    );
-  }
-
-  render() {
-    const { appContainer, navigationContainer } = this.props;
-    const { crowi, isSearchServiceConfigured } = appContainer.config;
-    const { isDeviceSmallerThanMd } = navigationContainer.state;
-
-    return (
-      <>
-
-        {/* Brand Logo  */}
-        <div className="navbar-brand mr-0">
-          <a className="grw-logo d-block" href="/">
-            <GrowiLogo />
-          </a>
-        </div>
-
-        <div className="grw-app-title d-none d-md-block">
-          {crowi.title}
-        </div>
-
-
-        {/* Navbar Right  */}
-        <ul className="navbar-nav ml-auto">
-          {this.renderNavbarRight()}
-          {crowi.confidential != null && this.renderConfidential()}
-        </ul>
-
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
-          <div className="grw-global-search grw-global-search-top position-absolute">
-            <GlobalSearch />
-          </div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer, NavigationContainer]);
-
-
-GrowiNavbar.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withTranslation()(GrowiNavbarWrapper);

+ 128 - 0
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -0,0 +1,128 @@
+import React, { FC, memo } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '~/interfaces/user';
+import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import GrowiLogo from '../Icons/GrowiLogo';
+
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+
+type NavbarRightProps = {
+  currentUser: IUser,
+}
+const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
+  const { t } = useTranslation();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+
+  const { currentUser } = props;
+
+  // render login button
+  if (currentUser == null) {
+    return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+  }
+
+  return (
+    <>
+      <li className="nav-item d-none d-md-block">
+        <button
+          className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
+          type="button"
+          onClick={() => mutatePageCreateModalOpened(true)}
+        >
+          <i className="icon-pencil mr-2"></i>
+          <span className="d-none d-lg-block">{ t('New') }</span>
+        </button>
+      </li>
+
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+        <PersonalDropdown />
+      </li>
+    </>
+  );
+});
+
+type ConfidentialProps = {
+  confidential?: string,
+}
+const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps) => {
+  const { confidential } = props;
+
+  if (confidential == null) {
+    return null;
+  }
+
+  return (
+    <li className="nav-item confidential text-light">
+      <i id="confidentialTooltip" className="icon-info d-md-none" />
+      <span className="d-none d-md-inline">
+        {confidential}
+      </span>
+      <UncontrolledTooltip
+        placement="bottom"
+        target="confidentialTooltip"
+        className="d-md-none"
+      >
+        {confidential}
+      </UncontrolledTooltip>
+    </li>
+  );
+});
+
+
+const GrowiNavbar = (props) => {
+
+  const { appContainer } = props;
+  const { currentUser } = appContainer;
+  const { crowi, isSearchServiceConfigured } = appContainer.config;
+
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
+  return (
+    <>
+      {/* Brand Logo  */}
+      <div className="navbar-brand mr-0">
+        <a className="grw-logo d-block" href="/">
+          <GrowiLogo />
+        </a>
+      </div>
+
+      <div className="grw-app-title d-none d-md-block">
+        {crowi.title}
+      </div>
+
+
+      {/* Navbar Right  */}
+      <ul className="navbar-nav ml-auto">
+        <NavbarRight currentUser={currentUser}></NavbarRight>
+        <Confidential confidential={crowi.confidential}></Confidential>
+      </ul>
+
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+        <div className="grw-global-search grw-global-search-top position-absolute">
+          <GlobalSearch />
+        </div>
+      ) }
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer]);
+
+
+GrowiNavbar.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default GrowiNavbarWrapper;

+ 8 - 4
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -2,8 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
+import { withUnstatedContainers } from '../UnstatedUtils';
 import GlobalSearch from './GlobalSearch';
 
 const GrowiNavbarBottom = (props) => {
@@ -11,7 +12,10 @@ const GrowiNavbarBottom = (props) => {
   const {
     navigationContainer,
   } = props;
-  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
@@ -36,7 +40,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.toggleDrawer()}
+              onClick={() => mutateDrawerOpened(true)}
             >
               <i className="icon-menu"></i>
             </a>
@@ -55,7 +59,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => navigationContainer.openPageCreateModal()}
+              onClick={() => mutatePageCreateModalOpened(true)}
             >
               <i className="icon-pencil"></i>
             </a>

+ 13 - 12
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,16 +1,16 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { withTranslation } from 'react-i18next';
-
 import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
 
 import CopyDropdown from '../Page/CopyDropdown';
 import TagLabels from '../Page/TagLabels';
@@ -67,21 +67,24 @@ const PagePathNav = ({
 };
 
 const GrowiSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+
   const {
-    appContainer, navigationContainer, pageContainer, isCompactMode,
+    appContainer, pageContainer, isCompactMode,
   } = props;
-  const { isDrawerMode, editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor, isPageExist,
   } = pageContainer.state;
 
   const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== 'view';
+  const isEditorMode = editorMode !== EditorMode.View;
   // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== 'edit' && !isPageExist);
+  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
 
   function onPageEditorModeButtonClicked(viewType) {
-    navigationContainer.setEditorMode(viewType);
+    mutateEditorMode(viewType);
   }
 
   return (
@@ -145,16 +148,14 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
 
 
 GrowiSubNavigation.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   isCompactMode: PropTypes.bool,
 };
 
-export default withTranslation()(GrowiSubNavigationWrapper);
+export default GrowiSubNavigationWrapper;

+ 17 - 15
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,10 +1,12 @@
 import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
+import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 /* eslint-disable react/prop-types */
 const PageEditorModeButtonWrapper = React.memo(({
@@ -36,14 +38,17 @@ const PageEditorModeButtonWrapper = React.memo(({
 
 function PageEditorModeManager(props) {
   const {
-    t, appContainer,
-    editorMode, onPageEditorModeButtonClicked, isBtnDisabled, isDeviceSmallerThanMd,
+    appContainer,
+    editorMode, onPageEditorModeButtonClicked, isBtnDisabled,
   } = props;
 
+  const { t } = useTranslation();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== 'hackmd';
+  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
     if (isBtnDisabled) {
@@ -62,32 +67,32 @@ function PageEditorModeManager(props) {
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
-        {(!isDeviceSmallerThanMd || editorMode !== 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="view"
+            targetMode={EditorMode.View}
             icon={<i className="icon-control-play" />}
             label={t('view')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="edit"
+            targetMode={EditorMode.Editor}
             icon={<i className="icon-note" />}
             label={t('Edit')}
           />
         )}
-        {(!isDeviceSmallerThanMd || editorMode === 'view') && showHackmdBtn && (
+        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
           <PageEditorModeButtonWrapper
             editorMode={editorMode}
             isBtnDisabled={isBtnDisabled}
             onClick={pageEditorModeButtonClickedHandler}
-            targetMode="hackmd"
+            targetMode={EditorMode.HackMD}
             icon={<i className="fa fa-file-text-o" />}
             label={t('hackmd.hack_md')}
             id="grw-page-editor-mode-manager-hackmd-button"
@@ -110,18 +115,15 @@ function PageEditorModeManager(props) {
 }
 
 PageEditorModeManager.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   onPageEditorModeButtonClicked: PropTypes.func,
   isBtnDisabled: PropTypes.bool,
   editorMode: PropTypes.string,
-  isDeviceSmallerThanMd: PropTypes.bool,
 };
 
 PageEditorModeManager.defaultProps = {
   isBtnDisabled: false,
-  isDeviceSmallerThanMd: false,
 };
 
 /**
@@ -129,4 +131,4 @@ PageEditorModeManager.defaultProps = {
  */
 const PageEditorModeManagerWrapper = withUnstatedContainers(PageEditorModeManager, [AppContainer]);
 
-export default withTranslation()(PageEditorModeManagerWrapper);
+export default PageEditorModeManagerWrapper;

+ 21 - 20
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -6,9 +6,13 @@ import { withTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '~/client/services/NavigationContainer';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 import {
   isUserPreferenceExists,
@@ -28,12 +32,15 @@ import SunIcon from '../Icons/SunIcon';
 
 const PersonalDropdown = (props) => {
 
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
   const user = appContainer.currentUser || {};
 
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
 
+  const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
+  const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
 
@@ -46,13 +53,15 @@ const PersonalDropdown = (props) => {
     window.location.href = '/logout';
   };
 
-  const preferDrawerModeSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreference(bool);
-  };
+  const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerMode(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeByUser: bool });
+  }, [mutatePreferDrawerMode]);
 
-  const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
-    navigationContainer.setDrawerModePreferenceOnEdit(bool);
-  };
+  const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
+    mutatePreferDrawerModeOnEdit(bool);
+    scheduleToPutUserUISettings({ preferDrawerModeOnEditByUser: bool });
+  }, [mutatePreferDrawerModeOnEdit]);
 
   const followOsCheckboxModifiedHandler = (bool) => {
     if (bool) {
@@ -77,13 +86,6 @@ const PersonalDropdown = (props) => {
   };
 
 
-  /*
-   * render
-   */
-  const {
-    preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  } = navigationContainer.state;
-
   /* eslint-disable react/prop-types */
   const IconWithTooltip = ({
     id, label, children, additionalClasses,
@@ -144,7 +146,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarMode"
                   className="custom-control-input"
                   type="checkbox"
-                  checked={!preferDrawerModeByUser}
+                  checked={!isPreferDrawerMode}
                   onChange={e => preferDrawerModeSwitchModifiedHandler(!e.target.checked)}
                 />
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
@@ -169,7 +171,7 @@ const PersonalDropdown = (props) => {
                   id="swSidebarModeOnEditor"
                   className="custom-control-input"
                   type="checkbox"
-                  checked={!preferDrawerModeOnEditByUser}
+                  checked={!isPreferDrawerModeOnEdit}
                   onChange={e => preferDrawerModeOnEditSwitchModifiedHandler(!e.target.checked)}
                 />
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
@@ -236,13 +238,12 @@ const PersonalDropdown = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer, NavigationContainer]);
+const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
 
 
 PersonalDropdown.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(PersonalDropdownWrapper);

+ 3 - 3
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import BookmarkButton from '../BookmarkButton';
@@ -14,7 +15,7 @@ const SubnavButtons = (props) => {
     appContainer, navigationContainer, pageContainer, isCompactMode,
   } = props;
 
-  /* eslint-enable react/prop-types */
+  const { data: editorMode } = useEditorMode();
 
   /* eslint-disable react/prop-types */
   const PageReactionButtons = ({ pageContainer }) => {
@@ -34,8 +35,7 @@ const SubnavButtons = (props) => {
   };
   /* eslint-enable react/prop-types */
 
-  const { editorMode } = navigationContainer.state;
-  const isViewMode = editorMode === 'view';
+  const isViewMode = editorMode === EditorMode.View;
 
   return (
     <>

+ 13 - 9
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import { TabContent, TabPane } from 'reactstrap';
 import propTypes from 'prop-types';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
 import Editor from '../PageEditor';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
@@ -16,15 +18,18 @@ import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
 
 const DisplaySwitcher = (props) => {
   const {
-    navigationContainer, pageContainer,
+    pageContainer,
   } = props;
-  const { editorMode } = navigationContainer.state;
   const { isPageExist, pageUser } = pageContainer.state;
 
+  const { data: editorMode } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
     <>
       <TabContent activeTab={editorMode}>
-        <TabPane tabId="view">
+        <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
             <div className="grw-side-contents-container">
@@ -49,26 +54,25 @@ const DisplaySwitcher = (props) => {
 
           </div>
         </TabPane>
-        <TabPane tabId="edit">
+        <TabPane tabId={EditorMode.Editor}>
           <div id="page-editor">
             <Editor />
           </div>
         </TabPane>
-        <TabPane tabId="hackmd">
+        <TabPane tabId={EditorMode.HackMD}>
           <div id="page-editor-with-hackmd">
             <PageEditorByHackmd />
           </div>
         </TabPane>
       </TabContent>
-      {editorMode !== 'view' && <EditorNavbarBottom /> }
+      {!isViewMode && <EditorNavbarBottom /> }
     </>
   );
 };
 
 DisplaySwitcher.propTypes = {
-  navigationContainer: propTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: propTypes.instanceOf(PageContainer).isRequired,
 };
 
 
-export default withUnstatedContainers(DisplaySwitcher, [NavigationContainer, PageContainer]);
+export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 13 - 13
packages/app/src/components/Page/NotFoundAlert.jsx

@@ -1,24 +1,26 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 const NotFoundAlert = (props) => {
-  const { t, isHidden, isGuestUserMode } = props;
-  function clickHandler(viewType) {
+  const { t } = useTranslation();
+  const { isHidden, isGuestUserMode } = props;
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const clickHandler = useCallback(() => {
     // check guest user,
     // disabled of button cannot be used for using tooltip.
     if (isGuestUserMode) {
       return;
     }
 
-    if (props.onPageCreateClicked === null) {
-      return;
-    }
-    props.onPageCreateClicked(viewType);
-  }
+    mutateEditorMode(EditorMode.Editor);
+
+  }, [isGuestUserMode, mutateEditorMode]);
 
   if (isHidden) {
     return null;
@@ -38,7 +40,7 @@ const NotFoundAlert = (props) => {
           <button
             type="button"
             className={`pl-3 pr-3 btn bg-info text-white ${isGuestUserMode ? 'disabled' : ''}`}
-            onClick={() => { clickHandler('edit') }}
+            onClick={clickHandler}
           >
             <i className="icon-note icon-fw" />
             {t('not_found_page.Create Page')}
@@ -58,10 +60,8 @@ const NotFoundAlert = (props) => {
 
 
 NotFoundAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onPageCreateClicked: PropTypes.func,
   isHidden: PropTypes.bool.isRequired,
   isGuestUserMode: PropTypes.bool.isRequired,
 };
 
-export default withTranslation()(NotFoundAlert);
+export default NotFoundAlert;

+ 18 - 5
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -8,7 +8,7 @@ import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 import RevisionBody from './RevisionBody';
 
-class RevisionRenderer extends React.PureComponent {
+class LegacyRevisionRenderer extends React.PureComponent {
 
   constructor(props) {
     super(props);
@@ -117,18 +117,31 @@ class RevisionRenderer extends React.PureComponent {
 
 }
 
+LegacyRevisionRenderer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
+  markdown: PropTypes.string.isRequired,
+  highlightKeywords: PropTypes.string,
+  additionalClassName: PropTypes.string,
+};
+
 /**
  * Wrapper component for using unstated
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, NavigationContainer]);
+const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer, NavigationContainer]);
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const RevisionRenderer = (props) => {
+  return <LegacyRevisionRendererWrapper {...props} />;
+};
 
 RevisionRenderer.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
   additionalClassName: PropTypes.string,
 };
 
-export default RevisionRendererWrapper;
+export default RevisionRenderer;

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

@@ -6,6 +6,7 @@ import AuthorInfo from './Navbar/AuthorInfo';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
+import { usePath } from '~/stores/context';
 
 const PageContentFooter = (props) => {
   const { pageContainer } = props;

+ 8 - 7
packages/app/src/components/PageCreateModal.jsx

@@ -11,9 +11,9 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
+import { usePageCreateModalOpened } from '~/stores/ui';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
@@ -22,7 +22,9 @@ const {
 } = pagePathUtils;
 
 const PageCreateModal = (props) => {
-  const { t, appContainer, navigationContainer } = props;
+  const { t, appContainer } = props;
+
+  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
@@ -264,12 +266,12 @@ const PageCreateModal = (props) => {
   return (
     <Modal
       size="lg"
-      isOpen={navigationContainer.state.isPageCreateModalShown}
-      toggle={navigationContainer.closePageCreateModal}
+      isOpen={isPageCreateModalOpened}
+      toggle={() => mutatePageCreateModalOpened(false)}
       className="grw-create-page"
       autoFocus={false}
     >
-      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => mutatePageCreateModalOpened(false)} className="bg-primary text-light">
         {t('New Page')}
       </ModalHeader>
       <ModalBody>
@@ -286,13 +288,12 @@ const PageCreateModal = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
 
 
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(ModalControlWrapper);

+ 15 - 9
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx

@@ -3,9 +3,12 @@ import PropTypes from 'prop-types';
 
 import { Collapse, Button } from 'reactstrap';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import AppContainer from '~/client/services/AppContainer';
+import {
+  EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+} from '~/stores/ui';
+
 import SlackNotification from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -16,20 +19,24 @@ import OptionsSelector from './OptionsSelector';
 
 const EditorNavbarBottom = (props) => {
 
+  const { data: editorMode } = useEditorMode();
+
   const [isExpanded, setExpanded] = useState(false);
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
-  const {
-    navigationContainer,
-  } = props;
-  const { editorMode, isDeviceSmallerThanMd } = navigationContainer.state;
+  const { mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
 
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
   const renderDrawerButton = () => (
-    <button type="button" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary border-0"
+      onClick={() => mutateDrawerOpened(true)}
+    >
       <i className="icon-menu"></i>
     </button>
   );
@@ -55,7 +62,7 @@ const EditorNavbarBottom = (props) => {
     </div>
   );
 
-  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isOptionsSelectorEnabled = editorMode !== EditorMode.HackMD;
   const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
 
   return (
@@ -127,9 +134,8 @@ const EditorNavbarBottom = (props) => {
 };
 
 EditorNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer, EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);

+ 2 - 1
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -162,7 +162,8 @@ class LinkEditModal extends React.PureComponent {
       const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
 
       try {
-        const { page } = await this.props.appContainer.apiGet('/pages.get', { path: pathWithoutFragment, page_id: pageId });
+        const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
         markdown = page.revision.body;
         permalink = page.id;
       }

+ 0 - 242
packages/app/src/components/Sidebar.jsx

@@ -1,242 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  withNavigationUIController,
-  LayoutManager,
-  NavigationProvider,
-  ThemeProvider,
-} from '@atlaskit/navigation-next';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import DrawerToggler from './Navbar/DrawerToggler';
-
-import SidebarNav from './Sidebar/SidebarNav';
-import SidebarContents from './Sidebar/SidebarContents';
-import StickyStretchableScroller from './StickyStretchableScroller';
-
-const sidebarDefaultWidth = 320;
-
-class Sidebar extends React.Component {
-
-  static propTypes = {
-    appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-    navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-    navigationUIController: PropTypes.any.isRequired,
-    isDrawerModeOnInit: PropTypes.bool,
-  };
-
-  componentWillMount() {
-    this.hackUIController();
-  }
-
-  componentDidUpdate(prevProps, prevState) {
-    this.toggleDrawerMode(this.isDrawerMode);
-  }
-
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  hackUIController() {
-    const { navigationUIController } = this.props;
-
-    // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-    const orgStoreState = navigationUIController.storeState;
-    navigationUIController.storeState = async(state) => {
-      await navigationUIController.setState(state);
-      orgStoreState(state);
-    };
-  }
-
-  /**
-   * return whether drawer mode or not
-   */
-  get isDrawerMode() {
-    let isDrawerMode = this.props.navigationContainer.state.isDrawerMode;
-    if (isDrawerMode == null) {
-      isDrawerMode = this.props.isDrawerModeOnInit;
-    }
-    return isDrawerMode;
-  }
-
-  toggleDrawerMode(bool) {
-    const { navigationUIController } = this.props;
-
-    const isStateModified = navigationUIController.state.isResizeDisabled !== bool;
-    if (!isStateModified) {
-      return;
-    }
-
-    // Drawer <-- Dock
-    if (bool) {
-      // cache state
-      this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      }
-
-      navigationUIController.disableResize();
-
-      // fix width
-      navigationUIController.setState({ productNavWidth: sidebarDefaultWidth });
-    }
-    // Drawer --> Dock
-    else {
-      // clear transition temporary
-      if (this.sidebarCollapsedCached) {
-        this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      }
-
-      navigationUIController.enableResize();
-
-      // restore width
-      if (this.sidebarWidthCached != null) {
-        navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      }
-    }
-  }
-
-  get sidebarElem() {
-    return document.querySelector('.grw-sidebar');
-  }
-
-  addCssClassTemporary(className) {
-    // clear
-    this.sidebarElem.classList.add(className);
-
-    // restore after 300ms
-    setTimeout(() => {
-      this.sidebarElem.classList.remove(className);
-    }, 300);
-  }
-
-  backdropClickedHandler = () => {
-    const { navigationContainer } = this.props;
-    navigationContainer.toggleDrawer();
-  }
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, navigationUIController } = this.props;
-    const { sidebarContentsId } = navigationContainer.state;
-
-    // already selected
-    if (sidebarContentsId === contentsId) {
-      navigationUIController.toggleCollapse();
-    }
-    // switch and expand
-    else {
-      navigationUIController.expand();
-    }
-  }
-
-  calcViewHeight() {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return window.innerHeight - scrollTargetElem.getBoundingClientRect().top;
-  }
-
-  renderGlobalNavigation = () => (
-    <SidebarNav onItemSelected={this.itemSelectedHandler} />
-  );
-
-  renderSidebarContents = () => {
-    const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
-
-    return (
-      <>
-        <StickyStretchableScroller
-          scrollTargetSelector={scrollTargetSelector}
-          contentsElemSelector="#grw-sidebar-content-container"
-          stickyElemSelector=".grw-sidebar"
-          calcViewHeightFunc={this.calcViewHeight}
-        />
-
-        <div id="grw-sidebar-contents-scroll-target">
-          <div id="grw-sidebar-content-container">
-            <SidebarContents
-              isSharedUser={this.props.appContainer.isSharedUser}
-            />
-          </div>
-        </div>
-
-        <DrawerToggler iconClass="icon-arrow-left" />
-      </>
-    );
-  };
-
-  render() {
-    const { isDrawerOpened } = this.props.navigationContainer.state;
-
-    return (
-      <>
-        <div className={`grw-sidebar d-print-none ${this.isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
-          <ThemeProvider
-            theme={theme => ({
-              ...theme,
-              context: 'product',
-            })}
-          >
-            <LayoutManager
-              globalNavigation={this.renderGlobalNavigation}
-              productNavigation={() => null}
-              containerNavigation={this.renderSidebarContents}
-              experimental_hideNavVisuallyOnCollapse
-              experimental_flyoutOnHover
-              experimental_alternateFlyoutBehaviour
-              experimental_fullWidthFlyout
-              shouldHideGlobalNavShadow
-              showContextualNavigation
-            >
-            </LayoutManager>
-          </ThemeProvider>
-        </div>
-
-        { isDrawerOpened && (
-          <div className="grw-sidebar-backdrop modal-backdrop show" onClick={this.backdropClickedHandler}></div>
-        ) }
-      </>
-    );
-  }
-
-}
-
-
-const SidebarWithNavigationUIController = withNavigationUIController(Sidebar);
-
-/**
- * Wrapper component for using unstated
- */
-
-const SidebarWithNavigation = (props) => {
-  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.navigationContainer.state;
-
-  const initUICForDrawerMode = isDrawerModeOnInit
-    // generate initialUIController for Drawer mode
-    ? {
-      isCollapsed: false,
-      isResizeDisabled: true,
-      productNavWidth: sidebarDefaultWidth,
-    }
-    // set undefined (should be initialized by cache)
-    : undefined;
-
-  return (
-    <NavigationProvider initialUIController={initUICForDrawerMode}>
-      <SidebarWithNavigationUIController {...props} isDrawerModeOnInit={isDrawerModeOnInit} />
-    </NavigationProvider>
-  );
-};
-
-SidebarWithNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withUnstatedContainers(SidebarWithNavigation, [AppContainer, NavigationContainer]);

+ 341 - 0
packages/app/src/components/Sidebar.tsx

@@ -0,0 +1,341 @@
+import React, {
+  FC, useCallback, useEffect, useRef, useState,
+} from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import {
+  useDrawerMode, useDrawerOpened,
+  useSidebarCollapsed,
+  useCurrentSidebarContents,
+  useCurrentProductNavWidth,
+  useSidebarResizeDisabled,
+} from '~/stores/ui';
+
+import DrawerToggler from './Navbar/DrawerToggler';
+
+import SidebarNav from './Sidebar/SidebarNav';
+import SidebarContents from './Sidebar/SidebarContents';
+import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
+import StickyStretchableScroller from './StickyStretchableScroller';
+
+const sidebarMinWidth = 240;
+const sidebarMinimizeWidth = 20;
+
+const GlobalNavigation = () => {
+  const { data: currentContents } = useCurrentSidebarContents();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+
+  const itemSelectedHandler = useCallback((selectedContents) => {
+
+    let newValue = false;
+
+    // already selected
+    if (currentContents === selectedContents) {
+      // toggle collapsed
+      newValue = !isCollapsed;
+    }
+
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+
+  }, [currentContents, isCollapsed, mutateSidebarCollapsed]);
+
+  return <SidebarNav onItemSelected={itemSelectedHandler} />;
+};
+
+// dummy skelton contents
+const GlobalNavigationSkelton = () => {
+  return (
+    <div className="grw-sidebar-nav">
+      <div className="grw-sidebar-nav-primary-container">
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+      </div>
+    </div>
+  );
+};
+
+
+const SidebarContentsWrapper = () => {
+  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+
+  const calcViewHeight = useCallback(() => {
+    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
+    return scrollTargetElem != null
+      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+      : window.innerHeight;
+  }, []);
+
+  return (
+    <>
+      <StickyStretchableScroller
+        scrollTargetSelector={scrollTargetSelector}
+        contentsElemSelector="#grw-sidebar-content-container"
+        stickyElemSelector=".grw-sidebar"
+        calcViewHeightFunc={calcViewHeight}
+      />
+
+      <div id="grw-sidebar-contents-scroll-target">
+        <div id="grw-sidebar-content-container">
+          <SidebarContents />
+        </div>
+      </div>
+
+      <DrawerToggler iconClass="icon-arrow-left" />
+    </>
+  );
+};
+
+// dummy skelton contents
+const SidebarSkeltonContents = () => {
+  return (
+    <div>Skelton Contents!!!</div>
+  );
+};
+
+
+type Props = {
+}
+
+const Sidebar: FC<Props> = (props: Props) => {
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
+  const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
+  const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
+  const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
+
+  const [isHover, setHover] = useState(false);
+  const [isDragging, setDrag] = useState(false);
+  const [isMounted, setMounted] = useState(false);
+
+  const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
+  /**
+   * hack and override UIController.storeState
+   *
+   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
+   */
+  // hackUIController() {
+  //   const { navigationUIController } = this.props;
+
+  //   // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
+  //   const orgStoreState = navigationUIController.storeState;
+  //   navigationUIController.storeState = async(state) => {
+  //     await navigationUIController.setState(state);
+  //     orgStoreState(state);
+  //   };
+  // }
+
+  const toggleDrawerMode = useCallback((bool) => {
+    const isStateModified = isResizeDisabled !== bool;
+    if (!isStateModified) {
+      return;
+    }
+
+    // Drawer <-- Dock
+    if (bool) {
+      // // cache state
+      // this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
+      // this.sidebarWidthCached = navigationUIController.state.productNavWidth;
+
+      // // clear transition temporary
+      // if (this.sidebarCollapsedCached) {
+      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
+      // }
+
+      // disable resize
+      mutateSidebarResizeDisabled(true, false);
+    }
+    // Drawer --> Dock
+    else {
+      // // clear transition temporary
+      // if (this.sidebarCollapsedCached) {
+      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
+      // }
+
+      // enable resize
+      mutateSidebarResizeDisabled(false, false);
+
+      // // restore width
+      // if (this.sidebarWidthCached != null) {
+      //   navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
+      // }
+    }
+  }, [isResizeDisabled, mutateSidebarResizeDisabled]);
+
+  // addCssClassTemporary(className) {
+  //   // clear
+  //   this.sidebarElem.classList.add(className);
+
+  //   // restore after 300ms
+  //   setTimeout(() => {
+  //     this.sidebarElem.classList.remove(className);
+  //   }, 300);
+  // }
+
+  const backdropClickedHandler = useCallback(() => {
+    mutateDrawerOpened(false, false);
+  }, [mutateDrawerOpened]);
+
+  useEffect(() => {
+    // this.hackUIController();
+    setMounted(true);
+  }, []);
+
+  useEffect(() => {
+    toggleDrawerMode(isDrawerMode);
+  }, [isDrawerMode, toggleDrawerMode]);
+
+  const resizableContainer = useRef<HTMLDivElement>(null);
+  const setContentWidth = useCallback((newWidth) => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+    resizableContainer.current.style.width = `${newWidth}px`;
+  }, []);
+
+  const hoverOnResizableContainerHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(true);
+    setContentWidth(currentProductNavWidth);
+  }, [isCollapsed, isDrawerMode, isDragging, setContentWidth, currentProductNavWidth]);
+
+  const hoverOutHandler = useCallback(() => {
+    if (!isCollapsed || isDrawerMode || isDragging) {
+      return;
+    }
+
+    setHover(false);
+    setContentWidth(sidebarMinimizeWidth);
+  }, [isCollapsed, isDragging, isDrawerMode, setContentWidth]);
+
+  const toggleNavigationBtnClickHandler = useCallback(() => {
+    const newValue = !isCollapsed;
+    mutateSidebarCollapsed(newValue, false);
+    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+  }, [isCollapsed, mutateSidebarCollapsed]);
+
+  useEffect(() => {
+    if (isCollapsed) {
+      setContentWidth(sidebarMinimizeWidth);
+    }
+    else {
+      setContentWidth(currentProductNavWidth);
+    }
+  }, [currentProductNavWidth, isCollapsed, setContentWidth]);
+
+  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
+    event.preventDefault();
+
+    const newWidth = event.pageX - 60;
+    if (resizableContainer.current != null) {
+      setContentWidth(newWidth);
+      resizableContainer.current.classList.add('dragging');
+    }
+  }, [setContentWidth]);
+
+  const dragableAreaMouseUpHandler = useCallback(() => {
+    if (resizableContainer.current == null) {
+      return;
+    }
+
+    setDrag(false);
+
+    if (resizableContainer.current.clientWidth < sidebarMinWidth) {
+      // force collapsed
+      mutateSidebarCollapsed(true);
+      mutateProductNavWidth(sidebarMinWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
+    }
+    else {
+      const newWidth = resizableContainer.current.clientWidth;
+      mutateSidebarCollapsed(false);
+      mutateProductNavWidth(newWidth, false);
+      scheduleToPutUserUISettings({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
+    }
+
+    resizableContainer.current.classList.remove('dragging');
+
+  }, [mutateProductNavWidth, mutateSidebarCollapsed]);
+
+  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
+    if (!isResizableByDrag) {
+      return;
+    }
+
+    event.preventDefault();
+
+    setDrag(true);
+
+    const removeEventListeners = () => {
+      document.removeEventListener('mousemove', draggableAreaMoveHandler);
+      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.removeEventListener('mouseup', removeEventListeners);
+    };
+
+    document.addEventListener('mousemove', draggableAreaMoveHandler);
+    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+    document.addEventListener('mouseup', removeEventListeners);
+
+  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
+
+  return (
+    <>
+      <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
+        <div className="data-layout-container">
+          <div className="navigation" onMouseLeave={hoverOutHandler}>
+            <div className="grw-navigation-wrap">
+              <div className="grw-global-navigation">
+                { isMounted ? <GlobalNavigation></GlobalNavigation> : <GlobalNavigationSkelton></GlobalNavigationSkelton> }
+              </div>
+              <div
+                ref={resizableContainer}
+                className="grw-contextual-navigation"
+                onMouseEnter={hoverOnResizableContainerHandler}
+                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
+              >
+                <div className="grw-contextual-navigation-child">
+                  <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
+                    { isMounted ? <SidebarContentsWrapper></SidebarContentsWrapper> : <SidebarSkeltonContents></SidebarSkeltonContents> }
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div className="grw-navigation-draggable">
+              { isResizableByDrag && (
+                <div
+                  className="grw-navigation-draggable-hitarea"
+                  onMouseDown={dragableAreaMouseDownHandler}
+                >
+                  <div className="grw-navigation-draggable-hitarea-child"></div>
+                </div>
+              ) }
+              <button
+                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
+                type="button"
+                aria-expanded="true"
+                aria-label="Toggle navigation"
+                disabled={isDrawerMode}
+                onClick={toggleNavigationBtnClickHandler}
+              >
+                <span className="hexagon-container" role="presentation">
+                  <NavigationResizeHexagon />
+                </span>
+                <span className="hitarea" role="presentation"></span>
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      { isDrawerOpened && (
+        <div className="grw-sidebar-backdrop modal-backdrop show" onClick={backdropClickedHandler}></div>
+      ) }
+    </>
+  );
+
+};
+
+export default Sidebar;

+ 17 - 39
packages/app/src/components/Sidebar/CustomSidebar.jsx → packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,13 +1,12 @@
-import React, {
-  useState, useCallback, useEffect,
-} from 'react';
-import PropTypes from 'prop-types';
+import React, { FC } from 'react';
 
+import AppContainer from '~/client/services/AppContainer';
 import loggerFactory from '~/utils/logger';
+import { useSWRxPageByPath } from '~/stores/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import RevisionRenderer from '../Page/RevisionRenderer';
+import { IRevision } from '~/interfaces/revision';
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
@@ -22,55 +21,38 @@ const SidebarNotFound = () => {
   );
 };
 
-const CustomSidebar = (props) => {
-
-  const { appContainer } = props;
-  const { apiGet } = appContainer;
+type Props = {
+  appContainer: AppContainer,
+};
 
-  const [isMounted, setMounted] = useState(false);
-  const [markdown, setMarkdown] = useState();
+const CustomSidebar: FC<Props> = (props: Props) => {
 
-  const growiRenderer = appContainer.getRenderer('sidebar');
+  const { appContainer } = props;
 
-  // TODO: refactor with SWR
-  const fetchDataAndRenderHtml = useCallback(async() => {
-    let page = null;
-    try {
-      const result = await apiGet('/pages.get', { path: '/Sidebar' });
-      page = result.page;
-    }
-    catch (e) {
-      logger.warn(e.message);
-      return;
-    }
-    finally {
-      setMounted(true);
-    }
+  const renderer = appContainer.getRenderer('sidebar');
 
-    setMarkdown(page.revision.body);
-  }, [apiGet]);
+  const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
 
-  useEffect(() => {
-    fetchDataAndRenderHtml();
-  }, [fetchDataAndRenderHtml]);
+  const isLoading = page === undefined;
+  const markdown = (page?.revision as IRevision)?.body;
 
   return (
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
-        <h3 className="mb-0 text-nowrap">
+        <h3 className="mb-0">
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={() => mutate()}>
           <i className="icon icon-reload"></i>
         </button>
       </div>
-      { isMounted && markdown == null && <SidebarNotFound /> }
+      { !isLoading && markdown == null && <SidebarNotFound /> }
       {/* eslint-disable-next-line react/no-danger */}
       { markdown != null && (
         <div className="p-3">
           <RevisionRenderer
-            growiRenderer={growiRenderer}
+            growiRenderer={renderer}
             markdown={markdown}
             additionalClassName="grw-custom-sidebar-content"
           />
@@ -81,10 +63,6 @@ const CustomSidebar = (props) => {
 
 };
 
-CustomSidebar.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
 /**
  * Wrapper component for using unstated
  */

+ 20 - 0
packages/app/src/components/Sidebar/NavigationResizeHexagon.tsx

@@ -0,0 +1,20 @@
+import React, { FC } from 'react';
+
+type Props = {
+
+};
+
+export const NavigationResizeHexagon: FC<Props> = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27.691 23.999"
+  >
+    <g className="background" transform="translate(0 0)">
+      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
+    </g>
+    <g className="icon" transform="translate(10 6)">
+      { /* eslint-disable-next-line max-len */ }
+      <path d="M2.124,9.114l5.28,5.34a.647.647,0,0,0,.922,0l.616-.623a.665.665,0,0,0,0-.932L4.759,8.648,8.943,4.4a.665.665,0,0,0,0-.932l-.616-.623a.647.647,0,0,0-.922,0l-5.28,5.34A.665.665,0,0,0,2.124,9.114Z" transform="translate(-1.933 -2.648)"></path>
+    </g>
+  </svg>
+);

+ 36 - 0
packages/app/src/components/Sidebar/PageTree.tsx

@@ -0,0 +1,36 @@
+import React, { FC, memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+
+import ItemsTree from './PageTree/ItemsTree';
+import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+
+
+const PageTree: FC = memo(() => {
+  const { t } = useTranslation();
+
+  const { data } = useSWRxV5MigrationStatus();
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3">
+        <h3 className="mb-0">{t('Page Tree')}</h3>
+      </div>
+
+      <div className="grw-sidebar-content-body">
+        <ItemsTree />
+      </div>
+
+      <div className="grw-sidebar-content-footer">
+        {
+          data?.migratablePagesCount != null && data.migratablePagesCount !== 0 && (
+            <PrivateLegacyPages />
+          )
+        }
+      </div>
+    </>
+  );
+});
+
+export default PageTree;

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

@@ -0,0 +1,110 @@
+import React, {
+  useCallback, useState, FC, useEffect,
+} from 'react';
+import nodePath from 'path';
+
+import { ItemNode } from './ItemNode';
+import { useSWRxPageChildren } from '../../../stores/page-listing';
+import { usePageId } from '../../../stores/context';
+
+
+interface ItemProps {
+  itemNode: ItemNode
+  isOpen?: boolean
+}
+
+// Utility to mark target
+const markTarget = (children: ItemNode[], targetId: string): void => {
+  children.forEach((node) => {
+    if (node.page._id === targetId) {
+      node.page.isTarget = true;
+    }
+    return node;
+  });
+
+  return;
+};
+
+const Item: FC<ItemProps> = (props: ItemProps) => {
+  const { itemNode, isOpen: _isOpen = false } = props;
+
+  const { page, children } = itemNode;
+
+  const [currentChildren, setCurrentChildren] = useState(children);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+
+  const { data: targetId } = usePageId();
+  const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
+
+  const hasChildren = useCallback((): boolean => {
+    return currentChildren != null && currentChildren.length > 0;
+  }, [currentChildren]);
+
+  const onClickLoadChildren = useCallback(async() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+
+  // didMount
+  useEffect(() => {
+    if (hasChildren()) setIsOpen(true);
+  }, []);
+
+  /*
+   * Make sure itemNode.children and currentChildren are synced
+   */
+  useEffect(() => {
+    if (children.length > currentChildren.length) {
+      markTarget(children, targetId);
+      setCurrentChildren(children);
+    }
+  }, []);
+
+  /*
+   * When swr fetch succeeded
+   */
+  useEffect(() => {
+    if (isOpen && error == null && data != null) {
+      const newChildren = ItemNode.generateNodesFromPages(data.children);
+      markTarget(newChildren, targetId);
+      setCurrentChildren(newChildren);
+    }
+  }, [data]);
+
+  // TODO: improve style
+  const opacityStyle = { opacity: 1.0 };
+  if (page.isTarget) opacityStyle.opacity = 0.7;
+
+  const buttonClass = isOpen ? 'rotate' : '';
+
+  return (
+    <div className="grw-pagetree-item-wrapper">
+      <div style={opacityStyle} className="grw-pagetree-item d-flex align-items-center">
+        <button
+          type="button"
+          className={`grw-pagetree-button btn ${buttonClass}`}
+          onClick={onClickLoadChildren}
+        >
+          <i className="icon-control-play"></i>
+        </button>
+        <a href={page._id} className="flex-grow-1">
+          <p className="grw-pagetree-title m-auto">{nodePath.basename(page.path as string) || '/'}</p>
+        </a>
+        <div className="grw-pagetree-control">
+          Ctrl
+        </div>
+      </div>
+      {
+        isOpen && hasChildren() && currentChildren.map(node => (
+          <Item
+            key={node.page._id}
+            itemNode={node}
+            isOpen={false}
+          />
+        ))
+      }
+    </div>
+  );
+
+};
+
+export default Item;

+ 18 - 0
packages/app/src/components/Sidebar/PageTree/ItemNode.ts

@@ -0,0 +1,18 @@
+import { IPageForItem } from '../../../interfaces/page';
+
+export class ItemNode {
+
+  page: IPageForItem;
+
+  children: ItemNode[];
+
+  constructor(page: IPageForItem, children: ItemNode[] = []) {
+    this.page = page;
+    this.children = children;
+  }
+
+  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
+    return pages.map(page => new ItemNode(page));
+  }
+
+}

+ 95 - 0
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -0,0 +1,95 @@
+import React, { FC } from 'react';
+
+import { IPage } from '../../../interfaces/page';
+import { ItemNode } from './ItemNode';
+import Item from './Item';
+import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
+import { useTargetAndAncestors, useCurrentPagePath } from '../../../stores/context';
+import { HasObjectId } from '../../../interfaces/has-object-id';
+
+
+/*
+ * Utility to generate initial node
+ */
+const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[]): ItemNode => {
+  const nodes = targetAndAncestors.map((page): ItemNode => {
+    return new ItemNode(page, []);
+  });
+
+  // update children for each node
+  const rootNode = nodes.reduce((child, parent) => {
+    parent.children = [child];
+    return parent;
+  });
+
+  return rootNode;
+};
+
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+  const paths = Object.keys(ancestorsChildren);
+
+  let currentNode = rootNode;
+  paths.reverse().forEach((path) => {
+    const childPages = ancestorsChildren[path];
+    currentNode.children = ItemNode.generateNodesFromPages(childPages);
+
+    const nextNode = currentNode.children.filter((node) => {
+      return paths.includes(node.page.path as string);
+    })[0];
+    currentNode = nextNode;
+  });
+
+  return rootNode;
+};
+
+
+/*
+ * ItemsTree
+ */
+const ItemsTree: FC = () => {
+  const { data: currentPath } = useCurrentPagePath();
+
+  const { data, error } = useTargetAndAncestors();
+
+  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(currentPath || null);
+
+  if (error != null || error2 != null) {
+    return null;
+  }
+
+  if (data == null) {
+    return null;
+  }
+
+  const { targetAndAncestors, rootPage } = data;
+
+  let initialNode: ItemNode;
+
+  /*
+   * Before swr response comes back
+   */
+  if (ancestorsChildrenData == null) {
+    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  }
+
+  /*
+   * When swr request finishes
+   */
+  else {
+    const { ancestorsChildren } = ancestorsChildrenData;
+
+    const rootNode = new ItemNode(rootPage);
+
+    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
+  }
+
+  const isOpen = true;
+  return (
+    <div className="grw-pagetree p-3">
+      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
+    </div>
+  );
+};
+
+
+export default ItemsTree;

+ 16 - 0
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx

@@ -0,0 +1,16 @@
+import React, { FC, memo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const PrivateLegacyPages: FC = memo(() => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="grw-prvt-legacy-pages p-3">
+      <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5">
+        <i className="icon-drawer mr-2"></i> PrivateLegacyPages
+      </a>
+    </div>
+  );
+});
+
+export default PrivateLegacyPages;

+ 6 - 100
packages/app/src/components/Sidebar/RecentChanges.jsx → packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -1,16 +1,15 @@
 import React, {
+  FC,
   useCallback, useEffect, useState,
 } from 'react';
 import PropTypes from 'prop-types';
 
-import { useTranslation, withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { UserPicture } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/apiNotification';
 import { useSWRxRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
@@ -124,14 +123,10 @@ SmallPageItem.propTypes = {
 };
 
 
-const RecentChanges = () => {
+const RecentChanges: FC<void> = () => {
 
   const { t } = useTranslation();
-  const { data: pages, error, mutate } = useSWRxRecentlyUpdated();
-
-  if (error != null) {
-    toastError(error, 'Error occurred in updating History');
-  }
+  const { data: pages, mutate } = useSWRxRecentlyUpdated();
 
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
 
@@ -139,7 +134,7 @@ const RecentChanges = () => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
     }
-  });
+  }, []);
 
   const changeSizeHandler = useCallback((e) => {
     setIsRecentChangesSidebarSmall(e.target.checked);
@@ -184,93 +179,4 @@ const RecentChanges = () => {
 
 };
 
-// export default RecentChanges;
-
-
-class DeprecatedRecentChanges extends React.Component {
-
-  static propTypes = {
-    t: PropTypes.func.isRequired, // i18next
-  };
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      isRecentChangesSidebarSmall: false,
-      recentlyUpdatedPages: [],
-    };
-    this.reloadData = this.reloadData.bind(this);
-  }
-
-  componentWillMount() {
-    this.retrieveSizePreferenceFromLocalStorage();
-  }
-
-  async componentDidMount() {
-    this.reloadData();
-  }
-
-  async reloadData() {
-    try {
-      const { data } = await apiv3Get('/pages/recent');
-      this.setState({ recentlyUpdatedPages: data.pages });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      toastError(error, 'Error occurred in updating History');
-    }
-  }
-
-  retrieveSizePreferenceFromLocalStorage() {
-    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
-      this.setState({
-        isRecentChangesSidebarSmall: true,
-      });
-    }
-  }
-
-  changeSizeHandler = (e) => {
-    this.setState({
-      isRecentChangesSidebarSmall: e.target.checked,
-    });
-    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <>
-        <div className="grw-sidebar-content-header p-3 d-flex">
-          <h3 className="mb-0">{t('Recent Changes')}</h3>
-          {/* <h3 className="mb-0">{t('Recent Created')}</h3> */} {/* TODO: impl switching */}
-          <button type="button" className="btn btn-sm ml-auto grw-btn-reload-rc" onClick={this.reloadData}>
-            <i className="icon icon-reload"></i>
-          </button>
-          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-2">
-            <input
-              id="recentChangesResize"
-              className="custom-control-input"
-              type="checkbox"
-              checked={this.state.isRecentChangesSidebarSmall}
-              onChange={this.changeSizeHandler}
-            />
-            <label className="custom-control-label" htmlFor="recentChangesResize">
-            </label>
-          </div>
-        </div>
-        <div className="grw-sidebar-content-body grw-recent-changes p-3">
-          <ul className="list-group list-group-flush">
-            {this.state.recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
-              ? <SmallPageItem key={page._id} page={page} />
-              : <LargePageItem key={page._id} page={page} />))}
-          </ul>
-        </div>
-      </>
-    );
-  }
-
-}
-
-
-export default withTranslation()(DeprecatedRecentChanges);
+export default RecentChanges;

+ 0 - 49
packages/app/src/components/Sidebar/SidebarContents.jsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-import RecentChanges from './RecentChanges';
-import CustomSidebar from './CustomSidebar';
-
-const SidebarContents = (props) => {
-  const { navigationContainer, isSharedUser } = props;
-
-  if (isSharedUser) {
-    return null;
-  }
-
-  let Contents;
-  switch (navigationContainer.state.sidebarContentsId) {
-    case 'recent':
-      Contents = RecentChanges;
-      break;
-    default:
-      Contents = CustomSidebar;
-  }
-
-  return (
-    <Contents />
-  );
-
-};
-
-SidebarContents.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isSharedUser: PropTypes.bool,
-};
-
-SidebarContents.defaultProps = {
-  isSharedUser: false,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarContentsWrapper = withUnstatedContainers(SidebarContents, [NavigationContainer]);
-
-export default withTranslation()(SidebarContentsWrapper);

+ 33 - 0
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -0,0 +1,33 @@
+import React, { FC } from 'react';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentSidebarContents } from '~/stores/ui';
+import RecentChanges from './RecentChanges';
+import CustomSidebar from './CustomSidebar';
+import PageTree from './PageTree';
+
+type Props = {
+};
+
+const SidebarContents: FC<Props> = (props: Props) => {
+  const { data: currentSidebarContents } = useCurrentSidebarContents();
+
+  let Contents;
+  switch (currentSidebarContents) {
+    case SidebarContentsType.RECENT:
+      Contents = RecentChanges;
+      break;
+    case SidebarContentsType.TREE:
+      Contents = PageTree;
+      break;
+    default:
+      Contents = CustomSidebar;
+  }
+
+  return (
+    <Contents />
+  );
+
+};
+
+export default SidebarContents;

+ 0 - 94
packages/app/src/components/Sidebar/SidebarNav.jsx

@@ -1,94 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-
-
-class SidebarNav extends React.Component {
-
-  static propTypes = {
-    onItemSelected: PropTypes.func,
-  };
-
-  state = {
-  };
-
-  itemSelectedHandler = (contentsId) => {
-    const { navigationContainer, onItemSelected } = this.props;
-    if (onItemSelected != null) {
-      onItemSelected(contentsId);
-    }
-
-    navigationContainer.selectSidebarContents(contentsId);
-  }
-
-  PrimaryItem = ({ id, label, iconName }) => {
-    const { sidebarContentsId } = this.props.navigationContainer.state;
-    const isSelected = sidebarContentsId === id;
-
-    return (
-      <button
-        type="button"
-        className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
-        onClick={() => this.itemSelectedHandler(id)}
-      >
-        <i className="material-icons">{iconName}</i>
-      </button>
-    );
-  }
-
-  SecondaryItem({
-    label, iconName, href, isBlank,
-  }) {
-    return (
-      <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
-        <i className="material-icons">{iconName}</i>
-      </a>
-    );
-  }
-
-  generateIconFactory(classNames) {
-    return () => <i className={classNames}></i>;
-  }
-
-  render() {
-    const { isAdmin, currentUsername, isSharedUser } = this.props.appContainer;
-    const isLoggedIn = currentUsername != null;
-
-    const { PrimaryItem, SecondaryItem } = this;
-
-    return (
-      <div className="grw-sidebar-nav">
-        <div className="grw-sidebar-nav-primary-container">
-          {!isSharedUser && <PrimaryItem id="custom" label="Custom Sidebar" iconName="code" />}
-          {!isSharedUser && <PrimaryItem id="recent" label="Recent Changes" iconName="update" />}
-          {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
-          {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
-        </div>
-        <div className="grw-sidebar-nav-secondary-container">
-          {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-          {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
-          <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
-          <SecondaryItem label="Trash" iconName="delete" href="/trash" />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SidebarNav.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SidebarNavWrapper = withUnstatedContainers(SidebarNav, [AppContainer, NavigationContainer]);
-
-export default withTranslation()(SidebarNavWrapper);

+ 99 - 0
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -0,0 +1,99 @@
+import React, { FC, memo, useCallback } from 'react';
+
+import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { SidebarContentsType } from '~/interfaces/ui';
+import { useCurrentUser, useIsSharedUser } from '~/stores/context';
+import { useCurrentSidebarContents } from '~/stores/ui';
+
+
+type PrimaryItemProps = {
+  contents: SidebarContentsType,
+  label: string,
+  iconName: string,
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
+  const {
+    contents, iconName, onItemSelected,
+  } = props;
+
+  // TODO: migrate from NavigationContainer
+  const { data: currentContents, mutate } = useCurrentSidebarContents();
+
+  const isSelected = contents === currentContents;
+
+  const itemSelectedHandler = useCallback(() => {
+    // const { navigationContainer, onItemSelected } = this.props;
+    if (onItemSelected != null) {
+      onItemSelected(contents);
+    }
+
+    mutate(contents, false);
+    scheduleToPutUserUISettings({ currentSidebarContents: contents });
+  }, [contents, mutate, onItemSelected]);
+
+  return (
+    <button
+      type="button"
+      className={`d-block btn btn-primary ${isSelected ? 'active' : ''}`}
+      onClick={itemSelectedHandler}
+    >
+      <i className="material-icons">{iconName}</i>
+    </button>
+  );
+};
+
+type SecondaryItemProps = {
+  label: string,
+  href: string,
+  iconName: string,
+  isBlank?: boolean,
+}
+
+const SecondaryItem: FC<SecondaryItemProps> = memo((props: SecondaryItemProps) => {
+  const { iconName, href, isBlank } = props;
+
+  return (
+    <a href={href} className="d-block btn btn-primary" target={`${isBlank ? '_blank' : ''}`}>
+      <i className="material-icons">{iconName}</i>
+    </a>
+  );
+});
+
+
+type Props = {
+  onItemSelected: (contents: SidebarContentsType) => void,
+}
+
+const SidebarNav: FC<Props> = (props: Props) => {
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
+  const isLoggedIn = currentUser != null;
+
+  const { onItemSelected } = props;
+
+  return (
+    <div className="grw-sidebar-nav">
+      <div className="grw-sidebar-nav-primary-container">
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />}
+        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
+      </div>
+      <div className="grw-sidebar-nav-secondary-container">
+        {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+        {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
+        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Trash" iconName="delete" href="/trash" />
+      </div>
+    </div>
+  );
+
+};
+
+export default SidebarNav;

+ 6 - 9
packages/app/src/components/StickyStretchableScroller.jsx

@@ -5,7 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
@@ -49,7 +48,6 @@ const StickyStretchableScroller = (props) => {
 
   let { scrollTargetSelector } = props;
   const {
-    navigationContainer,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
   } = props;
@@ -142,11 +140,11 @@ const StickyStretchableScroller = (props) => {
   }, [resetScrollbarDebounced]);
 
   // setup effect by isScrollTop
-  useEffect(() => {
-    if (navigationContainer.state.isScrollTop) {
-      resetScrollbar();
-    }
-  }, [navigationContainer.state.isScrollTop, resetScrollbar]);
+  // useEffect(() => {
+  //   if (navigationContainer.state.isScrollTop) {
+  //     resetScrollbar();
+  //   }
+  // }, [navigationContainer.state.isScrollTop, resetScrollbar]);
 
   // setup effect by update props
   useEffect(() => {
@@ -161,7 +159,6 @@ const StickyStretchableScroller = (props) => {
 };
 
 StickyStretchableScroller.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   contentsElemSelector: PropTypes.string.isRequired,
 
   children: PropTypes.node,
@@ -172,4 +169,4 @@ StickyStretchableScroller.propTypes = {
   calcContentsHeightFunc: PropTypes.func,
 };
 
-export default withUnstatedContainers(StickyStretchableScroller, [NavigationContainer]);
+export default StickyStretchableScroller;

+ 7 - 0
packages/app/src/interfaces/common.ts

@@ -0,0 +1,7 @@
+/*
+ * Common types and interfaces
+ */
+
+
+// Foreign key field
+export type Ref<T> = string | T;

+ 3 - 0
packages/app/src/interfaces/has-object-id.ts

@@ -0,0 +1,3 @@
+export type HasObjectId = {
+  _id: string,
+};

+ 13 - 0
packages/app/src/interfaces/named-query.ts

@@ -0,0 +1,13 @@
+import { IUser } from './user';
+
+
+export enum SearchDelegatorName {
+  DEFAULT = 'FullTextSearch',
+  PRIVATE_LEGACY_PAGES = 'PrivateLegacyPages',
+}
+export interface INamedQuery {
+  name: string
+  aliasOf?: string
+  delegatorName?: SearchDelegatorName
+  creator?: IUser
+}

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

@@ -0,0 +1,23 @@
+import { IPageForItem } from './page';
+
+
+type ParentPath = string;
+export interface AncestorsChildrenResult {
+  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
+}
+
+
+export interface ChildrenResult {
+  children: Partial<IPageForItem>[]
+}
+
+
+export interface TargetAndAncestors {
+  targetAndAncestors: Partial<IPageForItem>[]
+  rootPage: Partial<IPageForItem>,
+}
+
+
+export interface V5MigrationStatus {
+  migratablePagesCount: number
+}

+ 24 - 11
packages/app/src/interfaces/page.ts

@@ -1,21 +1,34 @@
+import { Ref } from './common';
 import { IUser } from './user';
 import { IRevision } from './revision';
 import { ITag } from './tag';
+import { HasObjectId } from './has-object-id';
+
 
 export type IPage = {
   path: string,
   status: string,
-  revision: string | IRevision,
-  tags?: ITag[],
-  lastUpdateUser: any,
-  commentCount: number,
-  creator: string | IUser,
-  seenUsers: string[],
-  liker: string[],
+  revision: Ref<IRevision>,
+  tags: Ref<ITag>[],
+  creator: Ref<IUser>,
   createdAt: Date,
   updatedAt: Date,
-};
+  seenUsers: Ref<IUser>[],
+  parent: Ref<IPage> | null,
+  isEmpty: boolean,
+  redirectTo: string,
+  grant: number,
+  grantedUsers: Ref<IUser>[],
+  grantedGroup: Ref<any>,
+  lastUpdateUser: Ref<IUser>,
+  liker: Ref<IUser>[],
+  commentCount: number
+  slackChannels: string,
+  pageIdOnHackmd: string,
+  revisionHackmdSynced: Ref<IRevision>,
+  hasDraftOnHackmd: boolean,
+  deleteUser: Ref<IUser>,
+  deletedAt: Date,
+}
 
-export type IPageHasId = IPage & {
-  _id: string,
-};
+export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;

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

@@ -0,0 +1,7 @@
+export const SidebarContentsType = {
+  CUSTOM: 'custom',
+  RECENT: 'recent',
+  TREE: 'tree',
+} as const;
+export const AllSidebarContentsType = Object.values(SidebarContentsType);
+export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 13 - 0
packages/app/src/interfaces/user-ui-settings.ts

@@ -0,0 +1,13 @@
+import { IUser } from './user';
+
+import { SidebarContentsType } from './ui';
+import { Ref } from './common';
+
+export interface IUserUISettings {
+  user: Ref<IUser> | null;
+  isSidebarCollapsed: boolean,
+  currentSidebarContents: SidebarContentsType,
+  currentProductNavWidth: number,
+  preferDrawerModeByUser: boolean,
+  preferDrawerModeOnEditByUser: boolean,
+}

+ 3 - 2
packages/app/src/migrations/20181019114028-abolish-page-group-relation.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
@@ -37,7 +38,7 @@ module.exports = {
       return;
     }
 
-    const Page = getModelSafely('Page') || require('~/server/models/page')();
+    const Page = getModelSafely('Page') || getPageModel();
     const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
 
     // retrieve all documents from 'pagegrouprelations'
@@ -74,7 +75,7 @@ module.exports = {
     logger.info('Rollback migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = getModelSafely('Page') || require('~/server/models/page')();
+    const Page = getModelSafely('Page') || getPageModel();
     const UserGroup = getModelSafely('UserGroup') || require('~/server/models/user-group')();
 
     // retrieve all Page documents which granted by UserGroup

+ 2 - 1
packages/app/src/migrations/20190619055421-adjust-page-grant.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:adjust-page-grant');
 
@@ -11,7 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     await Page.bulkWrite([
       {

+ 2 - 1
packages/app/src/migrations/20190624110950-fill-last-update-user.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:abolish-page-group-relation');
 
@@ -14,7 +15,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     // see https://stackoverflow.com/questions/3974985/update-mongodb-field-using-value-of-another-field/37280419#37280419
 

+ 2 - 1
packages/app/src/migrations/20190629193445-make-root-page-public.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:make-root-page-public');
 
@@ -10,7 +11,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     await Page.findOneAndUpdate(
       { path: '/' },

+ 2 - 2
packages/app/src/migrations/20191126173016-adjust-pages-path.js

@@ -1,6 +1,6 @@
 import mongoose from 'mongoose';
 import { pathUtils, getMongoUri, mongoOptions } from '@growi/core';
-
+import getPageModel from '~/server/models/page';
 
 import loggerFactory from '~/utils/logger';
 
@@ -12,7 +12,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = require('~/server/models/page')();
+    const Page = getPageModel();
 
     // retrieve target data
     const pages = await Page.find({ path: /^(?!\/)/ });

+ 2 - 1
packages/app/src/migrations/20210420160380-convert-double-to-date.js

@@ -2,6 +2,7 @@ import mongoose from 'mongoose';
 
 import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
 import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
 
 const logger = loggerFactory('growi:migrate:remove-crowi-lauout');
 
@@ -10,7 +11,7 @@ module.exports = {
     logger.info('Apply migration');
     mongoose.connect(getMongoUri(), mongoOptions);
 
-    const Page = getModelSafely('Page') || require('~/server/models/page')();
+    const Page = getModelSafely('Page') || getPageModel();
 
     const pages = await Page.find({ updatedAt: { $type: 'double' } });
 

+ 47 - 0
packages/app/src/migrations/20211129125654-initialize-private-legacy-pages-named-query.js

@@ -0,0 +1,47 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import NamedQuery from '~/server/models/named-query';
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:initialize-private-legacy-pages-named-query');
+
+module.exports = {
+  async up(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await NamedQuery.insertMany({
+        name: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+        delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to migrate named query for private legacy pages search delagator.', err);
+      throw err;
+    }
+
+    next();
+    logger.info('Successfully migrated named query for private legacy pages search delagator.');
+  },
+
+  async down(db, next) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await NamedQuery.findOneAndDelete({
+        name: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+        delegatorName: SearchDelegatorName.PRIVATE_LEGACY_PAGES,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to delete named query for private legacy pages search delagator.', err);
+      throw err;
+    }
+
+    next();
+    logger.info('Successfully deleted named query for private legacy pages search delagator.');
+  },
+};

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -18,6 +18,7 @@ import { projectRoot } from '~/utils/project-dir-utils';
 import ConfigManager from '../service/config-manager';
 import AppService from '../service/app';
 import AclService from '../service/acl';
+import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -371,7 +372,6 @@ Crowi.prototype.setupPassport = async function() {
 };
 
 Crowi.prototype.setupSearcher = async function() {
-  const SearchService = require('~/server/service/search');
   this.searchService = new SearchService(this);
 };
 

+ 42 - 0
packages/app/src/server/interfaces/search.ts

@@ -0,0 +1,42 @@
+/* eslint-disable camelcase */
+import { SearchDelegatorName } from '~/interfaces/named-query';
+
+
+export type QueryTerms = {
+  match: string[],
+  not_match: string[],
+  phrase: string[],
+  not_phrase: string[],
+  prefix: string[],
+  not_prefix: string[],
+  tag: string[],
+  not_tag: string[],
+}
+
+export type ParsedQuery = { queryString: string, terms?: QueryTerms, delegatorName?: string }
+
+export interface SearchQueryParser {
+  parseSearchQuery(queryString: string): Promise<ParsedQuery>
+}
+
+export interface SearchResolver{
+  resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]>
+}
+
+export interface SearchDelegator<T = unknown> {
+  name?: SearchDelegatorName
+  search(data: SearchableData | null, user, userGroups, option): Promise<Result<T> & MetaData>
+}
+
+export type Result<T> = {
+  data: T
+}
+
+export type MetaData = {
+  meta?: { [key:string]: any }
+}
+
+export type SearchableData = {
+  queryString: string
+  terms: QueryTerms
+}

+ 1 - 1
packages/app/src/server/middlewares/auto-reconnect-to-search.js

@@ -14,7 +14,7 @@ module.exports = (crowi) => {
       await searchService.reconnectClient();
     }
     catch (err) {
-      logger.error('Auto reconnection failed.');
+      logger.error('Auto reconnection failed.', err);
     }
 
     return searchService.isReachable;

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

@@ -36,6 +36,7 @@ export const generateConfigsForInstalling = (): { [key: string]: any } => {
   config['app:installed'] = true;
   config['app:fileUpload'] = true;
   config['customize:isSavedStatesOfTabChanges'] = false;
+  config['app:isV5Compatible'] = true;
 
   return config;
 };

+ 3 - 1
packages/app/src/server/models/index.js

@@ -1,5 +1,7 @@
+import Page from '~/server/models/page';
+
 module.exports = {
-  Page: require('./page'),
+  Page,
   // TODO GW-2746 bulk export pages
   // PageArchive: require('./page-archive'),
   PageTagRelation: require('./page-tag-relation'),

+ 36 - 0
packages/app/src/server/models/named-query.ts

@@ -0,0 +1,36 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import { getOrCreateModel } from '@growi/core';
+import loggerFactory from '../../utils/logger';
+import { INamedQuery, SearchDelegatorName } from '../../interfaces/named-query';
+
+const logger = loggerFactory('growi:models:named-query');
+
+export interface NamedQueryDocument extends INamedQuery, Document {}
+
+export type NamedQueryModel = Model<NamedQueryDocument>
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new Schema<NamedQueryDocument, NamedQueryModel>({
+  name: { type: String, required: true, unique: true },
+  aliasOf: { type: String },
+  delegatorName: { type: String, enum: SearchDelegatorName },
+  creator: {
+    type: ObjectId, ref: 'User', index: true, default: null,
+  },
+});
+
+schema.pre('validate', async function(this, next) {
+  if (this.aliasOf == null && this.delegatorName == null) {
+    throw Error('Either of aliasOf and delegatorNameName must not be null.');
+  }
+
+  next();
+});
+
+export default getOrCreateModel<NamedQueryDocument, NamedQueryModel>('NamedQuery', schema);

+ 72 - 74
packages/app/src/server/models/page.js → packages/app/src/server/models/obsolete-page.js

@@ -10,8 +10,6 @@ const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
 const urljoin = require('url-join');
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
 const differenceInYears = require('date-fns/differenceInYears');
 
 const { pathUtils } = require('growi-commons');
@@ -22,11 +20,6 @@ const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
 const GRANT_PUBLIC = 1;
 const GRANT_RESTRICTED = 2;
 const GRANT_SPECIFIED = 3;
@@ -36,37 +29,11 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-const pageSchema = new mongoose.Schema({
-  path: {
-    type: String, required: true, index: true, unique: true,
-  },
-  revision: { type: ObjectId, ref: 'Revision' },
-  redirectTo: { type: String, index: true },
-  status: { type: String, default: STATUS_PUBLISHED, index: true },
-  grant: { type: Number, default: GRANT_PUBLIC, index: true },
-  grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
-  creator: { type: ObjectId, ref: 'User', index: true },
-  lastUpdateUser: { type: ObjectId, ref: 'User' },
-  liker: [{ type: ObjectId, ref: 'User' }],
-  seenUsers: [{ type: ObjectId, ref: 'User' }],
-  commentCount: { type: Number, default: 0 },
-  slackChannels: { type: String },
-  pageIdOnHackmd: String,
-  revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
-  hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
-  createdAt: { type: Date, default: Date.now },
-  updatedAt: { type: Date, default: Date.now },
-  deleteUser: { type: ObjectId, ref: 'User' },
-  deletedAt: { type: Date },
-}, {
-  toJSON: { getters: true },
-  toObject: { getters: true },
-});
-// apply plugins
-pageSchema.plugin(mongoosePaginate);
-pageSchema.plugin(uniqueValidator);
-
+// schema definition has moved to page.ts
+const pageSchema = {
+  statics: {},
+  methods: {},
+};
 
 /**
  * return an array of ancestors paths that is extracted from specified pagePath
@@ -110,7 +77,7 @@ const populateDataToShowRevision = (page, userPublicFields) => {
 /* eslint-enable object-curly-newline, object-property-newline */
 
 
-class PageQueryBuilder {
+export class PageQueryBuilder {
 
   constructor(query) {
     this.query = query;
@@ -252,6 +219,41 @@ class PageQueryBuilder {
     return this;
   }
 
+  addConditionAsNonRootPage() {
+    this.query = this.query.and({ path: { $ne: '/' } });
+
+    return this;
+  }
+
+  addConditionAsNotMigrated() {
+    this.query = this.query
+      .and({ parent: null });
+
+    return this;
+  }
+
+  addConditionAsMigrated() {
+    this.query = this.query
+      .and({ parent: { $ne: null } });
+
+    return this;
+  }
+
+  /*
+   * Add this condition when get any ancestor pages including the target's parent
+   */
+  addConditionToSortAncestorPages() {
+    this.query = this.query.sort('-path');
+
+    return this;
+  }
+
+  addConditionToMinimizeDataForRendering() {
+    this.query = this.query.select('_id path isEmpty grant');
+
+    return this;
+  }
+
   addConditionToListByPathsArray(paths) {
     this.query = this.query
       .and({
@@ -290,7 +292,7 @@ class PageQueryBuilder {
 
 }
 
-module.exports = function(crowi) {
+export const getPageSchema = (crowi) => {
   let pageEvent;
 
   // init event
@@ -601,31 +603,6 @@ module.exports = function(crowi) {
     return this.findOne({ path });
   };
 
-  /**
-   * @param {string} path Page path
-   * @param {User} user User instance
-   * @param {UserGroup[]} userGroups List of UserGroup instances
-   */
-  pageSchema.statics.findByPathAndViewer = async function(path, user, userGroups) {
-    if (path == null) {
-      throw new Error('path is required.');
-    }
-
-    const baseQuery = this.findOne({ path });
-
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
-
-    const queryBuilder = new PageQueryBuilder(baseQuery);
-    queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
-
-    return await queryBuilder.query.exec();
-  };
-
   /**
    * @param {string} path Page path
    * @param {User} user User instance
@@ -727,13 +704,15 @@ module.exports = function(crowi) {
     return await findListFromBuilderAndViewer(builder, currentUser, showAnyoneKnowsLink, opt);
   };
 
-  pageSchema.statics.findListByPageIds = async function(ids, option) {
+  pageSchema.statics.findListByPageIds = async function(ids, option, excludeRedirect = true) {
     const User = crowi.model('User');
 
     const opt = Object.assign({}, option);
     const builder = new PageQueryBuilder(this.find({ _id: { $in: ids } }));
 
-    builder.addConditionToExcludeRedirect();
+    if (excludeRedirect) {
+      builder.addConditionToExcludeRedirect();
+    }
     builder.addConditionToPagenate(opt.offset, opt.limit);
 
     // count
@@ -971,9 +950,9 @@ module.exports = function(crowi) {
 
     const Page = this;
     const Revision = crowi.model('Revision');
-    const format = options.format || 'markdown';
-    const redirectTo = options.redirectTo || null;
-    const grantUserGroupId = options.grantUserGroupId || null;
+    const {
+      format = 'markdown', redirectTo, grantUserGroupId, parentId,
+    } = options;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -984,18 +963,37 @@ module.exports = function(crowi) {
       grant = GRANT_PUBLIC;
     }
 
-    const isExist = await this.count({ path });
-
+    const isExist = await this.count({ path, isEmpty: false }); // not validate empty page
     if (isExist) {
       throw new Error('Cannot create new page to existed path');
     }
 
-    const page = new Page();
+    /*
+     * update empty page if exists, if not, create a new page
+     */
+    let page;
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+    if (emptyPage != null) {
+      page = emptyPage;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    let parent = parentId;
+    if (isV5Compatible && parent == null && !isTopPage(path)) {
+      parent = await Page.getParentIdAndFillAncestors(path);
+    }
+
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
     page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
+    page.parent = parent;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
@@ -1177,5 +1175,5 @@ module.exports = function(crowi) {
 
   pageSchema.statics.PageQueryBuilder = PageQueryBuilder;
 
-  return mongoose.model('Page', pageSchema);
+  return pageSchema;
 };

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

@@ -0,0 +1,348 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+import nodePath from 'path';
+import RE2 from 're2';
+
+import { getOrCreateModel, pagePathUtils } from '@growi/core';
+import loggerFactory from '../../utils/logger';
+import Crowi from '../crowi';
+import { IPage } from '../../interfaces/page';
+import { getPageSchema, PageQueryBuilder } from './obsolete-page';
+
+const { isTopPage } = pagePathUtils;
+
+const logger = loggerFactory('growi:models:page');
+
+
+/*
+ * define schema
+ */
+const GRANT_PUBLIC = 1;
+const GRANT_RESTRICTED = 2;
+const GRANT_SPECIFIED = 3;
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+const PAGE_GRANT_ERROR = 1;
+const STATUS_PUBLISHED = 'published';
+const STATUS_DELETED = 'deleted';
+
+export interface PageDocument extends IPage, Document {}
+
+type TargetAndAncestorsResult = {
+  targetAndAncestors: PageDocument[]
+  rootPage: PageDocument
+}
+export interface PageModel extends Model<PageDocument> {
+  createEmptyPagesByPaths(paths: string[]): Promise<void>
+  getParentIdAndFillAncestors(path: string): Promise<string | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
+  findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
+
+  PageQueryBuilder: typeof PageQueryBuilder
+
+  GRANT_PUBLIC
+  GRANT_RESTRICTED
+  GRANT_SPECIFIED
+  GRANT_OWNER
+  GRANT_USER_GROUP
+  PAGE_GRANT_ERROR
+  STATUS_PUBLISHED
+  STATUS_DELETED
+}
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new Schema<PageDocument, PageModel>({
+  parent: {
+    type: ObjectId, ref: 'Page', index: true, default: null,
+  },
+  isEmpty: { type: Boolean, default: false },
+  path: {
+    type: String, required: true, index: true,
+  },
+  revision: { type: ObjectId, ref: 'Revision' },
+  redirectTo: { type: String, index: true },
+  status: { type: String, default: STATUS_PUBLISHED, index: true },
+  grant: { type: Number, default: GRANT_PUBLIC, index: true },
+  grantedUsers: [{ type: ObjectId, ref: 'User' }],
+  grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
+  creator: { type: ObjectId, ref: 'User', index: true },
+  lastUpdateUser: { type: ObjectId, ref: 'User' },
+  liker: [{ type: ObjectId, ref: 'User' }],
+  seenUsers: [{ type: ObjectId, ref: 'User' }],
+  commentCount: { type: Number, default: 0 },
+  slackChannels: { type: String },
+  pageIdOnHackmd: { type: String },
+  revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
+  hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  createdAt: { type: Date, default: new Date() },
+  updatedAt: { type: Date, default: new Date() },
+  deleteUser: { type: ObjectId, ref: 'User' },
+  deletedAt: { type: Date },
+}, {
+  toJSON: { getters: true },
+  toObject: { getters: true },
+});
+// apply plugins
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+
+/*
+ * Methods
+ */
+const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
+  if (isTopPage(path)) return ancestorPaths;
+
+  const parentPath = nodePath.dirname(path);
+  ancestorPaths.push(parentPath);
+  return collectAncestorPaths(parentPath, ancestorPaths);
+};
+
+
+const hasSlash = (str: string): boolean => {
+  return str.includes('/');
+};
+
+/*
+ * Generate RE2 instance for one level lower path
+ */
+const generateChildrenRE2 = (path: string): RE2 => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RE2(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RE2(`^${path}(\\/[^/]+)\\/?$`);
+};
+
+/*
+ * Generate RegExp instance for one level lower path
+ */
+const generateChildrenRegExp = (path: string): RegExp => {
+  // https://regex101.com/r/laJGzj/1
+  // ex. /any_level1
+  if (isTopPage(path)) return new RegExp(/^\/[^/]+$/);
+
+  // https://regex101.com/r/mrDJrx/1
+  // ex. /parent/any_child OR /any_level1
+  return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
+};
+
+/*
+ * Create empty pages if the page in paths didn't exist
+ */
+schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promise<void> {
+  // find existing parents
+  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }));
+  const existingPages = await builder
+    .addConditionToListByPathsArray(paths)
+    .query
+    .lean()
+    .exec();
+  const existingPagePaths = existingPages.map(page => page.path);
+
+  // paths to create empty pages
+  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+
+  // insertMany empty pages
+  try {
+    await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
+  }
+  catch (err) {
+    logger.error('Failed to insert empty pages.', err);
+    throw err;
+  }
+};
+
+/*
+ * Find the pages parent and update if the parent exists.
+ * If not,
+ *   - first   run createEmptyPagesByPaths with ancestor's paths to ensure all the ancestors exist
+ *   - second  update ancestor pages' parent
+ *   - finally return the target's parent page id
+ */
+schema.statics.getParentIdAndFillAncestors = async function(path: string): Promise<Schema.Types.ObjectId> {
+  const parentPath = nodePath.dirname(path);
+
+  const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
+  if (parent != null) { // fill parents if parent is null
+    return parent._id;
+  }
+
+  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
+
+  // just create ancestors with empty pages
+  await this.createEmptyPagesByPaths(ancestorPaths);
+
+  // find ancestors
+  const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }));
+  const ancestors = await builder
+    .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToSortAncestorPages()
+    .query
+    .lean()
+    .exec();
+
+
+  const ancestorsMap = new Map(); // Map<path, _id>
+  ancestors.forEach(page => ancestorsMap.set(page.path, page._id));
+
+  // bulkWrite to update ancestors
+  const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
+  const operations = nonRootAncestors.map((page) => {
+    const { path } = page;
+    const parentPath = nodePath.dirname(path);
+    return {
+      updateOne: {
+        filter: {
+          path,
+        },
+        update: {
+          parent: ancestorsMap.get(parentPath),
+        },
+      },
+    };
+  });
+  await this.bulkWrite(operations);
+
+  const parentId = ancestorsMap.get(parentPath);
+  return parentId;
+};
+
+// Utility function to add viewer condition to PageQueryBuilder instance
+const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
+  let relatedUserGroups = userGroups;
+  if (user != null && relatedUserGroups == null) {
+    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
+    relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+  }
+
+  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
+};
+
+/*
+ * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
+ */
+schema.statics.findByPathAndViewer = async function(
+    path: string | null, user, userGroups = null, useFindOne = true,
+): Promise<PageDocument | PageDocument[] | null> {
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
+  const queryBuilder = new PageQueryBuilder(baseQuery);
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.exec();
+};
+
+
+/*
+ * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
+ * The result will include the target as well
+ */
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<TargetAndAncestorsResult> {
+  let path;
+  if (!hasSlash(pathOrId)) {
+    const _id = pathOrId;
+    const page = await this.findOne({ _id });
+    if (page == null) throw new Error('Page not found.');
+
+    path = page.path;
+  }
+  else {
+    path = pathOrId;
+  }
+
+  const ancestorPaths = collectAncestorPaths(path);
+  ancestorPaths.push(path); // include target
+
+  // Do not populate
+  const queryBuilder = new PageQueryBuilder(this.find());
+  const _targetAndAncestors: PageDocument[] = await queryBuilder
+    .addConditionToListByPathsArray(ancestorPaths)
+    .addConditionToMinimizeDataForRendering()
+    .addConditionToSortAncestorPages()
+    .query
+    .lean()
+    .exec();
+
+  // no same path pages
+  const ancestorsMap = new Map<string, PageDocument>();
+  _targetAndAncestors.forEach(page => ancestorsMap.set(page.path, page));
+  const targetAndAncestors = Array.from(ancestorsMap.values());
+  const rootPage = targetAndAncestors[targetAndAncestors.length - 1];
+
+  return { targetAndAncestors, rootPage };
+};
+
+/*
+ * Find all children by parent's path or id. Using id should be prioritized
+ */
+schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPathOrId: string, user, userGroups = null): Promise<PageDocument[]> {
+  let queryBuilder: PageQueryBuilder;
+  if (hasSlash(parentPathOrId)) {
+    const path = parentPathOrId;
+    const regexp = generateChildrenRE2(path);
+    queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp.source } }));
+  }
+  else {
+    const parentId = parentPathOrId;
+    queryBuilder = new PageQueryBuilder(this.find({ parent: parentId }));
+  }
+  await addViewerCondition(queryBuilder, user, userGroups);
+
+  return queryBuilder.query.lean().exec();
+};
+
+schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
+  const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path);
+  const regexps = ancestorPaths.map(path => new RegExp(generateChildrenRegExp(path))); // cannot use re2
+
+  // get pages at once
+  const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
+  await addViewerCondition(queryBuilder, user, userGroups);
+  const _pages = await queryBuilder
+    .addConditionToMinimizeDataForRendering()
+    .query
+    .lean()
+    .exec();
+  // mark target
+  const pages = _pages.map((page: PageDocument & {isTarget?: boolean}) => {
+    if (page.path === path) {
+      page.isTarget = true;
+    }
+    return page;
+  });
+
+  // make map
+  const pathToChildren: Record<string, PageDocument[]> = {};
+  ancestorPaths.forEach((path) => {
+    pathToChildren[path] = pages.filter(page => nodePath.dirname(page.path) === path);
+  });
+
+  return pathToChildren;
+};
+
+
+/*
+ * Merge obsolete page model methods and define new methods which depend on crowi instance
+ */
+export default (crowi: Crowi): any => {
+  // add old page schema methods
+  const pageSchema = getPageSchema(crowi);
+  schema.methods = { ...pageSchema.methods, ...schema.methods };
+  schema.statics = { ...pageSchema.statics, ...schema.statics };
+
+  return getOrCreateModel<PageDocument, PageModel>('Page', schema);
+};

+ 28 - 0
packages/app/src/server/models/user-ui-settings.ts

@@ -0,0 +1,28 @@
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import { getOrCreateModel } from '@growi/core';
+
+import { SidebarContentsType } from '~/interfaces/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+
+
+export interface UserUISettingsDocument extends IUserUISettings, Document {}
+export type UserUISettingsModel = Model<UserUISettingsDocument>
+
+const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
+  user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  isSidebarCollapsed: { type: Boolean, default: false },
+  currentSidebarContents: {
+    type: String,
+    enum: SidebarContentsType,
+    default: SidebarContentsType.RECENT,
+  },
+  currentProductNavWidth: { type: Number },
+  preferDrawerModeByUser: { type: Boolean, default: false },
+  preferDrawerModeOnEditByUser: { type: Boolean, default: false },
+});
+
+
+export default getOrCreateModel<UserUISettingsDocument, UserUISettingsModel>('UserUISettings', schema);

+ 1 - 0
packages/app/src/server/routes/apiv3/admin-home.js

@@ -73,6 +73,7 @@ module.exports = (crowi) => {
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
       installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
+      isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
     };
 
     return res.apiv3({ adminHomeParams });

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

@@ -225,6 +225,7 @@ module.exports = (crowi) => {
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
       isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
+      isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
       isMailerSetup: crowi.mailService.isMailerSetup,

+ 6 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import pageListing from './page-listing';
+
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -41,6 +43,8 @@ module.exports = (crowi) => {
   router.use('/pages', require('./pages')(crowi));
   router.use('/revisions', require('./revisions')(crowi));
 
+  router.use('/page-listing', pageListing(crowi));
+
   router.use('/share-links', require('./share-links')(crowi));
 
   router.use('/bookmarks', require('./bookmarks')(crowi));
@@ -53,5 +57,7 @@ module.exports = (crowi) => {
 
   router.use('/forgot-password', require('./forgot-password')(crowi));
 
+  router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
+
   return router;
 };

+ 6 - 0
packages/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -0,0 +1,6 @@
+import { Response } from 'express';
+
+export interface ApiV3Response extends Response {
+  apiv3(obj?: any, status?: number): any
+  apiv3Err(_err: any, status?: number, info?: any): any
+}

+ 81 - 0
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -0,0 +1,81 @@
+import express, { Request, Router } from 'express';
+import { query, oneOf } from 'express-validator';
+
+import { PageDocument, PageModel } from '../../models/page';
+import ErrorV3 from '../../models/vo/error-apiv3';
+import loggerFactory from '../../../utils/logger';
+import Crowi from '../../crowi';
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:page-tree');
+
+/*
+ * Types & Interfaces
+ */
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+/*
+ * Validators
+ */
+const validator = {
+  pagePathRequired: [
+    query('path').isString().withMessage('path is required'),
+  ],
+  pageIdOrPathRequired: oneOf([
+    query('id').isMongoId(),
+    query('path').isString(),
+  ], 'id or path is required'),
+};
+
+/*
+ * Routes
+ */
+export default (crowi: Crowi): Router => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  // Do not use loginRequired with isGuestAllowed true since page tree may show private page titles
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const router = express.Router();
+
+
+  // eslint-disable-next-line max-len
+  router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+    const { path } = req.query;
+
+    const Page: PageModel = crowi.model('Page');
+
+    try {
+      const ancestorsChildren = await Page.findAncestorsChildrenByPathAndViewer(path as string, req.user);
+      return res.apiv3({ ancestorsChildren });
+    }
+    catch (err) {
+      logger.error('Failed to get ancestorsChildren.', err);
+      return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
+    }
+
+  });
+
+  /*
+   * In most cases, using id should be prioritized
+   */
+  // eslint-disable-next-line max-len
+  router.get('/children', accessTokenParser, loginRequiredStrictly, validator.pageIdOrPathRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { id, path } = req.query;
+
+    const Page: PageModel = crowi.model('Page');
+
+    try {
+      const pages = await Page.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
+      return res.apiv3({ children: pages });
+    }
+    catch (err) {
+      logger.error('Error occurred while finding children.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while finding children.'));
+    }
+  });
+
+  return router;
+};

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

@@ -169,9 +169,13 @@ module.exports = (crowi) => {
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting } = crowi.models;
-  const { exportService } = crowi;
+  const { pageService, exportService } = crowi;
 
   const validator = {
+    getPage: [
+      query('id').if(value => value != null).isMongoId(),
+      query('path').if(value => value != null).isString(),
+    ],
     likes: [
       body('pageId').isString(),
       body('bool').isBoolean(),
@@ -198,6 +202,70 @@ module.exports = (crowi) => {
     ],
   };
 
+  /**
+   * @swagger
+   *
+   *    /page:
+   *      get:
+   *        tags: [Page]
+   *        operationId: getPage
+   *        summary: /page
+   *        description: get page by pagePath or pageId
+   *        parameters:
+   *          - name: pageId
+   *            in: query
+   *            description: page id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *          - name: path
+   *            in: query
+   *            description: page path
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/path'
+   *        responses:
+   *          200:
+   *            description: Page data
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/Page'
+   */
+  router.get('/', accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
+    const { pageId, path } = req.query;
+
+    if (pageId == null && path == null) {
+      return res.apiv3Err(new ErrorV3('Parameter pagePath or pageId is required.', 'invalid-request'));
+    }
+
+    let result = {};
+    try {
+      result = await pageService.findPageAndMetaDataByViewer({ pageId, path, user: req.user });
+    }
+    catch (err) {
+      logger.error('get-page-failed', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    const page = result.page;
+
+    if (page == null) {
+      return res.apiv3(result);
+    }
+
+    try {
+      page.initLatestRevisionField();
+
+      // populate
+      result.page = await page.populateDataToShowRevision();
+    }
+    catch (err) {
+      logger.error('populate-page-failed', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(result);
+  });
+
   /**
    * @swagger
    *

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

@@ -184,12 +184,16 @@ module.exports = (crowi) => {
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
+    v5PageMigration: [
+      body('action').isString().withMessage('action is required'),
+      body('pageIds').isArray().withMessage('pageIds must be an array'),
+    ],
   };
 
   async function createPageAction({
     path, body, user, options,
   }) {
-    const createdPage = Page.create(path, body, user, options);
+    const createdPage = await Page.create(path, body, user, options);
     return createdPage;
   }
 
@@ -264,21 +268,21 @@ module.exports = (crowi) => {
     // check whether path starts slash
     path = pathUtils.addHeadingSlash(path);
 
-    // check page existence
-    const isExist = await Page.count({ path }) > 0;
-    if (isExist) {
-      return res.apiv3Err(new ErrorV3('Failed to post page', 'page_exists'), 500);
-    }
-
     const options = {};
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const createdPage = await createPageAction({
-      path, body, user: req.user, options,
-    });
+    let createdPage;
+    try {
+      createdPage = await createPageAction({
+        path, body, user: req.user, options,
+      });
+    }
+    catch (err) {
+      return res.apiv3Err(err);
+    }
 
     const savedTags = await saveTagsAction({ createdPage, pageTags });
 
@@ -680,5 +684,45 @@ module.exports = (crowi) => {
     }
 
   });
+
+  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, validator.v5PageMigration, apiV3FormValidator, async(req, res) => {
+    const { action, pageIds } = req.body;
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    const Page = crowi.model('Page');
+
+    try {
+      switch (action) {
+        case 'initialMigration':
+          if (!isV5Compatible) {
+            // this method throws and emit socketIo event when error occurs
+            crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC); // not await
+          }
+          break;
+        case 'privateLegacyPages':
+          crowi.pageService.v5MigrationByPageIds(pageIds);
+          break;
+
+        default:
+          logger.error(`${action} action is not supported.`);
+          return res.apiv3Err(new ErrorV3('This action is not supported.', 'not_supported'), 400);
+      }
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+    }
+
+    return res.apiv3({ isV5Compatible });
+  });
+
+  router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
+    try {
+      const migratablePagesCount = await crowi.pageService.v5MigratablePrivatePagesCount(req.user);
+      return res.apiv3({ migratablePagesCount });
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('Failed to obtain migration status'));
+    }
+  });
+
   return router;
 };

+ 86 - 0
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -0,0 +1,86 @@
+import express from 'express';
+import { body } from 'express-validator';
+import { AllSidebarContentsType } from '~/interfaces/ui';
+
+import loggerFactory from '~/utils/logger';
+
+import UserUISettings from '../../models/user-ui-settings';
+import ErrorV3 from '../../models/vo/error-apiv3';
+
+const logger = loggerFactory('growi:routes:apiv3:user-ui-settings');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const validatorForPut = [
+    body('settings').exists().withMessage('The body param \'settings\' is required'),
+    body('settings.isSidebarCollapsed').optional().isBoolean(),
+    body('settings.currentSidebarContents').optional().isIn(AllSidebarContentsType),
+    body('settings.currentProductNavWidth').optional().isNumeric(),
+    body('settings.preferDrawerModeByUser').optional().isBoolean(),
+    body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
+  ];
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  router.get('/', loginRequiredStrictly, async(req: any, res: any) => {
+    const { user } = req;
+
+    try {
+      const updatedSettings = await UserUISettings.findOneAndUpdate(
+        { user: user._id },
+        { user: user._id },
+        { upsert: true, new: true },
+      );
+      return res.apiv3(updatedSettings);
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
+    const { user } = req;
+    const { settings } = req.body;
+
+    // extract only necessary params
+    const updateData = {
+      isSidebarCollapsed: settings.isSidebarCollapsed,
+      currentSidebarContents: settings.currentSidebarContents,
+      currentProductNavWidth: settings.currentProductNavWidth,
+      preferDrawerModeByUser: settings.preferDrawerModeByUser,
+      preferDrawerModeOnEditByUser: settings.preferDrawerModeOnEditByUser,
+    };
+    // remove the keys that have null value
+    Object.keys(updateData).forEach((key) => {
+      if (updateData[key] == null) {
+        delete updateData[key];
+      }
+    });
+
+    try {
+      const updatedSettings = await UserUISettings.findOneAndUpdate(
+        { user: user._id },
+        {
+          $set: {
+            user: user._id,
+            ...updateData,
+          },
+        },
+        { upsert: true, new: true },
+      );
+      return res.apiv3(updatedSettings);
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
+  return router;
+};

+ 8 - 5
packages/app/src/server/routes/index.js

@@ -3,6 +3,7 @@ import express from 'express';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 
 import * as forgotPassword from './forgot-password';
+import * as privateLegacyPages from './private-legacy-pages';
 
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
@@ -139,8 +140,6 @@ module.exports = function(crowi, app) {
   // my drafts
   app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.redirector);
-  app.get('/_r/:id([0-9a-z]{24})'    , loginRequired , page.redirector); // alias
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
   app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
@@ -156,7 +155,6 @@ module.exports = function(crowi, app) {
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
-  app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);
   app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
@@ -194,9 +192,14 @@ module.exports = function(crowi, app) {
     .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleHttpErrosMiddleware));
 
+  app.use('/private-legacy-pages', express.Router()
+    .get('/', privateLegacyPages.renderPrivateLegacyPages));
+
   app.get('/share/:linkId', page.showSharedPage);
 
-  app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
-  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.showPage, page.notFound);
+  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.showPage);
+
+  app.get('/*/$'                   , loginRequired , page.redirectorWithEndOfSlash);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.redirector);
 
 };

+ 20 - 12
packages/app/src/server/routes/installer.js

@@ -33,18 +33,15 @@ module.exports = function(crowi) {
   }
 
   async function createInitialPages(owner, lang) {
-    const promises = [];
-
-    // create portal page for '/'
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'welcome.md'), '/', owner, lang));
-
-    // create /Sandbox/*
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox.md'), '/Sandbox', owner, lang));
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner, lang));
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner, lang));
-    promises.push(createPage(path.join(crowi.localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner, lang));
-
-    await Promise.all(promises);
+    /*
+     * Keep in this order to avoid creating the same pages
+     */
+    await createPage(path.join(crowi.localeDir, lang, 'sandbox.md'), '/Sandbox', owner, lang);
+    await Promise.all([
+      createPage(path.join(crowi.localeDir, lang, 'sandbox-diagrams.md'), '/Sandbox/Diagrams', owner, lang),
+      createPage(path.join(crowi.localeDir, lang, 'sandbox-bootstrap4.md'), '/Sandbox/Bootstrap4', owner, lang),
+      createPage(path.join(crowi.localeDir, lang, 'sandbox-math.md'), '/Sandbox/Math', owner, lang),
+    ]);
 
     try {
       await initSearchIndex();
@@ -73,6 +70,9 @@ module.exports = function(crowi) {
 
     await appService.initDB(language);
 
+    // create the root page before creating admin user
+    await createPage(path.join(crowi.localeDir, language, 'welcome.md'), '/', { _id: '000000000000000000000000' }, language); // use 0 as a mock user id
+
     // create first admin user
     // TODO: with transaction
     let adminUser;
@@ -84,6 +84,14 @@ module.exports = function(crowi) {
       req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
       return res.render('installer');
     }
+    // add owner after creating admin user
+    const Revision = crowi.model('Revision');
+    const rootPage = await Page.findOne({ path: '/' });
+    const rootRevision = await Revision.findOne({ path: '/' });
+    rootPage.creator = adminUser;
+    rootRevision.creator = adminUser;
+    await Promise.all([rootPage.save(), rootRevision.save()]);
+
     // create initial pages
     await createInitialPages(adminUser, language);
 

+ 113 - 137
packages/app/src/server/routes/page.js

@@ -1,9 +1,10 @@
 import { pagePathUtils } from '@growi/core';
+import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 
 import UpdatePost from '../models/update-post';
 
-const { isCreatablePage } = pagePathUtils;
+const { isCreatablePage, isTopPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -167,7 +168,7 @@ module.exports = function(crowi, app) {
   const actions = {};
 
   function getPathFromRequest(req) {
-    return pathUtils.normalizePath(req.params[0] || '');
+    return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
   }
 
   function isUserPage(path) {
@@ -263,6 +264,16 @@ module.exports = function(crowi, app) {
     renderVars.pages = result.pages;
   }
 
+  async function addRenderVarsForPageTree(renderVars, path) {
+    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path);
+
+    if (targetAndAncestors.length === 0 && !isTopPage(path)) {
+      throw new Error('Ancestors must have at least one page.');
+    }
+
+    renderVars.targetAndAncestors = { targetAndAncestors, rootPage };
+  }
+
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -278,16 +289,62 @@ module.exports = function(crowi, app) {
     return compiledTemplate(definitions);
   }
 
-  async function showPageForPresentation(req, res, next) {
+  async function _notFound(req, res) {
     const path = getPathFromRequest(req);
+
+    let view;
+    const renderVars = { path };
+
+    if (!isCreatablePage(path)) {
+      view = 'layout-growi/not_creatable';
+    }
+    else if (req.isForbidden) {
+      view = 'layout-growi/forbidden';
+    }
+    else {
+      view = 'layout-growi/not_found';
+
+      // retrieve templates
+      if (req.user != null) {
+        const template = await Page.findTemplate(path);
+        if (template.templateBody) {
+          const body = replacePlaceholdersOfTemplate(template.templateBody, req);
+          const tags = template.templateTags;
+          renderVars.template = body;
+          renderVars.templateTags = tags;
+        }
+      }
+
+      // add scope variables by ancestor page
+      const ancestor = await Page.findAncestorByPathAndViewer(path, req.user);
+      if (ancestor != null) {
+        await ancestor.populate('grantedGroup');
+        addRenderVarsForScope(renderVars, ancestor);
+      }
+    }
+
+    const limit = 50;
+    const offset = parseInt(req.query.offset) || 0;
+    await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
+
+    return res.render(view, renderVars);
+  }
+
+  async function showPageForPresentation(req, res, next) {
+    const id = req.params.id;
     const { revisionId } = req.query;
 
-    let page = await Page.findByPathAndViewer(path, req.user);
+    let page = await Page.findByIdAndViewer(id, req.user);
 
     if (page == null) {
       next();
     }
 
+    if (page.isEmpty) {
+      req.pagePath = page.path;
+      return next();
+    }
+
     const renderVars = {};
 
     // populate
@@ -328,27 +385,38 @@ module.exports = function(crowi, app) {
 
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
+    await addRenderVarsForPageTree(renderVars, portalPath);
+
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
 
   async function showPageForGrowiBehavior(req, res, next) {
-    const path = getPathFromRequest(req);
+    const id = req.params.id;
     const revisionId = req.query.revision;
 
-    let page = await Page.findByPathAndViewer(path, req.user);
+    let page = await Page.findByIdAndViewer(id, req.user);
 
     if (page == null) {
       // check the page is forbidden or just does not exist.
-      req.isForbidden = await Page.count({ path }) > 0;
-      return next();
+      req.isForbidden = await Page.count({ _id: id }) > 0;
+      return _notFound(req, res);
     }
+
+    // empty page
+    if (page.isEmpty) {
+      req.pagePath = page.path;
+      return _notFound(req, res);
+    }
+
+    const { path } = page; // this must exist
+
     if (page.redirectTo) {
       debug(`Redirect to '${page.redirectTo}'`);
       return res.redirect(`${encodeURI(page.redirectTo)}?redirectFrom=${encodeURIComponent(path)}`);
     }
 
-    logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, page.path);
+    logger.debug('Page is found when processing pageShowForGrowiBehavior', page._id, path);
 
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
@@ -373,12 +441,14 @@ module.exports = function(crowi, app) {
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
     renderVars.sharelinksNumber = sharelinksNumber;
 
-    if (isUserPage(page.path)) {
+    if (isUserPage(path)) {
       // change template
       view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page);
     }
 
+    await addRenderVarsForPageTree(renderVars, path);
+
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
   }
@@ -485,44 +555,7 @@ module.exports = function(crowi, app) {
   /* eslint-enable no-else-return */
 
   actions.notFound = async function(req, res) {
-    const path = getPathFromRequest(req);
-
-    let view;
-    const renderVars = { path };
-
-    if (!isCreatablePage(path)) {
-      view = 'layout-growi/not_creatable';
-    }
-    else if (req.isForbidden) {
-      view = 'layout-growi/forbidden';
-    }
-    else {
-      view = 'layout-growi/not_found';
-
-      // retrieve templates
-      if (req.user != null) {
-        const template = await Page.findTemplate(path);
-        if (template.templateBody) {
-          const body = replacePlaceholdersOfTemplate(template.templateBody, req);
-          const tags = template.templateTags;
-          renderVars.template = body;
-          renderVars.templateTags = tags;
-        }
-      }
-
-      // add scope variables by ancestor page
-      const ancestor = await Page.findAncestorByPathAndViewer(path, req.user);
-      if (ancestor != null) {
-        await ancestor.populate('grantedGroup');
-        addRenderVarsForScope(renderVars, ancestor);
-      }
-    }
-
-    const limit = 50;
-    const offset = parseInt(req.query.offset) || 0;
-    await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
-
-    return res.render(view, renderVars);
+    return _notFound(req, res);
   };
 
   actions.deletedPageListShow = async function(req, res) {
@@ -558,16 +591,42 @@ module.exports = function(crowi, app) {
   /**
    * redirector
    */
-  actions.redirector = async function(req, res) {
-    const id = req.params.id;
+  async function redirector(req, res, next, path) {
+    const pages = await Page.findByPathAndViewer(path, req.user, null, false);
+    const { redirectFrom } = req.query;
 
-    const page = await Page.findByIdAndViewer(id, req.user);
+    if (pages.length >= 2) {
+      // pass only redirectFrom since it is not sure whether the query params are related to the pages
+      return res.render('layout-growi/select-go-to-page', { pages, redirectFrom });
+    }
 
-    if (page != null) {
-      return res.redirect(encodeURI(page.path));
+    if (pages.length === 1) {
+      if (pages[0].isEmpty) {
+        return _notFound(req, res);
+      }
+
+      const url = new URL('https://dummy.origin');
+      url.pathname = `/${pages[0]._id}`;
+      Object.entries(req.query).forEach(([key, value], i) => {
+        url.searchParams.append(key, value);
+      });
+      return res.safeRedirect(urljoin(url.pathname, url.search));
     }
 
-    return res.redirect('/');
+    return _notFound(req, res);
+  }
+
+  actions.redirector = async function(req, res, next) {
+    const path = getPathFromRequest(req);
+
+    return redirector(req, res, next, path);
+  };
+
+  actions.redirectorWithEndOfSlash = async function(req, res, next) {
+    const _path = getPathFromRequest(req);
+    const path = pathUtils.removeTrailingSlash(_path);
+
+    return redirector(req, res, next, path);
   };
 
 
@@ -891,89 +950,6 @@ module.exports = function(crowi, app) {
     }
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.get:
-   *      get:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: getPage
-   *        summary: /pages.get
-   *        description: Get page data
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *          - in: query
-   *            name: path
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
-   *          - in: query
-   *            name: revision_id
-   *            schema:
-   *              $ref: '#/components/schemas/Revision/properties/_id'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get page data.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.get Get page data
-   * @apiName GetPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id
-   * @apiParam {String} path
-   * @apiParam {String} revision_id
-   */
-  api.get = async function(req, res) {
-    const pagePath = req.query.path || null;
-    const pageId = req.query.page_id || null; // TODO: handling
-
-    if (!pageId && !pagePath) {
-      return res.json(ApiResponse.error(new Error('Parameter path or page_id is required.')));
-    }
-
-    let page;
-    try {
-      if (pageId) { // prioritized
-        page = await Page.findByIdAndViewer(pageId, req.user);
-      }
-      else if (pagePath) {
-        page = await Page.findByPathAndViewer(pagePath, req.user);
-      }
-
-      if (page == null) {
-        throw new Error(`Page '${pageId || pagePath}' is not found or forbidden`, 'notfound_or_forbidden');
-      }
-
-      page.initLatestRevisionField();
-
-      // populate
-      page = await page.populateDataToShowRevision();
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-
-    const result = {};
-    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
-
-    return res.json(ApiResponse.success(result));
-  };
-
   /**
    * @swagger
    *

+ 7 - 0
packages/app/src/server/routes/private-legacy-pages.ts

@@ -0,0 +1,7 @@
+import {
+  Request, Response,
+} from 'express';
+
+export const renderPrivateLegacyPages = (req: Request, res: Response): void => {
+  return res.render('private-legacy-pages');
+};

+ 6 - 6
packages/app/src/server/service/config-loader.ts

@@ -172,6 +172,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  IS_V5_COMPATIBLE: {
+    ns:      'crowi',
+    key:     'app:isV5Compatible',
+    type:    ValueType.BOOLEAN,
+    default: undefined,
+  },
   S2SMSG_PUBSUB_SERVER_TYPE: {
     ns:      'crowi',
     key:     's2sMessagingPubsub:serverType',
@@ -256,12 +262,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     default: 8000, // msec
   },
-  SEARCHBOX_SSL_URL: {
-    ns:      'crowi',
-    key:     'app:searchboxSslUrl',
-    type:    ValueType.STRING,
-    default: null,
-  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',

+ 286 - 3
packages/app/src/server/service/page.js

@@ -4,13 +4,14 @@ import loggerFactory from '~/utils/logger';
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
 const streamToPromise = require('stream-to-promise');
+const pathlib = require('path');
 
-const logger = loggerFactory('growi:models:page');
-const debug = require('debug')('growi:models:page');
+const logger = loggerFactory('growi:services:page');
+const debug = require('debug')('growi:services:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
 
-const { isTrashPage } = pagePathUtils;
+const { isCreatablePage, isDeletablePage, isTrashPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 const BULK_REINDEX_SIZE = 100;
@@ -28,6 +29,43 @@ class PageService {
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
   }
 
+  async findPageAndMetaDataByViewer({ pageId, path, user }) {
+
+    const Page = this.crowi.model('Page');
+
+    let page;
+    if (pageId != null) { // prioritized
+      page = await Page.findByIdAndViewer(pageId, user);
+    }
+    else {
+      page = await Page.findByPathAndViewer(path, user);
+    }
+
+    const result = {};
+
+    if (page == null) {
+      const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
+      result.isForbidden = isExist;
+      result.isNotFound = !isExist;
+      result.isCreatable = isCreatablePage(path);
+      result.isDeletable = false;
+      result.canDeleteCompletely = false;
+      result.page = page;
+
+      return result;
+    }
+
+    result.page = page;
+    result.isForbidden = false;
+    result.isNotFound = false;
+    result.isCreatable = false;
+    result.isDeletable = isDeletablePage(path);
+    result.isDeleted = page.isDeleted();
+    result.canDeleteCompletely = user != null && user.canDeleteCompletely(page.creator);
+
+    return result;
+  }
+
   /**
    * go back by using redirectTo and return the paths
    *  ex: when
@@ -738,6 +776,251 @@ class PageService {
     }
   }
 
+  async v5MigrationByPageIds(pageIds) {
+    const Page = mongoose.model('Page');
+
+    if (pageIds == null || pageIds.length === 0) {
+      return;
+    }
+
+    // generate regexps
+    const regexps = await this._generateRegExpsByPageIds(pageIds);
+
+    // migrate recursively
+    try {
+      await this._v5RecursiveMigration(null, regexps);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message }); TODO: use socket to tell user
+
+      throw err;
+    }
+  }
+
+  async v5InitialMigration(grant) {
+    const socket = this.crowi.socketIoService.getAdminSocket();
+    try {
+      await this._v5RecursiveMigration(grant);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      socket.emit('v5InitialMirationFailed', { error: err.message });
+
+      throw err;
+    }
+
+    const Page = this.crowi.model('Page');
+    const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
+    const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
+    const isPathIndexExists = pathIndexStatus != null;
+    const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
+
+    if (isUnique || !isPathIndexExists) {
+      try {
+        await this._v5NormalizeIndex(isPathIndexExists);
+      }
+      catch (err) {
+        logger.error('V5 index normalization failed.', err);
+        socket.emit('v5IndexNormalizationFailed', { error: err.message });
+
+        throw err;
+      }
+    }
+
+    await this._setIsV5CompatibleTrue();
+  }
+
+  /*
+   * returns an array of js RegExp instance instead of RE2 instance for mongo filter
+   */
+  async _generateRegExpsByPageIds(pageIds) {
+    const Page = mongoose.model('Page');
+
+    let result;
+    try {
+      result = await Page.findListByPageIds(pageIds, null, false);
+    }
+    catch (err) {
+      logger.error('Failed to find pages by ids', err);
+      throw err;
+    }
+
+    const { pages } = result;
+    const regexps = pages.map(page => new RegExp(`^${page.path}`));
+
+    return regexps;
+  }
+
+  async _setIsV5CompatibleTrue() {
+    try {
+      await this.crowi.configManager.updateConfigsInTheSameNamespace('crowi', {
+        'app:isV5Compatible': true,
+      });
+      logger.info('Successfully migrated all public pages.');
+    }
+    catch (err) {
+      logger.warn('Failed to update app:isV5Compatible to true.');
+      throw err;
+    }
+  }
+
+  // TODO: use websocket to show progress
+  async _v5RecursiveMigration(grant, regexps) {
+    const BATCH_SIZE = 100;
+    const PAGES_LIMIT = 1000;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    // generate filter
+    let filter = {
+      parent: null,
+      path: { $ne: '/' },
+    };
+    if (grant != null) {
+      filter = {
+        ...filter,
+        grant,
+      };
+    }
+    if (regexps != null && regexps.length !== 0) {
+      filter = {
+        ...filter,
+        path: {
+          $in: regexps,
+        },
+      };
+    }
+
+    const total = await Page.countDocuments(filter);
+
+    let baseAggregation = Page
+      .aggregate([
+        {
+          $match: filter,
+        },
+        {
+          $project: { // minimize data to fetch
+            _id: 1,
+            path: 1,
+          },
+        },
+      ]);
+
+    // limit pages to get
+    if (total > PAGES_LIMIT) {
+      baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
+    }
+
+    const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
+
+    // use batch stream
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    let countPages = 0;
+
+    // migrate all siblings for each page
+    const migratePagesStream = new Writable({
+      objectMode: true,
+      async write(pages, encoding, callback) {
+        // make list to create empty pages
+        const parentPathsSet = new Set(pages.map(page => pathlib.dirname(page.path)));
+        const parentPaths = Array.from(parentPathsSet);
+
+        // fill parents with empty pages
+        await Page.createEmptyPagesByPaths(parentPaths);
+
+        // find parents again
+        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
+        const parents = await builder
+          .addConditionToListByPathsArray(parentPaths)
+          .query
+          .lean()
+          .exec();
+
+        // bulkWrite to update parent
+        const updateManyOperations = parents.map((parent) => {
+          const parentId = parent._id;
+
+          // modify to adjust for RegExp
+          const parentPath = parent.path === '/' ? '' : parent.path;
+
+          return {
+            updateMany: {
+              filter: {
+                // regexr.com/6889f
+                // ex. /parent/any_child OR /any_level1
+                path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
+              },
+              update: {
+                parent: parentId,
+              },
+            },
+          };
+        });
+        try {
+          const res = await Page.bulkWrite(updateManyOperations);
+          countPages += (res.items || []).length;
+          logger.info(`Page migration processing: (count=${countPages}, errors=${res.errors}, took=${res.took}ms)`);
+        }
+        catch (err) {
+          logger.error('Failed to update page.parent.', err);
+          throw err;
+        }
+
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+
+    pagesStream
+      .pipe(batchStream)
+      .pipe(migratePagesStream);
+
+    await streamToPromise(migratePagesStream);
+
+    if (await Page.exists(filter)) {
+      return this._v5RecursiveMigration(grant, regexps);
+    }
+
+  }
+
+  async _v5NormalizeIndex(isPathIndexExists) {
+    const collection = mongoose.connection.collection('pages');
+
+    if (isPathIndexExists) {
+      try {
+        // drop pages.path_1 indexes
+        await collection.dropIndex('path_1');
+        logger.info('Succeeded to drop unique indexes from pages.path.');
+      }
+      catch (err) {
+        logger.warn('Failed to drop unique indexes from pages.path.', err);
+        throw err;
+      }
+    }
+
+    try {
+      // create indexes without
+      await collection.createIndex({ path: 1 }, { unique: false });
+      logger.info('Succeeded to create non-unique indexes on pages.path.');
+    }
+    catch (err) {
+      logger.warn('Failed to create non-unique indexes on pages.path.', err);
+      throw err;
+    }
+  }
+
+  async v5MigratablePrivatePagesCount(user) {
+    if (user == null) {
+      throw Error('user is required');
+    }
+    const Page = this.crowi.model('Page');
+    return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
+  }
+
 }
 
 module.exports = PageService;

+ 60 - 107
packages/app/src/server/service/search-delegator/elasticsearch.js → packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,23 +1,39 @@
-import loggerFactory from '~/utils/logger';
+import elasticsearch from 'elasticsearch';
+import mongoose from 'mongoose';
 
-const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
-const elasticsearch = require('elasticsearch');
-const mongoose = require('mongoose');
+import { URL } from 'url';
 
-const { URL } = require('url');
+import { Writable, Transform } from 'stream';
+import streamToPromise from 'stream-to-promise';
 
-const {
-  Writable, Transform,
-} = require('stream');
-const streamToPromise = require('stream-to-promise');
+import { createBatchStream } from '../../util/batch-stream';
+import loggerFactory from '~/utils/logger';
+import { PageDocument, PageModel } from '../../models/page';
+import {
+  MetaData, SearchDelegator, Result, SearchableData, QueryTerms,
+} from '../../interfaces/search';
 
-const { createBatchStream } = require('../../util/batch-stream');
+const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 
 const DEFAULT_OFFSET = 0;
 const DEFAULT_LIMIT = 50;
 const BULK_REINDEX_SIZE = 100;
 
-class ElasticsearchDelegator {
+type Data = any;
+
+class ElasticsearchDelegator implements SearchDelegator<Data> {
+
+  configManager!: any
+
+  socketIoService!: any
+
+  client: any
+
+  queries: any
+
+  indexName: string
+
+  esUri: string
 
   constructor(configManager, socketIoService) {
     this.configManager = configManager;
@@ -115,7 +131,7 @@ class ElasticsearchDelegator {
     let esVersion = 'unknown';
     const esNodeInfos = {};
 
-    for (const [nodeName, nodeInfo] of Object.entries(info.nodes)) {
+    for (const [nodeName, nodeInfo] of Object.entries<any>(info.nodes)) {
       esVersion = nodeInfo.version;
 
       const filteredInfo = {
@@ -160,7 +176,7 @@ class ElasticsearchDelegator {
     const isExistsTmpIndex = await client.indices.exists({ index: tmpIndexName });
 
     // create indices name list
-    const existingIndices = [];
+    const existingIndices: string[] = [];
     if (isExistsMainIndex) { existingIndices.push(indexName) }
     if (isExistsTmpIndex) { existingIndices.push(tmpIndexName) }
 
@@ -358,7 +374,7 @@ class ElasticsearchDelegator {
   }
 
   updateOrInsertDescendantsPagesById(page, user) {
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as PageModel;
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListWithDescendants(page.path);
@@ -368,14 +384,14 @@ class ElasticsearchDelegator {
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
-  async updateOrInsertPages(queryFactory, option = {}) {
+  async updateOrInsertPages(queryFactory, option: any = {}) {
     const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
 
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as PageModel;
     const { PageQueryBuilder } = Page;
-    const Bookmark = mongoose.model('Bookmark');
-    const Comment = mongoose.model('Comment');
-    const PageTagRelation = mongoose.model('PageTagRelation');
+    const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
+    const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = this.socketIoService.getAdminSocket();
 
@@ -554,7 +570,7 @@ class ElasticsearchDelegator {
    *   data: [ pages ...],
    * }
    */
-  async search(query) {
+  async searchKeyword(query) {
     // for debug
     if (process.env.NODE_ENV === 'development') {
       const result = await this.client.indices.validateQuery({
@@ -611,7 +627,7 @@ class ElasticsearchDelegator {
   }
 
   createSearchQuerySortedByScore(option) {
-    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -631,7 +647,7 @@ class ElasticsearchDelegator {
     return query;
   }
 
-  appendResultSize(query, from, size) {
+  appendResultSize(query, from?, size?) {
     query.from = from || DEFAULT_OFFSET;
     query.size = size || DEFAULT_LIMIT;
   }
@@ -656,12 +672,9 @@ class ElasticsearchDelegator {
     return query;
   }
 
-  appendCriteriaForQueryString(query, queryString) {
+  appendCriteriaForQueryString(query, parsedKeywords: QueryTerms) {
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
-    // parse
-    const parsedKeywords = this.parseQueryString(queryString);
-
     if (parsedKeywords.match.length > 0) {
       const q = {
         multi_match: {
@@ -685,7 +698,7 @@ class ElasticsearchDelegator {
     }
 
     if (parsedKeywords.phrase.length > 0) {
-      const phraseQueries = [];
+      const phraseQueries: any[] = [];
       parsedKeywords.phrase.forEach((phrase) => {
         phraseQueries.push({
           multi_match: {
@@ -705,7 +718,7 @@ class ElasticsearchDelegator {
     }
 
     if (parsedKeywords.not_phrase.length > 0) {
-      const notPhraseQueries = [];
+      const notPhraseQueries: any[] = [];
       parsedKeywords.not_phrase.forEach((phrase) => {
         notPhraseQueries.push({
           multi_match: {
@@ -758,12 +771,12 @@ class ElasticsearchDelegator {
 
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as PageModel;
     const {
       GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
 
-    const grantConditions = [
+    const grantConditions: any[] = [
       { term: { grant: GRANT_PUBLIC } },
     ];
 
@@ -830,44 +843,9 @@ class ElasticsearchDelegator {
     query.body.query.bool.filter.push({ bool: { should: grantConditions } });
   }
 
-  filterPortalPages(query) {
-    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-    query.body.query.bool.must_not.push(this.queries.USER);
-    query.body.query.bool.filter.push(this.queries.PORTAL);
-  }
-
-  filterPublicPages(query) {
-    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-    query.body.query.bool.must_not.push(this.queries.USER);
-    query.body.query.bool.filter.push(this.queries.PUBLIC);
-  }
-
-  filterUserPages(query) {
-    query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
-
-    query.body.query.bool.filter.push(this.queries.USER);
-  }
-
-  filterPagesByType(query, type) {
-    const Page = mongoose.model('Page');
-
-    switch (type) {
-      case Page.TYPE_PORTAL:
-        return this.filterPortalPages(query);
-      case Page.TYPE_PUBLIC:
-        return this.filterPublicPages(query);
-      case Page.TYPE_USER:
-        return this.filterUserPages(query);
-      default:
-        return query;
-    }
-  }
-
-  appendFunctionScore(query, queryString) {
+  async appendFunctionScore(query, queryString) {
     const User = mongoose.model('User');
-    const count = User.count({}) || 1;
+    const count = await User.count({}) || 1;
 
     const minScore = queryString.length * 0.1 - 1; // increase with length
     logger.debug('min_score: ', minScore);
@@ -889,6 +867,7 @@ class ElasticsearchDelegator {
     };
   }
 
+<<<<<<< HEAD:packages/app/src/server/service/search-delegator/elasticsearch.js
   appendHighlight(query) {
     query.body.highlight = {
       fields: {
@@ -903,17 +882,21 @@ class ElasticsearchDelegator {
   }
 
   async searchKeyword(queryString, user, userGroups, option) {
+=======
+  async search(data: SearchableData, user, userGroups, option): Promise<Result<Data> & MetaData> {
+    const { queryString, terms } = data;
+
+>>>>>>> feat/pt-dev-master:packages/app/src/server/service/search-delegator/elasticsearch.ts
     const from = option.offset || null;
     const size = option.limit || null;
-    const type = option.type || null;
     const query = this.createSearchQuerySortedByScore();
-    this.appendCriteriaForQueryString(query, queryString);
+    this.appendCriteriaForQueryString(query, terms);
 
-    this.filterPagesByType(query, type);
     await this.filterPagesByViewer(query, user, userGroups);
 
     this.appendResultSize(query, from, size);
 
+<<<<<<< HEAD:packages/app/src/server/service/search-delegator/elasticsearch.js
     this.appendFunctionScore(query, queryString);
     this.appendHighlight(query);
     return this.search(query);
@@ -960,41 +943,11 @@ class ElasticsearchDelegator {
       const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
       // https://regex101.com/r/3qw9FQ/1
       const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
+=======
+    await this.appendFunctionScore(query, queryString);
+>>>>>>> feat/pt-dev-master:packages/app/src/server/service/search-delegator/elasticsearch.ts
 
-      if (matchNegative != null) {
-        if (matchNegative[1] === 'prefix:') {
-          notPrefixPaths.push(matchNegative[2]);
-        }
-        else if (matchNegative[1] === 'tag:') {
-          notTags.push(matchNegative[2]);
-        }
-        else {
-          notMatchWords.push(matchNegative[2]);
-        }
-      }
-      else if (matchPositive != null) {
-        if (matchPositive[1] === 'prefix:') {
-          prefixPaths.push(matchPositive[2]);
-        }
-        else if (matchPositive[1] === 'tag:') {
-          tags.push(matchPositive[2]);
-        }
-        else {
-          matchWords.push(matchPositive[2]);
-        }
-      }
-    });
-
-    return {
-      match: matchWords,
-      not_match: notMatchWords,
-      phrase: phraseWords,
-      not_phrase: notPhraseWords,
-      prefix: prefixPaths,
-      not_prefix: notPrefixPaths,
-      tag: tags,
-      not_tag: notTags,
-    };
+    return this.searchKeyword(query);
   }
 
   async syncPageUpdated(page, user) {
@@ -1016,11 +969,11 @@ class ElasticsearchDelegator {
 
   // remove pages whitch should nod Indexed
   async syncPagesUpdated(pages, user) {
-    const shoudDeletePages = [];
+    const shoudDeletePages: any[] = [];
     pages.forEach((page) => {
       logger.debug('SearchClient.syncPageUpdated', page.path);
       if (!this.shouldIndexed(page)) {
-        shoudDeletePages.append(page);
+        shoudDeletePages.push(page);
       }
     });
 
@@ -1083,4 +1036,4 @@ class ElasticsearchDelegator {
 
 }
 
-module.exports = ElasticsearchDelegator;
+export default ElasticsearchDelegator;

+ 53 - 0
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -0,0 +1,53 @@
+import mongoose from 'mongoose';
+
+import { PageModel, PageDocument } from '~/server/models/page';
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import { IPage } from '~/interfaces/page';
+import {
+  MetaData, Result, SearchableData, SearchDelegator,
+} from '../../interfaces/search';
+
+
+type Data = {
+  pages: IPage[]
+}
+
+class PrivateLegacyPagesDelegator implements SearchDelegator<Data> {
+
+  name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
+
+  async search(data: SearchableData | null, user, userGroups, option): Promise<Result<Data> & MetaData> {
+    const { offset, limit } = option;
+
+    if (offset == null || limit == null) {
+      throw Error('PrivateLegacyPagesDelegator requires pagination options (offset, limit).');
+    }
+    if (user == null && userGroups == null) {
+      throw Error('Either of user and userGroups must not be null.');
+    }
+
+    // find private legacy pages
+    const Page = mongoose.model('Page') as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const queryBuilder = new PageQueryBuilder(Page.find());
+
+    const pages: PageDocument[] = await queryBuilder
+      .addConditionAsNonRootPage()
+      .addConditionAsNotMigrated()
+      .addConditionToFilteringByViewer(user, userGroups)
+      .addConditionToPagenate(offset, limit)
+      .query
+      .lean()
+      .exec();
+
+    return {
+      data: {
+        pages,
+      },
+    };
+  }
+
+}
+
+export default PrivateLegacyPagesDelegator;

+ 0 - 49
packages/app/src/server/service/search-delegator/searchbox.js

@@ -1,49 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:service:search-delegator:searchbox');
-
-const ElasticsearchDelegator = require('./elasticsearch');
-
-class SearchboxDelegator extends ElasticsearchDelegator {
-
-  /**
-   * @inheritdoc
-   */
-  getConnectionInfo() {
-    const searchboxSslUrl = this.configManager.getConfig('crowi', 'app:searchboxSslUrl');
-    const url = new URL(searchboxSslUrl);
-
-    const indexName = 'crowi';
-    const host = `${url.protocol}//${url.username}:${url.password}@${url.host}:443`;
-
-    return {
-      host,
-      httpAuth: '',
-      indexName,
-    };
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async rebuildIndex() {
-    const { client, indexName, aliasName } = this;
-
-    // flush index
-    await client.indices.delete({
-      index: indexName,
-    });
-    await this.createIndex(indexName);
-    await this.addAllPages();
-
-    // put alias
-    await client.indices.putAlias({
-      name: aliasName,
-      index: indexName,
-    });
-  }
-
-}
-
-module.exports = SearchboxDelegator;

+ 354 - 0
packages/app/src/server/service/search.ts

@@ -0,0 +1,354 @@
+import RE2 from 're2';
+import xss from 'xss';
+
+import { SearchDelegatorName } from '~/interfaces/named-query';
+
+import NamedQuery from '../models/named-query';
+import {
+  SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
+} from '../interfaces/search';
+import ElasticsearchDelegator from './search-delegator/elasticsearch';
+import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
+
+import loggerFactory from '~/utils/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:service:search');
+
+// options for filtering xss
+const filterXssOptions = {
+  whiteList: {
+    em: ['class'],
+  },
+};
+
+const filterXss = new xss.FilterXSS(filterXssOptions);
+
+const normalizeQueryString = (_queryString: string): string => {
+  let queryString = _queryString.trim();
+  queryString = queryString.replace(/\s+/g, ' ');
+
+  return queryString;
+};
+
+class SearchService implements SearchQueryParser, SearchResolver {
+
+  crowi!: any
+
+  configManager!: any
+
+  isErrorOccuredOnHealthcheck: boolean | null
+
+  isErrorOccuredOnSearching: boolean | null
+
+  fullTextSearchDelegator: any & SearchDelegator
+
+  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+
+    this.isErrorOccuredOnHealthcheck = null;
+    this.isErrorOccuredOnSearching = null;
+
+    try {
+      this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
+      this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
+      logger.info('Succeeded to initialize search delegators');
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    if (this.isConfigured) {
+      this.fullTextSearchDelegator.init();
+      this.registerUpdateEvent();
+    }
+  }
+
+  get isConfigured() {
+    return this.fullTextSearchDelegator != null;
+  }
+
+  get isReachable() {
+    return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
+  }
+
+  get isElasticsearchEnabled() {
+    const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    return uri != null && uri.length > 0;
+  }
+
+  generateFullTextSearchDelegator() {
+    logger.info('Initializing search delegator');
+
+    if (this.isElasticsearchEnabled) {
+      logger.info('Elasticsearch is enabled');
+      return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
+    }
+
+    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
+  }
+
+  generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+    return {
+      [SearchDelegatorName.DEFAULT]: defaultDelegator,
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
+    };
+  }
+
+  registerUpdateEvent() {
+    const pageEvent = this.crowi.event('page');
+    pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPagesDeletedCompletely.bind(this.fullTextSearchDelegator));
+    pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
+    pageEvent.on('addSeenUsers', this.delegator.syncPageUpdated.bind(this.delegator));
+
+    const bookmarkEvent = this.crowi.event('bookmark');
+    bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
+    bookmarkEvent.on('delete', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
+
+    const tagEvent = this.crowi.event('tag');
+    tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
+
+    const commentEvent = this.crowi.event('comment');
+    commentEvent.on('create', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on('update', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+    commentEvent.on('delete', this.fullTextSearchDelegator.syncCommentChanged.bind(this.fullTextSearchDelegator));
+  }
+
+  resetErrorStatus() {
+    this.isErrorOccuredOnHealthcheck = false;
+    this.isErrorOccuredOnSearching = false;
+  }
+
+  async reconnectClient() {
+    logger.info('Try to reconnect...');
+    this.fullTextSearchDelegator.initClient();
+
+    try {
+      await this.getInfoForHealth();
+
+      logger.info('Reconnecting succeeded.');
+      this.resetErrorStatus();
+    }
+    catch (err) {
+      throw err;
+    }
+  }
+
+  async getInfo() {
+    try {
+      return await this.fullTextSearchDelegator.getInfo();
+    }
+    catch (err) {
+      logger.error(err);
+      throw err;
+    }
+  }
+
+  async getInfoForHealth() {
+    try {
+      const result = await this.fullTextSearchDelegator.getInfoForHealth();
+
+      this.isErrorOccuredOnHealthcheck = false;
+      return result;
+    }
+    catch (err) {
+      logger.error(err);
+
+      // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
+      this.isErrorOccuredOnHealthcheck = true;
+      throw err;
+    }
+  }
+
+  async getInfoForAdmin() {
+    return this.fullTextSearchDelegator.getInfoForAdmin();
+  }
+
+  async normalizeIndices() {
+    return this.fullTextSearchDelegator.normalizeIndices();
+  }
+
+  async rebuildIndex() {
+    return this.fullTextSearchDelegator.rebuildIndex();
+  }
+
+  async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
+    const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
+    const replaceRegexp = new RE2(/\[nq:|\]/g);
+
+    const queryString = normalizeQueryString(_queryString);
+
+    // when Normal Query
+    if (!regexp.test(queryString)) {
+      return { queryString, terms: this.parseQueryString(queryString) };
+    }
+
+    // when Named Query
+    const name = queryString.replace(replaceRegexp, '');
+    const nq = await NamedQuery.findOne({ name });
+
+    // will delegate to full-text search
+    if (nq == null) {
+      return { queryString, terms: this.parseQueryString(queryString) };
+    }
+
+    const { aliasOf, delegatorName } = nq;
+
+    let parsedQuery;
+    if (aliasOf != null) {
+      parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
+    }
+    if (delegatorName != null) {
+      parsedQuery = { queryString, delegatorName };
+    }
+
+    return parsedQuery;
+  }
+
+  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]> {
+    const { queryString, terms, delegatorName } = parsedQuery;
+    if (delegatorName != null) {
+      const nqDelegator = this.nqDelegators[delegatorName];
+      if (nqDelegator != null) {
+        return [nqDelegator, null];
+      }
+    }
+
+    const data = {
+      queryString,
+      terms: terms as QueryTerms,
+    };
+    return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
+  }
+
+  async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<Result<any> & MetaData> {
+    let parsedQuery;
+    // parse
+    try {
+      parsedQuery = await this.parseSearchQuery(keyword);
+    }
+    catch (err) {
+      logger.error('Error occurred while parseSearchQuery', err);
+      throw err;
+    }
+
+    let delegator;
+    let data;
+    // resolve
+    try {
+      [delegator, data] = await this.resolve(parsedQuery);
+    }
+    catch (err) {
+      logger.error('Error occurred while resolving search delegator', err);
+      throw err;
+    }
+
+    return delegator.search(data, user, userGroups, searchOpts);
+  }
+
+  parseQueryString(queryString: string): QueryTerms {
+    // terms
+    const matchWords: string[] = [];
+    const notMatchWords: string[] = [];
+    const phraseWords: string[] = [];
+    const notPhraseWords: string[] = [];
+    const prefixPaths: string[] = [];
+    const notPrefixPaths: string[] = [];
+    const tags: string[] = [];
+    const notTags: string[] = [];
+
+    // First: Parse phrase keywords
+    const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+    const phrases = queryString.match(phraseRegExp);
+
+    if (phrases !== null) {
+      queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
+
+      phrases.forEach((phrase) => {
+        phrase.trim();
+        if (phrase.match(/^-/)) {
+          notPhraseWords.push(phrase.replace(/^-/, ''));
+        }
+        else {
+          phraseWords.push(phrase);
+        }
+      });
+    }
+
+    // Second: Parse other keywords (include minus keywords)
+    queryString.split(' ').forEach((word) => {
+      if (word === '') {
+        return;
+      }
+
+      // https://regex101.com/r/pN9XfK/1
+      const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
+      // https://regex101.com/r/3qw9FQ/1
+      const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
+
+      if (matchNegative != null) {
+        if (matchNegative[1] === 'prefix:') {
+          notPrefixPaths.push(matchNegative[2]);
+        }
+        else if (matchNegative[1] === 'tag:') {
+          notTags.push(matchNegative[2]);
+        }
+        else {
+          notMatchWords.push(matchNegative[2]);
+        }
+      }
+      else if (matchPositive != null) {
+        if (matchPositive[1] === 'prefix:') {
+          prefixPaths.push(matchPositive[2]);
+        }
+        else if (matchPositive[1] === 'tag:') {
+          tags.push(matchPositive[2]);
+        }
+        else {
+          matchWords.push(matchPositive[2]);
+        }
+      }
+    });
+
+    const terms = {
+      match: matchWords,
+      not_match: notMatchWords,
+      phrase: phraseWords,
+      not_phrase: notPhraseWords,
+      prefix: prefixPaths,
+      not_prefix: notPrefixPaths,
+      tag: tags,
+      not_tag: notTags,
+    };
+
+    return terms;
+  }
+
+  /**
+   * formatting result
+   */
+  formatResult(esResult) {
+    esResult.data.forEach((data) => {
+      const highlightData = data._highlight;
+      const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
+      const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+
+      data.elasticSearchResult = {
+        snippet: filterXss.process(snippet),
+        // todo: use filter xss.process() for matchedPath;
+        matchedPath: pathMatch,
+      };
+    });
+    return esResult;
+  }
+
+}
+
+export default SearchService;

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

@@ -9,6 +9,8 @@
 {% block layout_main %}
 <div class="h-100 d-flex flex-column justify-content-between">
 
+  <div id="growi-context-extractor"></div>
+
   {% block content_header_wrapper %}
     <header class="py-0">
       {% block content_header %}

+ 22 - 0
packages/app/src/server/views/layout-growi/select-go-to-page.html

@@ -0,0 +1,22 @@
+{% extends 'base/layout.html' %}
+
+<!-- WIP -->
+
+{% block content_main %}
+  <div>ContentMain</div>
+  <div>
+    {% for page in pages %}
+      <li>{{page._id.toString()}}: {{page.path}}</li>
+    {% endfor %}
+  </div>
+  <br>
+  <div>redirectFrom: {{redirectFrom}}</div>
+{% endblock %}
+
+{% block content_footer %}
+  <div>Footer</div>
+{% endblock %}
+
+{% block body_end %}
+  <div>BodyEnd</div>
+{% endblock %}

+ 16 - 0
packages/app/src/server/views/private-legacy-pages.html

@@ -0,0 +1,16 @@
+{% extends 'layout/layout.html' %}
+
+{% block html_additional_headers %}
+  {% parent %}
+  {{ cdnScriptTag('highlight-addons') }}
+  {{ cdnScriptTag('drawio-viewer') }}
+{% endblock %}
+
+{% block html_base_attr %}
+  data-spy="scroll"
+  data-target="#search-result-list"
+{% endblock %}
+
+{% block layout_main %}
+<p>This page is not implemented.</p>
+{% endblock %} {# layout_main #}

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

@@ -27,6 +27,7 @@
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-target-and-ancestors="{% if targetAndAncestors %}{{ targetAndAncestors|json }}{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main d-flex"
@@ -48,6 +49,7 @@
 </div>
 
 <div id="grw-page-status-alert-container"></div>
+<div id="page-context"></div>
 
 </div>
 

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