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

Merge branch 'support/apply-nextjs-2' into imprv/99346-next-Growi-SubNavigation

yuken 3 лет назад
Родитель
Сommit
2277ddf869
100 измененных файлов с 2115 добавлено и 1482 удалено
  1. 0 1
      .github/workflows/ci-app.yml
  2. 10 1
      .github/workflows/reusable-app-prod.yml
  3. 21 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 5 0
      packages/app/.env.development
  7. 2 2
      packages/app/bin/github-actions/update-readme.sh
  8. 83 0
      packages/app/config/rate-limiter.ts
  9. 0 3
      packages/app/docker/Dockerfile
  10. 6 6
      packages/app/docker/README.md
  11. 10 9
      packages/app/package.json
  12. 16 0
      packages/app/public/static/locales/en_US/admin/admin.json
  13. 4 1
      packages/app/public/static/locales/en_US/translation.json
  14. 0 2
      packages/app/public/static/locales/index.js
  15. 16 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  16. 4 1
      packages/app/public/static/locales/ja_JP/translation.json
  17. 16 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  18. 4 1
      packages/app/public/static/locales/zh_CN/translation.json
  19. 2 0
      packages/app/src/client/admin.jsx
  20. 2 14
      packages/app/src/client/app.jsx
  21. 1 2
      packages/app/src/client/plugin.js
  22. 8 30
      packages/app/src/client/services/AppContainer.js
  23. 47 24
      packages/app/src/client/services/ContextExtractor.tsx
  24. 0 24
      packages/app/src/client/services/EditorContainer.js
  25. 2 5
      packages/app/src/client/services/PageContainer.js
  26. 0 186
      packages/app/src/client/services/PersonalContainer.js
  27. 0 208
      packages/app/src/client/util/GrowiRenderer.js
  28. 0 23
      packages/app/src/client/util/PreProcessor/XssFilter.js
  29. 4 7
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  30. 47 0
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  31. 26 0
      packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  32. 54 0
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  33. 67 0
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  34. 122 0
      packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  35. 123 0
      packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  36. 181 0
      packages/app/src/components/Admin/AuditLogManagement.tsx
  37. 16 11
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  38. 2 0
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  39. 2 4
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  40. 3 4
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  41. 2 4
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  42. 3 5
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  43. 7 8
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  44. 12 11
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  45. 55 0
      packages/app/src/components/AdminLayout.tsx
  46. 1 1
      packages/app/src/components/BasicLayout.tsx
  47. 7 2
      packages/app/src/components/BookmarkButtons.tsx
  48. 2 1
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  49. 2 1
      packages/app/src/components/ContentLinkButtons.tsx
  50. 4 4
      packages/app/src/components/DescendantsPageList.tsx
  51. 1 0
      packages/app/src/components/DescendantsPageListModal.tsx
  52. 2 2
      packages/app/src/components/EmptyTrashButton.tsx
  53. 9 20
      packages/app/src/components/IdenticalPathPage.tsx
  54. 1 1
      packages/app/src/components/InstallerForm.jsx
  55. 7 2
      packages/app/src/components/LikeButtons.tsx
  56. 0 116
      packages/app/src/components/Me/ApiSettings.jsx
  57. 91 0
      packages/app/src/components/Me/ApiSettings.tsx
  58. 0 152
      packages/app/src/components/Me/AssociateModal.jsx
  59. 111 0
      packages/app/src/components/Me/AssociateModal.tsx
  60. 0 181
      packages/app/src/components/Me/BasicInfoSettings.jsx
  61. 171 0
      packages/app/src/components/Me/BasicInfoSettings.tsx
  62. 0 98
      packages/app/src/components/Me/DisassociateModal.jsx
  63. 69 0
      packages/app/src/components/Me/DisassociateModal.tsx
  64. 8 18
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  65. 19 17
      packages/app/src/components/Me/PasswordSettings.jsx
  66. 10 2
      packages/app/src/components/MyDraftList/Draft.jsx
  67. 43 15
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  68. 1 2
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  69. 4 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  70. 6 28
      packages/app/src/components/NotFoundPage.tsx
  71. 16 10
      packages/app/src/components/Page.jsx
  72. 26 19
      packages/app/src/components/Page/DisplaySwitcher.tsx
  73. 8 2
      packages/app/src/components/Page/RevisionLoader.jsx
  74. 1 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  75. 11 8
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  76. 27 0
      packages/app/src/components/PageAlert/OldRevisionAlert.tsx
  77. 28 0
      packages/app/src/components/PageAlert/PageAlerts.tsx
  78. 53 0
      packages/app/src/components/PageAlert/PageGrantAlert.tsx
  79. 41 0
      packages/app/src/components/PageAlert/PageStaleAlert.tsx
  80. 34 44
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  81. 9 3
      packages/app/src/components/PageComment.tsx
  82. 1 1
      packages/app/src/components/PageComment/CommentEditor.tsx
  83. 6 1
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  84. 3 2
      packages/app/src/components/PageDeleteModal.tsx
  85. 26 4
      packages/app/src/components/PageEditor.tsx
  86. 4 5
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  87. 4 20
      packages/app/src/components/PageEditor/OptionsSelector.tsx
  88. 5 5
      packages/app/src/components/PageEditor/Preview.tsx
  89. 10 6
      packages/app/src/components/PageEditorByHackmd.jsx
  90. 33 25
      packages/app/src/components/PagePathHierarchicalLink.tsx
  91. 12 3
      packages/app/src/components/PageTimeline.jsx
  92. 4 0
      packages/app/src/components/PrivateLegacyPages.tsx
  93. 16 20
      packages/app/src/components/SavePageControls.jsx
  94. 5 6
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  95. 5 4
      packages/app/src/components/SearchPage/SearchResultList.tsx
  96. 12 14
      packages/app/src/components/Sidebar.tsx
  97. 6 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  98. 2 3
      packages/app/src/components/Sidebar/PageTree.tsx
  99. 10 6
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  100. 141 0
      packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss

+ 0 - 1
.github/workflows/ci-app.yml

@@ -7,7 +7,6 @@ on:
       - rc/**
       - chore/**
       - support/prepare-v**
-      - support/apply-nextjs-2
     paths:
       - .github/workflows/ci-app.yml
       - .eslint*

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

@@ -59,7 +59,16 @@ jobs:
     - name: Archive production files
       id: archive-prod-files
       run: |
-        tar -cf production.tar packages/**/dist packages/app/public
+        tar -cf production.tar \
+          package.json \
+          packages/app/config \
+          packages/app/public \
+          packages/app/resource \
+          packages/app/tmp \
+          packages/app/migrate-mongo-config.js \
+          packages/app/.env.production* \
+          packages/*/package.json \
+          packages/*/dist
         echo ::set-output name=file::production.tar
 
     - name: Upload production files as artifact

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.10...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.11...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
+
+### 💎 Features
+
+- feat: Integrate recount descendant count after paths fix (#6170) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Redirect when the anchor is #password (#6144) @Kami-jo
+
+### 🐛 Bug Fixes
+
+- fix: User registration page is not redirected after tmp login (#6197) @kaoritokashiki
+- fix: Empty trash doesn't work (#6168) @yukendev
+
+### 🧰 Maintenance
+
+- support: Ease rate limit temporary (#6191) @yuki-takei
+- support: Omit page history container and page revision comparer container (#6185) @yukendev
+
 ## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 5 - 0
packages/app/.env.development

@@ -29,3 +29,8 @@ OGP_URI="http://ogp:8088"
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
+# AUDIT_LOG_ENABLED=false
+# ACTIVITY_EXPIRATION_SECONDS=2592000
+# AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
+# AUDIT_LOG_ADDITIONAL_ACTIONS=
+# AUDIT_LOG_EXCLUDE_ACTIONS=

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

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

+ 83 - 0
packages/app/config/rate-limiter.ts

@@ -0,0 +1,83 @@
+export type IApiRateLimitConfig = {
+  method: string,
+  maxRequests: number,
+  usersPerIpProspection?: number,
+}
+export type IApiRateLimitEndpointMap = {
+  [endpoint: string]: IApiRateLimitConfig
+}
+
+export const DEFAULT_MAX_REQUESTS = 500;
+export const DEFAULT_DURATION_SEC = 60;
+export const DEFAULT_USERS_PER_IP_PROSPECTION = 5;
+
+const MAX_REQUESTS_TIER_1 = 5;
+const MAX_REQUESTS_TIER_2 = 20;
+const MAX_REQUESTS_TIER_3 = 50;
+const MAX_REQUESTS_TIER_4 = 100;
+
+// default config without reg exp
+export const defaultConfig: IApiRateLimitEndpointMap = {
+  '/_api/v3/healthcheck': {
+    method: 'GET',
+    maxRequests: 60,
+    usersPerIpProspection: 1,
+  },
+  '/installer': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 1,
+  },
+  '/login': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 100,
+  },
+  '/login/activateInvited': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_2,
+  },
+  '/register': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 20,
+  },
+  '/user-activation/register': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 20,
+  },
+  '/_api/login/testLdap': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_2,
+    usersPerIpProspection: 1,
+  },
+  '/_api/check_username': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_3,
+  },
+};
+
+// default config with reg exp
+export const defaultConfigWithRegExp = {
+  '/forgot-password/.*': {
+    method: 'ALL',
+    maxRequests: MAX_REQUESTS_TIER_1,
+  },
+  '/user-activation/.*': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_1,
+  },
+  '/attachment/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+  '/download/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+  '/share/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+};

+ 0 - 3
packages/app/docker/Dockerfile

@@ -111,15 +111,12 @@ RUN yarn lerna run build
 # make artifacts
 RUN tar -cf packages.tar \
   package.json \
-  tsconfig.base.json \
   packages/app/config \
   packages/app/public \
   packages/app/resource \
   packages/app/tmp \
   packages/app/migrate-mongo-config.js \
   packages/app/.env.production* \
-  packages/app/tsconfig.base.json \
-  packages/app/tsconfig.json \
   packages/*/package.json \
   packages/*/dist
 

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

@@ -10,12 +10,12 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.10`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
-* [`5.0.10-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
-* [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
-* [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
-* [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
-* [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
+* [`5.1.0`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/packages/app/docker/Dockerfile)
+* [`5.1.0-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/packages/app/docker/Dockerfile)
+* [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
+* [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
+* [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
+* [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 
 
 What is GROWI?

+ 10 - 9
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.11-RC.0",
+  "version": "5.1.0-RC.1",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -13,7 +13,7 @@
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
+    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -63,11 +63,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.11-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.11-RC.0",
-    "@growi/plugin-lsx": "^5.0.11-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.11-RC.0",
-    "@growi/slack": "^5.0.11-RC.0",
+    "@growi/codemirror-textlint": "^5.1.0-RC.1",
+    "@growi/plugin-attachment-refs": "^5.1.0-RC.1",
+    "@growi/plugin-lsx": "^5.1.0-RC.1",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.0-RC.1",
+    "@growi/slack": "^5.1.0-RC.1",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -102,7 +102,6 @@
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
-    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
@@ -142,8 +141,10 @@
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
+    "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-card-flip": "^1.0.10",
+    "react-datepicker": "^4.7.0",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
@@ -170,7 +171,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^5.0.11-RC.0",
+    "@growi/ui": "^5.1.0-RC.1",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

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

@@ -521,5 +521,21 @@
       "force_update_parents_label": "Forcibly add missing users",
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
+  },
+  "audit_log_management": {
+    "username": "Username",
+    "date": "Date",
+    "action": "Action",
+    "ip": "IP Address",
+    "url": "URL",
+    "settings": "Settings",
+    "return": "Return",
+    "activity_expiration_date": "Audit Log expiration date",
+    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
+    "available_action_list": "Search / View All Available Actions",
+    "available_action_list_explain": "List of actions that can be search / view in the Audit Log",
+    "action_list": "Action List",
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true."
   }
 }

+ 4 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -126,6 +126,8 @@
   "UserGroup": "UserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
+  "AuditLog": "Audit Log",
+  "AuditLog Settings": "Audit Log Settings",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Export Archive Data": "Export Archive Data",
@@ -387,7 +389,8 @@
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
     "notice": {
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
-    }
+    },
+    "changes_not_saved": "Changes you made may not be saved."
   },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",

+ 0 - 2
packages/app/public/static/locales/index.js

@@ -1,2 +0,0 @@
-// !!DO NOT EDIT/REMOVE THIS FILE!!
-// entry point for @alienfast/i18next-loader

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

@@ -520,5 +520,21 @@
       "force_update_parents_label": "強制的に足りないユーザーを追加する",
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
+  },
+  "audit_log_management": {
+    "username": "ユーザー名",
+    "date": "日付",
+    "action": "アクション",
+    "ip": "IPアドレス",
+    "url": "URL",
+    "settings": "設定",
+    "return": "戻る",
+    "activity_expiration_date": "監査ログの有効期限",
+    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
+    "available_action_list": "検索 / 表示 可能なアクション一覧",
+    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "action_list": "アクション一覧",
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
   }
 }

+ 4 - 1
packages/app/public/static/locales/ja_JP/translation.json

@@ -126,6 +126,8 @@
   "UserGroup": "グループ",
   "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
+  "AuditLog": "監査ログ",
+  "AuditLog Settings": "監査ログ設定",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Export Archive Data": "データアーカイブ",
@@ -387,7 +389,8 @@
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
     "notice": {
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
-    }
+    },
+    "changes_not_saved": "変更が保存されていない可能性があります。"
   },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",

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

@@ -530,5 +530,21 @@
       "force_update_parents_label": "强行添加失踪的用户",
       "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
+  },
+  "audit_log_management": {
+    "username": "帐号",
+    "date": "日期",
+    "action": "行动",
+    "ip": "IP地址",
+    "url": "URL",
+    "settings": "设置",
+    "return": "返回",
+    "activity_expiration_date": "审计日志的到期日",
+    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "available_action_list": "搜索/查看 所有可用的行动",
+    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "action_list": "行动清单",
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
   }
 }

+ 4 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -134,6 +134,8 @@
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
+  "AuditLog": "审计日志",
+  "AuditLog Settings": "审计日志设置",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Export Archive Data": "导出主题数据",
@@ -366,7 +368,8 @@
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
-		}
+		},
+    "changes_not_saved": "您所做的更改可能不会保存。"
   },
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",

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

@@ -31,6 +31,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import { AuditLogManagement } from '../components/Admin/AuditLogManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import Customize from '../components/Admin/Customize/Customize';
 import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
@@ -107,6 +108,7 @@ Object.assign(componentMappings, {
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-user-group-page': <UserGroupPage />,
+  'admin-audit-log': <AuditLogManagement />,
   'admin-navigation': <AdminNavigation />,
 });
 

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

@@ -10,7 +10,6 @@ import { Provider } from 'unstated';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
@@ -29,10 +28,8 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import TrashPageAlert from '../components/Page/TrashPageAlert';
 import PageComment from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
@@ -57,9 +54,8 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 // create unstated container instance
 const pageContainer = new PageContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
-const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, pageContainer, editorContainer, personalContainer,
+  appContainer, socketIoContainer, pageContainer, editorContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -85,8 +81,6 @@ Object.assign(componentMappings, {
 
   'maintenance-mode-content': <MaintenanceModeContent />,
 
-  'trash-page-alert': <TrashPageAlert />,
-
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
@@ -95,8 +89,7 @@ Object.assign(componentMappings, {
 
   'page-timeline': <PageTimeline />,
 
-  'personal-setting': <PersonalSettings crowi={personalContainer} />,
-
+  'personal-setting': <PersonalSettings />,
   'my-drafts': <MyDraftList />,
 
   'grw-fab-container': <Fab />,
@@ -119,11 +112,6 @@ if (pageContainer.state.pageId != null) {
 
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
-  if (!pageContainer.state.isEmpty) {
-    Object.assign(componentMappings, {
-      'fix-page-grant-alert': <FixPageGrantAlert />,
-    });
-  }
 }
 if (pageContainer.state.creator != null) {
   Object.assign(componentMappings, {

+ 1 - 2
packages/app/src/client/plugin.js

@@ -8,12 +8,11 @@ export default class GrowiPlugin {
    * process plugin entry
    *
    * @param {AppContainer} appContainer
-   * @param {GrowiRenderer} originRenderer The origin instance of GrowiRenderer
    *
    * @memberof CrowiPlugin
    */
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  installAll(appContainer, originRenderer) {
+  installAll(appContainer) {
     // import plugin definitions
     let definitions = [];
     try {

+ 8 - 30
packages/app/src/client/services/AppContainer.js

@@ -1,7 +1,8 @@
 import { Container } from 'unstated';
 
 
-import GrowiRenderer from '../util/GrowiRenderer';
+import GrowiRenderer, { generatePreviewRenderer } from '~/services/renderer/growi-renderer';
+
 import { i18nFactory } from '../util/i18n';
 
 /**
@@ -26,7 +27,6 @@ export default class AppContainer extends Container {
 
     this.containerInstances = {};
     this.componentInstances = {};
-    this.rendererInstances = {};
   }
 
   /**
@@ -45,8 +45,6 @@ export default class AppContainer extends Container {
 
     this.isDocSaved = true;
 
-    this.originRenderer = new GrowiRenderer(this);
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
       this.initPlugins();
@@ -57,18 +55,20 @@ export default class AppContainer extends Container {
 
   initPlugins() {
     const growiPlugin = window.growiPlugin;
-    growiPlugin.installAll(this, this.originRenderer);
+    growiPlugin.installAll(this);
   }
 
   injectToWindow() {
     window.appContainer = this;
 
-    const originRenderer = this.getOriginRenderer();
-    window.growiRenderer = originRenderer;
+    const growiRenderer = new GrowiRenderer(this.getConfig());
+    growiRenderer.init();
+
+    window.growiRenderer = growiRenderer;
 
     // backward compatibility
     window.crowi = this;
-    window.crowiRenderer = originRenderer;
+    window.crowiRenderer = window.growiRenderer;
     window.crowiPlugin = window.growiPlugin;
   }
 
@@ -126,26 +126,4 @@ export default class AppContainer extends Container {
     return this.componentInstances[id];
   }
 
-  getOriginRenderer() {
-    return this.originRenderer;
-  }
-
-  /**
-   * factory method
-   */
-  getRenderer(mode) {
-    if (this.rendererInstances[mode] != null) {
-      return this.rendererInstances[mode];
-    }
-
-    const renderer = new GrowiRenderer(this, this.originRenderer);
-    // setup
-    renderer.initMarkdownItConfigurers(mode);
-    renderer.setup(mode);
-    // register
-    this.rendererInstances[mode] = renderer;
-
-    return renderer;
-  }
-
 }

+ 47 - 24
packages/app/src/client/services/ContextExtractor.tsx

@@ -3,7 +3,10 @@ import React, { FC, useEffect, useState } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 
+import { CustomWindow } from '~/interfaces/global';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import { generatePreviewRenderer } from '~/services/renderer/growi-renderer';
+import { useRendererSettings } from '~/stores/renderer';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -13,13 +16,14 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 
 import {
   useSiteUrl,
-  useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
+  useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
   useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUser, useTargetAndAncestors,
+  useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useGrowiVersion, useAuditLogEnabled,
+  useActivityExpirationSeconds, useAuditLogAvailableActions, useGrowiRendererConfig,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -60,17 +64,9 @@ const ContextExtractorOnce: FC = () => {
   const path = decodeURI(mainContent?.getAttribute('data-path') || '');
   // assign `null` to avoid returning empty string
   const pageId = mainContent?.getAttribute('data-page-id') || null;
-  const emptyPageId = notFoundContext?.getAttribute('data-page-id') || null;
 
   const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
 
-  // createdAt
-  const createdAtAttribute = mainContent?.getAttribute('data-page-created-at');
-  const createdAt: Date | null = (createdAtAttribute != null) ? new Date(createdAtAttribute) : null;
-  // updatedAt
-  const updatedAtAttribute = mainContent?.getAttribute('data-page-updated-at');
-  const updatedAt: Date | null = (updatedAtAttribute != null) ? new Date(updatedAtAttribute) : null;
-
   const deletedAt = mainContent?.getAttribute('data-page-deleted-at') || null;
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
@@ -88,11 +84,8 @@ const ContextExtractorOnce: FC = () => {
   const deleteUsername = mainContent?.getAttribute('data-page-delete-username') || null;
   const pageIdOnHackmd = mainContent?.getAttribute('data-page-id-on-hackmd') || null;
   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(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
@@ -123,10 +116,29 @@ const ContextExtractorOnce: FC = () => {
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
   useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
   useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
+  useAuditLogEnabled(configByContextHydrate.auditLogEnabled);
+  useActivityExpirationSeconds(configByContextHydrate.activityExpirationSeconds);
+  useAuditLogAvailableActions(configByContextHydrate.auditLogAvailableActions);
   useGrowiVersion(configByContextHydrate.crowi.version);
+  useRendererSettings({
+    isEnabledLinebreaks: configByContextHydrate.isEnabledLinebreaks,
+    isEnabledLinebreaksInComments: configByContextHydrate.isEnabledLinebreaksInComments,
+    adminPreferredIndentSize: configByContextHydrate.adminPreferredIndentSize,
+    isIndentSizeForced: configByContextHydrate.isIndentSizeForced,
+  });
+  useGrowiRendererConfig({
+    isEnabledXssPrevention: configByContextHydrate.isEnabledXssPrevention,
+    attrWhiteList: configByContextHydrate.attrWhiteList,
+    tagWhiteList: configByContextHydrate.tagWhiteList,
+    highlightJsStyleBorder: configByContextHydrate.highlightJsStyleBorder,
+    env: {
+      MATHJAX: configByContextHydrate.env.MATHJAX,
+      PLANTUML_URI: configByContextHydrate.env.PLANTUML_URI,
+      BLOCKDIAG_URI: configByContextHydrate.env.BLOCKDIAG_URI,
+    },
+  });
 
   // Page
-  useCurrentCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
   useDeletedAt(deletedAt);
   useHasChildren(hasChildren);
@@ -138,7 +150,6 @@ const ContextExtractorOnce: FC = () => {
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);
-  useEmptyPageId(emptyPageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -148,14 +159,8 @@ const ContextExtractorOnce: FC = () => {
   useShareLinkId(shareLinkId);
   useShareLinksNumber(shareLinksNumber);
   useTemplateTagData(templateTagData);
-  useCurrentUpdatedAt(updatedAt);
-  useCreator(creator);
-  useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
-  useNotFoundTargetPathOrId(notFoundTargetPathOrId);
-  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
-  useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
 
   // Navigation
@@ -181,6 +186,24 @@ const ContextExtractorOnce: FC = () => {
   const shouldInitAdminSock = !!currentUser?.isAdmin;
   useSetupGlobalAdminSocket(shouldInitAdminSock);
 
+  // TODO: Remove this code when reveal.js is omitted. see: https://github.com/weseek/growi/pull/6223
+  // Do not access this property from other than reveal.js plugins.
+  (window as CustomWindow).previewRenderer = generatePreviewRenderer(
+    {
+      isEnabledXssPrevention: configByContextHydrate.isEnabledXssPrevention,
+      attrWhiteList: configByContextHydrate.attrWhiteList,
+      tagWhiteList: configByContextHydrate.tagWhiteList,
+      highlightJsStyleBorder: configByContextHydrate.highlightJsStyleBorder,
+      env: {
+        MATHJAX: configByContextHydrate.env.MATHJAX,
+        PLANTUML_URI: configByContextHydrate.env.PLANTUML_URI,
+        BLOCKDIAG_URI: configByContextHydrate.env.BLOCKDIAG_URI,
+      },
+    },
+    null,
+    path,
+  );
+
   return null;
 };
 

+ 0 - 24
packages/app/src/client/services/EditorContainer.js

@@ -21,8 +21,6 @@ export default class EditorContainer extends Container {
       tags: null,
     };
 
-    this.isSetBeforeunloadEventHandler = false;
-
     this.initDrafts();
 
   }
@@ -59,28 +57,6 @@ export default class EditorContainer extends Container {
     }
   }
 
-
-  // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
-  showUnsavedWarning(e) {
-    // Cancel the event
-    e.preventDefault();
-    // display browser default message
-    e.returnValue = '';
-    return '';
-  }
-
-  disableUnsavedWarning() {
-    window.removeEventListener('beforeunload', this.showUnsavedWarning);
-    this.isSetBeforeunloadEventHandler = false;
-  }
-
-  enableUnsavedWarning() {
-    if (!this.isSetBeforeunloadEventHandler) {
-      window.addEventListener('beforeunload', this.showUnsavedWarning);
-      this.isSetBeforeunloadEventHandler = true;
-    }
-  }
-
   clearDraft(path) {
     delete this.drafts[path];
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));

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

@@ -13,10 +13,10 @@ import { apiv3Post } from '../util/apiv3-client';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
-} from '../util/interceptor/detach-code-blocks';
+} from '../../services/renderer/interceptor/detach-code-blocks';
 import {
   DrawioInterceptor,
-} from '../util/interceptor/drawio-interceptor';
+} from '../../services/renderer/interceptor/drawio-interceptor';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -54,9 +54,6 @@ export default class PageContainer extends Container {
       path,
       isEmpty: mainContent.getAttribute('data-page-is-empty'),
 
-      createdAt: mainContent.getAttribute('data-page-created-at'),
-      // please use useCurrentUpdatedAt instead
-      updatedAt: mainContent.getAttribute('data-page-updated-at'),
       deletedAt: mainContent.getAttribute('data-page-deleted-at') || null,
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,

+ 0 - 186
packages/app/src/client/services/PersonalContainer.js

@@ -1,186 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiPost } from '../util/apiv1-client';
-import { apiv3Get, apiv3Put } from '../util/apiv3-client';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:PersonalContainer');
-
-/**
- * Service container for personal settings page (PersonalSettings.jsx)
- * @extends {Container} unstated Container
- */
-export default class PersonalContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      retrieveError: null,
-      name: '',
-      email: '',
-      registrationWhiteList: this.appContainer.getConfig().registrationWhiteList,
-      isEmailPublished: false,
-      lang: 'en_US',
-      isGravatarEnabled: false,
-      externalAccounts: [],
-      apiToken: '',
-      slackMemberId: '',
-    };
-
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PersonalContainer';
-  }
-
-  /**
-   * retrieve personal data
-   */
-  async retrievePersonalData() {
-    try {
-      const response = await apiv3Get('/personal-setting/');
-      const { currentUser } = response.data;
-      this.setState({
-        name: currentUser.name,
-        email: currentUser.email,
-        isEmailPublished: currentUser.isEmailPublished,
-        lang: currentUser.lang,
-        isGravatarEnabled: currentUser.isGravatarEnabled,
-        apiToken: currentUser.apiToken,
-        slackMemberId: currentUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch personal data');
-    }
-  }
-
-  /**
-   * retrieve external accounts that linked me
-   */
-  async retrieveExternalAccounts() {
-    try {
-      const response = await apiv3Get('/personal-setting/external-accounts');
-      const { externalAccounts } = response.data;
-
-      this.setState({ externalAccounts });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch external accounts');
-    }
-  }
-
-  /**
-   * Change name
-   */
-  changeName(inputValue) {
-    this.setState({ name: inputValue });
-  }
-
-  /**
-   * Change email
-   */
-  changeEmail(inputValue) {
-    this.setState({ email: inputValue });
-  }
-
-  /**
-   * Change Slack Member ID
-   */
-  changeSlackMemberId(inputValue) {
-    this.setState({ slackMemberId: inputValue });
-  }
-
-  /**
-   * Change isEmailPublished
-   */
-  changeIsEmailPublished(boolean) {
-    this.setState({ isEmailPublished: boolean });
-  }
-
-  /**
-   * Change lang
-   */
-  changeLang(lang) {
-    this.setState({ lang });
-  }
-
-  /**
-   * Change isGravatarEnabled
-   */
-  changeIsGravatarEnabled(boolean) {
-    this.setState({ isGravatarEnabled: boolean });
-  }
-
-  /**
-   * Update basic info
-   * @memberOf PersonalContainer
-   * @return {Array} basic info
-   */
-  async updateBasicInfo() {
-    try {
-      const response = await apiv3Put('/personal-setting/', {
-        name: this.state.name,
-        email: this.state.email,
-        isEmailPublished: this.state.isEmailPublished,
-        lang: this.state.lang,
-        slackMemberId: this.state.slackMemberId,
-      });
-      const { updatedUser } = response.data;
-
-      this.setState({
-        name: updatedUser.name,
-        email: updatedUser.email,
-        isEmailPublished: updatedUser.isEmailPublished,
-        lang: updatedUser.lang,
-        slackMemberId: updatedUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to update personal data');
-    }
-  }
-
-  /**
-   * Associate LDAP account
-   */
-  async associateLdapAccount(account) {
-    try {
-      await apiv3Put('/personal-setting/associate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to associate ldap account');
-    }
-  }
-
-  /**
-   * Disassociate LDAP account
-   */
-  async disassociateLdapAccount(account) {
-    try {
-      await apiv3Put('/personal-setting/disassociate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to disassociate ldap account');
-    }
-  }
-
-}

+ 0 - 208
packages/app/src/client/util/GrowiRenderer.js

@@ -1,208 +0,0 @@
-import MarkdownIt from 'markdown-it';
-
-import loggerFactory from '~/utils/logger';
-
-import CsvToTable from './PreProcessor/CsvToTable';
-import EasyGrid from './PreProcessor/EasyGrid';
-import Linker from './PreProcessor/Linker';
-import XssFilter from './PreProcessor/XssFilter';
-import BlockdiagConfigurer from './markdown-it/blockdiag';
-import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
-import EmojiConfigurer from './markdown-it/emoji';
-import FooternoteConfigurer from './markdown-it/footernote';
-import HeaderConfigurer from './markdown-it/header';
-import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
-import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
-import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
-import MathJaxConfigurer from './markdown-it/mathjax';
-import PlantUMLConfigurer from './markdown-it/plantuml';
-import TableConfigurer from './markdown-it/table';
-import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
-import TaskListsConfigurer from './markdown-it/task-lists';
-import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
-
-const logger = loggerFactory('growi:util:GrowiRenderer');
-
-export default class GrowiRenderer {
-
-  /**
-   *
-   * @param {AppContainer} appContainer
-   * @param {GrowiRenderer} originRenderer
-   * @param {string} mode
-   */
-  constructor(appContainer, originRenderer) {
-    this.appContainer = appContainer;
-
-    if (originRenderer != null) {
-      this.preProcessors = originRenderer.preProcessors;
-      this.postProcessors = originRenderer.postProcessors;
-    }
-    else {
-      this.preProcessors = [
-        new EasyGrid(),
-        new Linker(),
-        new CsvToTable(),
-        new XssFilter(appContainer),
-      ];
-      this.postProcessors = [
-      ];
-    }
-
-    this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
-    this.setup = this.setup.bind(this);
-    this.process = this.process.bind(this);
-    this.codeRenderer = this.codeRenderer.bind(this);
-  }
-
-  initMarkdownItConfigurers(mode) {
-    const appContainer = this.appContainer;
-
-    // init markdown-it
-    this.md = new MarkdownIt({
-      html: true,
-      linkify: true,
-      highlight: this.codeRenderer,
-    });
-
-    this.isMarkdownItConfigured = false;
-
-    this.markdownItConfigurers = [
-      new LinkerByRelativePathConfigurer(appContainer),
-      new TaskListsConfigurer(appContainer),
-      new HeaderConfigurer(),
-      new EmojiConfigurer(),
-      new MathJaxConfigurer(appContainer),
-      new DrawioViewerConfigurer(),
-      new PlantUMLConfigurer(appContainer),
-      new BlockdiagConfigurer(appContainer),
-    ];
-
-    // add configurers according to mode
-    switch (mode) {
-      case 'page': {
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(),
-          new TocAndAnchorConfigurer(),
-          new HeaderLineNumberConfigurer(),
-          new HeaderWithEditLinkConfigurer(),
-          new TableWithHandsontableButtonConfigurer(),
-        ]);
-        break;
-      }
-      case 'editor':
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(),
-          new HeaderLineNumberConfigurer(),
-          new TableConfigurer(),
-        ]);
-        break;
-      // case 'comment':
-      //   break;
-      default:
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(),
-        ]);
-        break;
-    }
-  }
-
-  /**
-   * setup with crowi config
-   */
-  setup(mode) {
-    const crowiConfig = this.appContainer.config;
-
-    let isEnabledLinebreaks;
-    switch (mode) {
-      case 'comment':
-        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
-        break;
-      default:
-        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaks;
-        break;
-    }
-
-    this.md.set({
-      breaks: isEnabledLinebreaks,
-    });
-
-    if (!this.isMarkdownItConfigured) {
-      this.markdownItConfigurers.forEach((configurer) => {
-        configurer.configure(this.md);
-      });
-    }
-  }
-
-  preProcess(markdown, context) {
-    let processed = markdown;
-    for (let i = 0; i < this.preProcessors.length; i++) {
-      if (!this.preProcessors[i].process) {
-        continue;
-      }
-      processed = this.preProcessors[i].process(processed, context);
-    }
-
-    return processed;
-  }
-
-  process(markdown, context) {
-    return this.md.render(markdown, context);
-  }
-
-  postProcess(html, context) {
-    let processed = html;
-    for (let i = 0; i < this.postProcessors.length; i++) {
-      if (!this.postProcessors[i].process) {
-        continue;
-      }
-      processed = this.postProcessors[i].process(processed, context);
-    }
-
-    return processed;
-  }
-
-  codeRenderer(code, langExt) {
-    const config = this.appContainer.getConfig();
-    const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
-
-    let citeTag = '';
-    let hljsLang = 'plaintext';
-    let showLinenumbers = false;
-
-    if (langExt) {
-      // https://regex101.com/r/qGs7eZ/3
-      const match = langExt.match(/^([^:=\n]+)?(=([^:=\n]*))?(:([^:=\n]*))?(=([^:=\n]*))?$/);
-
-      const lang = match[1];
-      const fileName = match[5] || null;
-      showLinenumbers = (match[2] != null) || (match[6] != null);
-
-      if (fileName != null) {
-        citeTag = `<cite>${fileName}</cite>`;
-      }
-      if (hljs.getLanguage(lang)) {
-        hljsLang = lang;
-      }
-    }
-
-    let highlightCode = code;
-    try {
-      highlightCode = hljs.highlight(hljsLang, code, true).value;
-
-      // add line numbers
-      if (showLinenumbers) {
-        highlightCode = hljs.lineNumbersValue((highlightCode));
-      }
-    }
-    catch (err) {
-      logger.error(err);
-    }
-
-    return `<pre class="hljs ${noborder}">${citeTag}<code>${highlightCode}</code></pre>`;
-  }
-
-  highlightCode(code, lang) {
-  }
-
-}

+ 0 - 23
packages/app/src/client/util/PreProcessor/XssFilter.js

@@ -1,23 +0,0 @@
-import Xss from '~/services/xss';
-import XssOption from '~/services/xss/xssOption';
-
-export default class XssFilter {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    if (crowi.config.isEnabledXssPrevention) {
-      this.xssOption = new XssOption(crowi.config);
-      this.xss = new Xss(this.xssOption);
-    }
-  }
-
-  process(markdown) {
-    if (this.crowi.config.isEnabledXssPrevention) {
-      return this.xss.process(markdown);
-    }
-
-    return markdown;
-  }
-
-}

+ 4 - 7
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -2,19 +2,16 @@
  * reveal.js growi-renderer plugin.
  */
 (function(root, factory) {
-  // get AppContainer instance from parent window
-  const appContainer = window.parent.appContainer;
-
-  const growiRendererPlugin = factory(appContainer);
+  const growiRendererPlugin = factory();
   growiRendererPlugin.initialize();
-}(this, (appContainer) => {
+}(this, () => {
   /* eslint-disable no-useless-escape */
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   /* eslint-enable no-useless-escape */
 
-  const growiRenderer = appContainer.getRenderer('editor');
+  const growiRenderer = window.parent.previewRenderer;
 
   let marked;
 
@@ -61,7 +58,7 @@
         section.setAttribute('data-markdown-parsed', 'true');
         const notes = section.querySelector('aside.notes');
         markdown = marked.getMarkdownFromSlide(section);
-        const context = { markdown };
+        const context = { markdown, currentPathname: decodeURIComponent(window.parent.location.pathname) };
 
         interceptorManager.process('preRender', context)
           .then(() => { return interceptorManager.process('prePreProcess', context) })

+ 47 - 0
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -0,0 +1,47 @@
+import React, { FC } from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { IActivityHasId } from '~/interfaces/activity';
+
+type Props = {
+  activityList: IActivityHasId[]
+}
+
+const formatDate = (date) => {
+  return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
+};
+
+export const ActivityTable : FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th scope="col">{t('admin:audit_log_management.username')}</th>
+            <th scope="col">{t('admin:audit_log_management.date')}</th>
+            <th scope="col">{t('admin:audit_log_management.action')}</th>
+            <th scope="col">{t('admin:audit_log_management.ip')}</th>
+            <th scope="col">{t('admin:audit_log_management.url')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.activityList.map((activity) => {
+            return (
+              <tr data-testid="activity-table" key={activity._id}>
+                <td>{activity.snapshot?.username}</td>
+                <td>{formatDate(activity.createdAt)}</td>
+                <td>{activity.action}</td>
+                <td>{activity.ip}</td>
+                <td>{activity.endpoint}</td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+};

+ 26 - 0
packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -0,0 +1,26 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+export const AuditLogDisableMode: FC = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="content-main" className="content-main container-lg">
+      <div className="container">
+        <div className="row justify-content-md-center">
+          <div className="col-md-6 mt-5">
+            <div className="text-center">
+              <h1><i className="icon-exclamation large"></i></h1>
+              <h1 className="text-center">{t('AuditLog')}</h1>
+              <h3
+                // eslint-disable-next-line react/no-danger
+                dangerouslySetInnerHTML={{ __html: t('admin:audit_log_management.disable_mode_explain') }}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 54 - 0
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -0,0 +1,54 @@
+import React, { FC, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Collapse } from 'reactstrap';
+
+import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
+
+export const AuditLogSettings: FC = () => {
+  const { t } = useTranslation();
+
+  const [isExpandActionList, setIsExpandActionList] = useState(false);
+
+  const { data: activityExpirationSecondsData } = useActivityExpirationSeconds();
+  const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000;
+
+  const { data: availableActionsData } = useAuditLogAvailableActions();
+  const availableActions = availableActionsData != null ? availableActionsData : [];
+
+  return (
+    <>
+      <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.activity_expiration_date_explain')}
+      </p>
+      <p className="alert alert-warning col-6">
+        <i className="icon-exclamation icon-fw">
+        </i><b>FIXED</b><br />
+        <b
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('admin:audit_log_management.fixed_by_env_var',
+              { key: 'ACTIVITY_EXPIRATION_SECONDS', value: activityExpirationSeconds }),
+          }}
+        />
+      </p>
+
+      <h4 className="mt-4">{t('admin:audit_log_management.available_action_list')}</h4>
+      <p className="form-text text-muted">{t('admin:audit_log_management.available_action_list_explain')}</p>
+      <p className="mt-1">
+        <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
+          <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>
+          { t('admin:audit_log_management.action_list') }
+        </button>
+      </p>
+      <Collapse isOpen={isExpandActionList}>
+        <ul className="list-group">
+          { availableActions.map(action => (
+            <li key={action} className="list-group-item">{ action }</li>
+          )) }
+        </ul>
+      </Collapse>
+    </>
+  );
+};

+ 67 - 0
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -0,0 +1,67 @@
+import React, {
+  FC, useRef, forwardRef, useCallback,
+} from 'react';
+
+import DatePicker from 'react-datepicker';
+import 'react-datepicker/dist/react-datepicker.css';
+
+import { useTranslation } from 'react-i18next';
+
+
+type CustomInputProps = {
+  buttonRef: React.Ref<HTMLButtonElement>
+  onClick?: () => void;
+}
+
+const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
+  const { t } = useTranslation();
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary dropdown-toggle"
+      ref={props.buttonRef}
+      onClick={props.onClick}
+    >
+      <i className="fa fa-fw fa-calendar" /> {t('admin:audit_log_management.date')}
+    </button>
+  );
+});
+
+CustomInput.displayName = 'CustomInput';
+
+type DateRangePickerProps = {
+  startDate: Date | null
+  endDate: Date | null
+  onChange: (dateList: Date[] | null[]) => void
+}
+
+export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
+  const { startDate, endDate, onChange } = props;
+
+  const buttonRef = useRef(null);
+
+  const changeHandler = useCallback((dateList: Date[] | null[]) => {
+    if (onChange != null) {
+      const [start, end] = dateList;
+      const isSameTime = (start != null && end != null) && (start.getTime() === end.getTime());
+      if (isSameTime) {
+        onChange([null, null]);
+      }
+      else {
+        onChange(dateList);
+      }
+    }
+  }, [onChange]);
+
+  return (
+    <div className="btn-group mr-2">
+      <DatePicker
+        selectsRange
+        startDate={startDate}
+        endDate={endDate}
+        onChange={changeHandler}
+        customInput={<CustomInput buttonRef={buttonRef} />}
+      />
+    </div>
+  );
+};

+ 122 - 0
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -0,0 +1,122 @@
+import React, {
+  FC, Fragment, useState, useCallback,
+} from 'react';
+
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxUsernames } from '~/stores/user';
+
+
+const Categories = {
+  activeUser: 'Active User',
+  inactiveUser: 'Inactive User',
+  activitySnapshotUser: 'Activity Snapshot User',
+} as const;
+
+type CategoryType = typeof Categories[keyof typeof Categories]
+
+type UserDataType = {
+  username: string
+  category: CategoryType
+}
+
+type Props = {
+  onChange: (text: string[]) => void
+}
+
+export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
+  const { onChange } = props;
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [searchKeyword, setSearchKeyword] = useState<string>('');
+
+  /*
+   * Fetch
+   */
+  const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
+  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
+  const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
+  const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
+  const isLoading = usernameData === undefined && error == null;
+
+  const allUser: UserDataType[] = [];
+  const pushToAllUser = (usernames: string[], category: CategoryType) => {
+    usernames.forEach(username => allUser.push({ username, category }));
+  };
+  pushToAllUser(activeUsernames, Categories.activeUser);
+  pushToAllUser(inactiveUsernames, Categories.inactiveUser);
+  pushToAllUser(activitySnapshotUsernames, Categories.activitySnapshotUser);
+
+  /*
+   * Functions
+   */
+  const changeHandler = useCallback((userData: UserDataType[]) => {
+    if (onChange != null) {
+      const usernames = userData.map(user => user.username);
+      onChange(usernames);
+    }
+  }, [onChange]);
+
+  const searchHandler = useCallback((text: string) => {
+    setSearchKeyword(text);
+  }, []);
+
+  const renderMenu = useCallback((allUser: UserDataType[], menuProps) => {
+    if (allUser == null || allUser.length === 0) {
+      return <></>;
+    }
+
+    let index = 0;
+    const items = Object.values(Categories).map((category) => {
+      const userData = allUser.filter(user => user.category === category);
+      return (
+        <Fragment key={category}>
+          {index !== 0 && <Menu.Divider />}
+          <Menu.Header>{category}</Menu.Header>
+          {userData.map((user) => {
+            const item = (
+              <MenuItem key={index} option={user} position={index}>
+                {user.username}
+              </MenuItem>
+            );
+            index++;
+            return item;
+          })}
+        </Fragment>
+      );
+    });
+
+    return (
+      <Menu {...menuProps}>{items}</Menu>
+    );
+  }, []);
+
+  return (
+    <div className="input-group mr-2">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="icon-people" />
+        </span>
+      </div>
+      <AsyncTypeahead
+        id="search-username-typeahead-asynctypeahead"
+        multiple
+        delay={400}
+        minLength={0}
+        placeholder={t('admin:audit_log_management.username')}
+        caseSensitive={false}
+        isLoading={isLoading}
+        options={allUser}
+        onSearch={searchHandler}
+        onChange={changeHandler}
+        renderMenu={renderMenu}
+        labelKey={(option: UserDataType) => `${option.username}`}
+      />
+    </div>
+  );
+};

+ 123 - 0
packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -0,0 +1,123 @@
+import React, { FC, useMemo, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import {
+  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
+} from '~/interfaces/activity';
+
+type Props = {
+  actionMap: Map<SupportedActionType, boolean>
+  availableActions: SupportedActionType[]
+  onChangeAction: (action: SupportedActionType) => void
+  onChangeMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
+}
+
+export const SelectActionDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const {
+    actionMap, availableActions, onChangeAction, onChangeMultipleAction,
+  } = props;
+
+  const dropdownItems = useMemo<Array<{actionCategory: SupportedActionCategoryType, actions: SupportedActionType[]}>>(() => {
+    return (
+      [
+        {
+          actionCategory: SupportedActionCategory.PAGE,
+          actions: PageActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.COMMENT,
+          actions: CommentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.TAG,
+          actions: TagActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ATTACHMENT,
+          actions: AttachmentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SHARE_LINK,
+          actions: ShareLinkActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
+          actions: InAppNotificationActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SEARCH,
+          actions: SearchActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.USER,
+          actions: UserActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ADMIN,
+          actions: AdminActions.filter(action => availableActions.includes(action)),
+        },
+      ]
+    );
+  }, [availableActions]).filter(item => item.actions.length !== 0);
+
+  const actionCheckboxChangedHandler = useCallback((action) => {
+    if (onChangeAction != null) {
+      onChangeAction(action);
+    }
+  }, [onChangeAction]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
+    if (onChangeMultipleAction != null) {
+      onChangeMultipleAction(actions, isChecked);
+    }
+  }, [onChangeMultipleAction]);
+
+  return (
+    <div className="btn-group mr-2 admin-audit-log">
+      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+        <i className="fa fa-fw fa-bolt" />{t('admin:audit_log_management.action')}
+      </button>
+      <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
+        {dropdownItems.map(item => (
+          <div key={item.actionCategory}>
+            <div className="dropdown-item">
+              <div className="form-group px-2 m-0">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  defaultChecked
+                  onChange={(e) => { multipleActionCheckboxChangedHandler(item.actions, e.target.checked) }}
+                />
+                <label className="form-check-label">{item.actionCategory}</label>
+              </div>
+            </div>
+            {
+              item.actions.map(action => (
+                <div className="dropdown-item" key={action}>
+                  <div className="form-group px-4 m-0">
+                    <input
+                      type="checkbox"
+                      className="form-check-input"
+                      id={`checkbox${action}`}
+                      onChange={() => { actionCheckboxChangedHandler(action) }}
+                      checked={actionMap.get(action)}
+                    />
+                    <label
+                      className="form-check-label"
+                      htmlFor={`checkbox${action}`}
+                    >
+                      {action}
+                    </label>
+                  </div>
+                </div>
+              ))
+            }
+          </div>
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 181 - 0
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -0,0 +1,181 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { SupportedActionType } from '~/interfaces/activity';
+import { useSWRxActivity } from '~/stores/activity';
+import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityTable } from './AuditLog/ActivityTable';
+import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
+import { AuditLogSettings } from './AuditLog/AuditLogSettings';
+import { DateRangePicker } from './AuditLog/DateRangePicker';
+import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
+import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
+
+
+const formatDate = (date: Date | null) => {
+  if (date == null) {
+    return '';
+  }
+  return format(new Date(date), 'yyyy-MM-dd');
+};
+
+const PAGING_LIMIT = 10;
+
+export const AuditLogManagement: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
+  const auditLogAvailableActions = auditLogAvailableActionsData != null ? auditLogAvailableActionsData : [];
+
+  /*
+   * State
+   */
+  const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
+  const [activePage, setActivePage] = useState<number>(1);
+  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [startDate, setStartDate] = useState<Date | null>(null);
+  const [endDate, setEndDate] = useState<Date | null>(null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
+  const [actionMap, setActionMap] = useState(
+    new Map<SupportedActionType, boolean>(auditLogAvailableActions.map(action => [action, true])),
+  );
+
+  /*
+   * Fetch
+   */
+  const selectedDate = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
+  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
+  const searchFilter = { actions: selectedActionList, dates: selectedDate, usernames: selectedUsernames };
+
+  const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
+  const activityList = activityData?.docs != null ? activityData.docs : [];
+  const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const isLoading = activityData === undefined && error == null;
+
+  if (error != null) {
+    toastError('Failed to get Audit Log');
+  }
+
+  const { data: auditLogEnabled } = useAuditLogEnabled();
+
+  /*
+   * Functions
+   */
+  const setActivePageHandler = useCallback((selectedPageNum: number) => {
+    setActivePage(selectedPageNum);
+  }, []);
+
+  const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
+    setActivePage(1);
+    setStartDate(dateList[0]);
+    setEndDate(dateList[1]);
+  }, []);
+
+  const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
+    setActivePage(1);
+    actionMap.set(action, !actionMap.get(action));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
+    setActivePage(1);
+    actions.forEach(action => actionMap.set(action, isChecked));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setActivePage(1);
+    setSelectedUsernames(usernames);
+  }, []);
+
+  const reloadButtonPushedHandler = useCallback(() => {
+    setActivePage(1);
+    mutateActivity();
+  }, [mutateActivity]);
+
+  // eslint-disable-next-line max-len
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+
+  if (!auditLogEnabled) {
+    return <AuditLogDisableMode />;
+  }
+
+  return (
+    <div data-testid="admin-auditlog">
+      <button type="button" className="btn btn-outline-secondary mb-4" onClick={() => setIsSettingPage(!isSettingPage)}>
+        {
+          isSettingPage
+            ? <><i className="fa fa-hand-o-left mr-1" />{t('admin:audit_log_management.return')}</>
+            : <><i className="fa icon-settings mr-1" />{t('admin:audit_log_management.settings')}</>
+        }
+      </button>
+
+      <h2 className="admin-setting-header mb-3">
+        <span>
+          {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
+        </span>
+      </h2>
+
+      {isSettingPage ? (
+        <AuditLogSettings />
+      ) : (
+        <>
+          <div className="form-inline mb-3">
+            <SearchUsernameTypeahead
+              onChange={setUsernamesHandler}
+            />
+
+            <DateRangePicker
+              startDate={startDate}
+              endDate={endDate}
+              onChange={datePickerChangedHandler}
+            />
+
+            <SelectActionDropdown
+              actionMap={actionMap}
+              availableActions={auditLogAvailableActions}
+              onChangeAction={actionCheckboxChangedHandler}
+              onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+            />
+
+            <button type="button" className="btn ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+              <i className="icon icon-reload" />
+            </button>
+          </div>
+
+          <p
+            className="ml-2"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: activityCounter }}
+          />
+
+          { isLoading
+            ? (
+              <div className="text-muted text-center mb-5">
+                <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+              </div>
+            )
+            : (
+              <ActivityTable activityList={activityList} />
+            )
+          }
+
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setActivePageHandler}
+            totalItemsCount={totalActivityNum}
+            pagingLimit={PAGING_LIMIT}
+            align="center"
+            size="sm"
+          />
+        </>
+      )}
+    </div>
+  );
+};

+ 16 - 11
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -5,22 +5,22 @@
 import React from 'react';
 
 import { pathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import urljoin from 'url-join';
 
 
-import AppContainer from '~/client/services/AppContainer';
+// import AppContainer from '~/client/services/AppContainer';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+// import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const AdminNavigation = (props) => {
   const { t } = useTranslation();
-  const { appContainer } = props;
+  // const { appContainer } = props;
   const pathname = window.location.pathname;
 
-  const growiCloudUri = appContainer.config.env.GROWI_CLOUD_URI;
-  const growiAppIdForGrowiCloud = appContainer.config.env.GROWI_APP_ID_FOR_GROWI_CLOUD;
+  // const growiCloudUri = appContainer.config.env.GROWI_CLOUD_URI;
+  // const growiAppIdForGrowiCloud = appContainer.config.env.GROWI_APP_ID_FOR_GROWI_CLOUD;
 
   // eslint-disable-next-line react/prop-types
   const MenuLabel = ({ menu }) => {
@@ -37,6 +37,8 @@ const AdminNavigation = (props) => {
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
+      // TODO: Consider where to place the "AuditLog"
+      case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('AuditLog')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
     }
@@ -86,7 +88,8 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
-        {growiCloudUri != null && growiAppIdForGrowiCloud != null
+        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
+        {/* {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
             <a
               href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
@@ -95,7 +98,7 @@ const AdminNavigation = (props) => {
               <MenuLabel menu="cloud" />
             </a>
           )
-        }
+        } */}
       </>
     );
   };
@@ -131,6 +134,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
           </span>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
@@ -142,10 +146,11 @@ const AdminNavigation = (props) => {
   );
 };
 
-const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
+// const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
 
 AdminNavigation.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default AdminNavigationWrapper;
+// export default AdminNavigationWrapper;
+export default AdminNavigation;

+ 2 - 0
packages/app/src/components/Admin/Security/LdapAuthTest.jsx

@@ -97,6 +97,7 @@ class LdapAuthTest extends React.Component {
               name="username"
               value={this.props.username}
               onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
+              autoComplete="off"
             />
           </div>
         </div>
@@ -109,6 +110,7 @@ class LdapAuthTest extends React.Component {
               name="password"
               value={this.props.password}
               onChange={(e) => { this.props.onChangePassword(e.target.value) }}
+              autoComplete="off"
             />
           </div>
         </div>

+ 2 - 4
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -9,7 +9,7 @@ import { Tooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -234,7 +234,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 }, [AppContainer]);
 
 const TestProcess = ({
-  apiv3Post, slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
+  slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
 }) => {
 
   const { t } = useTranslation();
@@ -353,7 +353,6 @@ const WithProxyAccordions = (props) => {
     '④': {
       title: 'test_connection',
       content: <TestProcess
-        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}
@@ -397,7 +396,6 @@ const WithProxyAccordions = (props) => {
     '⑥': {
       title: 'test_connection',
       content: <TestProcess
-        apiv3Post={props.apiv3Post}
         slackAppIntegrationId={props.slackAppIntegrationId}
         onSubmitForm={submitForm}
         onSubmitFormFailed={submitFormFailed}

+ 3 - 4
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,6 +1,7 @@
 import React, {
   FC, useCallback, useState, useMemo,
 } from 'react';
+
 import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 import {
@@ -8,9 +9,7 @@ import {
 } from 'reactstrap';
 
 import { IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
-
+import { useXss } from '~/stores/xss';
 /**
  * Delete User Group Select component
  *
@@ -42,7 +41,7 @@ const actionForPages = {
 };
 
 const UserGroupDeleteModal: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
+  const { data: xss } = useXss();
 
   const { t } = useTranslation();
 

+ 2 - 4
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,11 +1,10 @@
 import React, { FC, useCallback, useState } from 'react';
-import { useTranslation } from 'next-i18next';
+
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'next-i18next';
 
 import { IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 type Props = {
   userGroup: IUserGroupHasId,
@@ -15,7 +14,6 @@ type Props = {
 };
 
 const UserGroupForm: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
   const { t } = useTranslation();
 

+ 3 - 5
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,16 +1,15 @@
 import React, {
   FC, useState, useEffect, useCallback,
 } from 'react';
+
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { useTranslation } from 'next-i18next';
-import { TFunctionResult } from 'i18next';
 
 import { Ref } from '~/interfaces/common';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
-import { CustomWindow } from '~/interfaces/global';
-import Xss from '~/services/xss';
 
 type Props = {
   userGroup?: IUserGroupHasId,
@@ -21,7 +20,6 @@ type Props = {
 };
 
 const UserGroupModal: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
 
   const { t } = useTranslation();
 

+ 7 - 8
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,20 +1,19 @@
 import React, { FC, useState, useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
 
-import UserGroupTable from './UserGroupTable';
-import UserGroupModal from './UserGroupModal';
-import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 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, apiv3Put } from '~/client/util/apiv3-client';
-import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
+import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+import UserGroupModal from './UserGroupModal';
+import UserGroupTable from './UserGroupTable';
 
 const UserGroupPage: FC = () => {
-  const xss: Xss = (window as CustomWindow).xss;
   const { t } = useTranslation();
 
   const { data: isAclEnabled } = useIsAclEnabled();

+ 12 - 11
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,13 +2,14 @@ import React, {
   FC, useState, useCallback, useEffect,
 } from 'react';
 
-import { useTranslation } from 'next-i18next';
-import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
+import { TFunctionResult } from 'i18next';
+import { useTranslation } from 'next-i18next';
 
-import Xss from '~/services/xss';
-import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
+import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
+import Xss from '~/services/xss';
+import { useXss } from '~/stores/xss';
 
 type Props = {
   headerLabel?: TFunctionResult,
@@ -56,7 +57,7 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 
 
 const UserGroupTable: FC<Props> = (props: Props) => {
-  const xss: Xss = (window as CustomWindow).xss;
+  const { data: xss } = useXss();
   const { t } = useTranslation();
 
   /*
@@ -151,17 +152,17 @@ const UserGroupTable: FC<Props> = (props: Props) => {
               <tr key={group._id}>
                 {props.isAclEnabled
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{xss.process(group.name)}</a></td>
+                    <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.name)}</td>
                   )
                 }
-                <td>{xss.process(group.description)}</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>;
+                      return <li key={user._id} className="list-inline-item badge badge-pill badge-warning">{xss?.process(user.username)}</li>;
                     })}
                   </ul>
                 </td>
@@ -172,10 +173,10 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                         <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>
+                              <a href={`/admin/user-group-detail/${group._id}`}>{xss?.process(group.name)}</a>
                             )
                             : (
-                              <p>{xss.process(group.name)}</p>
+                              <p>{xss?.process(group.name)}</p>
                             )
                           }
                         </li>

+ 55 - 0
packages/app/src/components/AdminLayout.tsx

@@ -0,0 +1,55 @@
+import React, { ReactNode } from 'react';
+
+import dynamic from 'next/dynamic';
+import { Provider } from 'unstated';
+
+import { GrowiNavbar } from './Navbar/GrowiNavbar';
+import { RawLayout } from './RawLayout';
+
+// import { injectableContainers } from '~/client/admin';
+
+type Props = {
+  title: string
+  /**
+   * Set the current option of AdminNavigation
+   * Expected it is in ["home", "app", "security", "markdown", "customize", "importer", "export",
+   * "notification", 'global-notification', "users", "user-groups", "search"]
+   */
+  selectedNavOpt: string
+  children?: ReactNode
+}
+
+
+const AdminLayout = ({
+  children, title, selectedNavOpt,
+}: Props): JSX.Element => {
+
+  const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
+
+  return (
+    <RawLayout title={title}>
+      <GrowiNavbar />
+
+      <header className="py-0">
+        <h1 className="title">{title}</h1>
+      </header>
+      <div id="main" className="main">
+        <div className="container-fluid">
+          <div className="row">
+            <div className="col-lg-3">
+              <AdminNavigation selected={selectedNavOpt} />
+            </div>
+            <div className="col-lg-9">
+              {/* TODO: inject Admincontainer (injectableContainers & adminSecurityContainers) by https://redmine.weseek.co.jp/issues/100072 */}
+              <Provider>
+                {children}
+              </Provider>
+            </div>
+          </div>
+        </div>
+      </div>
+    </RawLayout>
+  );
+};
+
+export default AdminLayout;

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

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 
 import { GrowiNavbar } from './Navbar/GrowiNavbar';
 import { RawLayout } from './RawLayout';
+import Sidebar from './Sidebar';
 
 
 type Props = {
@@ -14,7 +15,6 @@ type Props = {
 
 export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
 
-  const Sidebar = dynamic(() => import('./Sidebar'), { ssr: false });
   // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
   // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
   const GrowiNavbarBottom = dynamic(() => import('./Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });

+ 7 - 2
packages/app/src/components/BookmarkButtons.tsx

@@ -57,7 +57,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         type="button"
         id="bookmark-button"
         onClick={handleClick}
-        className={`btn ${styles['btn-bookmark']} border-0
+        className={`shadow-none btn ${styles['btn-bookmark']} border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
@@ -69,7 +69,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
 
       { !hideTotalNumber && (
         <>
-          <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-bookmarks"
+            className={`shadow-none btn btn-bookmark border-0
+              total-bookmarks ${props.isBookmarked ? 'active' : ''}`}
+          >
             {bookmarkCount ?? 0}
           </button>
           { bookmarkedUsers != null && (

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

@@ -1,6 +1,7 @@
 import React, { useState, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+
 import { toastSuccess, toastError } from '../client/util/apiNotification';
 
 interface Props {
@@ -28,7 +29,7 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   useEffect(() => {
     const delayDebounceFn = setTimeout(async() => {
       try {
-        const { data } = await apiv3Get('/check_username', { username });
+        const { data } = await apiv3Get('/check-username', { username });
         if (data.ok) {
           setUsernameAvailable(data.valid);
         }

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

@@ -1,9 +1,10 @@
 import React, { useCallback, useMemo } from 'react';
 
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { usePageUser } from '~/stores/context';
 
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+
 const WIKI_HEADER_LINK = 120;
 
 

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

@@ -12,9 +12,9 @@ import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
 import {
-  useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
-} from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+  usePageTreeTermManager, useDescendantsPageListForCurrentPathTermManager, useSWRxDescendantsPageListForCurrrentPath,
+  useSWRxPageInfoForList, useSWRxPageList,
+} from '~/stores/page-listing';
 
 import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
@@ -45,7 +45,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   const { data: isGuestUser } = useIsGuestUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
-  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
 

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

@@ -77,6 +77,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
       size="xl"
       isOpen={isOpened}
       toggle={close}
+      data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>

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

@@ -9,7 +9,7 @@ import {
   IPageInfo,
 } from '~/interfaces/page';
 import { useEmptyTrashModal } from '~/stores/modal';
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
 
 
 const EmptyTrashButton = () => {
@@ -18,7 +18,7 @@ const EmptyTrashButton = () => {
   const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
-  const { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfo>[] = [];
 

+ 9 - 20
packages/app/src/components/IdenticalPathPage.tsx

@@ -1,12 +1,11 @@
 import React, { FC } from 'react';
-import { useTranslation } from 'next-i18next';
 
 import { DevidedPagePath } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 
-import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxPageInfoForList } from '~/stores/page';
+import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 
 import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
@@ -47,29 +46,21 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
 };
 
 
-type IdenticalPathPageProps= {
-  // add props and types here
-}
-
-
-const jsonNull = 'null';
-
-const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
+export const IdenticalPathPage = (): JSX.Element => {
   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 { injectTo } = useSWRxPageInfoForList(pageIds, true, true);
+  const { data: pages } = useSWRxPagesByPath(currentPath);
+  const { injectTo } = useSWRxPageInfoForList(null, currentPath, true, true);
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
+  if (pages == null) {
+    return <></>;
+  }
+
   const injectedPages = injectTo(pages);
 
   return (
@@ -118,5 +109,3 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
     </div>
   );
 };
-
-export default IdenticalPathPage;

+ 1 - 1
packages/app/src/components/InstallerForm.jsx

@@ -38,7 +38,7 @@ class InstallerForm extends React.Component {
   //     },
   //     responseType: 'json',
   //   });
-  //   axios.get('/_api/check_username', { params: { username: event.target.value } })
+  //   axios.get('/_api/v3/check-username', { params: { username: event.target.value } })
   //     .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
   // }
 

+ 7 - 2
packages/app/src/components/LikeButtons.tsx

@@ -50,7 +50,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         type="button"
         id="like-button"
         onClick={onLikeClicked}
-        className={`btn ${styles['btn-like']} border-0
+        className={`shadow-none btn ${styles['btn-like']} border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
@@ -62,7 +62,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
 
       { !hideTotalNumber && (
         <>
-          <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-likes"
+            className={`shadow-none btn btn-like border-0
+              total-likes ${isLiked ? 'active' : ''}`}
+          >
             {sumOfLikers}
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">

+ 0 - 116
packages/app/src/components/Me/ApiSettings.jsx

@@ -1,116 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Put } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class ApiSettings extends React.Component {
-
-  constructor(appContainer) {
-    super();
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, appContainer, personalContainer } = this.props;
-
-    try {
-      await apiv3Put('/personal-setting/api-token');
-
-      await personalContainer.retrievePersonalData();
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    return (
-      <React.Fragment>
-
-        <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
-
-        <div className="row mb-3">
-          <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>
-          <div className="col-md-6">
-            {personalContainer.state.apiToken != null
-              ? (
-                <input
-                  data-testid="grw-api-settings-input"
-                  data-hide-in-vrt
-                  className="form-control"
-                  type="text"
-                  name="apiToken"
-                  value={personalContainer.state.apiToken}
-                  readOnly
-                />
-              )
-              : (
-                <p>
-                  { t('page_me_apitoken.notice.apitoken_issued') }
-                </p>
-              )}
-          </div>
-        </div>
-
-
-        <div className="row">
-          <div className="offset-lg-2 col-lg-7">
-
-            <p className="alert alert-warning">
-              { t('page_me_apitoken.notice.update_token1') }<br />
-              { t('page_me_apitoken.notice.update_token2') }
-            </p>
-
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button
-              data-testid="grw-api-settings-update-button"
-              type="button"
-              className="btn btn-primary text-nowrap"
-              onClick={this.onClickSubmit}
-            >
-              {t('Update API Token')}
-            </button>
-          </div>
-        </div>
-
-      </React.Fragment>
-
-    );
-  }
-
-}
-
-ApiSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-const ApiSettingsWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ApiSettings t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ApiSettingsWrapper = withUnstatedContainers(ApiSettingsWrapperFC, [AppContainer, PersonalContainer]);
-
-export default ApiSettingsWrapper;

+ 91 - 0
packages/app/src/components/Me/ApiSettings.tsx

@@ -0,0 +1,91 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useSWRxPersonalSettings, usePersonalSettings } from '~/stores/personal-settings';
+
+
+const ApiSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
+  const { data: personalSettingsData } = usePersonalSettings();
+
+  const submitHandler = useCallback(async() => {
+
+    try {
+      await apiv3Put('/personal-setting/api-token');
+      mutateDatabaseData();
+
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateDatabaseData, t]);
+
+  return (
+    <>
+
+      <h2 className="border-bottom my-4">{ t('API Token Settings') }</h2>
+
+      <div className="row mb-3">
+        <label htmlFor="apiToken" className="col-md-3 text-md-right">{t('Current API Token')}</label>
+        <div className="col-md-6">
+          {personalSettingsData?.apiToken != null
+            ? (
+              <input
+                data-testid="grw-api-settings-input"
+                data-hide-in-vrt
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={personalSettingsData.apiToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_apitoken.notice.apitoken_issued') }
+              </p>
+            )}
+        </div>
+      </div>
+
+
+      <div className="row">
+        <div className="offset-lg-2 col-lg-7">
+
+          <p className="alert alert-warning">
+            { t('page_me_apitoken.notice.update_token1') }<br />
+            { t('page_me_apitoken.notice.update_token2') }
+          </p>
+
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-api-settings-update-button"
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={submitHandler}
+          >
+            {t('Update API Token')}
+          </button>
+        </div>
+      </div>
+
+    </>
+
+  );
+
+});
+
+ApiSettings.displayName = 'ApiSettings';
+
+export default ApiSettings;

+ 0 - 152
packages/app/src/components/Me/AssociateModal.jsx

@@ -1,152 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-class AssociateModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      username: '',
-      password: '',
-    };
-
-    this.onChangeUsername = this.onChangeUsername.bind(this);
-    this.onChangePassword = this.onChangePassword.bind(this);
-    this.onClickAddBtn = this.onClickAddBtn.bind(this);
-  }
-
-  /**
-   * Change username
-   */
-  onChangeUsername(username) {
-    this.setState({ username });
-  }
-
-  /**
-   * Change password
-   */
-  onChangePassword(password) {
-    this.setState({ password });
-  }
-
-  async onClickAddBtn() {
-    const { t, personalContainer } = this.props;
-    const { username, password } = this.state;
-
-    try {
-      await personalContainer.associateLdapAccount({ username, password });
-      this.props.onClose();
-      toastSuccess(t('security_setting.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    try {
-      await personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg" data-testid="grw-associate-modal">
-        <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
-          { t('admin:user_management.create_external_account') }
-        </ModalHeader>
-        <ModalBody>
-          <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
-            <li className="nav-item active">
-              <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
-                <i className="fa fa-sitemap"></i> LDAP
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-github"></i> (TBD) GitHub
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-google"></i> (TBD) Google OAuth
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-facebook"></i> (TBD) Facebook
-              </a>
-            </li>
-            <li className="nav-item">
-              <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
-                <i className="fa fa-twitter"></i> (TBD) Twitter
-              </a>
-            </li>
-          </ul>
-          <div className="tab-content">
-            <div id="passport-ldap" className="tab-pane active">
-              <LdapAuthTest
-                username={this.state.username}
-                password={this.state.password}
-                onChangeUsername={this.onChangeUsername}
-                onChangePassword={this.onChangePassword}
-              />
-            </div>
-            <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
-            <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          </div>
-        </ModalBody>
-        <ModalFooter className="border-top-0">
-          <button type="button" className="btn btn-primary mt-3" onClick={this.onClickAddBtn}>
-            <i className="fa fa-plus-circle" aria-hidden="true"></i>
-            {t('add')}
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-AssociateModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-const AssociateModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <AssociateModal t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const AssociateModalWrapper = withUnstatedContainers(AssociateModalWrapperFC, [AppContainer, PersonalContainer]);
-
-export default AssociateModalWrapper;

+ 111 - 0
packages/app/src/components/Me/AssociateModal.tsx

@@ -0,0 +1,111 @@
+import React, { useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+
+import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+}
+
+const AssociateModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { associateLdapAccount } = usePersonalSettings();
+  const { isOpen, onClose } = props;
+
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+
+  const closeModalHandler = useCallback(() => {
+    onClose();
+    setUsername('');
+    setPassword('');
+  }, [onClose]);
+
+
+  const clickAddLdapAccountHandler = useCallback(async() => {
+    try {
+      await associateLdapAccount({ username, password });
+      mutatePersonalExternalAccounts();
+
+      closeModalHandler();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
+
+
+  return (
+    <Modal isOpen={isOpen} toggle={closeModalHandler} size="lg" data-testid="grw-associate-modal">
+      <ModalHeader className="bg-primary text-light" toggle={onClose}>
+        { t('admin:user_management.create_external_account') }
+      </ModalHeader>
+      <ModalBody>
+        <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
+          <li className="nav-item active">
+            <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
+              <i className="fa fa-sitemap"></i> LDAP
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-github"></i> (TBD) GitHub
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-google"></i> (TBD) Google OAuth
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-facebook"></i> (TBD) Facebook
+            </a>
+          </li>
+          <li className="nav-item">
+            <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
+              <i className="fa fa-twitter"></i> (TBD) Twitter
+            </a>
+          </li>
+        </ul>
+        <div className="tab-content">
+          <div id="passport-ldap" className="tab-pane active">
+            <LdapAuthTest
+              username={username}
+              password={password}
+              onChangeUsername={username => setUsername(username)}
+              onChangePassword={password => setPassword(password)}
+            />
+          </div>
+          <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
+          <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
+        </div>
+      </ModalBody>
+      <ModalFooter className="border-top-0">
+        <button type="button" className="btn btn-primary mt-3" onClick={clickAddLdapAccountHandler}>
+          <i className="fa fa-plus-circle" aria-hidden="true"></i>
+          {t('add')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+export default AssociateModal;

+ 0 - 181
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -1,181 +0,0 @@
-
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { localeMetadatas } from '~/client/util/i18n';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class BasicInfoSettings extends React.Component {
-
-  constructor() {
-    super();
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async componentDidMount() {
-    try {
-      await this.props.personalContainer.retrievePersonalData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
-
-    try {
-      await personalContainer.updateBasicInfo();
-      toastSuccess(t('toaster.update_successed', { target: t('Basic Info') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, personalContainer } = this.props;
-    const { registrationWhiteList } = personalContainer.state;
-
-    return (
-      <Fragment>
-
-        <div className="form-group row">
-          <label htmlFor="userForm[name]" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              name="userForm[name]"
-              defaultValue={personalContainer.state.name}
-              onChange={(e) => { personalContainer.changeName(e.target.value) }}
-            />
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label htmlFor="userForm[email]" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              name="userForm[email]"
-              defaultValue={personalContainer.state.email}
-              onChange={(e) => { personalContainer.changeEmail(e.target.value) }}
-            />
-            {registrationWhiteList.length !== 0 && (
-              <div className="form-text text-muted">
-                {t('page_register.form_help.email')}
-                <ul>
-                  {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
-                </ul>
-              </div>
-            )}
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('Disclose E-mail')}</label>
-          <div className="col-md-6">
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioEmailShow"
-                className="custom-control-input"
-                name="userForm[isEmailPublished]"
-                checked={personalContainer.state.isEmailPublished}
-                onChange={() => { personalContainer.changeIsEmailPublished(true) }}
-              />
-              <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioEmailHide"
-                className="custom-control-input"
-                name="userForm[isEmailPublished]"
-                checked={!personalContainer.state.isEmailPublished}
-                onChange={() => { personalContainer.changeIsEmailPublished(false) }}
-              />
-              <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
-            </div>
-          </div>
-        </div>
-
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
-          <div className="col-md-6">
-            {
-              localeMetadatas.map(meta => (
-                <div key={meta.id} className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    id={`radioLang${meta.id}`}
-                    className="custom-control-input"
-                    name="userForm[lang]"
-                    checked={personalContainer.state.lang === meta.id}
-                    onChange={() => { personalContainer.changeLang(meta.id) }}
-                  />
-                  <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
-                </div>
-              ))
-            }
-          </div>
-        </div>
-        <div className="form-group row">
-          <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              key={personalContainer.state.slackMemberId}
-              name="userForm[slackMemberId]"
-              defaultValue={personalContainer.state.slackMemberId}
-              onChange={(e) => { personalContainer.changeSlackMemberId(e.target.value) }}
-            />
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="offset-4 col-5">
-            <button
-              data-testid="grw-besic-info-settings-update-button"
-              type="button"
-              className="btn btn-primary"
-              onClick={this.onClickSubmit}
-              disabled={personalContainer.state.retrieveError != null}
-            >
-              {t('Update')}
-            </button>
-          </div>
-        </div>
-
-      </Fragment>
-    );
-  }
-
-}
-
-BasicInfoSettings.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-};
-
-const BasicInfoSettingsWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <BasicInfoSettings t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettingsWrapperFC, [PersonalContainer]);
-
-export default BasicInfoSettingsWrapper;

+ 171 - 0
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -0,0 +1,171 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { localeMetadatas } from '~/client/util/i18n';
+import { usePersonalSettings } from '~/stores/personal-settings';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+type Props = {
+  appContainer: AppContainer,
+}
+
+const BasicInfoSettings = (props: Props) => {
+  const { t } = useTranslation();
+  const { appContainer } = props;
+
+  const {
+    data: personalSettingsInfo, mutate: mutatePersonalSettings, sync, updateBasicInfo, error,
+  } = usePersonalSettings();
+
+
+  const submitHandler = async() => {
+
+    try {
+      await updateBasicInfo();
+      sync();
+      toastSuccess(t('toaster.update_successed', { target: t('Basic Info') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+
+  const { registrationWhiteList } = appContainer.getConfig();
+
+  const changePersonalSettingsHandler = (updateData) => {
+    if (personalSettingsInfo == null) {
+      return;
+    }
+    mutatePersonalSettings({ ...personalSettingsInfo, ...updateData });
+  };
+
+
+  return (
+    <>
+
+      <div className="form-group row">
+        <label htmlFor="userForm[name]" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="userForm[name]"
+            defaultValue={personalSettingsInfo?.name || ''}
+            onChange={e => changePersonalSettingsHandler({ name: e.target.value })}
+          />
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label htmlFor="userForm[email]" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="userForm[email]"
+            defaultValue={personalSettingsInfo?.email || ''}
+            onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
+          />
+          {registrationWhiteList.length !== 0 && (
+            <div className="form-text text-muted">
+              {t('page_register.form_help.email')}
+              <ul>
+                {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
+              </ul>
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('Disclose E-mail')}</label>
+        <div className="col-md-6">
+          <div className="custom-control custom-radio custom-control-inline">
+            <input
+              type="radio"
+              id="radioEmailShow"
+              className="custom-control-input"
+              name="userForm[isEmailPublished]"
+              checked={personalSettingsInfo?.isEmailPublished === true}
+              onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
+            />
+            <label className="custom-control-label" htmlFor="radioEmailShow">{t('Show')}</label>
+          </div>
+          <div className="custom-control custom-radio custom-control-inline">
+            <input
+              type="radio"
+              id="radioEmailHide"
+              className="custom-control-input"
+              name="userForm[isEmailPublished]"
+              checked={personalSettingsInfo?.isEmailPublished === false}
+              onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
+            />
+            <label className="custom-control-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+          </div>
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
+        <div className="col-md-6">
+          {
+            localeMetadatas.map(meta => (
+              <div key={meta.id} className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  id={`radioLang${meta.id}`}
+                  className="custom-control-input"
+                  name="userForm[lang]"
+                  checked={personalSettingsInfo?.lang === meta.id}
+                  onChange={() => changePersonalSettingsHandler({ lang: meta.id })}
+                />
+                <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
+              </div>
+            ))
+          }
+        </div>
+      </div>
+      <div className="form-group row">
+        <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            key="slackMemberId"
+            name="userForm[slackMemberId]"
+            defaultValue={personalSettingsInfo?.slackMemberId || ''}
+            onChange={e => changePersonalSettingsHandler({ slackMemberId: e.target.value })}
+          />
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-besic-info-settings-update-button"
+            type="button"
+            className="btn btn-primary"
+            onClick={submitHandler}
+            disabled={error != null}
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+
+    </>
+  );
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [AppContainer]);
+
+export default BasicInfoSettingsWrapper;

+ 0 - 98
packages/app/src/components/Me/DisassociateModal.jsx

@@ -1,98 +0,0 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-class DisassociateModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickDisassociateBtn = this.onClickDisassociateBtn.bind(this);
-  }
-
-  async onClickDisassociateBtn() {
-    const { t, personalContainer } = this.props;
-    const { providerType, accountId } = this.props.accountForDisassociate;
-
-    try {
-      await personalContainer.disassociateLdapAccount({ providerType, accountId });
-      this.props.onClose();
-      toastSuccess(t('security_setting.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    try {
-      await personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, accountForDisassociate } = this.props;
-    const { providerType, accountId } = accountForDisassociate;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader className="bg-info text-light" toggle={this.props.onClose}>
-          {t('personal_settings.disassociate_external_account')}
-        </ModalHeader>
-        <ModalBody>
-          {/* eslint-disable-next-line react/no-danger */}
-          <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>
-            { t('Cancel') }
-          </button>
-          <button type="button" className="btn btn-sm btn-danger" onClick={this.onClickDisassociateBtn}>
-            <i className="ti-unlink"></i>
-            { t('Disassociate') }
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-DisassociateModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-  accountForDisassociate: PropTypes.object.isRequired,
-
-};
-
-const DisassociateModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <DisassociateModal t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const DisassociateModalWrapper = withUnstatedContainers(DisassociateModalWrapperFC, [AppContainer, PersonalContainer]);
-
-
-export default DisassociateModalWrapper;

+ 69 - 0
packages/app/src/components/Me/DisassociateModal.tsx

@@ -0,0 +1,69 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IExternalAccount } from '~/interfaces/external-account';
+import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+  accountForDisassociate: IExternalAccount,
+}
+
+
+const DisassociateModal = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { disassociateLdapAccount } = usePersonalSettings();
+
+  const { providerType, accountId } = props.accountForDisassociate;
+
+  const disassociateAccountHandler = useCallback(async() => {
+
+    try {
+      await disassociateLdapAccount({ providerType, accountId });
+      props.onClose();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    if (mutatePersonalExternalAccounts != null) {
+      mutatePersonalExternalAccounts();
+    }
+  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, props, providerType, t]);
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={props.onClose}>
+      <ModalHeader className="bg-info text-light" toggle={props.onClose}>
+        {t('personal_settings.disassociate_external_account')}
+      </ModalHeader>
+      <ModalBody>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-sm btn-outline-secondary" onClick={props.onClose}>
+          { t('Cancel') }
+        </button>
+        <button type="button" className="btn btn-sm btn-danger" onClick={disassociateAccountHandler}>
+          <i className="ti-unlink"></i>
+          { t('Disassociate') }
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+
+export default DisassociateModal;

+ 8 - 18
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -1,4 +1,3 @@
-
 import React, { Fragment } from 'react';
 
 import PropTypes from 'prop-types';
@@ -6,8 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import { toastError } from '~/client/util/apiNotification';
+import { useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -32,15 +30,6 @@ class ExternalAccountLinkedMe extends React.Component {
     this.closeDisassociateModal = this.closeDisassociateModal.bind(this);
   }
 
-  async componentDidMount() {
-    try {
-      await this.props.personalContainer.retrieveExternalAccounts();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
   openAssociateModal() {
     this.setState({ isAssociateModalOpen: true });
   }
@@ -65,8 +54,7 @@ class ExternalAccountLinkedMe extends React.Component {
   }
 
   render() {
-    const { t, personalContainer } = this.props;
-    const { externalAccounts } = personalContainer.state;
+    const { t, personalExternalAccounts } = this.props;
 
     return (
       <Fragment>
@@ -95,7 +83,7 @@ class ExternalAccountLinkedMe extends React.Component {
             </tr>
           </thead>
           <tbody>
-            {externalAccounts !== 0 && externalAccounts.map(account => (
+            {personalExternalAccounts != null && personalExternalAccounts.length > 0 && personalExternalAccounts.map(account => (
               <ExternalAccountRow
                 account={account}
                 key={account._id}
@@ -128,17 +116,19 @@ class ExternalAccountLinkedMe extends React.Component {
 ExternalAccountLinkedMe.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+  personalExternalAccounts: PropTypes.arrayOf(PropTypes.object),
 };
 
 const ExternalAccountLinkedMeWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <ExternalAccountLinkedMe t={t} {...props} />;
+  const { data: personalExternalAccountsData } = useSWRxPersonalExternalAccounts();
+
+  return <ExternalAccountLinkedMe t={t} personalExternalAccounts={personalExternalAccountsData} {...props} />;
 };
 
 /**
  * Wrapper component for using unstated
  */
-const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer, PersonalContainer]);
+const ExternalAccountLinkedMeWrapper = withUnstatedContainers(ExternalAccountLinkedMeWrapperFC, [AppContainer]);
 
 export default ExternalAccountLinkedMeWrapper;

+ 19 - 17
packages/app/src/components/Me/PasswordSettings.jsx

@@ -1,14 +1,12 @@
-
-import React from 'react';
+import React, { useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { usePersonalSettings } from '~/stores/personal-settings';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 class PasswordSettings extends React.Component {
 
@@ -24,7 +22,7 @@ class PasswordSettings extends React.Component {
       minPasswordLength: null,
     };
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
+    this.submitHandler = this.submitHandler.bind(this);
     this.onChangeOldPassword = this.onChangeOldPassword.bind(this);
 
   }
@@ -42,8 +40,8 @@ class PasswordSettings extends React.Component {
 
   }
 
-  async onClickSubmit() {
-    const { t, personalContainer } = this.props;
+  async submitHandler() {
+    const { t, onSubmit } = this.props;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
 
     try {
@@ -51,7 +49,9 @@ class PasswordSettings extends React.Component {
         oldPassword, newPassword, newPasswordConfirm,
       });
       this.setState({ oldPassword: '', newPassword: '', newPasswordConfirm: '' });
-      await personalContainer.retrievePersonalData();
+      if (onSubmit != null) {
+        onSubmit();
+      }
       toastSuccess(t('toaster.update_successed', { target: t('Password') }));
     }
     catch (err) {
@@ -140,7 +140,7 @@ class PasswordSettings extends React.Component {
               data-testid="grw-password-settings-update-button"
               type="button"
               className="btn btn-primary"
-              onClick={this.onClickSubmit}
+              onClick={this.submitHandler}
               disabled={isIncorrectConfirmPassword}
             >
               {t('Update')}
@@ -155,17 +155,19 @@ class PasswordSettings extends React.Component {
 
 PasswordSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
+  onSubmit: PropTypes.func,
 };
 
 const PasswordSettingsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <PasswordSettings t={t} {...props} />;
-};
+  const { mutate: mutatePersonalSettings } = usePersonalSettings();
+
+  const submitHandler = useCallback(() => {
+    mutatePersonalSettings();
+  }, [mutatePersonalSettings]);
 
-/**
- * Wrapper component for using unstated
- */
-const PasswordSettingsWrapper = withUnstatedContainers(PasswordSettingsWrapperFC, [PersonalContainer]);
 
-export default PasswordSettingsWrapper;
+  return <PasswordSettings t={t} onSubmit={submitHandler} {...props} />;
+};
+
+export default PasswordSettingsWrapperFC;

+ 10 - 2
packages/app/src/components/MyDraftList/Draft.jsx

@@ -9,6 +9,8 @@ import {
 } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
+import { useDraftRenderer } from '~/stores/renderer';
 
 import RevisionBody from '../Page/RevisionBody';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -25,7 +27,7 @@ class Draft extends React.Component {
       showCopiedMessage: false,
     };
 
-    this.growiRenderer = this.props.appContainer.getRenderer('draft');
+    this.growiRenderer = this.props.growiRenderer;
 
     this.changeToolTipLabel = this.changeToolTipLabel.bind(this);
     this.expandPanelHandler = this.expandPanelHandler.bind(this);
@@ -194,6 +196,7 @@ class Draft extends React.Component {
 Draft.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
 
   index: PropTypes.number.isRequired,
   path: PropTypes.string.isRequired,
@@ -204,7 +207,12 @@ Draft.propTypes = {
 
 const DraftWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <Draft t={t} {...props} />;
+  const { data: growiRenderer } = useDraftRenderer();
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
+  return <Draft t={t} growiRenderer={growiRenderer} {...props} />;
 };
 
 /**

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

@@ -1,6 +1,7 @@
 import React, { useState, useEffect, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { DropdownItem } from 'reactstrap';
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -11,7 +12,10 @@ import {
   IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { useCurrentUser, useIsGuestUser, useIsSharedUser } from '~/stores/context';
+import {
+  useCurrentPageId,
+  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
+} from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
@@ -148,15 +152,15 @@ type GrowiContextualSubNavigationProps = {
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
-  const { data: pageData, mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const pageId = pageData?._id;
-  const path = pageData?.path;
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const path = currentPage?.path;
 
-  const revision = pageData?.revision;
+  const revision = currentPage?.revision;
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
 
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
@@ -166,19 +170,32 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(pageId);
-  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
+  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(currentPage?._id);
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { data: templateTagData } = useTemplateTagData();
+
 
   useEffect(() => {
     // Run only when tagsInfoData has been updated
-    syncPageTagsForEditors();
+    if (templateTagData == null) {
+      syncPageTagsForEditors();
+    }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [tagsInfoData?.tags]);
 
+  useEffect(() => {
+    if (pageId === null && templateTagData != null) {
+      const tags = templateTagData.split(',').filter((str: string) => {
+        return str !== ''; // filter empty values
+      });
+      mutatePageTagsForEditors(tags);
+    }
+  }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
+
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
   const { isCompactMode, isLinkSharingDisabled } = props;
@@ -187,6 +204,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
+    if (currentPage == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId } = currentPage;
     try {
       await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
       mutateCurrentPage();
@@ -253,7 +275,9 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const ControlComponents = useCallback(() => {
-    const pageIdForSubNavButtons = pageId; // for SubNavButtons
+    if (currentPage == null || pageId == null) {
+      return <></>;
+    }
 
     function onPageEditorModeButtonClicked(viewType) {
       mutateEditorMode(viewType);
@@ -275,14 +299,18 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     return (
       <>
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
-          { pageIdForSubNavButtons != null && isViewMode && (
+
+          { isViewMode && (
             <div className="h-50">
               <SubNavButtons
                 isCompactMode={isCompactMode}
-                pageId={pageIdForSubNavButtons}
+                pageId={pageId}
                 revisionId={revisionId}
+                // pageId={pageId}
+                // shareLinkId={shareLinkId}
+                // revisionId={revision.toString()}
                 path={path}
-                disableSeenUserInfoPopover={isSharedUser}
+                // disableSeenUserInfoPopover={isSharedUser}
                 showPageControlDropdown={isAbleToShowPageManagement}
                 additionalMenuItemRenderer={additionalMenuItemsRenderer}
                 onClickDuplicateMenuItem={duplicateItemClickedHandler}
@@ -313,16 +341,16 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     isLinkSharingDisabled, isGuestUser, isSharedUser, currentUser,
     isViewMode, isAbleToShowPageEditorModeManager, isAbleToShowPageManagement,
     duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
-    path, templateMenuItemClickHandler, isPageTemplateModalShown,
+    templateMenuItemClickHandler, isPageTemplateModalShown,
   ]);
 
-  if (pageData == null) {
+  if (currentPage == null) {
     return <></>;
   }
 
   return (
     <GrowiSubNavigation
-      page={pageData}
+      page={currentPage}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
       showPageAuthors={isAbleToShowPageAuthors}

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

@@ -73,8 +73,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
         ) }
 
         <div className="grw-path-nav-container">
-          {/* "/trash" page does not exist on page collection and unable to add tags  */}
-          { showTagLabel && !isCompactMode && path !== '/trash' && (
+          { showTagLabel && !isCompactMode && (
             <div className="grw-taglabels-container">
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>

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

@@ -155,12 +155,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
       {revisionId != null && (
-        <span>
-          <SubscribeButton
-            status={pageInfo.subscriptionStatus}
-            onClick={subscribeClickhandler}
-          />
-        </span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
       )}
       {revisionId != null && (
         <LikeButtons

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

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

+ 16 - 10
packages/app/src/components/Page.jsx

@@ -2,16 +2,19 @@ import React, { useEffect, useRef } from 'react';
 
 import PropTypes from 'prop-types';
 
-
 import MarkdownTable from '~/client/models/MarkdownTable';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import {
   useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
+import { useViewRenderer } from '~/stores/renderer';
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -38,8 +41,6 @@ class Page extends React.Component {
       currentTargetDrawioArea: null,
     };
 
-    this.growiRenderer = this.props.appContainer.getRenderer('page');
-
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
@@ -76,7 +77,7 @@ class Page extends React.Component {
 
   async saveHandlerForHandsontableModal(markdownTable) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
+      isSlackEnabled, slackChannels, pageContainer, mutateIsEnabledUnsavedWarning, grant, grantGroupId, grantGroupName, pageTags,
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
@@ -89,7 +90,7 @@ class Page extends React.Component {
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
@@ -108,7 +109,7 @@ class Page extends React.Component {
 
   async saveHandlerForDrawioModal(drawioData) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, editorContainer,
+      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
@@ -121,7 +122,7 @@ class Page extends React.Component {
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
@@ -148,7 +149,7 @@ class Page extends React.Component {
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
 
         { revisionId != null && (
-          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} pagePath={pagePath} />
+          <RevisionRenderer growiRenderer={this.props.growiRenderer} markdown={markdown} pagePath={pagePath} />
         )}
 
         { !isGuestUser && (
@@ -170,6 +171,7 @@ Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
 
   pagePath: PropTypes.string.isRequired,
   pageTags:  PropTypes.arrayOf(PropTypes.string),
@@ -194,6 +196,8 @@ const PageWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: growiRenderer } = useViewRenderer();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   const pageRef = useRef(null);
 
@@ -225,7 +229,7 @@ const PageWrapper = (props) => {
     };
   }, []);
 
-  if (currentPagePath == null || editorMode == null || isGuestUser == null) {
+  if (currentPagePath == null || editorMode == null || isGuestUser == null || growiRenderer == null) {
     return null;
   }
 
@@ -234,6 +238,7 @@ const PageWrapper = (props) => {
     <Page
       {...props}
       ref={pageRef}
+      growiRenderer={growiRenderer}
       pagePath={currentPagePath}
       editorMode={editorMode}
       isGuestUser={isGuestUser}
@@ -244,6 +249,7 @@ const PageWrapper = (props) => {
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
   );
 };

+ 26 - 19
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -2,25 +2,24 @@ import React, { useMemo } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { TabContent, TabPane } from 'reactstrap';
 
-
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { isPopulated } from '~/interfaces/common';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId, useIsEmptyPage,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
-import ContentLinkButtons from '../ContentLinkButtons';
-import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
-import Page from '../Page';
-import PageEditor from '../PageEditor';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import PageEditorByHackmd from '../PageEditorByHackmd';
+import NotFoundPage from '../NotFoundPage';
+// import Page from '../Page';
+// import PageEditor from '../PageEditor';
+// import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import UserInfo from '../User/UserInfo';
 
@@ -33,34 +32,38 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
+  const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
+  const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
+
   // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
-  const { data: isEmptyPage } = useIsEmptyPage();
-  const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
-  const isPageExist = currentPageId != null;
   const isViewMode = editorMode === EditorMode.View;
   const isTopPagePath = isTopPage(currentPagePath ?? '');
 
+  const revision = currentPage?.revision;
+
   return (
     <>
       <TabContent activeTab={editorMode}>
         <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
 
-            { isPageExist && !isEmptyPage && (
+            { !isNotFound && !currentPage?.isEmpty && (
               <div className="grw-side-contents-container">
                 <div className="grw-side-contents-sticky-container">
 
@@ -71,6 +74,7 @@ const DisplaySwitcher = (): JSX.Element => {
                         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(currentPagePath)}
+                        data-testid="pageListButton"
                       >
                         <div className="grw-page-accessories-control-icon">
                           <PageListIcon />
@@ -82,12 +86,13 @@ const DisplaySwitcher = (): JSX.Element => {
                   </div>
 
                   {/* Comments */}
-                  { getCommentListDom != null && !isTopPagePath && (
+                  {/* { getCommentListDom != null && !isTopPagePath && ( */}
+                  { !isTopPagePath && (
                     <div className="grw-page-accessories-control 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)}
+                        // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
@@ -98,7 +103,7 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   <div className="d-none d-lg-block">
                     <div id="revision-toc" className="revision-toc">
-                      <TableOfContents />
+                      {/* <TableOfContents /> */}
                     </div>
                     <ContentLinkButtons />
                   </div>
@@ -109,7 +114,9 @@ const DisplaySwitcher = (): JSX.Element => {
 
             <div className="flex-grow-1 flex-basis-0 mw-0">
               { isUserPage && <UserInfo pageUser={pageUser} />}
-              <Page />
+              {/* { !isNotFound && <Page /> } */}
+              { !isNotFound && revision != null && isPopulated(revision) && revision.body }
+              { isNotFound && <NotFoundPage /> }
             </div>
 
           </div>
@@ -117,14 +124,14 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
-              <PageEditor />
+              {/* <PageEditor /> */}
             </div>
           </TabPane>
         ) }
         { isEditable && (
           <TabPane tabId={EditorMode.HackMD}>
             <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
+              {/* <PageEditorByHackmd /> */}
             </div>
           </TabPane>
         ) }

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

@@ -4,8 +4,9 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { Waypoint } from 'react-waypoint';
 
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
+import { useViewRenderer } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './RevisionRenderer';
@@ -134,7 +135,12 @@ RevisionLoader.propTypes = {
 
 const RevisionLoaderWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <RevisionLoader t={t} {...props} />;
+  const { data: growiRenderer } = useViewRenderer();
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
+  return <RevisionLoader t={t} growiRenderer={growiRenderer} {...props} />;
 };
 
 export default RevisionLoaderWrapperFC;

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

@@ -3,9 +3,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import AppContainer from '~/client/services/AppContainer';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useEditorSettings } from '~/stores/editor';
 
 import { withUnstatedContainers } from '../UnstatedUtils';

+ 11 - 8
packages/app/src/components/Page/FixPageGrantAlert.tsx → packages/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { useTranslation } from 'next-i18next';
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -9,8 +9,8 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { PageGrant, IPageGrantData } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
-import { useCurrentPageId, useCurrentUser, useHasParent } from '~/stores/context';
-import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+import { useCurrentUser } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
 type ModalProps = {
   isOpen: boolean
@@ -229,12 +229,13 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   );
 };
 
-const FixPageGrantAlert = (): JSX.Element => {
+export const FixPageGrantAlert = (): JSX.Element => {
   const { t } = useTranslation();
 
   const { data: currentUser } = useCurrentUser();
-  const { data: pageId } = useCurrentPageId();
-  const { data: hasParent } = useHasParent();
+  const { data: pageData } = useSWRxCurrentPage();
+  const hasParent = pageData != null ? pageData.parent != null : false;
+  const pageId = pageData?._id;
 
   const [isOpen, setOpen] = useState<boolean>(false);
 
@@ -242,6 +243,10 @@ const FixPageGrantAlert = (): JSX.Element => {
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
   // Dependencies
+  if (pageData == null) {
+    return <></>;
+  }
+
   if (!hasParent) {
     return <></>;
   }
@@ -277,5 +282,3 @@ const FixPageGrantAlert = (): JSX.Element => {
     </>
   );
 };
-
-export default FixPageGrantAlert;

+ 27 - 0
packages/app/src/components/PageAlert/OldRevisionAlert.tsx

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

+ 28 - 0
packages/app/src/components/PageAlert/PageAlerts.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { FixPageGrantAlert } from './FixPageGrantAlert';
+import { OldRevisionAlert } from './OldRevisionAlert';
+import { PageGrantAlert } from './PageGrantAlert';
+import { PageStaleAlert } from './PageStaleAlert';
+
+// dynamic import because TrashPageAlert uses localStorageMiddleware
+const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
+
+export const PageAlerts = (): JSX.Element => {
+
+
+  return (
+    <div className="row d-edit-none">
+      <div className="col-sm-12">
+        {/* alerts */}
+        <FixPageGrantAlert />
+        <PageGrantAlert />
+        <TrashPageAlert />
+        <PageStaleAlert />
+        <OldRevisionAlert />
+      </div>
+    </div>
+  );
+};

+ 53 - 0
packages/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useXss } from '~/stores/xss';
+import { useTranslation } from 'react-i18next';
+
+
+export const PageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: pageData } = useSWRxCurrentPage();
+  const { data: xss } = useXss();
+
+  if ( pageData == null || pageData.grant == null || pageData.grant == 1 || xss == null) {
+    return <></>
+  }
+
+  const renderAlertContent = () => {
+    const getGrantLabel = () => {
+      if (pageData.grant == 2) {
+        return (
+          <>
+            <i className="icon-fw icon-link"></i><strong>{t('Anyone with the link')} only</strong>
+          </>
+        )
+      }
+      if (pageData.grant == 4) {
+        return (
+          <>
+            <i className="icon-fw icon-lock"></i><strong>{t('Only me')} only</strong>
+          </>
+        )
+      }
+      if (pageData.grant == 5) {
+        return (
+          <>
+            <i className="icon-fw icon-organization"></i><strong>{xss.process(pageData.grantedGroup.name)} only</strong>
+          </>
+        )
+      }
+    };
+    return (
+      <>
+        {getGrantLabel()} ({t('Browsing of this page is restricted')})
+      </>
+    );
+  };
+
+
+  return (
+    <p className="alert alert-primary py-3 px-4">
+      {renderAlertContent()}
+    </p>
+  );
+}

+ 41 - 0
packages/app/src/components/PageAlert/PageStaleAlert.tsx

@@ -0,0 +1,41 @@
+import { useIsEnabledStaleNotification } from '../../stores/context'
+import { useSWRxCurrentPage, useSWRxPageInfo } from '../../stores/page'
+import { useTranslation } from 'react-i18next';
+
+export const PageStaleAlert = ():JSX.Element => {
+  const { t } = useTranslation()
+  const { data: isEnabledStaleNotification } = useIsEnabledStaleNotification();
+
+  // Todo: determine if it should fetch or not like useSWRxPageInfo below after https://redmine.weseek.co.jp/issues/96788
+  const { data: pageData } = useSWRxCurrentPage();
+  const { data: pageInfo } = useSWRxPageInfo(isEnabledStaleNotification ? pageData?._id : null);
+
+  const contentAge = pageInfo?.contentAge;
+
+  if (!isEnabledStaleNotification) {
+    return <></>
+  }
+
+  if( pageInfo == null || contentAge == null || contentAge === 0) {
+    return <></>
+  }
+
+  let alertClass;
+  switch (contentAge) {
+    case 1:
+      alertClass = "alert-info";
+      break;
+    case 2:
+      alertClass = "alert-warning";
+      break;
+    default:
+      alertClass = "alert-danger";
+  }
+
+  return (
+    <div className={`alert ${alertClass}`}>
+      <i className="icon-fw icon-hourglass"></i>
+      <strong>{ t('page_page.notice.stale', { count: pageInfo.contentAge }) }</strong>
+    </div>
+  )
+}

+ 34 - 44
packages/app/src/components/Page/TrashPageAlert.jsx → packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -1,18 +1,17 @@
 import React from 'react';
 
 import { UserPicture } from '@growi/ui';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
 
-import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUpdatedAt, useIsTrashPage, useShareLinkId } from '~/stores/context';
+import {
+  useIsTrashPage, useShareLinkId,
+} from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
-import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+const onDeletedHandler = (pathOrPathsToDelete) => {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
   }
@@ -20,42 +19,48 @@ const onDeletedHandler = (pathOrPathsToDelete, isRecursively, isCompletely) => {
   window.location.href = '/';
 };
 
-const TrashPageAlert = (props) => {
+export const TrashPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
-  const { pageContainer } = props;
-  const {
-    pageId, revisionId, path, lastUpdateUsername, deletedUserName, deletedAt,
-  } = pageContainer.state;
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
   const { data: shareLinkId } = useShareLinkId();
-
-  /*
-  * TODO: Do not use useSWRxPageInfo on this component
-  * Ideal: use useSWRxPageInfo on TrashPage after applying Next.js
-  * Reference: https://github.com/weseek/growi/pull/5359#discussion_r808381329
-  */
+  const { data: pageData } = useSWRxCurrentPage();
+  const { data: isTrashPage } = useIsTrashPage();
+  const pageId = pageData?._id;
+  const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
 
-  const { data: updatedAt } = useCurrentUpdatedAt();
-  const { data: isTrashPage } = useIsTrashPage();
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
+  const lastUpdateUserName = pageData?.lastUpdateUser.name;
+  const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
+  const revisionId = pageData?.revision._id;
+
+  if (!isTrashPage) {
+    return <></>;
+  }
+
   function openPutbackPageModalHandler() {
-    const putBackedHandler = (path) => {
+    if (pageId === undefined || pagePath === undefined) {
+      return;
+    }
+    const putBackedHandler = () => {
       window.location.reload();
     };
-    openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
+    openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
   }
 
   function openPageDeleteModalHandler() {
+    if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
+      return;
+    }
     const pageToDelete = {
       data: {
         _id: pageId,
         revision: revisionId,
-        path,
+        path: pagePath,
       },
       meta: pageInfo,
     };
@@ -90,15 +95,11 @@ const TrashPageAlert = (props) => {
       <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
-          {isTrashPage && (
-            <>
-              <br />
-              <UserPicture user={{ username: deletedUserName || lastUpdateUsername }} />
-              <span className="ml-2">
-                Deleted by {deletedUserName || lastUpdateUsername} at {deletedAt || updatedAt}
-              </span>
-            </>
-          )}
+          <br />
+          <UserPicture user={{ username: lastUpdateUserName }} />
+          <span className="ml-2">
+            Deleted by { lastUpdateUserName } at {deletedAt || pageData?.updatedAt}
+          </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
           { isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
@@ -107,14 +108,3 @@ const TrashPageAlert = (props) => {
     </>
   );
 };
-
-TrashPageAlert.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const TrashPageAlertWrapper = withUnstatedContainers(TrashPageAlert, [PageContainer]);
-
-export default TrashPageAlertWrapper;

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

@@ -8,6 +8,7 @@ import { Button } from 'reactstrap';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
+import { useCommentPreviewRenderer } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
@@ -35,6 +36,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
+  const { data: growiRenderer } = useCommentPreviewRenderer();
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
@@ -110,7 +112,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 
   const generateCommentInnerElement = (comment: ICommentHasId) => (
     <Comment
-      growiRenderer={appContainer.getRenderer('comment')}
+      growiRenderer={growiRenderer}
       deleteBtnClicked={onClickDeleteButton}
       comment={comment}
       onComment={mutate}
@@ -122,7 +124,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     <ReplayComments
       replyList={replyComments}
       deleteBtnClicked={onClickDeleteButton}
-      growiRenderer={appContainer.getRenderer('comment')}
+      growiRenderer={growiRenderer}
       isReadOnly={isReadOnly}
     />
   );
@@ -142,6 +144,10 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     return <></>;
   }
 
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
@@ -185,7 +191,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
                     {/* display reply editor */}
                     {(!isReadOnly && showEditorIds.has(comment._id)) && (
                       <CommentEditor
-                        growiRenderer={appContainer.getRenderer('comment')}
+                        growiRenderer={growiRenderer}
                         replyTo={comment._id}
                         onCancelButtonClicked={() => {
                           removeShowEditorId(comment._id);

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -12,10 +12,10 @@ import * as toastr from 'toastr';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { CustomWindow } from '~/interfaces/global';
 import { IInterceptorManager } from '~/interfaces/interceptor-manager';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
   useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,

+ 6 - 1
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -5,6 +5,7 @@ import { useSWRxPageComment } from '../../stores/comment';
 import AppContainer from '~/client/services/AppContainer';
 
 import CommentEditor from './CommentEditor';
+import { useCommentPreviewRenderer } from '~/stores/renderer';
 
 type Props = {
   appContainer: AppContainer,
@@ -15,9 +16,13 @@ const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
 
   const { pageId } = props;
   const { mutate } = useSWRxPageComment(pageId);
+  const { data: growiRenderer } = useCommentPreviewRenderer();
+
+  if (growiRenderer == null) {
+    return <></>;
+  }
 
   const { appContainer } = props;
-  const growiRenderer = appContainer.getRenderer('comment');
 
   return (
     <CommentEditor

+ 3 - 2
packages/app/src/components/PageDeleteModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useState, FC, useMemo, useEffect,
 } from 'react';
 
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -14,13 +15,13 @@ import {
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRxPageInfoForList } from '~/stores/page';
+import { useSWRxPageInfoForList } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-import { isTrashPage } from '^/../core/src/utils/page-path-utils';
+const { isTrashPage } = pagePathUtils;
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');

+ 26 - 4
packages/app/src/components/PageEditor.tsx

@@ -18,7 +18,9 @@ import {
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
+  useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
+import { usePreviewRenderer } from '~/stores/renderer';
 import {
   EditorMode,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
@@ -96,6 +98,9 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+
+  const { data: growiRenderer } = usePreviewRenderer();
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
@@ -129,7 +134,7 @@ const PageEditor = (props: Props): JSX.Element => {
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
@@ -144,7 +149,19 @@ const PageEditor = (props: Props): JSX.Element => {
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
     }
-  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer, pageTags]);
+  }, [
+    editorContainer,
+    editorMode,
+    grant,
+    grantGroupId,
+    grantGroupName,
+    isSlackEnabled,
+    slackChannelsData,
+    markdown,
+    pageContainer,
+    pageTags,
+    mutateIsEnabledUnsavedWarning,
+  ]);
 
 
   /**
@@ -355,9 +372,9 @@ const PageEditor = (props: Props): JSX.Element => {
   useEffect(() => {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     if (pageContainer.state.markdown! !== markdown) {
-      editorContainer.enableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(true);
     }
-  }, [editorContainer, markdown, pageContainer.state.markdown]);
+  }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
 
   // Detect indent size from contents (only when users are allowed to change it)
   useEffect(() => {
@@ -375,6 +392,10 @@ const PageEditor = (props: Props): JSX.Element => {
     return <></>;
   }
 
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
   const config = props.appContainer.getConfig();
   const isUploadable = config.upload.image || config.upload.file;
   const isUploadableFile = config.upload.file;
@@ -411,6 +432,7 @@ const PageEditor = (props: Props): JSX.Element => {
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
         <Preview
           markdown={markdown}
+          growiRenderer={growiRenderer}
           ref={previewRef}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={false}

+ 4 - 5
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { Collapse, Button } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
@@ -27,13 +26,14 @@ const EditorNavbarBottom = (props) => {
   const [isExpanded, setExpanded] = useState(false);
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
-  const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
+  const { data: isSlackConfigured } = useIsSlackConfigured();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
 
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
@@ -153,8 +153,7 @@ const EditorNavbarBottom = (props) => {
 };
 
 EditorNavbarBottom.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer]);

+ 4 - 20
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -7,12 +7,10 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import AppContainer from '~/client/services/AppContainer';
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useIsTextlintEnabled, useCurrentIndentSize } from '~/stores/editor';
 
 import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 import { DownloadDictModal } from './DownloadDictModal';
 
@@ -165,11 +163,10 @@ IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
 type ConfigurationDropdownProps = {
-  isMathJaxEnabled: boolean,
   onConfirmEnableTextlint?: () => void,
 }
 
-const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
+const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
   const { t } = useTranslation();
 
   const [isCddMenuOpened, setCddMenuOpened] = useState(false);
@@ -207,10 +204,6 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
       return <></>;
     }
 
-    if (!isMathJaxEnabled) {
-      return <></>;
-    }
-
     const isActive = editorSettings.renderMathJaxInRealtime;
 
     const iconClasses = ['text-info'];
@@ -228,7 +221,7 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
         </div>
       </DropdownItem>
     );
-  }, [editorSettings, isMathJaxEnabled, update]);
+  }, [editorSettings, update]);
 
   const renderRealtimeDrawioMenuItem = useCallback(() => {
     if (editorSettings == null) {
@@ -347,14 +340,7 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
 ConfigurationDropdown.displayName = 'ConfigurationDropdown';
 
 
-type Props = {
-  appContainer: AppContainer
-};
-
-const OptionsSelector = (props: Props): JSX.Element => {
-  const { appContainer } = props;
-  const config = appContainer.config;
-
+const OptionsSelector = (): JSX.Element => {
   const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
 
   const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
@@ -384,7 +370,6 @@ const OptionsSelector = (props: Props): JSX.Element => {
         </span>
         <span className="ml-2 ml-sm-4">
           <ConfigurationDropdown
-            isMathJaxEnabled={!!config.env.MATHJAX}
             onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
           />
         </span>
@@ -411,5 +396,4 @@ const OptionsSelector = (props: Props): JSX.Element => {
 };
 
 
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer]);
-export default OptionsSelectorWrapper;
+export default OptionsSelector;

+ 5 - 5
packages/app/src/components/PageEditor/Preview.tsx

@@ -5,6 +5,7 @@ import React, {
 
 import AppContainer from '~/client/services/AppContainer';
 import InterceptorManager from '~/services/interceptor-manager';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useEditorSettings } from '~/stores/editor';
 
 import RevisionBody from '../Page/RevisionBody';
@@ -15,6 +16,7 @@ declare const interceptorManager: InterceptorManager;
 
 
 type Props = {
+  growiRenderer: GrowiRenderer,
   markdown?: string,
   pagePath?: string,
   isMathJaxEnabled?: boolean,
@@ -27,7 +29,7 @@ type UnstatedProps = Props & { appContainer: AppContainer };
 const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivElement>): JSX.Element => {
 
   const {
-    appContainer,
+    growiRenderer,
     markdown, pagePath,
   } = props;
 
@@ -35,8 +37,6 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
 
   const { data: editorSettings } = useEditorSettings();
 
-  const growiRenderer = appContainer.getRenderer('editor');
-
   const context = useMemo(() => {
     return {
       markdown,
@@ -61,7 +61,7 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
     }
 
     setHtml(context.parsedHTML ?? '');
-  }, [interceptorManager, context, growiRenderer]);
+  }, [context, growiRenderer]);
 
   useEffect(() => {
     if (markdown == null) {
@@ -82,7 +82,7 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
         parsedHTML: html,
       });
     }
-  }, [context, html, interceptorManager]);
+  }, [context, html]);
 
   return (
     <div

+ 10 - 6
packages/app/src/components/PageEditorByHackmd.jsx

@@ -3,14 +3,15 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -172,13 +173,13 @@ class PageEditorByHackmd extends React.Component {
    */
   async onSaveWithShortcut(markdown) {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
     try {
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
@@ -200,7 +201,7 @@ class PageEditorByHackmd extends React.Component {
    */
   async hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer, editorContainer } = this.props;
+    const { pageContainer, mutateIsEnabledUnsavedWarning } = this.props;
 
     if (hackmdUri == null) {
       // do nothing
@@ -213,7 +214,7 @@ class PageEditorByHackmd extends React.Component {
     }
 
     // enable unsaved warning
-    editorContainer.enableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(true);
 
     const params = {
       pageId: pageContainer.state.pageId,
@@ -439,6 +440,7 @@ PageEditorByHackmd.propTypes = {
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
+  mutateIsEnabledUnsavedWarning: PropTypes.func,
 };
 
 /**
@@ -457,6 +459,7 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   if (editorMode == null) {
     return null;
@@ -473,6 +476,7 @@ const PageEditorByHackmdWrapper = (props) => {
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
   );
 };

+ 33 - 25
packages/app/src/components/PagePathHierarchicalLink.jsx → packages/app/src/components/PagePathHierarchicalLink.tsx

@@ -1,27 +1,39 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { memo } from 'react';
 
+import Link from 'next/link';
 import urljoin from 'url-join';
 
 import LinkedPagePath from '../models/linked-page-path';
 
 
+type PagePathHierarchicalLinkProps = {
+  linkedPagePath: LinkedPagePath,
+  linkedPagePathByHtml?: LinkedPagePath,
+  basePath?: string,
+  isInTrash?: boolean,
+
+  // !!INTERNAL USE ONLY!!
+  isInnerElem?: boolean,
+};
+
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PagePathHierarchicalLink = (props) => {
+const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkProps): JSX.Element => {
   const {
     linkedPagePath, linkedPagePathByHtml, basePath, isInTrash,
   } = props;
   // render root element
   if (linkedPagePath.isRoot) {
     if (basePath != null) {
-      return null;
+      return <></>;
     }
 
     return isInTrash
       ? (
         <>
           <span className="path-segment">
-            <a href="/trash"><i className="icon-trash"></i></a>
+            <Link href="/trash">
+              <a ><i className="icon-trash"></i></a>
+            </Link>
           </span>
           <span className="separator"><a href="/">/</a></span>
         </>
@@ -29,10 +41,12 @@ const PagePathHierarchicalLink = (props) => {
       : (
         <>
           <span className="path-segment">
-            <a href="/">
-              <i className="icon-home"></i>
-              <span className="separator">/</span>
-            </a>
+            <Link href="/">
+              <a >
+                <i className="icon-home"></i>
+                <span className="separator">/</span>
+              </a>
+            </Link>
           </span>
         </>
       );
@@ -68,25 +82,19 @@ const PagePathHierarchicalLink = (props) => {
         <span className="separator">/</span>
       ) }
 
-      {
-        shouldDangerouslySetInnerHTML
-          // eslint-disable-next-line react/no-danger
-          ? <a className="page-segment" href={href} dangerouslySetInnerHTML={{ __html: linkedPagePathByHtml.pathName }}></a>
-          : <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
-      }
+      <Link href={href}>
+        {
+          shouldDangerouslySetInnerHTML
+            // eslint-disable-next-line react/no-danger
+            ? <a className="page-segment" dangerouslySetInnerHTML={{ __html: linkedPagePathByHtml.pathName }}></a>
+            : <a className="page-segment" >{linkedPagePath.pathName}</a>
+        }
+      </Link>
 
     </RootElm>
   );
-};
-
-PagePathHierarchicalLink.propTypes = {
-  linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
-  linkedPagePathByHtml: PropTypes.instanceOf(LinkedPagePath), // Not required
-  basePath: PropTypes.string,
-  isInTrash: PropTypes.bool,
+});
+PagePathHierarchicalLink.displayName = 'PagePathHierarchicalLink';
 
-  // !!INTERNAL USE ONLY!!
-  isInnerElem: PropTypes.bool,
-};
 
 export default PagePathHierarchicalLink;

+ 12 - 3
packages/app/src/components/PageTimeline.jsx

@@ -6,6 +6,8 @@ import { useTranslation } from 'next-i18next';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
+import { useTimelineRenderer } from '~/stores/renderer';
 
 import RevisionLoader from './Page/RevisionLoader';
 import PaginationWrapper from './PaginationWrapper';
@@ -48,9 +50,9 @@ class PageTimeline extends React.Component {
   }
 
   UNSAFE_componentWillMount() {
-    const { appContainer } = this.props;
+    const { growiRenderer } = this.props;
     // initialize GrowiRenderer
-    this.growiRenderer = appContainer.getRenderer('timeline');
+    this.growiRenderer = growiRenderer;
   }
 
   async componentDidMount() {
@@ -110,13 +112,20 @@ class PageTimeline extends React.Component {
 PageTimeline.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pages: PropTypes.arrayOf(PropTypes.object),
 };
 
 const PageTimelineWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <PageTimeline t={t} {...props} />;
+  const { data: growiRenderer } = useTimelineRenderer();
+
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
+  return <PageTimeline t={t} growiRenderer={growiRenderer} {...props} />;
 };
 
 /**

+ 4 - 0
packages/app/src/components/PrivateLegacyPages.tsx

@@ -128,6 +128,8 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   );
 });
 
+SearchResultListHead.displayName = 'SearchResultListHead';
+
 /*
  * ConvertByPathModal
  */
@@ -182,6 +184,8 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
 });
 
+ConvertByPathModal.displayName = 'ConvertByPathModal';
+
 /**
  * LegacyPage
  */

+ 16 - 20
packages/app/src/components/SavePageControls.jsx

@@ -7,15 +7,10 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
-
-// TODO: remove this when omitting unstated is completed
-import { useIsEditable, useCurrentPageId } from '~/stores/context';
-import { usePageTagsForEditors } from '~/stores/editor';
+import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
+import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -31,9 +26,6 @@ class SavePageControls extends React.Component {
   constructor(props) {
     super(props);
 
-    const config = this.props.appContainer.getConfig();
-    this.isAclEnabled = config.isAclEnabled;
-
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
     this.save = this.save.bind(this);
@@ -51,10 +43,10 @@ class SavePageControls extends React.Component {
 
   async save() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     // disable unsaved warning
-    editorContainer.disableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(false);
 
     try {
       // save
@@ -77,10 +69,10 @@ class SavePageControls extends React.Component {
 
   saveAndOverwriteScopesOfDescendants() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     // disable unsaved warning
-    editorContainer.disableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(false);
     // save
     const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = Object.assign(currentOptionsToSave, {
@@ -92,7 +84,7 @@ class SavePageControls extends React.Component {
   render() {
 
     const {
-      t, pageContainer, grant, grantGroupId, grantGroupName,
+      t, pageContainer, isAclEnabled, grant, grantGroupId, grantGroupName,
     } = this.props;
 
     const isRootPage = pageContainer.state.path === '/';
@@ -102,7 +94,7 @@ class SavePageControls extends React.Component {
     return (
       <div className="d-flex align-items-center form-inline flex-nowrap">
 
-        {this.isAclEnabled
+        {isAclEnabled
           && (
             <div className="mr-2">
               <GrantSelector
@@ -135,20 +127,22 @@ class SavePageControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
 
 const SavePageControlsWrapper = (props) => {
   const { t } = useTranslation();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
+  const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
-  if (isEditable == null || editorMode == null) {
+  if (isEditable == null || editorMode == null || isAclEnabled == null) {
     return null;
   }
 
@@ -161,12 +155,14 @@ const SavePageControlsWrapper = (props) => {
       t={t}
       {...props}
       editorMode={editorMode}
+      isAclEnabled={isAclEnabled}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       mutateGrant={mutateGrant}
       mutateGrantGroupId={mutateGrantGroupId}
       mutateGrantGroupName={mutateGrantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       pageTags={pageTags}
     />
   );
@@ -175,21 +171,21 @@ const SavePageControlsWrapper = (props) => {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   pageTags: PropTypes.arrayOf(PropTypes.string),
+  isAclEnabled: PropTypes.bool.isRequired,
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
   mutateGrant: PropTypes.func,
   mutateGrantGroupId: PropTypes.func,
   mutateGrantGroupName: PropTypes.func,
+  mutateIsEnabledUnsavedWarning: PropTypes.func,
 };
 
 export default SavePageControlsWrapper;

+ 5 - 6
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -14,8 +14,8 @@ import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/in
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useDescendantsPageListForCurrentPathTermManager, usePageTreeTermManager } from '~/stores/page-listing';
+import { useSearchResultRenderer } from '~/stores/renderer';
 import { useFullTextSearchTermManager } from '~/stores/search';
 
 
@@ -120,8 +120,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
-  const growiRenderer = appContainer.getRenderer('searchresult');
-
+  const { data: growiRenderer } = useSearchResultRenderer();
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -194,8 +193,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     );
   }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
-  // return if page is null
-  if (page == null) return <></>;
+  // return if page or growiRenderer is null
+  if (page == null || growiRenderer == null) return <></>;
 
   return (
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">

+ 5 - 4
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -2,7 +2,9 @@ import React, {
   forwardRef,
   ForwardRefRenderFunction, useCallback, useImperativeHandle, useRef,
 } from 'react';
+
 import { useTranslation } from 'next-i18next';
+
 import { ISelectable, ISelectableAll } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
@@ -11,11 +13,10 @@ import {
 import { IPageSearchMeta } from '~/interfaces/search';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/stores/context';
-import { useSWRxPageInfoForList } from '~/stores/page';
-import { usePageTreeTermManager } from '~/stores/page-listing';
+import { useSWRxPageInfoForList, usePageTreeTermManager } from '~/stores/page-listing';
 import { useFullTextSearchTermManager } from '~/stores/search';
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { PageListItemL } from '../PageList/PageListItemL';
 
 
@@ -41,7 +42,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, true, true);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, null, true, true);
 
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();

+ 12 - 14
packages/app/src/components/Sidebar.tsx

@@ -2,6 +2,8 @@ import React, {
   useCallback, useEffect, useRef, useState,
 } from 'react';
 
+import dynamic from 'next/dynamic';
+
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import {
   useDrawerMode, useDrawerOpened,
@@ -14,9 +16,8 @@ import {
 
 import DrawerToggler from './Navbar/DrawerToggler';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
-import SidebarContents from './Sidebar/SidebarContents';
-import SidebarNav from './Sidebar/SidebarNav';
-import { StickyStretchableScroller } from './StickyStretchableScroller';
+import { SidebarNav } from './Sidebar/SidebarNav';
+import { StickyStretchableScrollerProps } from './StickyStretchableScroller';
 
 import styles from './Sidebar.module.scss';
 
@@ -25,7 +26,6 @@ const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
 const sidebarFixedWidthInDrawerMode = 320;
 
-
 const GlobalNavigation = () => {
   const { data: isDrawerMode } = useDrawerMode();
   const { data: currentContents } = useCurrentSidebarContents();
@@ -52,9 +52,13 @@ const GlobalNavigation = () => {
   }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed, scheduleToPut]);
 
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
+
 };
 
 const SidebarContentsWrapper = () => {
+  const StickyStretchableScroller = dynamic<StickyStretchableScrollerProps>(() => import('./StickyStretchableScroller')
+    .then(mod => mod.StickyStretchableScroller), { ssr: false });
+  const SidebarContents = dynamic(() => import('./Sidebar/SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {
@@ -83,7 +87,9 @@ const SidebarContentsWrapper = () => {
 
 
 const Sidebar = (): JSX.Element => {
-  const { data: isDrawerMode } = useDrawerMode();
+
+  // const { data: isDrawerMode } = useDrawerMode(); Todo Universalize
+  const isDrawerMode = false; // dummy
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
@@ -91,8 +97,6 @@ const Sidebar = (): JSX.Element => {
 
   const { scheduleToPut } = useUserUISettings();
 
-  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
-
   const [isHover, setHover] = useState(false);
   const [isHoverOnResizableContainer, setHoverOnResizableContainer] = useState(false);
   const [isDragging, setDrag] = useState(false);
@@ -236,12 +240,6 @@ const Sidebar = (): JSX.Element => {
 
   }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
 
-  useEffect(() => {
-    setTimeout(() => {
-      setTransitionEnabled(true);
-    }, 1000);
-  }, []);
-
   useEffect(() => {
     toggleDrawerMode(isDrawerMode);
   }, [isDrawerMode, toggleDrawerMode]);
@@ -295,7 +293,7 @@ const Sidebar = (): JSX.Element => {
         <div className={`d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'} ${isDrawerOpened ? 'open' : ''}`}>
           <div className="data-layout-container">
             <div
-              className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`}
+              className='navigation transition-enabled'
               onMouseEnter={hoverOnHandler}
               onMouseLeave={hoverOutHandler}
             >

+ 6 - 1
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -7,6 +7,7 @@ import { useSWRxPageByPath } from '~/stores/page';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import { IRevision } from '~/interfaces/revision';
+import { useCustomSidebarRenderer } from '~/stores/renderer';
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
@@ -29,10 +30,14 @@ const CustomSidebar: FC<Props> = (props: Props) => {
 
   const { appContainer } = props;
 
-  const renderer = appContainer.getRenderer('sidebar');
+  const { data: renderer } = useCustomSidebarRenderer();
 
   const { data: page, error, mutate } = useSWRxPageByPath('/Sidebar');
 
+  if (renderer == null) {
+    return <></>;
+  }
+
   const isLoading = page === undefined && error == null;
   const markdown = (page?.revision as IRevision | undefined)?.body;
 

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

@@ -3,7 +3,7 @@ import React, { FC, memo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import {
-  useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser, useNotFoundTargetPathOrId,
+  useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser,
 } from '~/stores/context';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
@@ -17,10 +17,9 @@ const PageTree: FC = memo(() => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
-  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
-  const targetPathOrId = targetId || notFoundTargetPathOrId;
+  const targetPathOrId = targetId || currentPath;
 
   if (migrationStatus == null) {
     return (

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

@@ -5,14 +5,16 @@ import React, {
 import nodePath from 'path';
 
 import { pathUtils, pagePathUtils } from '@growi/core';
-import { useDrag, useDrop } from 'react-dnd';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import { useDrag, useDrop } from 'react-dnd';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { Nullable } from '~/interfaces/common';
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
@@ -35,7 +37,7 @@ const logger = loggerFactory('growi:cli:Item');
 interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
-  targetPathOrId?: string
+  targetPathOrId?: Nullable<string>
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
   onRenamed?(): void
@@ -44,7 +46,7 @@ interface ItemProps {
 }
 
 // Utility to mark target
-const markTarget = (children: ItemNode[], targetPathOrId?: string): void => {
+const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
   if (targetPathOrId == null) {
     return;
   }
@@ -458,9 +460,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                 </>
               )}
 
-              <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-                <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-              </a>
+              <Link href={`/${page._id}`}>
+                <a className="grw-pagetree-title-anchor flex-grow-1">
+                  <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                </a>
+              </Link>
             </>
           )}
         {descendantCount > 0 && !isRenameInputShown && (

+ 141 - 0
packages/app/src/components/Sidebar/PageTree/ItemsTree.module.scss

@@ -0,0 +1,141 @@
+@use '~/styles/variables' as var;
+$grw-sidebar-content-header-height: 58px;
+$grw-sidebar-content-footer-height: 50px;
+$grw-pagetree-item-padding-left: 10px;
+
+.grw-pagetree {
+  :global {
+    min-height: calc(100vh - (var.$grw-navbar-height + var.$grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
+
+    .btn-page-item-control {
+      .icon-plus::before {
+        font-size: 18px;
+      }
+    }
+
+    .list-group-item {
+      .grw-visible-on-hover {
+        display: none;
+      }
+
+      &:hover {
+        .grw-visible-on-hover {
+          display: block;
+        }
+
+        .grw-count-badge {
+          display: none;
+        }
+      }
+
+      .grw-pagetree-triangle-btn {
+        background-color: transparent;
+        transition: all 0.2s ease-out;
+        transform: rotate(0deg);
+
+        &.grw-pagetree-open {
+          transform: rotate(90deg);
+        }
+      }
+
+      .grw-pagetree-title-anchor {
+        width: 100%;
+        overflow: hidden;
+        text-decoration: none;
+      }
+
+      .grw-pagetree-count-wrapper {
+        display: inline-block;
+
+        &:hover {
+          display: none;
+        }
+      }
+    }
+
+    .grw-pagetree-item-container {
+      .grw-triangle-container {
+        min-width: 35px;
+        height: 40px;
+      }
+    }
+  }
+  &:global{
+    // To realize a hierarchical structure, set multiplied padding-left to each pagetree-item
+    > .grw-pagetree-item-container {
+      > .list-group-item {
+        padding-left: 0;
+      }
+      > .grw-pagetree-item-children {
+        > .grw-pagetree-item-container {
+          > .list-group-item {
+            padding-left: $grw-pagetree-item-padding-left;
+          }
+          > .grw-pagetree-item-children {
+            > .grw-pagetree-item-container {
+              > .list-group-item {
+                padding-left: $grw-pagetree-item-padding-left * 2;
+              }
+              > .grw-pagetree-item-children {
+                > .grw-pagetree-item-container {
+                  > .list-group-item {
+                    padding-left: $grw-pagetree-item-padding-left * 3;
+                  }
+                  > .grw-pagetree-item-children {
+                    > .grw-pagetree-item-container {
+                      > .list-group-item {
+                        padding-left: $grw-pagetree-item-padding-left * 4;
+                      }
+                      > .grw-pagetree-item-children {
+                        > .grw-pagetree-item-container {
+                          > .list-group-item {
+                            padding-left: $grw-pagetree-item-padding-left * 5;
+                          }
+                          > .grw-pagetree-item-children {
+                            > .grw-pagetree-item-container {
+                              > .list-group-item {
+                                padding-left: $grw-pagetree-item-padding-left * 6;
+                              }
+                              > .grw-pagetree-item-children {
+                                > .grw-pagetree-item-container {
+                                  > .list-group-item {
+                                    padding-left: $grw-pagetree-item-padding-left * 7;
+                                  }
+                                  > .grw-pagetree-item-children {
+                                    > .grw-pagetree-item-container {
+                                      > .list-group-item {
+                                        padding-left: $grw-pagetree-item-padding-left * 8;
+                                      }
+                                      > .grw-pagetree-item-children {
+                                        > .grw-pagetree-item-container {
+                                          > .list-group-item {
+                                            padding-left: $grw-pagetree-item-padding-left * 9;
+                                          }
+                                          .grw-pagetree-item-children {
+                                            > .grw-pagetree-item-container {
+                                              > .list-group-item {
+                                                padding-left: $grw-pagetree-item-padding-left * 10;
+                                              }
+                                            }
+                                          }
+                                        }
+                                      }
+                                    }
+                                  }
+                                }
+                              }
+                            }
+                          }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

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