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

Merge branch 'feat/page-rename-v5' into imprv/page-function-v5

Taichi Masuyama 4 лет назад
Родитель
Сommit
0a22306125
100 измененных файлов с 3835 добавлено и 1730 удалено
  1. 1 0
      .stylelintrc.json
  2. 67 1
      CHANGELOG.md
  3. 3 3
      README_JP.md
  4. 5 1
      package.json
  5. 1 0
      packages/app/.stylelintrc.json
  6. 3 3
      packages/app/config/webpack.prod.js
  7. 41 36
      packages/app/docker/Dockerfile
  8. 5 6
      packages/app/docker/Dockerfile.dockerignore
  9. 2 2
      packages/app/docker/README.md
  10. 11 11
      packages/app/package.json
  11. 21 2
      packages/app/resource/locales/en_US/translation.json
  12. 20 1
      packages/app/resource/locales/ja_JP/translation.json
  13. 20 1
      packages/app/resource/locales/zh_CN/translation.json
  14. 5 1
      packages/app/src/client/app.jsx
  15. 0 27
      packages/app/src/client/legacy/crowi.js
  16. 23 9
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  17. 1 0
      packages/app/src/client/services/EditorContainer.js
  18. 56 10
      packages/app/src/client/services/PageContainer.js
  19. 6 2
      packages/app/src/client/util/apiv1-client.ts
  20. 0 118
      packages/app/src/components/Admin/UserGroup/UserGroupCreateForm.jsx
  21. 0 216
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  22. 219 0
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  23. 119 0
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  24. 0 152
      packages/app/src/components/Admin/UserGroup/UserGroupPage.jsx
  25. 158 0
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  26. 0 157
      packages/app/src/components/Admin/UserGroup/UserGroupTable.jsx
  27. 187 0
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  28. 0 49
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  29. 168 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  30. 0 111
      packages/app/src/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  31. 83 0
      packages/app/src/components/BookmarkButtons.tsx
  32. 37 6
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  33. 9 10
      packages/app/src/components/EventListeneres/HashChanged.tsx
  34. 0 33
      packages/app/src/components/ExpandOrContractButton.jsx
  35. 37 0
      packages/app/src/components/ExpandOrContractButton.tsx
  36. 49 15
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  37. 1 1
      packages/app/src/components/Navbar/SubNavButtons.tsx
  38. 21 19
      packages/app/src/components/Page/PageListItem.tsx
  39. 1 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  40. 31 1
      packages/app/src/components/Page/TagLabels.jsx
  41. 35 45
      packages/app/src/components/PageEditor/AbstractEditor.tsx
  42. 4 3
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  43. 282 0
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  44. 100 83
      packages/app/src/components/PageEditor/Editor.jsx
  45. 52 1
      packages/app/src/components/PageStatusAlert.jsx
  46. 8 0
      packages/app/src/components/SavePageControls.jsx
  47. 10 0
      packages/app/src/components/SearchPage.jsx
  48. 2 2
      packages/app/src/components/SearchPage/SearchResultList.tsx
  49. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  50. 42 5
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  51. 10 7
      packages/app/src/components/Sidebar/RecentChanges.tsx
  52. 4 0
      packages/app/src/components/Sidebar/SidebarContents.tsx
  53. 2 0
      packages/app/src/components/Sidebar/SidebarNav.tsx
  54. 44 0
      packages/app/src/components/Sidebar/Tag.tsx
  55. 38 0
      packages/app/src/components/TagCloudBox.tsx
  56. 44 18
      packages/app/src/components/TagsList.jsx
  57. 58 0
      packages/app/src/components/UncontrolledCodeMirror.tsx
  58. 2 2
      packages/app/src/components/UnstatedUtils.tsx
  59. 7 0
      packages/app/src/interfaces/bookmarks.ts
  60. 3 0
      packages/app/src/interfaces/global.ts
  61. 2 1
      packages/app/src/interfaces/page.ts
  62. 7 0
      packages/app/src/interfaces/revision.ts
  63. 1 0
      packages/app/src/interfaces/ui.ts
  64. 13 0
      packages/app/src/interfaces/user-group-response.ts
  65. 11 3
      packages/app/src/interfaces/user.ts
  66. 70 0
      packages/app/src/migrations/20210921173042-add-is-trashed-field.js
  67. 3 0
      packages/app/src/server/crowi/express-init.js
  68. 10 4
      packages/app/src/server/crowi/index.js
  69. 0 1
      packages/app/src/server/models/index.js
  70. 44 40
      packages/app/src/server/models/obsolete-page.js
  71. 45 25
      packages/app/src/server/models/page-tag-relation.js
  72. 351 47
      packages/app/src/server/models/page.ts
  73. 43 2
      packages/app/src/server/models/user-group-relation.js
  74. 0 133
      packages/app/src/server/models/user-group.js
  75. 134 0
      packages/app/src/server/models/user-group.ts
  76. 5 1
      packages/app/src/server/models/vo/s2c-message.js
  77. 1 1
      packages/app/src/server/routes/admin.js
  78. 8 2
      packages/app/src/server/routes/apiv3/bookmarks.js
  79. 2 1
      packages/app/src/server/routes/apiv3/page-listing.ts
  80. 7 5
      packages/app/src/server/routes/apiv3/pages.js
  81. 19 61
      packages/app/src/server/routes/apiv3/user-group-relation.js
  82. 57 36
      packages/app/src/server/routes/apiv3/user-group.js
  83. 11 2
      packages/app/src/server/routes/page.js
  84. 16 22
      packages/app/src/server/routes/tag.js
  85. 6 0
      packages/app/src/server/service/config-loader.ts
  86. 333 0
      packages/app/src/server/service/page-grant.ts
  87. 193 98
      packages/app/src/server/service/page.ts
  88. 2 1
      packages/app/src/server/service/passport.ts
  89. 2 3
      packages/app/src/server/service/search.ts
  90. 0 25
      packages/app/src/server/service/user-group.js
  91. 115 0
      packages/app/src/server/service/user-group.ts
  92. 2 1
      packages/app/src/server/util/apiResponse.js
  93. 43 0
      packages/app/src/server/util/compare-objectId.ts
  94. 2 5
      packages/app/src/server/views/tags.html
  95. 11 9
      packages/app/src/stores/bookmark.ts
  96. 21 0
      packages/app/src/stores/bookmarks.tsx
  97. 2 2
      packages/app/src/stores/context.tsx
  98. 2 1
      packages/app/src/stores/page.tsx
  99. 36 25
      packages/app/src/stores/ui.tsx
  100. 45 0
      packages/app/src/stores/user-group.tsx

+ 1 - 0
.stylelintrc.json

@@ -2,6 +2,7 @@
   "extends": [
   "extends": [
     "stylelint-config-recess-order"
     "stylelint-config-recess-order"
   ],
   ],
+  "customSyntax": "postcss-scss",
   "rules": {
   "rules": {
     "indentation": 2,
     "indentation": 2,
     "string-quotes": "single",
     "string-quotes": "single",

+ 67 - 1
CHANGELOG.md

@@ -1,9 +1,75 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v4.5.4...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v4.5.8...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.*
 
 
+## [v4.5.8](https://github.com/weseek/growi/compare/v4.5.7...v4.5.8) - 2022-01-12
+
+### 💎 Features
+
+- feat: Display a list of bookmarked users (#5044) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Built-in editor scroll position is reset after save (Introduced by v4.5.3) (#5074) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump y18n to v4.0.3 (#5071) @yuki-takei
+- support: Omit prettier-stylelint (#5070) @yuki-takei
+- support: Bump tar to 6.1.11 (#5069) @yuki-takei
+
+## [v4.5.7](https://github.com/weseek/growi/compare/v4.5.6...v4.5.7) - 2022-01-11
+
+### 🐛 Bug Fixes
+
+- fix: Subnavigation sticking initialization (#5062) @yuki-takei
+- fix: Built-in editor was broken (#5061) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Bump re2 to 1.17.2 (#5059) @yuki-takei
+
+## [v4.5.6](https://github.com/weseek/growi/compare/v4.5.5...v4.5.6) - 2022-01-07
+
+### 💎 Features
+
+- feat: Resolve conflict with 3-way merge like editor (#5012) @yuto-oweseek
+
+### 🚀 Improvement
+
+- imprv: Subnavigation behavior (#5047) @yuki-takei
+- imprv: Switching editor mode behavior (#5043) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by #5043 @yuki-takei
+
+## [v4.5.5](https://github.com/weseek/growi/compare/v4.5.4...v4.5.5) - 2022-01-05
+
+### 💎 Features
+
+- feat: OIDC reconnection (#5016) @mudana-grune
+- feat: In-App Notification (#4792) @kaoritokashiki
+
+### 🚀 Improvement
+
+- imprv: Improve tags functions (#5001) @yuto-oweseek
+- imprv: Migrate editor container grant to SWR (#4957) @stevenfukase
+
+### 🐛 Bug Fixes
+
+- Bug: Error: The specified instance couldn't register because same id has already been registered (#5031) by 573216c @yuki-takei
+
+### 🧰 Maintenance
+
+- fix: dependabot alert trim-newlines (#4931) @mudana-grune
+- fix: dependabot alert dot-prop (#4921) @mudana-grune
+- ci(deps-dev): bump tsconfig-paths-webpack-plugin from 3.5.1 to 3.5.2 (#4852) @dependabot
+- ci(deps): bump ua-parser-js from 0.7.17 to 0.7.31 (#4895) @dependabot
+- support: dependabot alert ssri (#4973) @mudana-grune
+
 ## [v4.5.4](https://github.com/weseek/growi/compare/v4.5.3...v4.5.4) - 2021-12-23
 ## [v4.5.4](https://github.com/weseek/growi/compare/v4.5.3...v4.5.4) - 2021-12-23
 
 
 ### 💎 Features
 ### 💎 Features

+ 3 - 3
README_JP.md

@@ -38,15 +38,15 @@
 # 機能紹介
 # 機能紹介
 
 
 - **主な機能**
 - **主な機能**
-  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html))
-  - HackMD(CodiMd)[https://hackmd.io/] と連携することで同時多人数編集が可能です。
+  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html)。
+  - [HackMD(CodiMd)](https://hackmd.io/) と連携することで同時多人数編集が可能です。
     - [GROWI Docs: HackMD(CodiMD) 連携](https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html)
     - [GROWI Docs: HackMD(CodiMD) 連携](https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html)
   - LDAP / Active Direcotry , OAuth 認証をサポートしています。
   - LDAP / Active Direcotry , OAuth 認証をサポートしています。
   - SAML を用いた Single Sign On が可能です。
   - SAML を用いた Single Sign On が可能です。
   - Slack / Mattermost, IFTTT と連携することが可能です。
   - Slack / Mattermost, IFTTT と連携することが可能です。
   - [GROWI Docs: 機能紹介](https://docs.growi.org/ja/guide/features/page_layout.html)
   - [GROWI Docs: 機能紹介](https://docs.growi.org/ja/guide/features/page_layout.html)
 - **プラグイン**
 - **プラグイン**
-  - [npm](https://www.npmjs.com/browse/keyword/growi-plugin) または [github](https://github.com/search?q=topic%3Agrowi-plugin) から 便利なプラグインを見つけることができます。
+  - [npm](https://www.npmjs.com/browse/keyword/growi-plugin) または [GitHub](https://github.com/search?q=topic%3Agrowi-plugin) から 便利なプラグインを見つけることができます。
 - **[Docker の準備][dockerhub]**
 - **[Docker の準備][dockerhub]**
 - **[Docker Compose の準備][docker-compose]**
 - **[Docker Compose の準備][docker-compose]**
   - [GROWI Docs: 複数の GROWI を起動](https://docs.growi.org/ja/admin-guide/admin-cookbook/multi-app.html)
   - [GROWI Docs: 複数の GROWI を起動](https://docs.growi.org/ja/admin-guide/admin-cookbook/multi-app.html)

+ 5 - 1
package.json

@@ -67,8 +67,12 @@
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
     "lerna": "^4.0.0",
+    "postcss": "^8.4.5",
+    "postcss-scss": "^4.0.3",
     "rewire": "^5.0.0",
     "rewire": "^5.0.0",
-    "shipjs": "^0.23.3",
+    "shipjs": "^0.24.1",
+    "stylelint": "^14.2.0",
+    "stylelint-config-recess-order": "^3.0.0",
     "ts-jest": "^27.0.4",
     "ts-jest": "^27.0.4",
     "ts-node": "^9.1.1",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",

+ 1 - 0
packages/app/.stylelintrc.json

@@ -2,6 +2,7 @@
   "extends": [
   "extends": [
     "stylelint-config-recess-order"
     "stylelint-config-recess-order"
   ],
   ],
+  "customSyntax": "postcss-scss",
   "ignoreFiles": [
   "ignoreFiles": [
     "src/styles/_override-bootstrap-variables.scss",
     "src/styles/_override-bootstrap-variables.scss",
     "src/linter-checker/test.scss"
     "src/linter-checker/test.scss"

+ 3 - 3
packages/app/config/webpack.prod.js

@@ -35,10 +35,10 @@ module.exports = require('./webpack.common')({
             loader: 'postcss-loader',
             loader: 'postcss-loader',
             options: {
             options: {
               sourceMap: false,
               sourceMap: false,
-              plugins: () => {
-                return [
+              postcssOptions: {
+                plugins: [
                   require('autoprefixer')(),
                   require('autoprefixer')(),
-                ];
+                ],
               },
               },
             },
             },
           },
           },

+ 41 - 36
packages/app/docker/Dockerfile

@@ -1,36 +1,40 @@
-# syntax = docker/dockerfile:experimental
+# syntax = docker/dockerfile:1
 
 
 ARG flavor=default
 ARG flavor=default
 
 
 
 
+##
+## packages-json-picker
+##
+FROM node:14-slim AS packages-json-picker
+
+ENV optDir /opt
+
+WORKDIR ${optDir}
+COPY ["package.json", "yarn.lock", "lerna.json", "./"]
+COPY packages packages
+# Find and remove non-package.json files
+RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
+
 
 
 ##
 ##
 ## deps-resolver
 ## deps-resolver
 ##
 ##
 FROM node:14-slim AS deps-resolver
 FROM node:14-slim AS deps-resolver
-LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
 
-WORKDIR ${appDir}
-COPY ./package.json .
-COPY ./yarn.lock .
-COPY ./lerna.json .
-COPY ./packages/app/package.json packages/app/
-COPY ./packages/core/package.json packages/core/
-COPY ./packages/codemirror-textlint/package.json packages/codemirror-textlint/
-COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
-COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
-COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
-COPY ./packages/slack/package.json packages/slack/
-COPY ./packages/ui/package.json packages/ui/
+WORKDIR ${optDir}
+
+# copy files
+COPY --from=packages-json-picker ${optDir} .
 
 
 # setup
 # setup
 RUN yarn config set network-timeout 300000
 RUN yarn config set network-timeout 300000
-RUN npx lerna bootstrap
+RUN npx lerna bootstrap -- --frozen-lockfile
 
 
 # make artifacts
 # make artifacts
-RUN tar cf node_modules.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
   node_modules \
   packages/*/node_modules
   packages/*/node_modules
 
 
@@ -40,9 +44,13 @@ RUN tar cf node_modules.tar \
 ## deps-resolver-prod
 ## deps-resolver-prod
 ##
 ##
 FROM deps-resolver AS deps-resolver-prod
 FROM deps-resolver AS deps-resolver-prod
+
+# remove unnecessary packages
+RUN rm -rf packages/slackbot-proxy
+
 RUN npx lerna bootstrap -- --production
 RUN npx lerna bootstrap -- --production
 # make artifacts
 # make artifacts
-RUN tar cf node_modules.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
   node_modules \
   packages/*/node_modules
   packages/*/node_modules
 
 
@@ -53,16 +61,16 @@ RUN tar cf node_modules.tar \
 ##
 ##
 FROM node:14-slim AS prebuilder-default
 FROM node:14-slim AS prebuilder-default
 
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
 
 # copy dependent packages
 # copy dependent packages
 COPY --from=deps-resolver \
 COPY --from=deps-resolver \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${optDir}/
 
 
 # extract node_modules.tar
 # extract node_modules.tar
-RUN tar xf node_modules.tar
+RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 RUN rm node_modules.tar
 
 
 
 
@@ -73,7 +81,7 @@ RUN rm node_modules.tar
 FROM prebuilder-default AS prebuilder-nocdn
 FROM prebuilder-default AS prebuilder-nocdn
 
 
 # add dotenv file for NO_CDN
 # add dotenv file for NO_CDN
-COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
+COPY packages/app/docker/nocdn/.env.production.local ${optDir}/packages/app/
 
 
 
 
 
 
@@ -82,14 +90,11 @@ COPY packages/app/docker/nocdn/.env.production.local ${appDir}/packages/app/
 ##
 ##
 FROM prebuilder-${flavor} AS builder
 FROM prebuilder-${flavor} AS builder
 
 
-ENV appDir /opt/growi
+ENV optDir /opt
 
 
-WORKDIR ${appDir}
+WORKDIR ${optDir}
 
 
-COPY ./package.json ./
-COPY ./yarn.lock ./
-COPY ./lerna.json ./
-COPY ./tsconfig.base.json ./
+COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 # copy all related packages
 COPY packages/app packages/app
 COPY packages/app packages/app
 COPY packages/core packages/core
 COPY packages/core packages/core
@@ -104,9 +109,8 @@ COPY packages/ui packages/ui
 RUN yarn lerna run build
 RUN yarn lerna run build
 
 
 # make artifacts
 # make artifacts
-RUN tar cf packages.tar \
+RUN tar -cf packages.tar \
   package.json \
   package.json \
-  yarn.lock \
   tsconfig.base.json \
   tsconfig.base.json \
   packages/app/config \
   packages/app/config \
   packages/app/public \
   packages/app/public \
@@ -129,7 +133,8 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 
 ENV NODE_ENV production
 ENV NODE_ENV production
 
 
-ENV appDir /opt/growi
+ENV optDir /opt
+ENV appDir ${optDir}/growi
 
 
 # Add gosu
 # Add gosu
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
@@ -141,15 +146,15 @@ RUN set -eux; \
 	gosu nobody true
 	gosu nobody true
 
 
 COPY --from=deps-resolver-prod --chown=node:node \
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${optDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
 COPY --from=builder --chown=node:node \
-  ${appDir}/packages.tar ${appDir}/
+  ${optDir}/packages.tar ${appDir}/
 
 
 # extract artifacts as 'node' user
 # extract artifacts as 'node' user
 USER node
 USER node
 WORKDIR ${appDir}
 WORKDIR ${appDir}
-RUN tar xf node_modules.tar
-RUN tar xf packages.tar
+RUN tar -xf node_modules.tar
+RUN tar -xf packages.tar
 RUN rm node_modules.tar packages.tar
 RUN rm node_modules.tar packages.tar
 
 
 USER root
 USER root

+ 5 - 6
packages/app/docker/Dockerfile.dockerignore

@@ -1,6 +1,5 @@
-node_modules
-*/node_modules
-*/coverage
-*/dist
-*/Dockerfile
-*/*.dockerignore
+**/node_modules
+**/dist
+**/coverage
+**/Dockerfile
+**/*.dockerignore

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

@@ -12,8 +12,8 @@ Supported tags and respective Dockerfile links
 
 
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
 * [`5.0.0-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.0/docker/Dockerfile)
-* [`4.5.4`, `4.5`, `4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
-* [`4.5.4-nocdn`, `4.5-nocdn`, `4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.4/docker/Dockerfile)
+* [`4.5.8`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
+* [`4.5.8-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.8/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 
 

+ 11 - 11
packages/app/package.json

@@ -6,7 +6,7 @@
     "//// for production": "",
     "//// for production": "",
     "start": "yarn build && yarn server",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
     "build": "run-p build:*",
-    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
@@ -33,7 +33,7 @@
     "predev:ci": "run-p resources:*",
     "predev:ci": "run-p resources:*",
     "lint:typecheck": "npx tsc",
     "lint:typecheck": "npx tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
-    "lint:styles": "stylelint src/**/*.scss --custom-syntax postcss-scss",
+    "lint:styles": "stylelint src/**/*.scss",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "lint": "run-p lint:*",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
@@ -51,7 +51,6 @@
   "// comments for dependencies": {
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "mongoose": "5.13.13 causes an error like 't.versions.node is undefined' about 'browser.umd.js' on browser",
     "string-width": "5.0.0 or above exports only ESM."
     "string-width": "5.0.0 or above exports only ESM."
   },
   },
   "dependencies": {
   "dependencies": {
@@ -78,6 +77,7 @@
     "browser-bunyan": "^1.6.3",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "check-node-version": "^4.1.0",
+    "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",
     "connect-mongo": "^4.6.0",
     "connect-redis": "^4.0.4",
     "connect-redis": "^4.0.4",
@@ -86,6 +86,7 @@
     "date-fns": "^2.23.0",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff": "^5.0.0",
+    "diff_match_patch": "^0.1.1",
     "elasticsearch": "^16.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
     "esa-nodejs": "^0.0.7",
@@ -122,6 +123,7 @@
     "nodemailer": "^6.6.2",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
     "openid-client": "=2.5.0",
+    "p-retry": "^4.0.0",
     "passport": "^0.5.0",
     "passport": "^0.5.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-google-oauth20": "^2.0.0",
@@ -130,12 +132,13 @@
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
-    "p-retry": "^4.0.0",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
-    "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
+    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
@@ -232,10 +235,8 @@
     "sticky-events": "^3.4.11",
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",
     "styled-components": "^5.0.1",
-    "stylelint": "^14.0.1",
-    "stylelint-config-recess-order": "^2.0.1",
     "swagger2openapi": "^5.3.1",
     "swagger2openapi": "^5.3.1",
-    "swr": "^1.0.1",
+    "swr": "^1.1.2",
     "terser-webpack-plugin": "^4.1.0",
     "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
@@ -244,10 +245,9 @@
     "tsc-alias": "^1.2.9",
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
-    "webpack": "^4.39.3",
+    "webpack": "^4.46.0",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.9.0",
     "webpack-bundle-analyzer": "^3.9.0",
-    "webpack-cli": "^3.3.7",
-    "webpack-merge": "^4.2.2"
+    "webpack-cli": "^4.9.1"
   }
   }
 }
 }

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

@@ -21,6 +21,7 @@
   "Done": "Done",
   "Done": "Done",
   "Cancel": "Cancel",
   "Cancel": "Cancel",
   "Create": "Create",
   "Create": "Create",
+  "Description": "Description",
   "Admin": "Admin",
   "Admin": "Admin",
   "administrator": "Admin",
   "administrator": "Admin",
   "Tag": "Tag",
   "Tag": "Tag",
@@ -60,6 +61,7 @@
   "The end": "The end",
   "The end": "The end",
   "Not available for guest": "Not available for guest",
   "Not available for guest": "Not available for guest",
   "No users have liked this yet.": "No users have liked this yet.",
   "No users have liked this yet.": "No users have liked this yet.",
+  "No users have bookmarked yet": "No users have bookmarked yet",
   "Create Archive Page": "Create Archive Page",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
   "File type": "File type",
   "Target page": "Target page",
   "Target page": "Target page",
@@ -119,6 +121,7 @@
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "Legacy_Slack_Integration": "Legacy Slack Integration",
   "User_Management": "User Management",
   "User_Management": "User Management",
   "external_account_management": "External Account Management",
   "external_account_management": "External Account Management",
+  "UserGroup": "UserGroup",
   "UserGroup Management": "UserGroup Management",
   "UserGroup Management": "UserGroup Management",
   "Full Text Search Management": "Full Text Search Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Import Data": "Import Data",
@@ -142,6 +145,7 @@
   "Shareable link": "Shareable link",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
   "Add tags for this page": "Add tags for this page",
+  "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Show latest": "Show latest",
   "Load latest": "Load latest",
   "Load latest": "Load latest",
@@ -151,7 +155,8 @@
   "Sign out": "Logout",
   "Sign out": "Logout",
   "Disassociate": "Disassociate",
   "Disassociate": "Disassociate",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
-  "Add to bookmark": "Add to bookmark",
+  "add_bookmark": "Add to Bookmarks",
+  "remove_bookmark": "Remove from Bookmarks",
   "Recent Created": "Recent Created",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
   "Page Tree": "Page Tree",
@@ -243,7 +248,7 @@
     "expire": "Expiration",
     "expire": "Expiration",
     "Days": "Days",
     "Days": "Days",
     "Custom": "Custom",
     "Custom": "Custom",
-    "description": "description",
+    "description": "Description",
     "enter_desc": "Enter description",
     "enter_desc": "Enter description",
     "Unlimited": "unlimited",
     "Unlimited": "unlimited",
     "Issue": "Issue",
     "Issue": "Issue",
@@ -484,6 +489,17 @@
     "enable_textlint": "Enable Textlint",
     "enable_textlint": "Enable Textlint",
     "dont_ask_again": "Don't ask again"
     "dont_ask_again": "Don't ask again"
   },
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
+    "resolve_conflict_message": "Please select page body",
+    "resolve_conflict": "Resolve Conflict",
+    "resolve_and_save" : "Resolve and save",
+    "select_revision" : "Select {{revision}}",
+    "requested_revision": "mine",
+    "origin_revision": "origin",
+    "latest_revision": "theirs",
+    "selected_editable_revision": "Selected Page Body (Editable)"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",
@@ -501,7 +517,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   },
   "toaster": {
   "toaster": {
+    "create_succeeded": "Succeeded 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}}",
     "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",

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

@@ -21,6 +21,7 @@
   "Done": "完了",
   "Done": "完了",
   "Cancel": "キャンセル",
   "Cancel": "キャンセル",
   "Create": "作成",
   "Create": "作成",
+  "Description": "説明",
   "Admin": "管理",
   "Admin": "管理",
   "administrator": "管理者",
   "administrator": "管理者",
   "Tag": "タグ",
   "Tag": "タグ",
@@ -60,6 +61,7 @@
   "Presentation Mode": "プレゼンテーション",
   "Presentation Mode": "プレゼンテーション",
   "The end": "おしまい",
   "The end": "おしまい",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available for guest": "ゲストユーザーは利用できません",
+  "No users have bookmarked yet": "ブックマークしているユーザーはいません",
   "Create Archive Page": "アーカイブページの作成",
   "Create Archive Page": "アーカイブページの作成",
   "Target page": "対象ページ",
   "Target page": "対象ページ",
   "File type": "ファイル形式",
   "File type": "ファイル形式",
@@ -119,6 +121,7 @@
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "Legacy_Slack_Integration": "Slack連携 (レガシー)",
   "User_Management": "ユーザー管理",
   "User_Management": "ユーザー管理",
   "external_account_management": "外部アカウント管理",
   "external_account_management": "外部アカウント管理",
+  "UserGroup": "グループ",
   "UserGroup Management": "グループ管理",
   "UserGroup Management": "グループ管理",
   "Full Text Search Management": "全文検索管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Import Data": "データインポート",
@@ -141,6 +144,7 @@
   "Shareable link": "このページの共有用URL",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
   "Add tags for this page": "タグを付ける",
+  "Check All tags": "全てのタグをチェックする",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
   "Load latest": "最新版を読み込む",
@@ -153,7 +157,8 @@
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode": "サイドバーモード",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "Sidebar mode on Editor": "サイドバーモード(編集時)",
   "No bookmarks yet": "No bookmarks yet",
   "No bookmarks yet": "No bookmarks yet",
-  "Add to bookmark": "ブックマークに追加",
+  "add_bookmark": "ブックマークに追加",
+  "remove_bookmark": "ブックマークから削除",
   "Recent Created": "最新の作成",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
   "Page Tree": "ページツリー",
@@ -484,6 +489,17 @@
     "enable_textlint": "Textlintを有効にする",
     "enable_textlint": "Textlintを有効にする",
     "dont_ask_again": "常に許可する"
     "dont_ask_again": "常に許可する"
   },
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
+    "resolve_conflict_message": "ページ本文を選んでください",
+    "resolve_conflict": "衝突を解消",
+    "resolve_and_save" : "解消し保存する",
+    "select_revision" : "{{revision}}にする",
+    "requested_revision": "送信された本文",
+    "origin_revision": "送信する前の本文",
+    "latest_revision": "最新の本文",
+    "selected_editable_revision": "保存するページ本文(編集可能)"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "リンク編集",
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",
     "set_link_and_label": "リンク情報",
@@ -501,7 +517,10 @@
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
   },
   },
   "toaster": {
   "toaster": {
+    "create_succeeded": "新しい{{target}}が作成されました",
+    "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_successed": "{{target}}を更新しました",
+    "update_failed": "{{target}}の更新に失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

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

@@ -22,6 +22,7 @@
   "Done": "Done",
   "Done": "Done",
   "Cancel": "取消",
   "Cancel": "取消",
 	"Create": "创建",
 	"Create": "创建",
+  "Description": "描述",
 	"Admin": "管理",
 	"Admin": "管理",
 	"administrator": "管理员",
 	"administrator": "管理员",
 	"Tag": "标签",
 	"Tag": "标签",
@@ -61,6 +62,7 @@
 	"Presentation Mode": "演示文稿",
 	"Presentation Mode": "演示文稿",
   "The end": "结束",
   "The end": "结束",
   "Not available for guest": "Not available for guest",
   "Not available for guest": "Not available for guest",
+  "No users have bookmarked yet": "还没有用户加入书签",
   "Create Archive Page": "创建归档页",
   "Create Archive Page": "创建归档页",
   "File type": "文件类型",
   "File type": "文件类型",
   "Target page": "目标页面",
   "Target page": "目标页面",
@@ -127,6 +129,7 @@
   "Legacy_Slack_Integration": "旧版Slack一体化",
   "Legacy_Slack_Integration": "旧版Slack一体化",
 	"User_Management": "用户管理",
 	"User_Management": "用户管理",
 	"external_account_management": "外部账户管理",
 	"external_account_management": "外部账户管理",
+  "UserGroup": "用户组",
 	"UserGroup Management": "用户组管理",
 	"UserGroup Management": "用户组管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Import Data": "导入数据",
@@ -150,6 +153,7 @@
 	"Shareable link": "可分享链接",
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
 	"Add tags for this page": "添加标签",
+  "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Show latest": "显示最新",
 	"Load latest": "家在最新",
 	"Load latest": "家在最新",
@@ -159,7 +163,8 @@
 	"Sign out": "退出",
 	"Sign out": "退出",
   "Disassociate": "解除关联",
   "Disassociate": "解除关联",
   "No bookmarks yet": "暂无书签",
   "No bookmarks yet": "暂无书签",
-  "Add to bookmark": "添加到书签",
+  "add_bookmark": "添加到书签",
+  "remove_bookmark": "从书签中删除",
 	"Recent Created": "最新创建",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
   "Page Tree": "页面树",
@@ -462,6 +467,17 @@
     "enable_textlint": "启用Textlint",
     "enable_textlint": "启用Textlint",
     "dont_ask_again": "不要再问"
     "dont_ask_again": "不要再问"
   },
   },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
+    "resolve_conflict_message": "选择页面正文",
+    "resolve_conflict": "解决冲突",
+    "resolve_and_save" : "解决冲突并保存",
+    "select_revision" : "选择{{revision}}",
+    "requested_revision": "发送的页面正文",
+    "origin_revision": "发送前的页面正文",
+    "latest_revision": "最新页面正文",
+    "selected_editable_revision": "选定的可编辑页面正文"
+  },
   "link_edit": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",
@@ -479,7 +495,10 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   },
 	"toaster": {
 	"toaster": {
+    "create_succeeded": "Succeeded 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}}",
     "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 ",

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

@@ -2,6 +2,8 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
 
 
 import { SWRConfig } from 'swr';
 import { SWRConfig } from 'swr';
 
 
@@ -160,7 +162,9 @@ const renderMainComponents = () => {
           <ErrorBoundary>
           <ErrorBoundary>
             <SWRConfig value={swrGlobalConfiguration}>
             <SWRConfig value={swrGlobalConfiguration}>
               <Provider inject={injectableContainers}>
               <Provider inject={injectableContainers}>
-                {componentMappings[key]}
+                <DndProvider backend={HTML5Backend}>
+                  {componentMappings[key]}
+                </DndProvider>
               </Provider>
               </Provider>
             </SWRConfig>
             </SWRConfig>
           </ErrorBoundary>
           </ErrorBoundary>

+ 0 - 27
packages/app/src/client/legacy/crowi.js

@@ -112,33 +112,6 @@ Crowi.initClassesByOS = function() {
   });
   });
 };
 };
 
 
-// window.addEventListener('load', () => {
-//   const { appContainer } = window;
-//   const pageContainer = appContainer.getContainer('PageContainer');
-
-//   // Do nothing if the page does not exist
-//   // ex.) admin page,login page
-//   if (pageContainer == null) {
-//     return null;
-//   }
-//   const { isAbleToOpenPageEditor } = pageContainer;
-
-//   // hash on page
-//   if (window.location.hash) {
-//     const navigationContainer = appContainer.getContainer('NavigationContainer');
-
-//     if (window.location.hash === '#edit' && isAbleToOpenPageEditor) {
-//       navigationContainer.setEditorMode('edit');
-
-//       // focus
-//       Crowi.setCaretLineAndFocusToEditor();
-//     }
-//     else if (window.location.hash === '#hackmd') {
-//       navigationContainer.setEditorMode('hackmd');
-//     }
-//   }
-// });
-
 window.addEventListener('load', () => {
 window.addEventListener('load', () => {
   const crowi = window.crowi;
   const crowi = window.crowi;
   if (crowi && crowi.users && crowi.users.length !== 0) {
   if (crowi && crowi.users && crowi.users.length !== 0) {

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

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

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

@@ -41,6 +41,7 @@ export default class EditorContainer extends Container {
 
 
     this.initDrafts();
     this.initDrafts();
 
 
+    this.editorOptions = null;
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
     this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
     this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
   }
   }

+ 56 - 10
packages/app/src/client/services/PageContainer.js

@@ -4,9 +4,11 @@ import { Container } from 'unstated';
 import * as entities from 'entities';
 import * as entities from 'entities';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import { toastError } from '../util/apiNotification';
+import { EditorMode } from '~/stores/ui';
 
 
+import { toastError } from '../util/apiNotification';
 import {
 import {
   DetachCodeBlockInterceptor,
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
@@ -52,9 +54,6 @@ export default class PageContainer extends Container {
       path,
       path,
       tocHtml: '',
       tocHtml: '',
 
 
-      isBookmarked: false,
-      sumOfBookmarks: 0,
-
       seenUsers: [],
       seenUsers: [],
       seenUserIds: [],
       seenUserIds: [],
       sumOfSeenUsers: [],
       sumOfSeenUsers: [],
@@ -86,12 +85,15 @@ export default class PageContainer extends Container {
 
 
       // latest(on remote) information
       // latest(on remote) information
       remoteRevisionId: revisionId,
       remoteRevisionId: revisionId,
+      remoteRevisionBody: null,
+      remoteRevisionUpdateAt: null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null,
       deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       deleteUsername: mainContent.getAttribute('data-page-delete-username') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
       isHackmdDraftUpdatingInRealtime: false,
+      isConflictDiffModalOpen: false,
     };
     };
 
 
     // parse creator, lastUpdateUser and revisionAuthor
     // parse creator, lastUpdateUser and revisionAuthor
@@ -103,6 +105,7 @@ export default class PageContainer extends Container {
     }
     }
     try {
     try {
       this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
       this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
+      this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author'));
     }
     }
     catch (e) {
     catch (e) {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
@@ -126,7 +129,6 @@ export default class PageContainer extends Container {
       // as it is stored in a separate collection to like and seen user
       // as it is stored in a separate collection to like and seen user
       // data so it has a separate api endpoint.
       // data so it has a separate api endpoint.
       this.initialPageLoad();
       this.initialPageLoad();
-      this.retrieveBookmarkInfo();
     }
     }
 
 
     this.setTocHtml = this.setTocHtml.bind(this);
     this.setTocHtml = this.setTocHtml.bind(this);
@@ -325,8 +327,12 @@ export default class PageContainer extends Container {
   setLatestRemotePageData(s2cMessagePageUpdated) {
   setLatestRemotePageData(s2cMessagePageUpdated) {
     const newState = {
     const newState = {
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
       remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt,
       revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
       revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
       lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername,
+      lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
     };
     };
 
 
     if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
     if (s2cMessagePageUpdated.hasDraftOnHackmd != null) {
@@ -355,6 +361,7 @@ export default class PageContainer extends Container {
       revisionId: revision._id,
       revisionId: revision._id,
       revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
       revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
       remoteRevisionId: revision._id,
       remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: revision.body,
       markdown: revision.body,
@@ -369,7 +376,7 @@ export default class PageContainer extends Container {
     // PageEditor component
     // PageEditor component
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     const pageEditor = this.appContainer.getComponentInstance('PageEditor');
     if (pageEditor != null) {
     if (pageEditor != null) {
-      if (editorMode !== 'edit') {
+      if (editorMode !== EditorMode.Editor) {
         pageEditor.updateEditorValue(newState.markdown);
         pageEditor.updateEditorValue(newState.markdown);
       }
       }
     }
     }
@@ -377,13 +384,36 @@ export default class PageContainer extends Container {
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
     if (pageEditorByHackmd != null) {
     if (pageEditorByHackmd != null) {
       // reset
       // reset
-      if (editorMode !== 'hackmd') {
+      if (editorMode !== EditorMode.HackMD) {
         pageEditorByHackmd.reset();
         pageEditorByHackmd.reset();
       }
       }
     }
     }
 
 
-    // hidden input
-    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * update page meta data
+   * @param {object} page Page instance
+   * @param {object} revision Revision instance
+   * @param {String[]} tags Array of Tag
+   */
+  updatePageMetaData(page, revision, tags) {
+
+    const newState = {
+      revisionId: revision._id,
+      revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
+      remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      updatedAt: page.updatedAt,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+
+    this.setState(newState);
+
   }
   }
 
 
   /**
   /**
@@ -395,7 +425,6 @@ export default class PageContainer extends Container {
   async save(markdown, editorMode, optionsToSave = {}) {
   async save(markdown, editorMode, optionsToSave = {}) {
     const { pageId, path } = this.state;
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
     let { revisionId } = this.state;
-
     const options = Object.assign({}, optionsToSave);
     const options = Object.assign({}, optionsToSave);
 
 
     if (editorMode === 'hackmd') {
     if (editorMode === 'hackmd') {
@@ -574,4 +603,21 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   retrieveMyBookmarkList() {
   }
   }
 
 
+  async resolveConflict(markdown, editorMode) {
+
+    const { pageId, remoteRevisionId, path } = this.state;
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+    const options = editorContainer.getCurrentOptionsToSave();
+    const optionsToSave = Object.assign({}, options);
+
+    const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
+
+    editorContainer.clearDraft(path);
+    this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+
+    editorContainer.setState({ tags: res.tags });
+
+    return res;
+  }
+
 }
 }

+ 6 - 2
packages/app/src/client/util/apiv1-client.ts

@@ -17,11 +17,15 @@ class Apiv1ErrorHandler extends Error {
 
 
   code;
   code;
 
 
-  constructor(message = '', code = '') {
+  data;
+
+  constructor(message = '', code = '', data = '') {
     super();
     super();
 
 
     this.message = message;
     this.message = message;
     this.code = code;
     this.code = code;
+    this.data = data;
+
   }
   }
 
 
 }
 }
@@ -35,7 +39,7 @@ export async function apiRequest(method: string, path: string, params: unknown):
 
 
   // Return error code if code is exist
   // Return error code if code is exist
   if (res.data.code != null) {
   if (res.data.code != null) {
-    const error = new Apiv1ErrorHandler(res.data.error, res.data.code);
+    const error = new Apiv1ErrorHandler(res.data.error, res.data.code, res.data.data);
     throw error;
     throw error;
   }
   }
 
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,168 @@
+import React, {
+  FC, useState, useCallback, useEffect,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+
+import UserGroupForm from '../UserGroup/UserGroupForm';
+import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupPageList from './UserGroupPageList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import {
+  apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
+} from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/apiNotification';
+import { IPageHasId } from '~/interfaces/page';
+import {
+  IUserGroup, IUserGroupHasId, IUserGroupRelation, IUserGroupRelationHasId,
+} from '~/interfaces/user';
+
+const UserGroupDetailPage: FC = () => {
+  const rootElem = document.getElementById('admin-user-group-detail');
+  const { t } = useTranslation();
+
+  /*
+   * State (from AdminUserGroupDetailContainer)
+   */
+  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(rootElem?.getAttribute('data-user-group') || 'null'));
+  const [userGroupRelations, setUserGroupRelations] = useState<IUserGroupRelationHasId[]>([]); // For user list
+
+  // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
+  const [childUserGroups, setChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+  const [grandChildUserGroups, setGrandChildUserGroups] = useState<IUserGroupHasId[]>([]); // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
+
+  const [childUserGroupRelations, setChildUserGroupRelations] = useState<IUserGroupRelation[]>([]); // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list
+  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
+  const [isUserGroupUserModalOpen, setUserGroupUserModalOpen] = useState<boolean>(false);
+  const [searchType, setSearchType] = useState<string>('partial');
+  const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
+  const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
+
+  /*
+   * Function
+   */
+  const sync = useCallback(async() => {
+    try {
+      const [
+        userGroupRelations,
+        relatedPages,
+      ] = await Promise.all([
+        apiv3Get(`/user-groups/${userGroup._id}/user-group-relations`).then(res => res.data.userGroupRelations),
+        apiv3Get(`/user-groups/${userGroup._id}/pages`).then(res => res.data.pages),
+      ]);
+
+      setUserGroupRelations(userGroupRelations);
+      setRelatedPages(relatedPages);
+    }
+    catch (err) {
+      toastError(new Error('Failed to fetch data'));
+    }
+  }, [userGroup]);
+
+  // TODO 85062: old name: switchIsAlsoMailSearched
+  const toggleIsAlsoMailSearched = useCallback(() => {
+    setAlsoMailSearched(prev => !prev);
+  }, []);
+
+  // TODO 85062: old name: switchIsAlsoNameSearched
+  const toggleAlsoNameSearched = useCallback(() => {
+    setAlsoNameSearched(prev => !prev);
+  }, []);
+
+  const switchSearchType = useCallback((searchType) => {
+    setSearchType(searchType);
+  }, []);
+
+  const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
+    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+    const { userGroup: newUserGroup } = res.data;
+
+    setUserGroup(newUserGroup);
+
+    return newUserGroup;
+  }, [userGroup]);
+
+  const openUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(true);
+  }, []);
+
+  const closeUserGroupUserModal = useCallback(() => {
+    setUserGroupUserModalOpen(false);
+  }, []);
+
+  const fetchApplicableUsers = useCallback(async(searchWord) => {
+    const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
+      searchWord,
+      searchType,
+      isAlsoMailSearched,
+      isAlsoNameSearched,
+    });
+
+    const { users } = res.data;
+
+    return users;
+  }, [searchType, isAlsoMailSearched, isAlsoNameSearched]);
+
+  // TODO 85062: will be used in UserGroupUserFormByInput
+  const addUserByUsername = useCallback(async(username: string) => {
+    await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
+
+    await sync();
+  }, [userGroup, sync]);
+
+  const removeUserByUsername = useCallback(async(username: string) => {
+    const res = await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+
+    setUserGroupRelations(prev => prev.filter(u => u._id !== res.data.userGroupRelation._id)); // TODO 85062: use swr to sync
+  }, [userGroup]);
+
+  /*
+   * componentDidMount
+   */
+  useEffect(() => {
+    sync();
+  }, []);
+
+  /*
+   * Dependencies
+   */
+  if (userGroup == null) {
+    return <></>;
+  }
+
+  return (
+    <div>
+      <a href="/admin/user-groups" className="btn btn-outline-secondary">
+        <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+        {t('admin:user_group_management.back_to_list')}
+      </a>
+      {/* TODO 85062: Link to the ancestors group */}
+      <div className="mt-4 form-box">
+        <UserGroupForm
+          userGroup={userGroup}
+          successedMessage={t('toaster.update_successed', { target: t('UserGroup') })}
+          failedMessage={t('toaster.update_failed', { target: t('UserGroup') })}
+          submitButtonLabel={t('Update')}
+          onSubmit={updateUserGroup}
+        />
+      </div>
+      <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
+      <UserGroupUserTable />
+      <UserGroupUserModal />
+      <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
+      <div className="page-list">
+        <UserGroupPageList />
+      </div>
+    </div>
+  );
+
+};
+
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDetailPageWrapper = withUnstatedContainers(UserGroupDetailPage, [AppContainer]);
+
+export default UserGroupDetailPageWrapper;

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

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

+ 83 - 0
packages/app/src/components/BookmarkButtons.tsx

@@ -0,0 +1,83 @@
+import React, { FC, useState } from 'react';
+
+import { Types } from 'mongoose';
+import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserPictureList from './User/UserPictureList';
+import { toastError } from '~/client/util/apiNotification';
+import { useIsGuestUser } from '~/stores/context';
+import { useSWRxBookmarksInfo } from '~/stores/bookmarks';
+import { apiv3Put } from '~/client/util/apiv3-client';
+
+interface Props {
+  pageId: Types.ObjectId
+}
+
+const BookmarkButton: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { pageId } = props;
+
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: bookmarksInfo, mutate } = useSWRxBookmarksInfo(pageId);
+
+  const isBookmarked = bookmarksInfo?.isBookmarked != null ? bookmarksInfo.isBookmarked : false;
+  const sumOfBookmarks = bookmarksInfo?.sumOfBookmarks != null ? bookmarksInfo.sumOfBookmarks : 0;
+  const bookmarkedUsers = bookmarksInfo?.bookmarkedUsers != null ? bookmarksInfo.bookmarkedUsers : [];
+
+  const togglePopover = () => {
+    setIsPopoverOpen(!isPopoverOpen);
+  };
+
+  const handleClick = async() => {
+    if (isGuestUser) {
+      return;
+    }
+
+    try {
+      const res = await apiv3Put('/bookmarks', { pageId, bool: !isBookmarked });
+      if (res) {
+        mutate();
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <div className="btn-group" role="group" aria-label="Bookmark buttons">
+      <button
+        type="button"
+        id="bookmark-button"
+        onClick={handleClick}
+        className={`btn btn-bookmark border-0
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
+      >
+        <i className="icon-star"></i>
+      </button>
+
+      {isGuestUser && (
+        <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+          {t('Not available for guest')}
+        </UncontrolledTooltip>
+      )}
+
+      <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${isBookmarked ? 'active' : ''}`}>
+        {sumOfBookmarks}
+      </button>
+
+      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
+        <PopoverBody className="seen-user-popover">
+          <div className="px-2 text-right user-list-content text-truncate text-muted">
+            {bookmarkedUsers.length ? <UserPictureList users={bookmarkedUsers} /> : t('No users have bookmarked yet')}
+          </div>
+        </PopoverBody>
+      </Popover>
+    </div>
+  );
+};
+
+export default BookmarkButton;

+ 37 - 6
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,12 +1,15 @@
-import React, { FC } from 'react';
+import React, { FC, useState } from 'react';
 import {
 import {
-  UncontrolledDropdown, DropdownMenu, DropdownToggle, DropdownItem,
+  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import toastr from 'toastr';
 import toastr from 'toastr';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/apiNotification';
+import { useSWRBookmarkInfo } from '~/stores/bookmark';
 
 
 type PageItemControlProps = {
 type PageItemControlProps = {
   page: Partial<IPageHasId>
   page: Partial<IPageHasId>
@@ -21,14 +24,42 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
     page, isEnableActions, onClickDeleteButton, isDeletable,
     page, isEnableActions, onClickDeleteButton, isDeletable,
   } = props;
   } = props;
   const { t } = useTranslation('');
   const { t } = useTranslation('');
+  const [isOpen, setIsOpen] = useState(false);
+  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id, isOpen);
 
 
   const deleteButtonHandler = () => {
   const deleteButtonHandler = () => {
     if (onClickDeleteButton != null && page._id != null) {
     if (onClickDeleteButton != null && page._id != null) {
       onClickDeleteButton(page._id);
       onClickDeleteButton(page._id);
     }
     }
   };
   };
+
+
+  const bookmarkToggleHandler = (async() => {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await apiv3Put('/bookmarks', { pageId: page._id, bool: !bookmarkInfo!.isBookmarked });
+      mutateBookmarkInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  });
+
+  const renderBookmarkText = () => {
+    if (bookmarkInfoError != null || bookmarkInfo == null) {
+      return '';
+    }
+    return bookmarkInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark');
+  };
+
+
+  const dropdownToggle = () => {
+    setIsOpen(!isOpen);
+  };
+
+
   return (
   return (
-    <UncontrolledDropdown>
+    <Dropdown isOpen={isOpen} toggle={dropdownToggle}>
       <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management p-0">
       <DropdownToggle color="transparent" className="btn-link border-0 rounded grw-btn-page-management p-0">
         <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
         <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
       </DropdownToggle>
       </DropdownToggle>
@@ -62,9 +93,9 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
           </DropdownItem>
           </DropdownItem>
         )}
         )}
         {isEnableActions && (
         {isEnableActions && (
-          <DropdownItem onClick={() => toastr.warning(t('search_result.currently_not_implemented'))}>
+          <DropdownItem onClick={bookmarkToggleHandler}>
             <i className="fa fa-fw fa-bookmark-o"></i>
             <i className="fa fa-fw fa-bookmark-o"></i>
-            {t('Add to bookmark')}
+            {renderBookmarkText()}
           </DropdownItem>
           </DropdownItem>
         )}
         )}
         {isEnableActions && (
         {isEnableActions && (
@@ -91,7 +122,7 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
       </DropdownMenu>
       </DropdownMenu>
 
 
 
 
-    </UncontrolledDropdown>
+    </Dropdown>
   );
   );
 
 
 };
 };

+ 9 - 10
packages/app/src/components/EventListeneres/HashChanged.tsx

@@ -1,23 +1,22 @@
 import { FC, useCallback, useEffect } from 'react';
 import { FC, useCallback, useEffect } from 'react';
 
 
-import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useEditorMode, determineEditorModeByHash } from '~/stores/ui';
 import { useIsEditable } from '~/stores/context';
 import { useIsEditable } from '~/stores/context';
 
 
+/**
+ * Change editorMode by browser forward/back operation
+ */
 const HashChanged: FC<void> = () => {
 const HashChanged: FC<void> = () => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
-  const { mutate: mutateEditorMode } = useEditorMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
 
 
   const hashchangeHandler = useCallback(() => {
   const hashchangeHandler = useCallback(() => {
-    const { hash } = window.location;
+    const newEditorMode = determineEditorModeByHash();
 
 
-    if (hash == null) {
-      return;
-    }
-
-    if (hash === '#edit') {
-      mutateEditorMode(EditorMode.Editor);
+    if (editorMode !== newEditorMode) {
+      mutateEditorMode(newEditorMode);
     }
     }
-  }, [mutateEditorMode]);
+  }, [editorMode, mutateEditorMode]);
 
 
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {

+ 0 - 33
packages/app/src/components/ExpandOrContractButton.jsx

@@ -1,33 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-function ExpandOrContractButton(props) {
-  const { isWindowExpanded, contractWindow, expandWindow } = props;
-
-  const clickContractButtonHandler = () => {
-    if (contractWindow != null) {
-      contractWindow();
-    }
-  };
-
-  const clickExpandButtonHandler = () => {
-    if (expandWindow != null) {
-      expandWindow();
-    }
-  };
-
-  return (
-    <button type="button" className="close" onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}>
-      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
-    </button>
-  );
-}
-
-ExpandOrContractButton.propTypes = {
-  isWindowExpanded: PropTypes.bool,
-  contractWindow: PropTypes.func,
-  expandWindow: PropTypes.func,
-};
-
-
-export default ExpandOrContractButton;

+ 37 - 0
packages/app/src/components/ExpandOrContractButton.tsx

@@ -0,0 +1,37 @@
+import React, { FC } from 'react';
+
+
+type Props = {
+  isWindowExpanded: boolean,
+  contractWindow?: () => void,
+  expandWindow?: () => void,
+};
+
+const ExpandOrContractButton: FC<Props> = (props: Props) => {
+  const { isWindowExpanded, contractWindow, expandWindow } = props;
+
+  const clickContractButtonHandler = (): void => {
+    if (contractWindow != null) {
+      contractWindow();
+    }
+  };
+
+  const clickExpandButtonHandler = (): void => {
+    if (expandWindow != null) {
+      expandWindow();
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      className="close"
+      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+    >
+      <i className={`${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`} style={{ fontSize: '0.8em' }} aria-hidden="true"></i>
+    </button>
+  );
+};
+
+
+export default ExpandOrContractButton;

+ 49 - 15
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,9 +1,12 @@
-import React, { useState, useEffect, useCallback } from 'react';
-// import PropTypes from 'prop-types';
+import React, {
+  useMemo, useState, useRef, useEffect, useCallback,
+} from 'react';
 
 
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { useSidebarCollapsed } from '~/stores/ui';
 
 
 import GrowiSubNavigation from './GrowiSubNavigation';
 import GrowiSubNavigation from './GrowiSubNavigation';
 
 
@@ -21,24 +24,43 @@ const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
  */
  */
 const GrowiSubNavigationSwitcher = (props) => {
 const GrowiSubNavigationSwitcher = (props) => {
 
 
+  const { data: isSidebarCollapsed } = useSidebarCollapsed();
+
   const [isVisible, setVisible] = useState(false);
   const [isVisible, setVisible] = useState(false);
+  const [width, setWidth] = useState(null);
+
+  const fixedContainerRef = useRef();
+  const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
 
 
-  const resetWidth = useCallback(() => {
-    const elem = document.getElementById('grw-subnav-fixed-container');
+  const initWidth = useCallback(() => {
+    const instance = fixedContainerRef.current;
 
 
-    if (elem == null || elem.parentNode == null) {
+    if (instance == null || instance.parentNode == null) {
       return;
       return;
     }
     }
 
 
     // get parent width
     // get parent width
-    const { clientWidth: width } = elem.parentNode;
+    const { clientWidth } = instance.parentNode;
     // update style
     // update style
-    elem.style.width = `${width}px`;
+    setWidth(clientWidth);
   }, []);
   }, []);
 
 
+  const initVisible = useCallback(() => {
+    const elements = stickyEvents.stickyElements;
+
+    for (const elem of elements) {
+      const bool = stickyEvents.isSticking(elem);
+      if (bool) {
+        setVisible(bool);
+        break;
+      }
+    }
+
+  }, [stickyEvents]);
+
   // setup effect by resizing event
   // setup effect by resizing event
   useEffect(() => {
   useEffect(() => {
-    const resizeHandler = debounce(100, resetWidth);
+    const resizeHandler = debounce(100, initWidth);
 
 
     window.addEventListener('resize', resizeHandler);
     window.addEventListener('resize', resizeHandler);
 
 
@@ -46,7 +68,7 @@ const GrowiSubNavigationSwitcher = (props) => {
     return () => {
     return () => {
       window.removeEventListener('resize', resizeHandler);
       window.removeEventListener('resize', resizeHandler);
     };
     };
-  }, [resetWidth]);
+  }, [initWidth]);
 
 
   const stickyChangeHandler = useCallback((event) => {
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
     logger.debug('StickyEvents.CHANGE detected');
@@ -57,7 +79,6 @@ const GrowiSubNavigationSwitcher = (props) => {
   useEffect(() => {
   useEffect(() => {
     // sticky
     // sticky
     // See: https://github.com/ryanwalters/sticky-events
     // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
     const { stickySelector } = stickyEvents;
     const { stickySelector } = stickyEvents;
     const elem = document.querySelector(stickySelector);
     const elem = document.querySelector(stickySelector);
     elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
     elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
@@ -66,16 +87,29 @@ const GrowiSubNavigationSwitcher = (props) => {
     return () => {
     return () => {
       elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
       elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
     };
     };
-  }, [stickyChangeHandler]);
+  }, [stickyChangeHandler, stickyEvents]);
+
+  // update width when sidebar collapsing changed
+  useEffect(() => {
+    if (isSidebarCollapsed != null) {
+      setTimeout(initWidth, 300);
+    }
+  }, [isSidebarCollapsed, initWidth]);
 
 
-  // update width
+  // initialize
   useEffect(() => {
   useEffect(() => {
-    resetWidth();
-  });
+    initWidth();
+
+    // check sticky state several times
+    setTimeout(initVisible, 100);
+    setTimeout(initVisible, 300);
+    setTimeout(initVisible, 2000);
+
+  }, [initWidth, initVisible]);
 
 
   return (
   return (
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
     <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed">
+      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed" ref={fixedContainerRef} style={{ width }}>
         <GrowiSubNavigation isCompactMode />
         <GrowiSubNavigation isCompactMode />
       </div>
       </div>
     </div>
     </div>

+ 1 - 1
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -34,7 +34,7 @@ const SubNavButtons: FC<SubNavButtonsProps> = (props: SubNavButtonsProps) => {
 
 
   const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
   const { data: pageInfo, error: pageInfoError, mutate: mutatePageInfo } = useSWRPageInfo(pageId);
   const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
   const { data: likers } = useSWRxLikerList(pageInfo?.likerIds);
-  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
+  const { data: bookmarkInfo, error: bookmarkInfoError, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId, true);
 
 
   const likeClickhandler = useCallback(async() => {
   const likeClickhandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
     if (isGuestUser == null || isGuestUser) {

+ 21 - 19
packages/app/src/components/SearchPage/SearchResultListItem.tsx → packages/app/src/components/Page/PageListItem.tsx

@@ -13,19 +13,21 @@ const { isTopPage } = pagePathUtils;
 
 
 type Props = {
 type Props = {
   page: IPageSearchResultData,
   page: IPageSearchResultData,
-  isSelected: boolean,
-  isChecked: boolean,
+  isSelected: boolean, // is item selected(focused)
+  isChecked: boolean, // is checkbox of item checked
   isEnableActions: boolean,
   isEnableActions: boolean,
   shortBody?: string
   shortBody?: string
+  showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onClickCheckbox?: (pageId: string) => void,
   onClickCheckbox?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 }
 
 
-const SearchResultListItem: FC<Props> = memo((props:Props) => {
+const PageListItem: FC<Props> = memo((props:Props) => {
   const {
   const {
     // todo: refactoring variable name to clear what changed
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
     page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
+    showPageUpdatedTime,
   } = props;
   } = props;
 
 
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
   const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
@@ -60,6 +62,7 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
     }
     }
   }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
   }, [isDeviceSmallerThanLg, onClickSearchResultItem, pageData._id]);
 
 
+  // background color of list item changes when class "active" exists under 'grw-search-result-item'
   const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
   const responsiveListStyleClass = `${isDeviceSmallerThanLg ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
   return (
   return (
     <li
     <li
@@ -72,26 +75,25 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
       >
       >
         <div className="d-flex h-100">
         <div className="d-flex h-100">
           {/* checkbox */}
           {/* checkbox */}
-          <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
-            <input
-              className="form-check-input position-relative m-0"
-              type="checkbox"
-              id="flexCheckDefault"
-              onChange={() => {
-                if (onClickCheckbox != null) {
-                  onClickCheckbox(pageData._id);
-                }
-              }}
-              checked={isChecked}
-            />
-          </div>
+          {onClickCheckbox != null && (
+            <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
+              <input
+                className="form-check-input position-relative m-0"
+                type="checkbox"
+                id="flexCheckDefault"
+                onChange={() => { onClickCheckbox(pageData._id) }}
+                checked={isChecked}
+              />
+            </div>
+          )}
           <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
           <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
             {/* page path */}
             {/* page path */}
-            <h6 className="mb-1 py-1">
-              <a href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
+            <h6 className="mb-1 py-1 d-flex">
+              <a className="d-inline-block" href={pagePath.isRoot ? pagePath.latter : pagePath.former}>
                 <i className="icon-fw icon-home"></i>
                 <i className="icon-fw icon-home"></i>
                 {pagePathElem}
                 {pagePathElem}
               </a>
               </a>
+              {showPageUpdatedTime && (<p className="ml-auto mb-0 mr-4 list-item-updated-time">Updated: 0000/00/00 00:00:00</p>)}
             </h6>
             </h6>
             <div className="d-flex align-items-center mb-2">
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
               {/* Picture */}
@@ -138,4 +140,4 @@ const SearchResultListItem: FC<Props> = memo((props:Props) => {
   );
   );
 });
 });
 
 
-export default SearchResultListItem;
+export default PageListItem;

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

@@ -135,7 +135,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     await interceptorManager.process('prePostProcess', context);
     await interceptorManager.process('prePostProcess', context);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
     context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
 
-    if (this.props.highlightKeywords != null) {
+    if (highlightKeywords != null) {
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
       context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
     }
     }
     await interceptorManager.process('postPostProcess', context);
     await interceptorManager.process('postPostProcess', context);

+ 31 - 1
packages/app/src/components/Page/TagLabels.jsx

@@ -5,6 +5,10 @@ import { withTranslation } from 'react-i18next';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { EditorMode } from '~/stores/ui';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
 
 
 import RenderTagLabels from './RenderTagLabels';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 import TagEditModal from './TagEditModal';
@@ -31,6 +35,29 @@ class TagLabels extends React.Component {
     this.setState({ isTagEditModalShown: false });
     this.setState({ isTagEditModalShown: false });
   }
   }
 
 
+  async tagsUpdatedHandler(newTags) {
+    const {
+      appContainer, editorContainer, pageContainer, editorMode,
+    } = this.props;
+
+    const { pageId, revisionId } = pageContainer.state;
+    // It will not be reflected in the DB until the page is refreshed
+    if (editorMode === EditorMode.Editor) {
+      return editorContainer.setState({ tags: newTags });
+    }
+    try {
+      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
+        pageId, tags: newTags, revisionId,
+      });
+      editorContainer.setState({ tags });
+      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
+  }
+
 
 
   render() {
   render() {
     const { appContainer, tagsUpdateInvoked, tags } = this.props;
     const { appContainer, tagsUpdateInvoked, tags } = this.props;
@@ -66,12 +93,15 @@ class TagLabels extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer]);
+const TagLabelsUnstatedWrapper = withUnstatedContainers(TagLabels, [AppContainer, EditorContainer, PageContainer]);
 
 
 TagLabels.propTypes = {
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 
 
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorMode: PropTypes.string.isRequired,
   tags: PropTypes.arrayOf(String),
   tags: PropTypes.arrayOf(String),
   tagsUpdateInvoked: PropTypes.func,
   tagsUpdateInvoked: PropTypes.func,
 };
 };

+ 35 - 45
packages/app/src/components/PageEditor/AbstractEditor.jsx → packages/app/src/components/PageEditor/AbstractEditor.tsx

@@ -1,11 +1,20 @@
-/* eslint-disable react/no-unused-prop-types */
-
+/* eslint-disable @typescript-eslint/no-unused-vars */
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
+import { ICodeMirror } from 'react-codemirror2';
+
+
+export interface AbstractEditorProps extends ICodeMirror {
+  value?: string;
+  isGfmMode?: boolean;
+  onScrollCursorIntoView?: (line: number) => void;
+  onSave?: () => Promise<void>;
+  onPasteFiles?: (event: Event) => void;
+  onCtrlEnter?: (event: Event) => void;
+}
 
 
-export default class AbstractEditor extends React.Component {
+export default class AbstractEditor<T extends AbstractEditorProps> extends React.Component<T, Record<string, unknown>> {
 
 
-  constructor(props) {
+  constructor(props: Readonly<T>) {
     super(props);
     super(props);
 
 
     this.forceToFocus = this.forceToFocus.bind(this);
     this.forceToFocus = this.forceToFocus.bind(this);
@@ -20,91 +29,87 @@ export default class AbstractEditor extends React.Component {
     this.dispatchSave = this.dispatchSave.bind(this);
     this.dispatchSave = this.dispatchSave.bind(this);
   }
   }
 
 
-  forceToFocus() {
-  }
+  forceToFocus(): void {}
 
 
   /**
   /**
    * set new value
    * set new value
    */
    */
-  setValue(newValue) {
-  }
+  setValue(_newValue: string): void {}
 
 
   /**
   /**
    * Enable/Disable GFM mode
    * Enable/Disable GFM mode
-   * @param {bool} bool
+   * @param {bool} _bool
    */
    */
-  setGfmMode(bool) {
-  }
+  setGfmMode(_bool: boolean): void {}
 
 
   /**
   /**
    * set caret position of codemirror
    * set caret position of codemirror
    * @param {string} number
    * @param {string} number
    */
    */
-  setCaretLine(line) {
-  }
+  setCaretLine(_line: number): void {}
 
 
   /**
   /**
    * scroll
    * scroll
-   * @param {number} line
+   * @param {number} _line
    */
    */
-  setScrollTopByLine(line) {
-  }
+  setScrollTopByLine(_line: number): void {}
 
 
   /**
   /**
    * return strings from BOL(beginning of line) to current position
    * return strings from BOL(beginning of line) to current position
    */
    */
-  getStrFromBol() {
+  getStrFromBol(): Error {
     throw new Error('this method should be impelemented in subclass');
     throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
   /**
   /**
    * return strings from current position to EOL(end of line)
    * return strings from current position to EOL(end of line)
    */
    */
-  getStrToEol() {
+  getStrToEol(): Error {
     throw new Error('this method should be impelemented in subclass');
     throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
   /**
   /**
    * return strings from BOL(beginning of line) to current position
    * return strings from BOL(beginning of line) to current position
    */
    */
-  getStrFromBolToSelectedUpperPos() {
+  getStrFromBolToSelectedUpperPos(): Error {
     throw new Error('this method should be impelemented in subclass');
     throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
   /**
   /**
    * replace Beggining Of Line to current position with param 'text'
    * replace Beggining Of Line to current position with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
    */
-  replaceBolToCurrentPos(text) {
+  replaceBolToCurrentPos(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
     throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
   /**
   /**
    * replace the current line with param 'text'
    * replace the current line with param 'text'
-   * @param {string} text
+   * @param {string} _text
    */
    */
-  replaceLine(text) {
+  replaceLine(_text: string): Error {
     throw new Error('this method should be impelemented in subclass');
     throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
   /**
   /**
    * insert text
    * insert text
-   * @param {string} text
+   * @param {string} _text
    */
    */
-  insertText(text) {
+  insertText(_text: string): Error {
+    throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
   /**
   /**
    * insert line break to the current position
    * insert line break to the current position
    */
    */
-  insertLinebreak() {
+  insertLinebreak(): void {
     this.insertText('\n');
     this.insertText('\n');
   }
   }
 
 
   /**
   /**
    * dispatch onSave event
    * dispatch onSave event
    */
    */
-  dispatchSave() {
+  dispatchSave(): void {
     if (this.props.onSave != null) {
     if (this.props.onSave != null) {
       this.props.onSave();
       this.props.onSave();
     }
     }
@@ -114,7 +119,7 @@ export default class AbstractEditor extends React.Component {
    * dispatch onPasteFiles event
    * dispatch onPasteFiles event
    * @param {object} event
    * @param {object} event
    */
    */
-  dispatchPasteFiles(event) {
+  dispatchPasteFiles(event: Event): void {
     if (this.props.onPasteFiles != null) {
     if (this.props.onPasteFiles != null) {
       this.props.onPasteFiles(event);
       this.props.onPasteFiles(event);
     }
     }
@@ -123,23 +128,8 @@ export default class AbstractEditor extends React.Component {
   /**
   /**
    * returns items(an array of react elements) in navigation bar for editor
    * returns items(an array of react elements) in navigation bar for editor
    */
    */
-  getNavbarItems() {
+  getNavbarItems(): null {
     return null;
     return null;
   }
   }
 
 
 }
 }
-
-AbstractEditor.propTypes = {
-  value: PropTypes.string,
-  isGfmMode: PropTypes.bool,
-  onChange: PropTypes.func,
-  onScroll: PropTypes.func,
-  onScrollCursorIntoView: PropTypes.func,
-  onSave: PropTypes.func,
-  onPasteFiles: PropTypes.func,
-  onDragEnter: PropTypes.func,
-  onCtrlEnter: PropTypes.func,
-};
-AbstractEditor.defaultProps = {
-  isGfmMode: true,
-};

+ 4 - 3
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
 
 
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
 import * as codemirror from 'codemirror';
+import { UnControlled as UncontrolledCodeMirror } from 'react-codemirror2';
 
 
 import { Button } from 'reactstrap';
 import { Button } from 'reactstrap';
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 
 import { JSHINT } from 'jshint';
 import { JSHINT } from 'jshint';
 
 
@@ -32,6 +32,7 @@ import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
 import DrawioModal from './DrawioModal';
+// import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
 // Textlint
 // Textlint
 window.JSHINT = JSHINT;
 window.JSHINT = JSHINT;
@@ -109,7 +110,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.state = {
     this.state = {
       value: this.props.value,
       value: this.props.value,
-      isGfmMode: this.props.isGfmMode,
+      isGfmMode: this.props.isGfmMode ?? true,
       isEnabledEmojiAutoComplete: false,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
@@ -924,7 +925,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return (
     return (
       <React.Fragment>
       <React.Fragment>
 
 
-        <ReactCodeMirror
+        <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
           ref={(c) => { this.cm = c }}
           className={additionalClasses}
           className={additionalClasses}
           placeholder="search"
           placeholder="search"

+ 282 - 0
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -0,0 +1,282 @@
+import React, {
+  useState, useEffect, FC, useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+import { UserPicture } from '@growi/ui';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+import CodeMirror from 'codemirror/lib/codemirror';
+
+import PageContainer from '../../client/services/PageContainer';
+import AppContainer from '../../client/services/AppContainer';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import { useEditorMode } from '~/stores/ui';
+
+import { IRevisionOnConflict } from '../../interfaces/revision';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
+
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/merge/merge');
+require('codemirror/addon/merge/merge.css');
+const DMP = require('diff_match_patch');
+
+Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
+
+type ConflictDiffModalProps = {
+  isOpen: boolean | null;
+  onClose?: (() => void);
+  pageContainer: PageContainer;
+  appContainer: AppContainer;
+  markdownOnEdit: string;
+};
+
+type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
+  createdAt: string
+}
+
+export const ConflictDiffModal: FC<ConflictDiffModalProps> = (props) => {
+  const { t } = useTranslation('');
+  const [resolvedRevision, setResolvedRevision] = useState<string>('');
+  const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
+  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
+  const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
+
+  const { data: editorMode } = useEditorMode();
+
+  const uncontrolledRef = useRef<CodeMirror>(null);
+
+  const { pageContainer, appContainer } = props;
+
+  const currentTime: Date = new Date();
+
+  const request: IRevisionOnConflictWithStringDate = {
+    revisionId: '',
+    revisionBody: props.markdownOnEdit,
+    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+    user: appContainer.currentUser,
+  };
+  const origin: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.revisionId || '',
+    revisionBody: pageContainer.state.markdown || '',
+    createdAt: pageContainer.state.updatedAt || '',
+    user: pageContainer.state.revisionAuthor,
+  };
+  const latest: IRevisionOnConflictWithStringDate = {
+    revisionId: pageContainer.state.remoteRevisionId || '',
+    revisionBody: pageContainer.state.remoteRevisionBody || '',
+    createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    user: pageContainer.state.lastUpdateUser,
+  };
+
+  useEffect(() => {
+    if (codeMirrorRef != null) {
+      CodeMirror.MergeView(codeMirrorRef, {
+        value: origin.revisionBody,
+        origLeft: request.revisionBody,
+        origRight: latest.revisionBody,
+        lineNumbers: true,
+        collapseIdentical: true,
+        showDifferences: true,
+        highlightDifferences: true,
+        connect: 'connect',
+        readOnly: true,
+        revertButtons: false,
+      });
+    }
+  }, [codeMirrorRef, origin.revisionBody, request.revisionBody, latest.revisionBody]);
+
+  const onClose = () => {
+    if (props.onClose != null) {
+      props.onClose();
+    }
+  };
+
+  const onResolveConflict = async() : Promise<void> => {
+    // disable button after clicked
+    setIsRevisionSelected(false);
+
+    const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
+
+    try {
+      await pageContainer.resolveConflict(codeMirrorVal, editorMode);
+      onClose();
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      pageContainer.showErrorToastr(error);
+    }
+
+  };
+
+  const onExpandModal = () => {
+    setIsModalExpanded(true);
+  };
+
+  const onContractModal = () => {
+    setIsModalExpanded(false);
+  };
+
+  const resizeAndCloseButtons = (
+    <div className="d-flex flex-nowrap">
+      <ExpandOrContractButton
+        isWindowExpanded={isModalExpanded}
+        expandWindow={onExpandModal}
+        contractWindow={onContractModal}
+      />
+      <button type="button" className="close text-white" onClick={onClose} aria-label="Close">
+        <span aria-hidden="true">&times;</span>
+      </button>
+    </div>
+  );
+
+  return (
+    <Modal
+      isOpen={props.isOpen || false}
+      toggle={onClose}
+      backdrop="static"
+      className={`${isModalExpanded ? ' grw-modal-expanded' : ''}`}
+      size="xl"
+    >
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light align-items-center py-3" close={resizeAndCloseButtons}>
+        <i className="icon-fw icon-exclamation" />{t('modal_resolve_conflict.resolve_conflict')}
+      </ModalHeader>
+      <ModalBody className="mx-4 my-1">
+        { props.isOpen
+        && (
+          <div className="row">
+            <div className="col-12 text-center mt-2 mb-4">
+              <h2 className="font-weight-bold">{t('modal_resolve_conflict.resolve_conflict_message')}</h2>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.requested_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={request.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {request.user.username}</p>
+                  <p className="my-0">{request.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.origin_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={origin.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {origin.user.username}</p>
+                  <p className="my-0">{origin.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-4">
+              <h3 className="font-weight-bold my-2">{t('modal_resolve_conflict.latest_revision')}</h3>
+              <div className="d-flex align-items-center my-3">
+                <div>
+                  <UserPicture user={latest.user} size="lg" noLink noTooltip />
+                </div>
+                <div className="ml-3 text-muted">
+                  <p className="my-0">updated by {latest.user.username}</p>
+                  <p className="my-0">{latest.createdAt}</p>
+                </div>
+              </div>
+            </div>
+            <div className="col-12" ref={(el) => { setCodeMirrorRef(el) }}></div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(request.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(origin.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'origin' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-4">
+              <div className="text-center my-4">
+                <button
+                  type="button"
+                  className="btn btn-outline-primary"
+                  onClick={() => {
+                    setIsRevisionSelected(true);
+                    setResolvedRevision(latest.revisionBody);
+                  }}
+                >
+                  <i className="icon-fw icon-arrow-down-circle"></i>
+                  {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                </button>
+              </div>
+            </div>
+            <div className="col-12">
+              <div className="border border-dark">
+                <h3 className="font-weight-bold my-2 mx-2">{t('modal_resolve_conflict.selected_editable_revision')}</h3>
+                <UncontrolledCodeMirror
+                  ref={uncontrolledRef}
+                  value={resolvedRevision}
+                  options={{
+                    placeholder: t('modal_resolve_conflict.resolve_conflict_message'),
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        )}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-outline-secondary"
+          onClick={onClose}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-primary ml-3"
+          onClick={onResolveConflict}
+          disabled={!isRevisionselected}
+        >
+          {t('modal_resolve_conflict.resolve_and_save')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+ConflictDiffModal.propTypes = {
+  isOpen: PropTypes.bool,
+  onClose: PropTypes.func,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markdownOnEdit: PropTypes.string.isRequired,
+};
+
+ConflictDiffModal.defaultProps = {
+  isOpen: false,
+};

+ 100 - 83
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,8 @@ import {
 import Dropzone from 'react-dropzone';
 import Dropzone from 'react-dropzone';
 
 
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import Cheatsheet from './Cheatsheet';
 import Cheatsheet from './Cheatsheet';
@@ -18,6 +20,7 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 import TextAreaEditor from './TextAreaEditor';
 
 
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
+import { ConflictDiffModal } from './ConflictDiffModal';
 
 
 class Editor extends AbstractEditor {
 class Editor extends AbstractEditor {
 
 
@@ -276,6 +279,7 @@ class Editor extends AbstractEditor {
     );
     );
   }
   }
 
 
+
   render() {
   render() {
     const flexContainer = {
     const flexContainer = {
       height: '100%',
       height: '100%',
@@ -286,88 +290,97 @@ class Editor extends AbstractEditor {
     const isMobile = this.props.isMobile;
     const isMobile = this.props.isMobile;
 
 
     return (
     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 && (
-                  <Subscribe to={[EditorContainer]}>
-                    { editorContainer => (
-                      // eslint-disable-next-line arrow-body-style
-                      <CodeMirrorEditor
-                        ref={(c) => { this.cmEditor = c }}
-                        indentSize={editorContainer.state.indentSize}
-                        editorOptions={editorContainer.state.editorOptions}
-                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
-                        textlintRules={editorContainer.state.textlintRules}
-                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
-                        onPasteFiles={this.pasteFilesHandler}
-                        onDragEnter={this.dragEnterHandler}
-                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                        {...this.props}
-                      />
-                    )}
-                  </Subscribe>
-                )}
-
-                {/* 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>
+      <>
+        <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 && (
+                    <Subscribe to={[EditorContainer]}>
+                      { editorContainer => (
+                        // eslint-disable-next-line arrow-body-style
+                        <CodeMirrorEditor
+                          ref={(c) => { this.cmEditor = c }}
+                          indentSize={editorContainer.state.indentSize}
+                          editorOptions={editorContainer.state.editorOptions}
+                          isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                          textlintRules={editorContainer.state.textlintRules}
+                          onInitializeTextlint={editorContainer.retrieveEditorSettings}
+                          onPasteFiles={this.pasteFilesHandler}
+                          onDragEnter={this.dragEnterHandler}
+                          onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                          onAddAttachmentButtonClicked={this.addAttachmentHandler}
+                          {...this.props}
+                        />
+                      )}
+                    </Subscribe>
+                  )}
+
+                  {/* 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>
+        <ConflictDiffModal
+          isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
+          onClose={() => this.props.pageContainer.setState({ isConflictDiffModalOpen: false })}
+          appContainer={this.props.appContainer}
+          pageContainer={this.props.pageContainer}
+          markdownOnEdit={this.props.value}
+        />
+      </>
     );
     );
   }
   }
 
 
@@ -375,6 +388,8 @@ class Editor extends AbstractEditor {
 
 
 Editor.propTypes = Object.assign({
 Editor.propTypes = Object.assign({
   noCdn: PropTypes.bool,
   noCdn: PropTypes.bool,
+  // this value is markdown
+  value: PropTypes.string,
   isMobile: PropTypes.bool,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
@@ -382,6 +397,8 @@ Editor.propTypes = Object.assign({
   onChange: PropTypes.func,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 }, AbstractEditor.propTypes);
 }, AbstractEditor.propTypes);
 
 
-export default withUnstatedContainers(Editor, [EditorContainer]);
+export default withUnstatedContainers(Editor, [EditorContainer, PageContainer, AppContainer]);

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

@@ -26,14 +26,22 @@ class PageStatusAlert extends React.Component {
     };
     };
 
 
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
     this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForRevisionOutdated = this.getContentsForRevisionOutdated.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
     this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
+    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
   }
   }
 
 
   refreshPage() {
   refreshPage() {
     window.location.reload();
     window.location.reload();
   }
   }
 
 
+  onClickResolveConflict() {
+    this.props.pageContainer.setState({
+      isConflictDiffModalOpen: true,
+    });
+  }
+
   getContentsForSomeoneEditingAlert() {
   getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
     const { t } = this.props;
     return [
     return [
@@ -49,6 +57,45 @@ class PageStatusAlert extends React.Component {
     ];
     ];
   }
   }
 
 
+  getContentsForRevisionOutdated() {
+    const { t, appContainer, pageContainer } = this.props;
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
+
+    let markdownOnEdit = '';
+    let isConflictOnEdit = false;
+
+    if (pageEditor != null) {
+      markdownOnEdit = pageEditor.getMarkdown();
+      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    }
+
+    return [
+      ['bg-warning', 'd-hackmd-none'],
+      <>
+        <i className="icon-fw icon-pencil"></i>
+        {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+      </>,
+      <>
+        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
+          <i className="icon-fw icon-reload mr-1"></i>
+          {t('Load latest')}
+        </button>
+        {isConflictOnEdit
+          && (
+            <button
+              type="button"
+              onClick={this.onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )
+        }
+      </>,
+    ];
+  }
+
   getContentsForDraftExistsAlert(isRealtime) {
   getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
     const { t } = this.props;
     return [
     return [
@@ -92,8 +139,12 @@ class PageStatusAlert extends React.Component {
 
 
     let getContentsFunc = null;
     let getContentsFunc = null;
 
 
+    // when conflicting on save
+    if (isRevisionOutdated) {
+      getContentsFunc = this.getContentsForRevisionOutdated;
+    }
     // when remote revision is newer than both
     // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+    else if (isHackmdDocumentOutdated && isRevisionOutdated) {
       getContentsFunc = this.getContentsForUpdatedAlert;
       getContentsFunc = this.getContentsForUpdatedAlert;
     }
     }
     // when someone editing with HackMD
     // when someone editing with HackMD

+ 8 - 0
packages/app/src/components/SavePageControls.jsx

@@ -66,6 +66,14 @@ class SavePageControls extends React.Component {
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
       pageContainer.showErrorToastr(error);
+      if (error.code === 'conflict') {
+        pageContainer.setState({
+          remoteRevisionId: error.data.revisionId,
+          remoteRevisionBody: error.data.revisionBody,
+          remoteRevisionUpdateAt: error.data.createdAt,
+          lastUpdateUser: error.data.user,
+        });
+      }
     }
     }
   }
   }
 
 

+ 10 - 0
packages/app/src/components/SearchPage.jsx

@@ -4,6 +4,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import {
+  DetachCodeBlockInterceptor,
+  RestoreCodeBlockInterceptor,
+} from '../client/util/interceptor/detach-code-blocks';
+
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
@@ -47,6 +52,11 @@ class SearchPage extends React.Component {
       deleteTargetPageIds: new Set(),
       deleteTargetPageIds: new Set(),
     };
     };
 
 
+    // TODO: Move this code to the right place after completing the "omit unstated" initiative.
+    const { interceptorManager } = props.appContainer;
+    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(props.appContainer), 10); // process as soon as possible
+    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(props.appContainer), 900); // process as late as possible
+
     this.changeURL = this.changeURL.bind(this);
     this.changeURL = this.changeURL.bind(this);
     this.search = this.search.bind(this);
     this.search = this.search.bind(this);
     this.onSearchInvoked = this.onSearchInvoked.bind(this);
     this.onSearchInvoked = this.onSearchInvoked.bind(this);

+ 2 - 2
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,5 +1,5 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
-import SearchResultListItem from './SearchResultListItem';
+import PageListItem from '../Page/PageListItem';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
 import { IPageSearchResultData } from '../../interfaces/search';
 import { IPageSearchResultData } from '../../interfaces/search';
 
 
@@ -32,7 +32,7 @@ const SearchResultList: FC<Props> = (props:Props) => {
         const isChecked = selectedPagesIdList.has(page.pageData._id);
         const isChecked = selectedPagesIdList.has(page.pageData._id);
 
 
         return (
         return (
-          <SearchResultListItem
+          <PageListItem
             key={page.pageData._id}
             key={page.pageData._id}
             page={page}
             page={page}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}

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

@@ -18,7 +18,7 @@ const PageTree: FC = memo(() => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
-  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
+  const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
 
 
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
 
@@ -26,7 +26,7 @@ const PageTree: FC = memo(() => {
   const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
   const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
   const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
   const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
 
 
-  const targetPathOrId = targetId || notFoundTargetPathOrId;
+  const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
 
 
   if (migrationStatus == null) {
   if (migrationStatus == null) {
     return (
     return (

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

@@ -4,6 +4,7 @@ import React, {
 import nodePath from 'path';
 import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
+import { useDrag, useDrop } from 'react-dnd';
 import { toastWarning } from '~/client/util/apiNotification';
 import { toastWarning } from '~/client/util/apiNotification';
 
 
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
@@ -109,6 +110,39 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
+
+  const [{ isDragging }, drag] = useDrag(() => ({
+    type: 'PAGE_TREE',
+    item: { page },
+    collect: monitor => ({
+      isDragging: monitor.isDragging(),
+    }),
+  }));
+
+  const pageItemDropHandler = () => {
+    // TODO: hit an api to rename the page by 85175
+    // eslint-disable-next-line no-console
+    console.log('pageItem was droped!!');
+  };
+
+  const [{ isOver }, drop] = useDrop(() => ({
+    accept: 'PAGE_TREE',
+    drop: pageItemDropHandler,
+    hover: (item, monitor) => {
+      // when a drag item is overlapped more than 1 sec, the drop target item will be opened.
+      if (monitor.isOver()) {
+        setTimeout(() => {
+          if (monitor.isOver()) {
+            setIsOpen(true);
+          }
+        }, 1000);
+      }
+    },
+    collect: monitor => ({
+      isOver: monitor.isOver(),
+    }),
+  }));
+
   const hasChildren = useCallback((): boolean => {
   const hasChildren = useCallback((): boolean => {
     return currentChildren != null && currentChildren.length > 0;
     return currentChildren != null && currentChildren.length > 0;
   }, [currentChildren]);
   }, [currentChildren]);
@@ -180,8 +214,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [data, isOpen]);
   }, [data, isOpen]);
 
 
   return (
   return (
-    <>
-      <div className={`grw-pagetree-item d-flex align-items-center pr-1 ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}>
+    <div className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}`}>
+      <div
+        ref={(c) => { drag(c); drop(c) }}
+        className={`grw-pagetree-item d-flex align-items-center pr-1 ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
+      >
         <button
         <button
           type="button"
           type="button"
           className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
           className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
@@ -211,7 +248,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       {isEnableActions && (
       {isEnableActions && (
         <ClosableTextInput
         <ClosableTextInput
           isShown={isNewPageInputShown}
           isShown={isNewPageInputShown}
-          placeholder={t('Input title')}
+          placeholder={t('Input page name')}
           onClickOutside={() => { setNewPageInputShown(false) }}
           onClickOutside={() => { setNewPageInputShown(false) }}
           onPressEnter={onPressEnterHandler}
           onPressEnter={onPressEnterHandler}
           inputValidator={inputValidator}
           inputValidator={inputValidator}
@@ -219,7 +256,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       )}
       )}
       {
       {
         isOpen && hasChildren() && currentChildren.map(node => (
         isOpen && hasChildren() && currentChildren.map(node => (
-          <div key={node.page._id} className="grw-pagetree-item-container">
+          <div key={node.page._id} className="grw-pagetree-item-children">
             <Item
             <Item
               isEnableActions={isEnableActions}
               isEnableActions={isEnableActions}
               itemNode={node}
               itemNode={node}
@@ -230,7 +267,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           </div>
           </div>
         ))
         ))
       }
       }
-    </>
+    </div>
   );
   );
 
 
 };
 };

+ 10 - 7
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -54,13 +54,16 @@ function LargePageItem({ page }) {
   }
   }
 
 
   const tags = page.tags;
   const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-        {tag.name}
-      </a>
-    );
-  });
+  // when tag document is deleted from database directly tags includes null
+  const tagElements = tags.includes(null)
+    ? <></>
+    : tags.map((tag) => {
+      return (
+        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+          {tag.name}
+        </a>
+      );
+    });
 
 
   return (
   return (
     <li className="list-group-item py-3 px-0">
     <li className="list-group-item py-3 px-0">

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

@@ -5,6 +5,7 @@ import { useCurrentSidebarContents } from '~/stores/ui';
 import RecentChanges from './RecentChanges';
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
 import CustomSidebar from './CustomSidebar';
 import PageTree from './PageTree';
 import PageTree from './PageTree';
+import Tag from './Tag';
 
 
 type Props = {
 type Props = {
 };
 };
@@ -20,6 +21,9 @@ const SidebarContents: FC<Props> = (props: Props) => {
     case SidebarContentsType.TREE:
     case SidebarContentsType.TREE:
       Contents = PageTree;
       Contents = PageTree;
       break;
       break;
+    case SidebarContentsType.TAG:
+      Contents = Tag;
+      break;
     default:
     default:
       Contents = CustomSidebar;
       Contents = CustomSidebar;
   }
   }

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

@@ -82,6 +82,8 @@ const SidebarNav: FC<Props> = (props: Props) => {
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />}
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />}
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} /> }
+        {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
       </div>
       </div>
       <div className="grw-sidebar-nav-secondary-container">
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}

+ 44 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -0,0 +1,44 @@
+import React, { FC, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import TagsList from '../TagsList';
+
+const Tag: FC = () => {
+  const { t } = useTranslation('');
+  const [isOnReload, setIsOnReload] = useState<boolean>(false);
+
+  useEffect(() => {
+    setIsOnReload(false);
+  }, [isOnReload]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">{t('Tags')}</h3>
+        <button
+          type="button"
+          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          onClick={() => {
+            setIsOnReload(true);
+          }}
+        >
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      <div className="d-flex justify-content-center">
+        <button
+          className="btn btn-primary my-4"
+          type="button"
+          onClick={() => { window.location.href = '/tags' }}
+        >
+          {t('Check All tags')}
+        </button>
+      </div>
+      <div className="grw-container-convertible mb-5 pb-5">
+        <TagsList isOnReload={isOnReload} />
+      </div>
+    </>
+  );
+
+};
+
+export default Tag;

+ 38 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -0,0 +1,38 @@
+import React, { FC } from 'react';
+
+import { TagCloud } from 'react-tagcloud';
+
+type Tag = {
+  _id: string,
+  name: string,
+  count: number,
+}
+
+type Props = {
+  tags:Tag[],
+  minSize?: number,
+  maxSize?: number,
+}
+
+const MIN_FONT_SIZE = 12;
+const MAX_FONT_SIZE = 36;
+
+const TagCloudBox: FC<Props> = (props:Props) => {
+  return (
+    <>
+      <TagCloud
+        minSize={props.minSize || MIN_FONT_SIZE}
+        maxSize={props.maxSize || MAX_FONT_SIZE}
+        tags={props.tags.map((tag) => {
+          return { value: tag.name, count: tag.count };
+        })}
+        style={{ cursor: 'pointer' }}
+        className="simple-cloud"
+        onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
+      />
+    </>
+  );
+
+};
+
+export default TagCloudBox;

+ 44 - 18
packages/app/src/components/TagsList.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+import { apiGet } from '../client/util/apiv1-client';
+import { toastError } from '../client/util/apiNotification';
 
 
 class TagsList extends React.Component {
 class TagsList extends React.Component {
 
 
@@ -25,6 +28,12 @@ class TagsList extends React.Component {
     await this.getTagList(1);
     await this.getTagList(1);
   }
   }
 
 
+  async componentDidUpdate() {
+    if (this.props.isOnReload) {
+      await this.getTagList(this.state.activePage);
+    }
+  }
+
   async handlePage(selectedPage) {
   async handlePage(selectedPage) {
     await this.getTagList(selectedPage);
     await this.getTagList(selectedPage);
   }
   }
@@ -32,7 +41,14 @@ class TagsList extends React.Component {
   async getTagList(selectPageNumber) {
   async getTagList(selectPageNumber) {
     const limit = this.state.pagingLimit;
     const limit = this.state.pagingLimit;
     const offset = (selectPageNumber - 1) * limit;
     const offset = (selectPageNumber - 1) * limit;
-    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+    let res;
+
+    try {
+      res = await apiGet('/tags.list', { limit, offset });
+    }
+    catch (error) {
+      toastError(error);
+    }
 
 
     const totalTags = res.totalCount;
     const totalTags = res.totalCount;
     const tagData = res.data;
     const tagData = res.data;
@@ -67,34 +83,44 @@ class TagsList extends React.Component {
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
 
 
     return (
     return (
-      <div className="text-center">
-        <div className="tag-list">
-          <ul className="list-group text-left">
-            {this.generateTagList(this.state.tagData)}
-          </ul>
-          {messageForNoTag}
-        </div>
-        <div className="tag-list-pagination">
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePage}
-            totalItemsCount={this.state.totalTags}
-            pagingLimit={this.state.pagingLimit}
-            size="sm"
-          />
+      <>
+        <header className="py-0">
+          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
+        </header>
+        <div className="row text-center">
+          <div className="col-12 mb-5 px-5">
+            <TagCloudBox tags={this.state.tagData} minSize={20} />
+          </div>
+          <div className="col-12 tag-list mb-4">
+            <ul className="list-group text-left">
+              {this.generateTagList(this.state.tagData)}
+            </ul>
+            {messageForNoTag}
+          </div>
+          <div className="col-12 tag-list-pagination">
+            <PaginationWrapper
+              activePage={this.state.activePage}
+              changePage={this.handlePage}
+              totalItemsCount={this.state.totalTags}
+              pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="md"
+            />
+          </div>
         </div>
         </div>
-      </div>
+      </>
     );
     );
   }
   }
 
 
 }
 }
 
 
 TagsList.propTypes = {
 TagsList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  isOnReload: PropTypes.bool,
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 };
 };
 
 
 TagsList.defaultProps = {
 TagsList.defaultProps = {
+  isOnReload: false,
 };
 };
 
 
 export default withTranslation()(TagsList);
 export default withTranslation()(TagsList);

+ 58 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -0,0 +1,58 @@
+import React, { forwardRef, ReactNode, Ref } from 'react';
+import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+import { Container, Subscribe } from 'unstated';
+import EditorContainer from '~/client/services/EditorContainer';
+import AbstractEditor, { AbstractEditorProps } from '~/components/PageEditor/AbstractEditor';
+
+window.CodeMirror = require('codemirror');
+require('codemirror/addon/display/placeholder');
+require('~/client/util/codemirror/gfm-growi.mode');
+
+export interface UncontrolledCodeMirrorProps extends AbstractEditorProps {
+  value: string;
+  options?: ICodeMirror['options'];
+  isGfmMode?: boolean;
+  indentSize?: number;
+  lineNumbers?: boolean;
+}
+
+interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
+  editorContainer: Container<EditorContainer>;
+  forwardedRef: Ref<UncontrolledCodeMirrorCore>;
+}
+
+class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+
+  render(): ReactNode {
+
+    const {
+      value, isGfmMode, indentSize, lineNumbers, editorContainer, options, forwardedRef, ...rest
+    } = this.props;
+
+    const { editorOptions } = editorContainer.state;
+
+    return (
+      <CodeMirror
+        ref={forwardedRef}
+        value={value}
+        options={{
+          lineNumbers: lineNumbers ?? true,
+          mode: isGfmMode ? 'gfm-growi' : undefined,
+          theme: editorOptions.theme,
+          styleActiveLine: editorOptions.styleActiveLine,
+          tabSize: 4,
+          indentUnit: indentSize,
+          ...options,
+        }}
+        {...rest}
+      />
+    );
+  }
+
+}
+
+export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, UncontrolledCodeMirrorProps>((props, ref) => (
+  <Subscribe to={[EditorContainer]}>
+    {(EditorContainer: Container<EditorContainer>) => <UncontrolledCodeMirrorCore {...props} forwardedRef={ref} editorContainer={EditorContainer} />}
+  </Subscribe>
+));

+ 2 - 2
packages/app/src/components/UnstatedUtils.jsx → packages/app/src/components/UnstatedUtils.tsx

@@ -42,8 +42,8 @@ function generateAutoNamedProps(instances) {
  *    )}
  *    )}
  *  </Subscribe>
  *  </Subscribe>
  */
  */
-export function withUnstatedContainers(Component, containerClasses) {
-  return React.forwardRef((props, ref) => (
+export function withUnstatedContainers<T, P>(Component, containerClasses): React.ForwardRefExoticComponent<React.PropsWithoutRef<P> & React.RefAttributes<T>> {
+  return React.forwardRef<T, P>((props, ref) => (
     // wrap with <Subscribe></Subscribe>
     // wrap with <Subscribe></Subscribe>
     <Subscribe to={containerClasses}>
     <Subscribe to={containerClasses}>
       { (...containers) => {
       { (...containers) => {

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

@@ -0,0 +1,7 @@
+import { IUser } from '~/interfaces/user';
+
+export interface IBookmarksInfo {
+  isBookmarked: boolean
+  sumOfBookmarks: number
+  bookmarkedUsers: IUser[]
+}

+ 3 - 0
packages/app/src/interfaces/global.ts

@@ -0,0 +1,3 @@
+import Xss from '~/services/xss';
+
+export type CustomWindow = Window & typeof globalThis & { xss: Xss };

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

@@ -5,7 +5,7 @@ import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
 import { HasObjectId } from './has-object-id';
 
 
 
 
-export type IPage = {
+export interface IPage {
   path: string,
   path: string,
   status: string,
   status: string,
   revision: Ref<IRevision>,
   revision: Ref<IRevision>,
@@ -15,6 +15,7 @@ export type IPage = {
   updatedAt: Date,
   updatedAt: Date,
   seenUsers: Ref<IUser>[],
   seenUsers: Ref<IUser>[],
   parent: Ref<IPage> | null,
   parent: Ref<IPage> | null,
+  descendantCount: number,
   isEmpty: boolean,
   isEmpty: boolean,
   redirectTo: string,
   redirectTo: string,
   grant: number,
   grant: number,

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

@@ -7,3 +7,10 @@ export type IRevision = {
   createdAt: Date,
   createdAt: Date,
   updatedAt: Date,
   updatedAt: Date,
 }
 }
+
+export type IRevisionOnConflict = {
+  revisionId: string,
+  revisionBody: string,
+  createdAt: Date,
+  user: IUser
+}

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

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

+ 13 - 0
packages/app/src/interfaces/user-group-response.ts

@@ -0,0 +1,13 @@
+import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
+
+export type UserGroupListResult = {
+  userGroups: IUserGroupHasId[],
+};
+
+export type ChildUserGroupListResult = {
+  childUserGroups: IUserGroupHasId[],
+};
+
+export type UserGroupRelationListResult = {
+  userGroupRelations: IUserGroupRelationHasId[],
+};

+ 11 - 3
packages/app/src/interfaces/user.ts

@@ -1,3 +1,6 @@
+import { Ref } from './common';
+import { HasObjectId } from './has-object-id';
+
 export type IUser = {
 export type IUser = {
   name: string;
   name: string;
   username: string;
   username: string;
@@ -6,13 +9,18 @@ export type IUser = {
 }
 }
 
 
 export type IUserGroupRelation = {
 export type IUserGroupRelation = {
-  relatedGroup: IUserGroup,
-  relatedUser: IUser,
+  relatedGroup: Ref<IUserGroup>,
+  relatedUser: Ref<IUser>,
   createdAt: Date,
   createdAt: Date,
 }
 }
 
 
 export type IUserGroup = {
 export type IUserGroup = {
-  userGroupId:string;
   name: string;
   name: string;
   createdAt: Date;
   createdAt: Date;
+  description: string;
+  parent: Ref<IUserGroup> | null;
 }
 }
+
+export type IUserHasId = IUser & HasObjectId;
+export type IUserGroupHasId = IUserGroup & HasObjectId;
+export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;

+ 70 - 0
packages/app/src/migrations/20210921173042-add-is-trashed-field.js

@@ -0,0 +1,70 @@
+import mongoose from 'mongoose';
+
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+import getPageModel from '~/server/models/page';
+
+const logger = loggerFactory('growi:migrate:add-column-is-trashed');
+
+const LIMIT = 1000;
+
+/**
+ * set isPageTrashed of pagetagrelations included in updateIdList as true
+ */
+const updateIsPageTrashed = async(db, updateIdList) => {
+  await db.collection('pagetagrelations').updateMany(
+    { relatedPage: { $in: updateIdList } },
+    { $set: { isPageTrashed: true } },
+  );
+};
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const Page = getModelSafely('Page') || getPageModel();
+
+    let updateDeletedPageIds = [];
+
+    // set isPageTrashed as false temporarily
+    await db.collection('pagetagrelations').updateMany(
+      {},
+      { $set: { isPageTrashed: false } },
+    );
+
+    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED }).select('_id').cursor()) {
+      updateDeletedPageIds.push(deletedPage._id);
+      // excute updateMany by one thousand ids
+      if (updateDeletedPageIds.length === LIMIT) {
+        await updateIsPageTrashed(db, updateDeletedPageIds);
+        updateDeletedPageIds = [];
+      }
+    }
+
+    // use ids that have not been updated
+    if (updateDeletedPageIds.length > 0) {
+      await updateIsPageTrashed(db, updateDeletedPageIds);
+    }
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await db.collection('pagetagrelations').updateMany(
+        {},
+        { $unset: { isPageTrashed: '' } },
+      );
+      logger.info('Migration has been successfully rollbacked');
+    }
+    catch (err) {
+      logger.error(err);
+      logger.info('Migration has failed');
+    }
+
+  },
+};

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:crowi:express-init');
   const debug = require('debug')('growi:crowi:express-init');
   const path = require('path');
   const path = require('path');
   const express = require('express');
   const express = require('express');
+  const compression = require('compression');
   const helmet = require('helmet');
   const helmet = require('helmet');
   const bodyParser = require('body-parser');
   const bodyParser = require('body-parser');
   const cookieParser = require('cookie-parser');
   const cookieParser = require('cookie-parser');
@@ -53,6 +54,8 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
       nsSeparator: '::',
     });
     });
 
 
+  app.use(compression());
+
   app.use(helmet({
   app.use(helmet({
     contentSecurityPolicy: false,
     contentSecurityPolicy: false,
     expectCt: false,
     expectCt: false,

+ 10 - 4
packages/app/src/server/crowi/index.js

@@ -20,10 +20,13 @@ import AppService from '../service/app';
 import AclService from '../service/acl';
 import AclService from '../service/acl';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
+import PageService from '../service/page';
+import PageGrantService from '../service/page-grant';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 
 
-import Actiity from '../models/activity';
+import Activity from '../models/activity';
+import UserGroup from '../models/user-group';
 
 
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -314,7 +317,8 @@ Crowi.prototype.setupModels = async function() {
   allModels = models;
   allModels = models;
 
 
   // include models that independent from crowi
   // include models that independent from crowi
-  allModels.Activity = Actiity;
+  allModels.Activity = Activity;
+  allModels.UserGroup = UserGroup;
 
 
   Object.keys(allModels).forEach((key) => {
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
     return this.model(key, models[key](this));
@@ -676,9 +680,11 @@ Crowi.prototype.setupImport = async function() {
 };
 };
 
 
 Crowi.prototype.setupPageService = async function() {
 Crowi.prototype.setupPageService = async function() {
-  const PageEventService = require('../service/page');
   if (this.pageService == null) {
   if (this.pageService == null) {
-    this.pageService = new PageEventService(this);
+    this.pageService = new PageService(this);
+  }
+  if (this.pageGrantService == null) {
+    this.pageGrantService = new PageGrantService(this);
   }
   }
 };
 };
 
 

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

@@ -7,7 +7,6 @@ module.exports = {
   PageTagRelation: require('./page-tag-relation'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   User: require('./user'),
   ExternalAccount: require('./external-account'),
   ExternalAccount: require('./external-account'),
-  UserGroup: require('./user-group'),
   UserGroupRelation: require('./user-group-relation'),
   UserGroupRelation: require('./user-group-relation'),
   Revision: require('./revision'),
   Revision: require('./revision'),
   Tag: require('./tag'),
   Tag: require('./tag'),

+ 44 - 40
packages/app/src/server/models/obsolete-page.js

@@ -156,6 +156,21 @@ export class PageQueryBuilder {
 
 
   }
   }
 
 
+  addConditionToListOnlyAncestors(path) {
+    const pathNormalized = pathUtils.normalizePath(path);
+    const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
+
+    this.query = this.query
+      .and({
+        path: {
+          $in: ancestorsPaths,
+        },
+      });
+
+    return this;
+
+  }
+
   /**
   /**
    * generate the query to find pages that start with `path`
    * generate the query to find pages that start with `path`
    *
    *
@@ -970,14 +985,17 @@ export const getPageSchema = (crowi) => {
     }
     }
   }
   }
 
 
-  pageSchema.statics.create = async function(path, body, user, options = {}) {
+  pageSchema.statics.createV4 = async function(path, body, user, options = {}) {
+    /*
+     * v4 compatible process
+     */
     validateCrowi();
     validateCrowi();
 
 
     const Page = this;
     const Page = this;
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', redirectTo, grantUserGroupId, parentId,
-    } = options;
+    const format = options.format || 'markdown';
+    const redirectTo = options.redirectTo || null;
+    const grantUserGroupId = options.grantUserGroupId || null;
 
 
     // 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
@@ -988,37 +1006,18 @@ export const getPageSchema = (crowi) => {
       grant = GRANT_PUBLIC;
       grant = GRANT_PUBLIC;
     }
     }
 
 
-    const isExist = await this.count({ path, isEmpty: false }); // not validate empty page
+    const isExist = await this.count({ path });
+
     if (isExist) {
     if (isExist) {
       throw new Error('Cannot create new page to existed path');
       throw new Error('Cannot create new page to existed path');
     }
     }
 
 
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
-    if (emptyPage != null) {
-      page = emptyPage;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    let parent = parentId;
-    if (isV5Compatible && parent == null && !isTopPage(path)) {
-      parent = await Page.getParentIdAndFillAncestors(path);
-    }
-
+    const page = new Page();
     page.path = path;
     page.path = path;
     page.creator = user;
     page.creator = user;
     page.lastUpdateUser = user;
     page.lastUpdateUser = user;
     page.redirectTo = redirectTo;
     page.redirectTo = redirectTo;
     page.status = STATUS_PUBLISHED;
     page.status = STATUS_PUBLISHED;
-    page.parent = parent;
 
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
@@ -1034,7 +1033,7 @@ export const getPageSchema = (crowi) => {
     return savedPage;
     return savedPage;
   };
   };
 
 
-  pageSchema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+  pageSchema.statics.updatePageV4 = async function(pageData, body, previousBody, user, options = {}) {
     validateCrowi();
     validateCrowi();
 
 
     const Revision = crowi.model('Revision');
     const Revision = crowi.model('Revision');
@@ -1121,24 +1120,29 @@ export const getPageSchema = (crowi) => {
     return await queryBuilder.query.exec();
     return await queryBuilder.query.exec();
   };
   };
 
 
-  pageSchema.statics.publicizePage = async function(page) {
-    page.grantedGroup = null;
-    page.grant = GRANT_PUBLIC;
-    await page.save();
+  pageSchema.statics.publicizePages = async function(pages) {
+    const operationsToPublicize = pages.map((page) => {
+      return {
+        updateOne: {
+          filter: { _id: page._id },
+          update: {
+            grantedGroup: null,
+            grant: this.GRANT_PUBLIC,
+          },
+        },
+      };
+    });
+    await this.bulkWrite(operationsToPublicize);
   };
   };
 
 
-  pageSchema.statics.transferPageToGroup = async function(page, transferToUserGroupId) {
+  pageSchema.statics.transferPagesToGroup = async function(pages, transferToUserGroupId) {
     const UserGroup = mongoose.model('UserGroup');
     const UserGroup = mongoose.model('UserGroup');
 
 
-    // check page existence
-    const isExist = await UserGroup.count({ _id: transferToUserGroupId }) > 0;
-    if (isExist) {
-      page.grantedGroup = transferToUserGroupId;
-      await page.save();
-    }
-    else {
-      throw new Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroupId);
+    if ((await UserGroup.count({ _id: transferToUserGroupId })) === 0) {
+      throw Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroupId);
     }
     }
+
+    await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroup: transferToUserGroupId });
   };
   };
 
 
   /**
   /**

+ 45 - 25
packages/app/src/server/models/page-tag-relation.js

@@ -24,6 +24,13 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     type: ObjectId,
     ref: 'Tag',
     ref: 'Tag',
     required: true,
     required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
   },
   },
 });
 });
 // define unique compound index
 // define unique compound index
@@ -39,27 +46,34 @@ schema.plugin(uniqueValidator);
 class PageTagRelation {
 class PageTagRelation {
 
 
   static async createTagListWithCount(option) {
   static async createTagListWithCount(option) {
-    const Tag = mongoose.model('Tag');
     const opt = option || {};
     const opt = option || {};
     const sortOpt = opt.sortOpt || {};
     const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset || 0;
-    const limit = opt.limit || 50;
+    const offset = opt.offset;
+    const limit = opt.limit;
 
 
-    const existTagIds = await Tag.find().distinct('_id');
     const tags = await this.aggregate()
     const tags = await this.aggregate()
-      .match({ relatedTag: { $in: existTagIds } })
-      .group({ _id: '$relatedTag', count: { $sum: 1 } })
-      .sort(sortOpt);
-
-    const list = tags.slice(offset, offset + limit);
-    const totalCount = tags.length;
-
-    return { list, totalCount };
+      .match({ isPageTrashed: false })
+      .lookup({
+        from: 'tags',
+        localField: 'relatedTag',
+        foreignField: '_id',
+        as: 'tag',
+      })
+      .unwind('$tag')
+      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+    return { data: tags, totalCount };
   }
   }
 
 
-  static async findByPageId(pageId) {
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
-    return relations.filter((relation) => { return relation.relatedTag !== null });
+  static async findByPageId(pageId, options = {}) {
+    const isAcceptRelatedTagNull = options.nullable || null;
+    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
   }
   }
 
 
   static async listTagNamesByPage(pageId) {
   static async listTagNamesByPage(pageId) {
@@ -125,17 +139,23 @@ class PageTagRelation {
     const Tag = mongoose.model('Tag');
     const Tag = mongoose.model('Tag');
 
 
     // get relations for this page
     // get relations for this page
-    const relations = await this.findByPageId(pageId);
-
-    // unlink relations
-    const unlinkTagRelations = relations.filter((relation) => { return !tags.includes(relation.relatedTag.name) });
-    const bulkDeletePromise = this.deleteMany({
-      relatedPage: pageId,
-      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
+    const relations = await this.findByPageId(pageId, { nullable: true });
+
+    const unlinkTagRelationIds = [];
+    const relatedTagNames = [];
+
+    relations.forEach((relation) => {
+      if (relation.relatedTag == null) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+      else {
+        relatedTagNames.push(relation.relatedTag.name);
+        if (!tags.includes(relation.relatedTag.name)) {
+          unlinkTagRelationIds.push(relation._id);
+        }
+      }
     });
     });
-
-    // filter tags to create
-    const relatedTagNames = relations.map((relation) => { return relation.relatedTag.name });
+    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
     // find or create tags
     // find or create tags
     const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
     const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
     const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
     const tagEntities = await Tag.findOrCreateMany(tagsToCreate);

+ 351 - 47
packages/app/src/server/models/page.ts

@@ -6,7 +6,6 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 import nodePath from 'path';
 import nodePath from 'path';
-import RE2 from 're2';
 
 
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
 import { getOrCreateModel, pagePathUtils } from '@growi/core';
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
@@ -14,7 +13,7 @@ import Crowi from '../crowi';
 import { IPage } from '../../interfaces/page';
 import { IPage } from '../../interfaces/page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
 import { getPageSchema, PageQueryBuilder } from './obsolete-page';
 
 
-const { isTopPage } = pagePathUtils;
+const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
 
 
@@ -24,7 +23,7 @@ const logger = loggerFactory('growi:models:page');
  */
  */
 const GRANT_PUBLIC = 1;
 const GRANT_PUBLIC = 1;
 const GRANT_RESTRICTED = 2;
 const GRANT_RESTRICTED = 2;
-const GRANT_SPECIFIED = 3;
+const GRANT_SPECIFIED = 3; // DEPRECATED
 const GRANT_OWNER = 4;
 const GRANT_OWNER = 4;
 const GRANT_USER_GROUP = 5;
 const GRANT_USER_GROUP = 5;
 const PAGE_GRANT_ERROR = 1;
 const PAGE_GRANT_ERROR = 1;
@@ -40,7 +39,7 @@ type TargetAndAncestorsResult = {
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
   createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
-  getParentIdAndFillAncestors(path: string): Promise<string | null>
+  getParentAndFillAncestors(path: string): Promise<PageDocument & { _id: any }>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
@@ -58,12 +57,14 @@ export interface PageModel extends Model<PageDocument> {
   STATUS_DELETED
   STATUS_DELETED
 }
 }
 
 
+type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
 
 const schema = new Schema<PageDocument, PageModel>({
 const schema = new Schema<PageDocument, PageModel>({
   parent: {
   parent: {
     type: ObjectId, ref: 'Page', index: true, default: null,
     type: ObjectId, ref: 'Page', index: true, default: null,
   },
   },
+  descendantCount: { type: Number, default: 0 },
   isEmpty: { type: Boolean, default: false },
   isEmpty: { type: Boolean, default: false },
   path: {
   path: {
     type: String, required: true, index: true,
     type: String, required: true, index: true,
@@ -95,36 +96,10 @@ const schema = new Schema<PageDocument, PageModel>({
 schema.plugin(mongoosePaginate);
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 schema.plugin(uniqueValidator);
 
 
-
-/*
- * Methods
- */
-const collectAncestorPaths = (path: string, ancestorPaths: string[] = []): string[] => {
-  if (isTopPage(path)) return ancestorPaths;
-
-  const parentPath = nodePath.dirname(path);
-  ancestorPaths.push(parentPath);
-  return collectAncestorPaths(parentPath, ancestorPaths);
-};
-
-
 const hasSlash = (str: string): boolean => {
 const hasSlash = (str: string): boolean => {
   return str.includes('/');
   return str.includes('/');
 };
 };
 
 
-/*
- * Generate RE2 instance for one level lower path
- */
-const generateChildrenRE2 = (path: string): RE2 => {
-  // https://regex101.com/r/laJGzj/1
-  // ex. /any_level1
-  if (isTopPage(path)) return new RE2(/^\/[^/]+$/);
-
-  // https://regex101.com/r/mrDJrx/1
-  // ex. /parent/any_child OR /any_level1
-  return new RE2(`^${path}(\\/[^/]+)\\/?$`);
-};
-
 /*
 /*
  * Generate RegExp instance for one level lower path
  * Generate RegExp instance for one level lower path
  */
  */
@@ -164,19 +139,77 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicO
   }
   }
 };
 };
 
 
-/*
- * Find the parent and update if the parent exists.
- * If not,
- *   - first   run createEmptyPagesByPaths with ancestor's paths to ensure all the ancestors exist
- *   - second  update ancestor pages' parent
- *   - finally return the target's parent page id
+schema.statics.createEmptyPage = async function(
+    path: string, parent: any, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
+): Promise<PageDocument & { _id: any }> {
+  if (parent == null) {
+    throw Error('parent must not be null');
+  }
+
+  const Page = this;
+  const page = new Page();
+  page.path = path;
+  page.isEmpty = true;
+  page.parent = parent;
+
+  return page.save();
+};
+
+/**
+ * Replace an existing page with an empty page.
+ * It updates the children's parent to the new empty page's _id.
+ * @param exPage a page document to be replaced
+ * @param pageToReplaceWith (optional) a page document to replace with
+ * @returns Promise<void>
  */
  */
-schema.statics.getParentIdAndFillAncestors = async function(path: string): Promise<Schema.Types.ObjectId> {
-  const parentPath = nodePath.dirname(path);
+schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?): Promise<void> {
+  // find parent
+  const parent = await this.findOne({ _id: exPage.parent });
+  if (parent == null) {
+    throw Error('parent to update does not exist. Prepare parent first.');
+  }
+
+  // create empty page at path
+  let newTarget = pageToReplaceWith;
+  if (newTarget) {
+    newTarget = await this.createEmptyPage(exPage.path, parent);
+  }
+
+  // find children by ex-page _id
+  const children = await this.find({ parent: exPage._id });
+
+  // bulkWrite
+  const operationForNewTarget = {
+    updateOne: {
+      filter: { _id: newTarget._id },
+      update: {
+        parent: parent._id,
+      },
+    },
+  };
+  const operationsForChildren = {
+    updateMany: {
+      filter: children.map(d => d._id),
+      update: {
+        parent: newTarget._id,
+      },
+    },
+  };
+
+  await this.bulkWrite([operationForNewTarget, operationsForChildren]);
+};
 
 
+/**
+ * Find parent or create parent if not exists.
+ * It also updates parent of ancestors
+ * @param path string
+ * @returns Promise<PageDocument>
+ */
+schema.statics.getParentAndFillAncestors = async function(path: string): Promise<PageDocument> {
+  const parentPath = nodePath.dirname(path);
   const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
   const parent = await this.findOne({ path: parentPath }); // find the oldest parent which must always be the true parent
   if (parent != null) {
   if (parent != null) {
-    return parent._id;
+    return parent;
   }
   }
 
 
   /*
   /*
@@ -188,16 +221,15 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string): Promi
   await this.createEmptyPagesByPaths(ancestorPaths);
   await this.createEmptyPagesByPaths(ancestorPaths);
 
 
   // find ancestors
   // find ancestors
-  const builder = new PageQueryBuilder(this.find({}, { _id: 1, path: 1 }), true);
+  const builder = new PageQueryBuilder(this.find(), true);
   const ancestors = await builder
   const ancestors = await builder
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToSortPagesByDescPath()
     .addConditionToSortPagesByDescPath()
     .query
     .query
-    .lean()
     .exec();
     .exec();
 
 
-  const ancestorsMap = new Map(); // Map<path, _id>
-  ancestors.forEach(page => ancestorsMap.set(page.path, page._id));
+  const ancestorsMap = new Map(); // Map<path, page>
+  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
 
 
   // bulkWrite to update ancestors
   // bulkWrite to update ancestors
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
   const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
@@ -217,8 +249,9 @@ schema.statics.getParentIdAndFillAncestors = async function(path: string): Promi
   });
   });
   await this.bulkWrite(operations);
   await this.bulkWrite(operations);
 
 
-  const parentId = ancestorsMap.get(parentPath);
-  return parentId;
+  const createdParent = ancestorsMap.get(parentPath);
+
+  return createdParent;
 };
 };
 
 
 // Utility function to add viewer condition to PageQueryBuilder instance
 // Utility function to add viewer condition to PageQueryBuilder instance
@@ -299,8 +332,8 @@ schema.statics.findChildrenByParentPathOrIdAndViewer = async function(parentPath
   let queryBuilder: PageQueryBuilder;
   let queryBuilder: PageQueryBuilder;
   if (hasSlash(parentPathOrId)) {
   if (hasSlash(parentPathOrId)) {
     const path = parentPathOrId;
     const path = parentPathOrId;
-    const regexp = generateChildrenRE2(path);
-    queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp.source } }), true);
+    const regexp = generateChildrenRegExp(path);
+    queryBuilder = new PageQueryBuilder(this.find({ path: { $regex: regexp } }), true);
   }
   }
   else {
   else {
     const parentId = parentPathOrId;
     const parentId = parentPathOrId;
@@ -354,11 +387,282 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   return pathToChildren;
   return pathToChildren;
 };
 };
 
 
+/*
+ * Utils from obsolete-page.js
+ */
+async function pushRevision(pageData, newRevision, user) {
+  await newRevision.save();
+
+  pageData.revision = newRevision;
+  pageData.lastUpdateUser = user;
+  pageData.updatedAt = Date.now();
+
+  return pageData.save();
+}
+
+/**
+ * return aggregate condition to get following pages
+ * - page that has the same path as the provided path
+ * - pages that are descendants of the above page
+ * pages without parent will be ignored
+ */
+schema.statics.getAggrConditionForPageWithProvidedPathAndDescendants = function(path:string) {
+  let match;
+  if (isTopPage(path)) {
+    match = {
+      // https://regex101.com/r/Kip2rV/1
+      $match: { $or: [{ path: { $regex: '^/.*' }, parent: { $ne: null } }, { path: '/' }] },
+    };
+  }
+  else {
+    match = {
+      // https://regex101.com/r/mJvGrG/1
+      $match: { path: { $regex: `^${path}(/.*|$)` }, parent: { $ne: null } },
+    };
+  }
+  return [
+    match,
+    {
+      $project: {
+        path: 1,
+        parent: 1,
+        field_length: { $strLenCP: '$path' },
+      },
+    },
+    { $sort: { field_length: -1 } },
+    { $project: { field_length: 0 } },
+  ];
+};
+
+/**
+ * add/subtract descendantCount of pages with provided paths by increment.
+ * increment can be negative number
+ */
+schema.statics.incrementDescendantCountOfPaths = async function(paths:string[], increment: number):Promise<void> {
+  const pages = await this.aggregate([{ $match: { path: { $in: paths } } }]);
+  const operations = pages.map((page) => {
+    return {
+      updateOne: {
+        filter: { path: page.path },
+        update: { descendantCount: page.descendantCount + increment },
+      },
+    };
+  });
+  await this.bulkWrite(operations);
+};
+
+// update descendantCount of a page with provided id
+schema.statics.recountDescendantCountOfSelfAndDescendants = async function(id:mongoose.Types.ObjectId):Promise<void> {
+  const res = await this.aggregate(
+    [
+      {
+        $match: {
+          parent: id,
+        },
+      },
+      {
+        $project: {
+          path: 1,
+          parent: 1,
+          descendantCount: 1,
+        },
+      },
+      {
+        $group: {
+          _id: '$parent',
+          sumOfDescendantCount: {
+            $sum: '$descendantCount',
+          },
+          sumOfDocsCount: {
+            $sum: 1,
+          },
+        },
+      },
+      {
+        $set: {
+          descendantCount: {
+            $sum: ['$sumOfDescendantCount', '$sumOfDocsCount'],
+          },
+        },
+      },
+    ],
+  );
+
+  const query = { descendantCount: res.length === 0 ? 0 : res[0].descendantCount };
+  await this.findByIdAndUpdate(id, query);
+};
 
 
 /*
 /*
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  * Merge obsolete page model methods and define new methods which depend on crowi instance
  */
  */
 export default (crowi: Crowi): any => {
 export default (crowi: Crowi): any => {
+  let pageEvent;
+  if (crowi != null) {
+    pageEvent = crowi.event('page');
+  }
+
+  schema.statics.create = async function(path, body, user, options = {}) {
+    if (crowi.pageGrantService == null || crowi.configManager == null) {
+      throw Error('Crowi is not setup');
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    // v4 compatible process
+    if (!isV5Compatible) {
+      return this.createV4(path, body, user, options);
+    }
+
+    const Page = this;
+    const Revision = crowi.model('Revision');
+    const {
+      format = 'markdown', redirectTo, grantUserGroupId,
+    } = options;
+    let grant = options.grant;
+
+    // sanitize path
+    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
+    // throw if exists
+    const isExist = (await this.count({ path, isEmpty: false })) > 0; // not validate empty page
+    if (isExist) {
+      throw new Error('Cannot create new page to existed path');
+    }
+    // force public
+    if (isTopPage(path)) {
+      grant = GRANT_PUBLIC;
+    }
+
+    // find an existing empty page
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (grant !== GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        // It must check descendants as well if emptyTarget is not null
+        const shouldCheckDescendants = emptyPage != null;
+        const newGrantedUserIds = grant === GRANT_OWNER ? [user._id] as IObjectId[] : undefined;
+
+        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    /*
+     * update empty page if exists, if not, create a new page
+     */
+    let page;
+    if (emptyPage != null) {
+      page = emptyPage;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    let parentId: IObjectId | string | null = null;
+    const parent = await Page.getParentAndFillAncestors(path);
+    if (!isTopPage(path)) {
+      parentId = parent._id;
+    }
+
+    page.path = path;
+    page.creator = user;
+    page.lastUpdateUser = user;
+    page.redirectTo = redirectTo;
+    page.status = STATUS_PUBLISHED;
+
+    // set parent to null when GRANT_RESTRICTED
+    if (grant === GRANT_RESTRICTED) {
+      page.parent = null;
+    }
+    else {
+      page.parent = parentId;
+    }
+
+    page.applyScope(user, grant, grantUserGroupId);
+
+    let savedPage = await page.save();
+
+    /*
+     * After save
+     */
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const revision = await pushRevision(savedPage, newRevision, user);
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
+
+    pageEvent.emit('create', savedPage, user);
+
+    return savedPage;
+  };
+
+  schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+    if (crowi.configManager == null || crowi.pageGrantService == null) {
+      throw Error('Crowi is not set up');
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      // v4 compatible process
+      return this.updatePageV4(pageData, body, previousBody, user, options);
+    }
+
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const grantedUserIds = pageData.grantedUserIds || [user._id];
+
+    const newPageData = pageData;
+
+    if (grant === GRANT_RESTRICTED) {
+      newPageData.parent = null;
+    }
+    else {
+      /*
+       * UserGroup & Owner validation
+       */
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = true;
+
+        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(pageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${pageData.path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    newPageData.applyScope(user, grant, grantUserGroupId);
+
+    // update existing page
+    let savedPage = await newPageData.save();
+    const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+    const revision = await pushRevision(savedPage, newRevision, user);
+    savedPage = await this.findByPath(revision.path);
+    await savedPage.populateDataToShowRevision();
+
+    if (isSyncRevisionToHackmd) {
+      savedPage = await this.syncRevisionToHackmd(savedPage);
+    }
+
+    pageEvent.emit('update', savedPage, user);
+
+    return savedPage;
+  };
+
   // add old page schema methods
   // add old page schema methods
   const pageSchema = getPageSchema(crowi);
   const pageSchema = getPageSchema(crowi);
   schema.methods = { ...pageSchema.methods, ...schema.methods };
   schema.methods = { ...pageSchema.methods, ...schema.methods };

+ 43 - 2
packages/app/src/server/models/user-group-relation.js

@@ -240,6 +240,18 @@ class UserGroupRelation {
     });
     });
   }
   }
 
 
+  static async createRelations(userGroupIds, user) {
+    const documentsToInsertMany = userGroupIds.map((groupId) => {
+      return {
+        relatedGroup: groupId,
+        relatedUser: user._id,
+        createdAt: new Date(),
+      };
+    });
+
+    return this.insertMany(documentsToInsertMany);
+  }
+
   /**
   /**
    * remove all relation for UserGroup
    * remove all relation for UserGroup
    *
    *
@@ -248,8 +260,12 @@ class UserGroupRelation {
    * @returns {Promise<any>}
    * @returns {Promise<any>}
    * @memberof UserGroupRelation
    * @memberof UserGroupRelation
    */
    */
-  static removeAllByUserGroup(userGroup) {
-    return this.deleteMany({ relatedGroup: userGroup });
+  static removeAllByUserGroups(groupsToDelete) {
+    if (!Array.isArray(groupsToDelete)) {
+      throw Error('groupsToDelete must be an array.');
+    }
+
+    return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
   }
   }
 
 
   /**
   /**
@@ -272,6 +288,31 @@ class UserGroupRelation {
       });
       });
   }
   }
 
 
+  static async findUserIdsByGroupId(groupId) {
+    const relations = await this.find({ relatedGroup: groupId }, { _id: 0, relatedUser: 1 }).lean().exec(); // .lean() to get not ObjectId but string
+
+    return relations.map(relation => relation.relatedUser);
+  }
+
+  static async createByGroupIdsAndUserIds(groupIds, userIds) {
+    const insertOperations = [];
+
+    groupIds.forEach((groupId) => {
+      userIds.forEach((userId) => {
+        insertOperations.push({
+          insertOne: {
+            document: {
+              relatedGroup: groupId,
+              relatedUser: userId,
+            },
+          },
+        });
+      });
+    });
+
+    await this.bulkWrite(insertOperations);
+  }
+
 }
 }
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {

+ 0 - 133
packages/app/src/server/models/user-group.js

@@ -1,133 +0,0 @@
-const debug = require('debug')('growi:models:userGroup');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  userGroupId: String,
-  name: { type: String, required: true, unique: true },
-  createdAt: { type: Date, default: Date.now },
-});
-schema.plugin(mongoosePaginate);
-
-class UserGroup {
-
-  /**
-   * public fields for UserGroup model
-   *
-   * @readonly
-   * @static
-   * @memberof UserGroup
-   */
-  static get USER_GROUP_PUBLIC_FIELDS() {
-    return '_id name createdAt';
-  }
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof UserGroup
-   */
-  static get PAGE_ITEMS() {
-    return 10;
-  }
-
-  /*
-   * model static methods
-   */
-
-  // グループ画像パスの生成
-  static createUserGroupPictureFilePath(userGroup, name) {
-    const ext = `.${name.match(/(.*)(?:\.([^.]+$))/)[2]}`;
-
-    return `userGroup/${userGroup._id}${ext}`;
-  }
-
-  // すべてのグループを取得(オプション指定可)
-  static findAllGroups(option) {
-    return this.find().exec();
-  }
-
-  /**
-   * find all entities with pagination
-   *
-   * @see https://github.com/edwardhotchkiss/mongoose-paginate
-   *
-   * @static
-   * @param {any} opts mongoose-paginate options object
-   * @returns {Promise<any>} mongoose-paginate result object
-   * @memberof UserGroup
-   */
-  static findUserGroupsWithPagination(opts) {
-    const query = {};
-    const options = Object.assign({}, opts);
-    if (options.page == null) {
-      options.page = 1;
-    }
-    if (options.limit == null) {
-      options.limit = UserGroup.PAGE_ITEMS;
-    }
-
-    return this.paginate(query, options)
-      .catch((err) => {
-        debug('Error on pagination:', err);
-      });
-  }
-
-  // 登録可能グループ名確認
-  static isRegisterableName(name) {
-    const query = { name };
-
-    return this.findOne(query)
-      .then((userGroupData) => {
-        return (userGroupData == null);
-      });
-  }
-
-  // グループの完全削除
-  static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId, user) {
-    const UserGroupRelation = mongoose.model('UserGroupRelation');
-
-    const groupToDelete = await this.findById(deleteGroupId);
-    if (groupToDelete == null) {
-      throw new Error('UserGroup data is not exists. id:', deleteGroupId);
-    }
-    const deletedGroup = await groupToDelete.remove();
-
-    await Promise.all([
-      UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      UserGroup.crowi.pageService.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId, user),
-    ]);
-
-    return deletedGroup;
-  }
-
-  static countUserGroups() {
-    return this.estimatedDocumentCount();
-  }
-
-  // グループ生成(名前が要る)
-  static createGroupByName(name) {
-    return this.create({ name });
-  }
-
-  // グループ名の更新
-  async updateName(name) {
-    // 名前を設定して更新
-    this.name = name;
-    await this.save();
-  }
-
-}
-
-
-module.exports = function(crowi) {
-  UserGroup.crowi = crowi;
-  schema.loadClass(UserGroup);
-  return mongoose.model('UserGroup', schema);
-};

+ 134 - 0
packages/app/src/server/models/user-group.ts

@@ -0,0 +1,134 @@
+import mongoose, {
+  Types, Schema, Model, Document,
+} from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import { getOrCreateModel } from '@growi/core';
+
+import { IUserGroup } from '~/interfaces/user';
+
+
+export interface UserGroupDocument extends IUserGroup, Document {}
+
+export interface UserGroupModel extends Model<UserGroupDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 10,
+}
+
+/*
+ * define schema
+ */
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new Schema<UserGroupDocument, UserGroupModel>({
+  name: { type: String, required: true, unique: true },
+  createdAt: { type: Date, default: new Date() },
+  parent: { type: ObjectId, ref: 'UserGroup', index: true },
+  description: { type: String, default: '' },
+});
+schema.plugin(mongoosePaginate);
+
+const PAGE_ITEMS = 10;
+
+schema.statics.findUserGroupsWithPagination = function(opts) {
+  const query = { parent: null };
+  const options = Object.assign({}, opts);
+  if (options.page == null) {
+    options.page = 1;
+  }
+  if (options.limit == null) {
+    options.limit = PAGE_ITEMS;
+  }
+
+  return this.paginate(query, options)
+    .catch((err) => {
+      // debug('Error on pagination:', err); TODO: add logger
+    });
+};
+
+
+schema.statics.findChildUserGroupsByParentIds = async function(parentIds, includeGrandChildren = false) {
+  if (!Array.isArray(parentIds)) {
+    throw Error('parentIds must be an array.');
+  }
+
+  const childUserGroups = await this.find({ parent: { $in: parentIds } });
+
+  let grandChildUserGroups: UserGroupDocument[] | null = null;
+  if (includeGrandChildren) {
+    const childUserGroupIds = childUserGroups.map(group => group._id);
+    grandChildUserGroups = await this.find({ parent: { $in: childUserGroupIds } });
+  }
+
+  return {
+    childUserGroups,
+    grandChildUserGroups,
+  };
+};
+
+schema.statics.countUserGroups = function() {
+  return this.estimatedDocumentCount();
+};
+
+schema.statics.createGroup = async function(name, description, parentId) {
+  // create without parent
+  if (parentId == null) {
+    return this.create({ name, description });
+  }
+
+  // create with parent
+  const parent = await this.findOne({ _id: parentId });
+  if (parent == null) {
+    throw Error('Parent does not exist.');
+  }
+  return this.create({ name, description, parent });
+};
+
+/**
+ * Find all ancestor groups starting from the UserGroup of the initial "group".
+ * Set "ancestors" as "[]" if the initial group is unnecessary as result.
+ * @param groups UserGroupDocument
+ * @param ancestors UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
+schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancestors = [group]) {
+  if (group == null) {
+    return ancestors;
+  }
+
+  const parent = await this.findOne({ _id: group.parent });
+  if (parent == null) {
+    return ancestors;
+  }
+
+  ancestors.push(parent);
+
+  return this.findGroupsWithAncestorsRecursively(parent, ancestors);
+};
+
+/**
+ * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
+ * Set "descendants" as "[]" if the initial groups are unnecessary as result.
+ * @param groups UserGroupDocument[] including at least one UserGroup
+ * @param descendants UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
+schema.statics.findGroupsWithDescendantsRecursively = async function(groups, descendants = groups) {
+  const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
+
+  if (nextGroups.length === 0) {
+    return descendants;
+  }
+
+  return this.findGroupsWithDescendantsRecursively(nextGroups, descendants.concat(nextGroups));
+};
+
+schema.statics.findGroupsWithDescendantsById = async function(groupId) {
+  const root = await this.findOne({ _id: groupId });
+  if (root == null) {
+    throw Error('The root user group does not exist');
+  }
+  return this.findGroupsWithDescendantsRecursively([root]);
+};
+
+export default getOrCreateModel<UserGroupDocument, UserGroupModel>('UserGroup', schema);

+ 5 - 1
packages/app/src/server/models/vo/s2c-message.js

@@ -10,15 +10,19 @@ class S2cMessagePageUpdated {
     const serializedPage = serializePageSecurely(page);
     const serializedPage = serializePageSecurely(page);
 
 
     const {
     const {
-      _id, revision, revisionHackmdSynced, hasDraftOnHackmd,
+      _id, revision, updatedAt, revisionHackmdSynced, hasDraftOnHackmd,
     } = serializedPage;
     } = serializedPage;
 
 
     this.pageId = _id;
     this.pageId = _id;
     this.revisionId = revision;
     this.revisionId = revision;
+    this.revisionBody = page.revision.body;
+    this.revisionUpdateAt = updatedAt;
     this.revisionIdHackmdSynced = revisionHackmdSynced;
     this.revisionIdHackmdSynced = revisionHackmdSynced;
     this.hasDraftOnHackmd = hasDraftOnHackmd;
     this.hasDraftOnHackmd = hasDraftOnHackmd;
 
 
     if (user != null) {
     if (user != null) {
+      this.remoteLastUpdateUser = user;
+      // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used
       this.lastUpdateUsername = user.name;
       this.lastUpdateUsername = user.name;
     }
     }
   }
   }

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

@@ -1,4 +1,5 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import UserGroup from '~/server/models/user-group';
 
 
 const logger = loggerFactory('growi:routes:admin');
 const logger = loggerFactory('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -7,7 +8,6 @@ const debug = require('debug')('growi:routes:admin');
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
 
 
   const models = crowi.models;
   const models = crowi.models;
-  const UserGroup = models.UserGroup;
   const UserGroupRelation = models.UserGroupRelation;
   const UserGroupRelation = models.UserGroupRelation;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
 
 

+ 8 - 2
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -113,10 +113,16 @@ module.exports = (crowi) => {
     const responsesParams = {};
     const responsesParams = {};
 
 
     try {
     try {
-      responsesParams.sumOfBookmarks = await Bookmark.countByPageId(pageId);
+      const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
+      let users = [];
+      if (bookmarks.length > 0) {
+        users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+      }
+      responsesParams.sumOfBookmarks = bookmarks.length;
+      responsesParams.bookmarkedUsers = users;
     }
     }
     catch (err) {
     catch (err) {
-      logger.error('get-bookmark-count-failed', err);
+      logger.error('get-bookmark-document-failed', err);
       return res.apiv3Err(err, 500);
       return res.apiv3Err(err, 500);
     }
     }
 
 

+ 2 - 1
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -98,7 +98,8 @@ export default (crowi: Crowi): Router => {
     const { pageIds } = req.query;
     const { pageIds } = req.query;
 
 
     try {
     try {
-      const shortBodiesMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds, req.user);
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const shortBodiesMap = await crowi.pageService!.shortBodiesMapByPageIds(pageIds as string[], req.user);
       return res.apiv3({ shortBodiesMap });
       return res.apiv3({ shortBodiesMap });
     }
     }
     catch (err) {
     catch (err) {

+ 7 - 5
packages/app/src/server/routes/apiv3/pages.js

@@ -178,7 +178,6 @@ module.exports = (crowi) => {
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
-      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
     ],
 
 
     duplicatePage: [
     duplicatePage: [
@@ -200,7 +199,9 @@ module.exports = (crowi) => {
 
 
   async function saveTagsAction({ createdPage, pageTags }) {
   async function saveTagsAction({ createdPage, pageTags }) {
     if (pageTags != null) {
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(createdPage.id, pageTags);
       await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
       return PageTagRelation.listTagNamesByPage(createdPage.id);
       return PageTagRelation.listTagNamesByPage(createdPage.id);
     }
     }
 
 
@@ -282,6 +283,7 @@ module.exports = (crowi) => {
       });
       });
     }
     }
     catch (err) {
     catch (err) {
+      logger.error('Error occurred while creating a page.', err);
       return res.apiv3Err(err);
       return res.apiv3Err(err);
     }
     }
 
 
@@ -451,7 +453,7 @@ module.exports = (crowi) => {
    *            description: page path is already existed
    *            description: page path is already existed
    */
    */
   router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
   router.put('/rename', accessTokenParser, loginRequiredStrictly, csrf, validator.renamePage, apiV3FormValidator, async(req, res) => {
-    const { pageId, isRecursively, revisionId } = req.body;
+    const { pageId, revisionId } = req.body;
 
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
 
 
@@ -461,7 +463,7 @@ module.exports = (crowi) => {
     };
     };
 
 
     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);
     }
     }
 
 
     // check whether path starts slash
     // check whether path starts slash
@@ -485,7 +487,7 @@ module.exports = (crowi) => {
       if (!page.isUpdatable(revisionId)) {
       if (!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);
       }
       }
-      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
+      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -522,7 +524,7 @@ module.exports = (crowi) => {
     const options = {};
     const options = {};
 
 
     try {
     try {
-      const pages = await crowi.pageService.deleteCompletelyDescendantsWithStream({ path: '/trash' }, req.user, options);
+      const pages = await crowi.pageService.emptyTrashPage(req.user, options);
       return res.apiv3({ pages });
       return res.apiv3({ pages });
     }
     }
     catch (err) {
     catch (err) {

+ 19 - 61
packages/app/src/server/routes/apiv3/user-group-relation.js

@@ -3,12 +3,15 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
+const { query } = require('express-validator');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
 const { serializeUserGroupRelationSecurely } = require('../../models/serializers/user-group-relation-serializer');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
+const validator = {};
+
 /**
 /**
  * @swagger
  * @swagger
  *  tags:
  *  tags:
@@ -21,6 +24,11 @@ module.exports = (crowi) => {
 
 
   const { UserGroupRelation } = crowi.models;
   const { UserGroupRelation } = crowi.models;
 
 
+  validator.list = [
+    query('groupIds', 'groupIds is required and must be an array').isArray(),
+    query('childGroupIds', 'childGroupIds must be an array').optional().isArray(),
+  ];
+
   /**
   /**
    * @swagger
    * @swagger
    *  paths:
    *  paths:
@@ -41,13 +49,21 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: contains arrays user objects related
    *                      description: contains arrays user objects related
    */
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', loginRequiredStrictly, adminRequired, validator.list, async(req, res) => {
+    const { query } = req;
+
     try {
     try {
-      const relations = await UserGroupRelation.find().populate('relatedUser');
+      const relations = await UserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+
+      let relationsOfChildGroups = null;
+      if (Array.isArray(query.childGroupIds)) {
+        const _relationsOfChildGroups = await UserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
+        relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
+      }
 
 
       const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
       const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
 
 
-      return res.apiv3({ userGroupRelations: serialized });
+      return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
     }
     }
     catch (err) {
     catch (err) {
       const msg = 'Error occurred in fetching user group relations';
       const msg = 'Error occurred in fetching user group relations';
@@ -58,61 +74,3 @@ module.exports = (crowi) => {
 
 
   return router;
   return router;
 };
 };
-
-// const MAX_PAGE_LIST = 50;
-
-// function createPager(total, limit, page, pagesCount, maxPageList) {
-//   const pager = {
-//     page,
-//     pagesCount,
-//     pages: [],
-//     total,
-//     previous: null,
-//     previousDots: false,
-//     next: null,
-//     nextDots: false,
-//   };
-
-//   if (page > 1) {
-//     pager.previous = page - 1;
-//   }
-
-//   if (page < pagesCount) {
-//     pager.next = page + 1;
-//   }
-
-//   let pagerMin = Math.max(1, Math.ceil(page - maxPageList / 2));
-//   let pagerMax = Math.min(pagesCount, Math.floor(page + maxPageList / 2));
-//   if (pagerMin === 1) {
-//     if (MAX_PAGE_LIST < pagesCount) {
-//       pagerMax = MAX_PAGE_LIST;
-//     }
-//     else {
-//       pagerMax = pagesCount;
-//     }
-//   }
-//   if (pagerMax === pagesCount) {
-//     if ((pagerMax - MAX_PAGE_LIST) < 1) {
-//       pagerMin = 1;
-//     }
-//     else {
-//       pagerMin = pagerMax - MAX_PAGE_LIST;
-//     }
-//   }
-
-//   pager.previousDots = null;
-//   if (pagerMin > 1) {
-//     pager.previousDots = true;
-//   }
-
-//   pager.nextDots = null;
-//   if (pagerMax < pagesCount) {
-//     pager.nextDots = true;
-//   }
-
-//   for (let i = pagerMin; i <= pagerMax; i++) {
-//     pager.pages.push(i);
-//   }
-
-//   return pager;
-// }

+ 57 - 36
packages/app/src/server/routes/apiv3/user-group.js

@@ -1,4 +1,6 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup from '~/server/models/user-group';
 
 
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 
@@ -34,12 +36,16 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
 
   const {
   const {
-    UserGroup,
     UserGroupRelation,
     UserGroupRelation,
     User,
     User,
     Page,
     Page,
   } = crowi.models;
   } = crowi.models;
 
 
+  validator.listChildren = [
+    query('parentIds', 'parentIds must be an array').optional().isArray(),
+    query('includeGrandChildren', 'parentIds must be boolean').optional().isBoolean(),
+  ];
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -61,10 +67,10 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: a result of `UserGroup.find`
    *                      description: a result of `UserGroup.find`
    */
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => { // TODO 85062: userGroups with no parent
     const { query } = req;
     const { query } = req;
 
 
-    // TODO: filter with querystring
+    // TODO 85062: improve sort
     try {
     try {
       const page = query.page != null ? parseInt(query.page) : undefined;
       const page = query.page != null ? parseInt(query.page) : undefined;
       const limit = query.limit != null ? parseInt(query.limit) : undefined;
       const limit = query.limit != null ? parseInt(query.limit) : undefined;
@@ -84,8 +90,28 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  // TODO 85062: improve sort
+  router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
+    try {
+      const { parentIds, includeGrandChildren = false } = req.query;
+
+      const userGroupsResult = await UserGroup.findChildUserGroupsByParentIds(parentIds, includeGrandChildren);
+      return res.apiv3({
+        childUserGroups: userGroupsResult.childUserGroups,
+        grandChildUserGroups: userGroupsResult.grandChildUserGroups,
+      });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching child user group list';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'child-user-group-list-fetch-failed'));
+    }
+  });
+
   validator.create = [
   validator.create = [
     body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
     body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+    body('description', 'Description must be a string').optional().isString(),
+    body('parentId', 'ParentId must be a string').optional().isString(),
   ];
   ];
 
 
   /**
   /**
@@ -119,11 +145,12 @@ module.exports = (crowi) => {
    *                      description: A result of `UserGroup.createGroupByName`
    *                      description: A result of `UserGroup.createGroupByName`
    */
    */
   router.post('/', loginRequiredStrictly, adminRequired, csrf, validator.create, apiV3FormValidator, async(req, res) => {
   router.post('/', loginRequiredStrictly, adminRequired, csrf, validator.create, apiV3FormValidator, async(req, res) => {
-    const { name } = req.body;
+    const { name, description = '', parentId } = req.body;
 
 
     try {
     try {
       const userGroupName = crowi.xss.process(name);
       const userGroupName = crowi.xss.process(name);
-      const userGroup = await UserGroup.createGroupByName(userGroupName);
+      const userGroupDescription = crowi.xss.process(description);
+      const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
 
 
       return res.apiv3({ userGroup }, 201);
       return res.apiv3({ userGroup }, 201);
     }
     }
@@ -183,23 +210,22 @@ module.exports = (crowi) => {
     const { actionName, transferToUserGroupId } = req.query;
     const { actionName, transferToUserGroupId } = req.query;
 
 
     try {
     try {
-      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId, req.user);
+      const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user);
 
 
-      return res.apiv3({ userGroup });
+      return res.apiv3({ userGroups });
     }
     }
     catch (err) {
     catch (err) {
-      const msg = 'Error occurred in deleting a user group';
+      const msg = 'Error occurred while deleting user groups';
       logger.error(msg, err);
       logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-delete-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-delete-failed'));
     }
     }
   });
   });
 
 
-  // return one group with the id
-  // router.get('/:id', async(req, res) => {
-  // });
-
   validator.update = [
   validator.update = [
     body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
     body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+    body('description', 'Group description must be a string').optional().isString(),
+    body('parentId', 'parentId must be a string').optional().isString(),
+    body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
   ];
   ];
 
 
   /**
   /**
@@ -232,21 +258,12 @@ module.exports = (crowi) => {
    */
    */
   router.put('/:id', loginRequiredStrictly, adminRequired, csrf, validator.update, apiV3FormValidator, async(req, res) => {
   router.put('/:id', loginRequiredStrictly, adminRequired, csrf, validator.update, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
-    const { name } = req.body;
+    const {
+      name, description, parentId, forceUpdateParents = false,
+    } = req.body;
 
 
     try {
     try {
-      const userGroup = await UserGroup.findById(id);
-      if (userGroup == null) {
-        throw new Error('The group does not exist');
-      }
-
-      // check if the new group name is available
-      const isRegisterableName = await UserGroup.isRegisterableName(name);
-      if (!isRegisterableName) {
-        throw new Error('The group name is already taken');
-      }
-
-      await userGroup.updateName(name);
+      const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
 
 
       res.apiv3({ userGroup });
       res.apiv3({ userGroup });
     }
     }
@@ -419,18 +436,19 @@ module.exports = (crowi) => {
         User.findUserByUsername(username),
         User.findUserByUsername(username),
       ]);
       ]);
 
 
+      const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+      const userGroupIds = userGroups.map(g => g._id);
+
       // check for duplicate users in groups
       // check for duplicate users in groups
-      const isRelatedUserForGroup = await UserGroupRelation.isRelatedUserForGroup(userGroup, user);
+      const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
+      const existingGroupIds = existingRelations.map(r => r.relatedGroup);
 
 
-      if (isRelatedUserForGroup) {
-        logger.warn('The user is already joined');
-        return res.apiv3();
-      }
+      const groupIdsOfRelationToCreate = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
 
 
-      const userGroupRelation = await UserGroupRelation.createRelation(userGroup, user);
+      const insertedRelations = await UserGroupRelation.createRelations(groupIdsOfRelationToCreate, user);
       const serializedUser = serializeUserSecurely(user);
       const serializedUser = serializeUserSecurely(user);
 
 
-      return res.apiv3({ user: serializedUser, userGroup, userGroupRelation });
+      return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
     }
     }
     catch (err) {
     catch (err) {
       const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
       const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
@@ -488,13 +506,16 @@ module.exports = (crowi) => {
         User.findUserByUsername(username),
         User.findUserByUsername(username),
       ]);
       ]);
 
 
-      const userGroupRelation = await UserGroupRelation.findOneAndDelete({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
+      const groupsOfRelationsToDelete = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
+      const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map(g => g._id);
+
+      const deleteManyRes = await UserGroupRelation.deleteMany({ relatedUser: user._id, relatedGroup: { $in: relatedGroupIdsToDelete } });
       const serializedUser = serializeUserSecurely(user);
       const serializedUser = serializeUserSecurely(user);
 
 
-      return res.apiv3({ user: serializedUser, userGroup, userGroupRelation });
+      return res.apiv3({ user: serializedUser, deletedGroupsCount: deleteManyRes.deletedCount });
     }
     }
     catch (err) {
     catch (err) {
-      const msg = `Error occurred in removing the user "${username}" from group "${id}"`;
+      const msg = 'Error occurred while removing the user from groups.';
       logger.error(msg, err);
       logger.error(msg, err);
       return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
       return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
     }
     }

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

@@ -905,9 +905,17 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     // check revision
     // check revision
+    const Revision = crowi.model('Revision');
     let page = await Page.findByIdAndViewer(pageId, req.user);
     let page = await Page.findByIdAndViewer(pageId, req.user);
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
     if (page != null && revisionId != null && !page.isUpdatable(revisionId)) {
-      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
+      const latestRevision = await Revision.findById(page.revision).populate('author');
+      const returnLatestRevision = {
+        revisionId: latestRevision._id.toString(),
+        revisionBody: xss.process(latestRevision.body),
+        createdAt: latestRevision.createdAt,
+        user: serializeUserSecurely(latestRevision.author),
+      };
+      return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'conflict', returnLatestRevision));
     }
     }
 
 
     const options = { isSyncRevisionToHackmd };
     const options = { isSyncRevisionToHackmd };
@@ -916,7 +924,6 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
       options.grantUserGroupId = grantUserGroupId;
     }
     }
 
 
-    const Revision = crowi.model('Revision');
     const previousRevision = await Revision.findById(revisionId);
     const previousRevision = await Revision.findById(revisionId);
     try {
     try {
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
       page = await Page.updatePage(page, pageBody, previousRevision.body, req.user, options);
@@ -928,8 +935,10 @@ module.exports = function(crowi, app) {
 
 
     let savedTags;
     let savedTags;
     if (pageTags != null) {
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(pageId, pageTags);
       await PageTagRelation.updatePageTags(pageId, pageTags);
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
+      tagEvent.emit('update', page, savedTags);
     }
     }
 
 
     const result = {
     const result = {

+ 16 - 22
packages/app/src/server/routes/tag.js

@@ -136,15 +136,27 @@ module.exports = function(crowi, app) {
    */
    */
   api.update = async function(req, res) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const Page = crowi.model('Page');
+    const User = crowi.model('User');
     const PageTagRelation = crowi.model('PageTagRelation');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const pageId = req.body.pageId;
     const tags = req.body.tags;
     const tags = req.body.tags;
+    const userId = req.user._id;
+    const revisionId = req.body.revisionId;
 
 
     const result = {};
     const result = {};
     try {
     try {
       // TODO GC-1921 consider permission
       // TODO GC-1921 consider permission
       const page = await Page.findById(pageId);
       const page = await Page.findById(pageId);
+      const user = await User.findById(userId);
+
+      if (!await Page.isAccessiblePageByViewer(page._id, user)) {
+        return res.json(ApiResponse.error("You don't have permission to update this page."));
+      }
+
+      const previousRevision = await Revision.findById(revisionId);
+      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
 
@@ -203,32 +215,14 @@ module.exports = function(crowi, app) {
   api.list = async function(req, res) {
   api.list = async function(req, res) {
     const limit = +req.query.limit || 50;
     const limit = +req.query.limit || 50;
     const offset = +req.query.offset || 0;
     const offset = +req.query.offset || 0;
-    const sortOpt = { count: -1 };
+    const sortOpt = { count: -1, _id: -1 };
     const queryOptions = { offset, limit, sortOpt };
     const queryOptions = { offset, limit, sortOpt };
-    const result = {};
 
 
     try {
     try {
-      // get tag list contains id and count properties
-      const listData = await PageTagRelation.createTagListWithCount(queryOptions);
-      const ids = listData.list.map((obj) => { return obj._id });
-
-      // get tag documents for add name data to the list
-      const tags = await Tag.find({ _id: { $in: ids } });
-
-      // add name property
-      result.data = listData.list.map((elm) => {
-        const data = {};
-        const tag = tags.find((tag) => { return (tag.id === elm._id.toString()) });
-
-        data._id = elm._id;
-        data.name = tag.name;
-        data.count = elm.count; // the number of related pages
-        return data;
-      });
-
-      result.totalCount = listData.totalCount;
+      // get tag list contains id name and count properties
+      const tagsWithCount = await PageTagRelation.createTagListWithCount(queryOptions);
 
 
-      return res.json(ApiResponse.success(result));
+      return res.json(ApiResponse.success(tagsWithCount));
     }
     }
     catch (err) {
     catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));

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

@@ -403,6 +403,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.NUMBER,
     type:    ValueType.NUMBER,
     default: 3,
     default: 3,
   },
   },
+  OIDC_CLIENT_CLOCK_TOLERANCE: {
+    ns: 'crowi',
+    key: 'security:passport-oidc:oidcClientClockTolerance',
+    type: ValueType.NUMBER,
+    default: 10,
+  },
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
   S3_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'aws:referenceFileWithRelayMode',
     key:     'aws:referenceFileWithRelayMode',

+ 333 - 0
packages/app/src/server/service/page-grant.ts

@@ -0,0 +1,333 @@
+import mongoose from 'mongoose';
+import { pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
+
+import UserGroup from '~/server/models/user-group';
+import { PageModel } from '~/server/models/page';
+import { PageQueryBuilder } from '../models/obsolete-page';
+import { isIncludesObjectId, removeDuplicates, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+
+const { addTrailingSlash } = pathUtils;
+const { isTopPage } = pagePathUtils;
+
+type ObjectId = mongoose.Types.ObjectId;
+
+type ComparableTarget = {
+  grant: number,
+  grantedUserIds?: ObjectId[],
+  grantedGroupId: ObjectId,
+  applicableUserIds?: ObjectId[],
+  applicableGroupIds?: ObjectId[],
+};
+
+type ComparableAncestor = {
+  grant: number,
+  grantedUserIds: ObjectId[],
+  applicableUserIds?: ObjectId[],
+  applicableGroupIds?: ObjectId[],
+};
+
+type ComparableDescendants = {
+  isPublicExist: boolean,
+  grantedUserIds: ObjectId[],
+  grantedGroupIds: ObjectId[],
+};
+
+class PageGrantService {
+
+  crowi!: any;
+
+  constructor(crowi: any) {
+    this.crowi = crowi;
+  }
+
+  private validateComparableTarget(comparable: ComparableTarget) {
+    const Page = mongoose.model('Page') as PageModel;
+
+    const { grant, grantedUserIds, grantedGroupId } = comparable;
+
+    if (grant === Page.GRANT_OWNER && (grantedUserIds == null || grantedUserIds.length !== 1)) {
+      throw Error('grantedUserIds must not be null and must have 1 length');
+    }
+    if (grant === Page.GRANT_USER_GROUP && grantedGroupId == null) {
+      throw Error('grantedGroupId is not specified');
+    }
+  }
+
+  /**
+   * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * @returns boolean
+   */
+  private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+    this.validateComparableTarget(target);
+
+    const Page = mongoose.model('Page') as PageModel;
+
+    /*
+     * ancestor side
+     */
+    // GRANT_PUBLIC
+    if (ancestor.grant === Page.GRANT_PUBLIC) { // any page can exist under public page
+      // do nothing
+    }
+    // GRANT_OWNER
+    else if (ancestor.grant === Page.GRANT_OWNER) {
+      if (target.grantedUserIds?.length !== 1) {
+        throw Error('grantedUserIds must have one user');
+      }
+
+      if (target.grant !== Page.GRANT_OWNER) { // only GRANT_OWNER page can exist under GRANT_OWNER page
+        return false;
+      }
+
+      if (!ancestor.grantedUserIds[0].equals(target.grantedUserIds[0])) { // the grantedUser must be the same as parent's under the GRANT_OWNER page
+        return false;
+      }
+    }
+    // GRANT_USER_GROUP
+    else if (ancestor.grant === Page.GRANT_USER_GROUP) {
+      if (ancestor.applicableGroupIds == null || ancestor.applicableUserIds == null) {
+        throw Error('applicableGroupIds and applicableUserIds are not specified');
+      }
+
+      if (target.grant === Page.GRANT_PUBLIC) { // public page must not exist under GRANT_USER_GROUP page
+        return false;
+      }
+
+      if (target.grant === Page.GRANT_OWNER) {
+        if (target.grantedUserIds?.length !== 1) {
+          throw Error('grantedUserIds must have one user');
+        }
+
+        if (!isIncludesObjectId(ancestor.applicableUserIds, target.grantedUserIds[0])) { // GRANT_OWNER pages under GRAND_USER_GROUP page must be owned by the member of the grantedGroup of the GRAND_USER_GROUP page
+          return false;
+        }
+      }
+
+      if (target.grant === Page.GRANT_USER_GROUP) {
+        if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
+          return false;
+        }
+      }
+    }
+
+    if (descendants == null) {
+      return true;
+    }
+    /*
+     * descendant side
+     */
+
+    // GRANT_PUBLIC
+    if (target.grant === Page.GRANT_PUBLIC) { // any page can exist under public page
+      // do nothing
+    }
+    // GRANT_OWNER
+    else if (target.grant === Page.GRANT_OWNER) {
+      if (target.grantedUserIds?.length !== 1) {
+        throw Error('grantedUserIds must have one user');
+      }
+
+      if (descendants.isPublicExist) { // public page must not exist under GRANT_OWNER page
+        return false;
+      }
+
+      if (descendants.grantedGroupIds.length !== 0 || descendants.grantedUserIds.length > 1) { // groups or more than 2 grantedUsers must not be in descendants
+        return false;
+      }
+
+      if (descendants.grantedUserIds.length === 1 && !descendants.grantedUserIds[0].equals(target.grantedUserIds[0])) { // if Only me page exists, then all of them must be owned by the same user as the target page
+        return false;
+      }
+    }
+    // GRANT_USER_GROUP
+    else if (target.grant === Page.GRANT_USER_GROUP) {
+      if (target.applicableGroupIds == null || target.applicableUserIds == null) {
+        throw Error('applicableGroupIds and applicableUserIds must not be null');
+      }
+
+      if (descendants.isPublicExist) { // public page must not exist under GRANT_USER_GROUP page
+        return false;
+      }
+
+      const shouldNotExistGroupIds = excludeTestIdsFromTargetIds(descendants.grantedGroupIds, target.applicableGroupIds);
+      const shouldNotExistUserIds = excludeTestIdsFromTargetIds(descendants.grantedUserIds, target.applicableUserIds);
+      if (shouldNotExistGroupIds.length !== 0 || shouldNotExistUserIds.length !== 0) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * Prepare ComparableTarget
+   * @returns Promise<ComparableAncestor>
+   */
+  private async generateComparableTarget(
+      grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, includeApplicable: boolean,
+  ): Promise<ComparableTarget> {
+    if (includeApplicable) {
+      const Page = mongoose.model('Page') as PageModel;
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+      let applicableUserIds: ObjectId[] | undefined;
+      let applicableGroupIds: ObjectId[] | undefined;
+
+      if (grant === Page.GRANT_USER_GROUP) {
+        const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
+        if (targetUserGroup == null) {
+          throw Error('Target user group does not exist');
+        }
+
+        const relatedUsers = await UserGroupRelation.find({ relatedGroup: targetUserGroup._id });
+        applicableUserIds = relatedUsers.map(u => u.relatedUser);
+
+        const applicableGroups = grantedGroupId != null ? await UserGroup.findGroupsWithDescendantsById(grantedGroupId) : null;
+        applicableGroupIds = applicableGroups?.map(g => g._id) || null;
+      }
+
+      return {
+        grant,
+        grantedUserIds,
+        grantedGroupId,
+        applicableUserIds,
+        applicableGroupIds,
+      };
+    }
+
+    return {
+      grant,
+      grantedUserIds,
+      grantedGroupId,
+    };
+  }
+
+  /**
+   * Prepare ComparableAncestor
+   * @param targetPath string of the target path
+   * @returns Promise<ComparableAncestor>
+   */
+  private async generateComparableAncestor(targetPath: string): Promise<ComparableAncestor> {
+    const Page = mongoose.model('Page') as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+    let applicableUserIds: ObjectId[] | undefined;
+    let applicableGroupIds: ObjectId[] | undefined;
+
+    /*
+     * make granted users list of ancestor's
+     */
+    const builderForAncestors = new PageQueryBuilder(Page.find(), false);
+    const ancestors = await builderForAncestors
+      .addConditionToListOnlyAncestors(targetPath)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+    const testAncestor = ancestors[0];
+    if (testAncestor == null) {
+      throw Error('testAncestor must exist');
+    }
+
+    if (testAncestor.grant === Page.GRANT_USER_GROUP) {
+      // make a set of all users
+      const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
+      const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
+      applicableGroupIds = grantedGroups.map(g => g._id);
+      applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectId[];
+    }
+
+    return {
+      grant: testAncestor.grant,
+      grantedUserIds: testAncestor.grantedUsers,
+      applicableUserIds,
+      applicableGroupIds,
+    };
+  }
+
+  /**
+   * Prepare ComparableDescendants
+   * @param targetPath string of the target path
+   * @returns ComparableDescendants
+   */
+  private async generateComparableDescendants(targetPath: string): Promise<ComparableDescendants> {
+    const Page = mongoose.model('Page') as PageModel;
+
+    /*
+     * make granted users list of descendant's
+     */
+    const pathWithTrailingSlash = addTrailingSlash(targetPath);
+    const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
+
+    const result = await Page.aggregate([
+      { // match to descendants excluding empty pages
+        $match: {
+          path: new RegExp(`^${startsPattern}`),
+          isEmpty: { $ne: true },
+        },
+      },
+      {
+        $project: {
+          _id: 0,
+          grant: 1,
+          grantedUsers: 1,
+          grantedGroup: 1,
+        },
+      },
+      { // remove duplicates from pipeline
+        $group: {
+          _id: '$grant',
+          grantedGroupSet: { $addToSet: '$grantedGroup' },
+          grantedUsersSet: { $addToSet: '$grantedUsers' },
+        },
+      },
+      { // flatten granted user set
+        $unwind: {
+          path: '$grantedUsersSet',
+        },
+      },
+    ]);
+
+    // GRANT_PUBLIC group
+    const isPublicExist = result.some(r => r._id === Page.GRANT_PUBLIC);
+    // GRANT_OWNER group
+    const grantOwnerResult = result.filter(r => r._id === Page.GRANT_OWNER)[0]; // users of GRANT_OWNER
+    const grantedUserIds: ObjectId[] = grantOwnerResult?.grantedUsersSet ?? [];
+    // GRANT_USER_GROUP group
+    const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
+    const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
+
+    return {
+      isPublicExist,
+      grantedUserIds,
+      grantedGroupIds,
+    };
+  }
+
+  /**
+   * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
+   * @returns Promise<boolean>
+   */
+  async isGrantNormalized(
+      targetPath: string, grant, grantedUserIds: ObjectId[] | undefined, grantedGroupId: ObjectId, shouldCheckDescendants = false,
+  ): Promise<boolean> {
+    if (isTopPage(targetPath)) {
+      return true;
+    }
+
+    const comparableAncestor = await this.generateComparableAncestor(targetPath);
+
+    if (!shouldCheckDescendants) { // checking the parent is enough
+      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
+      return this.processValidation(comparableTarget, comparableAncestor);
+    }
+
+    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
+    const comparableDescendants = await this.generateComparableDescendants(targetPath);
+
+    return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
+  }
+
+}
+
+export default PageGrantService;

+ 193 - 98
packages/app/src/server/service/page.js → packages/app/src/server/service/page.ts

@@ -1,38 +1,45 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-
+import mongoose from 'mongoose';
+import escapeStringRegexp from 'escape-string-regexp';
+import streamToPromise from 'stream-to-promise';
+import pathlib from 'path';
+import { Writable } from 'stream';
+
+import { serializePageSecurely } from '../models/serializers/page-serializer';
+import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-import { generateGrantCondition } from '~/server/models/page';
-
+import { generateGrantCondition, PageModel } from '~/server/models/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
-
 import ActivityDefine from '../util/activityDefine';
 import ActivityDefine from '../util/activityDefine';
+import { IPage } from '~/interfaces/page';
 
 
-const mongoose = require('mongoose');
-const escapeStringRegexp = require('escape-string-regexp');
-const streamToPromise = require('stream-to-promise');
-const pathlib = require('path');
-
-const logger = loggerFactory('growi:services:page');
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
-const { Writable } = require('stream');
-const { createBatchStream } = require('~/server/util/batch-stream');
 
 
-const { isCreatablePage, isDeletablePage, isTrashPage } = pagePathUtils;
-const { serializePageSecurely } = require('../models/serializers/page-serializer');
+const logger = loggerFactory('growi:services:page');
+const {
+  isCreatablePage, isDeletablePage, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 
 
 const BULK_REINDEX_SIZE = 100;
 const BULK_REINDEX_SIZE = 100;
 
 
 class PageService {
 class PageService {
 
 
+  crowi: any;
+
+  pageEvent: any;
+
+  tagEvent: 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');
 
 
     // init
     // init
     this.initPageEvent();
     this.initPageEvent();
   }
   }
 
 
-  initPageEvent() {
+  private initPageEvent() {
     // create
     // create
     this.pageEvent.on('create', this.pageEvent.onCreate);
     this.pageEvent.on('create', this.pageEvent.onCreate);
 
 
@@ -116,7 +123,7 @@ class PageService {
       page = await Page.findByPathAndViewer(path, user);
       page = await Page.findByPathAndViewer(path, user);
     }
     }
 
 
-    const result = {};
+    const result: any = {};
 
 
     if (page == null) {
     if (page == null) {
       const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
       const isExist = await Page.count({ $or: [{ _id: pageId }, { path }] }) > 0;
@@ -153,7 +160,7 @@ class PageService {
    * @param {object} redirectToPagePathMapping
    * @param {object} redirectToPagePathMapping
    * @param {array} pagePaths
    * @param {array} pagePaths
    */
    */
-  prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths = []) {
+  private prepareShoudDeletePagesByRedirectTo(redirectTo, redirectToPagePathMapping, pagePaths: any[] = []) {
     const pagePath = redirectToPagePathMapping[redirectTo];
     const pagePath = redirectToPagePathMapping[redirectTo];
 
 
     if (pagePath == null) {
     if (pagePath == null) {
@@ -169,7 +176,7 @@ class PageService {
    * @param {string} targetPagePath
    * @param {string} targetPagePath
    * @param {User} viewer
    * @param {User} viewer
    */
    */
-  async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
+  private async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
@@ -185,23 +192,85 @@ class PageService {
       .cursor({ batchSize: BULK_REINDEX_SIZE });
       .cursor({ batchSize: BULK_REINDEX_SIZE });
   }
   }
 
 
-  async renamePage(page, newPagePath, user, options, isRecursively = false) {
+  // TODO: rewrite recursive rename
+  async renamePage(page, newPagePath, user, options) {
+    // v4 compatible process
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      return this.renamePageV4(page, newPagePath, user, options);
+    }
+
+    const Page = this.crowi.model('Page');
+    const {
+      path, grant, grantedUsers: grantedUserIds, grantedGroup: grantUserGroupId,
+    } = page;
+    const updateMetadata = options.updateMetadata || false;
+
+    // sanitize path
+    newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
+
+    /*
+     * UserGroup & Owner validation
+     */
+    if (grant !== Page.GRANT_RESTRICTED) {
+      let isGrantNormalized = false;
+      try {
+        const shouldCheckDescendants = false;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${newPagePath}" when renaming`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error(`This page cannot be renamed to "${newPagePath}" since the selected grant or grantedGroup is not assignable to this page.`);
+      }
+    }
+
+    // update descendants first
+    await this.renameDescendantsWithStream(page, newPagePath, user, options);
+
+    /*
+     * TODO: https://redmine.weseek.co.jp/issues/86577
+     * bulkWrite PageRedirectDocument if createRedirectPage is true
+     */
+
+    /*
+     * update target
+     */
+    const update: Partial<IPage> = {};
+    // find or create parent
+    const newParent = await Page.getParentAndFillAncestors(newPagePath);
+    // update Page
+    update.path = newPagePath;
+    update.parent = newParent._id;
+    if (updateMetadata) {
+      update.lastUpdateUser = user;
+      update.updatedAt = new Date();
+    }
+    const renamedPage = await Page.findByIdAndUpdate(page._id, { $set: update }, { new: true });
+
+    this.pageEvent.emit('rename', page, user);
+
+    return renamedPage;
+  }
 
 
+  // !!renaming always include descendant pages!!
+  private async renamePageV4(page, newPagePath, user, options) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const Revision = this.crowi.model('Revision');
     const Revision = this.crowi.model('Revision');
     const path = page.path;
     const path = page.path;
-    const createRedirectPage = options.createRedirectPage || false;
     const updateMetadata = options.updateMetadata || false;
     const updateMetadata = options.updateMetadata || false;
 
 
     // sanitize path
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
     // create descendants first
     // create descendants first
-    if (isRecursively) {
-      await this.renameDescendantsWithStream(page, newPagePath, user, options);
-    }
+    await this.renameDescendantsWithStream(page, newPagePath, user, options);
+
 
 
-    const update = {};
+    const update: any = {};
     // update Page
     // update Page
     update.path = newPagePath;
     update.path = newPagePath;
     if (updateMetadata) {
     if (updateMetadata) {
@@ -213,10 +282,10 @@ class PageService {
     // update Rivisions
     // update Rivisions
     await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
     await Revision.updateRevisionListByPath(path, { path: newPagePath }, {});
 
 
-    if (createRedirectPage) {
-      const body = `redirect ${newPagePath}`;
-      await Page.create(path, body, user, { redirectTo: newPagePath });
-    }
+    /*
+     * TODO: https://redmine.weseek.co.jp/issues/86577
+     * bulkWrite PageRedirectDocument if createRedirectPage is true
+     */
 
 
     this.pageEvent.emit('rename', page, user);
     this.pageEvent.emit('rename', page, user);
 
 
@@ -224,21 +293,14 @@ class PageService {
   }
   }
 
 
 
 
-  async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
-    const Page = this.crowi.model('Page');
-
+  private async renameDescendants(pages, user, options, oldPagePathPrefix, newPagePathPrefix) {
     const pageCollection = mongoose.connection.collection('pages');
     const pageCollection = mongoose.connection.collection('pages');
-    const revisionCollection = mongoose.connection.collection('revisions');
-    const { updateMetadata, createRedirectPage } = options;
+    const { updateMetadata } = options;
 
 
     const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
     const unorderedBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const createRediectPageBulkOp = pageCollection.initializeUnorderedBulkOp();
-    const revisionUnorderedBulkOp = revisionCollection.initializeUnorderedBulkOp();
-    const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
 
 
     pages.forEach((page) => {
     pages.forEach((page) => {
       const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
       const newPagePath = page.path.replace(oldPagePathPrefix, newPagePathPrefix);
-      const revisionId = new mongoose.Types.ObjectId();
 
 
       if (updateMetadata) {
       if (updateMetadata) {
         unorderedBulkOp
         unorderedBulkOp
@@ -248,29 +310,14 @@ class PageService {
       else {
       else {
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
         unorderedBulkOp.find({ _id: page._id }).update({ $set: { path: newPagePath } });
       }
       }
-      if (createRedirectPage) {
-        createRediectPageBulkOp.insert({
-          path: page.path, revision: revisionId, creator: user._id, lastUpdateUser: user._id, status: Page.STATUS_PUBLISHED, redirectTo: newPagePath,
-        });
-        createRediectRevisionBulkOp.insert({
-          _id: revisionId, path: page.path, body: `redirect ${newPagePath}`, author: user._id, format: 'markdown',
-        });
-      }
-      revisionUnorderedBulkOp.find({ path: page.path }).update({ $set: { path: newPagePath } }, { multi: true });
     });
     });
 
 
     try {
     try {
       await unorderedBulkOp.execute();
       await unorderedBulkOp.execute();
-      await revisionUnorderedBulkOp.execute();
-      // Execute after unorderedBulkOp to prevent duplication
-      if (createRedirectPage) {
-        await createRediectPageBulkOp.execute();
-        await createRediectRevisionBulkOp.execute();
-      }
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
-        throw new Error('Failed to rename pages: ', err);
+        throw new Error(`Failed to rename pages: ${err}`);
       }
       }
     }
     }
 
 
@@ -280,7 +327,7 @@ class PageService {
   /**
   /**
    * Create rename stream
    * Create rename stream
    */
    */
-  async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
+  private async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
 
 
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
@@ -321,7 +368,7 @@ class PageService {
   }
   }
 
 
 
 
-  async deleteCompletelyOperation(pageIds, pagePaths) {
+  private async deleteCompletelyOperation(pageIds, pagePaths) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
     const Bookmark = this.crowi.model('Bookmark');
     const Bookmark = this.crowi.model('Bookmark');
     const Comment = this.crowi.model('Comment');
     const Comment = this.crowi.model('Comment');
@@ -340,7 +387,7 @@ class PageService {
       redirectToPagePathMapping[page.redirectTo] = page.path;
       redirectToPagePathMapping[page.redirectTo] = page.path;
     });
     });
 
 
-    const redirectedFromPagePaths = [];
+    const redirectedFromPagePaths: any[] = [];
     pagePaths.forEach((pagePath) => {
     pagePaths.forEach((pagePath) => {
       redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
       redirectedFromPagePaths.push(...this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping));
     });
     });
@@ -358,15 +405,15 @@ class PageService {
 
 
   async duplicate(page, newPagePath, user, isRecursively) {
   async duplicate(page, newPagePath, user, isRecursively) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
-    const PageTagRelation = mongoose.model('PageTagRelation');
+    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
 
 
     // create option
     // create option
-    const options = { page };
+    const options: any = { page };
     options.grant = page.grant;
     options.grant = page.grant;
     options.grantUserGroupId = page.grantedGroup;
     options.grantUserGroupId = page.grantedGroup;
-    options.grantedUsers = page.grantedUsers;
+    options.grantedUserIds = page.grantedUsers;
 
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
 
@@ -384,6 +431,7 @@ class PageService {
     if (originTags != null) {
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
     }
     }
 
 
     const result = serializePageSecurely(createdPage);
     const result = serializePageSecurely(createdPage);
@@ -396,12 +444,12 @@ class PageService {
    * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
    * Receive the object with oldPageId and newPageId and duplicate the tags from oldPage to newPage
    * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
    * @param {Object} pageIdMapping e.g. key: oldPageId, value: newPageId
    */
    */
-  async duplicateTags(pageIdMapping) {
+  private async duplicateTags(pageIdMapping) {
     const PageTagRelation = mongoose.model('PageTagRelation');
     const PageTagRelation = mongoose.model('PageTagRelation');
 
 
     // convert pageId from string to ObjectId
     // convert pageId from string to ObjectId
     const pageIds = Object.keys(pageIdMapping);
     const pageIds = Object.keys(pageIdMapping);
-    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: mongoose.Types.ObjectId(pageId) } }) };
+    const stage = { $or: pageIds.map((pageId) => { return { relatedPage: new mongoose.Types.ObjectId(pageId) } }) };
 
 
     const pagesAssociatedWithTag = await PageTagRelation.aggregate([
     const pagesAssociatedWithTag = await PageTagRelation.aggregate([
       {
       {
@@ -415,7 +463,7 @@ class PageService {
       },
       },
     ]);
     ]);
 
 
-    const newPageTagRelation = [];
+    const newPageTagRelation: any[] = [];
     pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
     pagesAssociatedWithTag.forEach(({ _id, relatedPages }) => {
       // relatedPages
       // relatedPages
       relatedPages.forEach((pageId) => {
       relatedPages.forEach((pageId) => {
@@ -429,7 +477,7 @@ class PageService {
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
   }
   }
 
 
-  async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
+  private async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const Revision = this.crowi.model('Revision');
     const Revision = this.crowi.model('Revision');
 
 
@@ -444,8 +492,8 @@ class PageService {
 
 
     // key: oldPageId, value: newPageId
     // key: oldPageId, value: newPageId
     const pageIdMapping = {};
     const pageIdMapping = {};
-    const newPages = [];
-    const newRevisions = [];
+    const newPages: any[] = [];
+    const newRevisions: any[] = [];
 
 
     pages.forEach((page) => {
     pages.forEach((page) => {
       const newPageId = new mongoose.Types.ObjectId();
       const newPageId = new mongoose.Types.ObjectId();
@@ -476,7 +524,7 @@ class PageService {
     await this.duplicateTags(pageIdMapping);
     await this.duplicateTags(pageIdMapping);
   }
   }
 
 
-  async duplicateDescendantsWithStream(page, newPagePath, user) {
+  private async duplicateDescendantsWithStream(page, newPagePath, user) {
 
 
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
 
 
@@ -518,6 +566,7 @@ class PageService {
 
 
   async deletePage(page, user, options = {}, isRecursively = false) {
   async deletePage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
     const Revision = this.crowi.model('Revision');
 
 
     const newPath = Page.getDeletedPageName(page.path);
     const newPath = Page.getDeletedPageName(page.path);
@@ -542,6 +591,7 @@ class PageService {
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
       },
     }, { new: true });
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
     const body = `redirect ${newPath}`;
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
 
@@ -551,7 +601,7 @@ class PageService {
     return deletedPage;
     return deletedPage;
   }
   }
 
 
-  async deleteDescendants(pages, user) {
+  private async deleteDescendants(pages, user) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
 
 
     const pageCollection = mongoose.connection.collection('pages');
     const pageCollection = mongoose.connection.collection('pages');
@@ -560,7 +610,7 @@ class PageService {
     const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
     const deletePageBulkOp = pageCollection.initializeUnorderedBulkOp();
     const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
     const updateRevisionListOp = revisionCollection.initializeUnorderedBulkOp();
     const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
     const createRediectRevisionBulkOp = revisionCollection.initializeUnorderedBulkOp();
-    const newPagesForRedirect = [];
+    const newPagesForRedirect: any[] = [];
 
 
     pages.forEach((page) => {
     pages.forEach((page) => {
       const newPath = Page.getDeletedPageName(page.path);
       const newPath = Page.getDeletedPageName(page.path);
@@ -597,7 +647,7 @@ class PageService {
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
-        throw new Error('Failed to revert pages: ', err);
+        throw new Error(`Failed to revert pages: ${err}`);
       }
       }
     }
     }
     finally {
     finally {
@@ -608,7 +658,7 @@ class PageService {
   /**
   /**
    * Create delete stream
    * Create delete stream
    */
    */
-  async deleteDescendantsWithStream(targetPage, user, options = {}) {
+  private async deleteDescendantsWithStream(targetPage, user, options = {}) {
 
 
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
@@ -641,7 +691,7 @@ class PageService {
   }
   }
 
 
   // delete multiple pages
   // delete multiple pages
-  async deleteMultipleCompletely(pages, user, options = {}) {
+  private async deleteMultipleCompletely(pages, user, options = {}) {
     const ids = pages.map(page => (page._id));
     const ids = pages.map(page => (page._id));
     const paths = pages.map(page => (page.path));
     const paths = pages.map(page => (page.path));
 
 
@@ -673,10 +723,14 @@ class PageService {
     return;
     return;
   }
   }
 
 
+  async emptyTrashPage(user, options = {}) {
+    return this.deleteCompletelyDescendantsWithStream({ path: '/trash' }, user, options);
+  }
+
   /**
   /**
    * Create delete completely stream
    * Create delete completely stream
    */
    */
-  async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
+  private async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
 
 
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
@@ -708,7 +762,7 @@ class PageService {
       .pipe(writeStream);
       .pipe(writeStream);
   }
   }
 
 
-  async revertDeletedDescendants(pages, user) {
+  private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const pageCollection = mongoose.connection.collection('pages');
     const pageCollection = mongoose.connection.collection('pages');
     const revisionCollection = mongoose.connection.collection('revisions');
     const revisionCollection = mongoose.connection.collection('revisions');
@@ -743,7 +797,7 @@ class PageService {
           path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
           path: toPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
         },
         },
       });
       });
-      revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } }, { multi: true });
+      revertRevisionBulkOp.find({ path: page.path }).update({ $set: { path: toPath } });
     });
     });
 
 
     try {
     try {
@@ -753,13 +807,14 @@ class PageService {
     }
     }
     catch (err) {
     catch (err) {
       if (err.code !== 11000) {
       if (err.code !== 11000) {
-        throw new Error('Failed to revert pages: ', err);
+        throw new Error(`Failed to revert pages: ${err}`);
       }
       }
     }
     }
   }
   }
 
 
   async revertDeletedPage(page, user, options = {}, isRecursively = false) {
   async revertDeletedPage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
     const Revision = this.crowi.model('Revision');
 
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     const newPath = Page.getRevertDeletedPageName(page.path);
@@ -788,6 +843,7 @@ class PageService {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
       },
       },
     }, { new: true });
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
     await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
     await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
 
 
     return updatedPage;
     return updatedPage;
@@ -796,7 +852,7 @@ class PageService {
   /**
   /**
    * Create revert stream
    * Create revert stream
    */
    */
-  async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
+  private async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
 
 
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
 
@@ -829,36 +885,32 @@ class PageService {
   }
   }
 
 
 
 
-  async handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId, user) {
+  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
-    const pages = await Page.find({ grantedGroup: deletedGroup });
+    const pages = await Page.find({ grantedGroup: { $in: groupsToDelete } });
 
 
     switch (action) {
     switch (action) {
       case 'public':
       case 'public':
-        await Promise.all(pages.map((page) => {
-          return Page.publicizePage(page);
-        }));
+        await Page.publicizePages(pages);
         break;
         break;
       case 'delete':
       case 'delete':
         return this.deleteMultipleCompletely(pages, user);
         return this.deleteMultipleCompletely(pages, user);
       case 'transfer':
       case 'transfer':
-        await Promise.all(pages.map((page) => {
-          return Page.transferPageToGroup(page, transferToUserGroupId);
-        }));
+        await Page.transferPagesToGroup(pages, transferToUserGroupId);
         break;
         break;
       default:
       default:
         throw new Error('Unknown action for private pages');
         throw new Error('Unknown action for private pages');
     }
     }
   }
   }
 
 
-  async shortBodiesMapByPageIds(pageIds = [], user) {
+  async shortBodiesMapByPageIds(pageIds: string[] = [], user) {
     const Page = mongoose.model('Page');
     const Page = mongoose.model('Page');
     const MAX_LENGTH = 350;
     const MAX_LENGTH = 350;
 
 
     // aggregation options
     // aggregation options
     const viewerCondition = await generateGrantCondition(user, null);
     const viewerCondition = await generateGrantCondition(user, null);
     const filterByIds = {
     const filterByIds = {
-      _id: { $in: pageIds.map(id => mongoose.Types.ObjectId(id)) },
+      _id: { $in: pageIds.map(id => new mongoose.Types.ObjectId(id)) },
     };
     };
 
 
     let pages;
     let pages;
@@ -915,13 +967,7 @@ class PageService {
     return shortBodiesMap;
     return shortBodiesMap;
   }
   }
 
 
-  validateCrowi() {
-    if (this.crowi == null) {
-      throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
-    }
-  }
-
-  createAndSendNotifications = async function(page, user, action) {
+  private async createAndSendNotifications(page, user, action) {
     const { activityService, inAppNotificationService } = this.crowi;
     const { activityService, inAppNotificationService } = this.crowi;
 
 
     const snapshot = stringifySnapshot(page);
     const snapshot = stringifySnapshot(page);
@@ -941,7 +987,7 @@ class PageService {
     // Create and send notifications
     // Create and send notifications
     await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
     await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
     await inAppNotificationService.emitSocketIo(targetUsers);
     await inAppNotificationService.emitSocketIo(targetUsers);
-  };
+  }
 
 
   async v5MigrationByPageIds(pageIds) {
   async v5MigrationByPageIds(pageIds) {
     const Page = mongoose.model('Page');
     const Page = mongoose.model('Page');
@@ -1032,6 +1078,16 @@ class PageService {
       throw err;
       throw err;
     }
     }
 
 
+    // update descendantCount of all public pages
+    try {
+      await this.updateDescendantCountOfSelfAndDescendants('/');
+      logger.info('Successfully updated all descendantCount of public pages.');
+    }
+    catch (err) {
+      logger.error('Failed updating descendantCount of public pages.', err);
+      throw err;
+    }
+
     await this._setIsV5CompatibleTrue();
     await this._setIsV5CompatibleTrue();
   }
   }
 
 
@@ -1039,7 +1095,7 @@ class PageService {
    * returns an array of js RegExp instance instead of RE2 instance for mongo filter
    * returns an array of js RegExp instance instead of RE2 instance for mongo filter
    */
    */
   async _generateRegExpsByPageIds(pageIds) {
   async _generateRegExpsByPageIds(pageIds) {
-    const Page = mongoose.model('Page');
+    const Page = mongoose.model('Page') as PageModel;
 
 
     let result;
     let result;
     try {
     try {
@@ -1077,7 +1133,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
 
 
     // generate filter
     // generate filter
-    let filter = {
+    let filter: any = {
       parent: null,
       parent: null,
       path: { $ne: '/' },
       path: { $ne: '/' },
     };
     };
@@ -1154,7 +1210,7 @@ class PageService {
             parentPath = parentPath.replace(bracket, `\\${bracket}`);
             parentPath = parentPath.replace(bracket, `\\${bracket}`);
           });
           });
 
 
-          const filter = {
+          const filter: any = {
             // regexr.com/6889f
             // regexr.com/6889f
             // ex. /parent/any_child OR /any_level1
             // ex. /parent/any_child OR /any_level1
             path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
             path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
@@ -1245,6 +1301,45 @@ class PageService {
     return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
     return Page.count({ parent: null, creator: user, grant: { $ne: Page.GRANT_PUBLIC } });
   }
   }
 
 
+  /**
+   * update descendantCount of the following pages
+   * - page that has the same path as the provided path
+   * - pages that are descendants of the above page
+   */
+  async updateDescendantCountOfSelfAndDescendants(path = '/') {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+
+    const aggregateCondition = Page.getAggrConditionForPageWithProvidedPathAndDescendants(path);
+    const aggregatedPages = await Page.aggregate(aggregateCondition).cursor({ batchSize: BATCH_SIZE });
+
+    const recountWriteStream = new Writable({
+      objectMode: true,
+      async write(pageDocuments, encoding, callback) {
+        for (const document of pageDocuments) {
+          // eslint-disable-next-line no-await-in-loop
+          await Page.recountDescendantCountOfSelfAndDescendants(document._id);
+        }
+        callback();
+      },
+      final(callback) {
+        callback();
+      },
+    });
+    aggregatedPages
+      .pipe(createBatchStream(BATCH_SIZE))
+      .pipe(recountWriteStream);
+
+    await streamToPromise(recountWriteStream);
+  }
+
+  // update descendantCount of all pages that are ancestors of a provided path by count
+  async updateDescendantCountOfAncestors(path = '/', count = 0) {
+    const Page = this.crowi.model('Page');
+    const ancestors = collectAncestorPaths(path);
+    await Page.incrementDescendantCountOfPaths(ancestors, count);
+  }
+
 }
 }
 
 
-module.exports = PageService;
+export default PageService;

+ 2 - 1
packages/app/src/server/service/passport.ts

@@ -677,7 +677,8 @@ class PassportService implements S2sMessageHandlable {
       });
       });
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // prevent error AssertionError [ERR_ASSERTION]: id_token issued in the future
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
       // Doc: https://github.com/panva/node-openid-client/tree/v2.x#allow-for-system-clock-skew
-      client.CLOCK_TOLERANCE = 5;
+      const OIDC_CLIENT_CLOCK_TOLERANCE = await this.crowi.configManager.getConfig('crowi', 'security:passport-oidc:oidcClientClockTolerance');
+      client.CLOCK_TOLERANCE = OIDC_CLIENT_CLOCK_TOLERANCE;
       passport.use('oidc', new OidcStrategy(
       passport.use('oidc', new OidcStrategy(
         {
         {
           client,
           client,

+ 2 - 3
packages/app/src/server/service/search.ts

@@ -1,4 +1,3 @@
-import RE2 from 're2';
 import xss from 'xss';
 import xss from 'xss';
 
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
@@ -189,8 +188,8 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
   }
 
 
   async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
   async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
-    const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
-    const replaceRegexp = new RE2(/\[nq:|\]/g);
+    const regexp = new RegExp(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
+    const replaceRegexp = new RegExp(/\[nq:|\]/g);
 
 
     const queryString = normalizeQueryString(_queryString);
     const queryString = normalizeQueryString(_queryString);
 
 

+ 0 - 25
packages/app/src/server/service/user-group.js

@@ -1,25 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
-
-const mongoose = require('mongoose');
-
-const UserGroupRelation = mongoose.model('UserGroupRelation');
-
-/**
- * the service class of UserGroupService
- */
-class UserGroupService {
-
-  constructor(configManager) {
-    this.configManager = configManager;
-  }
-
-  async init() {
-    logger.debug('removing all invalid relations');
-    return UserGroupRelation.removeAllInvalidRelations();
-  }
-
-}
-
-module.exports = UserGroupService;

+ 115 - 0
packages/app/src/server/service/user-group.ts

@@ -0,0 +1,115 @@
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
+import { isIncludesObjectId } from '~/server/util/compare-objectId';
+
+const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
+
+
+const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+/**
+ * the service class of UserGroupService
+ */
+class UserGroupService {
+
+  crowi: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async init() {
+    logger.debug('removing all invalid relations');
+    return UserGroupRelation.removeAllInvalidRelations();
+  }
+
+  // TODO 85062: write test code
+  // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
+  async updateGroup(id, name: string, description: string, parentId?: string, forceUpdateParents = false) {
+    const userGroup = await UserGroup.findById(id);
+    if (userGroup == null) {
+      throw new Error('The group does not exist');
+    }
+
+    // check if the new group name is available
+    const isExist = (await UserGroup.countDocuments({ name })) > 0;
+    if (userGroup.name !== name && isExist) {
+      throw new Error('The group name is already taken');
+    }
+
+    userGroup.name = name;
+    userGroup.description = description;
+
+    // return when not update parent
+    if (userGroup.parent === parentId) {
+      return userGroup.save();
+    }
+    // set parent to null and return when parentId is null
+    if (parentId == null) {
+      userGroup.parent = null;
+      return userGroup.save();
+    }
+
+    const parent = await UserGroup.findById(parentId);
+
+    if (parent == null) { // it should not be null
+      throw Error('parent does not exist.');
+    }
+
+
+    // throw if parent was in its descendants
+    const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
+    const descendants = descendantsWithTarget.filter(d => d._id.equals(userGroup._id));
+    if (isIncludesObjectId(descendants, parent._id)) {
+      throw Error('It is not allowed to choose parent from descendant groups.');
+    }
+
+    // find users for comparison
+    const [targetGroupUsers, parentGroupUsers] = await Promise.all(
+      [UserGroupRelation.findUserIdsByGroupId(userGroup._id), UserGroupRelation.findUserIdsByGroupId(parent?._id)], // TODO 85062: consider when parent is null to update the group as the root
+    );
+
+    const usersBelongsToTargetButNotParent = targetGroupUsers.filter(user => !parentGroupUsers.includes(user));
+    // add the target group's users to all ancestors
+    if (forceUpdateParents) {
+      const ancestorGroups = await UserGroup.findGroupsWithAncestorsRecursively(parent);
+      const ancestorGroupIds = ancestorGroups.map(group => group._id);
+
+      await UserGroupRelation.createByGroupIdsAndUserIds(ancestorGroupIds, usersBelongsToTargetButNotParent);
+
+      userGroup.parent = parent?._id; // TODO 85062: consider when parent is null to update the group as the root
+    }
+    // validate related users
+    else {
+      const isUpdatable = usersBelongsToTargetButNotParent.length === 0;
+      if (!isUpdatable) {
+        throw Error('The parent group does not contain the users in this group.');
+      }
+    }
+
+    return userGroup.save();
+  }
+
+  async removeCompletelyByRootGroupId(deleteRootGroupId, action, transferToUserGroupId, user) {
+    const rootGroup = await UserGroup.findById(deleteRootGroupId);
+    if (rootGroup == null) {
+      throw new Error(`UserGroup data does not exist. id: ${deleteRootGroupId}`);
+    }
+
+    const groupsToDelete = await UserGroup.findGroupsWithDescendantsRecursively([rootGroup]);
+
+    // 1. update page & remove all groups
+    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user);
+    // 2. remove all groups
+    const deletedGroups = await UserGroup.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
+    // 3. remove all relations
+    await UserGroupRelation.removeAllByUserGroups(groupsToDelete);
+
+    return deletedGroups;
+  }
+
+}
+
+module.exports = UserGroupService;

+ 2 - 1
packages/app/src/server/util/apiResponse.js

@@ -1,11 +1,12 @@
 function ApiResponse() {
 function ApiResponse() {
 }
 }
 
 
-ApiResponse.error = function(err, code) {
+ApiResponse.error = function(err, code, data) {
   const result = {};
   const result = {};
 
 
   result.ok = false;
   result.ok = false;
   result.code = code;
   result.code = code;
+  result.data = data;
 
 
   if (err instanceof Error) {
   if (err instanceof Error) {
     result.error = err.toString();
     result.error = err.toString();

+ 43 - 0
packages/app/src/server/util/compare-objectId.ts

@@ -0,0 +1,43 @@
+import mongoose from 'mongoose';
+
+type IObjectId = mongoose.Types.ObjectId;
+const ObjectId = mongoose.Types.ObjectId;
+
+export const isIncludesObjectId = (arr: (IObjectId | string)[], id: IObjectId | string): boolean => {
+  const _arr = arr.map(i => i.toString());
+  const _id = id.toString();
+
+  return _arr.includes(_id);
+};
+
+/**
+ * Exclude ObjectIds which exist in testIds from targetIds
+ * @param targetIds Array of mongoose.Types.ObjectId
+ * @param testIds Array of mongoose.Types.ObjectId
+ * @returns Array of mongoose.Types.ObjectId
+ */
+export const excludeTestIdsFromTargetIds = <T extends { toString: any } = IObjectId>(
+  targetIds: T[], testIds: (IObjectId | string)[],
+): T[] => {
+  // cast to string
+  const arr1 = targetIds.map(e => e.toString());
+  const arr2 = testIds.map(e => e.toString());
+
+  // filter
+  const excluded = arr1.filter(e => !arr2.includes(e));
+  // cast to ObjectId
+  const shouldReturnString = (arr: any[]): arr is string[] => {
+    return typeof arr[0] === 'string';
+  };
+
+  return shouldReturnString(targetIds) ? excluded : excluded.map(e => new ObjectId(e));
+};
+
+export const removeDuplicates = (objectIds: (IObjectId | string)[]): IObjectId[] => {
+  // cast to string
+  const strs = objectIds.map(id => id.toString());
+  const uniqueArr = Array.from(new Set(strs));
+
+  // cast to ObjectId
+  return uniqueArr.map(str => new ObjectId(str));
+};

+ 2 - 5
packages/app/src/server/views/tags.html

@@ -5,11 +5,8 @@
 {% block html_base_css %}tags-page{% endblock %}
 {% block html_base_css %}tags-page{% endblock %}
 
 
 {% block layout_main %}
 {% block layout_main %}
-<header class="py-0">
-  <h1 class="title">{{ t('Tags') }}</h1>
-</header>
-
-<div class="container-fluid">
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+<div class="grw-container-convertible">
   <div class="row">
   <div class="row">
     <div id="main" class="main mt-3 col-md-12 tags-page">
     <div id="main" class="main mt-3 col-md-12 tags-page">
       <div class="" id="tags-page"></div>
       <div class="" id="tags-page"></div>

+ 11 - 9
packages/app/src/stores/bookmark.ts

@@ -3,13 +3,15 @@ import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 
 
 
-export const useSWRBookmarkInfo = (pageId: string | null): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWR(pageId != null
-    ? `/bookmarks/info?pageId=${pageId}` : null,
-  endpoint => apiv3Get(endpoint).then((response) => {
-    return {
-      sumOfBookmarks: response.data.sumOfBookmarks,
-      isBookmarked: response.data.isBookmarked,
-    };
-  }));
+export const useSWRBookmarkInfo = (pageId: string | null | undefined, isOpen = false): SWRResponse<IBookmarkInfo, Error> => {
+  return useSWR(
+    pageId != null && isOpen
+      ? `/bookmarks/info?pageId=${pageId}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        isBookmarked: response.data.isBookmarked,
+      };
+    }),
+  );
 };
 };

+ 21 - 0
packages/app/src/stores/bookmarks.tsx

@@ -0,0 +1,21 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { Types } from 'mongoose';
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { IBookmarksInfo } from '~/interfaces/bookmarks';
+
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const useSWRxBookmarksInfo = <Data, Error>(pageId: Types.ObjectId):SWRResponse<IBookmarksInfo, Error> => {
+  return useSWR(
+    ['/bookmarks/info', pageId],
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+      return {
+        isBookmarked: response.data.isBookmarked,
+        sumOfBookmarks: response.data.sumOfBookmarks,
+        bookmarkedUsers: response.data.bookmarkedUsers,
+      };
+    }),
+  );
+};

+ 2 - 2
packages/app/src/stores/context.tsx

@@ -24,8 +24,8 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
 };
 };
 
 
 
 
-export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPageId', initialData ?? null);
+export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData ?? null);
 };
 };
 
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {

+ 2 - 1
packages/app/src/stores/page.tsx

@@ -68,6 +68,7 @@ export const useSWRTagsInfo = (pageId: string): SWRResponse<IPageTagsInfo, Error
     };
     };
   }));
   }));
 };
 };
+type GetSubscriptionStatusResult = { subscribing: boolean };
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRResponse<{status: boolean | null}, Error> => {
 export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRResponse<{status: boolean | null}, Error> => {
@@ -76,7 +77,7 @@ export const useSWRxSubscriptionStatus = <Data, Error>(pageId: string): SWRRespo
   const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
   const key = isGuestUser === false ? ['/page/subscribe', pageId] : null;
   return useSWR(
   return useSWR(
     key,
     key,
-    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then((response) => {
+    (endpoint, pageId) => apiv3Get<GetSubscriptionStatusResult>(endpoint, { pageId }).then((response) => {
       return {
       return {
         status: response.data.subscribing,
         status: response.data.subscribing,
       };
       };

+ 36 - 25
packages/app/src/stores/ui.tsx

@@ -51,15 +51,13 @@ export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   return useStaticSWR(key, null, configuration);
   return useStaticSWR(key, null, configuration);
 };
 };
 
 
-
-const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
+const updateBodyClassesByEditorMode = (newEditorMode: EditorMode) => {
   switch (newEditorMode) {
   switch (newEditorMode) {
     case EditorMode.View:
     case EditorMode.View:
       $('body').removeClass('on-edit');
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
       $('body').removeClass('hackmd');
       $('body').removeClass('pathname-sidebar');
       $('body').removeClass('pathname-sidebar');
-      window.history.replaceState(null, '', window.location.pathname);
       break;
       break;
     case EditorMode.Editor:
     case EditorMode.Editor:
       $('body').addClass('on-edit');
       $('body').addClass('on-edit');
@@ -69,38 +67,50 @@ const updateBodyClassesForEditorMode = (newEditorMode: EditorMode) => {
       if (window.location.pathname === '/Sidebar') {
       if (window.location.pathname === '/Sidebar') {
         $('body').addClass('pathname-sidebar');
         $('body').addClass('pathname-sidebar');
       }
       }
-      window.location.hash = '#edit';
       break;
       break;
     case EditorMode.HackMD:
     case EditorMode.HackMD:
       $('body').addClass('on-edit');
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('pathname-sidebar');
       $('body').removeClass('pathname-sidebar');
-      window.location.hash = '#hackmd';
       break;
       break;
   }
   }
 };
 };
 
 
-export const useEditorModeByHash = (): SWRResponse<EditorMode, Error> => {
-  return useSWRImmutable(
-    ['initialEditorMode', window.location.hash],
-    (key: Key, hash: string) => {
-      switch (hash) {
-        case '#edit':
-          return EditorMode.Editor;
-        case '#hackmd':
-          return EditorMode.HackMD;
-        default:
-          return EditorMode.View;
-      }
-    },
-  );
+const updateHashByEditorMode = (newEditorMode: EditorMode) => {
+  const { pathname } = window.location;
+
+  switch (newEditorMode) {
+    case EditorMode.View:
+      window.history.replaceState(null, '', pathname);
+      break;
+    case EditorMode.Editor:
+      window.history.replaceState(null, '', `${pathname}#edit`);
+      break;
+    case EditorMode.HackMD:
+      window.history.replaceState(null, '', `${pathname}#hackmd`);
+      break;
+  }
+};
+
+export const determineEditorModeByHash = (): EditorMode => {
+  const { hash } = window.location;
+
+  switch (hash) {
+    case '#edit':
+      return EditorMode.Editor;
+    case '#hackmd':
+      return EditorMode.HackMD;
+    default:
+      return EditorMode.View;
+  }
 };
 };
 
 
 let isEditorModeLoaded = false;
 let isEditorModeLoaded = false;
 export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
 export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   const { data: _isEditable } = useIsEditable();
   const { data: _isEditable } = useIsEditable();
-  const { data: editorModeByHash } = useEditorModeByHash();
+
+  const editorModeByHash = determineEditorModeByHash();
 
 
   const isLoading = _isEditable === undefined;
   const isLoading = _isEditable === undefined;
   const isEditable = !isLoading && _isEditable;
   const isEditable = !isLoading && _isEditable;
@@ -115,7 +125,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   // initial updating
   // initial updating
   if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
   if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
     if (isEditable) {
     if (isEditable) {
-      updateBodyClassesForEditorMode(swrResponse.data);
+      updateBodyClassesByEditorMode(swrResponse.data);
     }
     }
     isEditorModeLoaded = true;
     isEditorModeLoaded = true;
   }
   }
@@ -128,7 +138,8 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
       if (!isEditable) {
       if (!isEditable) {
         return Promise.resolve(EditorMode.View); // fixed if not editable
         return Promise.resolve(EditorMode.View); // fixed if not editable
       }
       }
-      updateBodyClassesForEditorMode(editorMode);
+      updateBodyClassesByEditorMode(editorMode);
+      updateHashByEditorMode(editorMode);
       return swrResponse.mutate(editorMode, shouldRevalidate);
       return swrResponse.mutate(editorMode, shouldRevalidate);
     },
     },
   };
   };
@@ -269,14 +280,14 @@ export const useCreateModalOpened = (): SWRResponse<boolean, Error> => {
   );
   );
 };
 };
 
 
-export const useCreateModalPath = (): SWRResponse<string, Error> => {
+export const useCreateModalPath = (): SWRResponse<string | null | undefined, Error> => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: status } = useCreateModalStatus();
   const { data: status } = useCreateModalStatus();
 
 
   return useSWR(
   return useSWR(
-    [currentPagePath, status],
+    currentPagePath != null && status != null ? [currentPagePath, status] : null,
     (currentPagePath, status) => {
     (currentPagePath, status) => {
-      return status.path || currentPagePath;
+      return status?.path || currentPagePath;
     },
     },
   );
   );
 };
 };

+ 45 - 0
packages/app/src/stores/user-group.tsx

@@ -0,0 +1,45 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
+import { UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult } from '~/interfaces/user-group-response';
+
+
+export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable<IUserGroupHasId[], Error>(
+    '/user-groups',
+    endpoint => apiv3Get<UserGroupListResult>(endpoint, { pagination: false }).then(result => result.data.userGroups),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+export const useSWRxChildUserGroupList = (
+    parentIds: string[] | undefined, includeGrandChildren?: boolean, initialData?: IUserGroupHasId[],
+): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable<IUserGroupHasId[], Error>(
+    parentIds != null ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
+    (endpoint, parentIds, includeGrandChildren) => apiv3Get<ChildUserGroupListResult>(
+      endpoint, { parentIds, includeGrandChildren },
+    ).then(result => result.data.childUserGroups),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+export const useSWRxUserGroupRelationList = (
+    groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
+): SWRResponse<IUserGroupRelationHasId[], Error> => {
+  return useSWRImmutable<IUserGroupRelationHasId[], Error>(
+    groupIds != null ? ['/user-group-relations', groupIds, childGroupIds] : null,
+    (endpoint, groupIds, childGroupIds) => apiv3Get<UserGroupRelationListResult>(
+      endpoint, { groupIds, childGroupIds },
+    ).then(result => result.data.userGroupRelations),
+    {
+      fallbackData: initialData,
+    },
+  );
+};

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