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

Conflict resolved

Merge branch 'master' of https://github.com/weseek/growi into imprv/gw7702-elasticsearch-switch-version

* 'master' of https://github.com/weseek/growi: (2217 commits)
  add '3' VRT tests in CI
  add VRT test for Mathjax
  remove empty line
  add empty line
  move VRT examples
  add vrt file
  fix lint errors
  imprv: access to trash page test
  Add visual regression test - Add test: access to admin page spec
  add test for checkboxes behaviors
  add VRT test
  add vrt tags page
  rename sentance
  rename sentence
  add draft vrt
  ok
  imprv: visit user page test
  add a new test for search page
  BugFix
  fix lint errors
  ...

# Conflicts:
#	packages/app/.env.development
LuqmanHakim-Grune 4 лет назад
Родитель
Сommit
09ceac6f7d
100 измененных файлов с 3745 добавлено и 2386 удалено
  1. 10 0
      .eslintrc.js
  2. 1 1
      .github/workflows/reusable-app-prod.yml
  3. 18 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 12 0
      packages/app/.eslintrc.js
  7. 2 2
      packages/app/bin/github-actions/update-readme.sh
  8. 2 0
      packages/app/config/logger/config.dev.js
  9. 4 2
      packages/app/docker/README.md
  10. 14 9
      packages/app/package.json
  11. 17 1
      packages/app/resource/locales/en_US/admin/admin.json
  12. 43 4
      packages/app/resource/locales/en_US/translation.json
  13. 16 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  14. 41 3
      packages/app/resource/locales/ja_JP/translation.json
  15. 16 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  16. 41 3
      packages/app/resource/locales/zh_CN/translation.json
  17. 3 0
      packages/app/resource/search/mappings-es6.json
  18. 20 17
      packages/app/src/client/app.jsx
  19. 12 0
      packages/app/src/client/base.jsx
  20. 13 0
      packages/app/src/client/interfaces/selectable-all.ts
  21. 18 0
      packages/app/src/client/services/AdminAppContainer.js
  22. 3 2
      packages/app/src/client/services/AdminHomeContainer.js
  23. 23 9
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  24. 33 17
      packages/app/src/client/services/ContextExtractor.tsx
  25. 0 54
      packages/app/src/client/services/PageAccessoriesContainer.js
  26. 5 110
      packages/app/src/client/services/PageContainer.js
  27. 62 0
      packages/app/src/client/services/page-operation.ts
  28. 2 2
      packages/app/src/client/util/smooth-scroll.ts
  29. 81 68
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  30. 25 2
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  31. 57 0
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  32. 61 0
      packages/app/src/components/Admin/App/V5PageMigrationModal.tsx
  33. 6 1
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  34. 1 1
      packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx
  35. 1 1
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  36. 0 118
      packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx
  37. 0 216
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  38. 219 0
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  39. 70 0
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  40. 119 0
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  41. 0 152
      packages/app/src/components/Admin/UserGroup/UserGroupPage.jsx
  42. 158 0
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  43. 0 157
      packages/app/src/components/Admin/UserGroup/UserGroupTable.jsx
  44. 187 0
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  45. 0 49
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  46. 179 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  47. 0 111
      packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  48. 2 2
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  49. 32 37
      packages/app/src/components/BookmarkButtons.tsx
  50. 127 0
      packages/app/src/components/Common/ClosableTextInput.tsx
  51. 259 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  52. 3 11
      packages/app/src/components/ComparePathsTable.jsx
  53. 0 96
      packages/app/src/components/ContentLinkButtons.jsx
  54. 66 0
      packages/app/src/components/ContentLinkButtons.tsx
  55. 3 13
      packages/app/src/components/CreateTemplateModal.jsx
  56. 22 4
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  57. 106 0
      packages/app/src/components/DescendantsPageList.tsx
  58. 100 0
      packages/app/src/components/DescendantsPageListModal.tsx
  59. 3 3
      packages/app/src/components/EventListeneres/HashChanged.tsx
  60. 4 3
      packages/app/src/components/Fab.jsx
  61. 20 17
      packages/app/src/components/ForbiddenPage.tsx
  62. 4 4
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  63. 2 1
      packages/app/src/components/Icons/AttachmentIcon.jsx
  64. 0 28
      packages/app/src/components/Icons/BookmarkIcon.jsx
  65. 2 1
      packages/app/src/components/Icons/HistoryIcon.jsx
  66. 1 1
      packages/app/src/components/Icons/ShareLinkIcon.jsx
  67. 17 0
      packages/app/src/components/Icons/TriangleIcon.tsx
  68. 127 0
      packages/app/src/components/IdenticalPathPage.tsx
  69. 1 2
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  70. 50 55
      packages/app/src/components/LikeButtons.tsx
  71. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  72. 9 5
      packages/app/src/components/Navbar/GlobalSearch.tsx
  73. 302 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  74. 8 4
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  75. 25 16
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  76. 0 164
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  77. 100 0
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  78. 4 2
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  79. 0 70
      packages/app/src/components/Navbar/SubNavButtons.jsx
  80. 208 0
      packages/app/src/components/Navbar/SubNavButtons.tsx
  81. 0 42
      packages/app/src/components/NotFoundPage.jsx
  82. 48 0
      packages/app/src/components/NotFoundPage.tsx
  83. 1 1
      packages/app/src/components/Page/CopyDropdown.jsx
  84. 0 87
      packages/app/src/components/Page/DisplaySwitcher.jsx
  85. 135 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  86. 12 12
      packages/app/src/components/Page/NotFoundAlert.tsx
  87. 38 32
      packages/app/src/components/Page/PageManagement.jsx
  88. 13 22
      packages/app/src/components/Page/RenderTagLabels.tsx
  89. 2 1
      packages/app/src/components/Page/RevisionBody.jsx
  90. 8 5
      packages/app/src/components/Page/RevisionLoader.jsx
  91. 60 10
      packages/app/src/components/Page/RevisionRenderer.jsx
  92. 0 119
      packages/app/src/components/Page/TagLabels.jsx
  93. 54 0
      packages/app/src/components/Page/TagLabels.tsx
  94. 12 16
      packages/app/src/components/Page/TrashPageAlert.jsx
  95. 0 44
      packages/app/src/components/PageAccessories.jsx
  96. 0 159
      packages/app/src/components/PageAccessoriesModal.jsx
  97. 134 0
      packages/app/src/components/PageAccessoriesModal.tsx
  98. 9 17
      packages/app/src/components/PageAccessoriesModalControl.jsx
  99. 14 7
      packages/app/src/components/PageCreateModal.jsx
  100. 0 157
      packages/app/src/components/PageDeleteModal.jsx

+ 10 - 0
.eslintrc.js

@@ -12,6 +12,7 @@ module.exports = {
   },
   plugins: [
     'jest',
+    'regex',
   ],
   rules: {
     'import/prefer-default-export': 'off',
@@ -30,5 +31,14 @@ module.exports = {
       'error',
       { additionalTestBlockFunctions: ['each.test'] },
     ],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
   },
 };

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

@@ -187,7 +187,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2']
+        spec-group: ['1', '2', '3']
 
     services:
       mongodb:

+ 18 - 1
CHANGELOG.md

@@ -1,9 +1,26 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.11...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.13...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v4.5.13](https://github.com/weseek/growi/compare/v4.5.12...v4.5.13) - 2022-02-08
+
+### 🐛 Bug Fixes
+
+- fix: fix: Sidebar collapsing (#5283) @yuki-takei
+
+## [v4.5.12](https://github.com/weseek/growi/compare/v4.5.11...v4.5.12) - 2022-02-01
+
+### 🚀 Improvement
+
+- imprv: Sidebar opening delay (for v4.5.x) (#5218) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: /_api/v3/page with pageId param occurs an 500 error (#5212) @yuki-takei
+- fix: Resolving OIDC issure host (#5220) @yuki-takei
+
 ## [v4.5.11](https://github.com/weseek/growi/compare/v4.5.10...v4.5.11) - 2022-01-26
 
 ### 🐛 Bug Fixes

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 12 - 0
packages/app/.eslintrc.js

@@ -3,6 +3,9 @@ module.exports = {
     'weseek/react',
     'weseek/typescript',
   ],
+  plugins: [
+    'regex',
+  ],
   env: {
     jquery: true,
   },
@@ -25,6 +28,15 @@ module.exports = {
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
     }],
+    'regex/invalid': ['error', [
+      {
+        regex: '\\?\\<\\!',
+        message: 'Do not use any negative lookbehind',
+      }, {
+        regex: '\\?\\<\\=',
+        message: 'Do not use any Positive lookbehind',
+      },
+    ]],
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

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

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

+ 2 - 0
packages/app/config/logger/config.dev.js

@@ -26,6 +26,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
+  'growi:service:search-delegator:elasticsearch': 'debug',
 
   /*
    * configure level for client
@@ -35,5 +36,6 @@ module.exports = {
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  'growi:searchResultList': 'debug',
 
 };

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

@@ -10,8 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.5.11`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
-* [`4.5.11-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.11/docker/Dockerfile)
+* [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
+* [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
+* [`4.5.13`, `4.5`, `4`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/docker/Dockerfile)
+* [`4.5.13-nocdn`, `4.5-nocdn`, `4-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.13/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)
 

+ 14 - 9
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.6.0-RC.0",
+  "version": "5.0.0-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -59,13 +59,13 @@
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^4.6.0-RC.0",
-    "@growi/plugin-attachment-refs": "^4.6.0-RC.0",
-    "@growi/plugin-lsx": "^4.6.0-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^4.6.0-RC.0",
-    "@growi/slack": "^4.6.0-RC.0",
-    "@promster/express": "^5.1.0",
-    "@promster/server": "^6.0.3",
+    "@growi/codemirror-textlint": "^5.0.0-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.0",
+    "@growi/plugin-lsx": "^5.0.0-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.0",
+    "@growi/slack": "^5.0.0-RC.0",
+    "@promster/express": "^7.0.2",
+    "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
@@ -95,6 +95,7 @@
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
     "escape-string-regexp": "=4.0.0",
+    "eslint-plugin-regex": "^1.8.0",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
@@ -137,7 +138,10 @@
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
+    "react-multiline-clamp": "^2.0.0",
     "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
@@ -162,7 +166,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^4.6.0-RC.0",
+    "@growi/ui": "^5.0.0-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -182,6 +186,7 @@
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
+    "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-cypress": "^2.12.1",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",

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

@@ -19,6 +19,17 @@
     "bug_report": "Submitting a bug report",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page tree feature is not available yet.",
+    "go_to_settings": "Go to settings to enable the feature",
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",
@@ -178,6 +189,9 @@
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -192,7 +206,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."
@@ -440,6 +454,7 @@
   },
   "user_group_management": {
     "create_group": "Create new group",
+    "add_child_group": "Add child group",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
@@ -452,6 +467,7 @@
       "backward_match": "Backward match"
     },
     "group_list": "Group list",
+    "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
     "basic_info": "Basic info",
     "user_list": "User list",

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

@@ -11,6 +11,7 @@
   "phone":"Smartphone",
   "tablet":"Tablet",
   "Click to copy": "Click to copy",
+  "Rename" : "Rename",
   "Move/Rename": "Move/Rename",
   "Moved": "Moved",
   "Redirected": "Redirected",
@@ -20,6 +21,7 @@
   "Done": "Done",
   "Cancel": "Cancel",
   "Create": "Create",
+  "Description": "Description",
   "Admin": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
@@ -39,6 +41,7 @@
   "account_id": "Account Id",
   "Update": "Update",
   "Update Page": "Update Page",
+  "Error": "Error",
   "Warning": "Warning",
   "Sign in": "Sign in",
   "Sign up is here": "Sign up",
@@ -66,6 +69,7 @@
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
+  "Include Subordinated Target Page": "include {{target}}",
   "All Subordinated Page": "All Subordinated Page",
   "Specify Hierarchy": "Specify Hierarchy",
   "Submitted the request to create the archive": "Submitted the request to create the archive",
@@ -108,6 +112,9 @@
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
+  "V5 Page Migration": "V5 Page Migration",
+  "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
+  "See_more_detail_on_new_schema": "See more detail on <a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
   "Site URL settings": "Site URL settings",
   "Markdown Settings": "Markdown Settings",
   "Customize": "Customize",
@@ -117,6 +124,7 @@
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
+  "UserGroup": "UserGroup",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
@@ -150,12 +158,17 @@
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
+  "add_bookmark": "Add to Bookmarks",
+  "remove_bookmark": "Remove from Bookmarks",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
+  "Page Tree": "Page Tree",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "Link sharing is disabled",
+  "successfully_saved_the_page": "Successfully saved the page",
+  "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -167,7 +180,10 @@
   "form_validation": {
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
-    "invalid_syntax": "The syntax of %s is invalid."
+    "invalid_syntax": "The syntax of %s is invalid.",
+    "title_required": "Title is required.",
+    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
+
   },
   "not_found_page": {
     "Create Page": "Create Page",
@@ -239,7 +255,7 @@
     "expire": "Expiration",
     "Days": "Days",
     "Custom": "Custom",
-    "description": "description",
+    "description": "Description",
     "enter_desc": "Enter description",
     "Unlimited": "unlimited",
     "Issue": "Issue",
@@ -508,7 +524,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   "toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
@@ -597,13 +616,24 @@
     "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
   },
   "search_result": {
-    "result_meta": "Found \"{{keyword}}\" in {{total}}.",
+    "result_meta": "Search results for:",
     "deletion_mode_btn_lavel": "Select and delete page",
     "cancel": "Cancel",
     "delete": "Delete",
     "check_all": "Check all",
     "deletion_modal_header": "Delete page",
-    "delete_completely": "Delete completely"
+    "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",
+    "sort_axis": {
+      "relationScore": "Sort by relevance",
+      "createdAt": "Creation date",
+      "updatedAt": "Last update date"
+    }
   },
   "security_setting": {
     "Guest Users Access": "Guest users access",
@@ -945,5 +975,14 @@
     "success_to_send_email": "Success to send email",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+  },
+  "pagetree": {
+    "private_legacy_pages": "Private Legacy Pages",
+    "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "Same page name exits as「{{pageName}}」",
+    "same_page_name_exists_at_path" : "Same page name as {{pageName}} exists at {{path}} ",
+    "select_page_to_see" : "Select a page to see"
   }
 }

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

@@ -19,6 +19,17 @@
     "bug_report": "バグを報告する",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
+    "go_to_settings": "設定する",
+    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_note": "注意: ページパスからユニーク制約が失われます。",
+    "upgrade_to_v5": "V5 にアップグレード",
+    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "アップグレードを開始",
+    "successfully_started": "正常にマイグレーションが開始されました",
+    "already_upgraded": "アップグレードは既に完了しています"
+  },
   "app_setting": {
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
@@ -196,6 +207,9 @@
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_growi_archive": "GROWI アーカイブをインポート",
+    "error": {
+      "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "既存のデータと同名であるデータをインポートする際の挙動は以下の3つのモードから選べます。",
@@ -439,6 +453,7 @@
   },
   "user_group_management": {
     "create_group": "新規グループの作成",
+    "add_child_group": "子グループの追加",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
@@ -451,6 +466,7 @@
       "backward_match": "後方一致"
     },
     "group_list": "グループ一覧",
+    "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",

+ 41 - 3
packages/app/resource/locales/ja_JP/translation.json

@@ -11,6 +11,7 @@
   "phone":"スマホ",
   "tablet":"タブレット",
   "Click to copy": "クリックでコピー",
+  "Rename": "名前変更",
   "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
   "Redirected": "リダイレクトされました",
@@ -20,6 +21,7 @@
   "Done": "完了",
   "Cancel": "キャンセル",
   "Create": "作成",
+  "Description": "説明",
   "Admin": "管理",
   "administrator": "管理者",
   "Tag": "タグ",
@@ -40,6 +42,7 @@
   "Initialize": "初期化",
   "Update": "更新",
   "Update Page": "ページを更新",
+  "Error": "エラー",
   "Warning": "注意",
   "Sign in": "ログイン",
   "Sign up is here": "新規登録はこちら",
@@ -66,6 +69,7 @@
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
+  "Include Subordinated Target Page": "{{target}} 下も含む",
   "All Subordinated Page": "全ての配下ページ",
   "Specify Hierarchy": "階層の深さを指定",
   "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
@@ -108,6 +112,9 @@
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
+  "V5 Page Migration": "V5 ページマイグレーション",
+  "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
+  "See_more_detail_on_new_schema": "詳しくは<a href='#'>{{url}}</a><i class='icon-share-alt'></i>を参照ください。",
   "Site URL settings": "サイトURL設定",
   "Markdown Settings": "マークダウン設定",
   "Customize": "カスタマイズ",
@@ -117,6 +124,7 @@
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
+  "UserGroup": "グループ",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
@@ -152,12 +160,17 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
+  "add_bookmark": "ブックマークに追加",
+  "remove_bookmark": "ブックマークから削除",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
+  "Page Tree": "ページツリー",
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
   "Link sharing is disabled": "リンクのシェアは無効化されています",
+  "successfully_saved_the_page": "ページが正常に保存されました",
+  "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -169,7 +182,9 @@
   "form_validation": {
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
-    "invalid_syntax": "%sの構文が不正です"
+    "invalid_syntax": "%sの構文が不正です",
+    "title_required": "タイトルを入力してください",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   "not_found_page": {
     "Create Page": "ページを作成する",
@@ -508,7 +523,10 @@
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
   },
   "toaster": {
+    "create_succeeded": "新しい{{target}}が作成されました",
+    "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
+    "update_failed": "{{target}}の更新に失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
@@ -597,13 +615,24 @@
     "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
   },
   "search_result": {
-    "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
+    "result_meta": "検索結果:",
     "deletion_mode_btn_lavel": "ページを指定して削除",
     "cancel": "キャンセル",
     "delete": "削除",
     "check_all": "すべてチェック",
     "deletion_modal_header": "以下のページを削除",
-    "delete_completely": "完全に削除する"
+    "delete_completely": "完全に削除する",
+    "include_certain_path": "{{pathToInclude}}下を含む ",
+    "delete_all_selected_page" : "一括削除",
+    "currently_not_implemented":"現在未実装の機能です",
+    "search_again" : "再検索",
+    "number_of_list_to_display" : "表示件数",
+    "page_number_unit" : "件",
+    "sort_axis": {
+      "relationScore": "関連度順",
+      "createdAt": "作成日時",
+      "updatedAt": "更新日時"
+    }
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
@@ -938,5 +967,14 @@
     "success_to_send_email": "メールを送信しました",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+  },
+  "pagetree": {
+    "private_legacy_pages": "待避所",
+    "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
+    "same_page_name_exists_at_path" : "”{{path}}” において ”{{pageName}}”というページは複数存在しています。",
+    "select_page_to_see" : "以下から遷移するページを選択してください。"
   }
 }

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

@@ -19,6 +19,17 @@
     "bug_report": "提交一个错误报告",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
+  "v5_page_migration": {
+    "page_tree_not_avaliable": "Page Tree 功能不可用",
+    "go_to_settings": "进入设置,启用该功能",
+    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
+    "migration_note": "Note: You will lose unique constraints from the page paths.",
+    "upgrade_to_v5": "Upgrade to V5",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
+    "start_upgrading": "Start upgrading",
+    "successfully_started": "Succeeded to start migration",
+    "already_upgraded": "You have already completed upgrading"
+  },
   "app_setting": {
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
@@ -188,6 +199,9 @@
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
+    "error": {
+      "only_upsert_available": "Only 'Upsert' option is available for pages collection."
+    },
     "growi_settings": {
       "description_of_import_mode": {
         "about": "When you import data with the same name as an existing one, choose from the following three modes below.",
@@ -449,6 +463,7 @@
   },
   "user_group_management": {
     "create_group": "创建新组",
+    "add_child_group": "添加一个子组",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
@@ -461,6 +476,7 @@
       "backward_match": "向后匹配"
     },
     "group_list": "组列表",
+    "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
     "basic_info": "基本信息",
     "user_list": "用户列表",

+ 41 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -12,6 +12,7 @@
   "tablet":"平板",
 	"Login": "登录",
 	"Click to copy": "点击复制",
+  "Rename": "重命名",
 	"Move/Rename": "移动/重命名",
 	"Moved": "移动",
 	"Redirected": "重定向",
@@ -21,6 +22,7 @@
   "Done": "Done",
   "Cancel": "取消",
 	"Create": "创建",
+  "Description": "描述",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"Tag": "标签",
@@ -41,6 +43,7 @@
 	"Initialize": "初始化",
   "Update": "更新",
 	"Update Page": "更新本页",
+	"Error": "误差",
 	"Warning": "警告",
   "Sign in": "登录",
 	"Sign up is here": "注册",
@@ -67,6 +70,7 @@
   "Include Attachment File": "包含附件",
   "Include Comment": "包含评论",
   "Include Subordinated Page": "包括子页面",
+  "Include Subordinated Target Page": "包括 {{target}}",
   "All Subordinated Page": "所有子页面",
   "Specify Hierarchy": "指定层级",
   "Submitted the request to create the archive": "提交创建归档请求",
@@ -116,6 +120,9 @@
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
+  "V5 Page Migration": "V5 Page Migration",
+  "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
+  "See_more_detail_on_new_schema": "更多详情请见<a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Customize": "页面定制",
@@ -125,6 +132,7 @@
   "Legacy_Slack_Integration": "旧版Slack一体化",
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
+  "UserGroup": "用户组",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
@@ -158,16 +166,23 @@
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
+  "add_bookmark": "添加到书签",
+  "remove_bookmark": "从书签中删除",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
+  "Page Tree": "页面树",
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
   "Link sharing is disabled": "你不允许分享该链接",
+  "successfully_saved_the_page": "成功地保存了该页面",
+  "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
-		"invalid_syntax": "%s的语法无效。"
+		"invalid_syntax": "%s的语法无效。",
+    "title_required": "标题是必需的。",
+    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
   },
   "not_found_page": {
     "Create Page": "创建页面",
@@ -486,7 +501,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
 	"toaster": {
+    "create_succeeded": "Succeeded to create {{target}}",
+    "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
+    "update_failed": "Failed to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
@@ -875,13 +893,24 @@
 		"use_os_settings": "使用操作系统设置"
 	},
 	"search_result": {
-		"result_meta": "在{{total}中找到了{{keyword}。",
+		"result_meta": "搜索结果:",
 		"deletion_mode_btn_lavel": "选择并删除页面",
 		"cancel": "取消",
 		"delete": "删除",
 		"check_all": "全部检查",
 		"deletion_modal_header": "删除页",
-		"delete_completely": "完全删除"
+		"delete_completely": "完全删除",
+    "include_certain_path": "包含 {{pathToInclude}} 路径 ",
+    "delete_all_selected_page": "删除所有",
+    "currently_not_implemented": "这是当前未实现的功能",
+    "search_again" : "再次搜索",
+    "number_of_list_to_display" : "显示器的数量",
+    "page_number_unit" : "例",
+    "sort_axis": {
+      "relationScore": "按相关性排序",
+      "createdAt": "按创建日期排序",
+      "updatedAt": "按更新日期排序"
+    }
 	},
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
@@ -948,5 +977,14 @@
     "success_to_send_email": "我发了一封电子邮件",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+  },
+  "pagetree": {
+    "private_legacy_pages": "私人遗留页面",
+    "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题"
+  },
+  "duplicated_page_alert" : {
+    "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
+    "same_page_name_exists_at_path" : "在”{{path}}” 中,有不止一个名为”{{pageName}}”的页面",
+    "select_page_to_see" : "请在下面选择你想去的页面。"
   }
 }

+ 3 - 0
packages/app/resource/search/mappings-es6.json

@@ -88,6 +88,9 @@
         "bookmark_count": {
           "type": "integer"
         },
+        "seenUsers_count":{
+          "type": "integer"
+        },
         "like_count": {
           "type": "integer"
         },

+ 20 - 17
packages/app/src/client/app.jsx

@@ -2,6 +2,8 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
 
 import { SWRConfig } from 'swr';
 
@@ -11,7 +13,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
 import Sidebar from '../components/Sidebar';
-import SearchPage from '../components/SearchPage';
+import { SearchPage } from '../components/SearchPage';
 import TagsList from '../components/TagsList';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
@@ -34,12 +36,12 @@ import PageStatusAlert from '../components/PageStatusAlert';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import MyDraftList from '../components/MyDraftList/MyDraftList';
-import BookmarkIcon from '../components/Icons/BookmarkIcon';
 import BookmarkList from '../components/PageList/BookmarkList';
 import Fab from '../components/Fab';
 import PersonalSettings from '../components/Me/PersonalSettings';
-import GrowiSubNavigation from '../components/Navbar/GrowiSubNavigation';
+import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
+import IdenticalPathPage from '~/components/IdenticalPathPage';
 
 import ContextExtractor from '~/client/services/ContextExtractor';
 import PageContainer from '~/client/services/PageContainer';
@@ -49,9 +51,9 @@ import CommentContainer from '~/client/services/CommentContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import TagContainer from '~/client/services/TagContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import { appContainer, componentMappings } from './base';
+import { toastError } from './util/apiNotification';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -68,10 +70,9 @@ const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
-const pageAccessoriesContainer = new PageAccessoriesContainer(appContainer);
 const injectableContainers = [
   appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, tagContainer, personalContainer, pageAccessoriesContainer,
+  commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -84,8 +85,9 @@ logger.info('unstated containers have been initialized');
 Object.assign(componentMappings, {
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'search-page': <SearchPage crowi={appContainer} />,
+  'search-page': <SearchPage appContainer={appContainer} />,
   'all-in-app-notifications': <InAppNotificationPage />,
+  'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
@@ -97,12 +99,8 @@ Object.assign(componentMappings, {
   'trash-page-list': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
-  'not-found-alert': <NotFoundAlert
-    isGuestUserMode={appContainer.isGuestUser}
-    isHidden={pageContainer.state.pageId != null ? (pageContainer.state.isNotCreatable ?? pageContainer.state.isTrashPage) : false} // !!DO NOT MOVE THIS!! https://github.com/weseek/growi/pull/4899
-  />,
 
-  'forbidden-page': <ForbiddenPage />,
+  'forbidden-page': <ForbiddenPage isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
 
   'page-timeline': <PageTimeline />,
 
@@ -116,6 +114,9 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
+  'not-found-alert': <NotFoundAlert
+    isGuestUserMode={appContainer.isGuestUser}
+  />,
 });
 
 // additional definitions if data exists
@@ -127,12 +128,12 @@ if (pageContainer.state.pageId != null) {
     'page-content-footer': <PageContentFooter />,
 
     'recent-created-icon': <RecentlyCreatedIcon />,
-    'user-bookmark-icon': <BookmarkIcon />,
   });
 
   // show the Page accessory modal when query of "compare" is requested
   if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
+  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
   }
 }
 if (pageContainer.state.creator != null) {
@@ -145,8 +146,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     'page': <Page />,
-    'grw-subnav-container': <GrowiSubNavigation />,
-    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
+    'grw-subnav-container': <GrowiContextualSubNavigation isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher isLinkSharingDisabled={appContainer.config.disableLinkSharing} />,
     'display-switcher': <DisplaySwitcher />,
   });
 }
@@ -160,7 +161,9 @@ const renderMainComponents = () => {
           <ErrorBoundary>
             <SWRConfig value={swrGlobalConfiguration}>
               <Provider inject={injectableContainers}>
-                {componentMappings[key]}
+                <DndProvider backend={HTML5Backend}>
+                  {componentMappings[key]}
+                </DndProvider>
               </Provider>
             </SWRConfig>
           </ErrorBoundary>

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

@@ -7,9 +7,15 @@ import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import PageCreateModal from '../components/PageCreateModal';
+import PageDeleteModal from '../components/PageDeleteModal';
+import PageDuplicateModal from '../components/PageDuplicateModal';
+import PageRenameModal from '../components/PageRenameModal';
+import PagePresentationModal from '../components/PagePresentationModal';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -40,6 +46,12 @@ const componentMappings = {
   'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
   'page-create-modal': <PageCreateModal />,
+  'page-delete-modal': <PageDeleteModal />,
+  'page-duplicate-modal': <PageDuplicateModal />,
+  'page-rename-modal': <PageRenameModal />,
+  'page-presentation-modal': <PagePresentationModal />,
+  'page-accessories-modal': <PageAccessoriesModal />,
+  'descendants-page-list-modal': <DescendantsPageListModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

+ 13 - 0
packages/app/src/client/interfaces/selectable-all.ts

@@ -0,0 +1,13 @@
+export interface ISelectable {
+  select: () => void,
+  deselect: () => void,
+}
+
+export interface ISelectableAndIndeterminatable extends ISelectable {
+  setIndeterminate: () => void,
+}
+
+export interface ISelectableAll {
+  selectAll: () => void,
+  deselectAll: () => void,
+}

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

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

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

@@ -25,13 +25,13 @@ export default class AdminHomeContainer extends Container {
     this.timer = null;
 
     this.state = {
-      retrieveError: null,
       growiVersion: '',
       nodeVersion: '',
       npmVersion: '',
       yarnVersion: '',
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
+      isV5Compatible: null,
     };
 
   }
@@ -63,11 +63,12 @@ export default class AdminHomeContainer extends Container {
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
+        isV5Compatible: adminHomeParams.isV5Compatible,
       }));
     }
     catch (err) {
       logger.error(err);
-      toastError(new Error('Failed to fetch data'));
+      throw new Error('Failed to retrive AdminHome data');
     }
   }
 

+ 23 - 9
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -1,9 +1,17 @@
+/*
+ * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
+ */
+
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
 
+import {
+  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
+} from '~/client/util/apiv3-client';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
@@ -11,7 +19,7 @@ const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
  * Service container for admin user group detail page (UserGroupDetailPage.jsx)
  * @extends {Container} unstated Container
  */
-export default class AdminAdminUserGroupDetailContainer extends Container {
+export default class AdminUserGroupDetailContainer extends Container {
 
   constructor(appContainer) {
     super();
@@ -27,8 +35,14 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
     this.state = {
       // TODO: [SPA] get userGroup from props
       userGroup: JSON.parse(rootElem.getAttribute('data-user-group')),
-      userGroupRelations: [],
-      relatedPages: [],
+      userGroupRelations: [], // For user list
+
+      // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
+      childUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+      grandChildUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+
+      childUserGroupRelations: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list users
+      relatedPages: [], // For page list
       isUserGroupUserModalOpen: false,
       searchType: 'partial',
       isAlsoMailSearched: false,
@@ -61,8 +75,8 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
         userGroupRelations,
         relatedPages,
       ] = await Promise.all([
-        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
-        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
+        apiv3Get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
+        apiv3Get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
       ]);
 
       await this.setState({
@@ -105,7 +119,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @return {object} response object
    */
   async updateUserGroup(param) {
-    const res = await this.appContainer.apiv3.put(`/user-groups/${this.state.userGroup._id}`, param);
+    const res = await apiv3Put(`/user-groups/${this.state.userGroup._id}`, param);
     const { userGroup } = res.data;
 
     await this.setState({ userGroup });
@@ -136,7 +150,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be searched
    */
   async fetchApplicableUsers(searchWord) {
-    const res = await this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
+    const res = await apiv3Get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
       searchWord,
       searchType: this.state.searchType,
       isAlsoMailSearched: this.state.isAlsoMailSearched,
@@ -156,7 +170,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be added to the group
    */
   async addUserByUsername(username) {
-    const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const res = await apiv3Post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
 
     // do not add users for ducaplicate
     if (res.data.userGroupRelation == null) { return }
@@ -171,7 +185,7 @@ export default class AdminAdminUserGroupDetailContainer extends Container {
    * @param {string} username username of the user to be removed from the group
    */
   async removeUserByUsername(username) {
-    const res = await this.appContainer.apiv3.delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const res = await apiv3Delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
 
     this.setState((prevState) => {
       return {

+ 33 - 17
packages/app/src/client/services/ContextExtractor.tsx

@@ -2,14 +2,14 @@ import React, { FC, useEffect, useState } from 'react';
 import { pagePathUtils } from '@growi/core';
 
 import {
-  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
-  useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
-  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser,
-  useSlackChannels,
-} from '~/stores/context';
+  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
+  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+} from '../../stores/context';
 import {
-  useIsDeviceSmallerThanMd,
+  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -22,6 +22,8 @@ const jsonNull = 'null';
 const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
+  const notFoundContent = document.getElementById('growi-pagetree-not-found-context');
+  const forbiddenContent = document.getElementById('forbidden-page');
 
   /*
    * App Context from DOM
@@ -49,13 +51,12 @@ const ContextExtractorOnce: FC = () => {
   const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
 
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
-  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
+  const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
+  const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull);
-  const isDeletable = JSON.parse(mainContent?.getAttribute('data-page-is-deletable') || jsonNull);
-  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull);
-  const isAbleToDeleteCompletely = JSON.parse(mainContent?.getAttribute('data-page-is-able-to-delete-completely') || jsonNull);
-  const isPageExist = mainContent?.getAttribute('data-page-id') != null;
+  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
+  const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
+  const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
@@ -68,10 +69,15 @@ const ContextExtractorOnce: FC = () => {
   const hasDraftOnHackmd = !!mainContent?.getAttribute('data-page-has-draft-on-hackmd');
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
+  const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
+  const notFoundTargetPathOrId = JSON.parse(notFoundContent?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
+  const isSearchPage = document.getElementById('search-page') != null;
+
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
   const grantGroupName = mainContent?.getAttribute('data-page-grant-group-name') || null;
+
   /*
    * use static swr
    */
@@ -91,15 +97,14 @@ const ContextExtractorOnce: FC = () => {
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
-  useIsAbleToDeleteCompletely(isAbleToDeleteCompletely);
-  useIsDeletable(isDeletable);
+  useIsIdenticalPath(isIdenticalPath);
   useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
-  useIsPageExist(isPageExist);
+  useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
-  usePageId(pageId);
+  useCurrentPageId(pageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -112,6 +117,14 @@ const ContextExtractorOnce: FC = () => {
   useCurrentUpdatedAt(updatedAt);
   useCreator(creator);
   useRevisionAuthor(revisionAuthor);
+  useTargetAndAncestors(targetAndAncestors);
+  useNotFoundTargetPathOrId(notFoundTargetPathOrId);
+  useIsSearchPage(isSearchPage);
+
+  // Navigation
+  usePreferDrawerModeByUser();
+  usePreferDrawerModeOnEditByUser();
+  useIsDeviceSmallerThanMd();
 
   // Navigation
   usePreferDrawerModeByUser();
@@ -124,6 +137,9 @@ const ContextExtractorOnce: FC = () => {
   useSelectedGrantGroupId(grantGroupId);
   useSelectedGrantGroupName(grantGroupName);
 
+  // SearchResult
+  useIsDeviceSmallerThanLg();
+
   return null;
 };
 

+ 0 - 54
packages/app/src/client/services/PageAccessoriesContainer.js

@@ -1,54 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to options for Application
- * @extends {Container} unstated Container
- */
-
-export default class PageAccessoriesContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['']),
-    };
-    this.openPageAccessoriesModal = this.openPageAccessoriesModal.bind(this);
-    this.closePageAccessoriesModal = this.closePageAccessoriesModal.bind(this);
-    this.switchActiveTab = this.switchActiveTab.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageAccessoriesContainer';
-  }
-
-
-  openPageAccessoriesModal(activeTab) {
-    this.setState({
-      isPageAccessoriesModalShown: true,
-    });
-    this.switchActiveTab(activeTab);
-  }
-
-  closePageAccessoriesModal() {
-    this.setState({
-      isPageAccessoriesModalShown: false,
-      activeTab: '',
-    });
-  }
-
-  switchActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-}

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

@@ -62,9 +62,7 @@ export default class PageContainer extends Container {
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
       isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
-      isDeletable: JSON.parse(mainContent.getAttribute('data-page-is-deletable')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
-      isAbleToDeleteCompletely: JSON.parse(mainContent.getAttribute('data-page-is-able-to-delete-completely')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 
       pageUser: JSON.parse(mainContent.getAttribute('data-page-user')),
@@ -140,71 +138,6 @@ export default class PageContainer extends Container {
     return 'PageContainer';
   }
 
-
-  /**
-   * whether to display reaction buttons
-   * ex.) like, bookmark
-   */
-  get isAbleToShowPageReactionButtons() {
-    const { isTrashPage, isPageExist } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isTrashPage && isPageExist && !isSharedUser);
-  }
-
-  /**
-   * whether to display tag labels
-   */
-  get isAbleToShowTagLabel() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display page management
-   * ex.) duplicate, rename
-   */
-  get isAbleToShowPageManagement() {
-    const { isPageExist, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (isPageExist && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageEditorModeManager
-   * ex.) view, edit, hackmd
-   */
-  get isAbleToShowPageEditorModeManager() {
-    const { isNotCreatable, isTrashPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isNotCreatable && !isTrashPage && !isSharedUser);
-  }
-
-  /**
-   * whether to display pageAuthors
-   * ex.) creator, lastUpdateUser
-   */
-  get isAbleToShowPageAuthors() {
-    const { isPageExist, isUserPage } = this.state;
-
-    return (isPageExist && !isUserPage);
-  }
-
-  /**
-   * whether to like button
-   * not displayed on user page
-   */
-  get isAbleToShowLikeButtons() {
-    const { isUserPage } = this.state;
-    const { isSharedUser } = this.appContainer;
-
-    return (!isUserPage && !isSharedUser);
-  }
-
   /**
    * whether to Empty Trash Page
    * not displayed when guest user and not on trash page
@@ -440,49 +373,6 @@ export default class PageContainer extends Container {
     return res;
   }
 
-  deletePage(isRecursively, isCompletely) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const completely = isCompletely ? true : null;
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.remove', {
-      recursively,
-      completely,
-      page_id: this.state.pageId,
-      revision_id: this.state.revisionId,
-    });
-
-  }
-
-  revertRemove(isRecursively) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-
-    // control flag
-    const recursively = isRecursively ? true : null;
-
-    return this.appContainer.apiPost('/pages.revertRemove', {
-      recursively,
-      page_id: this.state.pageId,
-    });
-  }
-
-  rename(newPagePath, isRecursively, isRenameRedirect, isRemainMetadata) {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const { pageId, revisionId, path } = this.state;
-
-    return this.appContainer.apiv3Put('/pages/rename', {
-      revisionId,
-      pageId,
-      isRecursively,
-      isRenameRedirect,
-      isRemainMetadata,
-      newPagePath,
-      path,
-    });
-  }
-
   showSuccessToastr() {
     toastr.success(undefined, 'Saved successfully', {
       closeButton: true,
@@ -569,6 +459,7 @@ export default class PageContainer extends Container {
 
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const options = editorContainer.getCurrentOptionsToSave();
     const optionsToSave = Object.assign({}, options);
 
@@ -577,6 +468,10 @@ export default class PageContainer extends Container {
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
+    if (pageEditor != null) {
+      pageEditor.updateEditorValue(markdown);
+    }
+
     editorContainer.setState({ tags: res.tags });
 
     return res;

+ 62 - 0
packages/app/src/client/services/page-operation.ts

@@ -0,0 +1,62 @@
+import urljoin from 'url-join';
+
+import { SubscriptionStatusType } from '~/interfaces/subscription';
+
+import { toastError } from '../util/apiNotification';
+import { apiv3Put } from '../util/apiv3-client';
+
+export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
+  try {
+    const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
+      ? SubscriptionStatusType.UNSUBSCRIBE
+      : SubscriptionStatusType.SUBSCRIBE;
+
+    await apiv3Put('/page/subscribe', { pageId, status: newStatus });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleLike = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/page/likes', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const toggleBookmark = async(pageId: string, currentValue?: boolean): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: !currentValue });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const bookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: true });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const unbookmark = async(pageId: string): Promise<void> => {
+  try {
+    await apiv3Put('/bookmarks', { pageId, bool: false });
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
+export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
+  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+  url.searchParams.append('format', format);
+  url.searchParams.append('revisionId', revisionId);
+  window.location.href = url.href;
+};

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

@@ -1,6 +1,6 @@
 const WIKI_HEADER_LINK = 120;
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void => {
+export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
   const targetElement = element || window.document.body;
 
   // get the distance to the target element top
@@ -8,7 +8,7 @@ export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0): void
 
   const top = window.pageYOffset + rectTop - offsetTop;
 
-  window.scrollTo({
+  scrollElement.scrollTo({
     top,
     behavior: 'smooth',
   });

+ 81 - 68
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -1,6 +1,6 @@
-import React, { Fragment } from 'react';
+import React, { useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
@@ -10,99 +10,112 @@ import { toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
 import EnvVarsTable from './EnvVarsTable';
 
 const logger = loggerFactory('growi:admin');
 
-class AdminHome extends React.Component {
-
-  async componentDidMount() {
-    const { adminHomeContainer } = this.props;
+const AdminHome = (props) => {
+  const { adminHomeContainer } = props;
+  const { t } = useTranslation();
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
+  const fetchAdminHomeData = useCallback(async() => {
     try {
       await adminHomeContainer.retrieveAdminHomeData();
     }
     catch (err) {
       toastError(err);
-      adminHomeContainer.setState({ retrieveError: err });
       logger.error(err);
     }
-  }
-
-  render() {
-    const { t, adminHomeContainer } = this.props;
-
-    return (
-      <Fragment>
-        <p>
-          {t('admin:admin_top.wiki_administrator')}
-          <br></br>
-          {t('admin:admin_top.assign_administrator')}
-        </p>
-
-        <div className="row mb-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
-            <SystemInfomationTable />
+  }, [adminHomeContainer]);
+
+  useEffect(() => {
+    fetchAdminHomeData();
+  }, [fetchAdminHomeData]);
+
+  return (
+    <>
+      {
+      // Alert message will be displayed in case that V5 migration has not been compleated
+        (migrationStatus != null && !migrationStatus.isV5Compatible)
+        && (
+          <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
+            {t('admin:v5_page_migration.migration_desc')}
+            <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
+            </a>
           </div>
+        )
+      }
+      <p>
+        {t('admin:admin_top.wiki_administrator')}
+        <br></br>
+        {t('admin:admin_top.assign_administrator')}
+      </p>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.system_information')}</h2>
+          <SystemInfomationTable />
         </div>
+      </div>
 
-        <div className="row mb-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
-            <InstalledPluginTable />
-          </div>
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
+          <InstalledPluginTable />
         </div>
-
-        <div className="row mb-5">
-          <div className="col-md-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
-            <p>{t('admin:admin_top.env_var_priority')}</p>
-            {/* eslint-disable-next-line react/no-danger */}
-            <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
-            {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
-          </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-md-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
+          <p>{t('admin:admin_top.env_var_priority')}</p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
+          {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
         </div>
-
-        <div className="row mb-5">
-          <div className="col-md-12">
-            <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
-            <div className="d-flex align-items-center">
-              <CopyToClipboard
-                text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
-                onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
-              >
-                <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
-                  {t('admin:admin_top:copy_prefilled_host_information:default')}
-                </button>
-              </CopyToClipboard>
-              <Tooltip
-                placement="bottom"
-                isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
-                target="prefilledHostInformationButton"
-                fade={false}
-              >
-                {t('admin:admin_top:copy_prefilled_host_information:done')}
-              </Tooltip>
-              {/* eslint-disable-next-line react/no-danger */}
-              <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
-            </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-md-12">
+          <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
+          <div className="d-flex align-items-center">
+            <CopyToClipboard
+              text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
+              onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
+            >
+              <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
+                {t('admin:admin_top:copy_prefilled_host_information:default')}
+              </button>
+            </CopyToClipboard>
+            <Tooltip
+              placement="bottom"
+              isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
+              target="prefilledHostInformationButton"
+              fade={false}
+            >
+              {t('admin:admin_top:copy_prefilled_host_information:done')}
+            </Tooltip>
+            {/* eslint-disable-next-line react/no-danger */}
+            <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
           </div>
         </div>
-      </Fragment>
-    );
-  }
+      </div>
+    </>
+  );
+};
 
-}
 
 const AdminHomeWrapper = withUnstatedContainers(AdminHome, [AppContainer, AdminHomeContainer]);
 
 AdminHome.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
 };
 
-export default withTranslation()(AdminHomeWrapper);
+export default AdminHomeWrapper;

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

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

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

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

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

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

+ 6 - 1
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -287,7 +287,9 @@ class ImportForm extends React.Component {
   }
 
   async import() {
-    const { appContainer, fileName, onPostImport } = this.props;
+    const {
+      appContainer, fileName, onPostImport, t,
+    } = this.props;
     const { selectedCollections, optionsMap } = this.state;
 
     // init progress data
@@ -312,6 +314,9 @@ class ImportForm extends React.Component {
       toastSuccess(undefined, 'Import process has requested.');
     }
     catch (err) {
+      if (err.code === 'only_upsert_available') {
+        toastError(t('admin:importer_management.error.only_upsert_available'));
+      }
       toastError(err, 'Import request failed.');
     }
   }

+ 1 - 1
packages/app/src/components/Admin/Notification/GlobalNotificationList.jsx

@@ -116,7 +116,7 @@ class GlobalNotificationList extends React.Component {
                   )}
                   {notification.triggerEvents.includes('pageLike') && (
                     <li className="list-inline-item badge badge-pill badge-info">
-                      <i className="icon-like"></i> LIKE
+                      <i className="fa fa-heart-o"></i> LIKE
                     </li>
                   )}
                   {notification.triggerEvents.includes('comment') && (

+ 1 - 1
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -275,7 +275,7 @@ class ManageGlobalNotification extends React.Component {
                   onChange={() => this.onChangeTriggerEvents('pageLike')}
                 >
                   <span className="badge badge-pill badge-info">
-                    <i className="icon-like mr-1" />LIKE
+                    <i className="fa fa-heart-o mr-1" />LIKE
                   </span>
                 </TriggerEventCheckBox>
               </div>

+ 0 - 118
packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx

@@ -1,118 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupCreateForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      name: '',
-    };
-
-    this.xss = window.xss;
-
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  handleChange(event) {
-    const target = event.target;
-    const value = target.type === 'checkbox' ? target.checked : target.value;
-    const name = target.name;
-
-    this.setState({
-      [name]: value,
-    });
-  }
-
-  async handleSubmit(e) {
-    e.preventDefault();
-
-    try {
-      const res = await this.props.appContainer.apiv3.post('/user-groups', {
-        name: this.state.name,
-      });
-
-      const userGroup = res.data.userGroup;
-      const userGroupId = userGroup._id;
-
-      const res2 = await this.props.appContainer.apiv3.get(`/user-groups/${userGroupId}/users`);
-
-      const { users } = res2.data;
-
-      this.props.onCreate(userGroup, users);
-
-      this.setState({ name: '' });
-
-      toastSuccess(`Created a user group "${this.xss.process(userGroup.name)}"`);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  validateForm() {
-    return this.state.name !== '';
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div>
-        <p>
-          {this.props.isAclEnabled
-            ? (
-              <button type="button" data-toggle="collapse" className="btn btn-outline-secondary" href="#createGroupForm">
-                {t('admin:user_group_management.create_group')}
-              </button>
-            )
-            : (
-              t('admin:user_group_management.deny_create_group')
-            )
-          }
-        </p>
-        <form onSubmit={this.handleSubmit}>
-          <div id="createGroupForm" className="collapse">
-            <div className="form-group">
-              <label htmlFor="name">{t('admin:user_group_management.group_name')}</label>
-              <textarea
-                id="name"
-                name="name"
-                className="form-control"
-                placeholder={t('admin:user_group_management.group_example')}
-                value={this.state.name}
-                onChange={this.handleChange}
-              >
-              </textarea>
-            </div>
-            <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{t('Create')}</button>
-          </div>
-        </form>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupCreateFormWrapper = withUnstatedContainers(UserGroupCreateForm, [AppContainer]);
-
-UserGroupCreateForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isAclEnabled: PropTypes.bool.isRequired,
-  onCreate: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(UserGroupCreateFormWrapper);

+ 0 - 216
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -1,216 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-/**
- * Delete User Group Select component
- *
- * @export
- * @class GrantSelector
- * @extends {React.Component}
- */
-class UserGroupDeleteModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { t } = this.props;
-
-    // actionName master constants
-    this.actionForPages = {
-      public: 'public',
-      delete: 'delete',
-      transfer: 'transfer',
-    };
-
-    this.availableOptions = [
-      {
-        id: 1,
-        actionForPages: this.actionForPages.public,
-        iconClass: 'icon-people',
-        styleClass: '',
-        label: t('admin:user_group_management.delete_modal.publish_pages'),
-      },
-      {
-        id: 2,
-        actionForPages: this.actionForPages.delete,
-        iconClass: 'icon-trash',
-        styleClass: 'text-danger',
-        label: t('admin:user_group_management.delete_modal.delete_pages'),
-      },
-      {
-        id: 3,
-        actionForPages: this.actionForPages.transfer,
-        iconClass: 'icon-options',
-        styleClass: '',
-        label: t('admin:user_group_management.delete_modal.transfer_pages'),
-      },
-    ];
-
-    this.initialState = {
-      actionName: '',
-      transferToUserGroupId: '',
-    };
-
-    this.state = this.initialState;
-
-    this.xss = window.xss;
-
-    this.onHide = this.onHide.bind(this);
-    this.handleActionChange = this.handleActionChange.bind(this);
-    this.handleGroupChange = this.handleGroupChange.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
-    this.renderGroupSelector = this.renderGroupSelector.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  onHide() {
-    this.setState(this.initialState);
-    this.props.onHide();
-  }
-
-  handleActionChange(e) {
-    const actionName = e.target.value;
-    this.setState({ actionName });
-  }
-
-  handleGroupChange(e) {
-    const transferToUserGroupId = e.target.value;
-    this.setState({ transferToUserGroupId });
-  }
-
-  handleSubmit(e) {
-    e.preventDefault();
-
-    this.props.onDelete({
-      deleteGroupId: this.props.deleteUserGroup._id,
-      actionName: this.state.actionName,
-      transferToUserGroupId: this.state.transferToUserGroupId,
-    });
-  }
-
-  renderPageActionSelector() {
-    const { t } = this.props;
-
-    const optoins = this.availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${opt.label}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{opt.label}</option>;
-    });
-
-    return (
-      <select
-        name="actionName"
-        className="form-control"
-        placeholder="select"
-        value={this.state.actionName}
-        onChange={this.handleActionChange}
-      >
-        <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
-        {optoins}
-      </select>
-    );
-  }
-
-  renderGroupSelector() {
-    const { t } = this.props;
-
-    const groups = this.props.userGroups.filter((group) => {
-      return group._id !== this.props.deleteUserGroup._id;
-    });
-
-    const options = groups.map((group) => {
-      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.xss.process(group.name)}`;
-      return <option key={group._id} value={group._id} data-content={dataContent}>{this.xss.process(group.name)}</option>;
-    });
-
-    const defaultOptionText = groups.length === 0 ? t('admin:user_group_management.delete_modal.no_groups')
-      : t('admin:user_group_management.delete_modal.select_group');
-
-    return (
-      <select
-        name="transferToUserGroupId"
-        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
-        value={this.state.transferToUserGroupId}
-        onChange={this.handleGroupChange}
-      >
-        <option value="" disabled>{defaultOptionText}</option>
-        {options}
-      </select>
-    );
-  }
-
-  validateForm() {
-    let isValid = true;
-
-    if (this.state.actionName === '') {
-      isValid = false;
-    }
-    else if (this.state.actionName === this.actionForPages.transfer) {
-      isValid = this.state.transferToUserGroupId !== '';
-    }
-
-    return isValid;
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal className="modal-md" isOpen={this.props.isShow} toggle={this.props.onHide}>
-        <ModalHeader tag="h4" toggle={this.props.onHide} className="bg-danger text-light">
-          <i className="icon icon-fire"></i> {t('admin:user_group_management.delete_modal.header')}
-        </ModalHeader>
-        <ModalBody>
-          <div>
-            <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{this.props.deleteUserGroup.name}&quot;
-          </div>
-          <div className="text-danger mt-5">
-            {t('admin:user_group_management.delete_modal.desc')}
-          </div>
-        </ModalBody>
-        <ModalFooter>
-          <form className="d-flex justify-content-between w-100" onSubmit={this.handleSubmit}>
-            <div className="d-flex form-group mb-0">
-              {this.renderPageActionSelector()}
-              {this.renderGroupSelector()}
-            </div>
-            <button type="submit" value="" className="btn btn-sm btn-danger text-nowrap" disabled={!this.validateForm()}>
-              <i className="icon icon-fire"></i> {t('Delete')}
-            </button>
-          </form>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDeleteModalWrapper = withUnstatedContainers(UserGroupDeleteModal, [AppContainer]);
-
-UserGroupDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
-  deleteUserGroup: PropTypes.object,
-  onDelete: PropTypes.func.isRequired,
-  isShow: PropTypes.bool.isRequired,
-  onShow: PropTypes.func.isRequired,
-  onHide: PropTypes.func.isRequired,
-};
-
-UserGroupDeleteModal.defaultProps = {
-  deleteUserGroup: {},
-};
-
-export default withTranslation()(UserGroupDeleteModalWrapper);

+ 219 - 0
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -0,0 +1,219 @@
+import React, {
+  FC, useCallback, useState, useMemo,
+} from 'react';
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import AppContainer from '~/client/services/AppContainer';
+import { IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+/**
+ * Delete User Group Select component
+ *
+ * @export
+ * @class GrantSelector
+ * @extends {React.Component}
+ */
+type Props = {
+  appContainer: AppContainer,
+
+  userGroups: IUserGroupHasId[],
+  deleteUserGroup?: IUserGroupHasId,
+  onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
+  isShow: boolean,
+  onShow?: (group: IUserGroupHasId) => Promise<void> | void,
+  onHide?: () => Promise<void> | void,
+};
+
+type AvailableOption = {
+  id: number,
+  actionForPages: string,
+  iconClass: string,
+  styleClass: string,
+  label: TFunctionResult,
+};
+
+// actionName master constants
+const actionForPages = {
+  public: 'public',
+  delete: 'delete',
+  transfer: 'transfer',
+};
+
+const UserGroupDeleteModal: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  const availableOptions = useMemo<AvailableOption[]>(() => {
+    return [
+      {
+        id: 1,
+        actionForPages: actionForPages.public,
+        iconClass: 'icon-people',
+        styleClass: '',
+        label: t('admin:user_group_management.delete_modal.publish_pages'),
+      },
+      {
+        id: 2,
+        actionForPages: actionForPages.delete,
+        iconClass: 'icon-trash',
+        styleClass: 'text-danger',
+        label: t('admin:user_group_management.delete_modal.delete_pages'),
+      },
+      {
+        id: 3,
+        actionForPages: actionForPages.transfer,
+        iconClass: 'icon-options',
+        styleClass: '',
+        label: t('admin:user_group_management.delete_modal.transfer_pages'),
+      },
+    ];
+  }, []);
+
+  /*
+   * State
+   */
+  const [actionName, setActionName] = useState<string>('');
+  const [transferToUserGroupId, setTransferToUserGroupId] = useState<string>('');
+
+  /*
+   * Function
+   */
+  const resetStates = useCallback(() => {
+    setActionName('');
+    setTransferToUserGroupId('');
+  }, []);
+
+  const onHide = useCallback(() => {
+    if (props.onHide == null) {
+      return;
+    }
+
+    resetStates();
+    props.onHide();
+  }, [props.onHide]);
+
+  const handleActionChange = useCallback((e) => {
+    const actionName = e.target.value;
+    setActionName(actionName);
+  }, [setActionName]);
+
+  const handleGroupChange = useCallback((e) => {
+    const transferToUserGroupId = e.target.value;
+    setTransferToUserGroupId(transferToUserGroupId);
+  }, []);
+
+  const handleSubmit = useCallback((e) => {
+    if (props.onDelete == null || props.deleteUserGroup == null) {
+      return;
+    }
+
+    e.preventDefault();
+
+    props.onDelete(
+      props.deleteUserGroup._id,
+      actionName,
+      transferToUserGroupId,
+    );
+  }, [props.onDelete, props.deleteUserGroup, actionName, transferToUserGroupId]);
+
+  const renderPageActionSelector = useCallback(() => {
+    const options = availableOptions.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${opt.label}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{opt.label}</option>;
+    });
+
+    return (
+      <select
+        name="actionName"
+        className="form-control"
+        placeholder="select"
+        value={actionName}
+        onChange={handleActionChange}
+      >
+        <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
+        {options}
+      </select>
+    );
+  }, [handleActionChange, actionName, availableOptions]);
+
+  const renderGroupSelector = useCallback(() => {
+    const { deleteUserGroup } = props;
+
+    if (deleteUserGroup == null) {
+      return;
+    }
+
+    const groups = props.userGroups.filter((group) => {
+      return group._id !== deleteUserGroup._id;
+    });
+
+    const options = groups.map((group) => {
+      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${xss.process(group.name)}`;
+      return <option key={group._id} value={group._id} data-content={dataContent}>{xss.process(group.name)}</option>;
+    });
+
+    const defaultOptionText = groups.length === 0 ? t('admin:user_group_management.delete_modal.no_groups')
+      : t('admin:user_group_management.delete_modal.select_group');
+
+    return (
+      <select
+        name="transferToUserGroupId"
+        className={`form-control ${actionName === actionForPages.transfer ? '' : 'd-none'}`}
+        value={transferToUserGroupId}
+        onChange={handleGroupChange}
+      >
+        <option value="" disabled>{defaultOptionText}</option>
+        {options}
+      </select>
+    );
+  }, [actionName, transferToUserGroupId, props.userGroups, props.deleteUserGroup]);
+
+  const validateForm = useCallback(() => {
+    let isValid = true;
+
+    if (actionName === '') {
+      isValid = false;
+    }
+    else if (actionName === actionForPages.transfer) {
+      isValid = transferToUserGroupId !== '';
+    }
+
+    return isValid;
+  }, [actionName, transferToUserGroupId]);
+
+  return (
+    <Modal className="modal-md" isOpen={props.isShow} toggle={onHide}>
+      <ModalHeader tag="h4" toggle={onHide} className="bg-danger text-light">
+        <i className="icon icon-fire"></i> {t('admin:user_group_management.delete_modal.header')}
+      </ModalHeader>
+      <ModalBody>
+        <div>
+          <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{props?.deleteUserGroup?.name || ''}&quot;
+        </div>
+        <div className="text-danger mt-5">
+          {t('admin:user_group_management.delete_modal.desc')}
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
+          <div className="d-flex form-group mb-0">
+            {renderPageActionSelector()}
+            {renderGroupSelector()}
+          </div>
+          <button type="submit" value="" className="btn btn-sm btn-danger text-nowrap" disabled={!validateForm()}>
+            <i className="icon icon-fire"></i> {t('Delete')}
+          </button>
+        </form>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default UserGroupDeleteModal;

+ 70 - 0
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -0,0 +1,70 @@
+import React, { FC, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+
+type Props = {
+  selectableUserGroups?: IUserGroupHasId[]
+  onClickAddExistingUserGroupButtonHandler?(userGroup: IUserGroupHasId | null): void
+  onClickCreateUserGroupButtonHandler?(): void
+};
+
+const UserGroupDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const { selectableUserGroups, onClickAddExistingUserGroupButtonHandler, onClickCreateUserGroupButtonHandler } = props;
+
+  const onClickAddExistingUserGroupButton = useCallback((userGroup: IUserGroupHasId) => {
+    if (onClickAddExistingUserGroupButtonHandler != null) {
+      onClickAddExistingUserGroupButtonHandler(userGroup);
+    }
+  }, [onClickAddExistingUserGroupButtonHandler]);
+
+  const onClickCreateUserGroupButton = useCallback(() => {
+    if (onClickCreateUserGroupButtonHandler != null) {
+      onClickCreateUserGroupButtonHandler();
+    }
+  }, [onClickCreateUserGroupButtonHandler]);
+
+  return (
+    <>
+      <div className="dropdown">
+        <button className="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+          {t('admin:user_group_management.add_child_group')}
+        </button>
+
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+
+          {
+            (selectableUserGroups != null && selectableUserGroups.length > 0) && (
+              <>
+                {
+                  selectableUserGroups.map(userGroup => (
+                    <button
+                      key={userGroup._id}
+                      type="button"
+                      className="dropdown-item"
+                      onClick={() => onClickAddExistingUserGroupButton(userGroup)}
+                    >
+                      {userGroup.name}
+                    </button>
+                  ))
+                }
+                <div className="dropdown-divider"></div>
+              </>
+            )
+          }
+
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => onClickCreateUserGroupButton()}
+          >{t('admin:user_group_management.create_group')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default UserGroupDropdown;

+ 119 - 0
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -0,0 +1,119 @@
+import React, { FC, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+import { TFunctionResult } from 'i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup?: IUserGroupHasId,
+  successedMessage: TFunctionResult;
+  failedMessage: TFunctionResult;
+  submitButtonLabel: TFunctionResult;
+  onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+};
+
+const UserGroupForm: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [currentName, setName] = useState(props.userGroup != null ? props.userGroup.name : '');
+  const [currentDescription, setDescription] = useState(props.userGroup != null ? props.userGroup.description : '');
+  const [currentParent, setParent] = useState(props.userGroup != null ? props.userGroup.parent : '');
+
+  /*
+   * Function
+   */
+  const onChangeNameHandler = useCallback((e) => {
+    setName(e.target.value);
+  }, []);
+
+  const onChangeDescriptionHandler = useCallback((e) => {
+    setDescription(e.target.value);
+  }, []);
+
+  const onSubmitHandler = useCallback(async(e) => {
+    e.preventDefault(); // no reload
+
+    if (props.onSubmit == null) {
+      return;
+    }
+
+    try {
+      await props.onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
+
+      toastSuccess(props.successedMessage);
+    }
+    catch (err) {
+      toastError(props.failedMessage);
+    }
+  }, [currentName, currentDescription, currentParent, props.onSubmit, props.successedMessage, props.failedMessage]);
+
+  return (
+    <form onSubmit={onSubmitHandler}>
+
+      <fieldset>
+        <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
+        {/* TODO 85062: improve style */}
+        {
+          props.userGroup?.createdAt != null && (
+            <div className="form-group row">
+              <p className="col-md-2 col-form-label">{t('Created')}</p>
+              <p className="col-md-4 my-auto">{dateFnsFormat(new Date(props.userGroup.createdAt), 'yyyy-MM-dd')}</p>
+            </div>
+          )
+        }
+        <div className="form-group row">
+          <label htmlFor="name" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.group_name')}
+          </label>
+          <div className="col-md-4">
+            <input
+              className="form-control"
+              type="text"
+              name="name"
+              placeholder={t('admin:user_group_management.group_example')}
+              value={currentName}
+              onChange={onChangeNameHandler}
+              required
+            />
+          </div>
+        </div>
+        <div className="form-group row">
+          <label htmlFor="description" className="col-md-2 col-form-label">
+            {t('Description')}
+          </label>
+          <div className="col-md-4">
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} required />
+          </div>
+        </div>
+
+        {/* TODO 85062: select parent dropdown */}
+
+        <div className="form-group row">
+          <div className="offset-md-2 col-md-10">
+            <button type="submit" className="btn btn-primary">
+              {props.submitButtonLabel}
+            </button>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupFormWrapper = withUnstatedContainers<unknown, Props>(UserGroupForm, [AppContainer]);
+
+export default UserGroupFormWrapper;

+ 0 - 152
packages/app/src/components/Admin/UserGroup/UserGroupPage.jsx

@@ -1,152 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-
-import UserGroupTable from './UserGroupTable';
-import UserGroupCreateForm from './UserGroupCreateForm';
-import UserGroupDeleteModal from './UserGroupDeleteModal';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      userGroups: [],
-      userGroupRelations: [],
-      selectedUserGroup: undefined, // not null but undefined (to use defaultProps in UserGroupDeleteModal)
-      isDeleteModalShow: false,
-    };
-
-    this.xss = window.xss;
-
-    this.showDeleteModal = this.showDeleteModal.bind(this);
-    this.hideDeleteModal = this.hideDeleteModal.bind(this);
-    this.addUserGroup = this.addUserGroup.bind(this);
-    this.deleteUserGroupById = this.deleteUserGroupById.bind(this);
-  }
-
-  async componentDidMount() {
-    await this.syncUserGroupAndRelations();
-  }
-
-  async showDeleteModal(group) {
-    try {
-      await this.syncUserGroupAndRelations();
-
-      this.setState({
-        selectedUserGroup: group,
-        isDeleteModalShow: true,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  hideDeleteModal() {
-    this.setState({
-      selectedUserGroup: undefined,
-      isDeleteModalShow: false,
-    });
-  }
-
-  addUserGroup(userGroup, users) {
-    this.setState((prevState) => {
-      const userGroupRelations = Object.assign(prevState.userGroupRelations, {
-        [userGroup._id]: users,
-      });
-
-      return {
-        userGroups: [...prevState.userGroups, userGroup],
-        userGroupRelations,
-      };
-    });
-  }
-
-  async deleteUserGroupById({ deleteGroupId, actionName, transferToUserGroupId }) {
-    try {
-      const res = await this.props.appContainer.apiv3.delete(`/user-groups/${deleteGroupId}`, {
-        actionName,
-        transferToUserGroupId,
-      });
-
-      this.setState((prevState) => {
-        const userGroups = prevState.userGroups.filter((userGroup) => {
-          return userGroup._id !== deleteGroupId;
-        });
-
-        delete prevState.userGroupRelations[deleteGroupId];
-
-        return {
-          userGroups,
-          userGroupRelations: prevState.userGroupRelations,
-          selectedUserGroup: undefined,
-          isDeleteModalShow: false,
-        };
-      });
-
-      toastSuccess(`Deleted a group "${this.xss.process(res.data.userGroup.name)}"`);
-    }
-    catch (err) {
-      toastError(new Error('Unable to delete the group'));
-    }
-  }
-
-  async syncUserGroupAndRelations() {
-    try {
-      const userGroupsRes = await this.props.appContainer.apiv3.get('/user-groups', { pagination: false });
-      const userGroupRelationsRes = await this.props.appContainer.apiv3.get('/user-group-relations');
-
-      this.setState({
-        userGroups: userGroupsRes.data.userGroups,
-        userGroupRelations: userGroupRelationsRes.data.userGroupRelations,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { isAclEnabled } = this.props.appContainer.config;
-
-    return (
-      <Fragment>
-        <UserGroupCreateForm
-          isAclEnabled={isAclEnabled}
-          onCreate={this.addUserGroup}
-        />
-        <UserGroupTable
-          userGroups={this.state.userGroups}
-          isAclEnabled={isAclEnabled}
-          onDelete={this.showDeleteModal}
-          userGroupRelations={this.state.userGroupRelations}
-        />
-        <UserGroupDeleteModal
-          userGroups={this.state.userGroups}
-          deleteUserGroup={this.state.selectedUserGroup}
-          onDelete={this.deleteUserGroupById}
-          isShow={this.state.isDeleteModalShow}
-          onShow={this.showDeleteModal}
-          onHide={this.hideDeleteModal}
-        />
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
-
-UserGroupPage.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default UserGroupPageWrapper;

+ 158 - 0
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -0,0 +1,158 @@
+import React, {
+  FC, Fragment, useState, useCallback,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupTable from './UserGroupTable';
+import UserGroupForm from './UserGroupForm';
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import Xss from '~/services/xss';
+import { CustomWindow } from '~/interfaces/global';
+import { apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
+import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+
+type Props = {
+  appContainer: AppContainer,
+};
+
+const UserGroupPage: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+  const { t } = useTranslation();
+  const { isAclEnabled } = props.appContainer.config;
+
+  /*
+   * Fetch
+   */
+  const { data: userGroups, mutate: mutateUserGroups } = useSWRxUserGroupList();
+  const userGroupIds = userGroups?.map(group => group._id);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(userGroupIds);
+  const { data: childUserGroups } = useSWRxChildUserGroupList(userGroupIds);
+
+  /*
+   * State
+   */
+  const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+
+  /*
+   * Functions
+   */
+  const syncUserGroupAndRelations = useCallback(async() => {
+    try {
+      await mutateUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateUserGroups]);
+
+  const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
+    try {
+      await syncUserGroupAndRelations();
+
+      setSelectedUserGroup(group);
+      setDeleteModalShown(true);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [syncUserGroupAndRelations]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, []);
+
+  const addUserGroup = useCallback(async(userGroupData: IUserGroup) => {
+    try {
+      await apiv3Post('/user-groups', {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parent: userGroupData.parent,
+      });
+
+      // sync
+      await mutateUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateUserGroups]);
+
+  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateUserGroups();
+
+      setSelectedUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateUserGroups, mutateUserGroupRelations]);
+
+  if (userGroups == null || userGroupRelations == null || childUserGroups == null) {
+    return <></>;
+  }
+
+  return (
+    <Fragment>
+      {
+        isAclEnabled ? (
+          <div className="mb-2">
+            <button type="button" className="btn btn-outline-secondary" data-toggle="collapse" data-target="#createGroupForm">
+              {t('admin:user_group_management.create_group')}
+            </button>
+            <div id="createGroupForm" className="collapse">
+              <UserGroupForm
+                successedMessage={t('toaster.create_succeeded', { target: t('UserGroup') })}
+                failedMessage={t('toaster.create_failed', { target: t('UserGroup') })}
+                submitButtonLabel={t('Create')}
+                onSubmit={addUserGroup}
+              />
+            </div>
+          </div>
+        ) : (
+          t('admin:user_group_management.deny_create_group')
+        )
+      }
+      <UserGroupTable
+        appContainer={props.appContainer}
+        userGroups={userGroups}
+        childUserGroups={childUserGroups}
+        isAclEnabled={isAclEnabled}
+        onDelete={showDeleteModal}
+        userGroupRelations={userGroupRelations}
+      />
+      <UserGroupDeleteModal
+        appContainer={props.appContainer}
+        userGroups={userGroups}
+        deleteUserGroup={selectedUserGroup}
+        onDelete={deleteUserGroupById}
+        isShow={isDeleteModalShown}
+        onShow={showDeleteModal}
+        onHide={hideDeleteModal}
+      />
+    </Fragment>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageWrapper = withUnstatedContainers(UserGroupPage, [AppContainer]);
+
+export default UserGroupPageWrapper;

+ 0 - 157
packages/app/src/components/Admin/UserGroup/UserGroupTable.jsx

@@ -1,157 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-class UserGroupTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.xss = window.xss;
-
-    this.state = {
-      userGroups: this.props.userGroups,
-      userGroupMap: {},
-    };
-
-    this.generateUserGroupMap = this.generateUserGroupMap.bind(this);
-    this.onDelete = this.onDelete.bind(this);
-  }
-
-  componentWillMount() {
-    const userGroupMap = this.generateUserGroupMap(this.props.userGroups, this.props.userGroupRelations);
-    this.setState({ userGroupMap });
-  }
-
-  componentWillReceiveProps(nextProps) {
-    const { userGroups, userGroupRelations } = nextProps;
-    const userGroupMap = this.generateUserGroupMap(userGroups, userGroupRelations);
-
-    this.setState({
-      userGroups,
-      userGroupMap,
-    });
-  }
-
-  generateUserGroupMap(userGroups, userGroupRelations) {
-    const userGroupMap = {};
-    userGroupRelations.forEach((relation) => {
-      const group = relation.relatedGroup;
-
-      const users = userGroupMap[group] || [];
-      users.push(relation.relatedUser);
-
-      // register
-      userGroupMap[group] = users;
-    });
-
-    return userGroupMap;
-  }
-
-  onDelete(e) {
-    const { target } = e;
-    const groupId = target.getAttribute('data-user-group-id');
-    const group = this.state.userGroups.find((group) => {
-      return group._id === groupId;
-    });
-
-    this.props.onDelete(group);
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Fragment>
-        <h2>{t('admin:user_group_management.group_list')}</h2>
-
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th>{t('Name')}</th>
-              <th>{t('User')}</th>
-              <th width="100px">{t('Created')}</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          <tbody>
-            {this.state.userGroups.map((group) => {
-              const users = this.state.userGroupMap[group._id];
-
-              return (
-                <tr key={group._id}>
-                  {this.props.isAclEnabled
-                    ? (
-                      <td><a href={`/admin/user-group-detail/${group._id}`}>{this.xss.process(group.name)}</a></td>
-                    )
-                    : (
-                      <td>{this.xss.process(group.name)}</td>
-                    )
-                  }
-                  <td>
-                    <ul className="list-inline">
-                      {users != null && users.map((user) => {
-                        return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{this.xss.process(user.username)}</li>;
-                      })}
-                    </ul>
-                  </td>
-                  <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                  {this.props.isAclEnabled
-                    ? (
-                      <td>
-                        <div className="btn-group admin-group-menu">
-                          <button
-                            type="button"
-                            id={`admin-group-menu-button-${group._id}`}
-                            className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                            data-toggle="dropdown"
-                          >
-                            <i className="icon-settings"></i>
-                          </button>
-                          <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
-                            <a className="dropdown-item" href={`/admin/user-group-detail/${group._id}`}>
-                              <i className="icon-fw icon-note"></i> {t('Edit')}
-                            </a>
-                            <button className="dropdown-item" type="button" role="button" onClick={this.onDelete} data-user-group-id={group._id}>
-                              <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
-                            </button>
-                          </div>
-                        </div>
-                      </td>
-                    )
-                    : (
-                      <td></td>
-                    )
-                  }
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupTableWrapper = withUnstatedContainers(UserGroupTable, [AppContainer]);
-
-
-UserGroupTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
-  userGroupRelations: PropTypes.arrayOf(PropTypes.object).isRequired,
-  isAclEnabled: PropTypes.bool.isRequired,
-  onDelete: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(UserGroupTableWrapper);

+ 187 - 0
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -0,0 +1,187 @@
+import React, {
+  FC, useState, useCallback, useEffect,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import Xss from '~/services/xss';
+import AppContainer from '~/client/services/AppContainer';
+import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
+import { CustomWindow } from '~/interfaces/global';
+
+
+type Props = {
+  appContainer: AppContainer,
+
+  userGroups: IUserGroupHasId[],
+  userGroupRelations: IUserGroupRelation[],
+  childUserGroups: IUserGroupHasId[],
+  isAclEnabled: boolean,
+  onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+};
+
+/*
+ * Utility
+ */
+const generateGroupIdToUsersMap = (userGroupRelations: IUserGroupRelation[]): Record<string, Partial<IUserHasId>[]> => {
+  const userGroupMap = {};
+  userGroupRelations.forEach((relation) => {
+    const group = relation.relatedGroup as string; // must be an id of related group
+
+    const users: Partial<IUserHasId>[] = userGroupMap[group] || [];
+    users.push(relation.relatedUser as IUserHasId);
+
+    // register
+    userGroupMap[group] = users;
+  });
+
+  return userGroupMap;
+};
+
+const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Record<string, IUserGroupHasId[]> => {
+  const map = {};
+  childUserGroups.forEach((group) => {
+    const parentId = group.parent as string; // must be an id
+
+    const groups: Partial<IUserGroupHasId>[] = map[parentId] || [];
+    groups.push(group);
+
+    // register
+    map[parentId] = groups;
+  });
+
+  return map;
+};
+
+
+const UserGroupTable: FC<Props> = (props: Props) => {
+  const xss: Xss = (window as CustomWindow).xss;
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+
+  /*
+   * Function
+   */
+  const onClickDelete = useCallback((e) => { // no preventDefault
+    if (props.onDelete == null) {
+      return;
+    }
+
+    const groupId = e.target.getAttribute('data-user-group-id');
+    const group = props.userGroups.find((group) => {
+      return group._id === groupId;
+    });
+
+    if (group == null) {
+      return;
+    }
+
+    props.onDelete(group);
+  }, [props.userGroups, props.onDelete]);
+
+  /*
+   * useEffect
+   */
+  useEffect(() => {
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  }, [props.userGroupRelations, props.childUserGroups]);
+
+  return (
+    <>
+      <h2>{t('admin:user_group_management.group_list')}</h2>
+
+      <table className="table table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th>{t('Name')}</th>
+            <th>{t('Description')}</th>
+            <th>{t('User')}</th>
+            <th>{t('Child groups')}</th>
+            <th style={{ width: 100 }}>{t('Created')}</th>
+            <th style={{ width: 70 }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.userGroups.map((group) => {
+            const users = groupIdToUsersMap[group._id];
+
+            return (
+              <tr key={group._id}>
+                {props.isAclEnabled
+                  ? (
+                    <td><a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a></td>
+                  )
+                  : (
+                    <td>{xss.process(group.name)}</td>
+                  )
+                }
+                <td>{xss.process(group.description)}</td>
+                <td>
+                  <ul className="list-inline">
+                    {users != null && users.map((user) => {
+                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{xss.process(user.username)}</li>;
+                    })}
+                  </ul>
+                </td>
+                <td>
+                  <ul className="list-inline">
+                    {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
+                      return (
+                        <li key={group._id} className="list-inline-item badge badge-success">
+                          {props.isAclEnabled
+                            ? (
+                              <a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a>
+                            )
+                            : (
+                              <p>{xss.process(group.name)}</p>
+                            )
+                          }
+                        </li>
+                      );
+                    })}
+                  </ul>
+                </td>
+                <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
+                {props.isAclEnabled
+                  ? (
+                    <td>
+                      <div className="btn-group admin-group-menu">
+                        <button
+                          type="button"
+                          id={`admin-group-menu-button-${group._id}`}
+                          className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                          data-toggle="dropdown"
+                        >
+                          <i className="icon-settings"></i>
+                        </button>
+                        <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
+                          <a className="dropdown-item" href={`/admin/user-group-detail/${group._id}`}>
+                            <i className="icon-fw icon-note"></i> {t('Edit')}
+                          </a>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
+                            <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
+                          </button>
+                        </div>
+                      </div>
+                    </td>
+                  )
+                  : (
+                    <td></td>
+                  )
+                }
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </>
+  );
+};
+
+export default UserGroupTable;

+ 0 - 49
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx

@@ -1,49 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import UserGroupEditForm from './UserGroupEditForm';
-import UserGroupUserTable from './UserGroupUserTable';
-import UserGroupUserModal from './UserGroupUserModal';
-import UserGroupPageList from './UserGroupPageList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-class UserGroupDetailPage extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div>
-        <a href="/admin/user-groups" className="btn btn-outline-secondary">
-          <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-          {t('admin:user_group_management.back_to_list')}
-        </a>
-        <div className="mt-4 form-box">
-          <UserGroupEditForm />
-        </div>
-        <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
-        <UserGroupUserTable />
-        <UserGroupUserModal />
-        <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
-        <div className="page-list">
-          <UserGroupPageList />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-UserGroupDetailPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
-
-export default withTranslation()(UserGroupDetailPageWrapper);

+ 179 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -0,0 +1,179 @@
+import React, {
+  FC, useState, useCallback,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
+import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupPageList from './UserGroupPageList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import {
+  apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
+} from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IPageHasId } from '~/interfaces/page';
+import {
+  IUserGroup, IUserGroupHasId, IUserGroupRelation,
+} from '~/interfaces/user';
+import { useSWRxUserGroupPages, useSWRxUserGroupRelations, useSWRxSelectableUserGroups } from '~/stores/user-group';
+
+
+const UserGroupDetailPage: FC = () => {
+  const rootElem = document.getElementById('admin-user-group-detail');
+  const { t } = useTranslation();
+
+  /*
+   * State (from AdminUserGroupDetailContainer)
+   */
+  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
+
+  // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
+  const [childUserGroups, setChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+  const [grandChildUserGroups, setGrandChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+
+  const [childUserGroupRelations, setChildUserGroupRelations] = useState<IUserGroupRelation[]>([]); // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list
+  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
+  const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
+  const [searchType, setSearchType] = useState<string>('partial');
+  const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
+  const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+
+  /*
+   * Fetch
+   */
+  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
+
+  /*
+   * Function
+   */
+  // TODO 85062: old name: switchIsAlsoMailSearched
+  const toggleIsAlsoMailSearched = useCallback(() => {
+    setAlsoMailSearched(prev => !prev);
+  }, []);
+
+  // TODO 85062: old name: switchIsAlsoNameSearched
+  const toggleAlsoNameSearched = useCallback(() => {
+    setAlsoNameSearched(prev => !prev);
+  }, []);
+
+  const switchSearchType = useCallback((searchType) => {
+    setSearchType(searchType);
+  }, []);
+
+  const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
+    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+    const { userGroup: newUserGroup } = res.data;
+
+    setUserGroup(newUserGroup);
+
+    return newUserGroup;
+  }, [userGroup]);
+
+  const openUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(true);
+  }, []);
+
+  const closeUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(false);
+  }, []);
+
+  const fetchApplicableUsers = useCallback(async(searchWord) => {
+    const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
+      searchWord,
+      searchType,
+      isAlsoMailSearched,
+      isAlsoNameSearched,
+    });
+
+    const { users } = res.data;
+
+    return users;
+  }, [searchType, isAlsoMailSearched, isAlsoNameSearched]);
+
+  // TODO 85062: will be used in UserGroupUserFormByInput
+  const addUserByUsername = useCallback(async(username: string) => {
+    await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
+
+  const removeUserByUsername = useCallback(async(username: string) => {
+    await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    mutateUserGroupRelations();
+  }, [userGroup, mutateUserGroupRelations]);
+
+  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
+        name: selectedUserGroup.name,
+        description: selectedUserGroup.description,
+        parentId: userGroup._id,
+        forceUpdateParents: false, //  TODO 87748: Make forceUpdateParents optionally selectable
+      });
+      mutateSelectableUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  // TODO 87614: UserGroup New creation form can be displayed in modal
+  const onClickCreateChildGroupButtonHandler = () => {
+    console.log('button clicked!');
+  };
+
+  /*
+   * Dependencies
+   */
+  if (userGroup == null) {
+    return <></>;
+  }
+
+  return (
+    <div>
+      <a href="/admin/user-groups" className="btn btn-outline-secondary">
+        <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+        {t('admin:user_group_management.back_to_list')}
+      </a>
+      {/* TODO 85062: Link to the ancestors group */}
+      <div className="mt-4 form-box">
+        <UserGroupForm
+          userGroup={userGroup}
+          successedMessage={t('toaster.update_successed', { target: t('UserGroup') })}
+          failedMessage={t('toaster.update_failed', { target: t('UserGroup') })}
+          submitButtonLabel={t('Update')}
+          onSubmit={updateUserGroup}
+        />
+      </div>
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
+      <UserGroupUserTable />
+      <UserGroupUserModal />
+
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
+      <UserGroupDropdown
+        selectableUserGroups={selectableUserGroups}
+        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
+        onClickCreateUserGroupButtonHandler={() => onClickCreateChildGroupButtonHandler()}
+      />
+
+      <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
+      <div className="page-list">
+        <UserGroupPageList />
+      </div>
+    </div>
+  );
+
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
+
+export default UserGroupDetailPageWrapper;

+ 0 - 111
packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -1,111 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import dateFnsFormat from 'date-fns/format';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class UserGroupEditForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { adminUserGroupDetailContainer } = props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
-
-    this.state = {
-      name: userGroup.name,
-      nameCache: userGroup.name, // cache for name. update every submit
-    };
-
-    this.xss = window.xss;
-
-    this.changeUserGroupName = this.changeUserGroupName.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  changeUserGroupName(event) {
-    this.setState({
-      name: event.target.value,
-    });
-  }
-
-  async handleSubmit(e) {
-    e.preventDefault();
-
-    try {
-      const res = await this.props.adminUserGroupDetailContainer.updateUserGroup({
-        name: this.state.name,
-      });
-
-      toastSuccess(`Updated the group name to "${this.xss.process(res.data.userGroup.name)}"`);
-      this.setState({ nameCache: this.state.name });
-    }
-    catch (err) {
-      toastError(new Error('Unable to update the group name'));
-    }
-  }
-
-  validateForm() {
-    return (
-      this.state.name !== this.state.nameCache
-      && this.state.name !== ''
-    );
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <form onSubmit={this.handleSubmit}>
-        <fieldset>
-          <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
-          <div className="form-group row">
-            <label htmlFor="name" className="col-md-2 col-form-label">
-              {t('Name')}
-            </label>
-            <div className="col-md-4">
-              <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.changeUserGroupName} />
-            </div>
-          </div>
-          <div className="form-group row">
-            <label className="col-md-2 col-form-label">{t('Created')}</label>
-            <div className="col-md-4">
-              <input
-                type="text"
-                className="form-control"
-                value={dateFnsFormat(new Date(adminUserGroupDetailContainer.state.userGroup.createdAt), 'yyyy-MM-dd')}
-                disabled
-              />
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="offset-md-2 col-md-10">
-              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
-                {t('Update')}
-              </button>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-    );
-  }
-
-}
-
-UserGroupEditForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupEditFormWrapper = withUnstatedContainers(UserGroupEditForm, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default withTranslation()(UserGroupEditFormWrapper);

+ 2 - 2
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -2,7 +2,7 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import Page from '../../PageList/Page';
+import PageListItemS from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
@@ -57,7 +57,7 @@ class UserGroupPageList extends React.Component {
     return (
       <Fragment>
         <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><Page page={page} /></li>)}
+          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
         </ul>
         {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
           <PaginationWrapper

+ 32 - 37
packages/app/src/components/BookmarkButtons.tsx

@@ -1,49 +1,39 @@
 import React, { FC, useState } from 'react';
 
-import { Types } from 'mongoose';
 import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 
+import { IUser } from '../interfaces/user';
+
 import UserPictureList from './User/UserPictureList';
-import { toastError } from '~/client/util/apiNotification';
 import { useIsGuestUser } from '~/stores/context';
-import { useSWRxBookmarksInfo } from '~/stores/bookmarks';
-import { apiv3Put } from '~/client/util/apiv3-client';
 
 interface Props {
-  pageId: Types.ObjectId
+  bookmarkCount?: number
+  isBookmarked?: boolean
+  bookmarkedUsers?: IUser[]
+  hideTotalNumber?: boolean
+  onBookMarkClicked: ()=>void;
 }
 
-const BookmarkButton: FC<Props> = (props: Props) => {
+const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const { pageId } = props;
+
+  const {
+    bookmarkCount, isBookmarked, bookmarkedUsers, hideTotalNumber,
+  } = props;
 
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: bookmarksInfo, mutate } = useSWRxBookmarksInfo(pageId);
-
-  const isBookmarked = bookmarksInfo?.isBookmarked != null ? bookmarksInfo.isBookmarked : false;
-  const sumOfBookmarks = bookmarksInfo?.sumOfBookmarks != null ? bookmarksInfo.sumOfBookmarks : 0;
-  const bookmarkedUsers = bookmarksInfo?.bookmarkedUsers != null ? bookmarksInfo.bookmarkedUsers : [];
 
   const togglePopover = () => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
   const handleClick = async() => {
-    if (isGuestUser) {
-      return;
-    }
-
-    try {
-      const res = await apiv3Put('/bookmarks', { pageId, bool: !isBookmarked });
-      if (res) {
-        mutate();
-      }
-    }
-    catch (err) {
-      toastError(err);
+    if (props.onBookMarkClicked != null) {
+      props.onBookMarkClicked();
     }
   };
 
@@ -56,7 +46,7 @@ const BookmarkButton: FC<Props> = (props: Props) => {
         className={`btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <i className="icon-star"></i>
+        <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
 
       {isGuestUser && (
@@ -65,19 +55,24 @@ const BookmarkButton: FC<Props> = (props: Props) => {
         </UncontrolledTooltip>
       )}
 
-      <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${isBookmarked ? 'active' : ''}`}>
-        {sumOfBookmarks}
-      </button>
-
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className="seen-user-popover">
-          <div className="px-2 text-right user-list-content text-truncate text-muted">
-            {bookmarkedUsers.length ? <UserPictureList users={bookmarkedUsers} /> : t('No users have bookmarked yet')}
-          </div>
-        </PopoverBody>
-      </Popover>
+      { !hideTotalNumber && (
+        <>
+          <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
+            {bookmarkCount ?? 0}
+          </button>
+          { bookmarkedUsers != null && (
+            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+              <PopoverBody className="user-list-popover">
+                <div className="px-2 text-right user-list-content text-truncate text-muted">
+                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
+                </div>
+              </PopoverBody>
+            </Popover>
+          ) }
+        </>
+      ) }
     </div>
   );
 };
 
-export default BookmarkButton;
+export default BookmarkButtons;

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

@@ -0,0 +1,127 @@
+import React, {
+  FC, memo, useEffect, useRef, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+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
+  value?: string
+  placeholder?: string
+  inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
+  onPressEnter?(inputText: string | null): void
+  onClickOutside?(): void
+}
+
+const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
+  const { t } = useTranslation();
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const [inputText, setInputText] = useState(props.value);
+  const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
+
+  const createValidation = async(inputText: string) => {
+    if (props.inputValidator != null) {
+      const alertInfo = await props.inputValidator(inputText);
+      setAlertInfo(alertInfo);
+    }
+  };
+
+  const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    createValidation(inputText);
+    setInputText(inputText);
+  };
+
+  const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    await createValidation(inputText);
+  };
+
+  const onPressEnter = () => {
+    if (props.onPressEnter != null) {
+      const text = inputText != null ? inputText.trim() : null;
+      if (currentAlertInfo == null) {
+        props.onPressEnter(text);
+      }
+    }
+  };
+
+  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();
+  });
+
+
+  const AlertInfo = () => {
+    if (currentAlertInfo == null) {
+      return <></>;
+    }
+
+    const alertType = currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR;
+    const alertMessage = currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value';
+    const alertTextStyle = alertType === AlertType.ERROR ? 'text-danger' : 'text-warning';
+    const translation = alertType === AlertType.ERROR ? 'Error' : 'Warning';
+    return (
+      <p className={`${alertTextStyle} text-center mt-1`}>{t(translation)}: {alertMessage}</p>
+    );
+  };
+
+
+  return (
+    <div className={props.isShown ? 'd-block' : 'd-none'}>
+      <input
+        value={inputText || ''}
+        ref={inputRef}
+        type="text"
+        className="form-control"
+        placeholder={props.placeholder}
+        name="input"
+        onFocus={onFocusHandler}
+        onChange={onChangeHandler}
+        onKeyDown={onKeyDownHandler}
+        onBlur={onBlurHandler}
+        autoFocus={false}
+      />
+      <AlertInfo />
+    </div>
+  );
+});
+
+export default ClosableTextInput;

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

@@ -0,0 +1,259 @@
+import React, { useState, useCallback } from 'react';
+import {
+  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
+} from 'reactstrap';
+
+import toastr from 'toastr';
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import {
+  IPageInfoAll, isIPageInfoForOperation,
+} from '~/interfaces/page';
+import { useSWRxPageInfo } from '~/stores/page';
+
+const logger = loggerFactory('growi:cli:PageItemControl');
+
+
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+
+type CommonProps = {
+  pageInfo?: IPageInfoAll,
+  isEnableActions?: boolean,
+  showBookmarkMenuItem?: boolean,
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
+
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+}
+
+
+type DropdownMenuProps = CommonProps & {
+  pageId: string,
+  isLoading?: boolean,
+}
+
+const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
+  const { t } = useTranslation('');
+
+  const {
+    pageId, isLoading,
+    pageInfo, isEnableActions, showBookmarkMenuItem,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+  } = props;
+
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const bookmarkItemClickedHandler = useCallback(async() => {
+    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+      return;
+    }
+    await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
+  }, [onClickBookmarkMenuItem, pageId, pageInfo]);
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const duplicateItemClickedHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const renameItemClickedHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
+
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const deleteItemClickedHandler = useCallback(async() => {
+    if (pageInfo == null || onClickDeleteMenuItem == null) {
+      return;
+    }
+    if (!pageInfo.isDeletable) {
+      logger.warn('This page could not be deleted.');
+      return;
+    }
+    await onClickDeleteMenuItem(pageId);
+  }, [onClickDeleteMenuItem, pageId, pageInfo]);
+
+  let contents = <></>;
+
+  if (isLoading) {
+    contents = (
+      <div className="text-muted text-center my-2">
+        <i className="fa fa-spinner fa-pulse"></i>
+      </div>
+    );
+  }
+  else if (pageId != null && pageInfo != null) {
+    contents = (
+      <>
+        { !isEnableActions && (
+          <DropdownItem>
+            <p>
+              {t('search_result.currently_not_implemented')}
+            </p>
+          </DropdownItem>
+        ) }
+
+        {/* Bookmark */}
+        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem onClick={bookmarkItemClickedHandler}>
+            <i className="fa fa-fw fa-bookmark-o"></i>
+            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
+          </DropdownItem>
+        ) }
+
+        {/* Duplicate */}
+        { isEnableActions && (
+          <DropdownItem onClick={duplicateItemClickedHandler}>
+            <i className="icon-fw icon-docs"></i>
+            {t('Duplicate')}
+          </DropdownItem>
+        ) }
+
+        {/* Move/Rename */}
+        { isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
+            <i className="icon-fw  icon-action-redo"></i>
+            {t('Move/Rename')}
+          </DropdownItem>
+        ) }
+
+        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+        {/* divider */}
+        {/* Delete */}
+        { isEnableActions && pageInfo.isMovable && (
+          <>
+            <DropdownItem divider />
+            <DropdownItem
+              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              disabled={!pageInfo.isDeletable}
+              onClick={deleteItemClickedHandler}
+            >
+              <i className="icon-fw icon-trash"></i>
+              {t('Delete')}
+            </DropdownItem>
+          </>
+        )}
+      </>
+    );
+  }
+
+  return (
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+      {contents}
+    </DropdownMenu>
+  );
+});
+
+
+type PageItemControlSubstanceProps = CommonProps & {
+  pageId: string,
+  fetchOnInit?: boolean,
+  children?: React.ReactNode,
+}
+
+export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
+
+  const {
+    pageId, pageInfo: presetPageInfo, fetchOnInit,
+    children,
+    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem,
+  } = props;
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
+  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+
+  // mutate after handle event
+  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
+    if (onClickBookmarkMenuItem != null) {
+      await onClickBookmarkMenuItem(_pageId, _newValue);
+    }
+
+    if (shouldMutate) {
+      mutatePageInfo();
+    }
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+
+  const isLoading = shouldFetch && fetchedPageInfo == null;
+
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
+  const renameMenuItemClickHandler = useCallback(async() => {
+    if (onClickRenameMenuItem == null) {
+      return;
+    }
+    await onClickRenameMenuItem(pageId);
+  }, [onClickRenameMenuItem, pageId]);
+
+  return (
+    <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
+
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
+          <i className="icon-options text-muted"></i>
+        </DropdownToggle>
+      ) }
+
+      <PageItemControlDropdownMenu
+        {...props}
+        isLoading={isLoading}
+        pageInfo={fetchedPageInfo ?? presetPageInfo}
+        onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+        onClickRenameMenuItem={renameMenuItemClickHandler}
+      />
+    </Dropdown>
+  );
+
+};
+
+
+type PageItemControlProps = CommonProps & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} {...props} />;
+};
+
+
+type AsyncPageItemControlProps = Omit<CommonProps, 'pageInfo'> & {
+  pageId?: string,
+  children?: React.ReactNode,
+}
+
+export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Element => {
+  const { pageId } = props;
+
+  if (pageId == null) {
+    return <></>;
+  }
+
+  return <PageItemControlSubstance pageId={pageId} fetchOnInit {...props} />;
+};

+ 3 - 11
packages/app/src/components/ComparePathsTable.jsx

@@ -3,17 +3,14 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
-import { withUnstatedContainers } from './UnstatedUtils';
 
-import PageContainer from '~/client/services/PageContainer';
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 function ComparePathsTable(props) {
   const {
-    subordinatedPages, pageContainer, newPagePath, t,
+    path, subordinatedPages, newPagePath, t,
   } = props;
-  const { path } = pageContainer.state;
 
   return (
     <table className="table table-bordered grw-compare-paths-table">
@@ -45,18 +42,13 @@ function ComparePathsTable(props) {
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const PageDuplicateModallWrapper = withUnstatedContainers(ComparePathsTable, [PageContainer]);
-
 ComparePathsTable.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
 
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  path: PropTypes.string.isRequired,
   subordinatedPages: PropTypes.array.isRequired,
   newPagePath: PropTypes.string.isRequired,
 };
 
 
-export default withTranslation()(PageDuplicateModallWrapper);
+export default withTranslation()(ComparePathsTable);

+ 0 - 96
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,96 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-
-import { pagePathUtils } from '@growi/core';
-import AppContainer from '~/client/services/AppContainer';
-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;
-
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const ContentLinkButtons = (props) => {
-
-  const { appContainer, pageContainer } = props;
-  const { pageUser, path } = pageContainer.state;
-  const { isPageExist } = pageContainer.state;
-  const { isSharedUser } = appContainer;
-
-  const isTopPagePath = isTopPage(path);
-
-  // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const CommentLinkButton = () => {
-    return (
-      <div className="mt-3">
-        <button
-          type="button"
-          className="btn btn-outline-secondary btn-sm btn-block"
-          onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-        >
-          <i className="mr-2 icon-fw icon-bubbles"></i>
-          <span>Comments</span>
-        </button>
-      </div>
-    );
-  };
-
-  const BookMarkLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="mr-2 icon-star"></i>
-        <span>Bookmarks</span>
-      </button>
-
-    );
-  };
-
-  const RecentlyCreatedLinkButton = () => {
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-
-    );
-  };
-
-  return (
-    <>
-      {isPageExist && !isSharedUser && !isTopPagePath && <CommentLinkButton />}
-
-      <div className="mt-3 d-flex justify-content-between">
-        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
-      </div>
-    </>
-  );
-
-};
-
-ContentLinkButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withUnstatedContainers(ContentLinkButtons, [AppContainer, PageContainer]);

+ 66 - 0
packages/app/src/components/ContentLinkButtons.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback, useMemo } from 'react';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { usePageUser } from '~/stores/context';
+
+const WIKI_HEADER_LINK = 120;
+
+
+const ContentLinkButtons = (): JSX.Element => {
+
+  const { data: pageUser } = usePageUser();
+
+  // get element for smoothScroll
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const BookMarkLinkButton = useCallback((): JSX.Element => {
+    if (getBookMarkListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="fa fa-fw fa-bookmark-o"></i>
+        <span>Bookmarks</span>
+      </button>
+    );
+  }, [getBookMarkListHeaderDom]);
+
+  const RecentlyCreatedLinkButton = useCallback(() => {
+    if (getRecentlyCreatedListHeaderDom == null) {
+      return <></>;
+    }
+
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+    );
+  }, [getRecentlyCreatedListHeaderDom]);
+
+  if (pageUser == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="mt-3 d-flex justify-content-between">
+      <BookMarkLinkButton />
+      <RecentlyCreatedLinkButton />
+    </div>
+  );
+
+};
+
+export default ContentLinkButtons;

+ 3 - 13
packages/app/src/components/CreateTemplateModal.jsx

@@ -6,14 +6,11 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
-import { withUnstatedContainers } from './UnstatedUtils';
 
-import PageContainer from '~/client/services/PageContainer';
 
 const CreateTemplateModal = (props) => {
-  const { t, pageContainer } = props;
+  const { t, path } = props;
 
-  const { path } = pageContainer.state;
   const parentPath = pathUtils.addTrailingSlash(path);
 
   function generateUrl(label) {
@@ -67,18 +64,11 @@ const CreateTemplateModal = (props) => {
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const CreateTemplateModalWrapper = withUnstatedContainers(CreateTemplateModal, [PageContainer]);
-
-
 CreateTemplateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
+  path: PropTypes.string.isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(CreateTemplateModalWrapper);
+export default withTranslation()(CreateTemplateModal);

+ 22 - 4
packages/app/src/components/CustomNavigation/CustomTabContent.jsx → packages/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -1,22 +1,40 @@
-import React from 'react';
+import React, { useEffect, useState } from 'react';
 import PropTypes from 'prop-types';
 import {
   TabContent, TabPane,
 } from 'reactstrap';
 
-const CustomTabContent = (props) => {
+import { ICustomNavTabMappings } from '~/interfaces/ui';
+
+
+type Props = {
+  activeTab: string,
+  navTabMapping: ICustomNavTabMappings,
+  additionalClassNames?: string[],
+
+}
+
+const CustomTabContent = (props: Props): JSX.Element => {
 
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
+  const [activatedContent, setActivatedContent] = useState<Set<string>>(new Set<string>());
+
+  // add activated content to Set
+  useEffect(() => {
+    setActivatedContent(activatedContent.add(activeTab));
+  }, [activatedContent, activeTab]);
+
   return (
-    <TabContent activeTab={activeTab} className={additionalClassNames.join(' ')}>
+    <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
       {Object.entries(navTabMapping).map(([key, value]) => {
 
+        const shouldRender = key === activeTab || activatedContent.has(key);
         const { Content } = value;
 
         return (
           <TabPane key={key} tabId={key}>
-            <Content />
+            { shouldRender && <Content /> }
           </TabPane>
         );
       })}

+ 106 - 0
packages/app/src/components/DescendantsPageList.tsx

@@ -0,0 +1,106 @@
+import React, { useState } from 'react';
+import {
+  IPageHasId, IPageWithMeta,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
+
+import PageList from './PageList/PageList';
+import PaginationWrapper from './PaginationWrapper';
+
+type Props = {
+  path: string,
+}
+
+
+const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
+  return { pageData: page };
+};
+
+const DescendantsPageList = (props: Props): JSX.Element => {
+  const { path } = props;
+
+  const [activePage, setActivePage] = useState(1);
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: pagingResult, error } = useSWRxPageList(isSharedUser ? null : path, activePage);
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+
+  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+
+  // initial data
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    // convert without meta at first
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
+    };
+  }
+
+  // inject data for listing
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    const pageWithMetas = pages.map((page) => {
+      const pageInfo = (idToPageInfo ?? {})[page._id];
+
+      return {
+        pageData: page,
+        pageMeta: pageInfo,
+      } as IPageWithMeta;
+    });
+
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pageWithMetas,
+    };
+  }
+
+  function setPageNumber(selectedPageNumber) {
+    setActivePage(selectedPageNumber);
+  }
+
+  if (error != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{error.message}</div>
+      </div>
+    );
+  }
+
+  if (pagingResult == null || pagingResultWithMeta == null) {
+    return (
+      <div className="wiki">
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
+
+      <div className="my-4">
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={setPageNumber}
+          totalItemsCount={pagingResult.totalCount}
+          pagingLimit={pagingResult.limit}
+          align="center"
+        />
+      </div>
+    </>
+  );
+};
+
+export default DescendantsPageList;

+ 100 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -0,0 +1,100 @@
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useDescendantsPageListModal } from '~/stores/modal';
+import { useIsSharedUser } from '~/stores/context';
+
+import DescendantsPageList from './DescendantsPageList';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+import PageTimeline from './PageTimeline';
+
+
+type Props = {
+}
+
+export const DescendantsPageListModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: status, close } = useDescendantsPageListModal();
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: () => {
+          if (status == null || status.path == null || !status.isOpened) {
+            return <></>;
+          }
+          return <DescendantsPageList path={status.path} />;
+        },
+        i18n: t('page_list'),
+        index: 0,
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: TimeLineIcon,
+        Content: () => <PageTimeline />,
+        i18n: t('Timeline View'),
+        index: 1,
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, status, t]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={v => setActiveTab(v)}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody>
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+
+};

+ 3 - 3
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,4 +1,4 @@
-import { FC, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
@@ -6,7 +6,7 @@ import { useIsEditable } from '~/stores/context';
 /**
  * Change editorMode by browser forward/back operation
  */
-const HashChanged: FC<void> = () => {
+const HashChanged = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
@@ -33,7 +33,7 @@ const HashChanged: FC<void> = () => {
 
   }, [hashchangeHandler, isEditable, mutateEditorMode]);
 
-  return null;
+  return <></>;
 };
 
 export default HashChanged;

+ 4 - 3
packages/app/src/components/Fab.jsx

@@ -5,8 +5,9 @@ import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
+
+import { usePageCreateModal } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-import { usePageCreateModalOpened } from '~/stores/ui';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
@@ -18,7 +19,7 @@ const Fab = (props) => {
   const { appContainer } = props;
   const { currentUser } = appContainer;
 
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = usePageCreateModal();
 
   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>

+ 20 - 17
packages/app/src/components/ForbiddenPage.jsx → packages/app/src/components/ForbiddenPage.tsx

@@ -1,19 +1,23 @@
 import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 import PageListIcon from './Icons/PageListIcon';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
+import DescendantsPageList from './DescendantsPageList';
+
 
+type Props = {
+  isLinkSharingDisabled?: boolean,
+}
 
-const ForbiddenPage = (props) => {
-  const { t } = props;
+const ForbiddenPage = React.memo((props: Props): JSX.Element => {
+  const { t } = useTranslation();
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: PageListIcon,
-        Content: PageList,
+        Content: DescendantsPageList,
         i18n: t('page_list'),
         index: 0,
       },
@@ -31,24 +35,23 @@ const ForbiddenPage = (props) => {
         </div>
       </div>
 
-
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
             <i className="icon-fw icon-lock" aria-hidden="true" />
-            {t('Browsing of this page is restricted')}
+            { props.isLinkSharingDisabled ? t('custom_navigation.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>
-      <div className="mt-5">
-        <CustomNavAndContents navTabMapping={navTabMapping} />
-      </div>
+
+      { !props.isLinkSharingDisabled && (
+        <div className="mt-5">
+          <CustomNavAndContents navTabMapping={navTabMapping} />
+        </div>
+      ) }
+
     </>
   );
-};
-
-ForbiddenPage.propTypes = {
-  t: PropTypes.func.isRequired,
-};
+});
 
-export default withTranslation()(ForbiddenPage);
+export default ForbiddenPage;

+ 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 { usePageCreateModal } from '~/stores/modal';
 
 const CreatePage = React.memo((props) => {
 
-  const { mutate } = usePageCreateModalOpened();
+  const { open: openCreateModal } = usePageCreateModal();
 
   // setup effect
   useEffect(() => {
-    mutate(true);
+    openCreateModal();
 
     // remove this
     props.onDeleteRender(this);
-  }, [mutate, props]);
+  }, [openCreateModal, props]);
 
   return <></>;
 });

+ 2 - 1
packages/app/src/components/Icons/AttachmentIcon.jsx

@@ -4,7 +4,8 @@ const Attachment = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <g className="cls-1">

+ 0 - 28
packages/app/src/components/Icons/BookmarkIcon.jsx

@@ -1,28 +0,0 @@
-import React from 'react';
-
-const BookmarkIcon = () => (
-
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    width="20"
-    height="20"
-    viewBox="0 0 20 20"
-  >
-
-    <g transform="translate(-925.888 168.873)">
-      <rect width="20" height="20" transform="translate(925.888 -168.873)" fill="none" />
-      <path d="M936.092-168.527a1.141,1.141,0,0,1,.205.039,1.685,1.685,0,0,1,.185.068c.058.026.116.056.175.088a1.038,1.038,0,0,1,
-        .166.117,1.826,1.826,0,0,1,.146.146c.045.052.088.1.127.156a.8.8,0,0,1,.1.175l2.26,4.7,5.2.76a1.424,1.424,0,0,1,.7.311,1.413,
-        1.413,0,0,1,.449.643,1.294,1.294,0,0,1-.351,1.423l-3.8,3.8.876,5.28a1.225,1.225,0,0,1-.088.76,1.451,1.451,0,0,1-.5.6,1.456,
-        1.456,0,0,1-.838.253,1.614,1.614,0,0,1-.351-.039,1.316,1.316,0,0,1-.35-.137l-4.52-2.435-4.54,2.435a1.37,1.37,0,0,1-.682.176h-.156a.525.525,
-        0,0,1-.146-.02l-.137-.039a1.117,1.117,0,0,1-.136-.049,1.231,1.231,0,0,1-.136-.068c-.046-.026-.088-.052-.127-.077a1.462,1.462,
-        0,0,1-.5-.6,1.232,1.232,0,0,1-.087-.76l.877-5.28-3.8-3.8a1.29,1.29,0,0,1-.35-1.423,1.4,1.4,0,0,1,.448-.643,1.423,1.423,0,0,1,
-        .7-.311l5.2-.76,2.26-4.7a1.351,1.351,0,0,1,.526-.584,1.467,1.467,0,0,1,.78-.215C935.953-168.537,936.02-168.533,936.092-168.527Zm-2.49,
-        5.9-.41.84-6.1.9,4.415,4.415-.136.879-.9,5.275,5.412-2.891,5.411,2.891-.9-5.275-.137-.879,4.415-4.415-6.115-.9-2.676-5.587Z"
-      />
-    </g>
-  </svg>
-
-);
-
-export default BookmarkIcon;

+ 2 - 1
packages/app/src/components/Icons/HistoryIcon.jsx

@@ -4,7 +4,8 @@ const RecentChanges = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 14 14"
-
+    width="14px"
+    height="14px"
   >
     <rect width="14" height="14" fillOpacity="0" />
     <path

+ 1 - 1
packages/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 20 20">
     <g transform="translate(-142 -502)">
       <rect width="20" height="20" transform="translate(142 502)" fill="none" />
       <g transform="translate(16 286.938)">

+ 17 - 0
packages/app/src/components/Icons/TriangleIcon.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+
+const TriangleIcon = (): JSX.Element => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="12"
+    height="12"
+    viewBox="0 0 12 12"
+  >
+    <g transform="translate(18194 -6790)">
+      <rect width="12" height="12" transform="translate(-18194 6790)" fill="none" />
+      <path d="M5.2,1.067a1,1,0,0,1,1.6,0l4,5.333A1,1,0,0,1,10,8H2a1,1,0,0,1-.8-1.6Z" transform="translate(-18183 6790) rotate(90)" />
+    </g>
+  </svg>
+);
+
+export default TriangleIcon;

+ 127 - 0
packages/app/src/components/IdenticalPathPage.tsx

@@ -0,0 +1,127 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { DevidedPagePath } from '@growi/core';
+
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
+import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/modal';
+import { useSWRxPageInfoForList } from '~/stores/page';
+
+import PageListIcon from './Icons/PageListIcon';
+import { PageListItemL } from './PageList/PageListItemL';
+
+
+type IdenticalPathAlertProps = {
+  path? : string | null,
+}
+
+const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+  const { path } = props;
+  const { t } = useTranslation();
+
+  let _path = '――';
+  let _pageName = '――';
+
+  if (path != null) {
+    const devidedPath = new DevidedPagePath(path);
+    _path = devidedPath.isFormerRoot ? '/' : devidedPath.former;
+    _pageName = devidedPath.latter;
+  }
+
+
+  return (
+    <div className="alert alert-warning py-3">
+      <h5 className="font-weight-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <p>
+        {t('duplicated_page_alert.same_page_name_exists_at_path',
+          { path: _path, pageName: _pageName })}<br />
+        <span
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
+        />
+      </p>
+      <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
+    </div>
+  );
+};
+
+
+type IdenticalPathPageProps= {
+  // add props and types here
+}
+
+
+const jsonNull = 'null';
+
+const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+  const { t } = useTranslation();
+
+  const identicalPageDocument = document.getElementById('identical-path-page');
+  const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
+
+  const pageIds = pages.map(page => page._id) as string[];
+
+
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  return (
+    <div className="d-flex flex-column flex-lg-row-reverse">
+
+      <div className="grw-side-contents-container">
+        <div className="grw-page-accessories-control pb-1">
+          { currentPath != null && !isSharedUser && (
+            <button
+              type="button"
+              className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between"
+              onClick={() => openDescendantPageListModal(currentPath)}
+            >
+              <PageListIcon />
+              {t('page_list')}
+              <span></span> {/* for a count badge */}
+            </button>
+          ) }
+        </div>
+      </div>
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+
+        <IdenticalPathAlert path={currentPath} />
+
+        <div className="page-list">
+          <ul className="page-list-ul list-group list-group-flush">
+            {pages.map((page) => {
+              const pageId = page._id;
+              const pageInfo = (idToPageInfoMap ?? {})[pageId];
+
+              const pageWithMeta: IPageWithMeta = {
+                pageData: page,
+                pageMeta: pageInfo,
+              };
+
+              return (
+                <PageListItemL
+                  key={pageId}
+                  page={pageWithMeta}
+                  isSelected={false}
+                  isEnableActions
+                  showPageUpdatedTime
+                // Todo: add onClickDeleteButton when delete feature implemented
+                />
+              );
+            })}
+          </ul>
+        </div>
+
+      </div>
+
+    </div>
+  );
+};
+
+export default IdenticalPathPage;

+ 1 - 2
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -24,7 +24,6 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   } = props;
 
   const snapshot = parseSnapshot(notification.snapshot);
-  const pagePath = { path: snapshot.path };
 
   // publish open()
   useImperativeHandle(ref, () => ({
@@ -42,7 +41,7 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   return (
     <div className="p-2">
       <div>
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel page={pagePath} />
+        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
       </div>
       <i className={`${actionIcon} mr-2`} />
       <FormattedDistanceDate

+ 50 - 55
packages/app/src/components/LikeButtons.tsx

@@ -2,85 +2,80 @@ import React, { FC, useState } from 'react';
 
 import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
-
 import UserPictureList from './User/UserPictureList';
-import { toastError } from '~/client/util/apiNotification';
-import { useIsGuestUser } from '~/stores/context';
-import { useSWRxPageInfo } from '~/stores/page';
-import { useSWRxUsersList } from '~/stores/user';
-import { apiv3Put } from '~/client/util/apiv3-client';
+import { withUnstatedContainers } from './UnstatedUtils';
 
-interface Props {
-  pageId: string,
-}
+import AppContainer from '~/client/services/AppContainer';
+import { IUser } from '../interfaces/user';
 
-const LikeButtons: FC<Props> = (props: Props) => {
-  const { t } = useTranslation();
-  const { pageId } = props;
-
-  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+type LikeButtonsProps = {
 
-  const { data: isGuestUser } = useIsGuestUser();
+  hideTotalNumber?: boolean,
+  sumOfLikers: number,
+  likers: IUser[],
 
-  const { data: pageInfo, mutate } = useSWRxPageInfo(pageId);
-  const isLiked = pageInfo?.isLiked ?? false;
-  const sumOfLikers = pageInfo?.sumOfLikers != null ? pageInfo.sumOfLikers : 0;
-  const likerIds = pageInfo?.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
-  const seenUserIds = pageInfo?.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
-
-  // Put in a mixture of seenUserIds and likerIds data to make the cache work
-  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
-  const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
+  isGuestUser?: boolean,
+  isLiked?: boolean,
+  onLikeClicked?: ()=>void,
+}
 
-  const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
+  const { t } = useTranslation();
 
-  const handleClick = async() => {
-    if (isGuestUser) {
-      return;
-    }
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
-    try {
-      const res = await apiv3Put('/page/likes', { pageId, bool: !isLiked });
-      if (res) {
-        mutate();
-      }
-    }
-    catch (err) {
-      toastError(err);
-    }
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
   };
 
+  const {
+    hideTotalNumber, isGuestUser, isLiked, sumOfLikers, onLikeClicked,
+  } = props;
+
   return (
     <div className="btn-group" role="group" aria-label="Like buttons">
       <button
         type="button"
         id="like-button"
-        onClick={handleClick}
+        onClick={onLikeClicked}
         className={`btn btn-like border-0
-          ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+            ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <i className="icon-like"></i>
+        <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
-
-      {isGuestUser && (
+      { isGuestUser && (
         <UncontrolledTooltip placement="top" target="like-button" fade={false}>
           {t('Not available for guest')}
         </UncontrolledTooltip>
       )}
 
-      <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
-        {sumOfLikers}
-      </button>
-
-      <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">
-            {likers.length ? <UserPictureList users={likers} /> : t('No users have liked this yet')}
-          </div>
-        </PopoverBody>
-      </Popover>
+      { !hideTotalNumber && (
+        <>
+          <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+            {sumOfLikers}
+          </button>
+          <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
+            <PopoverBody className="user-list-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.')}
+              </div>
+            </PopoverBody>
+          </Popover>
+        </>
+      ) }
     </div>
   );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonsUnstatedWrapper = withUnstatedContainers(LikeButtons, [AppContainer]);
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const LikeButtonsWrapper = (props) => {
+  return <LikeButtonsUnstatedWrapper {...props}></LikeButtonsUnstatedWrapper>;
 };
 
-export default LikeButtons;
+export default LikeButtonsWrapper;

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -34,7 +34,7 @@ const AuthorInfo = (props) => {
       if (err instanceof RangeError) {
         return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
       }
-      return;
+      return <></>;
     }
   }
 

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

@@ -2,15 +2,17 @@ import React, {
   FC, useState, useCallback, useRef,
 } from 'react';
 import { useTranslation } from 'react-i18next';
+import assert from 'assert';
 
 import AppContainer from '~/client/services/AppContainer';
-import { IPage } from '~/interfaces/page';
 import { IFocusable } from '~/client/interfaces/focusable';
+import { useGlobalSearchFormRef } from '~/stores/ui';
+import { IPageSearchMeta } from '~/interfaces/search';
+import { IPageWithMeta } from '~/interfaces/page';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
-import { useGlobalSearchFormRef } from '~/stores/ui';
 
 
 type Props = {
@@ -31,12 +33,14 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
 
-  const gotoPage = useCallback((data: unknown[]) => {
-    const page = data[0] as IPage; // should be single page selected
+  const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
+    assert(data.length > 0);
+
+    const page = data[0].pageData; // should be single page selected
 
     // navigate to page
     if (page != null) {
-      window.location.href = page.path;
+      window.location.href = page._id;
     }
   }, []);
 

+ 302 - 0
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -0,0 +1,302 @@
+import React, { useState, useCallback } from 'react';
+import PropTypes from 'prop-types';
+
+import { useTranslation } from 'react-i18next';
+
+import { DropdownItem } from 'reactstrap';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import EditorContainer from '~/client/services/EditorContainer';
+import {
+  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+} from '~/stores/ui';
+import {
+  usePageAccessoriesModal, PageAccessoriesModalContents,
+  usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
+} from '~/stores/modal';
+
+
+import {
+  useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
+  useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
+import { useSWRTagsInfo } from '~/stores/page';
+
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import HistoryIcon from '../Icons/HistoryIcon';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+import { SubNavButtons } from './SubNavButtons';
+import PageEditorModeManager from './PageEditorModeManager';
+import { GrowiSubNavigation } from './GrowiSubNavigation';
+import PresentationIcon from '../Icons/PresentationIcon';
+import CreateTemplateModal from '../CreateTemplateModal';
+import { exportAsMarkdown } from '~/client/services/page-operation';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+  isLinkSharingDisabled?: boolean,
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
+
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    pageId, revisionId, isLinkSharingDisabled, onClickTemplateMenuItem,
+  } = props;
+
+  const openPageTemplateModalHandler = () => {
+    onClickTemplateMenuItem(true);
+  };
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+
+  const { open: openPresentationModal } = usePagePresentationModal();
+  const { open: openAccessoriesModal } = usePageAccessoriesModal();
+
+  const hrefForPresentationModal = '?presentation=1';
+
+  return (
+    <>
+      {/* Presentation */}
+      <DropdownItem onClick={() => openPresentationModal(hrefForPresentationModal)}>
+        <i className="icon-fw"><PresentationIcon /></i>
+        { t('Presentation Mode') }
+      </DropdownItem>
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/*
+        TODO: show Tooltip when menu is disabled
+        refs: PageAccessoriesModalControl
+      */}
+      <DropdownItem
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
+        disabled={isGuestUser || isSharedUser}
+      >
+        <span className="mr-1"><HistoryIcon /></span>
+        {t('History')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+      >
+        <span className="mr-1"><AttachmentIcon /></span>
+        {t('attachment_data')}
+      </DropdownItem>
+
+      <DropdownItem
+        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
+        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+      >
+        <span className="mr-1"><ShareLinkIcon /></span>
+        {t('share_links.share_link_management')}
+      </DropdownItem>
+
+      <DropdownItem divider />
+
+      {/* Create template */}
+      <DropdownItem onClick={openPageTemplateModalHandler}>
+        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      </DropdownItem>
+    </>
+  );
+};
+
+
+const GrowiContextualSubNavigation = (props) => {
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDrawerMode } = useDrawerMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: createdAt } = useCurrentCreatedAt();
+  const { data: updatedAt } = useCurrentUpdatedAt();
+  const { data: pageId } = useCurrentPageId();
+  const { data: revisionId } = useRevisionId();
+  const { data: path } = useCurrentPagePath();
+  const { data: creator } = useCreator();
+  const { data: revisionAuthor } = useRevisionAuthor();
+  const { data: currentUser } = useCurrentUser();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
+  const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
+  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
+
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRTagsInfo(pageId);
+
+  const { open: openDuplicateModal } = usePageDuplicateModal();
+  const { open: openRenameModal } = usePageRenameModal();
+  const { open: openDeleteModal } = usePageDeleteModal();
+
+  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
+
+  const {
+    editorContainer, isCompactMode, isLinkSharingDisabled,
+  } = props;
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === EditorMode.Editor) {
+      return editorContainer.setState({ tags: newTags });
+    }
+
+    try {
+      const { tags } = await apiPost('/tags.update', { pageId, revisionId, tags: newTags }) as { tags };
+
+      // revalidate SWRTagsInfo
+      mutateSWRTagsInfo();
+      // update editorContainer.state
+      editorContainer.setState({ tags });
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pageId]);
+
+  const duplicateItemClickedHandler = useCallback(async(pageId, path) => {
+    openDuplicateModal(pageId, path);
+  }, [openDuplicateModal]);
+
+  const renameItemClickedHandler = useCallback(async(pageId, revisionId, path) => {
+    openRenameModal(pageId, revisionId, path);
+  }, [openRenameModal]);
+
+  const deleteItemClickedHandler = useCallback(async(pageToDelete) => {
+    openDeleteModal([pageToDelete]);
+  }, [openDeleteModal]);
+
+  const templateMenuItemClickHandler = useCallback(() => {
+    setIsPageTempleteModalShown(true);
+  }, []);
+
+
+  const ControlComponents = useCallback(() => {
+    function onPageEditorModeButtonClicked(viewType) {
+      mutateEditorMode(viewType);
+    }
+
+    return (
+      <>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          { pageId != null && isViewMode && (
+            <SubNavButtons
+              isCompactMode={isCompactMode}
+              pageId={pageId}
+              shareLinkId={shareLinkId}
+              revisionId={revisionId}
+              path={path}
+              disableSeenUserInfoPopover={isSharedUser}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={props => (
+                <AdditionalMenuItems
+                  {...props}
+                  pageId={pageId}
+                  revisionId={revisionId}
+                  isLinkSharingDisabled={isLinkSharingDisabled}
+                  onClickTemplateMenuItem={templateMenuItemClickHandler}
+                />
+              )}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
+            />
+          ) }
+        </div>
+        <div className="h-50 d-flex flex-column align-items-end justify-content-center">
+          {isAbleToShowPageEditorModeManager && (
+            <PageEditorModeManager
+              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+              isBtnDisabled={isGuestUser}
+              editorMode={editorMode}
+              isDeviceSmallerThanMd={isDeviceSmallerThanMd}
+            />
+          )}
+        </div>
+        {currentUser != null && (
+          <CreateTemplateModal
+            path={path}
+            isOpen={isPageTemplateModalShown}
+            onClose={() => setIsPageTempleteModalShown(false)}
+          />
+        )}
+      </>
+    );
+  }, [
+    pageId, revisionId, shareLinkId, editorMode, mutateEditorMode, isCompactMode,
+    isLinkSharingDisabled, isDeviceSmallerThanMd, isGuestUser, isSharedUser, currentUser,
+    isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
+    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
+    path, templateMenuItemClickHandler, isPageTemplateModalShown,
+  ]);
+
+
+  if (path == null) {
+    return <></>;
+  }
+
+  const currentPage: Partial<IPageHasId> = {
+    _id: pageId ?? undefined,
+    path,
+    revision: revisionId ?? undefined,
+    creator: creator ?? undefined,
+    lastUpdateUser: revisionAuthor,
+    createdAt: createdAt ?? undefined,
+    updatedAt: updatedAt ?? undefined,
+  };
+
+
+  return (
+    <GrowiSubNavigation
+      page={currentPage}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      showPageAuthors={isAbleToShowPageAuthors}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={tagsInfoData?.tags || []}
+      tagsUpdatedHandler={tagsUpdatedHandler}
+      controls={ControlComponents}
+    />
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiContextualSubNavigationWrapper = withUnstatedContainers(GrowiContextualSubNavigation, [EditorContainer]);
+
+
+GrowiContextualSubNavigation.propTypes = {
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
+  isLinkSharingDisabled: PropTypes.bool,
+};
+
+export default GrowiContextualSubNavigationWrapper;

+ 8 - 4
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -7,7 +7,9 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd, usePageCreateModalOpened } from '~/stores/ui';
+import { useIsDeviceSmallerThanMd } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
+import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
@@ -22,7 +24,8 @@ type NavbarRightProps = {
 }
 const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
   const { t } = useTranslation();
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { open: openCreateModal } = usePageCreateModal();
 
   const { currentUser } = props;
 
@@ -41,7 +44,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         <button
           className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
           type="button"
-          onClick={() => mutatePageCreateModalOpened(true)}
+          onClick={() => openCreateModal(currentPagePath || '')}
         >
           <i className="icon-pencil mr-2"></i>
           <span className="d-none d-lg-block">{ t('New') }</span>
@@ -90,6 +93,7 @@ const GrowiNavbar = (props) => {
   const { crowi, isSearchServiceConfigured } = appContainer.config;
 
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isSearchPage } = useIsSearchPage();
 
   return (
     <>
@@ -111,7 +115,7 @@ const GrowiNavbar = (props) => {
         <Confidential confidential={crowi.confidential}></Confidential>
       </ul>
 
-      { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+      { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
         <div className="grw-global-search grw-global-search-top position-absolute">
           <GlobalSearch />
         </div>

+ 25 - 16
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { usePageCreateModalOpened, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+
+import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
 
 import GlobalSearch from './GlobalSearch';
 
@@ -9,7 +12,9 @@ const GrowiNavbarBottom = (props) => {
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { open: openCreateModal } = usePageCreateModal();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isSearchPage } = useIsSearchPage();
 
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
@@ -19,7 +24,7 @@ const GrowiNavbarBottom = (props) => {
   return (
     <div className="d-md-none d-edit-none fixed-bottom">
 
-      { isDeviceSmallerThanMd && (
+      { isDeviceSmallerThanMd && !isSearchPage && (
         <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
           <div className="p-3">
             <GlobalSearch dropup />
@@ -30,7 +35,7 @@ const GrowiNavbarBottom = (props) => {
       <div className={`navbar navbar-expand navbar-dark bg-primary px-0 ${additionalClasses.join(' ')}`}>
 
         <ul className="navbar-nav w-100">
-          <li className="nav-item">
+          <li className="nav-item mr-auto">
             <a
               role="button"
               className="nav-link btn-lg"
@@ -39,21 +44,25 @@ const GrowiNavbarBottom = (props) => {
               <i className="icon-menu"></i>
             </a>
           </li>
-          <li className="nav-item mx-auto">
-            <a
-              role="button"
-              className="nav-link btn-lg"
-              data-target="#grw-global-search-collapse"
-              data-toggle="collapse"
-            >
-              <i className="icon-magnifier"></i>
-            </a>
-          </li>
-          <li className="nav-item">
+          {
+            !isSearchPage && (
+              <li className="nav-item">
+                <a
+                  role="button"
+                  className="nav-link btn-lg"
+                  data-target="#grw-global-search-collapse"
+                  data-toggle="collapse"
+                >
+                  <i className="icon-magnifier"></i>
+                </a>
+              </li>
+            )
+          }
+          <li className="nav-item ml-auto">
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => mutatePageCreateModalOpened(true)}
+              onClick={() => openCreateModal(currentPagePath || '')}
             >
               <i className="icon-pencil"></i>
             </a>

+ 0 - 164
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -1,164 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { DevidedPagePath } from '@growi/core';
-import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import LinkedPagePath from '~/models/linked-page-path';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import {
-  EditorMode, useDrawerMode, useEditorMode, useIsDeviceSmallerThanMd,
-} from '~/stores/ui';
-import { useCurrentCreatedAt, useCurrentUpdatedAt } from '~/stores/context';
-
-import CopyDropdown from '../Page/CopyDropdown';
-import TagLabels from '../Page/TagLabels';
-import SubnavButtons from './SubNavButtons';
-import PageEditorModeManager from './PageEditorModeManager';
-
-import AuthorInfo from './AuthorInfo';
-import DrawerToggler from './DrawerToggler';
-
-const PagePathNav = ({
-  // eslint-disable-next-line react/prop-types
-  pageId, pagePath, isEditorMode, isCompactMode,
-}) => {
-
-  const dPagePath = new DevidedPagePath(pagePath, false, true);
-
-  let formerLink;
-  let latterLink;
-
-  // one line
-  if (dPagePath.isRoot || dPagePath.isFormerRoot || isEditorMode) {
-    const linkedPagePath = new LinkedPagePath(pagePath);
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-  }
-  // two line
-  else {
-    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
-  }
-
-  const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block text-muted bg-transparent btn-copy border-0 py-0';
-
-  return (
-    <div className="grw-page-path-nav">
-      {formerLink}
-      <span className="d-flex align-items-center">
-        <h1 className="m-0">{latterLink}</h1>
-        <div className="mx-2">
-          <CopyDropdown
-            pageId={pageId}
-            pagePath={pagePath}
-            dropdownToggleId={copyDropdownId}
-            dropdownToggleClassName={copyDropdownToggleClassName}
-          >
-            <i className="ti-clipboard"></i>
-          </CopyDropdown>
-        </div>
-      </span>
-    </div>
-  );
-};
-
-const GrowiSubNavigation = (props) => {
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: isDrawerMode } = useDrawerMode();
-  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: createdAt } = useCurrentCreatedAt();
-  const { data: updatedAt } = useCurrentUpdatedAt();
-
-  const {
-    appContainer, pageContainer, isCompactMode,
-  } = props;
-  const {
-    pageId, path, creator, revisionAuthor, isPageExist,
-  } = pageContainer.state;
-
-  const { isGuestUser } = appContainer;
-  const isEditorMode = editorMode !== EditorMode.View;
-  // Tags cannot be edited while the new page and editorMode is view
-  const isTagLabelHidden = (editorMode !== EditorMode.Editor && !isPageExist);
-
-  function onPageEditorModeButtonClicked(viewType) {
-    mutateEditorMode(viewType);
-  }
-
-  return (
-    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
-
-      {/* Left side */}
-      <div className="d-flex grw-subnav-left-side">
-        { isDrawerMode && (
-          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
-            <DrawerToggler />
-          </div>
-        ) }
-
-        <div className="grw-path-nav-container">
-          { pageContainer.isAbleToShowTagLabel && !isCompactMode && !isTagLabelHidden && (
-            <div className="grw-taglabels-container">
-              <TagLabels editorMode={editorMode} />
-            </div>
-          ) }
-          <PagePathNav pageId={pageId} pagePath={path} isEditorMode={isEditorMode} isCompactMode={isCompactMode} />
-        </div>
-      </div>
-
-      {/* Right side */}
-      <div className="d-flex">
-
-        <div className="d-flex flex-column align-items-end">
-          <div className="d-flex">
-            <SubnavButtons isCompactMode={isCompactMode} />
-          </div>
-          <div className="mt-2">
-            {pageContainer.isAbleToShowPageEditorModeManager && (
-              <PageEditorModeManager
-                onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
-                isBtnDisabled={isGuestUser}
-                editorMode={editorMode}
-                isDeviceSmallerThanMd={isDeviceSmallerThanMd}
-              />
-            )}
-          </div>
-        </div>
-
-        {/* Page Authors */}
-        { (pageContainer.isAbleToShowPageAuthors && !isCompactMode) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
-            <li className="pb-1">
-              <AuthorInfo user={creator} date={createdAt} locate="subnav" />
-            </li>
-            <li className="mt-1 pt-1 border-top">
-              <AuthorInfo user={revisionAuthor} date={updatedAt} mode="update" locate="subnav" />
-            </li>
-          </ul>
-        ) }
-      </div>
-
-    </div>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
-
-
-GrowiSubNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default GrowiSubNavigationWrapper;

+ 100 - 0
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -0,0 +1,100 @@
+import React from 'react';
+
+import { IPageHasId } from '~/interfaces/page';
+
+import {
+  EditorMode, useEditorMode,
+} from '~/stores/ui';
+
+import TagLabels from '../Page/TagLabels';
+
+import AuthorInfo from './AuthorInfo';
+import DrawerToggler from './DrawerToggler';
+
+import PagePathNav from '../PagePathNav';
+import { IUser } from '~/interfaces/user';
+
+
+type Props = {
+  page: Partial<IPageHasId>,
+
+  showDrawerToggler?: boolean,
+  showTagLabel?: boolean,
+  showPageAuthors?: boolean,
+
+  isGuestUser?: boolean,
+  isDrawerMode?: boolean,
+  isCompactMode?: boolean,
+
+  tags?: string[],
+  tagsUpdatedHandler?: (newTags: string[]) => Promise<void>,
+
+  controls?: React.FunctionComponent,
+}
+
+export const GrowiSubNavigation = (props: Props): JSX.Element => {
+  const { data: editorMode } = useEditorMode();
+
+  const {
+    page,
+    showDrawerToggler, showTagLabel, showPageAuthors,
+    isGuestUser, isDrawerMode, isCompactMode,
+    tags, tagsUpdatedHandler,
+    controls: Controls,
+  } = props;
+
+  const {
+    _id: pageId, path, creator, lastUpdateUser,
+    createdAt, updatedAt,
+  } = page;
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`grw-subnav container-fluid d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
+
+      {/* Left side */}
+      <div className="d-flex grw-subnav-left-side">
+        { showDrawerToggler && isDrawerMode && (
+          <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
+            <DrawerToggler />
+          </div>
+        ) }
+
+        <div className="grw-path-nav-container">
+          { showTagLabel && !isCompactMode && (
+            <div className="grw-taglabels-container">
+              <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+            </div>
+          ) }
+          <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
+        </div>
+      </div>
+
+      {/* Right side */}
+      <div className="d-flex">
+
+        <div className="d-flex flex-column" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
+          { Controls && <Controls></Controls> }
+        </div>
+
+        {/* Page Authors */}
+        { (showPageAuthors && !isCompactMode) && (
+          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
+            <li className="pb-1">
+              <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
+            </li>
+            <li className="mt-1 pt-1 border-top">
+              <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="subnav" />
+            </li>
+          </ul>
+        ) }
+      </div>
+    </div>
+  );
+};

+ 4 - 2
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,6 +1,7 @@
 import React, {
   useMemo, useState, useRef, useEffect, useCallback,
 } from 'react';
+import PropTypes from 'prop-types';
 
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
@@ -8,7 +9,7 @@ import { debounce } from 'throttle-debounce';
 import loggerFactory from '~/utils/logger';
 import { useSidebarCollapsed } from '~/stores/ui';
 
-import GrowiSubNavigation from './GrowiSubNavigation';
+import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
 
 const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
 
@@ -110,13 +111,14 @@ const GrowiSubNavigationSwitcher = (props) => {
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
       <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
-        <GrowiSubNavigation isCompactMode />
+        <GrowiContextualSubNavigation isCompactMode isLinkSharingDisabled />
       </div>
     </div>
   );
 };
 
 GrowiSubNavigationSwitcher.propTypes = {
+  isLinkSharingDisabled: PropTypes.bool,
 };
 
 export default GrowiSubNavigationSwitcher;

+ 0 - 70
packages/app/src/components/Navbar/SubNavButtons.jsx

@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { usePageId } from '~/stores/context';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import BookmarkButtons from '../BookmarkButtons';
-import LikeButtons from '../LikeButtons';
-import SubscribeButton from '../SubscribeButton';
-import PageManagement from '../Page/PageManagement';
-
-const SubnavButtons = React.memo((props) => {
-  const {
-    appContainer, pageContainer, isCompactMode,
-  } = props;
-
-  const { data: pageId } = usePageId();
-  const { data: editorMode } = useEditorMode();
-
-  /* eslint-disable react/prop-types */
-  const PageReactionButtons = ({ pageContainer }) => {
-
-    return (
-      <>
-        <span>
-          <SubscribeButton pageId={pageId} />
-        </span>
-        {pageContainer.isAbleToShowLikeButtons && (
-          <span>
-            <LikeButtons pageId={pageId} />
-          </span>
-        )}
-        <span>
-          <BookmarkButtons pageId={pageId} />
-        </span>
-      </>
-    );
-  };
-  /* eslint-enable react/prop-types */
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  return (
-    <>
-      {isViewMode && (
-        <>
-          {pageContainer.isAbleToShowPageReactionButtons && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} />}
-          {pageContainer.isAbleToShowPageManagement && <PageManagement isCompactMode={isCompactMode} />}
-        </>
-      )}
-    </>
-  );
-});
-
-/**
- * Wrapper component for using unstated
- */
-const SubnavButtonsWrapper = withUnstatedContainers(SubnavButtons, [AppContainer, PageContainer]);
-
-
-SubnavButtons.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isCompactMode: PropTypes.bool,
-};
-
-export default SubnavButtonsWrapper;

+ 208 - 0
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -0,0 +1,208 @@
+import React, { useCallback } from 'react';
+
+import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
+
+import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRBookmarkInfo } from '../../stores/bookmark';
+import { useSWRxUsersList } from '../../stores/user';
+import { useIsGuestUser } from '~/stores/context';
+import { IPageForPageDeleteModal } from '~/stores/modal';
+
+import SubscribeButton from '../SubscribeButton';
+import LikeButtons from '../LikeButtons';
+import BookmarkButtons from '../BookmarkButtons';
+import SeenUserInfo from '../User/SeenUserInfo';
+import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import { AdditionalMenuItemsRendererProps, PageItemControl } from '../Common/Dropdown/PageItemControl';
+
+
+type CommonProps = {
+  isCompactMode?: boolean,
+  disableSeenUserInfoPopover?: boolean,
+  showPageControlDropdown?: boolean,
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+  onClickDuplicateMenuItem?: (pageId: string, path: string) => void,
+  onClickRenameMenuItem?: (pageId: string, revisionId: string, path: string) => void,
+  onClickDeleteMenuItem?: (pageToDelete: IPageForPageDeleteModal | null) => void,
+}
+
+type SubNavButtonsSubstanceProps= CommonProps & {
+  pageId: string,
+  shareLinkId?: string | null,
+  revisionId: string,
+  path?: string | null,
+  pageInfo: IPageInfoAll,
+}
+
+const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
+  const {
+    pageInfo,
+    pageId, revisionId, path, shareLinkId,
+    isCompactMode, disableSeenUserInfoPopover, showPageControlDropdown, additionalMenuItemRenderer,
+    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+  } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
+
+  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+
+  const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
+  const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
+
+  // Put in a mixture of seenUserIds and likerIds data to make the cache work
+  const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
+  const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
+  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+
+  const subscribeClickhandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
+
+    await toggleSubscribe(pageId, pageInfo.subscriptionStatus);
+    mutatePageInfo();
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const likeClickhandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
+
+    await toggleLike(pageId, pageInfo.isLiked);
+    mutatePageInfo();
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const bookmarkClickHandler = useCallback(async() => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
+
+    await toggleBookmark(pageId, pageInfo.isBookmarked);
+    mutatePageInfo();
+    mutateBookmarkInfo();
+  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
+
+  const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickDuplicateMenuItem == null || path == null) {
+      return;
+    }
+
+    onClickDuplicateMenuItem(pageId, path);
+  }, [onClickDuplicateMenuItem, pageId, path]);
+
+  const renameMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickRenameMenuItem == null || path == null) {
+      return;
+    }
+
+    onClickRenameMenuItem(pageId, revisionId, path);
+  }, [onClickRenameMenuItem, pageId, path, revisionId]);
+
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
+    if (onClickDeleteMenuItem == null || path == null) {
+      return;
+    }
+
+    const pageToDelete: IPageForPageDeleteModal = {
+      pageId,
+      revisionId,
+      path,
+    };
+
+    onClickDeleteMenuItem(pageToDelete);
+  }, [onClickDeleteMenuItem, pageId, path, revisionId]);
+
+  if (!isIPageInfoForOperation(pageInfo)) {
+    return <></>;
+  }
+
+
+  const {
+    sumOfLikers, isLiked, bookmarkCount, isBookmarked,
+  } = pageInfo;
+
+  return (
+    <div className="d-flex" style={{ gap: '2px' }}>
+      <span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
+      </span>
+      <LikeButtons
+        hideTotalNumber={isCompactMode}
+        onLikeClicked={likeClickhandler}
+        sumOfLikers={sumOfLikers}
+        isLiked={isLiked}
+        likers={likers}
+      />
+      <BookmarkButtons
+        hideTotalNumber={isCompactMode}
+        bookmarkCount={bookmarkCount}
+        isBookmarked={isBookmarked}
+        bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
+        onBookMarkClicked={bookmarkClickHandler}
+      />
+      <SeenUserInfo seenUsers={seenUsers} disabled={disableSeenUserInfoPopover} />
+      { showPageControlDropdown && (
+        <PageItemControl
+          pageId={pageId}
+          pageInfo={pageInfo}
+          isEnableActions={!isGuestUser}
+          additionalMenuItemRenderer={additionalMenuItemRenderer}
+          onClickRenameMenuItem={renameMenuItemClickHandler}
+          onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+          onClickDeleteMenuItem={deleteMenuItemClickHandler}
+        />
+      )}
+    </div>
+  );
+};
+
+type SubNavButtonsProps= CommonProps & {
+  pageId: string,
+  shareLinkId?: string | null,
+  revisionId?: string | null,
+  path?: string | null
+};
+
+export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
+  const {
+    pageId, revisionId, path, shareLinkId, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+  } = props;
+
+  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+
+  if (revisionId == null || error != null) {
+    return <></>;
+  }
+
+  if (!isIPageInfoForOperation(pageInfo)) {
+    return <></>;
+  }
+
+
+  return (
+    <SubNavButtonsSubstance
+      {...props}
+      pageInfo={pageInfo}
+      pageId={pageId}
+      revisionId={revisionId}
+      path={path}
+      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+      onClickRenameMenuItem={onClickRenameMenuItem}
+      onClickDeleteMenuItem={onClickDeleteMenuItem}
+    />
+  );
+};

+ 0 - 42
packages/app/src/components/NotFoundPage.jsx

@@ -1,42 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
-import PageList from './PageList';
-import PageTimeline from './PageTimeline';
-
-const NotFoundPage = (props) => {
-  const { t } = props;
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        Content: PageList,
-        i18n: t('page_list'),
-        index: 0,
-      },
-      timeLine: {
-        Icon: TimeLineIcon,
-        Content: PageTimeline,
-        i18n: t('Timeline View'),
-        index: 1,
-      },
-    };
-  }, [t]);
-
-
-  return (
-    <div className="mt-5 d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
-    </div>
-  );
-};
-
-NotFoundPage.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-};
-
-export default withTranslation()(NotFoundPage);

+ 48 - 0
packages/app/src/components/NotFoundPage.tsx

@@ -0,0 +1,48 @@
+import React, { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import PageListIcon from './Icons/PageListIcon';
+import TimeLineIcon from './Icons/TimeLineIcon';
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
+import DescendantsPageList from './DescendantsPageList';
+import PageTimeline from './PageTimeline';
+import { useCurrentPagePath } from '~/stores/context';
+
+
+const NotFoundPage = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
+    return currentPagePath != null
+      ? <DescendantsPageList path={currentPagePath} />
+      : <></>;
+  }, [currentPagePath]);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: PageListIcon,
+        Content: DescendantsPageListForThisPage,
+        i18n: t('page_list'),
+        index: 0,
+      },
+      timeLine: {
+        Icon: TimeLineIcon,
+        Content: PageTimeline,
+        i18n: t('Timeline View'),
+        index: 1,
+      },
+    };
+  }, [DescendantsPageListForThisPage, t]);
+
+
+  return (
+    <div className="d-edit-none">
+      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
+    </div>
+  );
+};
+
+export default NotFoundPage;

+ 1 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -118,7 +118,7 @@ const CopyDropdown = (props) => {
           <span id={dropdownToggleId}>{children}</span>
         </DropdownToggle>
 
-        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: null } }}>
+        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
 
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">

+ 0 - 87
packages/app/src/components/Page/DisplaySwitcher.jsx

@@ -1,87 +0,0 @@
-import React from 'react';
-import { TabContent, TabPane } from 'reactstrap';
-import propTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-
-import Editor from '../PageEditor';
-import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-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) => {
-  const {
-    pageContainer,
-  } = props;
-  const { isPageExist, pageUser } = pageContainer.state;
-
-  const { data: isEditable } = useIsEditable();
-  const { data: editorMode } = useEditorMode();
-
-  const isViewMode = editorMode === EditorMode.View;
-
-  return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <TabPane tabId={EditorMode.View}>
-          <div className="d-flex flex-column flex-lg-row-reverse">
-
-            <div className="grw-side-contents-container">
-              <div className="grw-side-contents-sticky-container">
-                <div className="border-bottom pb-1">
-                  <PageAccessories isNotFoundPage={!isPageExist} />
-                </div>
-
-                <div className="d-none d-lg-block">
-                  <div id="revision-toc" className="revision-toc">
-                    <TableOfContents />
-                  </div>
-                  <ContentLinkButtons />
-                </div>
-              </div>
-            </div>
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              {pageUser && <UserInfo pageUser={pageUser} />}
-              <Page />
-            </div>
-
-          </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>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
-
-      { isEditable && <HashChanged></HashChanged> }
-    </>
-  );
-};
-
-DisplaySwitcher.propTypes = {
-  pageContainer: propTypes.instanceOf(PageContainer).isRequired,
-};
-
-
-export default withUnstatedContainers(DisplaySwitcher, [PageContainer]);

+ 135 - 0
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -0,0 +1,135 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { pagePathUtils } from '@growi/core';
+
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useDescendantsPageListModal } from '~/stores/modal';
+import {
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
+} from '~/stores/context';
+
+
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+
+import PageListIcon from '../Icons/PageListIcon';
+import Editor from '../PageEditor';
+import Page from '../Page';
+import UserInfo from '../User/UserInfo';
+import TableOfContents from '../TableOfContents';
+import ContentLinkButtons from '../ContentLinkButtons';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
+import HashChanged from '../EventListeneres/HashChanged';
+
+
+const WIKI_HEADER_LINK = 120;
+
+const { isTopPage } = pagePathUtils;
+
+
+const DisplaySwitcher = (): JSX.Element => {
+  const { t } = useTranslation();
+
+
+  // get element for smoothScroll
+  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isUserPage } = useIsUserPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: pageUser } = usePageUser();
+
+  const { data: editorMode } = useEditorMode();
+
+  const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+
+  const isPageExist = currentPageId != null;
+  const isViewMode = editorMode === EditorMode.View;
+  const isTopPagePath = isTopPage(currentPath ?? '');
+
+  return (
+    <>
+      <TabContent activeTab={editorMode}>
+        <TabPane tabId={EditorMode.View}>
+          <div className="d-flex flex-column flex-lg-row-reverse">
+
+            { isPageExist && (
+              <div className="grw-side-contents-container">
+                <div className="grw-side-contents-sticky-container">
+
+                  {/* Page list */}
+                  <div className="grw-page-accessories-control">
+                    { currentPath != null && !isSharedUser && (
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => openDescendantPageListModal(currentPath)}
+                      >
+                        <PageListIcon />
+                        {t('page_list')}
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    ) }
+                  </div>
+
+                  {/* Comments */}
+                  { getCommentListDom != null && !isTopPagePath && (
+                    <div className="mt-2">
+                      <button
+                        type="button"
+                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                        onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                      >
+                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <span>Comments</span>
+                        <span></span> {/* for a count badge */}
+                      </button>
+                    </div>
+                  ) }
+
+                  <div className="d-none d-lg-block">
+                    <div id="revision-toc" className="revision-toc">
+                      <TableOfContents />
+                    </div>
+                    <ContentLinkButtons />
+                  </div>
+
+                </div>
+              </div>
+            ) }
+
+            <div className="flex-grow-1 flex-basis-0 mw-0">
+              { isUserPage && <UserInfo pageUser={pageUser} />}
+              <Page />
+            </div>
+
+          </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>
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
+
+      { isEditable && <HashChanged></HashChanged> }
+    </>
+  );
+};
+
+export default DisplaySwitcher;

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

@@ -1,15 +1,21 @@
 import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
+
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
-const NotFoundAlert = (props) => {
+type Props = {
+  isGuestUserMode?: boolean,
+}
+
+const NotFoundAlert = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { isHidden, isGuestUserMode } = props;
+  const { isGuestUserMode } = props;
+
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const isEditorMode = editorMode !== EditorMode.View;
 
   const clickHandler = useCallback(() => {
     // check guest user,
@@ -22,11 +28,10 @@ const NotFoundAlert = (props) => {
 
   }, [isGuestUserMode, mutateEditorMode]);
 
-  if (isHidden) {
-    return null;
+  if (isEditorMode) {
+    return <></>;
   }
 
-
   return (
     <div className="border border-info p-3">
       <div
@@ -59,9 +64,4 @@ const NotFoundAlert = (props) => {
 };
 
 
-NotFoundAlert.propTypes = {
-  isHidden: PropTypes.bool.isRequired,
-  isGuestUserMode: PropTypes.bool.isRequired,
-};
-
 export default NotFoundAlert;

+ 38 - 32
packages/app/src/components/Page/PageManagement.jsx

@@ -5,10 +5,10 @@ import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
 import { pagePathUtils } from '@growi/core';
+import { usePageDeleteModal } from '~/stores/modal';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import PageDeleteModal from '../PageDeleteModal';
 import PageRenameModal from '../PageRenameModal';
 import PageDuplicateModal from '../PageDuplicateModal';
 import CreateTemplateModal from '../CreateTemplateModal';
@@ -18,19 +18,20 @@ import PresentationIcon from '../Icons/PresentationIcon';
 const { isTopPage } = pagePathUtils;
 
 
-const PageManagement = (props) => {
+const LegacyPageManagemenet = (props) => {
   const {
-    t, appContainer, pageContainer, isCompactMode,
+    t, appContainer, isCompactMode, pageId, revisionId, path, isDeletable, isAbleToDeleteCompletely,
   } = props;
-  const { path, isDeletable, isAbleToDeleteCompletely } = pageContainer.state;
+
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   const { currentUser } = appContainer;
   const isTopPagePath = isTopPage(path);
   const [isPageRenameModalShown, setIsPageRenameModalShown] = useState(false);
   const [isPageDuplicateModalShown, setIsPageDuplicateModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-  const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
   const [isPagePresentationModalShown, setIsPagePresentationModalShown] = useState(false);
+  const presentationHref = urljoin(window.location.origin, path, '?presentation=1');
 
   function openPageRenameModalHandler() {
     setIsPageRenameModalShown(true);
@@ -55,14 +56,6 @@ const PageManagement = (props) => {
     setIsPageTempleteModalShown(false);
   }
 
-  function openPageDeleteModalHandler() {
-    setIsPageDeleteModalShown(true);
-  }
-
-  function closePageDeleteModalHandler() {
-    setIsPageDeleteModalShown(false);
-  }
-
   function openPagePresentationModalHandler() {
     setIsPagePresentationModalShown(true);
   }
@@ -84,7 +77,6 @@ const PageManagement = (props) => {
   // }
 
   async function exportPageHandler(format) {
-    const { pageId, revisionId } = pageContainer.state;
     const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
     url.searchParams.append('format', format);
     url.searchParams.append('revisionId', revisionId);
@@ -144,17 +136,23 @@ const PageManagement = (props) => {
     );
   }
 
+  function generatePageObjectToDelete() {
+    return { pageId, revisionId, path };
+  }
+  const pageToDelete = generatePageObjectToDelete();
+
   function renderDropdownItemForDeletablePage() {
     return (
       <>
         <div className="dropdown-divider"></div>
-        <button className="dropdown-item text-danger" type="button" onClick={openPageDeleteModalHandler}>
+        <button className="dropdown-item text-danger" type="button" onClick={() => openDeleteModal([pageToDelete])}>
           <i className="icon-fw icon-fire"></i> { t('Delete') }
         </button>
       </>
     );
   }
 
+
   function renderModals() {
     if (currentUser == null) {
       return null;
@@ -165,26 +163,25 @@ const PageManagement = (props) => {
         <PageRenameModal
           isOpen={isPageRenameModalShown}
           onClose={closePageRenameModalHandler}
+          pageId={pageId}
+          revisionId={revisionId}
           path={path}
         />
         <PageDuplicateModal
           isOpen={isPageDuplicateModalShown}
           onClose={closePageDuplicateModalHandler}
+          pageId={pageId}
+          path={path}
         />
         <CreateTemplateModal
+          path={path}
           isOpen={isPageTemplateModalShown}
           onClose={closePageTemplateModalHandler}
         />
-        <PageDeleteModal
-          isOpen={isPageDeleteModalShown}
-          onClose={closePageDeleteModalHandler}
-          path={path}
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        />
         <PagePresentationModal
           isOpen={isPagePresentationModalShown}
           onClose={closePagePresentationModalHandler}
-          href="?presentation=1"
+          href={presentationHref}
         />
       </>
     );
@@ -195,10 +192,10 @@ const PageManagement = (props) => {
       <>
         <button
           type="button"
-          className={`btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management ${isCompactMode && 'py-0'}`}
+          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded btn-page-item-control"
           data-toggle="dropdown"
         >
-          <i className="icon-options"></i>
+          <i className="text-muted icon-options"></i>
         </button>
       </>
     );
@@ -209,10 +206,10 @@ const PageManagement = (props) => {
       <>
         <button
           type="button"
-          className={`btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled ${isCompactMode && 'py-0'}`}
+          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
           id="icon-options-guest-tltips"
         >
-          <i className="icon-options"></i>
+          <i className="text-muted icon-options"></i>
         </button>
         <UncontrolledTooltip placement="top" target="icon-options-guest-tltips" fade={false}>
           {t('Not available for guest')}
@@ -240,19 +237,28 @@ const PageManagement = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const PageManagementWrapper = withUnstatedContainers(PageManagement, [AppContainer, PageContainer]);
+const LegacyPageManagemenetWrapper = withUnstatedContainers(LegacyPageManagemenet, [AppContainer]);
 
 
-PageManagement.propTypes = {
+LegacyPageManagemenet.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+
+  pageId: PropTypes.string.isRequired,
+  revisionId: PropTypes.string.isRequired,
+  path: PropTypes.string.isRequired,
+  isDeletable: PropTypes.bool.isRequired,
+  isAbleToDeleteCompletely: PropTypes.bool,
 
   isCompactMode: PropTypes.bool,
 };
 
-PageManagement.defaultProps = {
+LegacyPageManagemenet.defaultProps = {
   isCompactMode: false,
 };
 
-export default withTranslation()(PageManagementWrapper);
+const PageManagement = (props) => {
+  return <LegacyPageManagemenetWrapper {...props}></LegacyPageManagemenetWrapper>;
+};
+export default withTranslation()(PageManagement);

+ 13 - 22
packages/app/src/components/Page/RenderTagLabels.jsx → packages/app/src/components/Page/RenderTagLabels.tsx

@@ -1,28 +1,26 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
-const RenderTagLabels = React.memo((props) => {
-  const {
-    t, tags, isGuestUser,
-  } = props;
+type RenderTagLabelsProps = {
+  tags: string[],
+  isGuestUser: boolean,
+  openEditorModal?: () => void,
+}
+
+const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
+  const { tags, isGuestUser, openEditorModal } = props;
+  const { t } = useTranslation();
 
   function openEditorHandler() {
-    if (props.openEditorModal == null) {
+    if (openEditorModal == null) {
       return;
     }
-    props.openEditorModal();
-  }
-
-  // activate suspense
-  if (tags == null) {
-    throw new Promise(() => {});
+    openEditorModal();
   }
 
   const isTagsEmpty = tags.length === 0;
-
   const tagElements = tags.map((tag) => {
     return (
       <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
@@ -54,12 +52,5 @@ const RenderTagLabels = React.memo((props) => {
 
 });
 
-RenderTagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  tags: PropTypes.array,
-  openEditorModal: PropTypes.func,
-  isGuestUser: PropTypes.bool.isRequired,
-};
 
-export default withTranslation()(RenderTagLabels);
+export default RenderTagLabels;

+ 2 - 1
packages/app/src/components/Page/RevisionBody.jsx

@@ -42,7 +42,7 @@ export default class RevisionBody extends React.PureComponent {
     //   So, before MathJax is initialized, execute renderMathJaxWithDebounce again.
     //   Avoiding initialization of MathJax of draw.io solves the problem.
     //   refs: https://github.com/jgraph/drawio/pull/831
-    if (MathJax != null) {
+    if (MathJax != null && this.element != null) {
       MathJax.typesetPromise([this.element]);
     }
     else {
@@ -64,6 +64,7 @@ export default class RevisionBody extends React.PureComponent {
             this.props.inputRef(elm);
           }
         }}
+        id="wiki"
         className={`wiki ${additionalClassName}`}
         // eslint-disable-next-line react/no-danger
         dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}

+ 8 - 5
packages/app/src/components/Page/RevisionLoader.jsx

@@ -13,7 +13,7 @@ import RevisionRenderer from './RevisionRenderer';
 /**
  * Load data from server and render RevisionBody component
  */
-class RevisionLoader extends React.Component {
+class LegacyRevisionLoader extends React.Component {
 
   constructor(props) {
     super(props);
@@ -116,9 +116,9 @@ class RevisionLoader extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const RevisionLoaderWrapper = withUnstatedContainers(RevisionLoader, [AppContainer]);
+const LegacyRevisionLoaderWrapper = withUnstatedContainers(LegacyRevisionLoader, [AppContainer]);
 
-RevisionLoader.propTypes = {
+LegacyRevisionLoader.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
@@ -126,7 +126,10 @@ RevisionLoader.propTypes = {
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.arrayOf(PropTypes.string),
 };
 
-export default RevisionLoaderWrapper;
+const RevisionLoader = (props) => {
+  return <LegacyRevisionLoaderWrapper {...props}></LegacyRevisionLoaderWrapper>;
+};
+export default RevisionLoader;

+ 60 - 10
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -8,6 +8,9 @@ import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { blinkElem } from '~/client/util/blink-section-header';
 
 import RevisionBody from './RevisionBody';
+import { loggerFactory } from '^/../codemirror-textlint/src/utils/logger';
+
+const logger = loggerFactory('components:Page:RevisionRenderer');
 
 class LegacyRevisionRenderer extends React.PureComponent {
 
@@ -59,20 +62,67 @@ class LegacyRevisionRenderer extends React.PureComponent {
    * @param {string} body html strings
    * @param {string} keywords
    */
-  getHighlightedBody(body, keywords) {
-    let returnBody = body;
-
-    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
+  getHighlightedBody(body, _keywords) {
+    const keywords = Array.isArray(_keywords)
+      ? _keywords
+      : [_keywords];
+
+    const normalizedKeywordsArray = [];
+    // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
+    // Separate keywords
+    // - Surrounded by double quotation
+    // - Split by both full-width and half-width spaces
+    // [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
+    keywords.forEach((keyword, i) => {
       if (keyword === '') {
         return;
       }
       const k = keyword
-        .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+        .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex operators
         .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
-      returnBody = returnBody.replace(keywordExp, '<em class="highlighted-keyword">$&</em>');
+      normalizedKeywordsArray.push(k);
     });
 
+    const normalizedKeywords = `(${normalizedKeywordsArray.join('|')})`;
+    const keywordRegxp = new RegExp(`${normalizedKeywords}(?!(.*?"))`, 'ig'); // prior https://regex101.com/r/oX7dq5/1
+    let keywordRegexp2 = keywordRegxp;
+
+    // for non-chrome browsers compatibility
+    try {
+      // eslint-disable-next-line regex/invalid
+      keywordRegexp2 = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // inferior (this doesn't work well when html tags exist a lot) https://regex101.com/r/Dfi61F/1
+    }
+    catch (err) {
+      logger.debug('Failed to initialize regex:', err);
+    }
+
+    const highlighter = (str) => { return str.replace(keywordRegxp, '<em class="highlighted-keyword">$&</em>') }; // prior
+    const highlighter2 = (str) => { return str.replace(keywordRegexp2, '<em class="highlighted-keyword">$&</em>') }; // inferior
+
+    const insideTagRegex = /<[^<>]*>/g;
+    const betweenTagRegex = />([^<>]*)</g; // use (group) to ignore >< around
+
+    const insideTagStrs = body.match(insideTagRegex);
+    const betweenTagMatches = Array.from(body.matchAll(betweenTagRegex));
+
+    let returnBody = body;
+    const isSafeHtml = insideTagStrs.length === betweenTagMatches.length + 1; // to check whether is safe to join
+    if (isSafeHtml) {
+      // highlight
+      const betweenTagStrs = betweenTagMatches.map(match => highlighter(match[1])); // get only grouped part (exclude >< around)
+
+      const arr = [];
+      insideTagStrs.forEach((str, i) => {
+        arr.push(str);
+        arr.push(betweenTagStrs[i]);
+      });
+      returnBody = arr.join('');
+    }
+    else {
+      // inferior highlighter
+      returnBody = highlighter2(body);
+    }
+
     return returnBody;
   }
 
@@ -93,7 +143,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     await interceptorManager.process('prePostProcess', context);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
-    if (this.props.highlightKeywords != null) {
+    if (highlightKeywords != null && highlightKeywords.length > 0) {
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
     }
     await interceptorManager.process('postPostProcess', context);
@@ -122,7 +172,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
 
@@ -140,7 +190,7 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
-  highlightKeywords: PropTypes.string,
+  highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
 

+ 0 - 119
packages/app/src/components/Page/TagLabels.jsx

@@ -1,119 +0,0 @@
-import React, { Suspense } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-
-import RenderTagLabels from './RenderTagLabels';
-import TagEditModal from './TagEditModal';
-import { EditorMode } from '~/stores/ui';
-
-class TagLabels extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isTagEditModalShown: false,
-    };
-
-    this.openEditorModal = this.openEditorModal.bind(this);
-    this.closeEditorModal = this.closeEditorModal.bind(this);
-    this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
-  }
-
-  /**
-   * @return tags data
-   *   1. pageContainer.state.tags if editorMode is view
-   *   2. editorContainer.state.tags if editorMode is edit
-   */
-  getTagData() {
-    const { editorContainer, pageContainer, editorMode } = this.props;
-    return (editorMode === EditorMode.Editor) ? editorContainer.state.tags : pageContainer.state.tags;
-  }
-
-  openEditorModal() {
-    this.setState({ isTagEditModalShown: true });
-  }
-
-  closeEditorModal() {
-    this.setState({ isTagEditModalShown: false });
-  }
-
-  async tagsUpdatedHandler(newTags) {
-    const {
-      appContainer, editorContainer, pageContainer, editorMode,
-    } = this.props;
-
-    const { pageId, revisionId } = pageContainer.state;
-    // It will not be reflected in the DB until the page is refreshed
-    if (editorMode === EditorMode.Editor) {
-      return editorContainer.setState({ tags: newTags });
-    }
-    try {
-      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
-        pageId, tags: newTags, revisionId,
-      });
-      editorContainer.setState({ tags });
-      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err, 'fail to update tags');
-    }
-  }
-
-
-  render() {
-    const tags = this.getTagData();
-    const { appContainer } = this.props;
-
-    return (
-      <>
-
-        <form className="grw-tag-labels form-inline">
-          <i className="tag-icon icon-tag mr-2"></i>
-          <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
-            <RenderTagLabels
-              tags={tags}
-              openEditorModal={this.openEditorModal}
-              isGuestUser={appContainer.isGuestUser}
-            />
-          </Suspense>
-        </form>
-
-        <TagEditModal
-          tags={tags}
-          isOpen={this.state.isTagEditModalShown}
-          onClose={this.closeEditorModal}
-          appContainer={this.props.appContainer}
-          onTagsUpdated={this.tagsUpdatedHandler}
-        />
-
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
-
-TagLabels.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  editorMode: PropTypes.string,
-};
-
-export default withTranslation()(TagLabelsWrapper);

+ 54 - 0
packages/app/src/components/Page/TagLabels.tsx

@@ -0,0 +1,54 @@
+import React, { FC, useState } from 'react';
+
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
+
+type Props = {
+  tags?: string[],
+  isGuestUser: boolean,
+  tagsUpdateInvoked?: (tags: string[]) => Promise<void>,
+}
+
+
+const TagLabels:FC<Props> = (props: Props) => {
+  const { tags, isGuestUser, tagsUpdateInvoked } = props;
+
+  const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
+
+  const openEditorModal = () => {
+    setIsTagEditModalShown(true);
+  };
+
+  const closeEditorModal = () => {
+    setIsTagEditModalShown(false);
+  };
+
+  return (
+    <>
+      <form className="grw-tag-labels form-inline">
+        <i className="tag-icon icon-tag mr-2"></i>
+        { tags == null
+          ? (
+            <span className="grw-tag-label badge badge-secondary">―</span>
+          )
+          : (
+            <RenderTagLabels
+              tags={tags}
+              openEditorModal={openEditorModal}
+              isGuestUser={isGuestUser}
+            />
+          )
+        }
+      </form>
+
+      <TagEditModal
+        tags={tags}
+        isOpen={isTagEditModalShown}
+        onClose={closeEditorModal}
+        onTagsUpdated={tagsUpdateInvoked}
+      />
+    </>
+  );
+};
+
+export default TagLabels;

+ 12 - 16
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -7,21 +7,22 @@ import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt } from '~/stores/context';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
-import PageDeleteModal from '../PageDeleteModal';
 
+import { useCurrentUpdatedAt } from '~/stores/context';
+import { usePageDeleteModal } from '~/stores/modal';
 
 const TrashPageAlert = (props) => {
   const { t, pageContainer } = props;
   const {
-    path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt, isAbleToDeleteCompletely,
+    pageId, revisionId, path, isDeleted, lastUpdateUsername, deletedUserName, deletedAt, isAbleToDeleteCompletely,
   } = pageContainer.state;
   const { data: updatedAt } = useCurrentUpdatedAt();
   const [isEmptyTrashModalShown, setIsEmptyTrashModalShown] = useState(false);
   const [isPutbackPageModalShown, setIsPutbackPageModalShown] = useState(false);
-  const [isPageDeleteModalShown, setIsPageDeleteModalShown] = useState(false);
+
+  const { open: openDeleteModal } = usePageDeleteModal();
 
   function openEmptyTrashModalHandler() {
     setIsEmptyTrashModalShown(true);
@@ -40,11 +41,12 @@ const TrashPageAlert = (props) => {
   }
 
   function openPageDeleteModalHandler() {
-    setIsPageDeleteModalShown(true);
-  }
-
-  function opclosePageDeleteModalHandler() {
-    setIsPageDeleteModalShown(false);
+    const pageToDelete = {
+      pageId,
+      revisionId,
+      path,
+    };
+    openDeleteModal([pageToDelete]);
   }
 
   function renderEmptyButton() {
@@ -94,15 +96,9 @@ const TrashPageAlert = (props) => {
         <PutbackPageModal
           isOpen={isPutbackPageModalShown}
           onClose={closePutbackPageModalHandler}
+          pageId={pageId}
           path={path}
         />
-        <PageDeleteModal
-          isOpen={isPageDeleteModalShown}
-          onClose={opclosePageDeleteModalHandler}
-          path={path}
-          isDeleteCompletelyModal
-          isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        />
       </>
     );
   }

+ 0 - 44
packages/app/src/components/PageAccessories.jsx

@@ -1,44 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageAccessoriesModalControl from './PageAccessoriesModalControl';
-import PageAccessoriesModal from './PageAccessoriesModal';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-
-const PageAccessories = (props) => {
-  const { appContainer, pageAccessoriesContainer, isNotFoundPage } = props;
-  const { isGuestUser, isSharedUser } = appContainer;
-
-  return (
-    <>
-      <PageAccessoriesModalControl
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
-      />
-      <PageAccessoriesModal
-        isGuestUser={isGuestUser}
-        isSharedUser={isSharedUser}
-        isNotFoundPage={isNotFoundPage}
-        isOpen={pageAccessoriesContainer.state.isPageAccessoriesModalShown}
-        onClose={pageAccessoriesContainer.closePageAccessoriesModal}
-      />
-    </>
-  );
-};
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesWrapper = withUnstatedContainers(PageAccessories, [AppContainer, PageAccessoriesContainer]);
-
-PageAccessories.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-
-  isNotFoundPage: PropTypes.bool.isRequired,
-};
-
-export default PageAccessoriesWrapper;

+ 0 - 159
packages/app/src/components/PageAccessoriesModal.jsx

@@ -1,159 +0,0 @@
-import React, { useCallback, useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalBody, ModalHeader, TabContent, TabPane,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
-import PageAttachment from './PageAttachment';
-import PageTimeline from './PageTimeline';
-import PageList from './PageList';
-import PageHistory from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-const PageAccessoriesModal = (props) => {
-  const {
-    t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-  const { switchActiveTab } = pageAccessoriesContainer;
-  const { activeTab, activeComponents } = pageAccessoriesContainer.state;
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: PageListIcon,
-        i18n: t('page_list'),
-        index: 0,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      timeline: {
-        Icon: TimeLineIcon,
-        i18n: t('Timeline View'),
-        index: 1,
-        isLinkEnabled: v => !isSharedUser,
-      },
-      pageHistory: {
-        Icon: HistoryIcon,
-        i18n: t('History'),
-        index: 2,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
-      },
-      attachment: {
-        Icon: AttachmentIcon,
-        i18n: t('attachment_data'),
-        index: 3,
-        isLinkEnabled: v => !isNotFoundPage,
-      },
-      shareLink: {
-        Icon: ShareLinkIcon,
-        i18n: t('share_links.share_link_management'),
-        index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage && !isLinkSharingDisabled,
-      },
-    };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
-
-  const closeModalHandler = useCallback(() => {
-    if (onClose == null) {
-      return;
-    }
-    onClose();
-  }, [onClose]);
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-  };
-
-  const buttons = (
-    <div className="d-flex flex-nowrap">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="close" onClick={closeModalHandler} aria-label="Close">
-        <span aria-hidden="true">&times;</span>
-      </button>
-    </div>
-  );
-
-  return (
-    <React.Fragment>
-      <Modal
-        size="xl"
-        isOpen={props.isOpen}
-        toggle={closeModalHandler}
-        className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-      >
-        <ModalHeader className="p-0" toggle={closeModalHandler} close={buttons}>
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={switchActiveTab}
-            breakpointToHideInactiveTabsDown="md"
-            hideBorderBottom
-          />
-        </ModalHeader>
-        <ModalBody className="overflow-auto grw-modal-body-style">
-          {/* Do not use CustomTabContent because of performance problem:
-              the 'navTabMapping[tabId].Content' for PageAccessoriesModal depends on activeComponents */}
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="pagelist">
-              {activeComponents.has('pagelist') && <PageList />}
-            </TabPane>
-            <TabPane tabId="timeline">
-              {activeComponents.has('timeline') && <PageTimeline /> }
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="pageHistory">
-                {activeComponents.has('pageHistory') && <PageHistory /> }
-              </TabPane>
-            )}
-            <TabPane tabId="attachment">
-              {activeComponents.has('attachment') && <PageAttachment />}
-            </TabPane>
-            {!isGuestUser && (
-              <TabPane tabId="shareLink">
-                {activeComponents.has('shareLink') && <ShareLink />}
-              </TabPane>
-            )}
-          </TabContent>
-        </ModalBody>
-      </Modal>
-    </React.Fragment>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [PageAccessoriesContainer]);
-
-PageAccessoriesModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-};
-
-export default withTranslation()(PageAccessoriesModalWrapper);

+ 134 - 0
packages/app/src/components/PageAccessoriesModal.tsx

@@ -0,0 +1,134 @@
+import React, { useEffect, useMemo, useState } from 'react';
+
+import {
+  Modal, ModalBody, ModalHeader,
+} from 'reactstrap';
+
+import { useTranslation } from 'react-i18next';
+
+import { useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+import AppContainer from '~/client/services/AppContainer';
+
+import HistoryIcon from './Icons/HistoryIcon';
+import AttachmentIcon from './Icons/AttachmentIcon';
+import ShareLinkIcon from './Icons/ShareLinkIcon';
+import { withUnstatedContainers } from './UnstatedUtils';
+import PageAttachment from './PageAttachment';
+import PageHistory from './PageHistory';
+import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from './CustomNavigation/CustomNav';
+import ExpandOrContractButton from './ExpandOrContractButton';
+import CustomTabContent from './CustomNavigation/CustomTabContent';
+
+
+type Props = {
+  appContainer: AppContainer,
+  isLinkSharingDisabled: boolean,
+}
+
+const PageAccessoriesModal = (props: Props): JSX.Element => {
+  const {
+    appContainer,
+  } = props;
+
+  const isLinkSharingDisabled = appContainer.config.disableLinkSharing;
+
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState<PageAccessoriesModalContents>(PageAccessoriesModalContents.PageHistory);
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: status, mutate, close } = usePageAccessoriesModal();
+
+  // add event handler when opened
+  useEffect(() => {
+    if (status == null || status.onOpened != null) {
+      return;
+    }
+    mutate({
+      ...status,
+      onOpened: (activatedContents) => {
+        setActiveTab(activatedContents);
+      },
+    }, false);
+  }, [mutate, status]);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [PageAccessoriesModalContents.PageHistory]: {
+        Icon: HistoryIcon,
+        Content: () => <PageHistory />,
+        i18n: t('History'),
+        index: 0,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser,
+      },
+      [PageAccessoriesModalContents.Attachment]: {
+        Icon: AttachmentIcon,
+        Content: () => <PageAttachment />,
+        i18n: t('attachment_data'),
+        index: 1,
+      },
+      [PageAccessoriesModalContents.ShareLink]: {
+        Icon: ShareLinkIcon,
+        Content: () => <ShareLink />,
+        i18n: t('share_links.share_link_management'),
+        index: 2,
+        isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
+      },
+    };
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+
+  const buttons = useMemo(() => (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={() => setIsWindowExpanded(true)}
+        contractWindow={() => setIsWindowExpanded(false)}
+      />
+      <button type="button" className="close" onClick={close} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  ), [close, isWindowExpanded]);
+
+  if (status == null) {
+    return <></>;
+  }
+
+  const { isOpened } = status;
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={isOpened}
+      toggle={close}
+      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      <ModalHeader className="p-0" toggle={close} close={buttons}>
+        <CustomNavTab
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          breakpointToHideInactiveTabsDown="md"
+          onNavSelected={(v) => {
+            setActiveTab(v);
+          }}
+          hideBorderBottom
+        />
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+      </ModalBody>
+    </Modal>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PageAccessoriesModalWrapper = withUnstatedContainers(PageAccessoriesModal, [AppContainer]);
+
+export default PageAccessoriesModalWrapper;

+ 9 - 17
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -4,26 +4,24 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
-import PageAccessoriesContainer from '~/client/services/PageAccessoriesContainer';
 
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import HistoryIcon from './Icons/HistoryIcon';
 import AttachmentIcon from './Icons/AttachmentIcon';
 import ShareLinkIcon from './Icons/ShareLinkIcon';
-import SeenUserInfo from './User/SeenUserInfo';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
-import { usePageId } from '~/stores/context';
+import { useCurrentPageId } from '~/stores/context';
 
 const PageAccessoriesModalControl = (props) => {
   const {
-    t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
+    t, pageAccessoriesContainer, isGuestUser, isSharedUser,
   } = props;
   const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
-  const { data: pageId } = usePageId();
+  const { data: pageId } = useCurrentPageId();
 
   const accessoriesBtnList = useMemo(() => {
     return [
@@ -42,23 +40,22 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'pageHistory',
         Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser,
         i18n: t('History'),
       },
       {
         name: 'attachment',
         Icon: <AttachmentIcon />,
-        disabled: isNotFoundPage,
         i18n: t('attachment_data'),
       },
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
+        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -66,7 +63,7 @@ const PageAccessoriesModalControl = (props) => {
 
         let tooltipMessage;
         if (accessory.disabled) {
-          tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+          tooltipMessage = t('Not available for guest');
           if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
             tooltipMessage = t('Link sharing is disabled');
           }
@@ -92,26 +89,21 @@ const PageAccessoriesModalControl = (props) => {
           </Fragment>
         );
       })}
-      <div className="d-flex align-items-center">
-        <span className="border-left grw-border-vr">&nbsp;</span>
-        <SeenUserInfo disabled={isSharedUser} pageId={pageId} />
-      </div>
     </div>
   );
 };
 /**
  * Wrapper component for using unstated
  */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, [PageAccessoriesContainer]);
+const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
 
 PageAccessoriesModalControl.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
 
-  pageAccessoriesContainer: PropTypes.instanceOf(PageAccessoriesContainer).isRequired,
+  pageAccessoriesContainer: PropTypes.any,
 
   isGuestUser: PropTypes.bool.isRequired,
   isSharedUser: PropTypes.bool.isRequired,
-  isNotFoundPage: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(PageAccessoriesModalControlWrapper);

+ 14 - 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,8 @@ 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 { usePageCreateModal } from '~/stores/modal';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
@@ -24,11 +25,12 @@ const {
 const PageCreateModal = (props) => {
   const { t, appContainer } = props;
 
-  const { data: isPageCreateModalOpened, mutate: mutatePageCreateModalOpened } = usePageCreateModalOpened();
+  const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
+  const { isOpened, path } = pageCreateModalData;
 
   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 +40,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 +273,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>

+ 0 - 157
packages/app/src/components/PageDeleteModal.jsx

@@ -1,157 +0,0 @@
-import React, { useState } from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-import PageContainer from '~/client/services/PageContainer';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-
-const deleteIconAndKey = {
-  completely: {
-    color: 'danger',
-    icon: 'fire',
-    translationKey: 'completely',
-  },
-  temporary: {
-    color: 'primary',
-    icon: 'trash',
-    translationKey: 'page',
-  },
-};
-
-const PageDeleteModal = (props) => {
-  const {
-    t, pageContainer, isOpen, onClose, isDeleteCompletelyModal, path, isAbleToDeleteCompletely,
-  } = props;
-  const [isDeleteRecursively, setIsDeleteRecursively] = useState(true);
-  const [isDeleteCompletely, setIsDeleteCompletely] = useState(isDeleteCompletelyModal && isAbleToDeleteCompletely);
-  const deleteMode = isDeleteCompletely ? 'completely' : 'temporary';
-
-  const [errs, setErrs] = useState(null);
-
-  function changeIsDeleteRecursivelyHandler() {
-    setIsDeleteRecursively(!isDeleteRecursively);
-  }
-
-  function changeIsDeleteCompletelyHandler() {
-    if (!isAbleToDeleteCompletely) {
-      return;
-    }
-    setIsDeleteCompletely(!isDeleteCompletely);
-  }
-
-  async function deletePage() {
-    setErrs(null);
-
-    try {
-      const response = await pageContainer.deletePage(isDeleteRecursively, isDeleteCompletely);
-      const trashPagePath = response.page.path;
-      window.location.href = encodeURI(trashPagePath);
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  async function deleteButtonHandler() {
-    deletePage();
-  }
-
-  function renderDeleteRecursivelyForm() {
-    return (
-      <div className="custom-control custom-checkbox custom-checkbox-warning">
-        <input
-          className="custom-control-input"
-          id="deleteRecursively"
-          type="checkbox"
-          checked={isDeleteRecursively}
-          onChange={changeIsDeleteRecursivelyHandler}
-        />
-        <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>
-    );
-  }
-
-  function renderDeleteCompletelyForm() {
-    return (
-      <div className="custom-control custom-checkbox custom-checkbox-danger">
-        <input
-          className="custom-control-input"
-          name="completely"
-          id="deleteCompletely"
-          type="checkbox"
-          disabled={!isAbleToDeleteCompletely}
-          checked={isDeleteCompletely}
-          onChange={changeIsDeleteCompletelyHandler}
-        />
-        <label className="custom-control-label text-danger" htmlFor="deleteCompletely">
-          { t('modal_delete.delete_completely') }
-          <p className="form-text text-muted mt-0"> { t('modal_delete.completely') }</p>
-        </label>
-        {!isAbleToDeleteCompletely
-        && (
-          <p className="alert alert-warning p-2 my-0">
-            <i className="icon-ban icon-fw"></i>{ t('modal_delete.delete_completely_restriction') }
-          </p>
-        )}
-      </div>
-    );
-  }
-
-  return (
-    <Modal size="lg" isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
-        <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
-        { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
-      </ModalHeader>
-      <ModalBody>
-        <div className="form-group">
-          <label>{ t('modal_delete.deleting_page') }:</label><br />
-          <code>{ path }</code>
-        </div>
-        {renderDeleteRecursivelyForm()}
-        {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
-          <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
-          { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
-        </button>
-      </ModalFooter>
-    </Modal>
-
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageDeleteModalWrapper = withUnstatedContainers(PageDeleteModal, [PageContainer]);
-
-PageDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  path: PropTypes.string.isRequired,
-  isDeleteCompletelyModal: PropTypes.bool,
-  isAbleToDeleteCompletely: PropTypes.bool,
-};
-
-PageDeleteModal.defaultProps = {
-  isDeleteCompletelyModal: false,
-};
-
-export default withTranslation()(PageDeleteModalWrapper);

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