Преглед изворни кода

Merge branch 'dev/5.0.x' into feat/77544-search-sorting

NEEDLEMAN3\tatsu пре 4 година
родитељ
комит
4bed4e9451
82 измењених фајлова са 1171 додато и 869 уклоњено
  1. 41 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 3 1
      package.json
  4. 2 2
      packages/app/bin/github-actions/update-readme.sh
  5. 4 14
      packages/app/docker/README.md
  6. 9 9
      packages/app/package.json
  7. 1 1
      packages/app/resource/locales/en_US/admin/admin.json
  8. 1 0
      packages/app/resource/locales/en_US/translation.json
  9. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  10. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  11. 0 4
      packages/app/src/client/admin.jsx
  12. 1 3
      packages/app/src/client/app.jsx
  13. 6 54
      packages/app/src/client/legacy/crowi.js
  14. 11 19
      packages/app/src/client/services/ContextExtractor.tsx
  15. 0 157
      packages/app/src/client/services/NavigationContainer.js
  16. 5 21
      packages/app/src/client/services/PageContainer.js
  17. 27 0
      packages/app/src/client/util/blink-section-header.ts
  18. 45 0
      packages/app/src/client/util/smooth-scroll.ts
  19. 2 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  20. 5 5
      packages/app/src/components/BookmarkButton.jsx
  21. 103 0
      packages/app/src/components/Common/ClosableTextInput.tsx
  22. 69 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  23. 6 7
      packages/app/src/components/ContentLinkButtons.jsx
  24. 40 0
      packages/app/src/components/EventListeneres/HashChanged.tsx
  25. 8 8
      packages/app/src/components/Fab.jsx
  26. 4 4
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  27. 10 9
      packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx
  28. 1 1
      packages/app/src/components/LikeButtons.tsx
  29. 1 3
      packages/app/src/components/Navbar/GlobalSearch.jsx
  30. 3 3
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  31. 5 14
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  32. 0 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  33. 5 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  34. 19 3
      packages/app/src/components/Page.jsx
  35. 20 11
      packages/app/src/components/Page/DisplaySwitcher.jsx
  36. 6 3
      packages/app/src/components/Page/PageManagement.jsx
  37. 5 5
      packages/app/src/components/Page/RevisionRenderer.jsx
  38. 15 7
      packages/app/src/components/PageCreateModal.jsx
  39. 58 52
      packages/app/src/components/PageDeleteModal.tsx
  40. 27 3
      packages/app/src/components/PageEditor.jsx
  41. 5 2
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  42. 18 2
      packages/app/src/components/PageEditorByHackmd.jsx
  43. 26 3
      packages/app/src/components/SavePageControls.jsx
  44. 79 9
      packages/app/src/components/SearchPage.jsx
  45. 0 91
      packages/app/src/components/SearchPage/DeletePageListModal.jsx
  46. 20 26
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  47. 9 18
      packages/app/src/components/SearchPage/SearchControl.tsx
  48. 21 21
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  49. 15 6
      packages/app/src/components/SearchPage/SearchResultList.tsx
  50. 23 68
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  51. 2 2
      packages/app/src/components/SearchTypeahead.jsx
  52. 73 14
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  53. 3 4
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  54. 0 2
      packages/app/src/components/Sidebar/SidebarNav.tsx
  55. 1 10
      packages/app/src/components/StickyStretchableScroller.jsx
  56. 6 6
      packages/app/src/components/TableOfContents.jsx
  57. 2 2
      packages/app/src/interfaces/page.ts
  58. 8 1
      packages/app/src/server/models/obsolete-page.js
  59. 2 0
      packages/app/src/server/models/page.ts
  60. 0 1
      packages/app/src/server/routes/apiv3/pages.js
  61. 23 4
      packages/app/src/server/service/page.js
  62. 1 10
      packages/app/src/server/service/slack-command-handler/search.js
  63. 5 7
      packages/app/src/server/service/slack-event-handler/link-shared.ts
  64. 71 41
      packages/app/src/stores/context.tsx
  65. 6 7
      packages/app/src/stores/page.tsx
  66. 120 52
      packages/app/src/stores/ui.tsx
  67. 16 11
      packages/app/src/stores/use-static-swr.tsx
  68. 4 2
      packages/app/src/styles/_search.scss
  69. 5 0
      packages/app/src/styles/_subnav.scss
  70. 1 1
      packages/codemirror-textlint/package.json
  71. 1 1
      packages/core/package.json
  72. 1 1
      packages/plugin-attachment-refs/package.json
  73. 1 1
      packages/plugin-lsx/package.json
  74. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  75. 1 1
      packages/slack/package.json
  76. 1 0
      packages/slack/src/index.ts
  77. 10 0
      packages/slack/src/utils/generate-last-update-markdown.ts
  78. 2 0
      packages/slack/src/utils/required-scopes.ts
  79. 2 2
      packages/slackbot-proxy/package.json
  80. 5 1
      packages/slackbot-proxy/src/services/RelationsService.ts
  81. 1 1
      packages/ui/package.json
  82. 9 4
      yarn.lock

+ 41 - 1
CHANGELOG.md

@@ -1,9 +1,49 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.4.13...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.2](https://github.com/weseek/growi/compare/v4.5.1...v4.5.2) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: Added scope for unfurl (#4811) @hakumizuki
+
+## [v4.5.1](https://github.com/weseek/growi/compare/v4.5.0...v4.5.1) - 2021-12-06
+
+### 🐛 Bug Fixes
+
+- fix: /admin/slack-integration page dump undefined error (#4806) @yuki-takei
+
+## [v4.5.0](https://github.com/weseek/growi/compare/v4.4.13...v4.5.0) - 2021-12-06
+
+### BREAKING CHANGES
+
+- imprv: APIv3 payload (#4770) @LuqmanHakim-Grune
+
+### 💎 Features
+
+- feat: Slackbot unfurl (#4720) @hakumizuki
+
+### 🚀 Improvement
+
+- imprv: APIv3 payload (#4770) @LuqmanHakim-Grune
+- imprv: upgrade passport from v0.4.x to v0.5.x (#4727) @mudana-grune
+- imprv: Show site url in unfurl footer (#4755) @hakumizuki
+- imprv: SWRize context (#4740) @hakumizuki
+- imprv: Upgrade mongoose from 5.x to 6.x (#4659) @mudana-grune
+
+### 🐛 Bug Fixes
+
+- fix(slackbot-proxy): Support new API v3 data scheme (#4800) @yuki-takei
+- fix(Slackbot): Slash commands response when sent from disabled channels (#4754) @stevenfukase
+
+### 🧰 Maintenance
+
+- ci(deps): bump detect-indent from 6.0.0 to 7.0.0 (#4635) @dependabot
+- ci(deps): bump passport-saml from 2.2.0 to 3.2.0 (#4431) @dependabot
+
 ## [v4.4.13](https://github.com/weseek/growi/compare/v4.4.12...v4.4.13) - 2021-11-19
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.4.14-RC.0",
+  "version": "4.5.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -35,6 +35,8 @@
     "app:server": "yarn lerna run server --scope @growi/app",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",
+    "bump-versions:premajor": "node ./bin/github-actions/bump-versions -i premajor",
+    "bump-versions:preminor": "node ./bin/github-actions/bump-versions -i preminor",
     "bump-versions:patch": "node ./bin/github-actions/bump-versions -i patch",
     "bump-versions:rc": "node ./bin/github-actions/bump-versions -i prerelease",
     "bump-versions:slackbot-proxy": "node ./bin/github-actions/bump-versions -i prerelease -d packages/slackbot-proxy --preid slackbot-proxy --update-dependencies false",

+ 2 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.4\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.4-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.5\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.5-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md

+ 4 - 14
packages/app/docker/README.md

@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.4.13`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
-* [`4.4.13-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
-* [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
-* [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
+* [`4.5.2`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
+* [`4.5.2-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.2/docker/Dockerfile)
+* [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
+* [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 
 What is GROWI?
@@ -23,16 +23,6 @@ GROWI is a team collaboration software and it forked from [crowi](https://github
 
 see: [weseek/growi](https://github.com/weseek/growi)
 
-What is growi-docker?
--------------------
-
-The GROWI official docker image for production use which concludes several official plugins.
-
-- [growi-plugin-lsx](https://www.npmjs.com/package/growi-plugin-lsx)
-- [growi-plugin-pukiwiki-like-linker](https://www.npmjs.com/package/growi-plugin-pukiwiki-like-linker)
-- [growi-plugin-attachment-refs](https://www.npmjs.com/package/growi-plugin-attachment-refs)
-
-
 
 Requirements
 -------------

+ 9 - 9
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.4.14-RC.0",
+  "version": "4.5.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,11 +58,11 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.4.14-RC.0",
-    "@growi/plugin-attachment-refs": "^4.4.14-RC.0",
-    "@growi/plugin-lsx": "^4.4.14-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.14-RC.0",
-    "@growi/slack": "^4.4.14-RC.0",
+    "@growi/codemirror-textlint": "^4.5.3-RC.0",
+    "@growi/plugin-attachment-refs": "^4.5.3-RC.0",
+    "@growi/plugin-lsx": "^4.5.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.5.3-RC.0",
+    "@growi/slack": "^4.5.3-RC.0",
     "@promster/express": "^5.1.0",
     "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
@@ -84,7 +84,7 @@
     "cookie-parser": "^1.4.5",
     "csrf": "^3.1.0",
     "date-fns": "^2.23.0",
-    "detect-indent": "^6.0.0",
+    "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
@@ -158,7 +158,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "@growi/ui": "^4.4.14-RC.0",
+    "@growi/ui": "^4.5.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -228,7 +228,7 @@
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^4.2.0",
-    "sticky-events": "^3.1.3",
+    "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
     "stylelint": "^14.0.1",

+ 1 - 1
packages/app/resource/locales/en_US/admin/admin.json

@@ -201,7 +201,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "different_versions": "this growi and the uploarded data versions are not met",
+        "different_versions": "This growi and the uploaded data versions are not met",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."

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

@@ -579,6 +579,7 @@
     "delete_completely": "Delete completely",
     "include_certain_path" : "Include {{pathToInclude}} path ",
     "delete_all_selected_page" : "Delete All",
+    "currently_not_implemented":"This is not currently implemented",
     "search_again" : "Search again",
     "number_of_list_to_display" : "Display",
     "page_number_unit" : "pages"

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

@@ -579,6 +579,7 @@
     "delete_completely": "完全に削除する",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "delete_all_selected_page" : "一括削除",
+    "currently_not_implemented":"現在未実装の機能です",
     "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
     "page_number_unit" : "件"

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

@@ -852,6 +852,7 @@
 		"delete_completely": "完全删除",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "delete_all_selected_page": "删除所有",
+    "currently_not_implemented": "这是当前未实现的功能",
     "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
     "page_number_unit" : "例"

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

@@ -25,8 +25,6 @@ import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -57,7 +55,6 @@ appContainer.initContents();
 const { i18n } = appContainer;
 
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminImportContainer = new AdminImportContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
@@ -71,7 +68,6 @@ const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
   appContainer,
-  navigationContainer,
   adminAppContainer,
   adminImportContainer,
   adminSocketIoContainer,

+ 1 - 3
packages/app/src/client/app.jsx

@@ -42,7 +42,6 @@ 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';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
@@ -62,7 +61,6 @@ const { i18n } = appContainer;
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 // create unstated container instance
-const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
@@ -72,7 +70,7 @@ const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
+  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
   commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
 ];
 

+ 6 - 54
packages/app/src/client/legacy/crowi.js

@@ -1,3 +1,5 @@
+const { blinkElem, blinkSectionHeaderAtBoot } = require('../util/blink-section-header');
+
 /* eslint-disable react/jsx-filename-extension */
 require('jquery.cookie');
 
@@ -16,8 +18,6 @@ window.Crowi = Crowi;
  */
 Crowi.setCaretLineData = function(line) {
   const { appContainer } = window;
-  const navigationContainer = appContainer.getContainer('NavigationContainer');
-  // navigationContainer.setEditorMode('edit');
   const pageEditorDom = document.querySelector('#page-editor');
   pageEditorDom.setAttribute('data-caret-line', line);
 };
@@ -112,48 +112,6 @@ Crowi.initClassesByOS = function() {
   });
 };
 
-Crowi.findHashFromUrl = function(url) {
-  let match;
-  /* eslint-disable no-cond-assign */
-  if (match = url.match(/#(.+)$/)) {
-    return `#${match[1]}`;
-  }
-  /* eslint-enable no-cond-assign */
-
-  return '';
-};
-
-Crowi.findSectionHeader = function(hash) {
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    return elem;
-  }
-
-  return null;
-};
-
-Crowi.unblinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.remove('blink');
-  }
-};
-
-Crowi.blinkSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.add('blink');
-  }
-};
-
 // window.addEventListener('load', () => {
 //   const { appContainer } = window;
 //   const pageContainer = appContainer.getContainer('PageContainer');
@@ -219,28 +177,22 @@ window.addEventListener('load', () => {
     });
   }
 
-  Crowi.blinkSelectedSection(window.location.hash);
+  blinkSectionHeaderAtBoot();
+
   Crowi.modifyScrollTop();
   Crowi.initClassesByOS();
 });
 
 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');
-
 
   // hash on page
   if (window.location.hash) {
     if (window.location.hash === '#edit') {
-      // navigationContainer.setEditorMode('edit');
       Crowi.setCaretLineAndFocusToEditor();
     }
-    else if (window.location.hash === '#hackmd') {
-      // navigationContainer.setEditorMode('hackmd');
-    }
+    // else if (window.location.hash === '#hackmd') {
+    // }
   }
 });
 

+ 11 - 19
packages/app/src/client/services/ContextExtractor.tsx

@@ -7,26 +7,14 @@ import {
   usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
 } from '../../stores/context';
-
 import {
-  EditorMode, useEditorMode, useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
 } from '~/stores/ui';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 const jsonNull = 'null';
 
-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');
@@ -73,12 +61,6 @@ const ContextExtractorOnce: FC = () => {
   // App
   useCurrentUser(currentUser);
 
-  // Navigation
-  useEditorMode(getInitialEditorMode());
-  usePreferDrawerModeByUser();
-  usePreferDrawerModeOnEditByUser();
-  useIsDeviceSmallerThanMd();
-
   // Page
   useCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
@@ -108,6 +90,16 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
 
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
+
   return null;
 };
 

+ 0 - 157
packages/app/src/client/services/NavigationContainer.js

@@ -1,157 +0,0 @@
-import { Container } from 'unstated';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:services:NavigationContainer');
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-const SCROLL_THRES_SKIP = 200;
-const WIKI_HEADER_LINK = 120;
-
-export default class NavigationContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const { localStorage } = window;
-
-    this.state = {
-      // editorMode: 'view',
-
-      isScrollTop: true,
-    };
-
-    // this.setEditorMode = this.setEditorMode.bind(this);
-    this.initScrollEvent();
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'NavigationContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  initScrollEvent() {
-    window.addEventListener('scroll', () => {
-      const currentYOffset = window.pageYOffset;
-
-      // original throttling
-      if (SCROLL_THRES_SKIP < currentYOffset) {
-        return;
-      }
-
-      this.setState({
-        isScrollTop: currentYOffset === 0,
-      });
-    });
-  }
-
-  // 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
-   * @param {object} newState A newest state object
-   *
-   * Specify 'newState' like following code:
-   *
-   *   { ...this.state, overwriteParam: overwriteValue }
-   *
-   * 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 });
-  // }
-
-  /**
-   * Function that implements the click event for realizing smooth scroll
-   * @param {array} elements
-   */
-  addSmoothScrollEvent(elements = {}) {
-    elements.forEach(link => link.addEventListener('click', (e) => {
-      e.preventDefault();
-
-      const href = link.getAttribute('href').replace('#', '');
-      window.location.hash = href;
-      const targetDom = document.getElementById(href);
-      this.smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
-    }));
-  }
-
-  smoothScrollIntoView(element = null, offsetTop = 0) {
-    const targetElement = element || window.document.body;
-
-    // get the distance to the target element top
-    const rectTop = targetElement.getBoundingClientRect().top;
-
-    const top = window.pageYOffset + rectTop - offsetTop;
-
-    window.scrollTo({
-      top,
-      behavior: 'smooth',
-    });
-  }
-
-}

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

@@ -162,13 +162,6 @@ export default class PageContainer extends Container {
   }
 
 
-  // get isAbleToOpenPageEditor() {
-  //   const { isNotCreatable, isTrashPage } = this.state;
-  //   const { isGuestUser } = this.appContainer;
-
-  //   return (!isNotCreatable && !isTrashPage && !isGuestUser);
-  // }
-
   /**
    * whether to display reaction buttons
    * ex.) like, bookmark
@@ -329,10 +322,6 @@ export default class PageContainer extends Container {
     }
   }
 
-  get navigationContainer() {
-    return this.appContainer.getContainer('NavigationContainer');
-  }
-
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
@@ -359,9 +348,7 @@ export default class PageContainer extends Container {
    * @param {Array[Tag]} tags Array of Tag
    * @param {object} revision Revision instance
    */
-  updateStateAfterSave(page, tags, revision) {
-    const { editorMode } = this.navigationContainer.state;
-
+  updateStateAfterSave(page, tags, revision, editorMode) {
     // update state of PageContainer
     const newState = {
       pageId: page._id,
@@ -405,9 +392,7 @@ export default class PageContainer extends Container {
    * @param {object} optionsToSave
    * @return {object} { page: Page, tags: Tag[] }
    */
-  async save(markdown, optionsToSave = {}) {
-    const { editorMode } = this.navigationContainer.state;
-
+  async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
 
@@ -427,19 +412,18 @@ export default class PageContainer extends Container {
       res = await this.updatePage(pageId, revisionId, markdown, options);
     }
 
-    this.updateStateAfterSave(res.page, res.tags, res.revision);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
     return res;
   }
 
-  async saveAndReload(optionsToSave) {
+  async saveAndReload(optionsToSave, editorMode) {
     if (optionsToSave == null) {
       const msg = '\'saveAndReload\' requires the \'optionsToSave\' param';
       throw new Error(msg);
     }
 
-    const { editorMode } = this.navigationContainer.state;
     if (editorMode == null) {
-      logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
+      logger.warn('\'saveAndReload\' requires the \'editorMode\' param');
       return;
     }
 

+ 27 - 0
packages/app/src/client/util/blink-section-header.ts

@@ -0,0 +1,27 @@
+let lastBlinkedElem;
+
+export const blinkElem = (elem: HTMLElement): void => {
+  if (lastBlinkedElem != null) {
+    lastBlinkedElem.classList.remove('blink');
+  }
+
+  elem.classList.add('blink');
+  lastBlinkedElem = elem;
+};
+
+export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
+  const { hash } = window.location;
+
+  if (hash.length === 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
+    blinkElem(elem);
+  }
+};

+ 45 - 0
packages/app/src/client/util/smooth-scroll.ts

@@ -0,0 +1,45 @@
+const WIKI_HEADER_LINK = 120;
+
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+  const targetElement = element || window.document.body;
+
+  // get the distance to the target element top
+  const rectTop = targetElement.getBoundingClientRect().top;
+
+  const top = window.pageYOffset + rectTop - offsetTop;
+
+  window.scrollTo({
+    top,
+    behavior: 'smooth',
+  });
+};
+
+export type SmoothScrollEventCallback = (elem: HTMLElement) => void;
+
+export const addSmoothScrollEvent = (elements: HTMLAnchorElement[], callback?: SmoothScrollEventCallback): void => {
+  elements.forEach((link) => {
+    const href = link.getAttribute('href');
+
+    if (href == null) {
+      return;
+    }
+
+    link.addEventListener('click', (e) => {
+      e.preventDefault();
+
+      // modify location.hash without scroll
+      window.history.pushState({}, '', link.href);
+
+      // smooth scroll
+      const elemId = href.replace('#', '');
+      const targetDom = document.getElementById(elemId);
+      if (targetDom != null) {
+        smoothScrollIntoView(targetDom, WIKI_HEADER_LINK);
+
+        if (callback != null) {
+          callback(targetDom);
+        }
+      }
+    });
+  });
+};

+ 2 - 1
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -95,7 +95,7 @@ const OfficialBotSettings = (props) => {
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
-            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions,
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
@@ -118,6 +118,7 @@ const OfficialBotSettings = (props) => {
                 tokenPtoG={tokenPtoG}
                 permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
                 permissionsForSingleUseCommands={permissionsForSingleUseCommands}
+                permissionsForSlackEventActions={permissionsForSlackEventActions}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 5 - 5
packages/app/src/components/BookmarkButton.jsx

@@ -42,11 +42,11 @@ class LegacyBookmarkButton extends React.Component {
           ${`btn-${this.props.size}`} ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
           <i className="icon-star mr-3"></i>
-          {sumOfBookmarks && (
-            <span className="total-bookmarks">
-              {sumOfBookmarks}
-            </span>
-          )}
+          <span className="total-bookmarks">
+            {sumOfBookmarks && (
+              sumOfBookmarks
+            )}
+          </span>
         </button>
 
         {isGuestUser && (

+ 103 - 0
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -0,0 +1,103 @@
+import React, {
+  FC, memo, useEffect, useRef, useState,
+} from 'react';
+
+export const AlertType = {
+  WARNING: 'warning',
+  ERROR: 'error',
+} as const;
+
+export type AlertType = typeof AlertType[keyof typeof AlertType];
+
+export type AlertInfo = {
+  type?: AlertType
+  message?: string
+}
+
+type ClosableTextInputProps = {
+  isShown: boolean
+  placeholder?: string
+  inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
+  onPressEnter?(): void
+  onClickOutside?(): void
+}
+
+const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
+
+  const onChangeHandler = async(e) => {
+    if (props.inputValidator == null) { return }
+
+    const alertInfo = await props.inputValidator(e.target.value);
+
+    setAlertInfo(alertInfo);
+  };
+
+  const onPressEnter = () => {
+    if (props.onPressEnter == null) {
+      return;
+    }
+
+    props.onPressEnter();
+  };
+
+  const onKeyDownHandler = (e) => {
+    switch (e.key) {
+      case 'Enter':
+        onPressEnter();
+        break;
+      default:
+        break;
+    }
+  };
+
+  /*
+   * Hide when click outside the ref
+   */
+  const onBlurHandler = () => {
+    if (props.onClickOutside == null) {
+      return;
+    }
+
+    props.onClickOutside();
+  };
+
+  // didMount
+  useEffect(() => {
+    // autoFocus
+    if (inputRef?.current == null) {
+      return;
+    }
+    inputRef.current.focus();
+  });
+
+
+  // TODO: improve style
+  return (
+    <div className={props.isShown ? 'd-block' : 'd-none'}>
+      <input
+        ref={inputRef}
+        type="text"
+        className="form-control"
+        placeholder={props.placeholder}
+        name="input"
+        onChange={onChangeHandler}
+        onKeyDown={onKeyDownHandler}
+        onBlur={onBlurHandler}
+        autoFocus={false}
+      />
+      <div>
+        {currentAlertInfo != null && (
+          <p>
+            {/* eslint-disable-next-line max-len */}
+            {currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR}: {currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value' }
+          </p>
+        )}
+      </div>
+    </div>
+  );
+});
+
+export default ClosableTextInput;

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

@@ -0,0 +1,69 @@
+import React, { FC } from 'react';
+
+import toastr from 'toastr';
+import { useTranslation } from 'react-i18next';
+
+import { IPageHasId } from '~/interfaces/page';
+
+type PageItemControlProps = {
+  page: Partial<IPageHasId>,
+  onClickDeleteButton?: (pageId: string)=>void,
+}
+
+const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
+
+  const { page, onClickDeleteButton } = props;
+  const { t } = useTranslation('');
+
+  const deleteButtonHandler = () => {
+    if (onClickDeleteButton != null && page._id != null) {
+      onClickDeleteButton(page._id);
+    }
+  };
+  return (
+    <>
+      <button
+        type="button"
+        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        data-toggle="dropdown"
+      >
+        <i className="fa fa-ellipsis-v text-muted"></i>
+      </button>
+      <div className="dropdown-menu dropdown-menu-right">
+
+        {/* TODO: if there is the following button in XD add it here
+        <button
+          type="button"
+          className="btn btn-link p-0"
+          value={page.path}
+          onClick={(e) => {
+            window.location.href = e.currentTarget.value;
+          }}
+        >
+          <i className="icon-login" />
+        </button>
+        */}
+
+        {/*
+          TODO: add function to the following buttons like using modal or others
+          ref: https://estoc.weseek.co.jp/redmine/issues/79026
+        */}
+        <button className="dropdown-item text-danger" type="button" onClick={deleteButtonHandler}>
+          <i className="icon-fw icon-fire"></i>{t('Delete')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
+        </button>
+        <button className="dropdown-item" type="button" onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
+        </button>
+      </div>
+    </>
+  );
+
+};
+
+export default PageItemControl;

+ 6 - 7
packages/app/src/components/ContentLinkButtons.jsx

@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
 
 import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 const { isTopPage } = pagePathUtils;
 
@@ -20,7 +20,7 @@ const WIKI_HEADER_LINK = 120;
  */
 const ContentLinkButtons = (props) => {
 
-  const { appContainer, navigationContainer, pageContainer } = props;
+  const { appContainer, pageContainer } = props;
   const { pageUser, path } = pageContainer.state;
   const { isPageExist } = pageContainer.state;
   const { isSharedUser } = appContainer;
@@ -39,7 +39,7 @@ const ContentLinkButtons = (props) => {
         <button
           type="button"
           className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => navigationContainer.smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
         >
           <i className="mr-2 icon-fw icon-bubbles"></i>
           <span>Comments</span>
@@ -53,7 +53,7 @@ const ContentLinkButtons = (props) => {
       <button
         type="button"
         className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
       >
         <i className="mr-2 icon-star"></i>
         <span>Bookmarks</span>
@@ -67,7 +67,7 @@ const ContentLinkButtons = (props) => {
       <button
         type="button"
         className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
       >
         <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
         <span>Recently Created</span>
@@ -90,8 +90,7 @@ const ContentLinkButtons = (props) => {
 
 ContentLinkButtons.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, NavigationContainer, PageContainer]);
+export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 40 - 0
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -0,0 +1,40 @@
+import { FC, useCallback, useEffect } from 'react';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
+
+const HashChanged: FC<void> = () => {
+  const { data: isEditable } = useIsEditable();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const hashchangeHandler = useCallback(() => {
+    const { hash } = window.location;
+
+    if (hash == null) {
+      return;
+    }
+
+    if (hash === '#edit') {
+      mutateEditorMode(EditorMode.Editor);
+    }
+  }, [mutateEditorMode]);
+
+  // setup effect
+  useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
+    window.addEventListener('hashchange', hashchangeHandler);
+
+    // return remove handler
+    return function cleanup() {
+      window.removeEventListener('hashchange', hashchangeHandler);
+    };
+
+  }, [hashchangeHandler, isEditable, mutateEditorMode]);
+
+  return null;
+};
+
+export default HashChanged;

+ 8 - 8
packages/app/src/components/Fab.jsx

@@ -5,8 +5,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { usePageCreateModalOpened } from '~/stores/ui';
+
+import { useCreateModalStatus } from '~/stores/ui';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
@@ -15,10 +16,10 @@ import ReturnTopIcon from './Icons/ReturnTopIcon';
 const logger = loggerFactory('growi:cli:Fab');
 
 const Fab = (props) => {
-  const { navigationContainer, appContainer } = props;
+  const { appContainer } = props;
   const { currentUser } = appContainer;
 
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
@@ -56,7 +57,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={() => mutatePageCreateModalOpened(true)}
+            onClick={() => openCreateModal()}
           >
             <CreatePageIcon />
           </button>
@@ -72,7 +73,7 @@ const Fab = (props) => {
         <button
           type="button"
           className={`btn btn-light btn-scroll-to-top rounded-circle p-0 ${buttonClasses}`}
-          onClick={() => navigationContainer.smoothScrollIntoView()}
+          onClick={() => smoothScrollIntoView()}
         >
           <ReturnTopIcon />
         </button>
@@ -84,7 +85,6 @@ const Fab = (props) => {
 
 Fab.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
-export default withUnstatedContainers(Fab, [AppContainer, NavigationContainer]);
+export default withUnstatedContainers(Fab, [AppContainer]);

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

@@ -1,19 +1,19 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import { usePageCreateModalOpened } from '~/stores/ui';
+import { useCreateModalStatus } from '~/stores/ui';
 
 const CreatePage = React.memo((props) => {
 
-  const { mutate } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   // setup effect
   useEffect(() => {
-    mutate(true);
+    openCreateModal();
 
     // remove this
     props.onDeleteRender(this);
-  }, [mutate, props]);
+  }, [openCreateModal, props]);
 
   return <></>;
 });

+ 10 - 9
packages/app/src/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,15 +1,19 @@
 import React, { useEffect } from 'react';
 import PropTypes from 'prop-types';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
 
 const EditPage = (props) => {
+  const { data: isEditable } = useIsEditable();
   const { mutate: mutateEditorMode } = useEditorMode();
 
   // setup effect
   useEffect(() => {
+    if (!isEditable) {
+      return;
+    }
+
     // ignore when dom that has 'modal in' classes exists
     if (document.getElementsByClassName('modal in').length > 0) {
       return;
@@ -19,20 +23,17 @@ const EditPage = (props) => {
 
     // remove this
     props.onDeleteRender(this);
-  }, [mutateEditorMode, props]);
+  }, [isEditable, mutateEditorMode, props]);
 
-  return <></>;
+  return null;
 };
 
 EditPage.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   onDeleteRender: PropTypes.func.isRequired,
 };
 
-const EditPageWrapper = withUnstatedContainers(EditPage, [NavigationContainer]);
-
-EditPageWrapper.getHotkeyStrokes = () => {
+EditPage.getHotkeyStrokes = () => {
   return [['e']];
 };
 
-export default EditPageWrapper;
+export default EditPage;

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

@@ -60,7 +60,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
         <PopoverBody className="seen-user-popover">
           <div className="px-2 text-right user-list-content text-truncate text-muted">
-            {props.likers.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+            {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
           </div>
         </PopoverBody>
       </Popover>

+ 1 - 3
packages/app/src/components/Navbar/GlobalSearch.jsx

@@ -4,7 +4,6 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 
 import SearchForm from '../SearchForm';
 
@@ -97,7 +96,6 @@ class GlobalSearch extends React.Component {
 GlobalSearch.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 
   dropup: PropTypes.bool,
 };
@@ -105,6 +103,6 @@ GlobalSearch.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer, NavigationContainer]);
+const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer]);
 
 export default withTranslation()(GlobalSearchWrapper);

+ 3 - 3
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -7,7 +7,7 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+import { useIsDeviceSmallerThanMd, useCreateModalStatus } from '~/stores/ui';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
@@ -20,7 +20,7 @@ type NavbarRightProps = {
 }
 const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
   const { t } = useTranslation();
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   const { currentUser } = props;
 
@@ -35,7 +35,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
           className="px-md-2 nav-link btn-create-page border-0 bg-transparent"
           type="button"
-          onClick={() => mutatePageCreateModalOpened(true)}
+          onClick={() => openCreateModal()}
         >
           <i className="icon-pencil mr-2"></i>
           <span className="d-none d-lg-block">{ t('New') }</span>

+ 5 - 14
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,21 +1,15 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import NavigationContainer from '~/client/services/NavigationContainer';
-import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import GlobalSearch from './GlobalSearch';
 
 const GrowiNavbarBottom = (props) => {
 
-  const {
-    navigationContainer,
-  } = props;
-
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = useCreateModalStatus();
 
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
@@ -40,7 +34,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => mutateDrawerOpened(true)}
+              onClick={() => openCreateModal()}
             >
               <i className="icon-menu"></i>
             </a>
@@ -59,7 +53,7 @@ const GrowiNavbarBottom = (props) => {
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => mutatePageCreateModalOpened(true)}
+              onClick={() => openCreateModal(true)}
             >
               <i className="icon-pencil"></i>
             </a>
@@ -71,8 +65,5 @@ const GrowiNavbarBottom = (props) => {
   );
 };
 
-GrowiNavbarBottom.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
 
-export default withUnstatedContainers(GrowiNavbarBottom, [NavigationContainer]);
+export default GrowiNavbarBottom;

+ 0 - 1
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -11,7 +11,6 @@ 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 {

+ 5 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,8 +1,7 @@
 import React, {
-  FC, useCallback, useState, useEffect,
+  FC, useCallback,
 } from 'react';
 import AppContainer from '../../client/services/AppContainer';
-import NavigationContainer from '../../client/services/NavigationContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import PageReactionButtons from '../PageReactionButtons';
@@ -12,10 +11,10 @@ import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { toastError } from '../../client/util/apiNotification';
 import { apiv3Put } from '../../client/util/apiv3-client';
 import { useSWRxLikerList } from '../../stores/user';
+import { useEditorMode } from '~/stores/ui';
 
 type SubNavButtonsProps= {
   appContainer: AppContainer,
-  navigationContainer: NavigationContainer,
   isCompactMode?: boolean,
   pageId: string,
   revisionId: string,
@@ -26,9 +25,9 @@ type SubNavButtonsProps= {
 }
 const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
   const {
-    appContainer, navigationContainer, isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
+    appContainer, isCompactMode, pageId, revisionId, path, willShowPageManagement, isDeletable, isAbleToDeleteCompletely,
   } = props;
-  const { editorMode } = navigationContainer.state;
+  const { data: editorMode } = useEditorMode();
   const isViewMode = editorMode === 'view';
   const { isGuestUser } = appContainer;
 
@@ -109,7 +108,7 @@ const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
 /**
  * Wrapper component for using unstated
  */
-const SubNavButtonsUnstatedWrapper = withUnstatedContainers(SubNavButtons, [AppContainer, NavigationContainer]);
+const SubNavButtonsUnstatedWrapper = withUnstatedContainers(SubNavButtons, [AppContainer]);
 
 // wrapping tsx component returned by withUnstatedContainers to avoid type error when this component used in other tsx components.
 const SubNavButtonsWrapper = (props) => {

+ 19 - 3
packages/app/src/components/Page.jsx

@@ -17,6 +17,9 @@ import DrawioModal from './PageEditor/DrawioModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+
 const logger = loggerFactory('growi:Page');
 
 class Page extends React.Component {
@@ -85,7 +88,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -115,7 +118,7 @@ class Page extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -157,6 +160,19 @@ Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
+};
+
+const PageWrapper = (props) => {
+  const { data } = useEditorMode();
+
+  if (data == null) {
+    return null;
+  }
+
+  return <Page {...props} editorMode={data} />;
 };
 
-export default withUnstatedContainers(Page, [AppContainer, PageContainer, EditorContainer]);
+export default withUnstatedContainers(PageWrapper, [AppContainer, PageContainer, EditorContainer]);

+ 20 - 11
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -14,6 +14,8 @@ import ContentLinkButtons from '../ContentLinkButtons';
 import PageAccessories from '../PageAccessories';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+import { useIsEditable } from '~/stores/context';
 
 
 const DisplaySwitcher = (props) => {
@@ -22,6 +24,7 @@ const DisplaySwitcher = (props) => {
   } = props;
   const { isPageExist, pageUser } = pageContainer.state;
 
+  const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
 
   const isViewMode = editorMode === EditorMode.View;
@@ -54,18 +57,24 @@ const DisplaySwitcher = (props) => {
 
           </div>
         </TabPane>
-        <TabPane tabId={EditorMode.Editor}>
-          <div id="page-editor">
-            <Editor />
-          </div>
-        </TabPane>
-        <TabPane tabId={EditorMode.HackMD}>
-          <div id="page-editor-with-hackmd">
-            <PageEditorByHackmd />
-          </div>
-        </TabPane>
+        { isEditable && (
+          <TabPane tabId={EditorMode.Editor}>
+            <div id="page-editor">
+              <Editor />
+            </div>
+          </TabPane>
+        ) }
+        { isEditable && (
+          <TabPane tabId={EditorMode.HackMD}>
+            <div id="page-editor-with-hackmd">
+              <PageEditorByHackmd />
+            </div>
+          </TabPane>
+        ) }
       </TabContent>
-      {!isViewMode && <EditorNavbarBottom /> }
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
     </>
   );
 };

+ 6 - 3
packages/app/src/components/Page/PageManagement.jsx

@@ -153,10 +153,15 @@ const LegacyPageManagemenet = (props) => {
     );
   }
 
+  function generatePageObjectToDelete() {
+    return { pageId, revisionId, path };
+  }
+
   function renderModals() {
     if (currentUser == null) {
       return null;
     }
+    const pageToDelete = generatePageObjectToDelete();
 
     return (
       <>
@@ -181,9 +186,7 @@ const LegacyPageManagemenet = (props) => {
         <PageDeleteModal
           isOpen={isPageDeleteModalShown}
           onClose={closePageDeleteModalHandler}
-          pageId={pageId}
-          revisionId={revisionId}
-          path={path}
+          pages={[pageToDelete]}
           isAbleToDeleteCompletely={isAbleToDeleteCompletely}
         />
         <PagePresentationModal

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

@@ -3,8 +3,9 @@ import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 import RevisionBody from './RevisionBody';
 
@@ -35,7 +36,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
   componentDidUpdate(prevProps) {
     const { markdown: prevMarkdown, highlightKeywords: prevHighlightKeywords } = prevProps;
-    const { markdown, highlightKeywords, navigationContainer } = this.props;
+    const { markdown, highlightKeywords } = this.props;
 
     // render only when props.markdown is updated
     if (markdown !== prevMarkdown || highlightKeywords !== prevHighlightKeywords) {
@@ -46,7 +47,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
     const HeaderLink = document.getElementsByClassName('revision-head-link');
     const HeaderLinkArray = Array.from(HeaderLink);
-    navigationContainer.addSmoothScrollEvent(HeaderLinkArray);
+    addSmoothScrollEvent(HeaderLinkArray, blinkElem);
 
     const { interceptorManager } = this.props.appContainer;
 
@@ -119,7 +120,6 @@ class LegacyRevisionRenderer 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,
@@ -129,7 +129,7 @@ LegacyRevisionRenderer.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer, NavigationContainer]);
+const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRenderer, [AppContainer]);
 
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types

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

@@ -1,5 +1,5 @@
 
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import PropTypes from 'prop-types';
 
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
@@ -13,7 +13,7 @@ import { pagePathUtils, pathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
-import { usePageCreateModalOpened } from '~/stores/ui';
+import { useCreateModalStatus, useCreateModalOpened, useCreateModalPath } from '~/stores/ui';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
@@ -24,11 +24,14 @@ const {
 const PageCreateModal = (props) => {
   const { t, appContainer } = props;
 
-  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { close: closeCreateModal } = useCreateModalStatus();
+  const { data: isOpened } = useCreateModalOpened();
+  const { data: path } = useCreateModalPath();
+
 
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
-  const pathname = decodeURI(window.location.pathname);
+  const pathname = path || '';
   const userPageRootPath = userPageRoot(appContainer.currentUser);
   const pageNameInputInitialValue = isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
@@ -38,6 +41,11 @@ const PageCreateModal = (props) => {
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
 
+  // ensure pageNameInput is synced with selectedPagePath || currentPagePath
+  useEffect(() => {
+    setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
+  }, [pathname]);
+
   function transitBySubmitEvent(e, transitHandler) {
     // prevent page transition by submit
     e.preventDefault();
@@ -266,12 +274,12 @@ const PageCreateModal = (props) => {
   return (
     <Modal
       size="lg"
-      isOpen={isPageCreateModalOpened}
-      toggle={() => mutatePageCreateModalOpened(false)}
+      isOpen={isOpened}
+      toggle={() => closeCreateModal()}
       className="grw-create-page"
       autoFocus={false}
     >
-      <ModalHeader tag="h4" toggle={() => mutatePageCreateModalOpened(false)} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => closeCreateModal()} className="bg-primary text-light">
         {t('New Page')}
       </ModalHeader>
       <ModalBody>

+ 58 - 52
packages/app/src/components/PageDeleteModal.jsx → packages/app/src/components/PageDeleteModal.tsx

@@ -1,16 +1,20 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-
+import React, { useState, FC } from 'react';
+import toastr from 'toastr';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
 
-import { withTranslation } from 'react-i18next';
-
-import { apiPost } from '~/client/util/apiv1-client';
+// import { apiPost } from '~/client/util/apiv1-client';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
+export type IPageForPageDeleteModal = {
+  pageId: string,
+  revisionId: string,
+  path: string
+}
+
 const deleteIconAndKey = {
   completely: {
     color: 'danger',
@@ -24,9 +28,18 @@ const deleteIconAndKey = {
   },
 };
 
-const PageDeleteModal = (props) => {
+type Props = {
+  isOpen: boolean,
+  pages: IPageForPageDeleteModal[],
+  isDeleteCompletelyModal: boolean,
+  isAbleToDeleteCompletely: boolean,
+  onClose?: () => void,
+}
+
+const PageDeleteModal: FC<Props> = (props: Props) => {
+  const { t } = useTranslation('');
   const {
-    t, isOpen, onClose, isDeleteCompletelyModal, pageId, revisionId, path, isAbleToDeleteCompletely,
+    isOpen, onClose, isDeleteCompletelyModal, pages, isAbleToDeleteCompletely,
   } = props;
   const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
   const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
@@ -46,27 +59,29 @@ const PageDeleteModal = (props) => {
   }
 
   async function deletePage() {
-    setErrs(null);
-
-    try {
-      // control flag
-      // If is it not true, Request value must be `null`.
-      const recursively = isDeleteRecursively ? true : null;
-      const completely = isDeleteCompletely ? true : null;
-
-      const response = await apiPost('/pages.remove', {
-        page_id: pageId,
-        revision_id: revisionId,
-        recursively,
-        completely,
-      });
-
-      const trashPagePath = response.page.path;
-      window.location.href = encodeURI(trashPagePath);
-    }
-    catch (err) {
-      setErrs(err);
-    }
+    toastr.warning(t('search_result.currently_not_implemented'));
+    // Todo implement page delete function at https://redmine.weseek.co.jp/issues/82222
+    // setErrs(null);
+
+    // try {
+    //   // control flag
+    //   // If is it not true, Request value must be `null`.
+    //   const recursively = isDeleteRecursively ? true : null;
+    //   const completely = isDeleteCompletely ? true : null;
+
+    //   const response = await apiPost('/pages.remove', {
+    //     page_id: pageId,
+    //     revision_id: revisionId,
+    //     recursively,
+    //     completely,
+    //   });
+
+    //   const trashPagePath = response.page.path;
+    //   window.location.href = encodeURI(trashPagePath);
+    // }
+    // catch (err) {
+    //   setErrs(err);
+    // }
   }
 
   async function deleteButtonHandler() {
@@ -80,12 +95,13 @@ const PageDeleteModal = (props) => {
           className="custom-control-input"
           id="deleteRecursively"
           type="checkbox"
-          checked={isDeleteRecursively}
+          // checked={isDeleteRecursively}
+          checked={false}
           onChange={changeIsDeleteRecursivelyHandler}
+          disabled // Todo: enable this at https://redmine.weseek.co.jp/issues/82222
         />
         <label className="custom-control-label" htmlFor="deleteRecursively">
           { t('modal_delete.delete_recursively') }
-          <p className="form-text text-muted mt-0"><code>{path}</code> { t('modal_delete.recursively') }</p>
         </label>
       </div>
     );
@@ -105,11 +121,14 @@ const PageDeleteModal = (props) => {
           name="completely"
           id="deleteCompletely"
           type="checkbox"
-          disabled
+          // disabled={!isAbleToDeleteCompletely}
+          disabled // Todo: will be implemented at https://redmine.weseek.co.jp/issues/82222
           checked={isDeleteCompletely}
           onChange={changeIsDeleteCompletelyHandler}
         />
-        <label className="custom-control-label text-danger" htmlFor="deleteCompletely">
+        {/* ↓↓ undo this comment out at https://redmine.weseek.co.jp/issues/82222 ↓↓ */}
+        {/* <label className="custom-control-label text-danger" htmlFor="deleteCompletely"> */}
+        <label className="custom-control-label" htmlFor="deleteCompletely">
           { t('modal_delete.delete_completely')}
           <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
         </label>
@@ -132,7 +151,11 @@ const PageDeleteModal = (props) => {
       <ModalBody>
         <div className="form-group">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
-          <code>{ path }</code>
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {/* https://redmine.weseek.co.jp/issues/82787 */}
+          {pages.map((page) => {
+            return <div><code>{ page.path }</code></div>;
+          })}
         </div>
         {renderDeleteRecursivelyForm()}
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
@@ -149,21 +172,4 @@ const PageDeleteModal = (props) => {
   );
 };
 
-PageDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
-  isDeleteCompletelyModal: PropTypes.bool,
-  isAbleToDeleteCompletely: PropTypes.bool,
-};
-
-PageDeleteModal.defaultProps = {
-  isDeleteCompletelyModal: false,
-};
-
-export default withTranslation()(PageDeleteModal);
+export default PageDeleteModal;

+ 27 - 3
packages/app/src/components/PageEditor.jsx

@@ -15,6 +15,10 @@ import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import EditorContainer from '~/client/services/EditorContainer';
 
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
+
 const logger = loggerFactory('growi:PageEditor');
 
 class PageEditor extends React.Component {
@@ -132,7 +136,7 @@ class PageEditor extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(this.state.markdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(this.state.markdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -186,7 +190,7 @@ class PageEditor extends React.Component {
       // when if created newly
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision);
+        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, this.props.editorMode);
         editorContainer.setState({ grant: res.page.grant });
       }
     }
@@ -306,6 +310,10 @@ class PageEditor extends React.Component {
   }
 
   render() {
+    if (!this.props.isEditable) {
+      return null;
+    }
+
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const emojiStrategy = this.props.appContainer.getEmojiStrategy();
@@ -347,12 +355,28 @@ class PageEditor extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+
+const PageEditorWrapper = (props) => {
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+
+  if (isEditable == null || editorMode == null) {
+    return null;
+  }
+
+  return <PageEditorHOCWrapper {...props} isEditable={isEditable} editorMode={editorMode} />;
+};
 
 PageEditor.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isEditable: PropTypes.bool.isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
 };
 
 export default PageEditorWrapper;

+ 5 - 2
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -264,7 +264,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const linePosition = Math.max(0, line);
 
     editor.setCursor({ line: linePosition }); // leave 'ch' field as null/undefined to indicate the end of line
-    this.setScrollTopByLine(linePosition);
+
+    setTimeout(() => {
+      this.setScrollTopByLine(linePosition);
+    }, 100);
   }
 
   /**
@@ -277,7 +280,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     const editor = this.getCodeMirror();
     // get top position of the line
-    const top = editor.charCoords({ line, ch: 0 }, 'local').top;
+    const top = editor.charCoords({ line: line - 1, ch: 0 }, 'local').top;
     editor.scrollTo(null, top);
   }
 

+ 18 - 2
packages/app/src/components/PageEditorByHackmd.jsx

@@ -11,6 +11,9 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
 class PageEditorByHackmd extends React.Component {
@@ -171,7 +174,7 @@ class PageEditorByHackmd extends React.Component {
       editorContainer.disableUnsavedWarning();
 
       // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(markdown, optionsToSave);
+      const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
       logger.debug('success to save');
 
       pageContainer.showSuccessToastr();
@@ -417,7 +420,17 @@ class PageEditorByHackmd extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
+const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
+
+const PageEditorByHackmdWrapper = (props) => {
+  const { data } = useEditorMode();
+
+  if (data == null) {
+    return null;
+  }
+
+  return <PageEditorByHackmdHOCWrapper {...props} editorMode={data} />;
+};
 
 PageEditorByHackmd.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -425,6 +438,9 @@ PageEditorByHackmd.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(PageEditorByHackmdWrapper);

+ 26 - 3
packages/app/src/components/SavePageControls.jsx

@@ -17,6 +17,10 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import GrantSelector from './SavePageControls/GrantSelector';
 
+// TODO: remove this when omitting unstated is completed
+import { useEditorMode } from '~/stores/ui';
+import { useIsEditable } from '~/stores/context';
+
 const logger = loggerFactory('growi:SavePageControls');
 
 class SavePageControls extends React.Component {
@@ -31,6 +35,7 @@ class SavePageControls extends React.Component {
 
     this.save = this.save.bind(this);
     this.saveAndOverwriteScopesOfDescendants = this.saveAndOverwriteScopesOfDescendants.bind(this);
+
   }
 
   updateGrantHandler(data) {
@@ -44,7 +49,7 @@ class SavePageControls extends React.Component {
 
     try {
       // save
-      await pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave());
+      await pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave(), this.props.editorMode);
     }
     catch (error) {
       logger.error('failed to save', error);
@@ -60,7 +65,7 @@ class SavePageControls extends React.Component {
     const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
       overwriteScopesOfDescendants: true,
     });
-    pageContainer.saveAndReload(optionsToSave);
+    pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
   }
 
   render() {
@@ -107,7 +112,22 @@ class SavePageControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const SavePageControlsWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+
+const SavePageControlsWrapper = (props) => {
+  const { data: isEditable } = useIsEditable();
+  const { data: editorMode } = useEditorMode();
+
+  if (isEditable == null || editorMode == null) {
+    return null;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  return <SavePageControlsHOCWrapper {...props} editorMode={editorMode} />;
+};
 
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -115,6 +135,9 @@ SavePageControls.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  // TODO: remove this when omitting unstated is completed
+  editorMode: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(SavePageControlsWrapper);

+ 79 - 9
packages/app/src/components/SearchPage.jsx

@@ -11,12 +11,14 @@ import SearchPageLayout from './SearchPage/SearchPageLayout';
 import SearchResultContent from './SearchPage/SearchResultContent';
 import SearchResultList from './SearchPage/SearchResultList';
 import SearchControl from './SearchPage/SearchControl';
+import PageDeleteModal from './PageDeleteModal';
+
+import { CheckboxType } from '../interfaces/search';
 
 export const specificPathNames = {
   user: '/user',
   trash: '/trash',
 };
-
 class SearchPage extends React.Component {
 
   constructor(props) {
@@ -30,12 +32,15 @@ class SearchPage extends React.Component {
       searchResults: [],
       searchResultMeta: {},
       focusedSearchResultData: null,
-      selectedPages: new Set(),
+      selectedPagesIdList: new Set(),
       searchResultCount: 0,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL,
       excludeUserPages: true,
       excludeTrashPages: true,
+      selectAllCheckboxType: CheckboxType.NONE_CHECKED,
+      isDeleteConfirmModalShown: false,
+      deleteTargetPageIds: new Set(),
     };
 
     this.changeURL = this.changeURL.bind(this);
@@ -47,6 +52,9 @@ class SearchPage extends React.Component {
     this.switchExcludeTrashPagesHandler = this.switchExcludeTrashPagesHandler.bind(this);
     this.onPagingNumberChanged = this.onPagingNumberChanged.bind(this);
     this.onPagingLimitChanged = this.onPagingLimitChanged.bind(this);
+    this.deleteSinglePageButtonHandler = this.deleteSinglePageButtonHandler.bind(this);
+    this.deleteAllPagesButtonHandler = this.deleteAllPagesButtonHandler.bind(this);
+    this.closeDeleteConfirmModalHandler = this.closeDeleteConfirmModalHandler.bind(this);
   }
 
   componentDidMount() {
@@ -187,13 +195,65 @@ class SearchPage extends React.Component {
     });
   }
 
-  toggleCheckBox = (page) => {
-    if (this.state.selectedPages.has(page)) {
-      this.state.selectedPages.delete(page);
+  toggleCheckBox = (pageId) => {
+    const { selectedPagesIdList } = this.state;
+
+    if (selectedPagesIdList.has(pageId)) {
+      selectedPagesIdList.delete(pageId);
+    }
+    else {
+      selectedPagesIdList.add(pageId);
+    }
+    switch (selectedPagesIdList.size) {
+      case 0:
+        return this.setState({ selectAllCheckboxType: CheckboxType.NONE_CHECKED });
+      case this.state.searchResults.length:
+        return this.setState({ selectAllCheckboxType: CheckboxType.ALL_CHECKED });
+      default:
+        return this.setState({ selectAllCheckboxType: CheckboxType.INDETERMINATE });
+    }
+  }
+
+  toggleAllCheckBox = (nextSelectAllCheckboxType) => {
+    const { selectedPagesIdList, searchResults } = this.state;
+    if (nextSelectAllCheckboxType === CheckboxType.NONE_CHECKED) {
+      selectedPagesIdList.clear();
     }
     else {
-      this.state.selectedPages.add(page);
+      searchResults.forEach((page) => {
+        selectedPagesIdList.add(page.pageData._id);
+      });
     }
+    this.setState({
+      selectedPagesIdList,
+      selectAllCheckboxType: nextSelectAllCheckboxType,
+    });
+  };
+
+  getSelectedPagesToDelete() {
+    const filteredPages = this.state.searchResults.filter((page) => {
+      return Array.from(this.state.deleteTargetPageIds).find(id => id === page.pageData._id);
+    });
+    return filteredPages.map(page => ({
+      pageId: page.pageData._id,
+      revisionId: page.pageData.revision,
+      path: page.pageData.path,
+    }));
+  }
+
+  deleteSinglePageButtonHandler(pageId) {
+    this.setState({ deleteTargetPageIds: new Set([pageId]) });
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  deleteAllPagesButtonHandler() {
+    if (this.state.selectedPagesIdList.size === 0) { return }
+    this.setState({ deleteTargetPageIds: this.state.selectedPagesIdList });
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModalHandler() {
+    this.setState({ isDeleteConfirmModalShown: false });
   }
 
   renderSearchResultContent = () => {
@@ -212,13 +272,14 @@ class SearchPage extends React.Component {
       <SearchResultList
         pages={this.state.searchResults || []}
         focusedSearchResultData={this.state.focusedSearchResultData}
-        selectedPages={this.state.selectedPages || []}
+        selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
-        onClickInvoked={this.selectPage}
-        onChangedInvoked={this.toggleCheckBox}
+        onClickSearchResultItem={this.selectPage}
+        onClickCheckbox={this.toggleCheckBox}
         onPagingNumberChanged={this.onPagingNumberChanged}
+        onClickDeleteButton={this.deleteSinglePageButtonHandler}
       />
     );
   }
@@ -227,8 +288,12 @@ class SearchPage extends React.Component {
     return (
       <SearchControl
         searchingKeyword={this.state.searchingKeyword}
+        searchResultCount={this.state.searchResultCount || 0}
         appContainer={this.props.appContainer}
         onSearchInvoked={this.onSearchInvoked}
+        onClickSelectAllCheckbox={this.toggleAllCheckBox}
+        selectAllCheckboxType={this.state.selectAllCheckboxType}
+        onClickDeleteAllButton={this.deleteAllPagesButtonHandler}
         onExcludeUserPagesSwitched={this.switchExcludeUserPagesHandler}
         onExcludeTrashPagesSwitched={this.switchExcludeTrashPagesHandler}
         excludeUserPages={this.state.excludeUserPages}
@@ -251,6 +316,11 @@ class SearchPage extends React.Component {
           initialPagingLimit={this.props.appContainer.config.pageLimitationL || 50}
         >
         </SearchPageLayout>
+        <PageDeleteModal
+          isOpen={this.state.isDeleteConfirmModalShown}
+          onClose={this.closeDeleteConfirmModalHandler}
+          pages={this.getSelectedPagesToDelete()}
+        />
       </div>
     );
   }

+ 0 - 91
packages/app/src/components/SearchPage/DeletePageListModal.jsx

@@ -1,91 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-class DeletePageListModal extends React.Component {
-
-  /*
-   * the threshold for omitting body
-   */
-  static get OMIT_BODY_THRES() { return 400 }
-
-  componentWillMount() {
-  }
-
-  render() {
-    const { t } = this.props;
-    if (this.props.pages == null || this.props.pages.length === 0) {
-      return <div></div>;
-    }
-
-    const listView = this.props.pages.map((page) => {
-      return (
-        <li key={page._id}>{page.path}</li>
-      );
-    });
-
-    return (
-      <Modal isOpen={this.props.isShown} toggle={this.props.cancel} className="page-list-delete-modal">
-        <ModalHeader tag="h4" toggle={this.props.cancel} className="bg-danger text-light">
-          {t('search_result.deletion_modal_header')}
-        </ModalHeader>
-        <ModalBody>
-          <ul>
-            {listView}
-          </ul>
-        </ModalBody>
-        <ModalFooter>
-          <div className="d-flex justify-content-between">
-            <span className="text-danger">{this.props.errorMessage}</span>
-            <span className="d-flex align-items-center">
-              <div className="custom-control custom-checkbox custom-checkbox-danger mr-2">
-                <input
-                  type="checkbox"
-                  className="custom-control-input"
-                  id="customCheck-delete-completely"
-                  checked={this.props.isDeleteCompletely}
-                  onChange={this.props.toggleDeleteCompletely}
-                />
-                <label
-                  className="custom-control-label text-danger"
-                  htmlFor="customCheck-delete-completely"
-                >
-                  {t('search_result.delete_completely')}
-                </label>
-              </div>
-              <Button color={this.props.isDeleteCompletely ? 'danger' : 'light'} onClick={this.props.confirmedToDelete}>
-                <i className="icon-trash"></i>
-                {t('search_result.delete')}
-              </Button>
-            </span>
-          </div>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-DeletePageListModal.defaultProps = {
-  isDeleteCompletely: false, // for when undefined is passed
-};
-
-DeletePageListModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  isShown: PropTypes.bool.isRequired,
-  pages: PropTypes.array,
-  errorMessage: PropTypes.string,
-  cancel: PropTypes.func.isRequired, //                 for cancel evnet handling
-  isDeleteCompletely: PropTypes.bool,
-  confirmedToDelete: PropTypes.func.isRequired, //      for confirmed event handling
-  toggleDeleteCompletely: PropTypes.func.isRequired, // for delete completely check event handling
-};
-
-export default withTranslation()(DeletePageListModal);

+ 20 - 26
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -1,53 +1,49 @@
 import React, { FC } from 'react';
 import { useTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
 import { CheckboxType } from '../../interfaces/search';
 
-const logger = loggerFactory('growi:searchResultList');
-
 type Props = {
-  checkboxState: CheckboxType,
-  onClickInvoked?: () => void,
-  onCheckInvoked?: (string:CheckboxType) => void,
+  isSelectAllCheckboxDisabled: boolean,
+  selectAllCheckboxType: CheckboxType,
+  onClickDeleteAllButton?: () => void,
+  onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
 }
 
 const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
   const { t } = useTranslation();
   const {
-    checkboxState, onClickInvoked, onCheckInvoked,
+    onClickDeleteAllButton, onClickSelectAllCheckbox, selectAllCheckboxType,
   } = props;
 
-  const changeCheckboxStateHandler = () => {
-    console.log(`changeCheckboxStateHandler is called. current changebox state is ${checkboxState}`);
-    // Todo: determine next checkboxState from one of the following and tell the parent component
-    // to change the checkboxState by passing onCheckInvoked function the next checkboxState
-    // - NONE_CHECKED
-    // - INDETERMINATE
-    // - ALL_CHECKED
-    // https://estoc.weseek.co.jp/redmine/issues/77525
-    // use CheckboxType by importing from packages/app/src/interfaces/
-    if (onCheckInvoked == null) { logger.error('onCheckInvoked is null') }
-    else { onCheckInvoked(CheckboxType.ALL_CHECKED) } // change this to an appropriate value
+  const onClickCheckbox = () => {
+    if (onClickSelectAllCheckbox != null) {
+      const next = selectAllCheckboxType === CheckboxType.ALL_CHECKED ? CheckboxType.NONE_CHECKED : CheckboxType.ALL_CHECKED;
+      onClickSelectAllCheckbox(next);
+    }
   };
 
+  const onClickDeleteButton = () => {
+    if (onClickDeleteAllButton != null) { onClickDeleteAllButton() }
+  };
 
   return (
+
     <div className="d-flex align-items-center">
+      {/** todo: implement the design for CheckboxType = INDETERMINATE */}
       <input
         id="check-all-pages"
         type="checkbox"
         name="check-all-pages"
         className="custom-control custom-checkbox ml-1 align-self-center"
-        onChange={changeCheckboxStateHandler}
-        checked={checkboxState === CheckboxType.INDETERMINATE || checkboxState === CheckboxType.ALL_CHECKED}
+        disabled={props.isSelectAllCheckboxDisabled}
+        onClick={onClickCheckbox}
+        checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}
       />
       <button
         type="button"
         className="btn text-danger font-weight-light p-0 ml-2"
-        onClick={() => {
-          if (onClickInvoked == null) { logger.error('onClickInvoked is null') }
-          else { onClickInvoked() }
-        }}
+        disabled={selectAllCheckboxType === CheckboxType.NONE_CHECKED}
+        onClick={onClickDeleteButton}
       >
         <i className="icon-trash"></i>
         {t('search_result.delete_all_selected_page')}
@@ -57,6 +53,4 @@ const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
 
 };
 
-DeleteSelectedPageGroup.propTypes = {
-};
 export default DeleteSelectedPageGroup;

+ 9 - 18
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -9,6 +9,10 @@ import { CheckboxType } from '../../interfaces/search';
 type Props = {
   searchingKeyword: string,
   appContainer: AppContainer,
+  searchResultCount: number,
+  selectAllCheckboxType: CheckboxType,
+  onClickDeleteAllButton?: () => void
+  onClickSelectAllCheckbox?: (nextSelectAllCheckboxType: CheckboxType) => void,
   excludeUserPages: boolean,
   excludeTrashPages: boolean,
   onSearchInvoked: (data: {keyword: string}) => boolean,
@@ -23,6 +27,7 @@ const SearchControl: FC <Props> = (props: Props) => {
   // later needs to be fixed: SearchControl to typescript componet
   const SearchPageFormTypeAny : any = SearchPageForm;
   const { t } = useTranslation('');
+  const { searchResultCount } = props;
 
   const switchExcludeUserPagesHandler = () => {
     if (props.onExcludeUserPagesSwitched != null) {
@@ -36,21 +41,6 @@ const SearchControl: FC <Props> = (props: Props) => {
     }
   };
 
-  const onDeleteSelectedPageHandler = () => {
-    console.log('onDeleteSelectedPageHandler is called');
-    // TODO: implement this function to delete selected pages.
-    // https://estoc.weseek.co.jp/redmine/issues/77525
-  };
-
-  const onCheckAllPagesInvoked = (nextCheckboxState:CheckboxType) => {
-    console.log(`onCheckAllPagesInvoked is called with arg ${nextCheckboxState}`);
-    // Todo: set the checkboxState, isChecked, and indeterminate value of checkbox element according to the passed argument
-    // https://estoc.weseek.co.jp/redmine/issues/77525
-
-    // setting checkbox to indeterminate is required to use of useRef to access checkbox element.
-    // ref: https://getbootstrap.com/docs/4.5/components/forms/#checkboxes
-  };
-
   const openSearchOptionModalHandler = () => {
     setIsFileterOptionModalShown(true);
   };
@@ -99,9 +89,10 @@ const SearchControl: FC <Props> = (props: Props) => {
         <div className="d-flex mr-auto ml-3">
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           <DeleteSelectedPageGroup
-            checkboxState={'' || CheckboxType.NONE_CHECKED} // Todo: change the left value to appropriate value
-            onClickInvoked={onDeleteSelectedPageHandler}
-            onCheckInvoked={onCheckAllPagesInvoked}
+            isSelectAllCheckboxDisabled={searchResultCount === 0}
+            selectAllCheckboxType={props.selectAllCheckboxType}
+            onClickDeleteAllButton={props.onClickDeleteAllButton}
+            onClickSelectAllCheckbox={props.onClickSelectAllCheckbox}
           />
         </div>
         {/** filter option */}

+ 21 - 21
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -26,30 +26,30 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
   return (
     <div className="content-main">
       <div className="search-result row" id="search-result">
-        <div className="col-lg-6  page-list border boder-gray search-result-list px-0" id="search-result-list">
-
+        <div className="col-lg-6 page-list border boder-gray search-result-list px-0" id="search-result-list">
           <nav><SearchControl></SearchControl></nav>
-          <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
-            <div className="search-result-meta text-nowrap mr-3">
-              <span className="font-weight-light">{t('search_result.result_meta')} </span>
-              <span className="h5">{`"${searchingKeyword}"`}</span>
-              {/* Todo: replace "1-10" to the appropriate value */}
-              <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
-            </div>
-            <div className="input-group search-result-select-group">
-              <div className="input-group-prepend">
-                <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+          <div className="search-result-list-scroll">
+            <div className="d-flex align-items-center justify-content-between mt-1 mb-3">
+              <div className="search-result-meta text-nowrap mr-3">
+                <span className="font-weight-light">{t('search_result.result_meta')} </span>
+                <span className="h5">{`"${searchingKeyword}"`}</span>
+                {/* Todo: replace "1-10" to the appropriate value */}
+                <span className="ml-3">1-10 / {searchResultMeta.total || 0}</span>
+              </div>
+              <div className="input-group search-result-select-group">
+                <div className="input-group-prepend">
+                  <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
+                </div>
+                <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
+                  {[20, 50, 100, 200].map((limit) => {
+                    return <option selected={limit === props.initialPagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                  })}
+                </select>
               </div>
-              <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
-                {[20, 50, 100, 200].map((limit) => {
-                  return <option selected={limit === props.initialPagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
-                })}
-              </select>
             </div>
-          </div>
-
-          <div className="page-list">
-            <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+            <div className="page-list">
+              <ul className="page-list-ul page-list-ul-flat nav nav-pills"><SearchResultList></SearchResultList></ul>
+            </div>
           </div>
         </div>
         <div className="col-lg-6 d-none d-lg-block search-result-content">

+ 15 - 6
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -6,27 +6,36 @@ import { IPageSearchResultData } from '../../interfaces/search';
 
 type Props = {
   pages: IPageSearchResultData[],
-  selectedPages: IPageSearchResultData[],
-  onClickInvoked?: (pageId: string) => void,
+  selectedPagesIdList: Set<string>
   searchResultCount?: number,
   activePage?: number,
   pagingLimit?: number,
-  onPagingNumberChanged?: (activePage: number) => void,
   focusedSearchResultData?: IPageSearchResultData,
+  onPagingNumberChanged?: (activePage: number) => void,
+  onClickSearchResultItem?: (pageId: string) => void,
+  onClickCheckbox?: (pageId: string) => void,
+  onClickInvoked?: (pageId: string) => void,
+  onClickDeleteButton?: (pageId: string) => void,
 }
 
 const SearchResultList: FC<Props> = (props:Props) => {
-  const { focusedSearchResultData } = props;
+  const { focusedSearchResultData, selectedPagesIdList } = props;
+
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
     <>
-      {props.pages.map((page) => {
+      {Array.isArray(props.pages) && props.pages.map((page) => {
+        const isChecked = selectedPagesIdList.has(page.pageData._id);
+
         return (
           <SearchResultListItem
             key={page.pageData._id}
             page={page}
-            onClickInvoked={props.onClickInvoked}
+            onClickSearchResultItem={props.onClickSearchResultItem}
+            onClickCheckbox={props.onClickCheckbox}
+            isChecked={isChecked}
             isSelected={page.pageData._id === focusedPageId || false}
+            onClickDeleteButton={props.onClickDeleteButton}
           />
         );
       })}

+ 23 - 68
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -2,76 +2,27 @@ import React, { FC } from 'react';
 
 import Clamp from 'react-multiline-clamp';
 
-import { useTranslation } from 'react-i18next';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
-import { IPageSearchResultData } from '../../interfaces/search';
-
-import { IPageHasId } from '~/interfaces/page';
-
-type PageItemControlProps = {
-  page: IPageHasId,
-}
-
-const PageItemControl: FC<PageItemControlProps> = (props: {page: IPageHasId}) => {
-
-  const { page } = props;
-  const { t } = useTranslation('');
-
-  return (
-    <>
-      <button
-        type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
-      >
-        <i className="fa fa-ellipsis-v text-muted"></i>
-      </button>
-      <div className="dropdown-menu dropdown-menu-right">
 
-        {/* TODO: if there is the following button in XD add it here
-        <button
-          type="button"
-          className="btn btn-link p-0"
-          value={page.path}
-          onClick={(e) => {
-            window.location.href = e.currentTarget.value;
-          }}
-        >
-          <i className="icon-login" />
-        </button>
-        */}
-
-        {/*
-          TODO: add function to the following buttons like using modal or others
-          ref: https://estoc.weseek.co.jp/redmine/issues/79026
-        */}
-        <button className="dropdown-item text-danger" type="button" onClick={() => console.log('delete modal show')}>
-          <i className="icon-fw icon-fire"></i>{t('Delete')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => console.log('duplicate modal show')}>
-          <i className="icon-fw icon-star"></i>{t('Add to bookmark')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => console.log('duplicate modal show')}>
-          <i className="icon-fw icon-docs"></i>{t('Duplicate')}
-        </button>
-        <button className="dropdown-item" type="button" onClick={() => console.log('rename function will be added')}>
-          <i className="icon-fw  icon-action-redo"></i>{t('Move/Rename')}
-        </button>
-      </div>
-    </>
-  );
+import { IPageSearchResultData } from '../../interfaces/search';
+import PageItemControl from '../Common/Dropdown/PageItemControl';
 
-};
 
 type Props = {
   page: IPageSearchResultData,
   isSelected: boolean,
-  onClickInvoked?: (pageId: string) => void,
+  isChecked: boolean,
+  onClickCheckbox?: (pageId: string) => void,
+  onClickSearchResultItem?: (pageId: string) => void,
+  onClickDeleteButton?: (pageId: string) => void,
 }
 
 const SearchResultListItem: FC<Props> = (props:Props) => {
-  const { page: { pageData, pageMeta }, isSelected } = props;
+  const {
+    // todo: refactoring variable name to clear what changed
+    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked,
+  } = props;
 
   // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
   const pageId = `#${pageData._id}`;
@@ -86,23 +37,27 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
     />
   );
 
-  const onClickInvoked = (pageId) => {
-    if (props.onClickInvoked != null) {
-      props.onClickInvoked(pageId);
-    }
-  };
-
   return (
     <li key={pageData._id} className={`page-list-li search-page-item w-100 border-bottom px-4 list-group-item-action ${isSelected ? 'active' : ''}`}>
       <a
         className="d-block pt-3"
         href={pageId}
-        onClick={() => onClickInvoked(pageData._id)}
+        onClick={() => onClickSearchResultItem != null && onClickSearchResultItem(pageData._id)}
       >
         <div className="d-flex">
           {/* checkbox */}
           <div className="form-check my-auto mr-3">
-            <input className="form-check-input my-auto" type="checkbox" value="" id="flexCheckDefault" />
+            <input
+              className="form-check-input my-auto"
+              type="checkbox"
+              id="flexCheckDefault"
+              onChange={() => {
+                if (onClickCheckbox != null) {
+                  onClickCheckbox(pageData._id);
+                }
+              }}
+              checked={isChecked}
+            />
           </div>
           <div className="w-100">
             {/* page path */}
@@ -122,7 +77,7 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               </div>
               {/* doropdown icon includes page control buttons */}
               <div className="ml-auto">
-                <PageItemControl page={pageData} />
+                <PageItemControl page={pageData} onClickDeleteButton={props.onClickDeleteButton} />
               </div>
             </div>
             <div className="my-2">

+ 2 - 2
packages/app/src/components/SearchTypeahead.jsx

@@ -176,7 +176,7 @@ class SearchTypeahead extends React.Component {
   }
 
   renderMenuItemChildren(option, props, index) {
-    const page = option;
+    const page = option.pageData;
     return (
       <span>
         <UserPicture user={page.lastUpdateUser} size="sm" noLink />
@@ -205,7 +205,7 @@ class SearchTypeahead extends React.Component {
           ref={(c) => { this.typeahead = c }}
           inputProps={inputProps}
           isLoading={this.state.isLoading}
-          labelKey="path"
+          labelKey={data => data?.pageData?.path || this.props.keywordOnInit || ''} // https://github.com/ericgio/react-bootstrap-typeahead/blob/master/docs/Rendering.md#labelkey-stringfunction
           minLength={0}
           options={this.state.pages} // Search result (Some page names)
           promptText={this.props.helpElement}

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

@@ -1,11 +1,15 @@
 import React, {
-  useCallback, useState, FC, useEffect,
+  useCallback, useState, FC, useEffect, memo,
 } from 'react';
 import nodePath from 'path';
+import { useTranslation } from 'react-i18next';
 
 import { ItemNode } from './ItemNode';
+import { IPageHasId } from '~/interfaces/page';
 import { useSWRxPageChildren } from '../../../stores/page-listing';
 import { usePageId } from '../../../stores/context';
+import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import PageItemControl from '../../Common/Dropdown/PageItemControl';
 
 
 interface ItemProps {
@@ -25,38 +29,59 @@ const markTarget = (children: ItemNode[], targetId: string): void => {
   return;
 };
 
-const ItemContol: FC = () => {
+type ItemControlProps = {
+  page: Partial<IPageHasId>
+  onClickDeleteButtonHandler?(): void
+  onClickPlusButtonHandler?(): void
+}
+
+const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
+  const onClickPlusButton = () => {
+    if (props.onClickPlusButtonHandler == null) {
+      return;
+    }
+
+    props.onClickPlusButtonHandler();
+  };
+
+  const onClickDeleteButton = () => {
+    if (props.onClickDeleteButtonHandler == null) {
+      return;
+    }
+
+    props.onClickDeleteButtonHandler();
+  };
+
+  if (props.page == null) {
+    return <></>;
+  }
+
   return (
     <>
+      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} />
       <button
         type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
-      >
-        <i className="icon-options-vertical text-muted"></i>
-      </button>
-      <button
-        type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
-        data-toggle="dropdown"
+        className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
+        onClick={onClickPlusButton}
       >
         <i className="icon-plus text-muted"></i>
       </button>
     </>
   );
-};
+});
 
 const ItemCount: FC = () => {
   return (
     <>
       <span className="grw-pagetree-count badge badge-pill badge-light">
-        10
+        {/* TODO: consider to show the number of children pages */}
       </span>
     </>
   );
 };
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
+  const { t } = useTranslation();
   const { itemNode, isOpen: _isOpen = false } = props;
 
   const { page, children } = itemNode;
@@ -64,6 +89,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
 
+  const [isNewPageInputShown, setNewPageInputShown] = useState(false);
+
   const { data: targetId } = usePageId();
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
@@ -75,6 +102,26 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     setIsOpen(!isOpen);
   }, [isOpen]);
 
+  const onClickDeleteButtonHandler = useCallback(() => {
+    console.log('Show delete modal');
+  }, []);
+
+  const inputValidator = (title: string | null): AlertInfo | null => {
+    if (title == null || title === '') {
+      return {
+        type: AlertType.ERROR,
+        message: t('Page title is required'),
+      };
+    }
+
+    return null;
+  };
+
+  // TODO: go to create page page
+  const onPressEnterHandler = () => {
+    console.log('Enter key was pressed!');
+  };
+
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
@@ -124,9 +171,21 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <ItemCount />
         </div>
         <div className="grw-pagetree-control d-none">
-          <ItemContol />
+          <ItemControl
+            page={page}
+            onClickDeleteButtonHandler={onClickDeleteButtonHandler}
+            onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
+          />
         </div>
       </div>
+
+      <ClosableTextInput
+        isShown={isNewPageInputShown}
+        placeholder={t('Input title')}
+        onClickOutside={() => { setNewPageInputShown(false) }}
+        onPressEnter={onPressEnterHandler}
+        inputValidator={inputValidator}
+      />
       {
         isOpen && hasChildren() && currentChildren.map(node => (
           <Item

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

@@ -1,17 +1,16 @@
 import React, { FC } from 'react';
 
-import { IPage } from '../../../interfaces/page';
+import { IPageHasId } 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 generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPageHasId>[]): ItemNode => {
   const nodes = targetAndAncestors.map((page): ItemNode => {
     return new ItemNode(page, []);
   });
@@ -25,7 +24,7 @@ const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPage>[])
   return rootNode;
 };
 
-const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPage & HasObjectId>[]>, rootNode: ItemNode): ItemNode => {
+const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPageHasId>[]>, rootNode: ItemNode): ItemNode => {
   const paths = Object.keys(ancestorsChildren);
 
   let currentNode = rootNode;

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

@@ -18,13 +18,11 @@ const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
     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);
     }

+ 1 - 10
packages/app/src/components/StickyStretchableScroller.jsx

@@ -5,8 +5,6 @@ import { debounce } from 'throttle-debounce';
 import StickyEvents from 'sticky-events';
 import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 const logger = loggerFactory('growi:cli:StickyStretchableScroller');
 
 
@@ -103,7 +101,7 @@ const StickyStretchableScroller = (props) => {
 
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
-    resetScrollbar();
+    setTimeout(resetScrollbar, 100);
   }, [resetScrollbar]);
 
   // setup effect by sticky event
@@ -139,13 +137,6 @@ const StickyStretchableScroller = (props) => {
     };
   }, [resetScrollbarDebounced]);
 
-  // setup effect by isScrollTop
-  // useEffect(() => {
-  //   if (navigationContainer.state.isScrollTop) {
-  //     resetScrollbar();
-  //   }
-  // }, [navigationContainer.state.isScrollTop, resetScrollbar]);
-
   // setup effect by update props
   useEffect(() => {
     resetScrollbarDebounced();

+ 6 - 6
packages/app/src/components/TableOfContents.jsx

@@ -5,7 +5,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import PageContainer from '~/client/services/PageContainer';
-import NavigationContainer from '~/client/services/NavigationContainer';
+import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import { blinkElem } from '~/client/util/blink-section-header';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -20,7 +21,7 @@ const logger = loggerFactory('growi:TableOfContents');
  */
 const TableOfContents = (props) => {
 
-  const { t, pageContainer, navigationContainer } = props;
+  const { t, pageContainer } = props;
   const { pageUser } = pageContainer.state;
   const isUserPage = pageUser != null;
 
@@ -50,8 +51,8 @@ const TableOfContents = (props) => {
   useEffect(() => {
     const tocDom = document.getElementById('revision-toc-content');
     const anchorsInToc = Array.from(tocDom.getElementsByTagName('a'));
-    navigationContainer.addSmoothScrollEvent(anchorsInToc);
-  }, [tocHtml, navigationContainer]);
+    addSmoothScrollEvent(anchorsInToc, blinkElem);
+  }, [tocHtml]);
 
   return (
     <StickyStretchableScroller
@@ -85,13 +86,12 @@ const TableOfContents = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer, NavigationContainer]);
+const TableOfContentsWrapper = withUnstatedContainers(TableOfContents, [PageContainer]);
 
 TableOfContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 
 export default withTranslation()(TableOfContentsWrapper);

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

@@ -31,6 +31,6 @@ export type IPage = {
   deletedAt: Date,
 }
 
-export type IPageForItem = Partial<IPage & {isTarget?: boolean} & HasObjectId>;
-
 export type IPageHasId = IPage & HasObjectId;
+
+export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;

+ 8 - 1
packages/app/src/server/models/obsolete-page.js

@@ -234,7 +234,14 @@ export class PageQueryBuilder {
 
   addConditionAsMigrated() {
     this.query = this.query
-      .and({ parent: { $ne: null } });
+      .and(
+        {
+          $or: [
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      );
 
     return this;
   }

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

@@ -271,6 +271,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find());
   const _targetAndAncestors: PageDocument[] = await queryBuilder
+    .addConditionAsMigrated()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortAncestorPages()
@@ -314,6 +315,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }));
   await addViewerCondition(queryBuilder, user, userGroups);
   const _pages = await queryBuilder
+    .addConditionAsMigrated()
     .addConditionToMinimizeDataForRendering()
     .query
     .lean()

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

@@ -186,7 +186,6 @@ module.exports = (crowi) => {
     ],
     v5PageMigration: [
       body('action').isString().withMessage('action is required'),
-      body('pageIds').isArray().withMessage('pageIds must be an array'),
     ],
   };
 

+ 23 - 4
packages/app/src/server/service/page.js

@@ -1,4 +1,5 @@
 import { pagePathUtils } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
@@ -780,6 +781,7 @@ class PageService {
     const Page = mongoose.model('Page');
 
     if (pageIds == null || pageIds.length === 0) {
+      logger.error('pageIds is null or 0 length.');
       return;
     }
 
@@ -918,6 +920,7 @@ class PageService {
     const batchStream = createBatchStream(BATCH_SIZE);
 
     let countPages = 0;
+    let shouldContinue = true;
 
     // migrate all siblings for each page
     const migratePagesStream = new Writable({
@@ -943,7 +946,11 @@ class PageService {
           const parentId = parent._id;
 
           // modify to adjust for RegExp
-          const parentPath = parent.path === '/' ? '' : parent.path;
+          let parentPath = parent.path === '/' ? '' : parent.path;
+          // inject \ before brackets
+          ['(', ')', '[', ']', '{', '}'].forEach((bracket) => {
+            parentPath = parentPath.replace(bracket, `\\${bracket}`);
+          });
 
           return {
             updateMany: {
@@ -960,8 +967,20 @@ class PageService {
         });
         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)`);
+          countPages += res.result.nModified;
+          logger.info(`Page migration processing: (count=${countPages})`);
+
+          // throw
+          if (res.result.writeErrors.length > 0) {
+            logger.error('Failed to migrate some pages', res.result.writeErrors);
+            throw Error('Failed to migrate some pages');
+          }
+
+          // finish migration
+          if (res.result.nModified === 0) { // TODO: find the best property to count updated documents
+            shouldContinue = false;
+            logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
+          }
         }
         catch (err) {
           logger.error('Failed to update page.parent.', err);
@@ -981,7 +1000,7 @@ class PageService {
 
     await streamToPromise(migratePagesStream);
 
-    if (await Page.exists(filter)) {
+    if (await Page.exists(filter) && shouldContinue) {
       return this._v5RecursiveMigration(grant, regexps);
     }
 

+ 1 - 10
packages/app/src/server/service/slack-command-handler/search.js

@@ -3,7 +3,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:service:SlackCommandHandler:search');
 
 const {
-  markdownSectionBlock, divider,
+  markdownSectionBlock, divider, generateLastUpdateMrkdwn,
 } = require('@growi/slack');
 const { formatDistanceStrict } = require('date-fns');
 
@@ -36,15 +36,6 @@ module.exports = (crowi) => {
     return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
   }
 
-  function generateLastUpdateMrkdwn(updatedAt, baseDate) {
-    if (updatedAt != null) {
-      // cast to date
-      const date = new Date(updatedAt);
-      return formatDistanceStrict(date, baseDate);
-    }
-    return '';
-  }
-
   async function retrieveSearchResults(growiCommandArgs, offset = 0) {
     const keywords = getKeywords(growiCommandArgs);
 

+ 5 - 7
packages/app/src/server/service/slack-event-handler/link-shared.ts

@@ -1,9 +1,8 @@
 import urljoin from 'url-join';
-import { format } from 'date-fns';
 import {
   MessageAttachment, LinkUnfurls, WebClient,
 } from '@slack/web-api';
-import { GrowiBotEvent } from '@growi/slack';
+import { GrowiBotEvent, generateLastUpdateMrkdwn } from '@growi/slack';
 import { SlackEventHandler } from './base-event-handler';
 import {
   DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent,
@@ -84,18 +83,17 @@ export class LinkSharedEventHandler implements SlackEventHandler<UnfurlRequestEv
 
   // builder method for unfurl parameter
   generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls {
-    const { pageBody: text, updatedAt, commentCount } = body;
+    const { pageBody: text, updatedAt } = body;
 
+    const appTitle = this.crowi.appService.getAppTitle();
     const siteUrl = this.crowi.appService.getSiteUrl();
 
-    const updatedAtFormatted = format(updatedAt, 'yyyy-MM-dd HH:mm');
-    const footer = `URL: ${siteUrl}  Updated at: ${updatedAtFormatted}`;
-
     const attachment: MessageAttachment = {
       title: body.path,
       title_link: toUrl, // permalink
       text,
-      footer,
+      footer: `<${decodeURI(siteUrl)}|*${appTitle}*>`
+      + `  |  Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``,
     };
 
     const unfurls: LinkUnfurls = {

+ 71 - 41
packages/app/src/stores/context.tsx

@@ -1,4 +1,6 @@
-import { SWRResponse } from 'swr';
+import { Key, SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
 import { pagePathUtils } from '@growi/core';
 
 import { IUser } from '../interfaces/user';
@@ -10,123 +12,151 @@ import { TargetAndAncestors } from '../interfaces/page-listing-results';
 type Nullable<T> = T | null;
 
 export const useCurrentUser = (initialData?: IUser): SWRResponse<Nullable<IUser>, Error> => {
-  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData || null);
+  return useStaticSWR<Nullable<IUser>, Error>('currentUser', initialData ?? null);
 };
 
 export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionId', initialData ?? null);
 };
 
 export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData ?? null);
 };
 
-export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
-  const { data: currentUser } = useCurrentUser();
-  const { data: currentPagePath } = useCurrentPagePath();
-
-  const isLoading = currentUser === undefined || currentPagePath === undefined;
-
-  const key = isLoading ? null : 'isSharedUser';
-  const value = !isLoading && currentUser == null && pagePathUtils.isSharedPage(currentPagePath as string);
-
-  return useStaticSWR(key, value);
-};
 
 export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageId', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('pageId', initialData ?? null);
 };
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionCreatedAt', initialData ?? null);
 };
 
 export const useCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('createdAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('createdAt', initialData ?? null);
 };
 
 export const useUpdatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('updatedAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('updatedAt', initialData ?? null);
 };
 
 export const useDeletedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('deletedAt', initialData ?? null);
 };
 
 export const useIsUserPage = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isUserPage', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isUserPage', initialData ?? null);
 };
 
-export const useIsTrashPage = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isTrashPage', initialData || null);
+export const useIsTrashPage = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isTrashPage', initialData ?? null);
 };
 
 export const useIsDeleted = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isDeleted', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isDeleted', initialData ?? null);
 };
 
 export const useIsDeletable = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isDeletable', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isDeletable', initialData ?? null);
 };
 
-export const useIsNotCreatable = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isNotCreatable', initialData || null);
+export const useIsNotCreatable = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isNotCreatable', initialData ?? null);
 };
 
 export const useIsAbleToDeleteCompletely = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isAbleToDeleteCompletely', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isAbleToDeleteCompletely', initialData ?? null);
 };
 
 export const useIsPageExist = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('isPageExist', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('isPageExist', initialData ?? null);
 };
 
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageUser', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('pageUser', initialData ?? null);
 };
 
 export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData ?? null);
 };
 
 export const useTemplateTagData = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData ?? null);
 };
 
 export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData ?? null);
 };
 
 export const useShareLinkId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('shareLinkId', initialData ?? null);
 };
 
 export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData ?? null);
 };
 
 export const useLastUpdateUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('lastUpdateUsername', initialData ?? null);
 };
 
 export const useDeleteUsername = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('deleteUsername', initialData ?? null);
 };
 
 export const usePageIdOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('pageIdOnHackmd', initialData ?? null);
 };
 
 export const useHasDraftOnHackmd = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('hasDraftOnHackmd', initialData ?? null);
 };
 
 export const useCreator = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('creator', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('creator', initialData ?? null);
 };
 
 export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData || null);
+  return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData ?? null);
+};
+
+
+/** **********************************************************
+ *                     Computed contexts
+ *********************************************************** */
+
+export const useIsGuestUser = (): SWRResponse<boolean, Error> => {
+  const { data: currentUser } = useCurrentUser();
+
+  return useSWRImmutable(
+    ['isGuestUser', currentUser],
+    (key: Key, currentUser: IUser) => currentUser == null,
+  );
+};
+
+export const useIsEditable = (): SWRResponse<boolean, Error> => {
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isNotCreatable } = useIsNotCreatable();
+  const { data: isTrashPage } = useIsTrashPage();
+
+  return useSWRImmutable(
+    ['isEditable', isGuestUser, isTrashPage, isNotCreatable],
+    (key: Key, isGuestUser: boolean, isTrashPage: boolean, isNotCreatable: boolean) => {
+      return (!isNotCreatable && !isTrashPage && !isGuestUser);
+    },
+  );
+};
+
+export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  return useSWRImmutable(
+    ['isSharedUser', isGuestUser, currentPagePath],
+    (key: Key, isGuestUser: boolean, currentPagePath: string) => {
+      return isGuestUser && pagePathUtils.isSharedPage(currentPagePath as string);
+    },
+  );
 };
 
 export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResponse<TargetAndAncestors, Error> => {

+ 6 - 7
packages/app/src/stores/page.tsx

@@ -1,16 +1,15 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { HasObjectId } from '~/interfaces/has-object-id';
 
-import { IPage } from '~/interfaces/page';
+import { IPageHasId } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
 
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 import { IPageInfo } from '../interfaces/page-info';
 
-export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRResponse<IPage & HasObjectId, Error> => {
+export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
   return useSWR(
     ['/page', path],
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
@@ -22,10 +21,10 @@ export const useSWRxPageByPath = (path: string, initialData?: IPage): SWRRespons
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-export const useSWRxRecentlyUpdated = (): SWRResponse<(IPage & HasObjectId)[], Error> => {
+export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> => {
   return useSWR(
     '/pages/recent',
-    endpoint => apiv3Get<{ pages:(IPage & HasObjectId)[] }>(endpoint).then(response => response.data?.pages),
+    endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
   );
 };
 
@@ -33,11 +32,11 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPage & HasObjectId)[], E
 export const useSWRxPageList = (
     path: string,
     pageNumber?: number,
-): SWRResponse<IPagingResult<IPage>, Error> => {
+): SWRResponse<IPagingResult<IPageHasId>, Error> => {
   const page = pageNumber || 1;
   return useSWR(
     `/pages/list?path=${path}&page=${page}`,
-    endpoint => apiv3Get<{pages: IPage[], totalCount: number, limit: number}>(endpoint).then((response) => {
+    endpoint => apiv3Get<{pages: IPageHasId[], totalCount: number, limit: number}>(endpoint).then((response) => {
       return {
         items: response.data.pages,
         totalCount: response.data.totalCount,

+ 120 - 52
packages/app/src/stores/ui.tsx

@@ -12,6 +12,7 @@ import loggerFactory from '~/utils/logger';
 import { sessionStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import { useCurrentPagePath, useIsEditable } from './context';
 
 const logger = loggerFactory('growi:stores:ui');
 
@@ -60,54 +61,86 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
 };
 
 
-const postChangeEditorModeMiddleware: Middleware = (useSWRNext) => {
-  return (...args) => {
-    // -- TODO: https://redmine.weseek.co.jp/issues/81817
-    const swrNext = useSWRNext(...args);
-    return {
-      ...swrNext,
-      mutate: (data, shouldRevalidate) => {
-        return swrNext.mutate(data, shouldRevalidate)
-          .then((value) => {
-            const newEditorMode = value as unknown as EditorMode;
-            switch (newEditorMode) {
-              case EditorMode.View:
-                $('body').removeClass('on-edit');
-                $('body').removeClass('builtin-editor');
-                $('body').removeClass('hackmd');
-                $('body').removeClass('pathname-sidebar');
-                window.history.replaceState(null, '', window.location.pathname);
-                break;
-              case EditorMode.Editor:
-                $('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';
-                break;
-              case EditorMode.HackMD:
-                $('body').addClass('on-edit');
-                $('body').addClass('hackmd');
-                $('body').removeClass('builtin-editor');
-                $('body').removeClass('pathname-sidebar');
-                window.location.hash = '#hackmd';
-                break;
-            }
-            return value;
-          });
-      },
-    };
-  };
+const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
+  switch (newEditorMode) {
+    case EditorMode.View:
+      $('body').removeClass('on-edit');
+      $('body').removeClass('builtin-editor');
+      $('body').removeClass('hackmd');
+      $('body').removeClass('pathname-sidebar');
+      window.history.replaceState(null, '', window.location.pathname);
+      break;
+    case EditorMode.Editor:
+      $('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';
+      break;
+    case EditorMode.HackMD:
+      $('body').addClass('on-edit');
+      $('body').addClass('hackmd');
+      $('body').removeClass('builtin-editor');
+      $('body').removeClass('pathname-sidebar');
+      window.location.hash = '#hackmd';
+      break;
+  }
 };
 
-export const useEditorMode = (editorMode?: EditorMode): SWRResponse<EditorMode, Error> => {
-  const key: Key = 'editorMode';
-  const initialData = EditorMode.View;
+export const useEditorModeByHash = (): SWRResponse<EditorMode, Error> => {
+  return useSWRImmutable(
+    ['initialEditorMode', window.location.hash],
+    (key: Key, hash: string) => {
+      switch (hash) {
+        case '#edit':
+          return EditorMode.Editor;
+        case '#hackmd':
+          return EditorMode.HackMD;
+        default:
+          return EditorMode.View;
+      }
+    },
+  );
+};
+
+let isEditorModeLoaded = false;
+export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
+  const { data: _isEditable } = useIsEditable();
+  const { data: editorModeByHash } = useEditorModeByHash();
+
+  const isLoading = _isEditable === undefined;
+  const isEditable = !isLoading && _isEditable;
+  const initialData = isEditable ? editorModeByHash : EditorMode.View;
 
-  return useStaticSWR(key, editorMode || null, { fallbackData: initialData, use: [postChangeEditorModeMiddleware] });
+  const swrResponse = useSWRImmutable(
+    isLoading ? null : ['editorMode', isEditable],
+    null,
+    { fallbackData: initialData },
+  );
+
+  // initial updating
+  if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
+    if (isEditable) {
+      updateBodyClassesForEditorMode(swrResponse.data);
+    }
+    isEditorModeLoaded = true;
+  }
+
+  return {
+    ...swrResponse,
+
+    // overwrite mutate
+    mutate: (editorMode: EditorMode, shouldRevalidate?: boolean) => {
+      if (!isEditable) {
+        return Promise.resolve(EditorMode.View); // fixed if not editable
+      }
+      updateBodyClassesForEditorMode(editorMode);
+      return swrResponse.mutate(editorMode, shouldRevalidate);
+    },
+  };
 };
 
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> => {
@@ -168,13 +201,11 @@ export const useDrawerMode = (): SWRResponse<boolean, Error> => {
     return isDeviceSmallerThanMd || preferDrawerMode;
   };
 
-  return useSWR(
-    condition ? [editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
+  return useSWRImmutable(
+    condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
     calcDrawerMode,
     {
       fallback: calcDrawerMode,
-      revalidateOnFocus: false,
-      revalidateOnReconnect: false,
     },
   );
 };
@@ -234,7 +265,44 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
 };
 
-export const usePageCreateModalOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
-  const initialData = false;
-  return useStaticSWR('isPageCreateModalOpened', isOpened || null, { fallbackData: initialData });
+type CreateModalStatus = {
+  isOpened: boolean,
+  path?: string,
+}
+
+type CreateModalStatusUtils = {
+  open(path?: string): Promise<CreateModalStatus | undefined>
+  close(): Promise<CreateModalStatus | undefined>
+}
+
+export const useCreateModalStatus = (status?: CreateModalStatus): SWRResponse<CreateModalStatus, Error> & CreateModalStatusUtils => {
+  const swrResponse = useStaticSWR<CreateModalStatus, Error>('modalStatus', status || null);
+
+  return {
+    ...swrResponse,
+    open: (path?: string) => swrResponse.mutate({ isOpened: true, path }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+export const useCreateModalOpened = (): SWRResponse<boolean, Error> => {
+  const { data } = useCreateModalStatus();
+  return useSWR(
+    data != null ? ['isModalOpened', data] : null,
+    () => {
+      return data != null ? data.isOpened : false;
+    },
+  );
+};
+
+export const useCreateModalPath = (): SWRResponse<string, Error> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: status } = useCreateModalStatus();
+
+  return useSWR(
+    [currentPagePath, status],
+    (currentPagePath, status) => {
+      return status.path || currentPagePath;
+    },
+  );
 };

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

@@ -1,26 +1,31 @@
+import assert from 'assert';
 import {
-  Key, SWRConfiguration, SWRResponse, mutate,
+  Key, SWRConfiguration, SWRResponse,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
-import { Fetcher } from 'swr/dist/types';
 
 
 export function useStaticSWR<Data, Error>(key: Key): SWRResponse<Data, Error>;
-export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null): SWRResponse<Data, Error>;
-export function useStaticSWR<Data, Error>(key: Key, data: Data | Fetcher<Data> | null,
+export function useStaticSWR<Data, Error>(key: Key, data: Data | null): SWRResponse<Data, Error>;
+export function useStaticSWR<Data, Error>(key: Key, data: Data | null,
   configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
 
 export function useStaticSWR<Data, Error>(
     ...args: readonly [Key]
-    | readonly [Key, Data | Fetcher<Data> | null]
-    | readonly [Key, Data | Fetcher<Data> | null, SWRConfiguration<Data, Error> | undefined]
+    | readonly [Key, Data | null]
+    | readonly [Key, Data | null, SWRConfiguration<Data, Error> | undefined]
 ): SWRResponse<Data, Error> {
-  const [key, fetcher, configuration] = args;
+  const [key, data, configuration] = args;
 
-  const fetcherFixed = fetcher || configuration?.fetcher;
-  if (fetcherFixed != null) {
-    mutate(key, fetcherFixed);
+  assert.notStrictEqual(configuration?.fetcher, null, 'useStaticSWR does not support \'configuration.fetcher\'');
+
+  const swrResponse = useSWRImmutable(key, null, configuration);
+
+  // mutate
+  if (data != null) {
+    const { mutate } = swrResponse;
+    mutate(data);
   }
 
-  return useSWRImmutable(key, null, configuration);
+  return swrResponse;
 }

+ 4 - 2
packages/app/src/styles/_search.scss

@@ -172,9 +172,11 @@
   .search-result-list {
     position: sticky;
     top: 0px;
-    height: 100vh;
-    overflow-y: scroll;
 
+    .search-result-list-scroll {
+      height: calc(100vh - 125px); // subtract the height of SearchControl component
+      overflow-y: scroll;
+    }
     .nav.nav-pills {
       > .page-list-li {
         > a {

+ 5 - 0
packages/app/src/styles/_subnav.scss

@@ -45,6 +45,11 @@
     border-radius: $border-radius-xl;
   }
 
+  .btn-bookmark {
+    display: flex;
+    align-items: center;
+  }
+
   .total-likes,
   .total-bookmarks {
     font-size: 17px;

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

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

+ 1 - 1
packages/slack/package.json

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

+ 1 - 0
packages/slack/src/index.ts

@@ -44,6 +44,7 @@ export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
+export * from './utils/generate-last-update-markdown';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
 export * from './utils/publish-initial-home-view';

+ 10 - 0
packages/slack/src/utils/generate-last-update-markdown.ts

@@ -0,0 +1,10 @@
+import { formatDistanceStrict } from 'date-fns';
+
+export function generateLastUpdateMrkdwn(updatedAt: string | Date | number, baseDate: Date): string {
+  if (updatedAt != null) {
+    // cast to date
+    const date = new Date(updatedAt);
+    return formatDistanceStrict(date, baseDate);
+  }
+  return '';
+}

+ 2 - 0
packages/slack/src/utils/required-scopes.ts

@@ -8,4 +8,6 @@ export const requiredScopes: string[] = [
   'groups:history',
   'im:history',
   'mpim:history',
+  'links:read',
+  'links:write',
 ];

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

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

+ 5 - 1
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -50,7 +50,11 @@ export class RelationsService {
 
   private async syncSupportedGrowiCommands(relation:Relation): Promise<Relation> {
     const res = await this.getSupportedGrowiCommands(relation);
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = res.data.data;
+
+    // support both of v4.4.x and v4.5.x
+    // see: https://redmine.weseek.co.jp/issues/82985
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = res.data.data ?? res.data;
+
     if (relation !== null) {
       relation.permissionsForBroadcastUseCommands = permissionsForBroadcastUseCommands;
       relation.permissionsForSingleUseCommands = permissionsForSingleUseCommands;

+ 1 - 1
packages/ui/package.json

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

+ 9 - 4
yarn.lock

@@ -6856,6 +6856,11 @@ detect-indent@^6.0.0:
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd"
   integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==
 
+detect-indent@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-7.0.0.tgz#cab58e6ab1129c669e2101181a6c677917d43577"
+  integrity sha512-/6kJlmVv6RDFPqaHC/ZDcU8bblYcoph2dUQ3kB47QqhkUEqXe3VZPELK9BaEMrC73qu+wn0AQ7iSteceN+yuMw==
+
 detect-libc@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
@@ -18528,10 +18533,10 @@ statuses@~1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
 
-sticky-events@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/sticky-events/-/sticky-events-3.1.3.tgz#7b6b4091988b87b9f4e711c7c6532de07ab156dd"
-  integrity sha512-nTm2bDaYTXFHAyQS59mWDRnnno/D8oj3C4JddOdipq6ZRnLLqjj+PeyCSbHPwMVdfvQoKwmMmAztp+YybDhvtA==
+sticky-events@^3.4.11:
+  version "3.4.11"
+  resolved "https://registry.yarnpkg.com/sticky-events/-/sticky-events-3.4.11.tgz#c44b7866648c5b2818a00fe93f709aa86e9a09d3"
+  integrity sha512-g1ex5lR7EGJv8EXJh4gdBu0m8FMgAVeqFAow3dRR9MwxAIfBNVC2GtlXI1z+oMLE+/Ot2At+gp1aO/tbUGoOnQ==
 
 stoppable@^1.1.0:
   version "1.1.0"