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

Merge branch 'master' into feat/gw7836-truncate-recent-changes-long-path-on-s-size

Shun Miyazawa 3 лет назад
Родитель
Сommit
3d445eb6b8
84 измененных файлов с 1776 добавлено и 928 удалено
  1. 1 1
      .devcontainer/devcontainer.json
  2. 6 5
      .github/workflows/release-slackbot-proxy.yml
  3. 4 1
      .github/workflows/reusable-app-prod.yml
  4. 12 14
      .vscode/settings.json
  5. 61 1
      CHANGELOG.md
  6. 1 1
      lerna.json
  7. 2 2
      package.json
  8. 1 0
      packages/app/config/ci/.env.local.for-ci
  9. 1 0
      packages/app/config/webpack.common.js
  10. 2 2
      packages/app/docker/README.md
  11. 11 10
      packages/app/package.json
  12. 7 5
      packages/app/public/static/locales/en_US/admin/admin.json
  13. 3 0
      packages/app/public/static/locales/en_US/translation.json
  14. 6 4
      packages/app/public/static/locales/ja_JP/admin/admin.json
  15. 3 0
      packages/app/public/static/locales/ja_JP/translation.json
  16. 7 4
      packages/app/public/static/locales/zh_CN/admin/admin.json
  17. 3 0
      packages/app/public/static/locales/zh_CN/translation.json
  18. 1 0
      packages/app/regconfig.json
  19. 23 0
      packages/app/src/client/services/page-operation.ts
  20. 22 2
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  21. 18 2
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  22. 28 21
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  23. 69 30
      packages/app/src/components/Admin/AuditLogManagement.tsx
  24. 20 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  25. 1 1
      packages/app/src/components/DescendantsPageList.tsx
  26. 16 0
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  27. 5 0
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  28. 71 5
      packages/app/src/components/Navbar/SubNavButtons.tsx
  29. 14 1
      packages/app/src/components/Page/RevisionRenderer.tsx
  30. 0 108
      packages/app/src/components/PageAccessoriesModalControl.jsx
  31. 6 5
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  32. 0 404
      packages/app/src/components/PageEditor/Editor.jsx
  33. 362 0
      packages/app/src/components/PageEditor/Editor.tsx
  34. 1 1
      packages/app/src/components/PageList/PageListItemL.tsx
  35. 6 1
      packages/app/src/components/PageStatusAlert.jsx
  36. 2 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  37. 0 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  38. 9 14
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  39. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  40. 2 2
      packages/app/src/components/Sidebar/RecentChanges.tsx
  41. 16 0
      packages/app/src/interfaces/activity.ts
  42. 17 0
      packages/app/src/interfaces/editor-methods.ts
  43. 15 4
      packages/app/src/interfaces/page-operation.ts
  44. 2 0
      packages/app/src/interfaces/page.ts
  45. 15 0
      packages/app/src/interfaces/subscription.ts
  46. 2 1
      packages/app/src/linter-checker/test.scss
  47. 2 1
      packages/app/src/server/crowi/index.js
  48. 4 12
      packages/app/src/server/models/activity.ts
  49. 4 1
      packages/app/src/server/models/obsolete-page.js
  50. 1 16
      packages/app/src/server/models/page-operation.ts
  51. 1 0
      packages/app/src/server/models/page.ts
  52. 19 21
      packages/app/src/server/models/subscription.ts
  53. 27 2
      packages/app/src/server/routes/apiv3/page.js
  54. 15 13
      packages/app/src/server/routes/apiv3/pages.js
  55. 2 2
      packages/app/src/server/routes/index.js
  56. 14 17
      packages/app/src/server/routes/page.js
  57. 12 6
      packages/app/src/server/service/activity.ts
  58. 22 11
      packages/app/src/server/service/in-app-notification.ts
  59. 15 5
      packages/app/src/server/service/page-operation.ts
  60. 215 33
      packages/app/src/server/service/page.ts
  61. 3 0
      packages/app/src/server/util/rate-limiter.ts
  62. 9 1
      packages/app/src/server/views/layout/layout.html
  63. 6 0
      packages/app/src/server/views/widget/headers/drawio.html
  64. 1 0
      packages/app/src/services/renderer/growi-renderer.ts
  65. 4 2
      packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts
  66. 6 0
      packages/app/src/styles/_admin.scss
  67. 5 0
      packages/app/src/utils/page-operation.ts
  68. 3 2
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  69. 3 0
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  70. 167 0
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  71. 4 4
      packages/app/test/cypress/integration/50-sidebar/switching-sidebar-mode.spec.ts
  72. 38 13
      packages/app/test/integration/service/page.test.js
  73. 63 21
      packages/app/test/integration/service/v5.non-public-page.test.ts
  74. 15 5
      packages/app/test/integration/service/v5.page.test.ts
  75. 128 37
      packages/app/test/integration/service/v5.public-page.test.ts
  76. 1 1
      packages/codemirror-textlint/package.json
  77. 1 1
      packages/core/package.json
  78. 1 1
      packages/plugin-attachment-refs/package.json
  79. 1 1
      packages/plugin-lsx/package.json
  80. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  81. 1 1
      packages/slack/package.json
  82. 2 2
      packages/slackbot-proxy/package.json
  83. 1 1
      packages/ui/package.json
  84. 83 28
      yarn.lock

+ 1 - 1
.devcontainer/devcontainer.json

@@ -25,7 +25,7 @@
     "editorconfig.editorconfig",
     "editorconfig.editorconfig",
     "esbenp.prettier-vscode",
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
     "shinnn.stylelint",
-    "hex-ci.stylelint-plus",
+    "stylelint.vscode-stylelint"
 	],
 	],
 
 
 	// Uncomment the next line if you want start specific services in your Docker Compose config.
 	// Uncomment the next line if you want start specific services in your Docker Compose config.

+ 6 - 5
.github/workflows/release-slackbot-proxy.yml

@@ -42,12 +42,13 @@ jobs:
         username: wsmoogle
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
 
-    - name: Setup gcloud
-      uses: google-github-actions/setup-gcloud@master
+    - name: Authenticate to Google Cloud for GROWI.cloud
+      uses: google-github-actions/auth@v0
       with:
       with:
-        project_id: ${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}
-        service_account_key: ${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}
-        export_default_credentials: true
+        credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
+
+    - name: Setup gcloud
+      uses: google-github-actions/setup-gcloud@v0
 
 
     - name: Configure docker for gcloud
     - name: Configure docker for gcloud
       run: |
       run: |

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

@@ -189,7 +189,7 @@ jobs:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '30', '40', '60']
+        spec-group: ['10', '20', '21', '30', '40', '50', '60']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -206,6 +206,9 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v3
     - uses: actions/checkout@v3
 
 
+    - name: Install fonts
+      run: sudo apt install fonts-noto
+
     - uses: actions/setup-node@v3
     - uses: actions/setup-node@v3
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}

+ 12 - 14
.vscode/settings.json

@@ -3,23 +3,21 @@
 
 
   "eslint.workingDirectories": [{ "mode": "auto" }],
   "eslint.workingDirectories": [{ "mode": "auto" }],
 
 
-  // use stylelint-plus
-  // see https://qiita.com/y-w/items/bd7f11013fe34b69f0df#vs-code%E3%81%A8%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B
+  // use vscode-stylelint
+  // see https://github.com/stylelint/vscode-stylelint
+  "stylelint.validate": ["css", "less", "scss"],
+  "stylelint.ignoreDisables": true,
   "css.validate": false,
   "css.validate": false,
+  "less.validate": false,
   "scss.validate": false,
   "scss.validate": false,
-  "[css]": {
-    "editor.formatOnSave": true
-  },
-  "[scss]": {
-    "editor.formatOnSave": true
-  },
 
 
-  // for vscode-eslint
-  "[javascript]": {
-    "editor.formatOnSave": false
-  },
   "editor.codeActionsOnSave": {
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": true,
     "source.fixAll.eslint": true,
-    "source.fixAll.markdownlint": true
-  }
+    "source.fixAll.markdownlint": true,
+    "source.fixAll.stylelint": true
+  },
+
+  "githubPullRequests.ignoredPullRequestBranches": [
+    "master"
+  ]
 }
 }

+ 61 - 1
CHANGELOG.md

@@ -1,9 +1,69 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.3...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v5.1.3](https://github.com/weseek/growi/compare/v5.1.2...v5.1.3) - 2022-08-28
+
+### 💎 Features
+
+- feat(auditlog): Copy URL of the table (#6421) @miya
+
+### 🚀 Improvement
+
+- imprv(auditlog): Activity paging UI (#6444) @miya
+- imprv: Improvement behavior when click on drawio diagram. (#6486) @kaishuu0123
+
+### 🐛 Bug Fixes
+
+- fix: Label of alert when updating tags (#6478) @miya
+- fix: Uploading image using shortcut key(ctrl+v) shows toastError (#6474) @Yohei-Shiina
+- fix: Pager is not displayed (#6468) @miya
+
+### 🧰 Maintenance
+
+- support: Use vscode-stylelint (#6430) @yuki-takei
+
+## [v5.1.2](https://github.com/weseek/growi/compare/v5.1.1...v5.1.2) - 2022-08-03
+
+### 💎 Features
+
+- feat: Make content width of each page configurable (#6107) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv(auditlog): Clear and reload button (#6398) @miya
+- imprv(auditlog): Date Range Picker  (#6395) @miya
+
+### 🐛 Bug Fixes
+
+- fix: MathJax rendering (#6396) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Make Editor component Functional Component and TypeScript (#6374) @yukendev
+
+## [v5.1.1](https://github.com/weseek/growi/compare/v5.1.0...v5.1.1) - 2022-08-01
+
+### 💎 Features
+
+- feat: Users can set users per ip from env var at API Rate Limit  (#6379) @yukendev
+- feat: Show user picture in Audit Log (#6342) @miya
+- feat: Reset search criteria button (#6327) @miya
+
+### 🚀 Improvement
+
+- imprv(auditlog): Display number of actions that can be saved (#6353) @miya
+- imprv(auditlog): Include delete-related actions in small group (#6351) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (v5.1.0) (#6378) @yuki-takei
+- fix: Recover page path operation (#6368) @hakumizuki
+- fix: Migration script for inserting NamedQuery (#6364) @yuki-takei
+- fix: "Error: cannnot get grant label" occured with lsx (#6348) @yukendev
+
 ## [v5.1.0](https://github.com/weseek/growi/compare/v5.0.11...v5.1.0) - 2022-07-21
 ## [v5.1.0](https://github.com/weseek/growi/compare/v5.0.11...v5.1.0) - 2022-07-21
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.4-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -75,7 +75,7 @@
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
-    "reg-suit": "^0.11.1",
+    "reg-suit": "^0.12.1",
     "rewire": "^5.0.0",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint": "^14.2.0",

+ 1 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
 FORMAT_NODE_LOG=true
+MATHJAX=1

+ 1 - 0
packages/app/config/webpack.common.js

@@ -76,6 +76,7 @@ module.exports = (options) => {
     },
     },
     node: {
     node: {
       fs: 'empty',
       fs: 'empty',
+      module: 'empty',
     },
     },
     module: {
     module: {
       rules: options.module.rules.concat([
       rules: options.module.rules.concat([

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.1.0`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
-* [`5.1.0-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
+* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
+* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/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`, `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)
 * [`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`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 11 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -59,16 +59,17 @@
   "dependencies": {
   "dependencies": {
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
-    "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.1-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.1-RC.0",
-    "@growi/plugin-lsx": "^5.1.1-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.1-RC.0",
-    "@growi/slack": "^5.1.1-RC.0",
+    "@growi/codemirror-textlint": "^5.1.4-RC.0",
+    "@growi/core": "^5.1.4-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.4-RC.0",
+    "@growi/plugin-lsx": "^5.1.4-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.4-RC.0",
+    "@growi/slack": "^5.1.4-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -82,7 +83,7 @@
     "axios": "^0.24.0",
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
     "body-parser": "^1.18.2",
-    "browser-bunyan": "^1.6.3",
+    "browser-bunyan": "^1.8.0",
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "check-node-version": "^4.1.0",
     "compression": "^1.7.4",
     "compression": "^1.7.4",
@@ -171,7 +172,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.1-RC.0",
+    "@growi/ui": "^5.1.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -207,7 +208,7 @@
     "lodash-webpack-plugin": "^0.11.5",
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
     "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.1.1",
     "markdown-it-blockdiag": "^1.1.1",
-    "markdown-it-drawio-viewer": "^1.3.1",
+    "markdown-it-drawio-viewer": "^1.4.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-emoji-mart": "^0.1.1",
     "markdown-it-emoji-mart": "^0.1.1",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-footnote": "^3.0.1",

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

@@ -422,7 +422,7 @@
     }
     }
   },
   },
   "slack_integration_legacy": {
   "slack_integration_legacy": {
-    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   },
   "user_management": {
   "user_management": {
@@ -537,15 +537,17 @@
     "url": "URL",
     "url": "URL",
     "settings": "Settings",
     "settings": "Settings",
     "return": "Return",
     "return": "Return",
-    "clear": "Clear search criteria",
-    "reload": "Reload",
+    "clear": "Clear",
     "activity_expiration_date": "Audit Log expiration date",
     "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",
     "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>.",
     "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": "Search / View All Available Actions",
-    "available_action_list_explain": "List of actions that can be search / view in the Audit Log",
+    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
     "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."
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
   },
   },
   "audit_log_action_category": {
   "audit_log_action_category": {
     "Page": "Page",
     "Page": "Page",

+ 3 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -164,6 +164,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
+  "wide_view": "Wide View",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
   "Page Tree": "Page Tree",
@@ -537,6 +538,8 @@
     "create_failed": "Failed to create {{target}}",
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_succeeded": "File upload succeeded.",
+    "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

+ 6 - 4
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -536,15 +536,17 @@
     "url": "URL",
     "url": "URL",
     "settings": "設定",
     "settings": "設定",
     "return": "戻る",
     "return": "戻る",
-    "clear": "検索条件のクリア",
-    "reload": "再読み込み",
+    "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
   },
   },
   "audit_log_action_category": {
   "audit_log_action_category": {
     "Page": "ページ",
     "Page": "ページ",

+ 3 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -166,6 +166,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
   "Page Tree": "ページツリー",
@@ -537,6 +538,8 @@
     "create_failed": "{{target}}の作成に失敗しました",
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_successed": "{{target}}を更新しました",
     "update_failed": "{{target}}の更新に失敗しました",
     "update_failed": "{{target}}の更新に失敗しました",
+    "file_upload_succeeded": "ファイルをアップロードしました",
+    "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

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

@@ -546,15 +546,18 @@
     "url": "URL",
     "url": "URL",
     "settings": "设置",
     "settings": "设置",
     "return": "返回",
     "return": "返回",
-    "clear": "清除搜索标准",
-    "reload": "重新加载",
+    "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+
   },
   },
   "audit_log_action_category": {
   "audit_log_action_category": {
     "Page": "页面",
     "Page": "页面",

+ 3 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -172,6 +172,7 @@
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
   "Page Tree": "页面树",
@@ -515,6 +516,8 @@
     "create_failed": "Failed to create {{target}}",
     "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_succeeded": "文件上传成功",
+    "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 1 - 0
packages/app/regconfig.json

@@ -13,6 +13,7 @@
     "reg-notify-github-plugin": {
     "reg-notify-github-plugin": {
       "prCommentBehavior": "new",
       "prCommentBehavior": "new",
       "setCommitStatus": false,
       "setCommitStatus": false,
+      "shortDescription": true,
       "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
       "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
     },
     },
     "reg-notify-slack-plugin": {
     "reg-notify-slack-plugin": {

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

@@ -37,6 +37,29 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
   }
 };
 };
 
 
+// Utility to update body class
+const updateBodyClassByView = (expandContentWidth: boolean): void => {
+  const bodyClasses = document.body.classList;
+  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
+
+  if (expandContentWidth && !isLayoutFluid) {
+    bodyClasses.add('growi-layout-fluid');
+  }
+  else if (isLayoutFluid) {
+    bodyClasses.remove('growi-layout-fluid');
+  }
+};
+
+export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+  try {
+    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
+    updateBodyClassByView(newValue);
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
 export const bookmark = async(pageId: string): Promise<void> => {
 export const bookmark = async(pageId: string): Promise<void> => {
   try {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });
     await apiv3Put('/bookmarks', { pageId, bool: true });

+ 22 - 2
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,9 +1,11 @@
-import React, { FC } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
 
 
 import { IActivityHasId } from '~/interfaces/activity';
 import { IActivityHasId } from '~/interfaces/activity';
 
 
@@ -17,6 +19,14 @@ const formatDate = (date) => {
 
 
 export const ActivityTable : FC<Props> = (props: Props) => {
 export const ActivityTable : FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const [tooltopOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, [setTooltipOpen]);
 
 
   return (
   return (
     <div className="table-responsive text-nowrap h-100">
     <div className="table-responsive text-nowrap h-100">
@@ -45,7 +55,17 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                 <td>{formatDate(activity.createdAt)}</td>
                 <td>{formatDate(activity.createdAt)}</td>
                 <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
                 <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
                 <td>{activity.ip}</td>
                 <td>{activity.ip}</td>
-                <td>{activity.endpoint}</td>
+                <td>
+                  {activity.endpoint}
+                  <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
+                    <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
+                      <i className="fa fa-clipboard" aria-hidden="true"></i>
+                    </button>
+                  </CopyToClipboard>
+                  <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">
+                    copied!
+                  </Tooltip>
+                </td>
               </tr>
               </tr>
             );
             );
           })}
           })}

+ 18 - 2
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -3,6 +3,7 @@ import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 import { Collapse } from 'reactstrap';
 
 
+import { AllSupportedActions } from '~/interfaces/activity';
 import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
 import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
 
 
 export const AuditLogSettings: FC = () => {
 export const AuditLogSettings: FC = () => {
@@ -34,8 +35,23 @@ export const AuditLogSettings: FC = () => {
         />
         />
       </p>
       </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>
+      <h4 className="mt-4">
+        {t('admin:audit_log_management.available_action_list')}
+        <span className="badge badge-pill badge-secondary ml-2">
+          {`${availableActions.length} / ${AllSupportedActions.length}`}
+        </span>
+        <a
+          className="ml-2"
+          href={t('admin:audit_log_management.docs_url.log_type')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <i className="icon-fw icon-question" aria-hidden="true"></i>
+        </a>
+      </h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.available_action_list_explain')}
+      </p>
       <p className="mt-1">
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
         <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>
           <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>

+ 28 - 21
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,33 +1,42 @@
-import React, {
-  FC, useRef, forwardRef, useCallback,
-} from 'react';
+import React, { FC, forwardRef, useCallback } from 'react';
 
 
+import { addDays, format } from 'date-fns';
 import DatePicker from 'react-datepicker';
 import DatePicker from 'react-datepicker';
 import 'react-datepicker/dist/react-datepicker.css';
 import 'react-datepicker/dist/react-datepicker.css';
 
 
-import { useTranslation } from 'react-i18next';
-
 
 
 type CustomInputProps = {
 type CustomInputProps = {
-  buttonRef: React.Ref<HTMLButtonElement>
-  onClick?: () => void;
+  value?: string
+  onChange?: () => void
+  onFocus?: () => void
 }
 }
 
 
-const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
-  const { t } = useTranslation();
+const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: CustomInputProps, ref) => {
+  const dateFormat = 'MM/dd/yyyy';
+  const date = new Date();
+  const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
+
   return (
   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>
+    <div className="input-group admin-audit-log">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="fa fa-fw fa-calendar" />
+        </span>
+      </div>
+      <input
+        ref={ref}
+        type="text"
+        value={props?.value}
+        onFocus={props?.onFocus}
+        onChange={props?.onChange}
+        placeholder={placeholder}
+        className="form-control date-range-picker"
+        aria-describedby="basic-addon1"
+      />
+    </div>
   );
   );
 });
 });
 
 
-
 type DateRangePickerProps = {
 type DateRangePickerProps = {
   startDate: Date | null
   startDate: Date | null
   endDate: Date | null
   endDate: Date | null
@@ -37,8 +46,6 @@ type DateRangePickerProps = {
 export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
 export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
   const { startDate, endDate, onChange } = props;
   const { startDate, endDate, onChange } = props;
 
 
-  const buttonRef = useRef(null);
-
   const changeHandler = useCallback((dateList: Date[] | null[]) => {
   const changeHandler = useCallback((dateList: Date[] | null[]) => {
     if (onChange != null) {
     if (onChange != null) {
       const [start, end] = dateList;
       const [start, end] = dateList;
@@ -59,7 +66,7 @@ export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePicker
         startDate={startDate}
         startDate={startDate}
         endDate={endDate}
         endDate={endDate}
         onChange={changeHandler}
         onChange={changeHandler}
-        customInput={<CustomInput buttonRef={buttonRef} />}
+        customInput={<CustomInput />}
       />
       />
     </div>
     </div>
   );
   );

+ 69 - 30
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -40,8 +40,9 @@ export const AuditLogManagement: FC = () => {
    * State
    * State
    */
    */
   const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
   const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
-  const [activePage, setActivePage] = useState<number>(1);
-  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [activePageNumber, setActivePageNumber] = useState<number>(1);
+  const [jumpPageNumber, setJumpPageNumber] = useState<number>(1);
+  const offset = (activePageNumber - 1) * PAGING_LIMIT;
   const [startDate, setStartDate] = useState<Date | null>(null);
   const [startDate, setStartDate] = useState<Date | null>(null);
   const [endDate, setEndDate] = useState<Date | null>(null);
   const [endDate, setEndDate] = useState<Date | null>(null);
   const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
   const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
@@ -59,6 +60,7 @@ export const AuditLogManagement: FC = () => {
   const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
   const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
   const activityList = activityData?.docs != null ? activityData.docs : [];
   const activityList = activityData?.docs != null ? activityData.docs : [];
   const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
   const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const totalPagingPages = activityData?.totalPages != null ? activityData.totalPages : 0;
   const isLoading = activityData === undefined && error == null;
   const isLoading = activityData === undefined && error == null;
 
 
   if (error != null) {
   if (error != null) {
@@ -71,34 +73,34 @@ export const AuditLogManagement: FC = () => {
    * Functions
    * Functions
    */
    */
   const setActivePageHandler = useCallback((selectedPageNum: number) => {
   const setActivePageHandler = useCallback((selectedPageNum: number) => {
-    setActivePage(selectedPageNum);
+    setActivePageNumber(selectedPageNum);
   }, []);
   }, []);
 
 
   const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
   const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setStartDate(dateList[0]);
     setStartDate(dateList[0]);
     setEndDate(dateList[1]);
     setEndDate(dateList[1]);
   }, []);
   }, []);
 
 
   const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
   const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     actionMap.set(action, !actionMap.get(action));
     actionMap.set(action, !actionMap.get(action));
     setActionMap(new Map(actionMap.entries()));
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
   }, [actionMap, setActionMap]);
 
 
   const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
   const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     actions.forEach(action => actionMap.set(action, isChecked));
     actions.forEach(action => actionMap.set(action, isChecked));
     setActionMap(new Map(actionMap.entries()));
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
   }, [actionMap, setActionMap]);
 
 
   const setUsernamesHandler = useCallback((usernames: string[]) => {
   const setUsernamesHandler = useCallback((usernames: string[]) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setSelectedUsernames(usernames);
     setSelectedUsernames(usernames);
   }, []);
   }, []);
 
 
   const clearButtonPushedHandler = useCallback(() => {
   const clearButtonPushedHandler = useCallback(() => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setStartDate(null);
     setStartDate(null);
     setEndDate(null);
     setEndDate(null);
     setSelectedUsernames([]);
     setSelectedUsernames([]);
@@ -107,15 +109,39 @@ export const AuditLogManagement: FC = () => {
     if (auditLogAvailableActionsData != null) {
     if (auditLogAvailableActionsData != null) {
       setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
       setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
     }
     }
-  }, [setActivePage, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
+  }, [setActivePageNumber, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
 
 
   const reloadButtonPushedHandler = useCallback(() => {
   const reloadButtonPushedHandler = useCallback(() => {
-    setActivePage(1);
+    setActivePageNumber(1);
     mutateActivity();
     mutateActivity();
   }, [mutateActivity]);
   }, [mutateActivity]);
 
 
+  const jumpPageInputChangeHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputNumber = Number(e.target.value);
+    const isNan = Number.isNaN(inputNumber);
+
+    if (!isNan) {
+      // eslint-disable-next-line no-nested-ternary
+      const jumpPageNumber = inputNumber > totalPagingPages ? totalPagingPages : inputNumber <= 0 ? activePageNumber : inputNumber;
+      setJumpPageNumber(jumpPageNumber);
+    }
+    else {
+      setJumpPageNumber(activePageNumber);
+    }
+  }, [totalPagingPages, activePageNumber, setJumpPageNumber]);
+
+  const jumpPageInputKeyDownHandler = useCallback((e) => {
+    if (e.key === 'Enter') {
+      setActivePageNumber(jumpPageNumber);
+    }
+  }, [setActivePageNumber, jumpPageNumber]);
+
+  const jumpPageButtonPushedHandler = useCallback(() => {
+    setActivePageNumber(jumpPageNumber);
+  }, [jumpPageNumber]);
+
   // eslint-disable-next-line max-len
   // 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/>`;
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePageNumber) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
 
 
   if (!auditLogEnabled) {
   if (!auditLogEnabled) {
     return <AuditLogDisableMode />;
     return <AuditLogDisableMode />;
@@ -135,6 +161,11 @@ export const AuditLogManagement: FC = () => {
         <span>
         <span>
           {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
           {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
         </span>
         </span>
+        { !isSettingPage && (
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+            <i className="icon icon-reload"></i>
+          </button>
+        )}
       </h2>
       </h2>
 
 
       {isSettingPage ? (
       {isSettingPage ? (
@@ -160,17 +191,9 @@ export const AuditLogManagement: FC = () => {
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
             />
             />
 
 
-            <div className="ml-auto">
-              <button type="button" className="btn btn-outline-secondary btn-sm mr-2" onClick={clearButtonPushedHandler}>
-                <span className="icon-refresh mr-1" />
-                {t('admin:audit_log_management.clear')}
-              </button>
-
-              <button type="button" className="btn btn-outline-secondary btn-sm" onClick={reloadButtonPushedHandler}>
-                <i className="icon icon-reload mr-1" />
-                {t('admin:audit_log_management.reload')}
-              </button>
-            </div>
+            <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              {t('admin:audit_log_management.clear')}
+            </button>
           </div>
           </div>
 
 
           <p
           <p
@@ -190,14 +213,30 @@ export const AuditLogManagement: FC = () => {
             )
             )
           }
           }
 
 
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={setActivePageHandler}
-            totalItemsCount={totalActivityNum}
-            pagingLimit={PAGING_LIMIT}
-            align="center"
-            size="sm"
-          />
+          <div className="d-flex flex-row justify-content-center">
+            <PaginationWrapper
+              activePage={activePageNumber}
+              changePage={setActivePageHandler}
+              totalItemsCount={totalActivityNum}
+              pagingLimit={PAGING_LIMIT}
+              align="center"
+              size="sm"
+            />
+
+            <div className="admin-audit-log ml-3">
+              <label htmlFor="jumpPageInput" className="mr-1 text-secondary">Jump To Page</label>
+              <input
+                id="jumpPageInput"
+                type="text"
+                className="jump-page-input"
+                onChange={jumpPageInputChangeHandler}
+                onKeyDown={jumpPageInputKeyDownHandler}
+              />
+              <button className="btn btn-sm" type="button" onClick={jumpPageButtonPushedHandler}>
+                <b>Go</b>
+              </button>
+            </div>
+          </div>
         </>
         </>
       )}
       )}
     </div>
     </div>

+ 20 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -11,6 +11,7 @@ import {
 import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
@@ -22,6 +23,7 @@ export const MenuItemType = {
   DELETE: 'delete',
   DELETE: 'delete',
   REVERT: 'revert',
   REVERT: 'revert',
   PATH_RECOVERY: 'pathRecovery',
   PATH_RECOVERY: 'pathRecovery',
+  SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
 
@@ -41,6 +43,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
 
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   isInstantRename?: boolean,
   alignRight?: boolean,
   alignRight?: boolean,
@@ -57,13 +60,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
   const {
   const {
-    pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
+    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+    isInstantRename, alignRight,
   } = props;
   } = props;
 
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
   const bookmarkItemClickedHandler = useCallback(async() => {
     if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
     if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
@@ -136,7 +140,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
     // PathRecovery
     // PathRecovery
     // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
     // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
 
 
     contents = (
     contents = (
       <>
       <>
@@ -148,8 +152,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
+        { AdditionalMenuItemsOnTop && (
+          <>
+            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+            <DropdownItem divider />
+          </>
+        ) }
+
         {/* Bookmark */}
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
@@ -251,9 +262,8 @@ type PageItemControlSubstanceProps = CommonProps & {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
 
   const {
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnInit,
-    children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId, pageInfo: presetPageInfo, fetchOnInit, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
+    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
   } = props;
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);

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

@@ -103,7 +103,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
     );
     );
   }
   }
 
 
-  const showPager = pagingResult.items.length > pagingResult.limit;
+  const showPager = pagingResult.totalCount > pagingResult.limit;
 
 
   return (
   return (
     <>
     <>

+ 16 - 0
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -119,6 +119,22 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'reverted';
       actionMsg = 'reverted';
       actionIcon = 'icon-action-undo';
       actionIcon = 'icon-action-undo';
       break;
       break;
+    case 'PAGE_RECURSIVELY_RENAME':
+      actionMsg = 'renamed under';
+      actionIcon = 'icon-action-redo';
+      break;
+    case 'PAGE_RECURSIVELY_DELETE':
+      actionMsg = 'deleted under';
+      actionIcon = 'icon-trash';
+      break;
+    case 'PAGE_RECURSIVELY_DELETE_COMPLETELY':
+      actionMsg = 'deleted completely under';
+      actionIcon = 'icon-fire';
+      break;
+    case 'PAGE_RECURSIVELY_REVERT':
+      actionMsg = 'reverted under';
+      actionIcon = 'icon-action-undo';
+      break;
     case 'COMMENT_CREATE':
     case 'COMMENT_CREATE':
       actionMsg = 'commented on';
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       actionIcon = 'icon-bubble';

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

@@ -15,6 +15,7 @@ import {
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import { IUser } from '~/interfaces/user';
 import {
 import {
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
   useCurrentCreatedAt, useCurrentUpdatedAt, useCurrentPageId, useRevisionId, useCurrentPagePath,
   useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useEmptyPageId, useTemplateTagData,
   useCreator, useRevisionAuthor, useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useEmptyPageId, useTemplateTagData,
@@ -211,9 +212,13 @@ const GrowiContextualSubNavigation = (props) => {
   const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
   const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
     try {
     try {
       const res: IResTagsUpdateApiv1 = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
       const res: IResTagsUpdateApiv1 = await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
+
       const updatedRevisionId = getIdForRef(res.savedPage.revision);
       const updatedRevisionId = getIdForRef(res.savedPage.revision);
       await pageContainer.setState({ revisionId: updatedRevisionId });
       await pageContainer.setState({ revisionId: updatedRevisionId });
 
 
+      const lastUpdateUser = res.savedPage?.lastUpdateUser as IUser;
+      await pageContainer.setState({ lastUpdateUser });
+
       // revalidate SWRTagsInfo
       // revalidate SWRTagsInfo
       mutateSWRTagsInfo();
       mutateSWRTagsInfo();
       mutatePageTagsForEditors(newTags);
       mutatePageTagsForEditors(newTags);

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

@@ -1,8 +1,14 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
+import { useTranslation } from 'react-i18next';
+import { DropdownItem } from 'reactstrap';
+
+import {
+  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+} from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
 import {
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -19,6 +25,43 @@ import SubscribeButton from '../SubscribeButton';
 import SeenUserInfo from '../User/SeenUserInfo';
 import SeenUserInfo from '../User/SeenUserInfo';
 
 
 
 
+type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
+  onClickMenuItem: (newValue: boolean) => void,
+}
+
+const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    pageInfo, onClickMenuItem,
+  } = props;
+
+  if (!isIPageInfoForEntity(pageInfo)) {
+    return <></>;
+  }
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      className="grw-page-control-dropdown-item"
+    >
+      <div className="custom-control custom-switch ml-1">
+        <input
+          id="switchContentWidth"
+          className="custom-control-input"
+          type="checkbox"
+          checked={pageInfo.expandContentWidth}
+          onChange={() => {}}
+        />
+        <label className="custom-control-label" htmlFor="switchContentWidth">
+          { t('wide_view') }
+        </label>
+      </div>
+    </DropdownItem>
+  );
+};
+
+
 type CommonProps = {
 type CommonProps = {
   isCompactMode?: boolean,
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
   disableSeenUserInfoPopover?: boolean,
@@ -35,7 +78,7 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   shareLinkId?: string | null,
   shareLinkId?: string | null,
   revisionId: string | null,
   revisionId: string | null,
   path?: string | null,
   path?: string | null,
-  pageInfo: IPageInfoAll,
+  pageInfo: IPageInfoForOperation,
 }
 }
 
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
@@ -140,11 +183,33 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     onClickDeleteMenuItem(pageToDelete);
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
 
+  const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return;
+    }
+    try {
+      await updateContentWidth(pageId, newValue);
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const wideviewMenuItemRenderer = useMemo(() => {
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return undefined;
+    }
+    return props => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+  }, [pageInfo, switchContentWidthClickHandler]);
+
   if (!isIPageInfoForOperation(pageInfo)) {
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
     return <></>;
   }
   }
 
 
-
   const {
   const {
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
   } = pageInfo;
@@ -192,6 +257,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           isEnableActions={!isGuestUser}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          additionalMenuItemOnTopRenderer={wideviewMenuItemRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 14 - 1
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useEffect, useMemo, useState,
   useCallback, useEffect, useMemo, useState,
 } from 'react';
 } from 'react';
 
 
+import AppContainer from '~/client/services/AppContainer';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
 import { CustomWindow } from '~/interfaces/global';
 import { CustomWindow } from '~/interfaces/global';
@@ -9,6 +10,8 @@ import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 import RevisionBody from './RevisionBody';
 import RevisionBody from './RevisionBody';
 
 
 
 
@@ -84,6 +87,7 @@ function getHighlightedBody(body: string, _keywords: string | string[]): string
 
 
 
 
 type Props = {
 type Props = {
+  appContainer: AppContainer,
   growiRenderer: GrowiRenderer,
   growiRenderer: GrowiRenderer,
   markdown: string,
   markdown: string,
   pagePath: string,
   pagePath: string,
@@ -154,9 +158,13 @@ const RevisionRenderer = (props: Props): JSX.Element => {
 
 
   }, [currentRenderingContext, interceptorManager, renderHtml]);
   }, [currentRenderingContext, interceptorManager, renderHtml]);
 
 
+  const config = props.appContainer.getConfig();
+  const isMathJaxEnabled = !!config.env.MATHJAX;
+
   return (
   return (
     <RevisionBody
     <RevisionBody
       html={html}
       html={html}
+      isMathJaxEnabled={isMathJaxEnabled}
       additionalClassName={props.additionalClassName}
       additionalClassName={props.additionalClassName}
       renderMathJaxOnInit
       renderMathJaxOnInit
     />
     />
@@ -164,4 +172,9 @@ const RevisionRenderer = (props: Props): JSX.Element => {
 
 
 };
 };
 
 
-export default RevisionRenderer;
+/**
+   * Wrapper component for using unstated
+   */
+const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer]);
+
+export default RevisionRendererWrapper;

+ 0 - 108
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -1,108 +0,0 @@
-import React, { Fragment, useMemo } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useCurrentPageId } from '~/stores/context';
-
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import PageListIcon from './Icons/PageListIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-const PageAccessoriesModalControl = (props) => {
-  const { t } = useTranslation();
-  const {
-    pageAccessoriesContainer, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-
-  const { data: pageId } = useCurrentPageId();
-
-  const accessoriesBtnList = useMemo(() => {
-    return [
-      {
-        name: 'pagelist',
-        Icon: <PageListIcon />,
-        disabled: isSharedUser,
-        i18n: t('page_list'),
-      },
-      {
-        name: 'timeline',
-        Icon: <TimeLineIcon />,
-        disabled: isSharedUser,
-        i18n: t('Timeline View'),
-      },
-      {
-        name: 'pageHistory',
-        Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser,
-        i18n: t('History'),
-      },
-      {
-        name: 'attachment',
-        Icon: <AttachmentIcon />,
-        i18n: t('attachment_data'),
-      },
-      {
-        name: 'shareLink',
-        Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
-        i18n: t('share_links.share_link_management'),
-      },
-    ];
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  return (
-    <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
-      {accessoriesBtnList.map((accessory) => {
-
-        let tooltipMessage;
-        if (accessory.disabled) {
-          tooltipMessage = t('Not available for guest');
-          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
-            tooltipMessage = t('Link sharing is disabled');
-          }
-        }
-        else {
-          tooltipMessage = accessory.i18n;
-        }
-
-        return (
-          <Fragment key={accessory.name}>
-            <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
-              <button
-                type="button"
-                className={`btn btn-link grw-btn-page-accessories ${accessory.disabled ? 'disabled' : ''}`}
-                onClick={() => pageAccessoriesContainer.openPageAccessoriesModal(accessory.name)}
-              >
-                {accessory.Icon}
-              </button>
-            </div>
-            <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-              {tooltipMessage}
-            </UncontrolledTooltip>
-          </Fragment>
-        );
-      })}
-    </div>
-  );
-};
-
-PageAccessoriesModalControl.propTypes = {
-  pageAccessoriesContainer: PropTypes.any,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
-
-export default PageAccessoriesModalControlWrapper;

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

@@ -25,7 +25,8 @@ import geu from './GridEditorUtil';
 import HandsontableModal from './HandsontableModal';
 import HandsontableModal from './HandsontableModal';
 import LinkEditModal from './LinkEditModal';
 import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import mdu from './MarkdownDrawioUtil';
-import mlu from './MarkdownLinkUtil';
+import markdownLinkUtil from './MarkdownLinkUtil';
+import markdownListUtil from './MarkdownListUtil';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
 import mtu from './MarkdownTableUtil';
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
@@ -521,7 +522,7 @@ class CodeMirrorEditor extends AbstractEditor {
     interceptorManager.process('preHandleEnter', context)
     interceptorManager.process('preHandleEnter', context)
       .then(() => {
       .then(() => {
         if (context.handlers.length === 0) {
         if (context.handlers.length === 0) {
-          codemirror.commands.newlineAndIndentContinueMarkdownList(this.getCodeMirror());
+          markdownListUtil.newlineAndIndentContinueMarkdownList(this);
         }
         }
       });
       });
   }
   }
@@ -548,7 +549,7 @@ class CodeMirrorEditor extends AbstractEditor {
     const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
     const hasLinkClass = additionalClassSet.has(MARKDOWN_LINK_ACTIVATED_CLASS);
 
 
     const isInTable = mtu.isInTable(editor);
     const isInTable = mtu.isInTable(editor);
-    const isInLink = mlu.isInLink(editor);
+    const isInLink = markdownLinkUtil.isInLink(editor);
 
 
     if (!hasCustomClass && isInTable) {
     if (!hasCustomClass && isInTable) {
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
       additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
@@ -794,7 +795,7 @@ class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   showLinkEditHandler() {
   showLinkEditHandler() {
-    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+    this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
   }
 
 
   showHandsonTableHandler() {
   showHandsonTableHandler() {
@@ -1058,7 +1059,7 @@ class CodeMirrorEditor extends AbstractEditor {
         />
         />
         <LinkEditModal
         <LinkEditModal
           ref={this.linkEditModal}
           ref={this.linkEditModal}
-          onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
+          onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         />
         <HandsontableModal
         <HandsontableModal
           ref={this.handsontableModal}
           ref={this.handsontableModal}

+ 0 - 404
packages/app/src/components/PageEditor/Editor.jsx

@@ -1,404 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import Dropzone from 'react-dropzone';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useDefaultIndentSize } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
-
-import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
-import CodeMirrorEditor from './CodeMirrorEditor';
-import pasteHelper from './PasteHelper';
-import TextAreaEditor from './TextAreaEditor';
-
-
-class Editor extends AbstractEditor {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isComponentDidMount: false,
-      dropzoneActive: false,
-      isUploading: false,
-      isCheatsheetModalShown: false,
-    };
-
-    this.getEditorSubstance = this.getEditorSubstance.bind(this);
-
-    this.pasteFilesHandler = this.pasteFilesHandler.bind(this);
-
-    this.dragEnterHandler = this.dragEnterHandler.bind(this);
-    this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
-    this.dropHandler = this.dropHandler.bind(this);
-
-    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
-    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
-
-    this.getAcceptableType = this.getAcceptableType.bind(this);
-    this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
-    this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
-  }
-
-  componentDidMount() {
-    this.setState({ isComponentDidMount: true });
-  }
-
-  getEditorSubstance() {
-    return this.props.isMobile
-      ? this.taEditor
-      : this.cmEditor;
-  }
-
-  /**
-   * @inheritDoc
-   */
-  forceToFocus() {
-    this.getEditorSubstance().forceToFocus();
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setValue(newValue) {
-    this.getEditorSubstance().setValue(newValue);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setGfmMode(bool) {
-    this.getEditorSubstance().setGfmMode(bool);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setCaretLine(line) {
-    this.getEditorSubstance().setCaretLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setScrollTopByLine(line) {
-    this.getEditorSubstance().setScrollTopByLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  insertText(text) {
-    this.getEditorSubstance().insertText(text);
-  }
-
-  /**
-   * remove overlay and set isUploading to false
-   */
-  terminateUploadingState() {
-    this.setState({
-      dropzoneActive: false,
-      isUploading: false,
-    });
-  }
-
-  /**
-   * dispatch onUpload event
-   */
-  dispatchUpload(files) {
-    if (this.props.onUpload != null) {
-      this.props.onUpload(files);
-    }
-  }
-
-  /**
-   * get acceptable(uploadable) file type
-   */
-  getAcceptableType() {
-    let accept = 'null'; // reject all
-    if (this.props.isUploadable) {
-      if (!this.props.isUploadableFile) {
-        accept = 'image/*'; // image only
-      }
-      else {
-        accept = ''; // allow all
-      }
-    }
-
-    return accept;
-  }
-
-  pasteFilesHandler(event) {
-    const items = event.clipboardData.items || event.clipboardData.files || [];
-
-    // abort if length is not 1
-    if (items.length < 1) {
-      return;
-    }
-
-    for (let i = 0; i < items.length; i++) {
-      try {
-        const file = items[i].getAsFile();
-        // check file type (the same process as Dropzone)
-        if (file != null && pasteHelper.isAcceptableType(file, this.getAcceptableType())) {
-          this.dispatchUpload(file);
-          this.setState({ isUploading: true });
-        }
-      }
-      catch (e) {
-        this.logger.error(e);
-      }
-    }
-  }
-
-  dragEnterHandler(event) {
-    const dataTransfer = event.dataTransfer;
-
-    // do nothing if contents is not files
-    if (!dataTransfer.types.includes('Files')) {
-      return;
-    }
-
-    this.setState({ dropzoneActive: true });
-  }
-
-  dragLeaveHandler() {
-    this.setState({ dropzoneActive: false });
-  }
-
-  dropHandler(accepted, rejected) {
-    // rejected
-    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
-      this.setState({ dropzoneActive: false });
-      return;
-    }
-
-    const file = accepted[0];
-    this.dispatchUpload(file);
-    this.setState({ isUploading: true });
-  }
-
-  showMarkdownHelp() {
-    this.setState({ isCheatsheetModalShown: true });
-  }
-
-  addAttachmentHandler() {
-    this.dropzone.open();
-  }
-
-  getDropzoneClassName(isDragAccept, isDragReject) {
-    let className = 'dropzone';
-    if (!this.props.isUploadable) {
-      className += ' dropzone-unuploadable';
-    }
-    else {
-      className += ' dropzone-uploadable';
-
-      if (this.props.isUploadableFile) {
-        className += ' dropzone-uploadablefile';
-      }
-    }
-
-    // uploading
-    if (this.state.isUploading) {
-      className += ' dropzone-uploading';
-    }
-
-    if (isDragAccept) {
-      className += ' dropzone-accepted';
-    }
-
-    if (isDragReject) {
-      className += ' dropzone-rejected';
-    }
-
-    return className;
-  }
-
-  renderDropzoneOverlay() {
-    return (
-      <div className="overlay overlay-dropzone-active">
-        {this.state.isUploading
-          && (
-            <span className="overlay-content">
-              <div className="speeding-wheel d-inline-block"></div>
-              <span className="sr-only">Uploading...</span>
-            </span>
-          )
-        }
-        {!this.state.isUploading && <span className="overlay-content"></span>}
-      </div>
-    );
-  }
-
-  renderNavbar() {
-    return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
-        <ul className="pl-2 nav nav-navbar">
-          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`navbarItem-${idx}`}>{item}</li>;
-          }) }
-        </ul>
-      </div>
-    );
-  }
-
-  getNavbarItems() {
-    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
-    const navbarItems = [];
-
-    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
-  }
-
-  renderCheatsheetModal() {
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
-
-    return (
-      <Modal isOpen={this.state.isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
-        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
-          <i className="icon-fw icon-question" />Markdown help
-        </ModalHeader>
-        <ModalBody>
-          <Cheatsheet />
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-
-  render() {
-    const flexContainer = {
-      height: '100%',
-      display: 'flex',
-      flexDirection: 'column',
-    };
-
-    const {
-      isMobile,
-      indentSize,
-    } = this.props;
-
-    return (
-      <>
-        <div style={flexContainer} className="editor-container">
-          <Dropzone
-            ref={(c) => { this.dropzone = c }}
-            accept={this.getAcceptableType()}
-            noClick
-            noKeyboard
-            multiple={false}
-            onDragLeave={this.dragLeaveHandler}
-            onDrop={this.dropHandler}
-          >
-            {({
-              getRootProps,
-              getInputProps,
-              isDragAccept,
-              isDragReject,
-            }) => {
-              return (
-                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                  { this.state.isComponentDidMount && this.renderNavbar() }
-
-                  {/* for PC */}
-                  { !isMobile && (
-                    // eslint-disable-next-line arrow-body-style
-                    <CodeMirrorEditor
-                      ref={(c) => { this.cmEditor = c }}
-                      indentSize={indentSize}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                      onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  {/* for mobile */}
-                  { isMobile && (
-                    <TextAreaEditor
-                      ref={(c) => { this.taEditor = c }}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  <input {...getInputProps()} />
-                </div>
-              );
-            }}
-          </Dropzone>
-
-          { this.props.isUploadable
-            && (
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-block btn-open-dropzone"
-                onClick={this.addAttachmentHandler}
-              >
-                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-                Attach files
-                <span className="d-none d-sm-inline">
-                &nbsp;by dragging &amp; dropping,&nbsp;
-                  <span className="btn-link">selecting them</span>,&nbsp;
-                  or pasting from the clipboard.
-                </span>
-
-              </button>
-            )
-          }
-
-          { this.renderCheatsheetModal() }
-
-        </div>
-      </>
-    );
-  }
-
-}
-
-Editor.propTypes = Object.assign({
-  noCdn: PropTypes.bool,
-  // this value is markdown
-  value: PropTypes.string,
-  isMobile: PropTypes.bool,
-  isUploadable: PropTypes.bool,
-  isUploadableFile: PropTypes.bool,
-  onChange: PropTypes.func,
-  onUpload: PropTypes.func,
-  editorSettings: PropTypes.object.isRequired,
-  indentSize: PropTypes.number,
-}, AbstractEditor.propTypes);
-
-
-const EditorWrapper = React.forwardRef((props, ref) => {
-  const { data: editorSettings } = useEditorSettings();
-  const { data: defaultIndentSize } = useDefaultIndentSize();
-
-  if (editorSettings == null) {
-    return <></>;
-  }
-
-  return (
-    <Editor
-      ref={ref}
-      {...props}
-      editorSettings={editorSettings}
-      // eslint-disable-next-line react/prop-types
-      indentSize={props.indentSize ?? defaultIndentSize}
-    />
-  );
-});
-
-export default EditorWrapper;

+ 362 - 0
packages/app/src/components/PageEditor/Editor.tsx

@@ -0,0 +1,362 @@
+import React, {
+  useState, useRef, useImperativeHandle, useCallback, useMemo,
+} from 'react';
+
+import Dropzone from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { useDefaultIndentSize } from '~/stores/context';
+import { useEditorSettings } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+import { IEditorMethods } from '../../interfaces/editor-methods';
+
+import Cheatsheet from './Cheatsheet';
+import CodeMirrorEditor from './CodeMirrorEditor';
+import pasteHelper from './PasteHelper';
+import TextAreaEditor from './TextAreaEditor';
+
+
+type EditorPropsType = {
+  value?: string,
+  isGfmMode?: boolean,
+  noCdn?: boolean,
+  isUploadable?: boolean,
+  isUploadableFile?: boolean,
+  onChange?: () => void,
+  onUpload?: (file) => void,
+  indentSize?: number,
+  onScrollCursorIntoView?: (line: number) => void,
+  onSave?: () => Promise<void>,
+  onPasteFiles?: (event: Event) => void,
+  onCtrlEnter?: (event: Event) => void,
+}
+
+type DropzoneRef = {
+  open: () => void
+}
+
+const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+  const {
+    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+  } = props;
+
+  const [dropzoneActive, setDropzoneActive] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [isCheatsheetModalShown, setIsCheatsheetModalShown] = useState(false);
+
+  const { t } = useTranslation();
+  const { data: editorSettings } = useEditorSettings();
+  const { data: defaultIndentSize } = useDefaultIndentSize();
+  const { data: isMobile } = useIsMobile();
+
+  const dropzoneRef = useRef<DropzoneRef>(null);
+  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  const taEditorRef = useRef<TextAreaEditor>(null);
+
+  const editorSubstance = isMobile ? taEditorRef.current : cmEditorRef.current;
+
+  const methods: Partial<IEditorMethods> = useMemo(() => {
+    return {
+      forceToFocus: () => {
+        if (editorSubstance == null) { return }
+        editorSubstance.forceToFocus();
+      },
+      setValue: (newValue: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setValue(newValue);
+      },
+      setGfmMode: (bool: boolean) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setGfmMode(bool);
+      },
+      setCaretLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setCaretLine(line);
+      },
+      setScrollTopByLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setScrollTopByLine(line);
+      },
+      insertText: (text: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.insertText(text);
+      },
+      getNavbarItems: (): JSX.Element[] => {
+        if (editorSubstance == null) { return [] }
+        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+        const navbarItems = editorSubstance.getNavbarItems() ?? [];
+        return navbarItems;
+      },
+    };
+  }, [editorSubstance]);
+
+  // methods for ref
+  useImperativeHandle(ref, () => ({
+    forceToFocus: methods.forceToFocus,
+    setValue: methods.setValue,
+    setGfmMode: methods.setGfmMode,
+    setCaretLine: methods.setCaretLine,
+    setScrollTopByLine: methods.setScrollTopByLine,
+    insertText: methods.insertText,
+    /**
+   * remove overlay and set isUploading to false
+   */
+    terminateUploadingState: () => {
+      setDropzoneActive(false);
+      setIsUploading(false);
+    },
+  }));
+
+  /**
+   * dispatch onUpload event
+   */
+  const dispatchUpload = useCallback((files) => {
+    if (onUpload != null) {
+      onUpload(files);
+    }
+  }, [onUpload]);
+
+  /**
+   * get acceptable(uploadable) file type
+   */
+  const getAcceptableType = useCallback(() => {
+    let accept = 'null'; // reject all
+    if (isUploadable) {
+      if (!isUploadableFile) {
+        accept = 'image/*'; // image only
+      }
+      else {
+        accept = ''; // allow all
+      }
+    }
+
+    return accept;
+  }, [isUploadable, isUploadableFile]);
+
+  const pasteFilesHandler = useCallback((event) => {
+    const items = event.clipboardData.items || event.clipboardData.files || [];
+
+    toastSuccess(t('toaster.file_upload_succeeded'));
+
+    // abort if length is not 1
+    if (items.length < 1) {
+      return;
+    }
+
+    for (let i = 0; i < items.length; i++) {
+      try {
+        const file = items[i].getAsFile();
+        // check file type (the same process as Dropzone)
+        if (file != null && pasteHelper.isAcceptableType(file, getAcceptableType())) {
+          dispatchUpload(file);
+          setIsUploading(true);
+        }
+      }
+      catch (e) {
+        toastError(t('toaster.file_upload_failed'));
+      }
+    }
+  }, [dispatchUpload, getAcceptableType, t]);
+
+  const dragEnterHandler = useCallback((event) => {
+    const dataTransfer = event.dataTransfer;
+
+    // do nothing if contents is not files
+    if (!dataTransfer.types.includes('Files')) {
+      return;
+    }
+
+    setDropzoneActive(true);
+  }, []);
+
+  const dropHandler = useCallback((accepted) => {
+    // rejected
+    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
+      setDropzoneActive(false);
+      return;
+    }
+
+    const file = accepted[0];
+    dispatchUpload(file);
+    setIsUploading(true);
+  }, [dispatchUpload]);
+
+  const addAttachmentHandler = useCallback(() => {
+    if (dropzoneRef.current == null) { return }
+    dropzoneRef.current.open();
+  }, []);
+
+  const getDropzoneClassName = useCallback((isDragAccept: boolean, isDragReject: boolean) => {
+    let className = 'dropzone';
+    if (!isUploadable) {
+      className += ' dropzone-unuploadable';
+    }
+    else {
+      className += ' dropzone-uploadable';
+
+      if (isUploadableFile) {
+        className += ' dropzone-uploadablefile';
+      }
+    }
+
+    // uploading
+    if (isUploading) {
+      className += ' dropzone-uploading';
+    }
+
+    if (isDragAccept) {
+      className += ' dropzone-accepted';
+    }
+
+    if (isDragReject) {
+      className += ' dropzone-rejected';
+    }
+
+    return className;
+  }, [isUploadable, isUploading, isUploadableFile]);
+
+  const renderDropzoneOverlay = useCallback(() => {
+    return (
+      <div className="overlay overlay-dropzone-active">
+        {isUploading
+          && (
+            <span className="overlay-content">
+              <div className="speeding-wheel d-inline-block"></div>
+              <span className="sr-only">Uploading...</span>
+            </span>
+          )
+        }
+        {!isUploading && <span className="overlay-content"></span>}
+      </div>
+    );
+  }, [isUploading]);
+
+  const renderNavbar = useCallback(() => {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pl-2 nav nav-navbar">
+          { methods.getNavbarItems?.().map((item, idx) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`navbarItem-${idx}`}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }, [methods]);
+
+  const renderCheatsheetModal = useCallback(() => {
+    const hideCheatsheetModal = () => {
+      setIsCheatsheetModalShown(false);
+    };
+
+    return (
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
+        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
+          <i className="icon-fw icon-question" />Markdown help
+        </ModalHeader>
+        <ModalBody>
+          <Cheatsheet />
+        </ModalBody>
+      </Modal>
+    );
+  }, [isCheatsheetModalShown]);
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  const flexContainer: React.CSSProperties = {
+    height: '100%',
+    display: 'flex',
+    flexDirection: 'column',
+  };
+
+  return (
+    <>
+      <div style={flexContainer} className="editor-container">
+        <Dropzone
+          ref={dropzoneRef}
+          accept={getAcceptableType()}
+          noClick
+          noKeyboard
+          multiple={false}
+          onDragLeave={() => { setDropzoneActive(false) }}
+          onDrop={dropHandler}
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            isDragAccept,
+            isDragReject,
+          }) => {
+            return (
+              <div className={getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                { dropzoneActive && renderDropzoneOverlay() }
+
+                { renderNavbar() }
+
+                {/* for PC */}
+                { !isMobile && (
+                  // eslint-disable-next-line arrow-body-style
+                  <CodeMirrorEditor
+                    ref={cmEditorRef}
+                    indentSize={indentSize ?? defaultIndentSize}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    onMarkdownHelpButtonClicked={() => { setIsCheatsheetModalShown(true) }}
+                    onAddAttachmentButtonClicked={addAttachmentHandler}
+                    editorSettings={editorSettings}
+                    isGfmMode={isGfmMode}
+                    {...props}
+                  />
+                )}
+
+                {/* for mobile */}
+                { isMobile && (
+                  <TextAreaEditor
+                    ref={taEditorRef}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    {...props}
+                  />
+                )}
+
+                <input {...getInputProps()} />
+              </div>
+            );
+          }}
+        </Dropzone>
+
+        { isUploadable
+          && (
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-block btn-open-dropzone"
+              onClick={addAttachmentHandler}
+            >
+              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              Attach files
+              <span className="d-none d-sm-inline">
+              &nbsp;by dragging &amp; dropping,&nbsp;
+                <span className="btn-link">selecting them</span>,&nbsp;
+                or pasting from the clipboard.
+              </span>
+
+            </button>
+          )
+        }
+
+        { renderCheatsheetModal() }
+
+      </div>
+    </>
+  );
+});
+
+
+export default Editor;

+ 1 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -99,7 +99,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
 
   useEffect(() => {
   useEffect(() => {
-    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+    if (isIPageInfoForEntity(pageInfo)) {
       // likerCount
       // likerCount
       setLikerCount(pageInfo.likerIds?.length ?? 0);
       setLikerCount(pageInfo.likerIds?.length ?? 0);
       // bookmarkCount
       // bookmarkCount

+ 6 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -1,10 +1,12 @@
 import React from 'react';
 import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import ReactDOMServer from 'react-dom/server';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
+import Username from '~/components/User/Username';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
@@ -82,9 +84,12 @@ class PageStatusAlert extends React.Component {
       isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
       isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
     }
     }
 
 
+    const usernameComponentToString = ReactDOMServer.renderToString(<Username user={pageContainer.state.lastUpdateUser} />);
+
     const label1 = isConflictOnEdit
     const label1 = isConflictOnEdit
       ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
       ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
-      : `${pageContainer.state.lastUpdateUsername} ${t('edited this page')}`;
+      // eslint-disable-next-line react/no-danger
+      : <span dangerouslySetInnerHTML={{ __html: `${usernameComponentToString} ${t('edited this page')}` }} />;
 
 
     return [
     return [
       ['bg-warning'],
       ['bg-warning'],

+ 2 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -21,7 +21,7 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 
 
 
 
 import AppContainer from '../../client/services/AppContainer';
 import AppContainer from '../../client/services/AppContainer';
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
@@ -176,6 +176,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       ? page.revision
       ? page.revision
       : page.revision._id;
       : page.revision._id;
 
 
+
     return (
     return (
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
         <SubNavButtons
         <SubNavButtons

+ 0 - 1
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -126,7 +126,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     advanceFts();
     advanceFts();
   };
   };
 
 
-
   return (
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {
       { (injectedPages ?? pages).map((page, i) => {

+ 9 - 14
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,13 +1,12 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import loggerFactory from '~/utils/logger';
+import { IRevision } from '~/interfaces/revision';
 import { useSWRxPageByPath } from '~/stores/page';
 import { useSWRxPageByPath } from '~/stores/page';
+import { useCustomSidebarRenderer } from '~/stores/renderer';
+import loggerFactory from '~/utils/logger';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import RevisionRenderer from '../Page/RevisionRenderer';
-import { IRevision } from '~/interfaces/revision';
-import { useCustomSidebarRenderer } from '~/stores/renderer';
 
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
 
@@ -26,9 +25,7 @@ type Props = {
   appContainer: AppContainer,
   appContainer: AppContainer,
 };
 };
 
 
-const CustomSidebar: FC<Props> = (props: Props) => {
-
-  const { appContainer } = props;
+const CustomSidebar: FC<Props> = () => {
 
 
   const { data: renderer } = useCustomSidebarRenderer();
   const { data: renderer } = useCustomSidebarRenderer();
 
 
@@ -41,6 +38,9 @@ const CustomSidebar: FC<Props> = (props: Props) => {
   const isLoading = page === undefined && error == null;
   const isLoading = page === undefined && error == null;
   const markdown = (page?.revision as IRevision | undefined)?.body;
   const markdown = (page?.revision as IRevision | undefined)?.body;
 
 
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const RevisionRendererAny: any = RevisionRenderer;
+
   return (
   return (
     <>
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
       <div className="grw-sidebar-content-header p-3 d-flex">
@@ -64,7 +64,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
       {
       {
         (!isLoading && markdown != null) && (
         (!isLoading && markdown != null) && (
           <div className="p-3">
           <div className="p-3">
-            <RevisionRenderer
+            <RevisionRendererAny
               growiRenderer={renderer}
               growiRenderer={renderer}
               markdown={markdown}
               markdown={markdown}
               pagePath="/Sidebar"
               pagePath="/Sidebar"
@@ -83,9 +83,4 @@ const CustomSidebar: FC<Props> = (props: Props) => {
   );
   );
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const CustomSidebarWrapper = withUnstatedContainers(CustomSidebar, [AppContainer]);
-
-export default CustomSidebarWrapper;
+export default CustomSidebar;

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

@@ -20,7 +20,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
 import CountBadge from '../../Common/CountBadge';
@@ -410,7 +410,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
   // Rename process
   // Rename process
   // Icon that draw attention from users for some actions
   // Icon that draw attention from users for some actions
-  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
 
   return (
   return (
     <div
     <div

+ 2 - 2
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -145,7 +145,7 @@ const RecentChanges = (): JSX.Element => {
   }, [retrieveSizePreferenceFromLocalStorage]);
   }, [retrieveSizePreferenceFromLocalStorage]);
 
 
   return (
   return (
-    <>
+    <div data-testid="grw-recent-changes">
       <div className="grw-sidebar-content-header p-3 d-flex">
       <div className="grw-sidebar-content-header p-3 d-flex">
         <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
         <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
         <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
         <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
@@ -180,7 +180,7 @@ const RecentChanges = (): JSX.Element => {
           </InfiniteScroll>
           </InfiniteScroll>
         </ul>
         </ul>
       </div>
       </div>
-    </>
+    </div>
   );
   );
 
 
 };
 };

+ 16 - 0
packages/app/src/interfaces/activity.ts

@@ -46,6 +46,10 @@ const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
 const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
+const ACTION_PAGE_RECURSIVELY_RENAME = 'PAGE_RECURSIVELY_RENAME';
+const ACTION_PAGE_RECURSIVELY_DELETE = 'PAGE_RECURSIVELY_DELETE';
+const ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY = 'PAGE_RECURSIVELY_DELETE_COMPLETELY';
+const ACTION_PAGE_RECURSIVELY_REVERT = 'PAGE_RECURSIVELY_REVERT';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
 const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
@@ -225,6 +229,10 @@ export const SupportedAction = {
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_EMPTY_TRASH,
   ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,
   ACTION_PAGE_EXPORT,
   ACTION_PAGE_EXPORT,
@@ -354,6 +362,10 @@ export const EssentialActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_CREATE,
 } as const;
 } as const;
 
 
@@ -405,6 +417,10 @@ export const MediumActionGroup = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_RECURSIVELY_RENAME,
+  ACTION_PAGE_RECURSIVELY_DELETE,
+  ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
+  ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_PAGE_EMPTY_TRASH,
   ACTION_PAGE_EMPTY_TRASH,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_SUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,
   ACTION_PAGE_UNSUBSCRIBE,

+ 17 - 0
packages/app/src/interfaces/editor-methods.ts

@@ -0,0 +1,17 @@
+export interface IEditorMethods {
+  forceToFocus: () => void,
+  setValue: (newValue: string) => void,
+  setGfmMode: (bool: boolean) => void,
+  setCaretLine: (line: number) => void,
+  setScrollTopByLine: (line: number) => void,
+  getStrFromBol(): void,
+  getStrToEol: () => void,
+  getStrFromBolToSelectedUpperPos: () => void,
+  replaceBolToCurrentPos: (text: string) => void,
+  replaceLine: (text: string) => void,
+  insertText: (text: string) => void,
+  insertLinebreak: () => void,
+  dispatchSave: () => void,
+  dispatchPasteFiles: (event: Event) => void,
+  getNavbarItems: () => JSX.Element[],
+}

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

@@ -6,10 +6,21 @@ export const PageActionType = {
   Revert: 'Revert',
   Revert: 'Revert',
   NormalizeParent: 'NormalizeParent',
   NormalizeParent: 'NormalizeParent',
 } as const;
 } as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
-export type IPageOperationProcessData = Partial<{
-  [key in PageActionType]: {isProcessable: boolean}
-}>
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+export type IPageOperationProcessData = {
+  [key in PageActionType]?: {
+    [PageActionStage.Main]?: { isProcessable: boolean },
+    [PageActionStage.Sub]?: { isProcessable: boolean },
+  }
+}
+
 export type IPageOperationProcessInfo = {
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
   [pageId: string]: IPageOperationProcessData,
 }
 }

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

@@ -29,6 +29,7 @@ export interface IPage {
   pageIdOnHackmd: string,
   pageIdOnHackmd: string,
   revisionHackmdSynced: Ref<IRevision>,
   revisionHackmdSynced: Ref<IRevision>,
   hasDraftOnHackmd: boolean,
   hasDraftOnHackmd: boolean,
+  expandContentWidth?: boolean,
   deleteUser: Ref<IUser>,
   deleteUser: Ref<IUser>,
   deletedAt: Date,
   deletedAt: Date,
 }
 }
@@ -61,6 +62,7 @@ export type IPageInfoForEntity = IPageInfo & {
   likerIds: string[],
   likerIds: string[],
   sumOfSeenUsers: number,
   sumOfSeenUsers: number,
   seenUserIds: string[],
   seenUserIds: string[],
+  expandContentWidth?: boolean,
 }
 }
 
 
 export type IPageInfoForOperation = IPageInfoForEntity & {
 export type IPageInfoForOperation = IPageInfoForEntity & {

+ 15 - 0
packages/app/src/interfaces/subscription.ts

@@ -1,6 +1,21 @@
+import { Ref } from './common';
+import { IPage } from './page';
+import { IUser } from './user';
+
 export const SubscriptionStatusType = {
 export const SubscriptionStatusType = {
   SUBSCRIBE: 'SUBSCRIBE',
   SUBSCRIBE: 'SUBSCRIBE',
   UNSUBSCRIBE: 'UNSUBSCRIBE',
   UNSUBSCRIBE: 'UNSUBSCRIBE',
 } as const;
 } as const;
 export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
 export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
 export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];
 export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];
+
+export interface ISubscription {
+  user: Ref<IUser>
+  targetModel: string
+  target: Ref<IPage>
+  status: string
+  createdAt: Date
+
+  isSubscribing(): boolean
+  isUnsubscribing(): boolean
+}

+ 2 - 1
packages/app/src/linter-checker/test.scss

@@ -1,7 +1,8 @@
 /*
 /*
  * VSCode の Stylelint 設定チェック方法
  * VSCode の Stylelint 設定チェック方法
  *
  *
- * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除
+ * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除し、
+ *    このファイルを VSCode 上で開き直す
  *
  *
  * 2. VSCode で以下のエラーが表示されていることを確認
  * 2. VSCode で以下のエラーが表示されていることを確認
  *   - color で stylelint(order/properties-order)
  *   - color で stylelint(order/properties-order)

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

@@ -10,13 +10,14 @@ import mongoose from 'mongoose';
 
 
 import pkg from '^/package.json';
 import pkg from '^/package.json';
 
 
+import { PageActionType } from '~/interfaces/page-operation';
 import CdnResourcesService from '~/services/cdn-resources-service';
 import CdnResourcesService from '~/services/cdn-resources-service';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import Activity from '../models/activity';
 import Activity from '../models/activity';
-import PageOperation, { PageActionType } from '../models/page-operation';
+import PageOperation from '../models/page-operation';
 import PageRedirect from '../models/page-redirect';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
 import UserGroup from '../models/user-group';

+ 4 - 12
packages/app/src/server/models/activity.ts

@@ -9,6 +9,8 @@ import {
   AllSupportedTargetModels, SupportedTargetModelType,
   AllSupportedTargetModels, SupportedTargetModelType,
   AllSupportedEventModels, SupportedEventModelType,
   AllSupportedEventModels, SupportedEventModelType,
 } from '~/interfaces/activity';
 } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
+import { IPage } from '~/interfaces/page';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 
 
@@ -94,18 +96,8 @@ activitySchema.post('save', function() {
 activitySchema.methods.getNotificationTargetUsers = async function() {
 activitySchema.methods.getNotificationTargetUsers = async function() {
   const User = getModelSafely('User') || require('~/server/models/user')();
   const User = getModelSafely('User') || require('~/server/models/user')();
   const { user: actionUser, target } = this;
   const { user: actionUser, target } = this;
-
-  const [subscribeUsers, unsubscribeUsers] = await Promise.all([
-    Subscription.getSubscription((target as any) as Types.ObjectId),
-    Subscription.getUnsubscription((target as any) as Types.ObjectId),
-  ]);
-
-  const unique = array => Object.values(array.reduce((objects, object) => ({ ...objects, [object.toString()]: object }), {}));
-  const filter = (array, pull) => {
-    const ids = pull.map(object => object.toString());
-    return array.filter(object => !ids.includes(object.toString()));
-  };
-  const notificationUsers = filter(unique([...subscribeUsers]), [...unsubscribeUsers, actionUser]);
+  const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
+  const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
   const activeNotificationUsers = await User.find({
   const activeNotificationUsers = await User.find({
     _id: { $in: notificationUsers },
     _id: { $in: notificationUsers },
     status: User.STATUS_ACTIVE,
     status: User.STATUS_ACTIVE,

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

@@ -683,6 +683,7 @@ export const getPageSchema = (crowi) => {
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const format = options.format || 'markdown';
     const grantUserGroupId = options.grantUserGroupId || null;
     const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
 
     // sanitize path
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -704,7 +705,9 @@ export const getPageSchema = (crowi) => {
     page.creator = user;
     page.creator = user;
     page.lastUpdateUser = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
     page.status = STATUS_PUBLISHED;
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     await validateAppliedScope(user, grant, grantUserGroupId);
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 
 

+ 1 - 16
packages/app/src/server/models/page-operation.ts

@@ -4,6 +4,7 @@ import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 } from 'mongoose';
 
 
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
 import {
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/models/interfaces/page-operation';
 } from '~/server/models/interfaces/page-operation';
@@ -18,22 +19,6 @@ const logger = loggerFactory('growi:models:page-operation');
 type IObjectId = mongoose.Types.ObjectId;
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
-export const PageActionType = {
-  Rename: 'Rename',
-  Duplicate: 'Duplicate',
-  Delete: 'Delete',
-  DeleteCompletely: 'DeleteCompletely',
-  Revert: 'Revert',
-  NormalizeParent: 'NormalizeParent',
-} as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
-
-export const PageActionStage = {
-  Main: 'Main',
-  Sub: 'Sub',
-} as const;
-export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
-
 /*
 /*
  * Main Schema
  * Main Schema
  */
  */

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

@@ -102,6 +102,7 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  expandContentWidth: { type: Boolean },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },
   deletedAt: { type: Date },

+ 19 - 21
packages/app/src/server/models/subscription.ts

@@ -1,31 +1,24 @@
+
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
 import {
 import {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
 import { AllSupportedTargetModels } from '~/interfaces/activity';
 import { AllSupportedTargetModels } from '~/interfaces/activity';
-import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
-
-
-export interface ISubscription {
-  user: Types.ObjectId
-  targetModel: string
-  target: Types.ObjectId
-  status: string
-  createdAt: Date
-
-  isSubscribing(): boolean
-  isUnsubscribing(): boolean
-}
+import { Ref } from '~/interfaces/common';
+import { IPage } from '~/interfaces/page';
+import { SubscriptionStatusType, AllSubscriptionStatusType, ISubscription } from '~/interfaces/subscription';
+import { IUser } from '~/interfaces/user';
 
 
 export interface SubscriptionDocument extends ISubscription, Document {}
 export interface SubscriptionDocument extends ISubscription, Document {}
 
 
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
 export interface SubscriptionModel extends Model<SubscriptionDocument> {
   findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
   findByUserIdAndTargetId(userId: Types.ObjectId | string, targetId: Types.ObjectId | string): any
-  upsertSubscription(user: Types.ObjectId, targetModel: string, target: Types.ObjectId, status: string): any
-  subscribeByPageId(user: Types.ObjectId, pageId: Types.ObjectId, status: string): any
-  getSubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
-  getUnsubscription(target: Types.ObjectId): Promise<Types.ObjectId[]>
+  upsertSubscription(user: Ref<IUser>, targetModel: string, target: Ref<IPage>, status: string): any
+  subscribeByPageId(userId: Types.ObjectId, pageId: Types.ObjectId, status: string): any
+  getSubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
+  getUnsubscription(target: Ref<IPage>): Promise<Ref<IUser>[]>
+  getSubscriptions(targets: Ref<IPage>[]): Promise<Ref<IUser>[]>
 }
 }
 
 
 const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
 const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
@@ -42,6 +35,7 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
+    ref: 'Page',
     refPath: 'targetModel',
     refPath: 'targetModel',
     required: true,
     required: true,
   },
   },
@@ -75,16 +69,20 @@ subscriptionSchema.statics.upsertSubscription = function(user, targetModel, targ
   return this.findOneAndUpdate(query, doc, options);
   return this.findOneAndUpdate(query, doc, options);
 };
 };
 
 
-subscriptionSchema.statics.subscribeByPageId = function(user, pageId, status) {
-  return this.upsertSubscription(user, 'Page', pageId, status);
+subscriptionSchema.statics.subscribeByPageId = function(userId, pageId, status) {
+  return this.upsertSubscription(userId, 'Page', pageId, status);
 };
 };
 
 
-subscriptionSchema.statics.getSubscription = async function(target) {
+subscriptionSchema.statics.getSubscription = async function(target: Ref<IPage>) {
   return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
   return this.find({ target, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
 };
 };
 
 
-subscriptionSchema.statics.getUnsubscription = async function(target) {
+subscriptionSchema.statics.getUnsubscription = async function(target: Ref<IPage>) {
   return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
   return this.find({ target, status: SubscriptionStatusType.UNSUBSCRIBE }).distinct('user');
 };
 };
 
 
+subscriptionSchema.statics.getSubscriptions = async function(targets: Ref<IPage>[]) {
+  return this.find({ target: { $in: targets }, status: SubscriptionStatusType.SUBSCRIBE }).distinct('user');
+};
+
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);
 export default getOrCreateModel<SubscriptionDocument, SubscriptionModel>('Subscription', subscriptionSchema);

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

@@ -18,7 +18,6 @@ const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
-
 /**
 /**
  * @swagger
  * @swagger
  *  tags:
  *  tags:
@@ -166,8 +165,9 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
+  const configManager = crowi.configManager;
+
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
   const { pageService, exportService } = crowi;
 
 
@@ -220,6 +220,9 @@ module.exports = (crowi) => {
     subscribeStatus: [
     subscribeStatus: [
       query('pageId').isString(),
       query('pageId').isString(),
     ],
     ],
+    contentWidth: [
+      body('expandContentWidth').isBoolean(),
+    ],
   };
   };
 
 
   /**
   /**
@@ -817,5 +820,27 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, csrf,
+    validator.contentWidth, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.params;
+      const { expandContentWidth } = req.body;
+
+      const isContainerFluidBySystem = configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+      try {
+        const updateQuery = expandContentWidth === isContainerFluidBySystem
+          ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
+          : { $set: { expandContentWidth } };
+
+        const page = await Page.updateOne({ _id: pageId }, updateQuery);
+        return res.apiv3({ page });
+      }
+      catch (err) {
+        logger.error('update-content-width-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   return router;
   return router;
 };
 };

+ 15 - 13
packages/app/src/server/routes/apiv3/pages.js

@@ -489,7 +489,7 @@ module.exports = (crowi) => {
    *          409:
    *          409:
    *            description: page path is already existed
    *            description: page path is already existed
    */
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, addActivity, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
     const { pageId, revisionId } = req.body;
 
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
@@ -501,6 +501,11 @@ module.exports = (crowi) => {
       isMoveMode: req.body.isMoveMode,
       isMoveMode: req.body.isMoveMode,
     };
     };
 
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     if (!isCreatablePage(newPagePath)) {
     if (!isCreatablePage(newPagePath)) {
       return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
       return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
     }
     }
@@ -519,6 +524,7 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       page = await Page.findByIdAndViewer(pageId, req.user, null, true);
       page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+      options.isRecursively = page.descendantCount > 0;
 
 
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
@@ -532,15 +538,13 @@ module.exports = (crowi) => {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
       if (!page.isEmpty && !page.isUpdatable(revisionId)) {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       }
-      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
+      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
     }
-
     const result = { page: serializePageSecurely(renamedPage ?? page) };
     const result = { page: serializePageSecurely(renamedPage ?? page) };
-
     try {
     try {
       // global notification
       // global notification
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
       await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
@@ -551,14 +555,6 @@ module.exports = (crowi) => {
       logger.error('Move notification failed', err);
       logger.error('Move notification failed', err);
     }
     }
 
 
-    const activityId = res.locals.activity._id;
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_RENAME,
-    };
-    activityEvent.emit('update', activityId, parameters, page);
-
     return res.apiv3(result);
     return res.apiv3(result);
   });
   });
 
 
@@ -575,8 +571,14 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, code), 403);
       return res.apiv3Err(new ErrorV3(msg, code), 403);
     }
     }
 
 
+    const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+    if (pageOp == null) {
+      const msg = 'PageOperation document for Rename Sub operation not found.';
+      const code = 'document_not_found';
+      return res.apiv3Err(new ErrorV3(msg, code), 404);
+    }
+
     try {
     try {
-      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
       await crowi.pageService.resumeRenameSubOperation(page, pageOp);
       await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     }
     catch (err) {
     catch (err) {

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

@@ -172,8 +172,8 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , csrf, addActivity, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , csrf, addActivity, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);

+ 14 - 17
packages/app/src/server/routes/page.js

@@ -1270,6 +1270,11 @@ module.exports = function(crowi, app) {
 
 
     const options = {};
     const options = {};
 
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
     const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
 
     if (page == null) {
     if (page == null) {
@@ -1293,7 +1298,7 @@ module.exports = function(crowi, app) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
         }
-        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
+        await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
       }
       }
       else {
       else {
         // behave like not found
         // behave like not found
@@ -1310,7 +1315,7 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
         }
 
 
-        await crowi.pageService.deletePage(page, req.user, options, isRecursively);
+        await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -1324,13 +1329,6 @@ module.exports = function(crowi, app) {
     result.isRecursively = isRecursively;
     result.isRecursively = isRecursively;
     result.isCompletely = isCompletely;
     result.isCompletely = isCompletely;
 
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: isCompletely ? SupportedAction.ACTION_PAGE_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
-
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
     try {
     try {
@@ -1362,13 +1360,19 @@ module.exports = function(crowi, app) {
     // get recursively flag
     // get recursively flag
     const isRecursively = req.body.recursively;
     const isRecursively = req.body.recursively;
 
 
+    const activityParameters = {
+      ip: req.ip,
+      endpoint: req.originalUrl,
+    };
+
     let page;
     let page;
+    let descendantPages;
     try {
     try {
       page = await Page.findByIdAndViewer(pageId, req.user);
       page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
       }
-      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively);
+      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively, activityParameters);
     }
     }
     catch (err) {
     catch (err) {
       if (err instanceof PathAlreadyExistsError) {
       if (err instanceof PathAlreadyExistsError) {
@@ -1382,13 +1386,6 @@ module.exports = function(crowi, app) {
     const result = {};
     const result = {};
     result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
     result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_REVERT,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
-
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 

+ 12 - 6
packages/app/src/server/service/activity.ts

@@ -4,11 +4,14 @@ import {
   IActivity, SupportedAction, SupportedActionType, AllSupportedActions, ActionGroupSize,
   IActivity, SupportedAction, SupportedActionType, AllSupportedActions, ActionGroupSize,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
 } from '~/interfaces/activity';
 } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
+import { IUser } from '~/interfaces/user';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
+import { PageDocument } from '../models/page';
 
 
 
 
 const logger = loggerFactory('growi:service:ActivityService');
 const logger = loggerFactory('growi:service:ActivityService');
@@ -39,7 +42,7 @@ class ActivityService {
   }
   }
 
 
   initActivityEventListeners(): void {
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage) => {
+    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
       let activity: IActivity;
       let activity: IActivity;
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
 
 
@@ -52,7 +55,7 @@ class ActivityService {
           return;
           return;
         }
         }
 
 
-        this.activityEvent.emit('updated', activity, target);
+        this.activityEvent.emit('updated', activity, target, descendantsSubscribedUsers);
       }
       }
     });
     });
   }
   }
@@ -96,23 +99,26 @@ class ActivityService {
     }
     }
 
 
     return Array.from(availableActionsSet);
     return Array.from(availableActionsSet);
-  }
+  };
 
 
   shoudUpdateActivity = function(action: SupportedActionType): boolean {
   shoudUpdateActivity = function(action: SupportedActionType): boolean {
     return this.getAvailableActions().includes(action);
     return this.getAvailableActions().includes(action);
-  }
+  };
 
 
   // for GET request
   // for GET request
-  createActivity = async function(parameters): Promise<void> {
+  createActivity = async function(parameters): Promise<IActivity | null> {
     const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
     const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
     if (shoudCreateActivity) {
     if (shoudCreateActivity) {
+      let activity: IActivity;
       try {
       try {
-        await Activity.createByParameters(parameters);
+        activity = await Activity.createByParameters(parameters);
+        return activity;
       }
       }
       catch (err) {
       catch (err) {
         logger.error('Create activity failed', err);
         logger.error('Create activity failed', err);
       }
       }
     }
     }
+    return null;
   };
   };
 
 
   createTtlIndex = async function() {
   createTtlIndex = async function() {

+ 22 - 11
packages/app/src/server/service/in-app-notification.ts

@@ -2,6 +2,7 @@ import { subDays } from 'date-fns';
 import { Types } from 'mongoose';
 import { Types } from 'mongoose';
 
 
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
@@ -18,6 +19,7 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import Crowi from '../crowi';
 import Crowi from '../crowi';
+import { PageDocument } from '../models/page';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
 
 
@@ -51,11 +53,11 @@ export default class InAppNotificationService {
   }
   }
 
 
   initActivityEventListeners(): void {
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage) => {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
       try {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target);
+          await this.createInAppNotification(activity, target, descendantsSubscribedUsers);
         }
         }
       }
       }
       catch (err) {
       catch (err) {
@@ -74,7 +76,7 @@ export default class InAppNotificationService {
           .emit('notificationUpdated');
           .emit('notificationUpdated');
       });
       });
     }
     }
-  }
+  };
 
 
   upsertByActivity = async function(
   upsertByActivity = async function(
       users: Types.ObjectId[], activity: ActivityDocument, snapshot: string, createdAt?: Date | null,
       users: Types.ObjectId[], activity: ActivityDocument, snapshot: string, createdAt?: Date | null,
@@ -110,7 +112,7 @@ export default class InAppNotificationService {
     await InAppNotification.bulkWrite(operations);
     await InAppNotification.bulkWrite(operations);
     logger.info('InAppNotification bulkWrite has run');
     logger.info('InAppNotification bulkWrite has run');
     return;
     return;
-  }
+  };
 
 
   getLatestNotificationsByUser = async(
   getLatestNotificationsByUser = async(
       userId: Types.ObjectId,
       userId: Types.ObjectId,
@@ -145,7 +147,7 @@ export default class InAppNotificationService {
       logger.error('Error', err);
       logger.error('Error', err);
       throw new Error(err);
       throw new Error(err);
     }
     }
-  }
+  };
 
 
   read = async function(user: Types.ObjectId): Promise<void> {
   read = async function(user: Types.ObjectId): Promise<void> {
     const query = { user, status: STATUS_UNREAD };
     const query = { user, status: STATUS_UNREAD };
@@ -162,7 +164,7 @@ export default class InAppNotificationService {
 
 
     await InAppNotification.findOneAndUpdate(query, parameters, options);
     await InAppNotification.findOneAndUpdate(query, parameters, options);
     return;
     return;
-  }
+  };
 
 
   updateAllNotificationsAsOpened = async function(user: IUser & HasObjectId): Promise<void> {
   updateAllNotificationsAsOpened = async function(user: IUser & HasObjectId): Promise<void> {
     const filter = { user: user._id, status: STATUS_UNOPENED };
     const filter = { user: user._id, status: STATUS_UNOPENED };
@@ -170,7 +172,7 @@ export default class InAppNotificationService {
 
 
     await InAppNotification.updateMany(filter, options);
     await InAppNotification.updateMany(filter, options);
     return;
     return;
-  }
+  };
 
 
   getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
   getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
     const query = { user, status: STATUS_UNREAD };
     const query = { user, status: STATUS_UNREAD };
@@ -199,17 +201,26 @@ export default class InAppNotificationService {
     return;
     return;
   };
   };
 
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IPage): Promise<void> {
+  createInAppNotification = async function(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]): Promise<void> {
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+    const snapshot = stringifySnapshot(target);
     if (shouldNotification) {
     if (shouldNotification) {
       let mentionedUsers: IUser[] = [];
       let mentionedUsers: IUser[] = [];
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
         mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
         mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
       }
       }
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
-      const snapshot = stringifySnapshot(target as IPage);
-      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers], activity, snapshot);
-      await this.emitSocketIo(notificationTargetUsers);
+      let notificationDescendantsUsers = [];
+      if (descendantsSubscribedUsers != null) {
+        const User = this.crowi.model('User');
+        const descendantsUsers = descendantsSubscribedUsers.filter(item => (item.toString() !== activity.user._id.toString()));
+        notificationDescendantsUsers = await User.find({
+          _id: { $in: descendantsUsers },
+          status: User.STATUS_ACTIVE,
+        }).distinct('_id');
+      }
+      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers, ...notificationDescendantsUsers], activity, snapshot);
+      await this.emitSocketIo([...notificationTargetUsers, notificationDescendantsUsers]);
     }
     }
     else {
     else {
       throw Error('No activity to notify');
       throw Error('No activity to notify');

+ 15 - 5
packages/app/src/server/service/page-operation.ts

@@ -1,7 +1,9 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import PageOperation, { PageActionType, PageActionStage, PageOperationDocument } from '~/server/models/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionType, PageActionStage,
+} from '~/interfaces/page-operation';
+import PageOperation, { PageOperationDocument } from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -26,9 +28,10 @@ class PageOperationService {
   }
   }
 
 
   async init(): Promise<void> {
   async init(): Promise<void> {
-    // cleanup PageOperation documents except ones with actionType: Rename
+    // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
     await PageOperation.deleteByActionTypes(types);
+    await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }
   }
 
 
   /**
   /**
@@ -137,12 +140,19 @@ class PageOperationService {
       const isProcessable = pageOp.isProcessable();
       const isProcessable = pageOp.isProcessable();
 
 
       // processData for processInfo
       // processData for processInfo
-      const processData: IPageOperationProcessData = { [actionType]: { isProcessable } };
+      const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
+      const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
+      const processData: IPageOperationProcessData = {
+        [actionType]: {
+          [PageActionStage.Main]: mainProcessableInfo,
+          [PageActionStage.Sub]: subProcessableInfo,
+        },
+      };
 
 
       // Merge processData if other processData exist
       // Merge processData if other processData exist
       if (processInfo[pageId] != null) {
       if (processInfo[pageId] != null) {
         const otherProcessData = processInfo[pageId];
         const otherProcessData = processInfo[pageId];
-        processInfo[pageId] = { ...otherProcessData, ...processData };
+        processInfo[pageId] = Object.assign(otherProcessData, processData);
         return;
         return;
       }
       }
       // add new process data to processInfo
       // add new process data to processInfo

+ 215 - 33
packages/app/src/server/service/page.ts

@@ -6,6 +6,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
 import { Ref } from '~/interfaces/common';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
@@ -15,8 +16,10 @@ import {
 import {
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import { IUserHasId } from '~/interfaces/user';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionStage, PageActionType,
+} from '~/interfaces/page-operation';
+import { IUser, IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
   CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
@@ -27,7 +30,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
+import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
 import Subscription from '../models/subscription';
@@ -135,10 +138,13 @@ class PageService {
 
 
   tagEvent: any;
   tagEvent: any;
 
 
+  activityEvent: any;
+
   constructor(crowi) {
   constructor(crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
     this.pageEvent = crowi.event('page');
     this.tagEvent = crowi.event('tag');
     this.tagEvent = crowi.event('tag');
+    this.activityEvent = crowi.event('activity');
 
 
     // init
     // init
     this.initPageEvent();
     this.initPageEvent();
@@ -346,12 +352,26 @@ class PageService {
       .cursor({ batchSize: BULK_REINDEX_SIZE });
       .cursor({ batchSize: BULK_REINDEX_SIZE });
   }
   }
 
 
-  async renamePage(page, newPagePath, user, options) {
+  async renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null> {
     /*
     /*
      * Common Operation
      * Common Operation
      */
      */
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_RENAME : SupportedAction.ACTION_PAGE_RENAME,
+      user,
+      targetModel: 'Page',
+      target: page,
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     const isExist = await Page.exists({ path: newPagePath });
     const isExist = await Page.exists({ path: newPagePath });
     if (isExist) {
     if (isExist) {
       throw Error(`Page already exists at ${newPagePath}`);
       throw Error(`Page already exists at ${newPagePath}`);
@@ -401,12 +421,25 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       logger.error('Failed to create PageOperation document.', err);
       throw err;
       throw err;
     }
     }
-    const renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+    let renamedPage: PageDocument | null = null;
+    try {
+      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id, activity);
+    }
+    catch (err) {
+      logger.error('Error occurred while running renameMainOperation', err);
 
 
+      // cleanup
+      await PageOperation.deleteOne({ _id: pageOp._id });
+
+      throw err;
+    }
+    if (page.descendantCount < 1) {
+      this.activityEvent.emit('updated', activity, page);
+    }
     return renamedPage;
     return renamedPage;
   }
   }
 
 
-  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike) {
+  async renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const updateMetadata = options.updateMetadata || false;
     const updateMetadata = options.updateMetadata || false;
@@ -491,12 +524,12 @@ class PageService {
     /*
     /*
      * Sub Operation
      * Sub Operation
      */
      */
-    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id);
+    this.renameSubOperation(page, newPagePath, user, options, renamedPage, pageOp._id, activity);
 
 
     return renamedPage;
     return renamedPage;
   }
   }
 
 
-  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike): Promise<void> {
+  async renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const exParentId = page.parent;
     const exParentId = page.parent;
@@ -504,7 +537,10 @@ class PageService {
     const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
     const timerObj = this.crowi.pageOperationService.autoUpdateExpiryDate(pageOpId);
     try {
     try {
     // update descendants first
     // update descendants first
-      await this.renameDescendantsWithStream(page, newPagePath, user, options, false);
+      const descendantsSubscribedSets = new Set();
+      await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
+      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+      this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
     }
     }
     catch (err) {
     catch (err) {
       logger.warn(err);
       logger.warn(err);
@@ -535,7 +571,7 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
-  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
       throw Error('This page operation is currently being processed');
@@ -548,7 +584,7 @@ class PageService {
       page, fromPath, toPath, options, user,
       page, fromPath, toPath, options, user,
     } = pageOp;
     } = pageOp;
 
 
-    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath, activity);
   }
   }
 
 
   /**
   /**
@@ -556,8 +592,8 @@ class PageService {
    * `renameSubOperation` to restart rename operation
    * `renameSubOperation` to restart rename operation
    * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
    * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
    */
    */
-  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
-    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath, activity?): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId, activity);
     const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
     const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
     await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
     await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
   }
@@ -792,7 +828,7 @@ class PageService {
     this.pageEvent.emit('updateMany', pages, user);
     this.pageEvent.emit('updateMany', pages, user);
   }
   }
 
 
-  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true) {
+  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?) {
     // v4 compatible process
     // v4 compatible process
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
       return this.renameDescendantsWithStreamV4(targetPage, newPagePath, user, options);
@@ -815,6 +851,10 @@ class PageService {
           await renameDescendants(
           await renameDescendants(
             batch, user, options, pathRegExp, newPagePathPrefix, shouldUseV4Process,
             batch, user, options, pathRegExp, newPagePathPrefix, shouldUseV4Process,
           );
           );
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Renaming pages progressing: (count=${count})`);
           logger.debug(`Renaming pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -996,7 +1036,20 @@ class PageService {
         logger.error('Failed to create PageOperation document.', err);
         logger.error('Failed to create PageOperation document.', err);
         throw err;
         throw err;
       }
       }
-      this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+
+      (async() => {
+        try {
+          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
     }
 
 
     const result = serializePageSecurely(duplicatedTarget);
     const result = serializePageSecurely(duplicatedTarget);
@@ -1308,7 +1361,7 @@ class PageService {
   /*
   /*
    * Delete
    * Delete
    */
    */
-  async deletePage(page, user, options = {}, isRecursively = false) {
+  async deletePage(page, user, options = {}, isRecursively = false, activityParameters?) {
     /*
     /*
      * Common Operation
      * Common Operation
      */
      */
@@ -1346,6 +1399,20 @@ class PageService {
       await Page.replaceTargetWithPage(page, null, true);
       await Page.replaceTargetWithPage(page, null, true);
     }
     }
 
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE : SupportedAction.ACTION_PAGE_DELETE,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // Delete target (only updating an existing document's properties )
     // Delete target (only updating an existing document's properties )
     let deletedPage;
     let deletedPage;
     if (!page.isEmpty) {
     if (!page.isEmpty) {
@@ -1387,7 +1454,22 @@ class PageService {
       /*
       /*
        * Resumable Operation
        * Resumable Operation
        */
        */
-      this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteRecursivelyMainOperation(page, user, pageOp._id, activity);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
+    }
+    else {
+      this.activityEvent.emit('updated', activity, page);
     }
     }
 
 
     return deletedPage;
     return deletedPage;
@@ -1420,8 +1502,12 @@ class PageService {
     return deletedPage;
     return deletedPage;
   }
   }
 
 
-  async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
-    await this.deleteDescendantsWithStream(page, user, false);
+  async deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void> {
+    const descendantsSubscribedSets = new Set();
+    await this.deleteDescendantsWithStream(page, user, false, descendantsSubscribedSets);
+
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
 
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
 
 
@@ -1543,7 +1629,7 @@ class PageService {
   /**
   /**
    * Create delete stream and return deleted document count
    * Create delete stream and return deleted document count
    */
    */
-  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true): Promise<number> {
+  private async deleteDescendantsWithStream(targetPage, user, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     let readStream;
     let readStream;
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
       readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
@@ -1566,6 +1652,10 @@ class PageService {
         try {
         try {
           count += batch.length;
           count += batch.length;
           await deleteDescendants(batch, user);
           await deleteDescendants(batch, user);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Deleting pages progressing: (count=${count})`);
           logger.debug(`Deleting pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -1630,7 +1720,7 @@ class PageService {
     return;
     return;
   }
   }
 
 
-  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false) {
+  async deleteCompletely(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) {
     /*
     /*
      * Common Operation
      * Common Operation
      */
      */
@@ -1660,6 +1750,20 @@ class PageService {
 
 
     logger.debug('Deleting completely', paths);
     logger.debug('Deleting completely', paths);
 
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE_COMPLETELY,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // 1. update descendantCount
     // 1. update descendantCount
     if (isRecursively) {
     if (isRecursively) {
       const inc = page.isEmpty ? -page.descendantCount : -(page.descendantCount + 1);
       const inc = page.isEmpty ? -page.descendantCount : -(page.descendantCount + 1);
@@ -1703,14 +1807,32 @@ class PageService {
       /*
       /*
        * Main Operation
        * Main Operation
        */
        */
-      this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id, activity);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteCompletelyRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
+    }
+    else {
+      this.activityEvent.emit('updated', activity, page);
     }
     }
 
 
     return;
     return;
   }
   }
 
 
-  async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
-    await this.deleteCompletelyDescendantsWithStream(page, user, options, false);
+  async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
+    const descendantsSubscribedSets = new Set();
+    await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
 
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
 
 
@@ -1743,7 +1865,7 @@ class PageService {
   /**
   /**
    * Create delete completely stream
    * Create delete completely stream
    */
    */
-  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     let readStream;
     let readStream;
 
 
     if (shouldUseV4Process) { // pages don't have parents
     if (shouldUseV4Process) { // pages don't have parents
@@ -1766,6 +1888,10 @@ class PageService {
         try {
         try {
           count += batch.length;
           count += batch.length;
           await deleteMultipleCompletely(batch, user, options);
           await deleteMultipleCompletely(batch, user, options);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Adding pages progressing: (count=${count})`);
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -1849,13 +1975,27 @@ class PageService {
     }
     }
   }
   }
 
 
-  async revertDeletedPage(page, user, options = {}, isRecursively = false) {
+  async revertDeletedPage(page, user, options = {}, isRecursively = false, activityParameters?) {
     /*
     /*
      * Common Operation
      * Common Operation
      */
      */
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
     const PageTagRelation = this.crowi.model('PageTagRelation');
 
 
+    const parameters = {
+      ip: activityParameters.ip,
+      endpoint: activityParameters.endpoint,
+      action: page.descendantCount > 0 ? SupportedAction.ACTION_PAGE_RECURSIVELY_REVERT : SupportedAction.ACTION_PAGE_REVERT,
+      user,
+      target: page,
+      targetModel: 'Page',
+      snapshot: {
+        username: user.username,
+      },
+    };
+
+    const activity = await this.crowi.activityService.createActivity(parameters);
+
     // 1. Separate v4 & v5 process
     // 1. Separate v4 & v5 process
     const shouldUseV4Process = this.shouldUseV4ProcessForRevert(page);
     const shouldUseV4Process = this.shouldUseV4ProcessForRevert(page);
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
@@ -1890,6 +2030,7 @@ class PageService {
 
 
     if (!isRecursively) {
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
+      this.activityEvent.emit('updated', activity, page);
     }
     }
     else {
     else {
       let pageOp;
       let pageOp;
@@ -1911,16 +2052,31 @@ class PageService {
       /*
       /*
        * Resumable Operation
        * Resumable Operation
        */
        */
-      this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id, activity);
+        }
+        catch (err) {
+          logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
     }
 
 
     return updatedPage;
     return updatedPage;
   }
   }
 
 
-  async revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike): Promise<void> {
+  async revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    await this.revertDeletedDescendantsWithStream(page, user, options, false);
+    const descendantsSubscribedSets = new Set();
+    await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
 
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     const newPath = Page.getRevertDeletedPageName(page.path);
     // normalize parent of descendant pages
     // normalize parent of descendant pages
@@ -1995,7 +2151,7 @@ class PageService {
   /**
   /**
    * Create revert stream
    * Create revert stream
    */
    */
-  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true): Promise<number> {
+  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}, shouldUseV4Process = true, descendantsSubscribedSets?): Promise<number> {
     if (shouldUseV4Process) {
     if (shouldUseV4Process) {
       return this.revertDeletedDescendantsWithStreamV4(targetPage, user, options);
       return this.revertDeletedDescendantsWithStreamV4(targetPage, user, options);
     }
     }
@@ -2010,6 +2166,10 @@ class PageService {
         try {
         try {
           count += batch.length;
           count += batch.length;
           await revertDeletedDescendants(batch, user);
           await revertDeletedDescendants(batch, user);
+          const subscribedUsers = await Subscription.getSubscriptions(batch);
+          subscribedUsers.forEach((eachUser) => {
+            descendantsSubscribedSets.add(eachUser);
+          });
           logger.debug(`Reverting pages progressing: (count=${count})`);
           logger.debug(`Reverting pages progressing: (count=${count})`);
         }
         }
         catch (err) {
         catch (err) {
@@ -2110,6 +2270,7 @@ class PageService {
 
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
+    const expandContentWidth = page.expandContentWidth ?? this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
 
     return {
     return {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
       isV5Compatible: isTopPage(page.path) || page.parent != null,
@@ -2122,6 +2283,7 @@ class PageService {
       isDeletable: isMovable,
       isDeletable: isMovable,
       isAbleToDeleteCompletely: false,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       isRevertible: isTrashPage(page.path),
+      expandContentWidth,
     };
     };
 
 
   }
   }
@@ -2295,7 +2457,19 @@ class PageService {
       throw err;
       throw err;
     }
     }
 
 
-    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+    (async() => {
+      try {
+        await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      }
+      catch (err) {
+        logger.error('Error occurred while running normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
+        throw err;
+      }
+    })();
   }
   }
 
 
   async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
   async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
@@ -2480,7 +2654,11 @@ class PageService {
       }
       }
       catch (err) {
       catch (err) {
         errorPagePaths.push(page.path);
         errorPagePaths.push(page.path);
-        logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
+        logger.error('Failed to run normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
         throw err;
         throw err;
       }
       }
     }
     }
@@ -3275,6 +3453,8 @@ class PageService {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
     // Switch method
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
     if (!isV5Compatible) {
@@ -3321,7 +3501,9 @@ class PageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
       page.parent = parent._id;
     }
     }
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     // Save
     // Save
     let savedPage = await page.save();
     let savedPage = await page.save();
 
 

+ 3 - 0
packages/app/src/server/util/rate-limiter.ts

@@ -20,8 +20,10 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     }
     }
     const methodKey = `API_RATE_LIMIT_${target}_METHODS`;
     const methodKey = `API_RATE_LIMIT_${target}_METHODS`;
     const maxRequestsKey = `API_RATE_LIMIT_${target}_MAX_REQUESTS`;
     const maxRequestsKey = `API_RATE_LIMIT_${target}_MAX_REQUESTS`;
+    const usersPerIpProspectionKey = `API_RATE_LIMIT_${target}_USERS_PER_IP`;
     const method = envVar[methodKey] ?? 'ALL';
     const method = envVar[methodKey] ?? 'ALL';
     const maxRequests = Number(envVar[maxRequestsKey]);
     const maxRequests = Number(envVar[maxRequestsKey]);
+    const usersPerIpProspection = Number(envVar[usersPerIpProspectionKey]);
 
 
     if (endpoint == null || maxRequests == null) {
     if (endpoint == null || maxRequests == null) {
       return;
       return;
@@ -30,6 +32,7 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     const config = {
     const config = {
       method,
       method,
       maxRequests,
       maxRequests,
+      usersPerIpProspection,
     };
     };
 
 
     apiRateLimitConfig[endpoint] = config;
     apiRateLimitConfig[endpoint] = config;

+ 9 - 1
packages/app/src/server/views/layout/layout.html

@@ -61,9 +61,17 @@
 {% block html_body %}
 {% block html_body %}
 {% set additionalBodyClasses = []; %}
 {% set additionalBodyClasses = []; %}
 {% block html_additional_body_classes %}{% endblock %}
 {% block html_additional_body_classes %}{% endblock %}
-{% if getConfig('crowi', 'customize:isContainerFluid') %}
+
+{% if page.expandContentWidth !== undefined %}
+  {% set isContainerFluid = page.expandContentWidth; %}
+{% else %}
+  {% set isContainerFluid = getConfig('crowi', 'customize:isContainerFluid'); %}
+{% endif %}
+
+{% if isContainerFluid  %}
   {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
   {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
 {% endif %}
 {% endif %}
+
 <body
 <body
   class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
   class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"

+ 6 - 0
packages/app/src/server/views/widget/headers/drawio.html

@@ -27,6 +27,12 @@
       // Set responsive option.
       // Set responsive option.
       // refs: https://github.com/jgraph/drawio/blob/v13.9.1/src/main/webapp/js/diagramly/GraphViewer.js#L89-L95
       // refs: https://github.com/jgraph/drawio/blob/v13.9.1/src/main/webapp/js/diagramly/GraphViewer.js#L89-L95
       DrawioViewer.prototype.responsive = true;
       DrawioViewer.prototype.responsive = true;
+
+      // Set z-index ($zindex-dropdown + 200) for lightbox.
+      // 'lightbox' is like a modal dialog that appears when click on a drawio diagram.
+      // z-index refs: https://github.com/twbs/bootstrap/blob/v4.6.2/scss/_variables.scss#L681
+      DrawioViewer.prototype.lightboxZIndex = 1200;
+      DrawioViewer.prototype.toolbarZIndex = 1200;
     }
     }
   };
   };
 </script>
 </script>

+ 1 - 0
packages/app/src/services/renderer/growi-renderer.ts

@@ -73,6 +73,7 @@ export default class GrowiRenderer {
         }),
         }),
       ];
       ];
       this.postProcessors = [
       this.postProcessors = [
+
       ];
       ];
     }
     }
 
 

+ 4 - 2
packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts

@@ -5,14 +5,16 @@ const PATTERN_RELATIVE_PATH = new RegExp(/^(\.{1,2})(\/.*)?$/);
 
 
 export default class LinkerByRelativePathConfigurer {
 export default class LinkerByRelativePathConfigurer {
 
 
-  pagePath: string
+  pagePath: string;
 
 
   constructor(pagePath: string) {
   constructor(pagePath: string) {
     this.pagePath = pagePath;
     this.pagePath = pagePath;
   }
   }
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  configure(md, pagePath: string): void {
+  configure(md): void {
+    const pagePath = this.pagePath;
+
     // Remember old renderer, if overridden, or proxy to default renderer
     // Remember old renderer, if overridden, or proxy to default renderer
     const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
     const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
       return self.renderToken(tokens, idx, options);
       return self.renderToken(tokens, idx, options);

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

@@ -233,6 +233,12 @@ $slack-work-space-name-card-border: #efc1f6;
       max-height: 500px;
       max-height: 500px;
       overflow-y: auto;
       overflow-y: auto;
     }
     }
+    .date-range-picker {
+      width: 188px;
+    }
+    .jump-page-input {
+      width: 50px;
+    }
   }
   }
 
 
   #layoutOptions {
   #layoutOptions {

+ 5 - 0
packages/app/src/utils/page-operation.ts

@@ -0,0 +1,5 @@
+import { IPageOperationProcessData } from '~/interfaces/page-operation';
+
+export const shouldRecoverPagePaths = (processData: IPageOperationProcessData): boolean => {
+  return processData.Rename?.Sub != null ? processData.Rename.Sub.isProcessable : false;
+};

+ 3 - 2
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -26,6 +26,9 @@ context('Access to page', () => {
 
 
   it('/Sandbox/Math is successfully loaded', () => {
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
     cy.visit('/Sandbox/Math');
+
+    cy.get('mjx-container').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-sandbox-math`);
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
   });
 
 
@@ -163,8 +166,6 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.get('.notification-wrapper > a').click();
     cy.get('.notification-wrapper > a').click();
     cy.get('.notification-wrapper > .dropdown-menu > a').click();
     cy.get('.notification-wrapper > .dropdown-menu > a').click();
 
 
-    cy.get('#all-in-app-notifications').should('be.visible');
-
     cy.screenshot(`${ssPrefix}-see-all`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-see-all`, { capture: 'viewport' });
 
 
     cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();
     cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();

+ 3 - 0
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -22,6 +22,9 @@ context('Access to page by guest', () => {
 
 
   it('/Sandbox/Math is successfully loaded', () => {
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
     cy.visit('/Sandbox/Math');
+
+    cy.get('mjx-container').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-sandbox-math`);
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
   });
 
 

+ 167 - 0
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -0,0 +1,167 @@
+context('Access to sidebar', () => {
+  const ssPrefix = 'access-to-sidebar-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(false);
+  });
+
+  it('Successfully show/collapse sidebar', () => {
+    cy.visit('/');
+    cy.screenshot(`${ssPrefix}-1-sidebar-shown`, {capture: 'viewport'});
+    cy.getByTestid('grw-navigation-resize-button').click({force: true});
+    cy.screenshot(`${ssPrefix}-2-sidebar-collapsed`, {capture: 'viewport'});
+
+  });
+  it('Successfully access recent changes side bar ', () => {
+    cy.visit('/');
+    cy.getByTestid('grw-sidebar-nav-primary-recent-changes').click();
+    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
+      if($el.hasClass('d-none')){
+        cy.getByTestid('grw-navigation-resize-button').click({force: true});
+      }
+    });
+
+    cy.getByTestid('grw-recent-changes').should('be.visible');
+
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}recent-changes-1-page-list`);
+
+    cy.get('#grw-sidebar-contents-wrapper').within(() => {
+      cy.get('#recentChangesResize').click({force: true});
+      cy.screenshot(`${ssPrefix}recent-changes-2-switch-sidebar-size`);
+    });
+  });
+
+  it('Successfully create a custom sidebar page', () => {
+    cy.visit('/');
+    cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').click();
+    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
+      if($el.hasClass('d-none')){
+        cy.getByTestid('grw-navigation-resize-button').click({force: true});
+      }
+    });
+
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}custom-sidebar-1-click-on-custom-sidebar`);
+
+    // create /Sidebar contents
+    const content = '# HELLO \n ## Hello\n ### Hello';
+    cy.get('.grw-sidebar-content-header.h5').find('a').click();
+    cy.get('.CodeMirror textarea').type(content, {force: true});
+    cy.screenshot(`${ssPrefix}custom-sidebar-2-custom-sidebar-editor`);
+    cy.get('.dropup > .btn-submit').click();
+    cy.get('body').should('not.have.class', 'on-edit');
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}custom-sidebar-3-custom-sidebar-created`);
+  });
+
+  it('Successfully performed page operation from "page tree"', () => {
+    cy.visit('/');
+    cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
+    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
+      if($el.hasClass('d-none')){
+        cy.getByTestid('grw-navigation-resize-button').click({force: true});
+      }
+    });
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-1-access-to-page-tree`);
+    cy.get('.grw-pagetree-triangle-btn').eq(0).click();
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-2-hide-page-tree-item`);
+    cy.get('.grw-pagetree-triangle-btn').eq(0).click();
+
+    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+    });
+
+    cy.screenshot(`${ssPrefix}page-tree-3-click-three-dots-menu`);
+    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+      cy.getByTestid('add-remove-bookmark-btn').click();
+    });
+    cy.screenshot(`${ssPrefix}page-tree-4-add-bookmark`);
+
+
+    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+    });
+    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+      cy.getByTestid('open-page-duplicate-modal-btn').click();
+    });
+
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').type('_test');
+      cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page`);
+      cy.get('.modal-header > button').click();
+    });
+
+    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+    });
+    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+      cy.getByTestid('open-page-move-rename-modal-btn').click();
+    });
+
+    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+      cy.get('.flex-fill > input').type('_newname');
+    });
+
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-6-rename-page`);
+    cy.get('body').click(0,0);
+
+    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+    });
+    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+      cy.getByTestid('open-page-delete-modal-btn').click();
+    });
+
+    cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}page-tree-7-delete-page`);
+      cy.get('.modal-header > button').click();
+    });
+
+  });
+
+  it('Successfully performed page operation from "Tags" ', () => {
+    cy.visit('/');
+    cy.getByTestid('grw-sidebar-nav-primary-tags').click();
+    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
+      if($el.hasClass('d-none')){
+        cy.getByTestid('grw-navigation-resize-button').click({force: true});
+      }
+    });
+    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}tags-1-access-to-tags`);
+
+    cy.get('.grw-container-convertible > div > .btn-primary').click({force: true});
+
+    // collapse sidebar
+    cy.collapseSidebar(true);
+
+    cy.screenshot(`${ssPrefix}tags-2-check-all-tags`);
+  });
+
+  it('Successfully access to My Drafts page', () => {
+    cy.visit('/');
+    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+      cy.get('a[href*="/me/drafts"]').click();
+    });
+    cy.screenshot(`${ssPrefix}access-to-drafts-page`);
+  });
+  it('Successfully access to Growi Docs page', () => {
+    cy.visit('/');
+    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+      cy.get('a[href*="https://docs.growi.org"]').then(($a) => {
+        const url = $a.prop('href')
+        cy.request(url).its('body').should('include', '</html>');
+      });
+    });
+  });
+
+  it('Successfully access to trash page', () => {
+    cy.visit('/');
+    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+      cy.get('a[href*="/trash"]').click();
+    });
+    cy.screenshot(`${ssPrefix}access-to-trash-page`);
+  });
+});

+ 4 - 4
packages/app/test/cypress/integration/50-switch-sidebar-mode/switching-sidebar-mode.spec.ts → packages/app/test/cypress/integration/50-sidebar/switching-sidebar-mode.spec.ts

@@ -21,13 +21,13 @@ context('Switch sidebar mode', () => {
 
 
   it('Switching sidebar mode', () => {
   it('Switching sidebar mode', () => {
     cy.visit('/');
     cy.visit('/');
-    cy.get('.grw-personal-dropdown').click();
+    cy.get('.grw-personal-dropdown').first().click();
 
 
-    cy.get('[for="swSidebarModeOnEditor"]').click();
+    cy.get('[for="swSidebarMode"]').click({force: true});
     cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, { capture: 'viewport' });
 
 
-    cy.get('[for="swSidebarModeOnEditor"]').click();
+    cy.get('[for="swSidebarMode"]').click({force: true});
     cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, { capture: 'viewport' });
   });
   });
 
 
-});
+});

+ 38 - 13
packages/app/test/integration/service/page.test.js

@@ -386,7 +386,8 @@ describe('PageService', () => {
 
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
     test('rename page with different tree with isRecursively [deeper]', async() => {
     test('rename page with different tree with isRecursively [deeper]', async() => {
-      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true });
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, { isRecursively: true },
+        { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
       const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
       const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
       const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
       const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
       const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
       const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
@@ -408,7 +409,8 @@ describe('PageService', () => {
 
 
       // when
       // when
       //   rename /level1/level2 --> /level1
       //   rename /level1/level2 --> /level1
-      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true });
+      await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, { isRecursively: true },
+        { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
 
       // then
       // then
       expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
       expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
@@ -438,7 +440,8 @@ describe('PageService', () => {
 
 
       test('rename page without options', async() => {
       test('rename page without options', async() => {
 
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename1, '/renamed1', testUser2, {});
+        const resultPage = await crowi.pageService.renamePage(parentForRename1,
+          '/renamed1', testUser2, {}, { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
 
 
@@ -451,7 +454,8 @@ describe('PageService', () => {
 
 
       test('rename page with updateMetadata option', async() => {
       test('rename page with updateMetadata option', async() => {
 
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename2, '/renamed2', testUser2, { updateMetadata: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
 
 
@@ -464,7 +468,8 @@ describe('PageService', () => {
 
 
       test('rename page with createRedirectPage option', async() => {
       test('rename page with createRedirectPage option', async() => {
 
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
         expect(pageEventSpy).toHaveBeenCalledWith('rename');
@@ -476,7 +481,8 @@ describe('PageService', () => {
 
 
       test('rename page with isRecursively', async() => {
       test('rename page with isRecursively', async() => {
 
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename4, '/renamed4', testUser2, { isRecursively: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -489,7 +495,8 @@ describe('PageService', () => {
 
 
       test('rename page with different tree with isRecursively', async() => {
       test('rename page with different tree with isRecursively', async() => {
 
 
-        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true });
+        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, { isRecursively: true },
+          { ip: '::ffff:127.0.0.1', endpoint: '/_api/v3/pages/rename' });
         const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
         const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
         const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
         const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
 
 
@@ -644,7 +651,10 @@ describe('PageService', () => {
     });
     });
 
 
     test('delete page without options', async() => {
     test('delete page without options', async() => {
-      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { });
+      const resultPage = await crowi.pageService.deletePage(parentForDelete1, testUser2, { }, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
 
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -661,7 +671,10 @@ describe('PageService', () => {
     });
     });
 
 
     test('delete page with isRecursively', async() => {
     test('delete page with isRecursively', async() => {
-      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true);
+      const resultPage = await crowi.pageService.deletePage(parentForDelete2, testUser2, { }, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
 
 
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(getDeletedPageNameSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
       expect(deleteDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -731,7 +744,10 @@ describe('PageService', () => {
     });
     });
 
 
     test('delete completely without options', async() => {
     test('delete completely without options', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { });
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
 
 
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -741,7 +757,10 @@ describe('PageService', () => {
 
 
 
 
     test('delete completely with isRecursively', async() => {
     test('delete completely with isRecursively', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true);
+      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
 
 
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
@@ -764,7 +783,10 @@ describe('PageService', () => {
     });
     });
 
 
     test('revert deleted page when the redirect from page exists', async() => {
     test('revert deleted page when the redirect from page exists', async() => {
-      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2);
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert1, testUser2, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert1.path);
       expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
       expect(revertDeletedDescendantsWithStreamSpy).not.toHaveBeenCalled();
@@ -782,7 +804,10 @@ describe('PageService', () => {
         return null;
         return null;
       });
       });
 
 
-      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true);
+      const resultPage = await crowi.pageService.revertDeletedPage(parentForRevert2, testUser2, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
 
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
       expect(getRevertDeletedPageNameSpy).toHaveBeenCalledWith(parentForRevert2.path);
       expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');
       expect(findByPathSpy).toHaveBeenCalledWith('/parentForRevert2');

+ 63 - 21
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -859,10 +859,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   });
 
 
   describe('Rename', () => {
   describe('Rename', () => {
-    const renamePage = async(page, newPagePath, user, options) => {
+    const renamePage = async(page, newPagePath, user, options, activityParameters?) => {
       // mock return value
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options, activityParameters);
 
 
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
@@ -894,7 +894,11 @@ describe('PageService page operations with non-public pages', () => {
 
 
       const newPathForPage2 = '/np_rename1_destination/np_rename2';
       const newPathForPage2 = '/np_rename1_destination/np_rename2';
       const newPathForPage3 = '/np_rename1_destination/np_rename2/np_rename3';
       const newPathForPage3 = '/np_rename1_destination/np_rename2/np_rename3';
-      await renamePage(_page2, newPathForPage2, npDummyUser2, {});
+      await renamePage(_page2, newPathForPage2, npDummyUser2, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
 
       const pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
       const pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
       const page2 = await Page.findOne({ path: _path2, ..._properties2 }); // not exist
       const page2 = await Page.findOne({ path: _path2, ..._properties2 }); // not exist
@@ -930,7 +934,11 @@ describe('PageService page operations with non-public pages', () => {
       const newPathForPage3 = '/np_rename4_destination/np_rename5/np_rename6';
       const newPathForPage3 = '/np_rename4_destination/np_rename5/np_rename6';
       let isThrown = false;
       let isThrown = false;
       try {
       try {
-        await renamePage(_page2, newPathForPage2, dummyUser1, {});
+        await renamePage(_page2, newPathForPage2, dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+          activityId: '62e291bc10e0ab61bd691794',
+        });
       }
       }
       catch (err) {
       catch (err) {
         isThrown = true;
         isThrown = true;
@@ -958,7 +966,11 @@ describe('PageService page operations with non-public pages', () => {
 
 
       const newPathForPage2 = '/np_rename7_destination/np_rename8';
       const newPathForPage2 = '/np_rename7_destination/np_rename8';
       const newpathForPage3 = '/np_rename7_destination/np_rename8/np_rename9';
       const newpathForPage3 = '/np_rename7_destination/np_rename8/np_rename9';
-      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true });
+      await renamePage(_page2, newPathForPage2, npDummyUser1, { isRecursively: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
 
       const page2 = await Page.findOne({ path: _path2 }); // not exist
       const page2 = await Page.findOne({ path: _path2 }); // not exist
       const page3 = await Page.findOne({ path: _path3 }); // not renamed thus exist
       const page3 = await Page.findOne({ path: _path3 }); // not renamed thus exist
@@ -1092,10 +1104,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   });
   describe('Delete', () => {
   describe('Delete', () => {
 
 
-    const deletePage = async(page, user, options, isRecursively) => {
+    const deletePage = async(page, user, options, isRecursively, activityParameters?) => {
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
 
 
-      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively, activityParameters);
 
 
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
 
@@ -1114,7 +1126,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_pageT).toBeTruthy();
         expect(_pageT).toBeTruthy();
 
 
         const isRecursively = false;
         const isRecursively = false;
-        await deletePage(_pageT, dummyUser1, {}, isRecursively);
+        await deletePage(_pageT, dummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
 
         const pageT = await Page.findOne({ path: `/trash${_pathT}` });
         const pageT = await Page.findOne({ path: `/trash${_pathT}` });
         const pageN = await Page.findOne({ path: _pathT }); // should not exist
         const pageN = await Page.findOne({ path: _pathT }); // should not exist
@@ -1131,7 +1146,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_page1).toBeTruthy();
         expect(_page1).toBeTruthy();
 
 
         const isRecursively = false;
         const isRecursively = false;
-        await deletePage(_page1, npDummyUser1, {}, isRecursively);
+        await deletePage(_page1, npDummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
 
         const pageN = await Page.findOne({ path: _path, grantedGroup: groupIdA });
         const pageN = await Page.findOne({ path: _path, grantedGroup: groupIdA });
         const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroup: groupIdA });
         const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroup: groupIdA });
@@ -1157,7 +1175,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_pageR).toBeTruthy();
         expect(_pageR).toBeTruthy();
 
 
         const isRecursively = true;
         const isRecursively = true;
-        await deletePage(_pageT, npDummyUser1, {}, isRecursively);
+        await deletePage(_pageT, npDummyUser1, {}, isRecursively, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
 
         const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A should not exist
         const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A should not exist
         const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B should not exist
         const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B should not exist
@@ -1187,10 +1208,10 @@ describe('PageService page operations with non-public pages', () => {
 
 
   });
   });
   describe('Delete completely', () => {
   describe('Delete completely', () => {
-    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
 
 
-      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting, activityParameters);
 
 
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
 
@@ -1209,7 +1230,10 @@ describe('PageService page operations with non-public pages', () => {
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         expect(_page).toBeTruthy();
         expect(_page).toBeTruthy();
 
 
-        await deleteCompletely(_page, dummyUser1, {}, false);
+        await deleteCompletely(_page, dummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
 
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_RESTRICTED });
         expect(page).toBeNull();
         expect(page).toBeNull();
@@ -1221,7 +1245,10 @@ describe('PageService page operations with non-public pages', () => {
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         expect(_page).toBeTruthy();
         expect(_page).toBeTruthy();
 
 
-        await deleteCompletely(_page, npDummyUser1, {}, false);
+        await deleteCompletely(_page, npDummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
 
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         expect(page).toBeNull();
         expect(page).toBeNull();
@@ -1241,7 +1268,10 @@ describe('PageService page operations with non-public pages', () => {
         expect(_page3).toBeTruthy();
         expect(_page3).toBeTruthy();
         expect(_page4).toBeTruthy();
         expect(_page4).toBeTruthy();
 
 
-        await deleteCompletely(_page1, npDummyUser1, {}, true);
+        await deleteCompletely(_page1, npDummyUser1, {}, true, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
 
 
         const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
         const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
         const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
@@ -1256,10 +1286,10 @@ describe('PageService page operations with non-public pages', () => {
     });
     });
   });
   });
   describe('revert', () => {
   describe('revert', () => {
-    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false, activityParameters?) => {
       // mock return value
       // mock return value
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
-      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively, activityParameters);
 
 
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
 
@@ -1282,7 +1312,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(tag).toBeTruthy();
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
 
-      await revertDeletedPage(trashedPage, dummyUser1, {}, false);
+      await revertDeletedPage(trashedPage, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
@@ -1309,7 +1342,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(tag).toBeTruthy();
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
 
-      await revertDeletedPage(trashedPage, user1, {}, false);
+      await revertDeletedPage(trashedPage, user1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
@@ -1337,7 +1373,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(revision1).toBeTruthy();
       expect(revision1).toBeTruthy();
       expect(revision2).toBeTruthy();
       expect(revision2).toBeTruthy();
 
 
-      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true);
+      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
 
 
       const revertedPage = await Page.findOne({ path: '/np_revert3' });
       const revertedPage = await Page.findOne({ path: '/np_revert3' });
       const middlePage = await Page.findOne({ path: '/np_revert3/middle' });
       const middlePage = await Page.findOne({ path: '/np_revert3/middle' });
@@ -1376,7 +1415,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(user).toBeTruthy();
       expect(user).toBeTruthy();
       expect(nonExistantPage3).toBeNull();
       expect(nonExistantPage3).toBeNull();
 
 
-      await revertDeletedPage(trashedPage1, user, {}, true);
+      await revertDeletedPage(trashedPage1, user, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const revertedPage1 = await Page.findOne({ path: '/np_revert5' });
       const revertedPage1 = await Page.findOne({ path: '/np_revert5' });
       const newlyCreatedPage = await Page.findOne({ path: '/np_revert5/middle' });
       const newlyCreatedPage = await Page.findOne({ path: '/np_revert5/middle' });
       const revertedPage2 = await Page.findOne({ path: '/np_revert5/middle/np_revert6' });
       const revertedPage2 = await Page.findOne({ path: '/np_revert5/middle/np_revert6' });

+ 15 - 5
packages/app/test/integration/service/v5.page.test.ts

@@ -1,7 +1,7 @@
 import { addSeconds } from 'date-fns';
 import { addSeconds } from 'date-fns';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { PageActionStage, PageActionType } from '../../../src/server/models/page-operation';
+import { PageActionStage, PageActionType } from '../../../src/interfaces/page-operation';
 import { getInstance } from '../setup-crowi';
 import { getInstance } from '../setup-crowi';
 
 
 
 
@@ -489,9 +489,9 @@ describe('Test page service methods', () => {
   });
   });
 
 
   describe('restart renameOperation', () => {
   describe('restart renameOperation', () => {
-    const resumeRenameSubOperation = async(renamePage, pageOp) => {
+    const resumeRenameSubOperation = async(renamePage, pageOp, activity?) => {
       const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
       const mockedPathsAndDescendantCountOfAncestors = jest.spyOn(crowi.pageService, 'fixPathsAndDescendantCountOfAncestors').mockReturnValue(null);
-      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp);
+      await crowi.pageService.resumeRenameSubOperation(renamePage, pageOp, activity);
 
 
       const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
       const argsForRenameSubOperation = mockedPathsAndDescendantCountOfAncestors.mock.calls[0];
 
 
@@ -512,6 +512,9 @@ describe('Test page service methods', () => {
       const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
       const path2 = '/resume_rename_0/resume_rename_1/resume_rename_2';
       const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
       const path3 = '/resume_rename_0/resume_rename_1/resume_rename_2/resume_rename_3';
 
 
+      // activity options
+      const activity = 'randomActivityId';
+
       // page
       // page
       const _page0 = await Page.findOne({ path: _path0 });
       const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
       const _page1 = await Page.findOne({ path: _path1 });
@@ -536,7 +539,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
       expect(_pageOperation).toBeTruthy();
 
 
       // rename
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation);
+      await resumeRenameSubOperation(_page1, _pageOperation, activity);
 
 
       // page
       // page
       const page0 = await Page.findById(_page0._id);
       const page0 = await Page.findById(_page0._id);
@@ -573,6 +576,13 @@ describe('Test page service methods', () => {
       const path1 = '/resume_rename_8/resume_rename_9';
       const path1 = '/resume_rename_8/resume_rename_9';
       const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
       const path2 = '/resume_rename_8/resume_rename_9/resume_rename_10';
 
 
+      // activity options
+      const activityParameters = {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      };
+
       // page
       // page
       const _page0 = await Page.findOne({ path: _path0 });
       const _page0 = await Page.findOne({ path: _path0 });
       const _page1 = await Page.findOne({ path: _path1 });
       const _page1 = await Page.findOne({ path: _path1 });
@@ -594,7 +604,7 @@ describe('Test page service methods', () => {
       expect(_pageOperation).toBeTruthy();
       expect(_pageOperation).toBeTruthy();
 
 
       // rename
       // rename
-      await resumeRenameSubOperation(_page1, _pageOperation);
+      await resumeRenameSubOperation(_page1, _pageOperation, activityParameters);
 
 
       // page
       // page
       const page0 = await Page.findById(_page0._id);
       const page0 = await Page.findById(_page0._id);

+ 128 - 37
packages/app/test/integration/service/v5.public-page.test.ts

@@ -2,7 +2,7 @@
 import { advanceTo } from 'jest-date-mock';
 import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { PageActionType, PageActionStage } from '../../../src/server/models/page-operation';
+import { PageActionType, PageActionStage } from '../../../src/interfaces/page-operation';
 import Tag from '../../../src/server/models/tag';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 import { getInstance } from '../setup-crowi';
 
 
@@ -429,6 +429,10 @@ describe('PageService page operations with only public pages', () => {
           createRedirectPage: false,
           createRedirectPage: false,
           updateMetadata: true,
           updateMetadata: true,
         },
         },
+        activityParameters: {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        },
         unprocessableExpiryDate: null,
         unprocessableExpiryDate: null,
       },
       },
     ]);
     ]);
@@ -1139,10 +1143,10 @@ describe('PageService page operations with only public pages', () => {
 
 
   describe('Rename', () => {
   describe('Rename', () => {
 
 
-    const renamePage = async(page, newPagePath, user, options) => {
+    const renamePage = async(page, newPagePath, user, options, activityParameters?) => {
       // mock return value
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
+      const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options, activityParameters);
 
 
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       // retrieve the arguments passed when calling method renameSubOperation inside renamePage method
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
       const argsForRenameSubOperation = mockedRenameSubOperation.mock.calls[0];
@@ -1159,7 +1163,7 @@ describe('PageService page operations with only public pages', () => {
     /**
     /**
      * This function only execute renameMainOperation. renameSubOperation is basically omitted(only return null)
      * This function only execute renameMainOperation. renameSubOperation is basically omitted(only return null)
      */
      */
-    const renameMainOperation = async(page, newPagePath, user, options) => {
+    const renameMainOperation = async(page, newPagePath, user, options, activityParameters?) => {
       // create page operation from target page
       // create page operation from target page
       const pageOp = await PageOperation.create({
       const pageOp = await PageOperation.create({
         actionType: PageActionType.Rename,
         actionType: PageActionType.Rename,
@@ -1173,7 +1177,7 @@ describe('PageService page operations with only public pages', () => {
 
 
       // mock return value
       // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
-      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+      const renamedPage = await crowi.pageService.renameMainOperation(page, newPagePath, user, options, pageOp._id, activityParameters);
 
 
       // restores the original implementation
       // restores the original implementation
       mockedRenameSubOperation.mockRestore();
       mockedRenameSubOperation.mockRestore();
@@ -1185,7 +1189,10 @@ describe('PageService page operations with only public pages', () => {
       expect(rootPage).toBeTruthy();
       expect(rootPage).toBeTruthy();
       let isThrown = false;
       let isThrown = false;
       try {
       try {
-        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {});
+        await crowi.pageService.renamePage(rootPage, '/new_root', dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
       }
       }
       catch (err) {
       catch (err) {
         isThrown = true;
         isThrown = true;
@@ -1201,7 +1208,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage).toBeTruthy();
       expect(parentPage).toBeTruthy();
 
 
       const newPath = '/v5_ParentForRename1/renamedChildForRename1';
       const newPath = '/v5_ParentForRename1/renamedChildForRename1';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename1' });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename1' });
 
 
       expect(xssSpy).toHaveBeenCalled();
       expect(xssSpy).toHaveBeenCalled();
@@ -1219,7 +1229,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage.isEmpty).toBe(true);
       expect(parentPage.isEmpty).toBe(true);
 
 
       const newPath = '/v5_ParentForRename2/renamedChildForRename2';
       const newPath = '/v5_ParentForRename2/renamedChildForRename2';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename2' });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename2' });
 
 
       expect(xssSpy).toHaveBeenCalled();
       expect(xssSpy).toHaveBeenCalled();
@@ -1238,7 +1251,10 @@ describe('PageService page operations with only public pages', () => {
 
 
       const newPath = '/v5_ParentForRename3/renamedChildForRename3';
       const newPath = '/v5_ParentForRename3/renamedChildForRename3';
       const oldUpdateAt = childPage.updatedAt;
       const oldUpdateAt = childPage.updatedAt;
-      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true });
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { updateMetadata: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
 
       expect(xssSpy).toHaveBeenCalled();
       expect(xssSpy).toHaveBeenCalled();
       expect(renamedPage.path).toBe(newPath);
       expect(renamedPage.path).toBe(newPath);
@@ -1255,7 +1271,10 @@ describe('PageService page operations with only public pages', () => {
 
 
       const oldPath = childPage.path;
       const oldPath = childPage.path;
       const newPath = '/v5_ParentForRename4/renamedChildForRename4';
       const newPath = '/v5_ParentForRename4/renamedChildForRename4';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true });
+      const renamedPage = await renamePage(childPage, newPath, dummyUser2, { createRedirectPage: true }, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const pageRedirect = await PageRedirect.findOne({ fromPath: oldPath, toPath: renamedPage.path });
       const pageRedirect = await PageRedirect.findOne({ fromPath: oldPath, toPath: renamedPage.path });
 
 
       expect(xssSpy).toHaveBeenCalled();
       expect(xssSpy).toHaveBeenCalled();
@@ -1274,7 +1293,10 @@ describe('PageService page operations with only public pages', () => {
       expect(grandchild).toBeTruthy();
       expect(grandchild).toBeTruthy();
 
 
       const newPath = '/v5_ParentForRename5/renamedChildForRename5';
       const newPath = '/v5_ParentForRename5/renamedChildForRename5';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       // find child of renamed page
       // find child of renamed page
       const renamedGrandchild = await Page.findOne({ parent: renamedPage._id });
       const renamedGrandchild = await Page.findOne({ parent: renamedPage._id });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename5' });
       const childPageBeforeRename = await Page.findOne({ path: '/v5_ChildForRename5' });
@@ -1300,7 +1322,10 @@ describe('PageService page operations with only public pages', () => {
       expect(grandchild).toBeTruthy();
       expect(grandchild).toBeTruthy();
 
 
       const newPath = '/v5_ParentForRename7/renamedChildForRename7';
       const newPath = '/v5_ParentForRename7/renamedChildForRename7';
-      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {});
+      const renamedPage = await renamePage(childPage, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
       const grandchildAfterRename = await Page.findOne({ parent: renamedPage._id });
       const grandchildAfterRename = await Page.findOne({ parent: renamedPage._id });
       const grandchildBeforeRename = await Page.findOne({ path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
       const grandchildBeforeRename = await Page.findOne({ path: '/v5_ChildForRename7/v5_GrandchildForRename7' });
 
 
@@ -1320,7 +1345,10 @@ describe('PageService page operations with only public pages', () => {
       const newPath = '/v5_ParentForRename9';
       const newPath = '/v5_ParentForRename9';
       let isThrown;
       let isThrown;
       try {
       try {
-        await renamePage(page, newPath, dummyUser1, {});
+        await renamePage(page, newPath, dummyUser1, {}, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/rename',
+        });
       }
       }
       catch (err) {
       catch (err) {
         isThrown = true;
         isThrown = true;
@@ -1339,7 +1367,10 @@ describe('PageService page operations with only public pages', () => {
 
 
       const newParentalPath = '/v5_pageForRename17/v5_pageForRename18';
       const newParentalPath = '/v5_pageForRename17/v5_pageForRename18';
       const newPath = newParentalPath + page1.path;
       const newPath = newParentalPath + page1.path;
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1376,7 +1407,10 @@ describe('PageService page operations with only public pages', () => {
 
 
       const newParentalPath = '/v5_pageForRename19/v5_pageForRename20';
       const newParentalPath = '/v5_pageForRename19/v5_pageForRename20';
       const newPath = newParentalPath + page1.path;
       const newPath = newParentalPath + page1.path;
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1417,7 +1451,10 @@ describe('PageService page operations with only public pages', () => {
       const newParentalPath = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
       const newParentalPath = '/v5_pageForRename21/v5_pageForRename22/v5_pageForRename23';
       const newPath = newParentalPath + page1.path;
       const newPath = newParentalPath + page1.path;
 
 
-      await renamePage(page1, newPath, dummyUser1, {});
+      await renamePage(page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
 
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPage = await Page.findOne({ path: newParentalPath + initialPathForPage1 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
       const renamedPageChild = await Page.findOne({ path: newParentalPath + initialPathForPage2 });
@@ -1519,7 +1556,11 @@ describe('PageService page operations with only public pages', () => {
       expect(_page1.descendantCount).toBe(0);
       expect(_page1.descendantCount).toBe(0);
 
 
       // renameSubOperation only
       // renameSubOperation only
-      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id);
+      await crowi.pageService.renameSubOperation(_page1, newPath, dummyUser1, {}, _page1, pageOperation._id, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+        activityId: '62e291bc10e0ab61bd691794',
+      });
 
 
       // page
       // page
       const page0 = await Page.findById(_page0._id); // new parent
       const page0 = await Page.findById(_page0._id); // new parent
@@ -1561,7 +1602,10 @@ describe('PageService page operations with only public pages', () => {
       expect(_page1.descendantCount).toBe(1);
       expect(_page1.descendantCount).toBe(1);
       expect(_page2.descendantCount).toBe(0);
       expect(_page2.descendantCount).toBe(0);
 
 
-      await renamePage(_page1, newPath, dummyUser1, {});
+      await renamePage(_page1, newPath, dummyUser1, {}, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/rename',
+      });
 
 
       const page0 = await Page.findById(_page0._id); // new parent
       const page0 = await Page.findById(_page0._id); // new parent
       const page1 = await Page.findById(_page1._id); // renamed
       const page1 = await Page.findById(_page1._id); // renamed
@@ -1768,10 +1812,10 @@ describe('PageService page operations with only public pages', () => {
     });
     });
   });
   });
   describe('Delete', () => {
   describe('Delete', () => {
-    const deletePage = async(page, user, options, isRecursively) => {
+    const deletePage = async(page, user, options, isRecursively, activityParameters?) => {
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
       const mockedDeleteRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteRecursivelyMainOperation').mockReturnValue(null);
 
 
-      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively);
+      const deletedPage = await crowi.pageService.deletePage(page, user, options, isRecursively, activityParameters);
 
 
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
       const argsForDeleteRecursivelyMainOperation = mockedDeleteRecursivelyMainOperation.mock.calls[0];
 
 
@@ -1787,7 +1831,12 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT delete root page', async() => {
     test('Should NOT delete root page', async() => {
       let isThrown;
       let isThrown;
       expect(rootPage).toBeTruthy();
       expect(rootPage).toBeTruthy();
-      try { await deletePage(rootPage, dummyUser1, {}, false) }
+      try {
+        await deletePage(rootPage, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
       catch (err) { isThrown = true }
 
 
       const page = await Page.findOne({ path: '/' });
       const page = await Page.findOne({ path: '/' });
@@ -1801,7 +1850,12 @@ describe('PageService page operations with only public pages', () => {
       expect(trashedPage).toBeTruthy();
       expect(trashedPage).toBeTruthy();
 
 
       let isThrown;
       let isThrown;
-      try { await deletePage(trashedPage, dummyUser1, {}, false) }
+      try {
+        await deletePage(trashedPage, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
       catch (err) { isThrown = true }
 
 
       const page = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
       const page = await Page.findOne({ path: '/trash/v5_PageForDelete1' });
@@ -1814,7 +1868,12 @@ describe('PageService page operations with only public pages', () => {
       const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
       const dummyUser1Page = await Page.findOne({ path: '/user/v5DummyUser1' });
       expect(dummyUser1Page).toBeTruthy();
       expect(dummyUser1Page).toBeTruthy();
       let isThrown;
       let isThrown;
-      try { await deletePage(dummyUser1Page, dummyUser1, {}, false) }
+      try {
+        await deletePage(dummyUser1Page, dummyUser1, {}, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/delete',
+        });
+      }
       catch (err) { isThrown = true }
       catch (err) { isThrown = true }
 
 
       const page = await Page.findOne({ path: '/user/v5DummyUser1' });
       const page = await Page.findOne({ path: '/user/v5DummyUser1' });
@@ -1826,7 +1885,10 @@ describe('PageService page operations with only public pages', () => {
     test('Should delete single page', async() => {
     test('Should delete single page', async() => {
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
       const pageToDelete = await Page.findOne({ path: '/v5_PageForDelete2' });
       expect(pageToDelete).toBeTruthy();
       expect(pageToDelete).toBeTruthy();
-      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const page = await Page.findOne({ path: '/v5_PageForDelete2' });
       const page = await Page.findOne({ path: '/v5_PageForDelete2' });
 
 
       expect(page).toBeNull();
       expect(page).toBeNull();
@@ -1842,7 +1904,10 @@ describe('PageService page operations with only public pages', () => {
       expect(parentPage).toBeTruthy();
       expect(parentPage).toBeTruthy();
       expect(childPage).toBeTruthy();
       expect(childPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
-      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true);
+      const deletedParentPage = await deletePage(parentPage, dummyUser1, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const deletedChildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4' });
       const deletedChildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4' });
       const deletedGrandchildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
       const deletedGrandchildPage = await Page.findOne({ path: '/trash/v5_PageForDelete3/v5_PageForDelete4/v5_PageForDelete5' });
 
 
@@ -1869,7 +1934,10 @@ describe('PageService page operations with only public pages', () => {
       expect(tag2).toBeTruthy();
       expect(tag2).toBeTruthy();
       expect(pageRelation1).toBeTruthy();
       expect(pageRelation1).toBeTruthy();
       expect(pageRelation2).toBeTruthy();
       expect(pageRelation2).toBeTruthy();
-      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false);
+      const deletedPage = await deletePage(pageToDelete, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/delete',
+      });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
       const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
       const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
       const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
       const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
@@ -1881,10 +1949,10 @@ describe('PageService page operations with only public pages', () => {
     });
     });
   });
   });
   describe('Delete completely', () => {
   describe('Delete completely', () => {
-    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
+    const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false, activityParameters?) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
 
 
-      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting);
+      await crowi.pageService.deleteCompletely(page, user, options, isRecursively, preventEmitting, activityParameters);
 
 
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
       const argsForDeleteCompletelyRecursivelyMainOperation = mockedDeleteCompletelyRecursivelyMainOperation.mock.calls[0];
 
 
@@ -1900,7 +1968,12 @@ describe('PageService page operations with only public pages', () => {
     test('Should NOT completely delete root page', async() => {
     test('Should NOT completely delete root page', async() => {
       expect(rootPage).toBeTruthy();
       expect(rootPage).toBeTruthy();
       let isThrown;
       let isThrown;
-      try { await deleteCompletely(rootPage, dummyUser1, {}, false) }
+      try {
+        await deleteCompletely(rootPage, dummyUser1, {}, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
+      }
       catch (err) { isThrown = true }
       catch (err) { isThrown = true }
       const page = await Page.findOne({ path: '/' });
       const page = await Page.findOne({ path: '/' });
       expect(page).toBeTruthy();
       expect(page).toBeTruthy();
@@ -1910,7 +1983,10 @@ describe('PageService page operations with only public pages', () => {
       const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
       const page = await Page.findOne({ path: '/v5_PageForDeleteCompletely1' });
       expect(page).toBeTruthy();
       expect(page).toBeTruthy();
 
 
-      await deleteCompletely(page, dummyUser1, {}, false);
+      await deleteCompletely(page, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
       const deletedPage = await Page.findOne({ _id: page._id, path: '/v5_PageForDeleteCompletely1' });
 
 
       expect(deletedPage).toBeNull();
       expect(deletedPage).toBeNull();
@@ -1943,7 +2019,10 @@ describe('PageService page operations with only public pages', () => {
       expect(shareLink1).toBeTruthy();
       expect(shareLink1).toBeTruthy();
       expect(shareLink2).toBeTruthy();
       expect(shareLink2).toBeTruthy();
 
 
-      await deleteCompletely(parentPage, dummyUser1, {}, true);
+      await deleteCompletely(parentPage, dummyUser1, {}, true, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
@@ -1975,7 +2054,10 @@ describe('PageService page operations with only public pages', () => {
       const revision = await Revision.findOne({ pageId: page._id });
       const revision = await Revision.findOne({ pageId: page._id });
       expect(page).toBeTruthy();
       expect(page).toBeTruthy();
       expect(revision).toBeTruthy();
       expect(revision).toBeTruthy();
-      await deleteCompletely(page, dummyUser1, {}, false);
+      await deleteCompletely(page, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const deltedPage = await Page.findOne({ _id: page._id });
       const deltedPage = await Page.findOne({ _id: page._id });
       const deltedRevision = await Revision.findOne({ _id: revision._id });
       const deltedRevision = await Revision.findOne({ _id: revision._id });
 
 
@@ -1990,7 +2072,10 @@ describe('PageService page operations with only public pages', () => {
       expect(childPage).toBeTruthy();
       expect(childPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
       expect(grandchildPage).toBeTruthy();
 
 
-      await deleteCompletely(childPage, dummyUser1, {}, false);
+      await deleteCompletely(childPage, dummyUser1, {}, false, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/deletecompletely',
+      });
       const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
       const parentPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6' });
       const childPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
       const childPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7' });
       const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
       const grandchildPageAfterDelete = await Page.findOne({ path: '/v5_PageForDeleteCompletely6/v5_PageForDeleteCompletely7/v5_PageForDeleteCompletely8' });
@@ -2008,10 +2093,10 @@ describe('PageService page operations with only public pages', () => {
     });
     });
   });
   });
   describe('revert', () => {
   describe('revert', () => {
-    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false, activityParameters?) => {
       // mock return value
       // mock return value
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
       const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
-      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively, activityParameters);
 
 
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
       const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
 
 
@@ -2035,7 +2120,10 @@ describe('PageService page operations with only public pages', () => {
       expect(tag).toBeTruthy();
       expect(tag).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
       expect(deletedPageTagRelation).toBeTruthy();
 
 
-      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false);
+      const revertedPage = await revertDeletedPage(deletedPage, dummyUser1, {}, false, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
       const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
@@ -2055,7 +2143,10 @@ describe('PageService page operations with only public pages', () => {
       expect(revision1).toBeTruthy();
       expect(revision1).toBeTruthy();
       expect(revision2).toBeTruthy();
       expect(revision2).toBeTruthy();
 
 
-      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true);
+      const revertedPage1 = await revertDeletedPage(deletedPage1, dummyUser1, {}, true, {
+        ip: '::ffff:127.0.0.1',
+        endpoint: '/_api/v3/pages/revert',
+      });
       const revertedPage2 = await Page.findOne({ _id: deletedPage2._id });
       const revertedPage2 = await Page.findOne({ _id: deletedPage2._id });
       const newlyCreatedPage = await Page.findOne({ path: '/v5_revert2/v5_revert3' });
       const newlyCreatedPage = await Page.findOne({ path: '/v5_revert2/v5_revert3' });
 
 

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

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

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

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

+ 1 - 1
packages/slack/package.json

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

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

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

+ 1 - 1
packages/ui/package.json

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

+ 83 - 28
yarn.lock

@@ -1715,6 +1715,13 @@
   dependencies:
   dependencies:
     "@browser-bunyan/levels" "^1.6.0"
     "@browser-bunyan/levels" "^1.6.0"
 
 
+"@browser-bunyan/console-formatted-stream@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.8.0.tgz#dda9dcab6ce445cbf2911045709930757e5d48c1"
+  integrity sha512-Lg5SC2uXrvZ6aLwLZT6SErfN1Is4NcrTOb5km4BW/BfL8Lv0CfpsYuhuD7ltdURL6awTYBUiT+BwhKw1Xd9glQ==
+  dependencies:
+    "@browser-bunyan/levels" "^1.8.0"
+
 "@browser-bunyan/console-plain-stream@^1.6.0":
 "@browser-bunyan/console-plain-stream@^1.6.0":
   version "1.6.0"
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.6.0.tgz#295404482150e7693846ccb07045676218bcc911"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.6.0.tgz#295404482150e7693846ccb07045676218bcc911"
@@ -1722,6 +1729,13 @@
   dependencies:
   dependencies:
     "@browser-bunyan/levels" "^1.6.0"
     "@browser-bunyan/levels" "^1.6.0"
 
 
+"@browser-bunyan/console-plain-stream@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.8.0.tgz#18cd8fe879a0f576cf84c4fa4647e86cd3feea3e"
+  integrity sha512-S0WNsH5zvMfkbayIx90wANGHQ8l3Bvd7mjgy95/bYmUzcI+Mwkv2eJcSufdTP/MbdHBhjv/lEdLDOXEPBi+w3A==
+  dependencies:
+    "@browser-bunyan/levels" "^1.8.0"
+
 "@browser-bunyan/console-raw-stream@^1.6.0":
 "@browser-bunyan/console-raw-stream@^1.6.0":
   version "1.6.0"
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.6.0.tgz#255f4734c064dc046fe7896353982c563e2ec150"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.6.0.tgz#255f4734c064dc046fe7896353982c563e2ec150"
@@ -1729,11 +1743,23 @@
   dependencies:
   dependencies:
     "@browser-bunyan/levels" "^1.6.0"
     "@browser-bunyan/levels" "^1.6.0"
 
 
+"@browser-bunyan/console-raw-stream@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.8.0.tgz#5d0438139bbffd9ed779241df6ae7e5f3a2a7b0c"
+  integrity sha512-6M/xEiNckbFslQMaS1BHAxvuvN1Wtbh/aq4UzQD3fjEPFCxtubvf4KyzwPxUXA5CXq7leVZ+cibEUCRBsm5bzg==
+  dependencies:
+    "@browser-bunyan/levels" "^1.8.0"
+
 "@browser-bunyan/levels@^1.6.0":
 "@browser-bunyan/levels@^1.6.0":
   version "1.6.0"
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.6.0.tgz#3a50b8118254aa2ac26caf9d2aafa72d157e374b"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.6.0.tgz#3a50b8118254aa2ac26caf9d2aafa72d157e374b"
   integrity sha512-wte6nXXZH62Y/RGysYRlOkKxuJn+4S8xEamMF0fDncxxy0SriCHYwGPyWGF0FWYWmRzbZuEkp7dNebBf9Xfeeg==
   integrity sha512-wte6nXXZH62Y/RGysYRlOkKxuJn+4S8xEamMF0fDncxxy0SriCHYwGPyWGF0FWYWmRzbZuEkp7dNebBf9Xfeeg==
 
 
+"@browser-bunyan/levels@^1.8.0":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@browser-bunyan/levels/-/levels-1.8.0.tgz#1c0a98d04284e0620e8ee414d7ce43385080a5cf"
+  integrity sha512-f9oSDik8kAl+4rhVyHqIr012P1boHFUKc7D9nzA5+lDsFoP90UQnDwpseqBdF2mTaWYju10E7h+GdH8u+7MHOQ==
+
 "@cspotcode/source-map-support@^0.8.0":
 "@cspotcode/source-map-support@^0.8.0":
   version "0.8.1"
   version "0.8.1"
   resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
   resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1"
@@ -5792,6 +5818,16 @@ browser-bunyan@^1.6.3:
     "@browser-bunyan/console-raw-stream" "^1.6.0"
     "@browser-bunyan/console-raw-stream" "^1.6.0"
     "@browser-bunyan/levels" "^1.6.0"
     "@browser-bunyan/levels" "^1.6.0"
 
 
+browser-bunyan@^1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/browser-bunyan/-/browser-bunyan-1.8.0.tgz#6b9662fea571c642fce80ad002d62e3ea1453393"
+  integrity sha512-Et1TaRUm8m2oy4OTi69g0qAM8wqpofACUgkdBnj1Kq2aC8Wpl8w+lNevebPG6zKH2w0Aq+BHiAXWwjm0/QbkaQ==
+  dependencies:
+    "@browser-bunyan/console-formatted-stream" "^1.8.0"
+    "@browser-bunyan/console-plain-stream" "^1.8.0"
+    "@browser-bunyan/console-raw-stream" "^1.8.0"
+    "@browser-bunyan/levels" "^1.8.0"
+
 browser-or-node@>=1.2.1:
 browser-or-node@>=1.2.1:
   version "1.2.1"
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/browser-or-node/-/browser-or-node-1.2.1.tgz#cd65172da6a7fd689c7a650d326bd2ad145419a7"
   resolved "https://registry.yarnpkg.com/browser-or-node/-/browser-or-node-1.2.1.tgz#cd65172da6a7fd689c7a650d326bd2ad145419a7"
@@ -11594,10 +11630,10 @@ inquirer@7.1.0:
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
     through "^2.3.6"
     through "^2.3.6"
 
 
-inquirer@8.1.5:
-  version "8.1.5"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.1.5.tgz#2dc5159203c826d654915b5fe6990fd17f54a150"
-  integrity sha512-G6/9xUqmt/r+UvufSyrPpt84NYwhKZ9jLsgMbQzlx804XErNupor8WQdBnBRrXmBfTPpuwf1sV+ss2ovjgdXIg==
+inquirer@8.2.1:
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.1.tgz#e00022e3e8930a92662f760f020686530a84671d"
+  integrity sha512-pxhBaw9cyTFMjwKtkjePWDhvwzvrNGAw7En4hottzlPvz80GZaMZthdDU35aA6/f5FRZf3uhE057q8w1DE3V2g==
   dependencies:
   dependencies:
     ansi-escapes "^4.2.1"
     ansi-escapes "^4.2.1"
     chalk "^4.1.1"
     chalk "^4.1.1"
@@ -11609,7 +11645,7 @@ inquirer@8.1.5:
     mute-stream "0.0.8"
     mute-stream "0.0.8"
     ora "^5.4.1"
     ora "^5.4.1"
     run-async "^2.4.0"
     run-async "^2.4.0"
-    rxjs "^7.2.0"
+    rxjs "^7.5.5"
     string-width "^4.1.0"
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
     through "^2.3.6"
     through "^2.3.6"
@@ -13914,10 +13950,10 @@ markdown-it-blockdiag@^1.1.1:
     url-join "^4.0.0"
     url-join "^4.0.0"
     utf8-bytes "0.0.1"
     utf8-bytes "0.0.1"
 
 
-markdown-it-drawio-viewer@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.3.1.tgz#96ec4d02c159cb1ccb07760ea3ce7bfb07ac8730"
-  integrity sha512-jNjKM6ULboy1VCYePZkNzqRUZDGrhlvo7giIGSnSX2X4DAWSTZ4bGtH0gBUGsRVRjj2BjsRm0mah8RMi8I1qGQ==
+markdown-it-drawio-viewer@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.4.0.tgz#4a4b590775a75f85e8c83b9042018b57657a172f"
+  integrity sha512-2xorLkpvwl+ncqlvF2CWEFRqfmYjq/SNy2Avc0tW8am+1BpoaIHfyM+sHH6XoLEJ3ZgkAsNnkIjmxssqNsbWBg==
   dependencies:
   dependencies:
     "@kaishuu0123/markdown-it-fence" "^1.0.1"
     "@kaishuu0123/markdown-it-fence" "^1.0.1"
     xmldoc "^1.1.2"
     xmldoc "^1.1.2"
@@ -18278,14 +18314,14 @@ reg-publish-s3-plugin@^0.11.0:
     reg-suit-util "^0.11.0"
     reg-suit-util "^0.11.0"
     uuid "^8.3.0"
     uuid "^8.3.0"
 
 
-reg-suit-core@^0.11.1:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/reg-suit-core/-/reg-suit-core-0.11.1.tgz#e554fab4da79a6caf2c8a312fadbdc4539a1583e"
-  integrity sha512-v3U6c8Mn8f9pz44YrnvxCLCRUWDs4t86/55XfBtxt3LGB+QxN9ekK2dNRPk67UsX3OZoB1n1dSjAJONTlWpNPw==
+reg-suit-core@^0.12.1:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/reg-suit-core/-/reg-suit-core-0.12.1.tgz#511f63d2053a5bb76181d994074bbbb6ca432848"
+  integrity sha512-lc8MSax1CAZVJgps3EjhRIsHjfUFZun0FjC+FOwjKXohWOq+z0HfI8+IfCjhMHNbkG2kIa/YcnQF6Zd0/X/Usw==
   dependencies:
   dependencies:
     cpx "^1.5.0"
     cpx "^1.5.0"
     reg-cli "^0.17.0"
     reg-cli "^0.17.0"
-    reg-suit-util "^0.11.0"
+    reg-suit-util "^0.12.1"
     rimraf "^3.0.2"
     rimraf "^3.0.2"
 
 
 reg-suit-util@^0.11.0:
 reg-suit-util@^0.11.0:
@@ -18307,16 +18343,35 @@ reg-suit-util@^0.11.0:
     mime-types "^2.1.27"
     mime-types "^2.1.27"
     mkdirp "^1.0.4"
     mkdirp "^1.0.4"
 
 
-reg-suit@^0.11.1:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/reg-suit/-/reg-suit-0.11.1.tgz#2bc7180873cf793724825eb492e5396a2d95daa3"
-  integrity sha512-tGCPEoQhHcUn4oZGj5nu26yaFEpksPHUwGPoKpBxSV5ldmwS/zY13KyJZaetlZh/IV7HTxLP/i9TXBlhzc3QWw==
+reg-suit-util@^0.12.1:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/reg-suit-util/-/reg-suit-util-0.12.1.tgz#ceec40cf116ec4986d151b0af96d9fe49a60fb9e"
+  integrity sha512-w/cLYCBX8ULDsSZEJHArOuaWQms/YErFFhMsnKClvuf/mlvRQgok/zKcksaYoyAQVe/seY+/SRnHdPVtw5YViQ==
+  dependencies:
+    "@types/cli-progress" "^3.8.0"
+    "@types/cli-spinner" "^0.2.0"
+    "@types/glob" "^7.1.3"
+    "@types/lodash" "^4.14.161"
+    "@types/mime-types" "^2.1.0"
+    "@types/mkdirp" "^1.0.1"
+    chalk "^4.1.0"
+    cli-progress "^3.8.2"
+    cli-spinner "^0.2.6"
+    glob "^7.1.6"
+    lodash "^4.17.20"
+    mime-types "^2.1.27"
+    mkdirp "^1.0.4"
+
+reg-suit@^0.12.1:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/reg-suit/-/reg-suit-0.12.1.tgz#c6897c1e909d932673dc9ed93f0ce544b8c0262f"
+  integrity sha512-aXVcRK7fVE582F+iv3VEJEsnJ3g7CfF1pjeSS+WbObCYBN+FucO3eMMq8AvgYDwhH7oc0eTBofW7Nc0Ko2zumg==
   dependencies:
   dependencies:
     cp-file "9.1.0"
     cp-file "9.1.0"
     ignore "5.1.8"
     ignore "5.1.8"
-    inquirer "8.1.5"
-    reg-suit-core "^0.11.1"
-    reg-suit-util "^0.11.0"
+    inquirer "8.2.1"
+    reg-suit-core "^0.12.1"
+    reg-suit-util "^0.12.1"
     yargs "17.0.1"
     yargs "17.0.1"
 
 
 regenerator-runtime@^0.11.0:
 regenerator-runtime@^0.11.0:
@@ -18797,13 +18852,6 @@ rxjs@^6.5.3, rxjs@^6.6.0:
   dependencies:
   dependencies:
     tslib "^1.9.0"
     tslib "^1.9.0"
 
 
-rxjs@^7.2.0:
-  version "7.5.2"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.2.tgz#11e4a3a1dfad85dbf7fb6e33cbba17668497490b"
-  integrity sha512-PwDt186XaL3QN5qXj/H9DGyHhP3/RYYgZZwqBv9Tv8rsAaiwFH1IsJJlcgD37J7UW5a6O67qX0KWKS3/pu0m4w==
-  dependencies:
-    tslib "^2.1.0"
-
 rxjs@^7.4.0:
 rxjs@^7.4.0:
   version "7.5.1"
   version "7.5.1"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.1.tgz#af73df343cbcab37628197f43ea0c8256f54b157"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.1.tgz#af73df343cbcab37628197f43ea0c8256f54b157"
@@ -18811,6 +18859,13 @@ rxjs@^7.4.0:
   dependencies:
   dependencies:
     tslib "^2.1.0"
     tslib "^2.1.0"
 
 
+rxjs@^7.5.5:
+  version "7.5.6"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.6.tgz#0446577557862afd6903517ce7cae79ecb9662bc"
+  integrity sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==
+  dependencies:
+    tslib "^2.1.0"
+
 safe-buffer@5.1.1:
 safe-buffer@5.1.1:
   version "5.1.1"
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"