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

Merge branch 'dev/7.0.x' into feat/create-title-component-for-page-header

WNomunomu 2 лет назад
Родитель
Сommit
c8c74589c8
100 измененных файлов с 2296 добавлено и 1157 удалено
  1. 3 1
      .devcontainer/Dockerfile
  2. 2 1
      .devcontainer/devcontainer.json
  3. 2 36
      .devcontainer/docker-compose.yml
  4. 3 3
      .vscode/settings.json
  5. 23 1
      CHANGELOG.md
  6. 1 1
      apps/app/.env.test
  7. 15 13
      apps/app/_obsolete/src/components/PageEditor/MarkdownTableInterceptor.js
  8. 4 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  9. 0 5
      apps/app/_obsolete/src/styles/_override.scss
  10. 2 1
      apps/app/docker/README.md
  11. 13 3
      apps/app/package.json
  12. 62 3
      apps/app/public/static/locales/en_US/admin.json
  13. 1 1
      apps/app/public/static/locales/en_US/commons.json
  14. 16 1
      apps/app/public/static/locales/en_US/translation.json
  15. 63 3
      apps/app/public/static/locales/ja_JP/admin.json
  16. 2 2
      apps/app/public/static/locales/ja_JP/commons.json
  17. 16 1
      apps/app/public/static/locales/ja_JP/translation.json
  18. 64 4
      apps/app/public/static/locales/zh_CN/admin.json
  19. 1 1
      apps/app/public/static/locales/zh_CN/commons.json
  20. 16 1
      apps/app/public/static/locales/zh_CN/translation.json
  21. 1 1
      apps/app/resource/search/mappings-es7.json
  22. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  23. 1 1
      apps/app/resource/search/mappings-es8.json
  24. 78 0
      apps/app/src/client/services/AdminAppContainer.js
  25. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  26. 20 11
      apps/app/src/client/services/layout.ts
  27. 11 6
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  28. 15 10
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  29. 49 0
      apps/app/src/client/services/use-on-template-button-clicked.ts
  30. 6 0
      apps/app/src/client/services/user-ui-settings.ts
  31. 211 0
      apps/app/src/components/Admin/App/AzureSetting.tsx
  32. 75 2
      apps/app/src/components/Admin/App/FileUploadSetting.tsx
  33. 12 0
      apps/app/src/components/Admin/App/MaskedInput.module.scss
  34. 43 0
      apps/app/src/components/Admin/App/MaskedInput.tsx
  35. 3 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  36. 2 2
      apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx
  37. 1 1
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  38. 14 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  39. 23 11
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  40. 10 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  41. 6 0
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  42. 42 24
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  43. 63 38
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  44. 35 36
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  45. 50 0
      apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts
  46. 18 21
      apps/app/src/components/Comments.tsx
  47. 5 3
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  48. 32 10
      apps/app/src/components/Common/PageViewLayout.module.scss
  49. 13 5
      apps/app/src/components/Common/PageViewLayout.tsx
  50. 0 76
      apps/app/src/components/CreateTemplateModal.jsx
  51. 101 0
      apps/app/src/components/CreateTemplateModal.tsx
  52. 14 54
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  53. 1 1
      apps/app/src/components/InAppNotification/InAppNotificationList.tsx
  54. 5 12
      apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx
  55. 59 27
      apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  56. 33 26
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  57. 19 0
      apps/app/src/components/InAppNotification/PageNotification/index.tsx
  58. 1 18
      apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts
  59. 2 4
      apps/app/src/components/InstallerForm.tsx
  60. 15 0
      apps/app/src/components/Me/ColorModeSettings.module.scss
  61. 62 0
      apps/app/src/components/Me/ColorModeSettings.tsx
  62. 10 94
      apps/app/src/components/Me/OtherSettings.tsx
  63. 106 0
      apps/app/src/components/Me/QuestionnaireSettings.tsx
  64. 2 2
      apps/app/src/components/Me/SidebarCollapsedIcon.jsx
  65. 0 0
      apps/app/src/components/Me/SidebarDockIcon.jsx
  66. 7 0
      apps/app/src/components/Me/UISettings.module.scss
  67. 114 0
      apps/app/src/components/Me/UISettings.tsx
  68. 11 11
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  69. 2 2
      apps/app/src/components/Navbar/PageEditorModeManager.module.scss
  70. 7 5
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  71. 7 7
      apps/app/src/components/Navbar/hooks.tsx
  72. 22 10
      apps/app/src/components/Page/PageView.tsx
  73. 12 10
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  74. 6 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  75. 49 55
      apps/app/src/components/PageComment.tsx
  76. 9 0
      apps/app/src/components/PageControls/PageControls.tsx
  77. 4 4
      apps/app/src/components/PageCreateModal.jsx
  78. 13 6
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  79. 1 1
      apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx
  80. 0 190
      apps/app/src/components/PageEditor/MarkdownTableUtil.js
  81. 18 27
      apps/app/src/components/PageEditor/PageEditor.tsx
  82. 9 20
      apps/app/src/components/PageEditor/Preview.module.scss
  83. 6 2
      apps/app/src/components/PageEditor/Preview.tsx
  84. 147 0
      apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts
  85. 26 0
      apps/app/src/components/PageEditor/markdown-table-util-for-view.ts
  86. 10 5
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  87. 3 4
      apps/app/src/components/SavePageControls.tsx
  88. 27 37
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  89. 1 0
      apps/app/src/components/SavePageControls/GrantSelector/index.ts
  90. 38 0
      apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts
  91. 8 0
      apps/app/src/components/SearchPage/SearchResultContent.module.scss
  92. 13 13
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  93. 4 0
      apps/app/src/components/ShareLinkPageView.tsx
  94. 2 1
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  95. 7 7
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  96. 26 150
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  97. 95 0
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  98. 1 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  99. 1 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  100. 1 1
      apps/app/src/components/Sidebar/Tag.tsx

+ 3 - 1
.devcontainer/Dockerfile

@@ -37,7 +37,9 @@ RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable
 RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
 RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
 
 
 RUN apt-get update \
 RUN apt-get update \
-    && apt-get -y install --no-install-recommends git-lfs \
+    && apt-get -y install --no-install-recommends \
+      git-lfs \
+      iputils-ping net-tools dnsutils \
 
 
     # Uncomment below lines to install Chromium
     # Uncomment below lines to install Chromium
     # --- works only on AMD64 ---
     # --- works only on AMD64 ---

+ 2 - 1
.devcontainer/devcontainer.json

@@ -19,6 +19,7 @@
     "eamodio.gitlens",
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
     "cschleiden.vscode-github-actions",
+    "mongodb.mongodb-vscode",
     "msjsdiag.debugger-for-chrome",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",
     "editorconfig.editorconfig",
@@ -34,7 +35,7 @@
   // "shutdownAction": "none",
   // "shutdownAction": "none",
 
 
   // Use 'postCreateCommand' to run commands after the container is created.
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "git-lfs pull & yarn install",
+  "postCreateCommand": "git-lfs pull & turbo run bootstrap",
 
 
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "node"
   "remoteUser": "node"

+ 2 - 36
.devcontainer/docker-compose.yml

@@ -31,17 +31,10 @@ services:
     image: mongo:6.0
     image: mongo:6.0
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
-      - 27018:27017
+      - 27017
     volumes:
     volumes:
       - /data/db
       - /data/db
 
 
-  # ogp:
-  #   image: ghcr.io/weseek/growi-unique-ogp:latest
-  #   ports:
-  #     - 8088:8088
-  #   restart: unless-stopped
-  #   tty: true
-
   # This container requires '../../growi-docker-compose' repository
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
   elasticsearch:
@@ -52,7 +45,7 @@ services:
         - version=8.7.0
         - version=8.7.0
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
-      - 9201:9200
+      - 9200
     environment:
     environment:
       - bootstrap.memory_lock=true
       - bootstrap.memory_lock=true
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
@@ -65,33 +58,6 @@ services:
       - /usr/share/elasticsearch/data
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
 
-  #need to adjust kibana version based on elasticsearch version (use same version as elasticsearch version)
-  # kibana:
-  #   image: docker.elastic.co/kibana/kibana:8.7.0
-  #   restart: unless-stopped
-  #   environment:
-  #     ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
-  #   ports:
-  #     - 5601:5601
-  #   depends_on:
-  #     - elasticsearch
-
-  # This container requires '../../growi-docker-compose' repository
-  #   cloned from https://github.com/weseek/growi-docker-compose.git
-  hackmd:
-    build:
-      context: ../../growi-docker-compose/hackmd
-    restart: unless-stopped
-    environment:
-      - GROWI_URI=http://localhost:3000
-      # define 'storage' option value
-      # see https://github.com/sequelize/cli/blob/7160d0/src/helpers/config-helper.js#L192
-      - CMD_DB_URL=sqlite://dummyhost/hackmd/sqlite/codimd.db
-      - CMD_CSP_ENABLE=false
-    ports:
-      - 3011:3000
-    volumes:
-      - /files/sqlite
 volumes:
 volumes:
   node_modules:
   node_modules:
   node_modules_app:
   node_modules_app:

+ 3 - 3
.vscode/settings.json

@@ -12,9 +12,9 @@
   "scss.validate": false,
   "scss.validate": false,
 
 
   "editor.codeActionsOnSave": {
   "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": true,
-    "source.fixAll.markdownlint": true,
-    "source.fixAll.stylelint": true
+    "source.fixAll.eslint": "explicit",
+    "source.fixAll.markdownlint": "explicit",
+    "source.fixAll.stylelint": "explicit"
   },
   },
 
 
   "githubPullRequests.ignoredPullRequestBranches": [
   "githubPullRequests.ignoredPullRequestBranches": [

+ 23 - 1
CHANGELOG.md

@@ -1,9 +1,31 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.4...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.*
 
 
+## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
+
+### 💎 Features
+* feat: Show create date in Attachment Data list (#8229) @sakazuki
+
+### 🚀 Improvement
+
+* imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
+* imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
+
+### 🧰 Maintenance
+
+* support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
+
+
+## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
+
+### 🚀 Improvement
+
+- imprv: Certify shared page attachment middleware (#8211) @yuki-takei
+- imprv: Printing styles 2 (#8203) @yuki-takei
+
 ## [v6.2.2](https://github.com/weseek/growi/compare/v6.2.1...v6.2.2) - 2023-10-30
 ## [v6.2.2](https://github.com/weseek/growi/compare/v6.2.1...v6.2.2) - 2023-10-30
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 1
apps/app/.env.test

@@ -5,5 +5,5 @@
 ## > To prevent accidentally leaking env variables to the client, only variables prefixed with
 ## > To prevent accidentally leaking env variables to the client, only variables prefixed with
 ## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
 ## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
 ##
 ##
-VITE_MONGOMS_VERSION="6.0.6"
+VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1
 # VITE_MONGOMS_DEBUG=1

+ 15 - 13
apps/app/src/components/PageEditor/MarkdownTableInterceptor.js → apps/app/_obsolete/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -2,7 +2,10 @@ import { BasicInterceptor } from '@growi/core/dist/utils';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 
 
-import mtu from './MarkdownTableUtil';
+import {
+  getStrFromBot, addRowToMarkdownTable, getStrToEot, isEndOfLine, mergeMarkdownTable, replaceFocusedMarkdownTableWithEditor,
+  isInTable, emptyLineOfTableRE,
+} from '../../../../src/components/PageEditor/markdown-table-util-for-editor';
 
 
 /**
 /**
  * Interceptor for markdown table
  * Interceptor for markdown table
@@ -27,24 +30,24 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
 
 
   addRow(cm) {
   addRow(cm) {
     // get lines all of table from current position to beginning of table
     // get lines all of table from current position to beginning of table
-    const strFromBot = mtu.getStrFromBot(cm);
+    const strFromBot = getStrFromBot(cm);
     let table = MarkdownTable.fromMarkdownString(strFromBot);
     let table = MarkdownTable.fromMarkdownString(strFromBot);
 
 
-    mtu.addRowToMarkdownTable(table);
+    addRowToMarkdownTable(table);
 
 
-    const strToEot = mtu.getStrToEot(cm);
+    const strToEot = getStrToEot(cm);
     const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
     const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
     if (tableBottom.table.length > 0) {
     if (tableBottom.table.length > 0) {
-      table = mtu.mergeMarkdownTable([table, tableBottom]);
+      table = mergeMarkdownTable([table, tableBottom]);
     }
     }
 
 
-    mtu.replaceMarkdownTableWithReformed(cm, table);
+    replaceFocusedMarkdownTableWithEditor(cm, table);
   }
   }
 
 
   reformTable(cm) {
   reformTable(cm) {
-    const tableStr = mtu.getStrFromBot(cm) + mtu.getStrToEot(cm);
+    const tableStr = getStrFromBot(cm) + getStrToEot(cm);
     const table = MarkdownTable.fromMarkdownString(tableStr);
     const table = MarkdownTable.fromMarkdownString(tableStr);
-    mtu.replaceMarkdownTableWithReformed(cm, table);
+    replaceFocusedMarkdownTableWithEditor(cm, table);
   }
   }
 
 
   removeRow(editor) {
   removeRow(editor) {
@@ -67,16 +70,15 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
 
 
     const cm = editor.getCodeMirror();
     const cm = editor.getCodeMirror();
 
 
-    const isInTable = mtu.isInTable(cm);
-    const isLastRow = mtu.getStrToEot(cm) === editor.getStrToEol();
+    const isLastRow = getStrToEot(cm) === editor.getStrToEol();
 
 
-    if (isInTable) {
+    if (isInTable(cm)) {
       // at EOL in the table
       // at EOL in the table
-      if (mtu.isEndOfLine(cm)) {
+      if (isEndOfLine(cm)) {
         this.addRow(cm);
         this.addRow(cm);
       }
       }
       // last empty row
       // last empty row
-      else if (isLastRow && mtu.emptyLineOfTableRE.test(editor.getStrFromBol() + editor.getStrToEol())) {
+      else if (isLastRow && emptyLineOfTableRE.test(editor.getStrFromBol() + editor.getStrToEol())) {
         this.removeRow(editor);
         this.removeRow(editor);
       }
       }
       else {
       else {

+ 4 - 2
apps/app/_obsolete/src/components/PageEditorByHackmd.tsx

@@ -98,13 +98,15 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
     if (grantData == null) {
       return;
       return;
     }
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       grant: grantData.grant,
       pageTags: pageTags ?? [],
       pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      grantUserGroupIds: grantedGroups,
     };
     };
     return optionsToSave;
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);
   }, [grantData, isSlackEnabled, pageTags]);

+ 0 - 5
packages/core/scss/bootstrap/_override.scss → apps/app/_obsolete/src/styles/_override.scss

@@ -99,11 +99,6 @@
 //   }
 //   }
 // }
 // }
 
 
-// Badges
-.badge {
-  @extend .rounded-pill;
-}
-
 // //Modals
 // //Modals
 // .modal-open {
 // .modal-open {
 //   width: 100%;
 //   width: 100%;

+ 2 - 1
apps/app/docker/README.md

@@ -11,7 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
-* [`6.2.2`, `6.2`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.2/apps/app/docker/Dockerfile)
+* [`6.3.0`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.0/apps/app/docker/Dockerfile)
+* [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 
 
 
 

+ 13 - 3
apps/app/package.json

@@ -40,6 +40,7 @@
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -50,14 +51,18 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
+    "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "string-width": "5.0.0 or above exports only ESM.",
     "string-width": "5.0.0 or above exports only ESM.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   },
   "dependencies": {
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
-    "@aws-sdk/client-s3": "^3.58.0",
-    "@aws-sdk/s3-request-presigner": "^3.58.0",
+    "@aws-sdk/client-s3": "3.454.0",
+    "@aws-sdk/s3-request-presigner": "3.454.0",
+    "@azure/identity": "^3.3.2",
+    "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
@@ -72,9 +77,11 @@
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/slack": "link:../../packages/slack",
     "@growi/slack": "link:../../packages/slack",
+    "@keycloak/keycloak-admin-client": "^18.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
@@ -120,6 +127,7 @@
     "i18next-localstorage-backend": "^4.0.0",
     "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
+    "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
     "md5": "^2.2.1",
@@ -220,6 +228,7 @@
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
     "@types/url-join": "^4.0.2",
+    "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
     "bootstrap": "^5.3.1",
     "bootstrap": "^5.3.1",
@@ -238,7 +247,8 @@
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
-    "mongodb-memory-server": "^8.12.2",
+    "material-icons": "^1.11.3",
+    "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "null-loader": "^4.0.1",
     "plantuml-encoder": "^1.2.5",
     "plantuml-encoder": "^1.2.5",

+ 62 - 3
apps/app/public/static/locales/en_US/admin.json

@@ -9,6 +9,7 @@
   "specified_users": "Specified users",
   "specified_users": "Specified users",
   "only_me": "Only me",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
   "only_inside_the_group": "Only inside the group",
+  "optional": "Optional",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -47,8 +48,9 @@
     "anyone": "Anyone",
     "anyone": "Anyone",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "User homepage deletion",
       "user_homepage_deletion": "User homepage deletion",
-      "enable_user_homepage_deletion": "Complete deletion of user homepage, when user deletion",
-      "desc": "When deleting a user, the user homepage and its sub pages are also completely deleted."
+      "enable_user_homepage_deletion": "Enable user homepage deletion",
+      "enable_force_delete_user_homepage_on_user_deletion": "When you delete a user, the user's homepage and all its sub pages will be completely deleted",
+      "desc": "You will be able to delete a deleted user's homepage."
     },
     },
     "session": "Session",
     "session": "Session",
     "max_age": "Max age (msec)",
     "max_age": "Max age (msec)",
@@ -94,7 +96,6 @@
     "enable_link_sharing": "Enable link sharing",
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "all_share_links": "All share links",
     "configuration": " Configuration",
     "configuration": " Configuration",
-    "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -381,6 +382,13 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "Test connection to mail",
     "test_connection": "Test connection to mail",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
@@ -1053,6 +1061,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "updated_group_sync_settings": "Updated LDAP group sync settings",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+    },
+    "auto_generate_user_on_sync": "Auto Generate User on Sync",
+    "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
+  },
   "toaster": {
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 1 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -71,7 +71,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "Create New Page",
     "new_page": "Create New Page",
     "todays": {
     "todays": {
-      "desc": "Create today's ...",
+      "desc": "Create today's memo",
       "memo": "memo"
       "memo": "memo"
     },
     },
     "template": {
     "template": {

+ 16 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -249,6 +249,21 @@
       "page_create": "Subscribe to the page when you create it."
       "page_create": "Subscribe to the page when you create it."
     }
     }
   },
   },
+  "ui_settings": {
+    "ui_settings": "UI Settings",
+    "side_bar_mode": {
+      "settings": "Sidebar mode settings",
+      "side_bar_mode_setting": "Set the sidebar mode",
+      "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
+    }
+  },
+  "color_mode_settings": {
+    "light": "Light",
+    "dark": "Dark",
+    "system": "System",
+    "settings": "Color mode settings",
+    "description": "Select whether to display in light mode, dark mode, or a system-specific display.<br>Only supported themes can be switched."
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "Editor Settings"
     "editor_settings": "Editor Settings"
   },
   },
@@ -464,7 +479,7 @@
       "label": "Template for children",
       "label": "Template for children",
       "desc": "Applies only to the same level pages which the template exists"
       "desc": "Applies only to the same level pages which the template exists"
     },
     },
-    "decendants": {
+    "descendants": {
       "label": "Template for descendants",
       "label": "Template for descendants",
       "desc": "Applies to all decendant pages"
       "desc": "Applies to all decendant pages"
     }
     }

+ 63 - 3
apps/app/public/static/locales/ja_JP/admin.json

@@ -10,6 +10,7 @@
   "Created": "作成日",
   "Created": "作成日",
   "Edit": "編集",
   "Edit": "編集",
   "Description": "説明",
   "Description": "説明",
+  "Execute": "実行",
   "last_login": "最終ログイン",
   "last_login": "最終ログイン",
   "wiki_management_homepage": "Wiki管理トップ",
   "wiki_management_homepage": "Wiki管理トップ",
   "public": "公開",
   "public": "公開",
@@ -17,6 +18,7 @@
   "specified_users": "特定ユーザーのみ",
   "specified_users": "特定ユーザーのみ",
   "only_me": "自分のみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
   "only_inside_the_group": "特定グループのみ",
+  "optional": "オプション",
   "security_settings": {
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -55,8 +57,9 @@
     "anyone": "誰でも可能",
     "anyone": "誰でも可能",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "ユーザーホームページの削除",
       "user_homepage_deletion": "ユーザーホームページの削除",
-      "enable_user_homepage_deletion": "ユーザー削除時にユーザーホームページを完全削除する",
-      "desc": "ユーザーを削除する際に、ユーザーホームページとその配下のページも完全削除されます。"
+      "enable_user_homepage_deletion": "ユーザーホームページの削除を有効化",
+      "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
+      "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
     },
     "session": "セッション",
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",
@@ -102,7 +105,6 @@
     "enable_link_sharing": "リンクのシェアを許可",
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
     "configuration": "設定",
-    "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
@@ -388,6 +390,13 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "テナントID",
+    "azure_client_id": "クライアントID",
+    "azure_client_secret": "クライアントシークレット",
+    "azure_storage_account_name": "ストレージアカウント名",
+    "azure_storage_container_name": "コンテナ名",
+    "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",
     "test_connection": "接続テスト",
@@ -1062,6 +1071,57 @@
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   },
+  "external_user_group": {
+    "management": "外部グループ管理",
+    "execute_sync": "同期実行",
+    "sync": "同期",
+    "invalid_sync_settings": "同期設定に誤りがあります",
+    "update_sync_settings_failed": "同期設定の更新が失敗しました",
+    "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
+    "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
+    "sync_being_executed": "自身または他のユーザが実行した外部グループ同期が終了するまで次の実行ができません",
+    "sync_succeeded": "外部グループ同期に成功しました",
+    "sync_failed": "外部グループ同期に失敗しました",
+    "provider": "プロバイダ",
+    "confirmation_before_sync": "同期実行前の確認",
+    "execution_time_warning": "同期するグループやユーザが多い場合、同期が完了するまでに時間を要します",
+    "parallel_sync_forbidden": "同期実行中は、他の外部グループ同期は実行できません",
+    "ldap": {
+      "group_sync_settings": "LDAP グループ同期設定",
+      "group_search_base_DN": "グループ検索ベース DN",
+      "group_search_base_dn_detail": "グループ検索をするベース DN。設定されていない場合、セキュリティ設定で設定されたものが利用されます。",
+      "membership_attribute": "所属メンバーを表す LDAP 属性",
+      "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
+      "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",
+      "membership_attribute_type_detail": "グループの所属メンバーを表すグループオブジェクトの属性値は DN か UID か",
+      "child_group_attribute": "子グループを表す LDAP 属性",
+      "child_group_attribute_detail": "グループに所属する子グループを表すグループオブジェクトの属性。属性値は DN である必要があります。",
+      "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
+      "name_mapper_detail": "グループの「名前」として読み込む属性",
+      "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
+      "password": "パスワード",
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
+      "auth_not_set": "同期実行前にセキュリティ設定で LDAP 認証を有効にし、設定してください"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak グループ同期設定",
+      "host": "Host",
+      "host_detail": "Keycloak ホスト URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "同期対象のグループがある realm",
+      "group_sync_client_realm": "Admin API にリクエストするための client がある realm",
+      "group_sync_client_realm_detail": "Keycloak admin API にリクエストするための認証に使う client がある realm",
+      "group_sync_client_id": "Client の ID",
+      "group_sync_client_id_detail": "Keycloak admin API にリクエストするための認証に使う client の Client ID",
+      "group_sync_client_secret": "Client の Secret",
+      "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
+      "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
+      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
+      "auth_not_set": "同期実行前にセキュリティ設定で Keycloak を使った OIDC または SAML 認証を有効にし、設定してください"
+    },
+    "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
+    "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
+  },
   "toaster": {
   "toaster": {
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",

+ 2 - 2
apps/app/public/static/locales/ja_JP/commons.json

@@ -73,13 +73,13 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "新規ページ作成",
     "new_page": "新規ページ作成",
     "todays": {
     "todays": {
-      "desc": "今日の◯◯を作成",
+      "desc": "今日のメモを作成",
       "memo": "メモ"
       "memo": "メモ"
     },
     },
     "template": {
     "template": {
       "desc": "テンプレートページの作成/編集",
       "desc": "テンプレートページの作成/編集",
       "children": "同一階層テンプレート",
       "children": "同一階層テンプレート",
-      "decendants": "下位層テンプレート"
+      "descendants": "下位層テンプレート"
     }
     }
   },
   },
 
 

+ 16 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -250,6 +250,21 @@
       "page_create": "ページを作成した時にそのページをサブスクライブします。"
       "page_create": "ページを作成した時にそのページをサブスクライブします。"
     }
     }
   },
   },
+  "ui_settings": {
+    "ui_settings": "UI設定",
+    "side_bar_mode": {
+      "settings": "サイドバーモードの設定",
+      "side_bar_mode_setting": "サイドバーのモードを設定する",
+      "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
+    }
+  },
+  "color_mode_settings": {
+    "light": "ライト",
+    "dark": "ダーク",
+    "system": "システム",
+    "settings": "カラーモードの設定",
+    "description": "ライトモードかダークモード、もしくはシステム合わせた表示をするか選択します。<br>対応したテーマのみ切り替えることができます。"
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "エディター設定",
     "editor_settings": "エディター設定",
     "common_settings": {
     "common_settings": {
@@ -497,7 +512,7 @@
       "label": "同一階層テンプレート",
       "label": "同一階層テンプレート",
       "desc": "テンプレートページが存在する階層にのみ適用されます"
       "desc": "テンプレートページが存在する階層にのみ適用されます"
     },
     },
-    "decendants": {
+    "descendants": {
       "label": "下位層テンプレート",
       "label": "下位層テンプレート",
       "desc": "テンプレートページが存在する下位層のすべてのページに適用されます"
       "desc": "テンプレートページが存在する下位層のすべてのページに適用されます"
     }
     }

+ 64 - 4
apps/app/public/static/locales/zh_CN/admin.json

@@ -10,6 +10,7 @@
   "Page": "页面",
   "Page": "页面",
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
+  "Execute": "执行",
   "last_login": "上次登录",
   "last_login": "上次登录",
   "wiki_management_homepage": "Wiki管理首页",
   "wiki_management_homepage": "Wiki管理首页",
   "public": "公共",
   "public": "公共",
@@ -17,6 +18,7 @@
   "specified_users": "仅指定用户",
   "specified_users": "仅指定用户",
   "only_me": "只有我",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
   "only_inside_the_group": "仅组内",
+  "optional": "可选的",
   "security_settings": {
   "security_settings": {
     "security_settings": "安全设置",
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
     "scope_of_page_disclosure": "页面公开范围",
@@ -54,9 +56,10 @@
 		"admin_and_author": "管理员|作者",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
 		"anyone": "任何人",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
-      "user_homepage_deletion": "删除用户页面",
-      "enable_user_homepage_deletion": "用户删除时,完全删除用户主页",
-      "desc": "删除用户时,用户主页及其下属页面也会被完全删除。"
+      "user_homepage_deletion": "删除用户主页",
+      "enable_user_homepage_deletion": "启用用户主页删除功能",
+      "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
+      "desc": "您可以删除已删除用户的主页。"
     },
     },
     "session": "会议",
     "session": "会议",
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",
@@ -102,7 +105,6 @@
     "enable_link_sharing": "启用链接共享",
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
     "all_share_links": "所有共享链接",
 		"configuration": " 配置",
 		"configuration": " 配置",
-		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -388,6 +390,13 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
@@ -1061,6 +1070,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "updated_group_sync_settings": "Updated LDAP group sync settings",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+    },
+    "auto_generate_user_on_sync": "Auto Generate User on Sync",
+    "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
+  },
   "toaster": {
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 1 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -74,7 +74,7 @@
   "create_page_dropdown": {
   "create_page_dropdown": {
     "new_page": "新页面",
     "new_page": "新页面",
     "todays": {
     "todays": {
-      "desc": "Create today's ...",
+      "desc": "Create today's memo",
       "memo": "memo"
       "memo": "memo"
     },
     },
     "template": {
     "template": {

+ 16 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -240,6 +240,21 @@
       "page_create": "创建页面时订阅页面。"
       "page_create": "创建页面时订阅页面。"
     }
     }
   },
   },
+  "ui_settings": {
+    "ui_settings": "用户界面设置",
+    "side_bar_mode": {
+      "settings": "侧边栏模式设置",
+      "side_bar_mode_setting": "设置侧边栏模式",
+      "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
+    }
+  },
+  "color_mode_settings": {
+    "light": "灯光",
+    "dark": "暗处",
+    "system": "系统",
+    "settings": "色彩模式设置",
+    "description": "选择是以浅色模式、深色模式还是系统特定的显示方式显示。<br>只能切换支持的主题。"
+  },
   "editor_settings": {
   "editor_settings": {
     "editor_settings": "编辑器设置"
     "editor_settings": "编辑器设置"
   },
   },
@@ -451,7 +466,7 @@
 			"label": "子模板",
 			"label": "子模板",
 			"desc": "仅应用于模板存在的同一级别页"
 			"desc": "仅应用于模板存在的同一级别页"
 		},
 		},
-		"decendants": {
+		"descendants": {
 			"label": "子代模板",
 			"label": "子代模板",
 			"desc": "适用于所有分散页"
 			"desc": "适用于所有分散页"
 		}
 		}

+ 1 - 1
apps/app/resource/search/mappings-es7.json

@@ -96,7 +96,7 @@
       "granted_users": {
       "granted_users": {
         "type": "keyword"
         "type": "keyword"
       },
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
         "type": "keyword"
       },
       },
       "created_at": {
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8-for-ci.json

@@ -99,7 +99,7 @@
       "granted_users": {
       "granted_users": {
         "type": "keyword"
         "type": "keyword"
       },
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
         "type": "keyword"
       },
       },
       "created_at": {
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8.json

@@ -96,7 +96,7 @@
       "granted_users": {
       "granted_users": {
         "type": "keyword"
         "type": "keyword"
       },
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
         "type": "keyword"
       },
       },
       "created_at": {
       "created_at": {

+ 78 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -60,6 +60,19 @@ export default class AdminAppContainer extends Container {
       s3SecretAccessKey: '',
       s3SecretAccessKey: '',
       s3ReferenceFileWithRelayMode: false,
       s3ReferenceFileWithRelayMode: false,
 
 
+      azureReferenceFileWithRelayMode: false,
+      azureUseOnlyEnvVars: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      envAzureTenantId: '',
+      envAzureClientId: '',
+      envAzureClientSecret: '',
+      envAzureStorageAccountName: '',
+      envAzureStorageContainerName: '',
+
       isEnabledPlugins: true,
       isEnabledPlugins: true,
 
 
       isMaintenanceMode: false,
       isMaintenanceMode: false,
@@ -120,6 +133,20 @@ export default class AdminAppContainer extends Container {
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+      azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars,
+      azureTenantId: appSettingsParams.azureTenantId,
+      azureClientId: appSettingsParams.azureClientId,
+      azureClientSecret: appSettingsParams.azureClientSecret,
+      azureStorageAccountName: appSettingsParams.azureStorageAccountName,
+      azureStorageContainerName: appSettingsParams.azureStorageContainerName,
+      azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode,
+      envAzureTenantId: appSettingsParams.envAzureTenantId,
+      envAzureClientId: appSettingsParams.envAzureClientId,
+      envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+      envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+      envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
     });
@@ -316,6 +343,48 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsReferenceFileWithRelayMode });
     this.setState({ gcsReferenceFileWithRelayMode });
   }
   }
 
 
+  /**
+   * Change azureReferenceFileWithRelayMode
+   */
+  changeAzureReferenceFileWithRelayMode(azureReferenceFileWithRelayMode) {
+    this.setState({ azureReferenceFileWithRelayMode });
+  }
+
+  /**
+   * Change azureTenantId
+   */
+  changeAzureTenantId(azureTenantId) {
+    this.setState({ azureTenantId });
+  }
+
+  /**
+   * Change azureClientId
+   */
+  changeAzureClientId(azureClientId) {
+    this.setState({ azureClientId });
+  }
+
+  /**
+   * Change azureClientSecret
+   */
+  changeAzureClientSecret(azureClientSecret) {
+    this.setState({ azureClientSecret });
+  }
+
+  /**
+   * Change azureStorageAccountName
+   */
+  changeAzureStorageAccountName(azureStorageAccountName) {
+    this.setState({ azureStorageAccountName });
+  }
+
+  /**
+   * Change azureStorageContainerName
+   */
+  changeAzureStorageContainerName(azureStorageContainerName) {
+    this.setState({ azureStorageContainerName });
+  }
+
   /**
   /**
    * Update app setting
    * Update app setting
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
@@ -430,6 +499,15 @@ export default class AdminAppContainer extends Container {
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
     }
 
 
+    if (fileUploadType === 'azure') {
+      requestParams.azureTenantId = this.state.azureTenantId;
+      requestParams.azureClientId = this.state.azureClientId;
+      requestParams.azureClientSecret = this.state.azureClientSecret;
+      requestParams.azureStorageAccountName = this.state.azureStorageAccountName;
+      requestParams.azureStorageContainerName = this.state.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = this.state.azureReferenceFileWithRelayMode;
+    }
+
     const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const { responseParams } = response.data;
     const { responseParams } = response.data;
     return this.setState(responseParams);
     return this.setState(responseParams);

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -39,6 +39,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
       isUsersHomepageDeletionEnabled: false,
+      isForceDeleteUserHomepageOnUserDeletion: false,
       isLocalEnabled: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
       isSamlEnabled: false,
@@ -75,6 +76,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       sessionMaxAge: generalSetting.sessionMaxAge,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -202,6 +204,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
   }
   }
 
 
+  /**
+   * Switch isForceDeleteUserHomepageOnUserDeletion
+   */
+  switchIsForceDeleteUserHomepageOnUserDeletion() {
+    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+  }
+
   /**
   /**
    * Update restrictGuestMode
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
    * @memberOf AdminGeneralSecuritySContainer
@@ -219,6 +228,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
     };
     };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 20 - 11
apps/app/src/client/services/layout.ts

@@ -9,20 +9,29 @@ export const useEditorModeClassName = (): string => {
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
   return `${getClassNamesByEditorMode().join(' ') ?? ''}`;
 };
 };
 
 
-export const useLayoutFluidClassName = (expandContentWidth?: boolean | null): string => {
+const useDetermineExpandContent = (expandContentWidth?: boolean | null): boolean => {
   const { data: dataIsContainerFluid } = useIsContainerFluid();
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 
 
   const isContainerFluidDefault = dataIsContainerFluid;
   const isContainerFluidDefault = dataIsContainerFluid;
-  const isContainerFluid = expandContentWidth ?? isContainerFluidDefault;
-
-  return isContainerFluid ? 'growi-layout-fluid' : '';
+  return expandContentWidth ?? isContainerFluidDefault ?? false;
 };
 };
 
 
-export const useLayoutFluidClassNameByPage = (initialPage?: IPage): string => {
-  const page = initialPage;
-  const expandContentWidth = page == null || !('expandContentWidth' in page)
-    ? null
-    : page.expandContentWidth;
-
-  return useLayoutFluidClassName(expandContentWidth);
+export const useShouldExpandContent = (data?: IPage | boolean | null): boolean => {
+  const expandContentWidth = (() => {
+    // when data is null
+    if (data == null) {
+      return null;
+    }
+    // when data is boolean
+    if (data === true || data === false) {
+      return data;
+    }
+    // when IPage does not have expandContentWidth
+    if (!('expandContentWidth' in data)) {
+      return null;
+    }
+    return data.expandContentWidth;
+  })();
+
+  return useDetermineExpandContent(expandContentWidth);
 };
 };

+ 11 - 6
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -30,27 +30,32 @@ export const useDrawioModalLauncherForView = (opts?: {
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
 
 
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
 
 
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openDrawioModal } = useDrawioModal();
 
 
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
 
 
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+    if (currentPage == null || shareLinkId != null) {
       return;
       return;
     }
     }
 
 
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
     const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       isSlackEnabled: false,
       slackChannels: '',
       slackChannels: '',
       grant: currentPage.grant,
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
+      // grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
     };
 
 
     try {
     try {
@@ -67,7 +72,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
       opts?.onSaveError?.(error);
     }
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
 
 
 
 
   // set handler to open DrawioModal
   // set handler to open DrawioModal

+ 15 - 10
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -4,7 +4,7 @@ import EventEmitter from 'events';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
-import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/PageEditor/markdown-table-util-for-view';
 import type { OptionsToSave } from '~/interfaces/page-operation';
 import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useShareLinkId } from '~/stores/context';
 import { useHandsontableModal } from '~/stores/modal';
 import { useHandsontableModal } from '~/stores/modal';
@@ -29,27 +29,32 @@ export const useHandsontableModalLauncherForView = (opts?: {
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
 
 
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
 
 
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openHandsontableModal } = useHandsontableModal();
 
 
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
 
 
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+    if (currentPage == null || shareLinkId != null) {
       return;
       return;
     }
     }
 
 
     const currentMarkdown = currentPage.revision.body;
     const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
 
 
     const optionsToSave: OptionsToSave = {
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       isSlackEnabled: false,
       slackChannels: '',
       slackChannels: '',
       grant: currentPage.grant,
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
+      // grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
     };
 
 
     try {
     try {
@@ -66,7 +71,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
       opts?.onSaveError?.(error);
     }
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
 
 
 
 
   // set handler to open HandsonTableModal
   // set handler to open HandsonTableModal
@@ -77,8 +82,8 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
 
     const handler = (bol: number, eol: number) => {
     const handler = (bol: number, eol: number) => {
       const markdown = currentPage.revision.body;
       const markdown = currentPage.revision.body;
-      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
-      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
+      const currentMarkdownTable = getMarkdownTableFromLine(markdown, bol, eol);
+      openHandsontableModal(currentMarkdownTable, false, table => saveByHandsontableModal(table, bol, eol));
     };
     };
     globalEmitter.on('launchHandsonTableModal', handler);
     globalEmitter.on('launchHandsonTableModal', handler);
 
 

+ 49 - 0
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -0,0 +1,49 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { LabelType } from '~/interfaces/template';
+
+export const useOnTemplateButtonClicked = (
+    currentPagePath?: string,
+): {
+  onClickHandler: (label: LabelType) => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async(label: LabelType) => {
+    try {
+      setIsPageCreating(true);
+
+      const path = currentPagePath == null || currentPagePath === '/'
+        ? `/${label}`
+        : `${currentPagePath}/${label}`;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+      // grant: currentPage?.grant || 1,
+      // grantUserGroupId: currentPage?.grantedGroup?._id,
+      };
+
+      const res = await exist(JSON.stringify([path]));
+      if (!res.pages[path]) {
+        await createPage(path, '', params);
+      }
+
+      router.push(`${path}#edit`);
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPagePath, router]);
+
+  return { onClickHandler, isPageCreating };
+};

+ 6 - 0
apps/app/src/client/services/user-ui-settings.ts

@@ -25,3 +25,9 @@ export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
 
 
   _putUserUISettingsInBulkDebounced();
   _putUserUISettingsInBulkDebounced();
 };
 };
+
+export const updateUserUISettings = async(settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', { settings });
+
+  return result;
+};

+ 211 - 0
apps/app/src/components/Admin/App/AzureSetting.tsx

@@ -0,0 +1,211 @@
+import { useTranslation } from 'next-i18next';
+
+import MaskedInput from './MaskedInput';
+
+export type AzureSettingMoleculeProps = {
+  azureReferenceFileWithRelayMode
+  azureUseOnlyEnvVars
+  azureTenantId
+  azureClientId
+  azureClientSecret
+  azureStorageAccountName
+  azureStorageContainerName
+  envAzureTenantId?
+  envAzureClientId?
+  envAzureClientSecret?
+  envAzureStorageAccountName?
+  envAzureStorageContainerName?
+  onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeAzureTenantId: (val: string) => void
+  onChangeAzureClientId: (val: string) => void
+  onChangeAzureClientSecret: (val: string) => void
+  onChangeAzureStorageAccountName: (val: string) => void
+  onChangeAzureStorageContainerName: (val: string) => void
+};
+
+export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    azureReferenceFileWithRelayMode,
+    azureUseOnlyEnvVars,
+    azureTenantId,
+    azureClientId,
+    azureClientSecret,
+    azureStorageAccountName,
+    envAzureTenantId,
+    envAzureClientId,
+    envAzureClientSecret,
+    envAzureStorageAccountName,
+    azureStorageContainerName,
+    envAzureStorageContainerName,
+  } = props;
+
+  return (
+    <>
+
+      <div className="row form-group my-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_delivery_method')}
+        </label>
+
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle"
+              type="button"
+              id="ddAzureReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddAzureReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+              >
+                { t('admin:app_setting.file_delivery_method_redirect')}
+              </button>
+            </div>
+
+            <p className="form-text text-muted small">
+              {t('admin:app_setting.file_delivery_method_redirect_info')}
+              <br />
+              {t('admin:app_setting.file_delivery_method_relay_info')}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {azureUseOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+        />
+      )}
+      <table className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}>
+        <colgroup>
+          <col className="item-name" />
+          <col className="from-db" />
+          <col className="from-env-vars" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Database</th>
+            <th>Environment variables</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th>{t('admin:app_setting.azure_tenant_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureTenantId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureTenantId}
+                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureTenantId" defaultValue={envAzureTenantId || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_TENANT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientId}
+                onChange={e => props?.onChangeAzureClientId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientId" defaultValue={envAzureClientId || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_secret')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientSecret"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientSecret}
+                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientSecret" defaultValue={envAzureClientSecret || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_CLIENT_SECRET' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_account_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageAccountName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageAccountName}
+                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageAccountName || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_ACCOUNT_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_container_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageContainerName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageContainerName}
+                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageContainerName || ''} readOnly tabIndex={-1} />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'AZURE_STORAGE_CONTAINER_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </>
+  );
+};

+ 75 - 2
apps/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -10,17 +10,20 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 import { AwsSettingMolecule } from './AwsSetting';
 import { AwsSettingMolecule } from './AwsSetting';
 import type { AwsSettingMoleculeProps } from './AwsSetting';
 import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { AzureSettingMolecule } from './AzureSetting';
+import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 
 
-const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
+
+const fileUploadTypes = ['aws', 'gcs', 'azure', 'gridfs', 'local'] as const;
 
 
 type FileUploadSettingMoleculeProps = {
 type FileUploadSettingMoleculeProps = {
   fileUploadType: string
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
   isFixedFileUploadByEnvVar: boolean
   envFileUploadType?: string
   envFileUploadType?: string
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
 
 
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
@@ -102,6 +105,28 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
           onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
           onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
         />
         />
       )}
       )}
+      {props.fileUploadType === 'azure' && (
+        <AzureSettingMolecule
+          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
+          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
+          azureTenantId={props.azureTenantId}
+          azureClientId={props.azureClientId}
+          azureClientSecret={props.azureClientSecret}
+          azureStorageAccountName={props.azureStorageAccountName}
+          azureStorageContainerName={props.azureStorageContainerName}
+          envAzureStorageAccountName={props.envAzureStorageAccountName}
+          envAzureStorageContainerName={props.envAzureStorageContainerName}
+          envAzureTenantId={props.envAzureTenantId}
+          envAzureClientId={props.envAzureClientId}
+          envAzureClientSecret={props.envAzureClientSecret}
+          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
+          onChangeAzureTenantId={props.onChangeAzureTenantId}
+          onChangeAzureClientId={props.onChangeAzureClientId}
+          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
+          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
+          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+        />
+      )}
     </>
     </>
   );
   );
 });
 });
@@ -124,6 +149,11 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
     gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
+    azureTenantId, azureClientId, azureClientSecret,
+    azureStorageAccountName, azureStorageContainerName,
+    envAzureTenantId, envAzureClientId, envAzureClientSecret,
+    envAzureStorageAccountName, envAzureStorageContainerName,
   } = adminAppContainer.state;
   } = adminAppContainer.state;
 
 
   const submitHandler = useCallback(async() => {
   const submitHandler = useCallback(async() => {
@@ -182,6 +212,31 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     adminAppContainer.changeGcsUploadNamespace(val);
     adminAppContainer.changeGcsUploadNamespace(val);
   }, [adminAppContainer]);
   }, [adminAppContainer]);
 
 
+  // Azure
+  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureTenantId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientSecret(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageAccountName(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageContainerName(val);
+  }, [adminAppContainer]);
+
   return (
   return (
     <>
     <>
       <FileUploadSettingMolecule
       <FileUploadSettingMolecule
@@ -213,6 +268,24 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
         onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
+        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
+        azureTenantId={azureTenantId}
+        azureClientId={azureClientId}
+        azureClientSecret={azureClientSecret}
+        azureStorageAccountName={azureStorageAccountName}
+        azureStorageContainerName={azureStorageContainerName}
+        envAzureTenantId={envAzureTenantId}
+        envAzureClientId={envAzureClientId}
+        envAzureClientSecret={envAzureClientSecret}
+        envAzureStorageAccountName={envAzureStorageAccountName}
+        envAzureStorageContainerName={envAzureStorageContainerName}
+        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
+        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
+        onChangeAzureClientId={onChangeAzureClientIdHandler}
+        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
+        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
+        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
       />
       />
       <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
       <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>
     </>

+ 12 - 0
apps/app/src/components/Admin/App/MaskedInput.module.scss

@@ -0,0 +1,12 @@
+.MaskedInput {
+  position: relative;
+  display: flex;
+}
+
+.PasswordReveal {
+  position: absolute;
+  top: 0rem;
+  right: 0.5rem;
+  left: auto;
+  font-size: 1.4rem;
+}

+ 43 - 0
apps/app/src/components/Admin/App/MaskedInput.tsx

@@ -0,0 +1,43 @@
+import { useState } from 'react';
+
+import styles from './MaskedInput.module.scss';
+
+type Props = {
+  name: string
+  readOnly: boolean
+  defaultValue: string
+  onChange?: (e: any) => void
+  tabIndex?: number | undefined
+};
+
+export default function MaskedInput(props: Props): JSX.Element {
+  const [passwordShown, setPasswordShown] = useState(false);
+  const togglePassword = () => {
+    setPasswordShown(!passwordShown);
+  };
+
+  const {
+    name, readOnly, defaultValue, onChange, tabIndex,
+  } = props;
+
+  return (
+    <div className={styles.MaskedInput}>
+      <input
+        className="form-control"
+        type={passwordShown ? 'text' : 'password'}
+        name={name}
+        readOnly={readOnly}
+        defaultValue={defaultValue}
+        onChange={onChange}
+        tabIndex={tabIndex}
+      />
+      <span onClick={togglePassword} className={styles.PasswordReveal}>
+        {passwordShown ? (
+          <i className="fa fa-eye" />
+        ) : (
+          <i className="fa fa-eye-slash" />
+        )}
+      </span>
+    </div>
+  );
+}

+ 3 - 2
apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { SocketEventName } from '~/interfaces/websocket';
 import { useAdminSocket } from '~/stores/socket-io';
 import { useAdminSocket } from '~/stores/socket-io';
 
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
@@ -27,7 +28,7 @@ class RebuildIndexControls extends React.Component {
     const { socket } = this.props;
     const { socket } = this.props;
 
 
     if (socket != null) {
     if (socket != null) {
-      socket.on('addPageProgress', (data) => {
+      socket.on(SocketEventName.AddPageProgress, (data) => {
         this.setState({
         this.setState({
           total: data.totalCount,
           total: data.totalCount,
           current: data.count,
           current: data.count,
@@ -35,7 +36,7 @@ class RebuildIndexControls extends React.Component {
         });
         });
       });
       });
 
 
-      socket.on('finishAddPage', (data) => {
+      socket.on(SocketEventName.FinishAddPage, (data) => {
         this.setState({
         this.setState({
           total: data.totalCount,
           total: data.totalCount,
           current: data.count,
           current: data.count,

+ 2 - 2
apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -235,7 +235,7 @@ class LdapSecuritySettingContents extends React.Component {
             </div>
             </div>
 
 
             <h3 className="alert-anchor border-bottom">
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
             </h3>
 
 
             <div className="row">
             <div className="row">
@@ -325,7 +325,7 @@ class LdapSecuritySettingContents extends React.Component {
 
 
 
 
             <h3 className="alert-anchor border-bottom">
             <h3 className="alert-anchor border-bottom">
-              {t('security_settings.ldap.group_search_filter')} ({t('security_settings.optional')})
+              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
             </h3>
             </h3>
 
 
             <div className="row">
             <div className="row">

+ 1 - 1
apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -296,7 +296,7 @@ class OidcSecurityManagementContents extends React.Component {
             </div>
             </div>
 
 
             <h3 className="alert-anchor border-bottom">
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
             </h3>
 
 
             <div className="row mb-5">
             <div className="row mb-5">

+ 14 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -468,8 +468,21 @@ class SecuritySetting extends React.Component {
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
               </label>
               </label>
             </div>
             </div>
+            <div className="custom-control custom-switch custom-checkbox-success mt-2">
+              <input
+                type="checkbox"
+                className="form-check-input"
+                id="is-force-delete-user-homepage-on-user-deletion"
+                checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
+                onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
+                disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+              />
+              <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
+                {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
+              </label>
+            </div>
             <p
             <p
-              className="form-text text-muted small"
+              className="form-text text-muted small mt-2"
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
             />
             />
           </div>
           </div>

+ 23 - 11
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,4 +1,6 @@
-import React, { FC, useCallback, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 
 
 import type { IUserGroupHasId } from '@growi/core';
 import type { IUserGroupHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
@@ -10,6 +12,7 @@ type Props = {
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   submitButtonLabel: string;
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
+  isExternalGroup?: boolean
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,14 +20,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit, isExternalGroup = false,
   } = props;
   } = props;
   /*
   /*
    * State
    * State
    */
    */
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -36,12 +39,16 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
     setDescription(e.target.value);
   }, []);
   }, []);
 
 
-  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+  const onChangeParentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
     if (userGroup._id !== selectedParent?._id) {
     if (userGroup._id !== selectedParent?._id) {
       setSelectedParent(userGroup);
       setSelectedParent(userGroup);
     }
     }
   }, [selectedParent, setSelectedParent]);
   }, [selectedParent, setSelectedParent]);
 
 
+  useEffect(() => {
+    setSelectedParent(parentUserGroup);
+  }, [parentUserGroup]);
+
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
 
   const isChildUserGroup = parentUserGroup !== undefined;
   const isChildUserGroup = parentUserGroup !== undefined;
@@ -60,7 +67,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
 
       <fieldset>
       <fieldset>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
-
+        {isExternalGroup
+        && (
+          <div className="mb-3">
+            <small className="text-muted">{t('external_user_group.only_description_edit_allowed')}</small>
+          </div>
+        )}
         {
         {
           userGroup?.createdAt != null && (
           userGroup?.createdAt != null && (
             <div className="row">
             <div className="row">
@@ -74,7 +86,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
           <label htmlFor="name" className="col-md-2 col-form-label">
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('user_group_management.group_name')}
             {t('user_group_management.group_name')}
           </label>
           </label>
-          <div className="col-md-4">
+          <div className="col-md-4 my-auto">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
@@ -83,6 +95,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               value={currentName}
               value={currentName}
               onChange={onChangeNameHandler}
               onChange={onChangeNameHandler}
               required
               required
+              disabled={isExternalGroup}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -104,10 +117,9 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
             <button
             <button
               type="button"
               type="button"
               id="dropdownMenuButton"
               id="dropdownMenuButton"
-              data-bs-toggle="dropdown"
-              className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
-              `}
+              data-toggle="dropdown"
+              className="btn btn-outline-secondary dropdown-toggle mb-3"
+              disabled={isExternalGroup || !isSelectableParentUserGroups}
             >
             >
               {selectedParent?.name ?? messageAtReleaseParentGroup}
               {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             </button>
@@ -121,7 +133,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                           key={userGroup._id}
                           key={userGroup._id}
                           type="button"
                           type="button"
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
-                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                          onClick={() => onChangeParentButtonHandler(userGroup)}
                         >
                         >
                           {userGroup.name}
                           {userGroup.name}
                         </button>
                         </button>

+ 10 - 1
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -14,6 +14,7 @@ type Props = {
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   isShow?: boolean
   onHide?: () => Promise<void> | void
   onHide?: () => Promise<void> | void
+  isExternalGroup?: boolean
 };
 };
 
 
 export const UserGroupModal: FC<Props> = (props: Props) => {
 export const UserGroupModal: FC<Props> = (props: Props) => {
@@ -21,7 +22,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
   } = props;
   } = props;
 
 
   /*
   /*
@@ -86,6 +87,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               value={currentName}
               value={currentName}
               onChange={onChangeNameHandler}
               onChange={onChangeNameHandler}
               required
               required
+              disabled={isExternalGroup}
             />
             />
           </div>
           </div>
 
 
@@ -94,6 +96,13 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               {t('Description')}
               {t('Description')}
             </label>
             </label>
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            {isExternalGroup && (
+              <p className="form-text text-muted">
+                <small>
+                  {t('external_user_group.description_form_detail')}
+                </small>
+              </p>
+            )}
           </div>
           </div>
 
 
           {/* TODO 90732: Add a drop-down to show selectable parents */}
           {/* TODO 90732: Add a drop-down to show selectable parents */}

+ 6 - 0
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
 
 
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
 
+
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
@@ -146,6 +148,7 @@ export const UserGroupPage: FC = () => {
 
 
   return (
   return (
     <div data-testid="admin-user-groups">
     <div data-testid="admin-user-groups">
+      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
       {
       {
         isAclEnabled ? (
         isAclEnabled ? (
           <div className="mb-3">
           <div className="mb-3">
@@ -190,6 +193,9 @@ export const UserGroupPage: FC = () => {
         isShow={isDeleteModalShown}
         isShow={isDeleteModalShown}
         onHide={hideDeleteModal}
         onHide={hideDeleteModal}
       />
       />
+      <div className="mt-5">
+        <ExternalGroupManagement />
+      </div>
     </div>
     </div>
   );
   );
 };
 };

+ 42 - 24
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -7,6 +7,8 @@ import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+
 
 
 type Props = {
 type Props = {
   headerLabel?: string,
   headerLabel?: string,
@@ -17,6 +19,7 @@ type Props = {
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  isExternalGroup?: boolean
 };
 };
 
 
 /*
 /*
@@ -53,27 +56,37 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 };
 
 
 
 
-export const UserGroupTable: FC<Props> = (props: Props) => {
+export const UserGroupTable: FC<Props> = ({
+  headerLabel,
+  userGroups,
+  userGroupRelations,
+  childUserGroups,
+  isAclEnabled,
+  onEdit,
+  onRemove,
+  onDelete,
+  isExternalGroup = false,
+}: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   /*
   /*
    * State
    * State
    */
    */
-  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
-  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(childUserGroups));
 
 
   /*
   /*
    * Function
    * Function
    */
    */
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
     const groupId = e.target.getAttribute('data-user-group-id');
-    return props.userGroups.find((group) => {
+    return userGroups.find((group) => {
       return group._id === groupId;
       return group._id === groupId;
     });
     });
   };
   };
 
 
   const onClickEdit = async(e) => {
   const onClickEdit = async(e) => {
-    if (props.onEdit == null) {
+    if (onEdit == null) {
       return;
       return;
     }
     }
 
 
@@ -82,11 +95,11 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
       return;
     }
     }
 
 
-    props.onEdit(userGroup);
+    onEdit(userGroup);
   };
   };
 
 
   const onClickRemove = async(e) => {
   const onClickRemove = async(e) => {
-    if (props.onRemove == null) {
+    if (onRemove == null) {
       return;
       return;
     }
     }
 
 
@@ -96,7 +109,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
     }
     }
 
 
     try {
     try {
-      await props.onRemove(userGroup);
+      await onRemove(userGroup);
       userGroup.parent = null;
       userGroup.parent = null;
     }
     }
     catch {
     catch {
@@ -105,7 +118,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   };
   };
 
 
   const onClickDelete = (e) => { // no preventDefault
   const onClickDelete = (e) => { // no preventDefault
-    if (props.onDelete == null) {
+    if (onDelete == null) {
       return;
       return;
     }
     }
 
 
@@ -114,24 +127,25 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
       return;
     }
     }
 
 
-    props.onDelete(userGroup);
+    onDelete(userGroup);
   };
   };
 
 
   /*
   /*
    * useEffect
    * useEffect
    */
    */
   useEffect(() => {
   useEffect(() => {
-    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
-  }, [props.userGroupRelations, props.childUserGroups]);
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+  }, [userGroupRelations, childUserGroups]);
 
 
   return (
   return (
     <div data-testid="grw-user-group-table">
     <div data-testid="grw-user-group-table">
-      <h2>{props.headerLabel}</h2>
+      <h3>{headerLabel}</h3>
 
 
       <table className="table table-bordered table-user-list">
       <table className="table table-bordered table-user-list">
         <thead>
         <thead>
           <tr>
           <tr>
+            {isExternalGroup && <th>{t('external_user_group.provider')}</th>}
             <th>{t('Name')}</th>
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
             <th>{t('User')}</th>
@@ -141,14 +155,15 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          {props.userGroups.map((group) => {
+          {userGroups.map((group) => {
             const users = groupIdToUsersMap[group._id];
             const users = groupIdToUsersMap[group._id];
 
 
             return (
             return (
               <tr key={group._id}>
               <tr key={group._id}>
-                {props.isAclEnabled
+                {isExternalGroup && <td>{(group as IExternalUserGroupHasId).provider}</td>}
+                {isAclEnabled
                   ? (
                   ? (
-                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link></td>
                   )
                   )
                   : (
                   : (
                     <td>{group.name}</td>
                     <td>{group.name}</td>
@@ -166,10 +181,10 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   <ul className="list-inline">
                   <ul className="list-inline">
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                       return (
                       return (
-                        <li key={group._id} className="list-inline-item badge bg-success">
-                          {props.isAclEnabled
+                        <li key={group._id} className="list-inline-item badge badge-success">
+                          {isAclEnabled
                             ? (
                             ? (
-                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
+                              <Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link>
                             )
                             )
                             : (
                             : (
                               <p>{group.name}</p>
                               <p>{group.name}</p>
@@ -181,7 +196,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   </ul>
                   </ul>
                 </td>
                 </td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                   ? (
                     <td>
                     <td>
                       <div className="btn-group admin-group-menu">
                       <div className="btn-group admin-group-menu">
@@ -197,9 +212,12 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
                           </button>
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
-                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
-                          </button>
+                          {onRemove != null
+                          && (
+                            <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                              <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                            </button>
+                          )}
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                           <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')}
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>
                           </button>

+ 63 - 38
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -13,22 +13,25 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
-import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
-  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
-} from '~/stores/user-group';
+import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import {
+  useAncestorUserGroups,
+  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+} from './use-user-group-resource';
+
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
 
 
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
-const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable').then(mod => mod.UserGroupUserTable), { ssr: false });
 
 
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
 
@@ -42,15 +45,16 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 
 type Props = {
 type Props = {
   userGroupId: string,
   userGroupId: string,
+  isExternalGroup: boolean,
 }
 }
 
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const router = useRouter();
   const router = useRouter();
   const xss = useMemo(() => new Xss(), []);
   const xss = useMemo(() => new Xss(), []);
-  const { userGroupId: currentUserGroupId } = props;
+  const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
 
-  const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+  const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
 
 
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -76,26 +80,36 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
 
 
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(currentUserGroupId);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useUserGroupRelations(currentUserGroupId, isExternalGroup);
 
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups, updateChild } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
   const childUserGroupIds = childUserGroups.map(group => group._id);
 
 
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useUserGroupRelationList(childUserGroupIds, isExternalGroup);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroupId);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
 
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroupId);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
-  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  const parentUserGroup = (() => {
+    if (isExternalGroup) {
+      return ancestorUserGroups != null && ancestorUserGroups.length > 1
+        ? ancestorUserGroups[ancestorUserGroups.length - 2] : undefined;
+    }
+    return selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  })();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -113,19 +127,26 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-      name: update.name,
-      description: update.description,
-      parentId: parentId ?? null,
-      forceUpdateParents,
-    });
+    if (isExternalGroup) {
+      await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
+        description: update.description,
+      });
+    }
+    else {
+      await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: update.name,
+        description: update.description,
+        parentId: parentId ?? null,
+        forceUpdateParents,
+      });
+    }
 
 
     // mutate
     // mutate
     mutateChildUserGroups();
     mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
 
   const onSubmitUpdateGroup = useCallback(
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -213,23 +234,16 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
     try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: userGroupData.parent,
-      });
+      updateChild(userGroupData);
 
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
 
 
-      // mutate
-      mutateChildUserGroups();
-
       hideUpdateModal();
       hideUpdateModal();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [t, mutateChildUserGroups, hideUpdateModal]);
+  }, [t, updateChild, hideUpdateModal]);
 
 
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
     // show confirm modal before submiting
     // show confirm modal before submiting
@@ -283,8 +297,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [setSelectedUserGroup, setDeleteModalShown]);
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
     try {
-      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+      const res = await apiv3Delete(url, {
         actionName,
         actionName,
         transferToUserGroupId,
         transferToUserGroupId,
       });
       });
@@ -300,7 +315,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     catch (err) {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
       toastError(new Error('Unable to delete the groups'));
     }
     }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
 
 
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
     try {
@@ -348,7 +363,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
+                  <Link href={{
+                    pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
+                    query: { isExternalGroup: 'true' },
+                  }}
+                  >
                     {ancestorUserGroup.name}
                     {ancestorUserGroup.name}
                   </Link>
                   </Link>
                 ) }
                 ) }
@@ -366,6 +385,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           selectableParentUserGroups={selectableParentUserGroups}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
           onSubmit={onClickSubmitForm}
+          isExternalGroup={isExternalGroup}
         />
         />
       </div>
       </div>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
@@ -373,6 +393,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         userGroupRelations={userGroupRelations}
         userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
         onClickRemoveUserBtn={removeUserByUsername}
+        isExternalGroup={isExternalGroup}
       />
       />
       <UserGroupUserModal
       <UserGroupUserModal
         isOpen={isUserGroupUserModalShown}
         isOpen={isUserGroupUserModalShown}
@@ -389,11 +410,13 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       />
       />
 
 
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
-      <UserGroupDropdown
-        selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
-        onClickCreateUserGroupButton={showCreateModal}
-      />
+      {!isExternalGroup && (
+        <UserGroupDropdown
+          selectableUserGroups={selectableChildUserGroups}
+          onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+          onClickCreateUserGroupButton={showCreateModal}
+        />
+      )}
 
 
       <UserGroupModal
       <UserGroupModal
         userGroup={selectedUserGroup}
         userGroup={selectedUserGroup}
@@ -401,6 +424,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onClickSubmit={updateChildUserGroup}
         onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
         onHide={hideUpdateModal}
+        isExternalGroup={isExternalGroup}
       />
       />
 
 
       <UserGroupModal
       <UserGroupModal
@@ -420,6 +444,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onRemove={removeChildUserGroup}
         onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
         userGroupRelations={childUserGroupRelations}
+        isExternalGroup={isExternalGroup}
       />
       />
 
 
       <UserGroupDeleteModal
       <UserGroupDeleteModal

+ 35 - 36
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -10,15 +10,12 @@ type Props = {
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
   onClickPlusBtn: () => void,
+  isExternalGroup?: boolean
 }
 }
 
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const {
-    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
-  } = props;
-
   return (
   return (
     <table className="table table-bordered table-user-list">
     <table className="table table-bordered table-user-list">
       <thead>
       <thead>
@@ -30,11 +27,11 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <th>{t('Name')}</th>
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
-          <th style={{ width: '70px' }}></th>
+          {!props.isExternalGroup && <th style={{ width: '70px' }}></th>}
         </tr>
         </tr>
       </thead>
       </thead>
       <tbody>
       <tbody>
-        {userGroupRelations != null && userGroupRelations.map((relation) => {
+        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
           const { relatedUser } = relation;
           const { relatedUser } = relation;
 
 
           return (
           return (
@@ -48,47 +45,49 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
               <td>{relatedUser.name}</td>
               <td>{relatedUser.name}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-              <td>
-                <div className="btn-group admin-user-menu">
-                  <button
-                    type="button"
-                    id={`admin-group-menu-button-${relatedUser._id}`}
-                    className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                    data-bs-toggle="dropdown"
-                  >
-                    <i className="icon-settings"></i>
-                  </button>
-                  <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+              {!props.isExternalGroup && (
+                <td>
+                  <div className="btn-group admin-user-menu">
                     <button
                     <button
-                      className="dropdown-item"
                       type="button"
                       type="button"
-                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                      id={`admin-group-menu-button-${relatedUser._id}`}
+                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                      data-toggle="dropdown"
                     >
                     >
-                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      <i className="icon-settings"></i>
                     </button>
                     </button>
+                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
+                      >
+                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      </button>
+                    </div>
                   </div>
                   </div>
-                </div>
-              </td>
+                </td>
+              )}
             </tr>
             </tr>
           );
           );
         })}
         })}
 
 
-        <tr>
-          <td></td>
-          <td className="text-center">
-            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
-              <i className="ti ti-plus"></i>
-            </button>
-          </td>
-          <td></td>
-          <td></td>
-          <td></td>
-          <td></td>
-        </tr>
+        {!props.isExternalGroup && (
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-outline-secondary" type="button" onClick={props.onClickPlusBtn}>
+                <i className="ti ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+        )}
 
 
       </tbody>
       </tbody>
     </table>
     </table>
   );
   );
 };
 };
-
-export default UserGroupUserTable;

+ 50 - 0
apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts

@@ -0,0 +1,50 @@
+import {
+  useSWRxAncestorExternalUserGroups,
+  useSWRxChildExternalUserGroupList,
+  useSWRxExternalUserGroup,
+  useSWRxExternalUserGroupRelationList,
+  useSWRxExternalUserGroupRelations,
+} from '~/features/external-user-group/client/stores/external-user-group';
+import {
+  useSWRxAncestorUserGroups,
+  useSWRxChildUserGroupList, useSWRxUserGroup, useSWRxUserGroupRelationList, useSWRxUserGroupRelations,
+} from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroup = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroup(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroup(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelations = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelations(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelations(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useChildUserGroupList = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxChildUserGroupList(
+    !isExternalGroup ? [userGroupId] : [], true,
+  );
+  const externalUserGroupRes = useSWRxChildExternalUserGroupList(
+    isExternalGroup ? [userGroupId] : [], true,
+  );
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelationList = (userGroupIds: string[], isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelationList(isExternalGroup ? null : userGroupIds);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelationList(isExternalGroup ? userGroupIds : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useAncestorUserGroups = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxAncestorUserGroups(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxAncestorExternalUserGroups(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};

+ 18 - 21
apps/app/src/components/Comments.tsx

@@ -55,7 +55,7 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
     // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
     // > You can call observe() multiple times on the same MutationObserver
     // > You can call observe() multiple times on the same MutationObserver
     // > to watch for changes to different parts of the DOM tree and/or different types of changes.
     // > to watch for changes to different parts of the DOM tree and/or different types of changes.
-  }, [onLoaded]);
+  }, [onLoadedDebounced]);
 
 
   const isTopPagePath = isTopPage(pagePath);
   const isTopPagePath = isTopPage(pagePath);
 
 
@@ -69,29 +69,26 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   };
   };
 
 
   return (
   return (
-    <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
-      <div className="container-lg">
-        <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
-          <PageComment
+    <div className="page-comments-row mt-5 py-4 border-top border-3 d-edit-none d-print-none">
+      <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
+        <PageComment
+          pageId={pageId}
+          pagePath={pagePath}
+          revision={revision}
+          currentUser={currentUser}
+          isReadOnly={false}
+        />
+      </div>
+      {!isDeleted && (
+        <div id="page-comment-write">
+          <CommentEditor
             pageId={pageId}
             pageId={pageId}
-            pagePath={pagePath}
-            revision={revision}
-            currentUser={currentUser}
-            isReadOnly={false}
-            titleAlign="left"
+            isForNewComment
+            onCommentButtonClicked={onCommentButtonClickHandler}
+            revisionId={revision._id}
           />
           />
         </div>
         </div>
-        {!isDeleted && (
-          <div id="page-comment-write">
-            <CommentEditor
-              pageId={pageId}
-              isForNewComment
-              onCommentButtonClicked={onCommentButtonClickHandler}
-              revisionId={revision._id}
-            />
-          </div>
-        )}
-      </div>
+      )}
     </div>
     </div>
   );
   );
 
 

+ 5 - 3
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -85,7 +85,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
-    if (!pageInfo?.isMovable) {
+
+    if (!pageInfo?.isDeletable) {
       logger.warn('This page could not be renamed.');
       logger.warn('This page could not be renamed.');
       return;
       return;
     }
     }
@@ -176,9 +177,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
         ) }
 
 
         {/* Move/Rename */}
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
           <DropdownItem
           <DropdownItem
             onClick={renameItemClickedHandler}
             onClick={renameItemClickedHandler}
+            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
           >
           >
@@ -230,7 +232,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* divider */}
         {/* divider */}
         {/* Delete */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
           <>
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
             <DropdownItem

+ 32 - 10
apps/app/src/components/Common/PageViewLayout.module.scss

@@ -1,11 +1,42 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 @use '@growi/core/scss/bootstrap/init' as bs;
 
 
+@use '~/styles/mixins';
 @use '~/styles/variables' as var;
 @use '~/styles/variables' as var;
 
 
 
 
+$subnavigation-height: 50px;
+$page-view-layout-margin-top: 32px;
+
+.page-view-layout :global {
+  $page-content-footer-min-heigh: 130px;
+  min-height: calc(100vh - #{$subnavigation-height + $page-view-layout-margin-top + $page-content-footer-min-heigh});
+}
+
+// md/lg layout padding
 .page-view-layout :global {
 .page-view-layout :global {
-  min-height: calc(100vh - 48px - 250px); // 100vh - subnavigation height - page-comments-row minimum height
+  @include bs.media-breakpoint-between(md, xl) {
+    padding-left: var.$grw-sidebar-nav-width;
+  }
+}
+
+// container padding
+.page-view-layout :global,
+.footer-layout :global {
+  @include bs.media-breakpoint-up(lg) {
+    .container-lg {
+      --bs-gutter-x: 3rem;
+    }
+  }
+}
+
+// fluid layout
+.fluid-layout :global {
+  .grw-container-convertible {
+    @include mixins.fluid-layout();
+  }
+}
 
 
+.page-view-layout :global {
   .grw-side-contents-container {
   .grw-side-contents-container {
     margin-bottom: 1rem;
     margin-bottom: 1rem;
 
 
@@ -17,20 +48,11 @@
   }
   }
 }
 }
 
 
-// md/lg layout padding
-.page-view-layout :global {
-  @include bs.media-breakpoint-between(md, xl) {
-    padding-left: var.$grw-sidebar-nav-width;
-  }
-}
-
 // sticky side contents
 // sticky side contents
 .page-view-layout :global {
 .page-view-layout :global {
   .grw-side-contents-sticky-container {
   .grw-side-contents-sticky-container {
     position: sticky;
     position: sticky;
 
 
-    $subnavigation-height: 50px;
-    $page-view-layout-margin-top: 32px;
     $page-path-nav-height: 99px;
     $page-path-nav-height: 99px;
     top: calc($subnavigation-height + $page-view-layout-margin-top + $page-path-nav-height + 4px);
     top: calc($subnavigation-height + $page-view-layout-margin-top + $page-path-nav-height + 4px);
   }
   }

+ 13 - 5
apps/app/src/components/Common/PageViewLayout.tsx

@@ -2,27 +2,35 @@ import type { ReactNode } from 'react';
 
 
 import styles from './PageViewLayout.module.scss';
 import styles from './PageViewLayout.module.scss';
 
 
+const pageViewLayoutClass = styles['page-view-layout'] ?? '';
+const footerLayoutClass = styles['footer-layout'] ?? '';
+const _fluidLayoutClass = styles['fluid-layout'] ?? '';
+
 type Props = {
 type Props = {
   children?: ReactNode,
   children?: ReactNode,
   headerContents?: ReactNode,
   headerContents?: ReactNode,
   sideContents?: ReactNode,
   sideContents?: ReactNode,
   footerContents?: ReactNode,
   footerContents?: ReactNode,
+  expandContentWidth?: boolean,
 }
 }
 
 
 export const PageViewLayout = (props: Props): JSX.Element => {
 export const PageViewLayout = (props: Props): JSX.Element => {
   const {
   const {
     children, headerContents, sideContents, footerContents,
     children, headerContents, sideContents, footerContents,
+    expandContentWidth,
   } = props;
   } = props;
 
 
+  const fluidLayoutClass = expandContentWidth ? _fluidLayoutClass : '';
+
   return (
   return (
     <>
     <>
-      <div id="main" className={`main ${styles['page-view-layout']}`}>
-        <div id="content-main" className="content-main container-lg grw-container-convertible">
+      <div id="main" className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert`}>
+        <div id="content-main" className="content-main container-lg grw-container-convertible flex-expand-vert">
           { headerContents != null && headerContents }
           { headerContents != null && headerContents }
           { sideContents != null
           { sideContents != null
             ? (
             ? (
-              <div className="d-flex gap-3">
-                <div className="flex-grow-1 flex-basis-0 mw-0">
+              <div className="flex-expand-horiz gap-3">
+                <div className="flex-expand-vert flex-basis-0 mw-0">
                   {children}
                   {children}
                 </div>
                 </div>
                 <div className="grw-side-contents-container col-lg-3  d-edit-none d-print-none" data-vrt-blackout-side-contents>
                 <div className="grw-side-contents-container col-lg-3  d-edit-none d-print-none" data-vrt-blackout-side-contents>
@@ -40,7 +48,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       </div>
       </div>
 
 
       { footerContents != null && (
       { footerContents != null && (
-        <footer className="footer d-edit-none">
+        <footer className={`footer d-edit-none ${footerLayoutClass} ${fluidLayoutClass}`}>
           {footerContents}
           {footerContents}
         </footer>
         </footer>
       ) }
       ) }

+ 0 - 76
apps/app/src/components/CreateTemplateModal.jsx

@@ -1,76 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-import urljoin from 'url-join';
-
-const CreateTemplateModal = (props) => {
-  const { t } = useTranslation();
-  const { path } = props;
-
-  const parentPath = pathUtils.addTrailingSlash(path);
-
-  function generateUrl(label) {
-    return encodeURI(urljoin(parentPath, label, '#edit'));
-  }
-
-  /**
-   * @param {string} target Which hierarchy to create [children, decendants]
-   */
-  function renderTemplateCard(target, label) {
-    return (
-      <div className="card card-select-template">
-        <div className="card-header">{ t(`template.${target}.label`) }</div>
-        <div className="card-body">
-          <p className="text-center"><code>{label}</code></p>
-          <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`) }</small></p>
-        </div>
-        <div className="card-footer text-center">
-          <a
-            data-testid={`template-button-${target}`}
-            href={generateUrl(label)}
-            className="btn btn-sm btn-primary"
-            id={`template-button-${target}`}
-          >
-            { t('Edit') }
-          </a>
-        </div>
-      </div>
-    );
-  }
-
-  return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal">
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
-        {t('template.modal_label.Create/Edit Template Page')}
-      </ModalHeader>
-      <ModalBody>
-        <div>
-          <label className="form-label mb-4">
-            <code>{parentPath}</code><br />
-            { t('template.modal_label.Create template under') }
-          </label>
-          <div className="row row-cols-2">
-            <div className="col">
-              {renderTemplateCard('children', '_template')}
-            </div>
-            <div className="col">
-              {renderTemplateCard('decendants', '__template')}
-            </div>
-          </div>
-        </div>
-      </ModalBody>
-    </Modal>
-
-  );
-};
-
-CreateTemplateModal.propTypes = {
-  path: PropTypes.string.isRequired,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default CreateTemplateModal;

+ 101 - 0
apps/app/src/components/CreateTemplateModal.tsx

@@ -0,0 +1,101 @@
+import React, { useCallback } from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { toastError } from '~/client/util/toastr';
+import { TargetType, LabelType } from '~/interfaces/template';
+
+
+type TemplateCardProps = {
+  target: TargetType;
+  label: LabelType;
+  isPageCreating: boolean;
+  onClickHandler: () => void;
+};
+
+const TemplateCard: React.FC<TemplateCardProps> = ({
+  target, label, isPageCreating, onClickHandler,
+}) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="card card-select-template">
+      <div className="card-header">{t(`template.${target}.label`)}</div>
+      <div className="card-body">
+        <p className="text-center"><code>{label}</code></p>
+        <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`)}</small></p>
+      </div>
+      <div className="card-footer text-center">
+        <button
+          disabled={isPageCreating}
+          data-testid={`template-button-${target}`}
+          className="btn btn-sm btn-primary"
+          id={`template-button-${target}`}
+          onClick={onClickHandler}
+          type="button"
+        >
+          {t('Edit')}
+        </button>
+      </div>
+    </div>
+  );
+};
+
+type CreateTemplateModalProps = {
+  path: string;
+  isOpen: boolean;
+  onClose: () => void;
+};
+
+export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
+  path, isOpen, onClose,
+}) => {
+  const { t } = useTranslation();
+
+  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+
+  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
+    try {
+      await onClickTemplateButton(label);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [onClickTemplateButton]);
+
+  const parentPath = pathUtils.addTrailingSlash(path);
+
+  const renderTemplateCard = (target: TargetType, label: LabelType) => (
+    <div className="col">
+      <TemplateCard
+        target={target}
+        label={label}
+        isPageCreating={isPageCreating}
+        onClickHandler={() => onClickTemplateButtonHandler(label)}
+      />
+    </div>
+  );
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+        {t('template.modal_label.Create/Edit Template Page')}
+      </ModalHeader>
+      <ModalBody>
+        <div>
+          <label className="form-label mb-4">
+            <code>{parentPath}</code><br />
+            {t('template.modal_label.Create template under')}
+          </label>
+          <div className="row row-cols-2">
+            {renderTemplateCard('children', '_template')}
+            {renderTemplateCard('descendants', '__template')}
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};

+ 14 - 54
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,19 +1,13 @@
-import React, {
-  FC, useRef,
-} from 'react';
+import React, { FC } from 'react';
 
 
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { SupportedTargetModel } from '~/interfaces/activity';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 
-// Change the display for each targetmodel
-import PageModelNotification from './PageNotification/PageModelNotification';
-import UserModelNotification from './PageNotification/UserModelNotification';
+import { useModelNotification } from './PageNotification';
 
 
 interface Props {
 interface Props {
   notification: IInAppNotification & HasObjectId
   notification: IInAppNotification & HasObjectId
@@ -26,7 +20,14 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
 
   const { notification } = props;
   const { notification } = props;
 
 
-  const notificationRef = useRef<IInAppNotificationOpenable>(null);
+  const modelNotificationUtils = useModelNotification(notification);
+
+  const Notification = modelNotificationUtils?.Notification;
+  const publishOpen = modelNotificationUtils?.publishOpen;
+
+  if (Notification == null || publishOpen == null) {
+    return <></>;
+  }
 
 
   const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
   const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
@@ -34,35 +35,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       await apiv3Post('/in-app-notification/open', { id: notification._id });
       await apiv3Post('/in-app-notification/open', { id: notification._id });
     }
     }
 
 
-    const currentInstance = notificationRef.current;
-    if (currentInstance != null) {
-      currentInstance.open();
-    }
-  };
-
-  const getActionUsers = () => {
-    if (notification.targetModel === SupportedTargetModel.MODEL_USER) {
-      return notification.target.username;
-    }
-
-    const latestActionUsers = notification.actionUsers.slice(0, 3);
-    const latestUsers = latestActionUsers.map((user) => {
-      return `@${user.name}`;
-    });
-
-    let actionedUsers = '';
-    const latestUsersCount = latestUsers.length;
-    if (latestUsersCount === 1) {
-      actionedUsers = latestUsers[0];
-    }
-    else if (notification.actionUsers.length >= 4) {
-      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
-    }
-    else {
-      actionedUsers = latestUsers.join(', ');
-    }
-
-    return actionedUsers;
+    publishOpen();
   };
   };
 
 
   const renderActionUserPictures = (): JSX.Element => {
   const renderActionUserPictures = (): JSX.Element => {
@@ -84,8 +57,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     );
     );
   };
   };
 
 
-  const actionUsers = getActionUsers();
-
   const isDropdownItem = props.type === 'dropdown-item';
   const isDropdownItem = props.type === 'dropdown-item';
 
 
   // determine tag
   // determine tag
@@ -105,20 +76,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         >
         </span>
         </span>
         {renderActionUserPictures()}
         {renderActionUserPictures()}
-        {notification.targetModel === SupportedTargetModel.MODEL_PAGE && (
-          <PageModelNotification
-            ref={notificationRef}
-            notification={notification}
-            actionUsers={actionUsers}
-          />
-        )}
-        {notification.targetModel === SupportedTargetModel.MODEL_USER && (
-          <UserModelNotification
-            ref={notificationRef}
-            notification={notification}
-            actionUsers={actionUsers}
-          />
-        )}
+
+        <Notification />
+
       </div>
       </div>
     </TagElem>
     </TagElem>
   );
   );

+ 1 - 1
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -26,7 +26,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     );
     );
   }
   }
 
 
-  const notifications = inAppNotificationData.docs.filter((notification) => { return notification.parsedSnapshot != null });
+  const notifications = inAppNotificationData.docs;
 
 
   return (
   return (
     <>
     <>

+ 5 - 12
apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx

@@ -1,9 +1,8 @@
-import React, { FC, useImperativeHandle } from 'react';
+import React, { FC } from 'react';
 
 
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui/dist/components';
 import { PagePathLabel } from '@growi/ui/dist/components';
 
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
@@ -13,25 +12,19 @@ type Props = {
   actionMsg: string
   actionMsg: string
   actionIcon: string
   actionIcon: string
   actionUsers: string
   actionUsers: string
-  publishOpen:() => void
-  ref: React.ForwardedRef<IInAppNotificationOpenable>
 };
 };
 
 
 export const ModelNotification: FC<Props> = (props) => {
 export const ModelNotification: FC<Props> = (props) => {
   const {
   const {
-    notification, actionMsg, actionIcon, actionUsers, publishOpen, ref,
+    notification, actionMsg, actionIcon, actionUsers,
   } = props;
   } = props;
 
 
-  useImperativeHandle(ref, () => ({
-    open() {
-      publishOpen();
-    },
-  }));
-
   return (
   return (
     <div className="p-2 overflow-hidden">
     <div className="p-2 overflow-hidden">
       <div className="text-truncate">
       <div className="text-truncate">
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
+        <b>{actionUsers}</b>
+        {actionMsg}
+        <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
       </div>
       <i className={`${actionIcon} me-2`} />
       <i className={`${actionIcon} me-2`} />
       <FormattedDistanceDate
       <FormattedDistanceDate

+ 59 - 27
apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,53 +1,85 @@
 import React, {
 import React, {
-  forwardRef, ForwardRefRenderFunction,
+  FC, useCallback,
 } from 'react';
 } from 'react';
 
 
-import type { HasObjectId } from '@growi/core';
+import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
 
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
-import { useActionMsgAndIconForPageModelNotification } from './useActionAndMsg';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
 
 
-interface Props {
-  notification: IInAppNotification & HasObjectId
-  actionUsers: string
+export interface ModelNotificationUtils {
+  Notification: FC
+  publishOpen: () => void
 }
 }
 
 
-const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
+export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
 
 
-  const {
-    notification, actionUsers,
-  } = props;
+  const router = useRouter();
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
 
 
-  const { actionMsg, actionIcon } = useActionMsgAndIconForPageModelNotification(notification);
+  const getActionUsers = useCallback(() => {
+    const latestActionUsers = notification.actionUsers.slice(0, 3);
+    const latestUsers = latestActionUsers.map((user) => {
+      return `@${user.name}`;
+    });
 
 
-  const router = useRouter();
+    let actionedUsers = '';
+    const latestUsersCount = latestUsers.length;
+    if (latestUsersCount === 1) {
+      actionedUsers = latestUsers[0];
+    }
+    else if (notification.actionUsers.length >= 4) {
+      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
+    }
+    else {
+      actionedUsers = latestUsers.join(', ');
+    }
+
+    return actionedUsers;
+  }, [notification.actionUsers]);
+
+  const isPageModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IPage> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
+  };
+
+  if (!isPageModelNotification(notification)) {
+    return null;
+  }
+
+  const actionUsers = getActionUsers();
+
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+      />
+    );
+  };
 
 
-  // publish open()
   const publishOpen = () => {
   const publishOpen = () => {
     if (notification.target != null) {
     if (notification.target != null) {
       // jump to target page
       // jump to target page
-      const targetPagePath = notification.target.path;
+      const targetPagePath = (notification.target as IPage).path;
       if (targetPagePath != null) {
       if (targetPagePath != null) {
         router.push(targetPagePath);
         router.push(targetPagePath);
       }
       }
     }
     }
   };
   };
 
 
-  return (
-    <ModelNotification
-      notification={notification}
-      actionMsg={actionMsg}
-      actionIcon={actionIcon}
-      actionUsers={actionUsers}
-      publishOpen={publishOpen}
-      ref={ref}
-    />
-  );
-};
+  return {
+    Notification,
+    publishOpen,
+  };
 
 
-export default forwardRef(PageModelNotification);
+};

+ 33 - 26
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

@@ -1,42 +1,49 @@
-import React, {
-  forwardRef, ForwardRefRenderFunction,
-} from 'react';
+import React from 'react';
 
 
-import type { HasObjectId } from '@growi/core';
+import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
 import { ModelNotification } from './ModelNotification';
 import { ModelNotification } from './ModelNotification';
-import { useActionMsgAndIconForUserModelNotification } from './useActionAndMsg';
+import { ModelNotificationUtils } from './PageModelNotification';
+import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
 
 
 
-const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, {
-  notification: IInAppNotification & HasObjectId
-  actionUsers: string
-}> = ({
-  notification, actionUsers,
-}, ref) => {
+export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
+
+  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
   const router = useRouter();
   const router = useRouter();
 
 
-  const { actionMsg, actionIcon } = useActionMsgAndIconForUserModelNotification(notification);
+  const isUserModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IUser> & HasObjectId => {
+    return notification.targetModel === SupportedTargetModel.MODEL_USER;
+  };
+
+  if (!isUserModelNotification(notification)) {
+    return null;
+  }
+
+  const actionUsers = notification.target.username;
+
+  const Notification = () => {
+    return (
+      <ModelNotification
+        notification={notification}
+        actionMsg={actionMsg}
+        actionIcon={actionIcon}
+        actionUsers={actionUsers}
+      />
+    );
+  };
 
 
-  // publish open()
   const publishOpen = () => {
   const publishOpen = () => {
     router.push('/admin/users');
     router.push('/admin/users');
   };
   };
 
 
-  return (
-    <ModelNotification
-      notification={notification}
-      actionMsg={actionMsg}
-      actionIcon={actionIcon}
-      actionUsers={actionUsers}
-      publishOpen={publishOpen}
-      ref={ref}
-    />
-  );
-};
+  return {
+    Notification,
+    publishOpen,
+  };
 
 
-export default forwardRef(UserModelNotification);
+};

+ 19 - 0
apps/app/src/components/InAppNotification/PageNotification/index.tsx

@@ -0,0 +1,19 @@
+import type { HasObjectId } from '@growi/core';
+
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+
+import { usePageModelNotification, type ModelNotificationUtils } from './PageModelNotification';
+import { useUserModelNotification } from './UserModelNotification';
+
+
+export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
+
+  const pageModelNotificationUtils = usePageModelNotification(notification);
+  const userModelNotificationUtils = useUserModelNotification(notification);
+
+  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils;
+
+
+  return modelNotificationUtils;
+};

+ 1 - 18
apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts

@@ -8,7 +8,7 @@ export type ActionMsgAndIconType = {
   actionIcon: string
   actionIcon: string
 }
 }
 
 
-export const useActionMsgAndIconForPageModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionMsg: string;
   let actionIcon: string;
   let actionIcon: string;
@@ -66,23 +66,6 @@ export const useActionMsgAndIconForPageModelNotification = (notification: IInApp
       actionMsg = 'commented on';
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       actionIcon = 'icon-bubble';
       break;
       break;
-    default:
-      actionMsg = '';
-      actionIcon = '';
-  }
-
-  return {
-    actionMsg,
-    actionIcon,
-  };
-};
-
-export const useActionMsgAndIconForUserModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
-  const actionType: string = notification.action;
-  let actionMsg: string;
-  let actionIcon: string;
-
-  switch (actionType) {
     case SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST:
     case SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST:
       actionMsg = 'requested registration approval';
       actionMsg = 'requested registration approval';
       actionIcon = 'icon-bubble';
       actionIcon = 'icon-bubble';

+ 2 - 4
apps/app/src/components/InstallerForm.tsx

@@ -99,10 +99,8 @@ const InstallerForm = memo((): JSX.Element => {
       <div className="row">
       <div className="row">
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
           <div className="dropdown mb-3">
-            <div className="input-group">
-              <div className=" dropdown-with-icon">
-                <i className="input-group-text icon-bubbles border-0 rounded-0" />
-              </div>
+            <div className="input-group dropdown-with-icon">
+              <span className="input-group-text"><i className="icon-bubbles" /></span>
               <button
               <button
                 type="button"
                 type="button"
                 className="btn btn-secondary dropdown-toggle form-control text-end rounded-end"
                 className="btn btn-secondary dropdown-toggle form-control text-end rounded-end"

+ 15 - 0
apps/app/src/components/Me/ColorModeSettings.module.scss

@@ -0,0 +1,15 @@
+@use '@growi/core/scss/bootstrap/init' as *;
+
+.color-settings :global {
+  .btn {
+    font-weight: bold;
+    color: var(--color-global);
+    background-color: transparent;
+    border-width: 3px;
+  }
+
+  .btn-outline-secondary {
+    border-color: $gray-400;
+  }
+}
+

+ 62 - 0
apps/app/src/components/Me/ColorModeSettings.tsx

@@ -0,0 +1,62 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { Themes, useNextThemes } from '~/stores/use-next-themes';
+
+import styles from './ColorModeSettings.module.scss';
+
+export const ColorModeSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { setTheme, theme } = useNextThemes();
+
+  const isActive = useCallback((targetTheme: Themes) => {
+    return targetTheme === theme;
+  }, [theme]);
+
+  return (
+    <div className={`color-settings ${styles['color-settings']}`}>
+      <h2 className="border-bottom mb-4">{t('color_mode_settings.settings')}</h2>
+
+      <div className="offset-md-3">
+        <div className="d-flex">
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.LIGHT) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.LIGHT) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
+            <span>{t('color_mode_settings.light')}</span>
+          </button>
+
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.DARK) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.DARK) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
+            <span>{t('color_mode_settings.dark')}</span>
+          </button>
+
+          <button
+            type="button"
+            onClick={() => { setTheme(Themes.SYSTEM) }}
+            // eslint-disable-next-line max-len
+            className={`btn py-2 px-4 d-flex align-items-center justify-content-center ${isActive(Themes.SYSTEM) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">devices</span>
+            <span>{t('color_mode_settings.system')}</span>
+          </button>
+        </div>
+
+        <div className="mt-3 text-muted">
+          {/* eslint-disable-next-line react/no-danger */}
+          <span dangerouslySetInnerHTML={{ __html: t('color_mode_settings.description') }} />
+        </div>
+      </div>
+    </div>
+  );
+};

+ 10 - 94
apps/app/src/components/Me/OtherSettings.tsx

@@ -1,105 +1,21 @@
-import {
-  useState, useEffect, useCallback,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
-import { useCurrentUser } from '~/stores/context';
+import { ColorModeSettings } from './ColorModeSettings';
+import { QuestionnaireSettings } from './QuestionnaireSettings';
+import { UISettings } from './UISettings';
 
 
 const OtherSettings = (): JSX.Element => {
 const OtherSettings = (): JSX.Element => {
-  const { t } = useTranslation();
-  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
-  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
-
-  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
-
-  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
-    setIsQuestionnaireEnabled(prev => !prev);
-  }, []);
-
-  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
-    try {
-      await apiv3Put('/personal-setting/questionnaire-settings', {
-        isQuestionnaireEnabled,
-      });
-      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [isQuestionnaireEnabled, t]);
-
-  // Sync currentUser and state
-  useEffect(() => {
-    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
-  }, [currentUser?.isQuestionnaireEnabled]);
-
-  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
 
 
   return (
   return (
     <>
     <>
-      <h2 className="border-bottom my-4">{t('questionnaire.settings')}</h2>
-
-      {isLoadingCurrentUser && (
-        <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
-        </div>
-      )}
+      <div className="mt-4">
+        <ColorModeSettings />
+      </div>
 
 
-      <div className="row">
-        <div className="offset-md-3 col-md-6 text-start">
-          {!isLoadingCurrentUser && (
-            <div className="form-check form-switch form-check-primary">
-              <span id="grw-questionnaire-settings-toggle-wrapper">
-                <input
-                  type="checkbox"
-                  className="form-check-input"
-                  id="isQuestionnaireEnabled"
-                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
-                  onChange={onChangeIsQuestionnaireEnabledHandler}
-                  disabled={!growiIsQuestionnaireEnabled}
-                />
-                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
-                  {t('questionnaire.enable_questionnaire')}
-                </label>
-              </span>
-              <p className="form-text text-muted small">
-                {t('questionnaire.personal_settings_explanation')}
-              </p>
-              {!growiIsQuestionnaireEnabled && (
-                <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
-                  {t('questionnaire.disabled_by_admin')}
-                </UncontrolledTooltip>
-              ) }
-            </div>
-          )}
-        </div>
+      <div className="mt-4">
+        <UISettings />
       </div>
       </div>
 
 
-      <div className="row my-3">
-        <div className="offset-4 col-5">
-          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
-            <button
-              data-testid="grw-questionnaire-settings-update-btn"
-              type="button"
-              className="btn btn-primary"
-              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
-              disabled={!growiIsQuestionnaireEnabled}
-              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
-            >
-              {t('Update')}
-            </button>
-          </span>
-          {!growiIsQuestionnaireEnabled && (
-            <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
-              {t('questionnaire.disabled_by_admin')}
-            </UncontrolledTooltip>
-          )}
-        </div>
+      <div className="mt-4">
+        <QuestionnaireSettings />
       </div>
       </div>
     </>
     </>
   );
   );

+ 106 - 0
apps/app/src/components/Me/QuestionnaireSettings.tsx

@@ -0,0 +1,106 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
+import { useCurrentUser } from '~/stores/context';
+
+
+export const QuestionnaireSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
+  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
+
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/personal-setting/questionnaire-settings', {
+        isQuestionnaireEnabled,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isQuestionnaireEnabled, t]);
+
+  // Sync currentUser and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
+  }, [currentUser?.isQuestionnaireEnabled]);
+
+  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
+
+  return (
+    <>
+      <h2 className="border-bottom mb-4">{t('questionnaire.settings')}</h2>
+
+      {isLoadingCurrentUser && (
+        <div className="text-muted text-center mb-5">
+          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+        </div>
+      )}
+
+      <div className="row">
+        <div className="offset-md-3 col-md-6 text-start">
+          {!isLoadingCurrentUser && (
+            <div className="form-check form-switch">
+              <span id="grw-questionnaire-settings-toggle-wrapper">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isQuestionnaireEnabled"
+                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
+                  onChange={onChangeIsQuestionnaireEnabledHandler}
+                  disabled={!growiIsQuestionnaireEnabled}
+                />
+                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
+                  {t('questionnaire.enable_questionnaire')}
+                </label>
+              </span>
+              <p className="form-text text-muted small">
+                {t('questionnaire.personal_settings_explanation')}
+              </p>
+              {!growiIsQuestionnaireEnabled && (
+                <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
+                  {t('questionnaire.disabled_by_admin')}
+                </UncontrolledTooltip>
+              ) }
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
+            <button
+              data-testid="grw-questionnaire-settings-update-btn"
+              type="button"
+              className="btn btn-primary"
+              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
+              disabled={!growiIsQuestionnaireEnabled}
+              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
+            >
+              {t('Update')}
+            </button>
+          </span>
+          {!growiIsQuestionnaireEnabled && (
+            <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
+              {t('questionnaire.disabled_by_admin')}
+            </UncontrolledTooltip>
+          )}
+        </div>
+      </div>
+    </>
+
+  );
+};

+ 2 - 2
apps/app/src/components/Icons/SidebarDrawerIcon.jsx → apps/app/src/components/Me/SidebarCollapsedIcon.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
-const SidebarDrawerIcon = () => (
+const SidebarCollapsedIcon = () => (
   <svg
   <svg
     xmlns="http://www.w3.org/2000/svg"
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 23 23"
     viewBox="0 0 23 23"
@@ -22,4 +22,4 @@ const SidebarDrawerIcon = () => (
   </svg>
   </svg>
 );
 );
 
 
-export default SidebarDrawerIcon;
+export default SidebarCollapsedIcon;

+ 0 - 0
apps/app/src/components/Icons/SidebarDockIcon.jsx → apps/app/src/components/Me/SidebarDockIcon.jsx


+ 7 - 0
apps/app/src/components/Me/UISettings.module.scss

@@ -0,0 +1,7 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-sidebar-mode-icon {
+  width: 20px;
+  height: 20px;
+  color: bs.$secondary;
+}

+ 114 - 0
apps/app/src/components/Me/UISettings.tsx

@@ -0,0 +1,114 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { updateUserUISettings } from '~/client/services/user-ui-settings';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useCollapsedContentsOpened, usePreferCollapsedMode, useSidebarMode } from '~/stores/ui';
+
+import SidebarCollapsedIcon from './SidebarCollapsedIcon';
+import SidebarDockIcon from './SidebarDockIcon';
+
+import styles from './UISettings.module.scss';
+
+const IconWithTooltip = ({
+  id, label, children, additionalClasses,
+}: {
+id: string,
+label: string,
+children: JSX.Element,
+additionalClasses: string
+}) => (
+  <>
+    <div id={id} className={`${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
+    <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
+  </>
+);
+
+export const UISettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isDockMode, isCollapsedMode,
+  } = useSidebarMode();
+  const { mutate: mutatePreferCollapsedMode } = usePreferCollapsedMode();
+  const { mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
+
+  const toggleCollapsed = useCallback(() => {
+    mutatePreferCollapsedMode(!isCollapsedMode());
+    mutateCollapsedContentsOpened(false);
+  }, [mutatePreferCollapsedMode, isCollapsedMode, mutateCollapsedContentsOpened]);
+
+  const updateButtonHandler = useCallback(async() => {
+    try {
+      await updateUserUISettings({ preferCollapsedModeByUser: isCollapsedMode() });
+      toastSuccess(t('toaster.update_successed', { target: t('ui_settings.side_bar_mode.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [isCollapsedMode, t]);
+
+  const renderSidebarModeSwitch = () => {
+    return (
+      <>
+        <div className="d-flex align-items-start">
+          <div className="d-flex align-items-center">
+            <IconWithTooltip
+              id="iwt-sidebar-collapsed"
+              label="Collapsed"
+              additionalClasses={styles['grw-sidebar-mode-icon']}
+            >
+              <SidebarCollapsedIcon />
+            </IconWithTooltip>
+            <div className="form-check form-switch ms-2">
+
+              <input
+                id="swSidebarMode"
+                className="form-check-input"
+                type="checkbox"
+                checked={isDockMode()}
+                onChange={toggleCollapsed}
+              />
+              <label className="form-label form-check-label" htmlFor="swSidebarMode"></label>
+            </div>
+            <IconWithTooltip id="iwt-sidebar-dock" label="Dock" additionalClasses={styles['grw-sidebar-mode-icon']}>
+              <SidebarDockIcon />
+            </IconWithTooltip>
+          </div>
+          <div className="ms-2">
+            <label className="form-label form-check-label" htmlFor="swSidebarMode">
+              {t('ui_settings.side_bar_mode.side_bar_mode_setting')}
+            </label>
+            <p className="form-text text-muted small">{t('ui_settings.side_bar_mode.description')}</p>
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <h2 className="border-bottom mb-4">{t('ui_settings.ui_settings')}</h2>
+
+      <div className="row justify-content-center">
+        <div className="col-md-6">
+
+          { renderSidebarModeSwitch() }
+
+          <div>
+          </div>
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button data-testid="" type="button" className="btn btn-primary" onClick={updateButtonHandler}>
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};

+ 11 - 11
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -11,11 +11,12 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
   useCurrentPathname,
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId, useIsContainerFluid,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
@@ -28,10 +29,9 @@ import { mutatePageTree } from '~/stores/page-listing';
 import {
 import {
   useEditorMode, useIsAbleToShowPageManagement,
   useEditorMode, useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
   useIsAbleToChangeEditorMode,
-  useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
-import CreateTemplateModal from '../CreateTemplateModal';
+import { CreateTemplateModal } from '../CreateTemplateModal';
 import AttachmentIcon from '../Icons/AttachmentIcon';
 import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
@@ -196,8 +196,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isContainerFluid } = useIsContainerFluid();
-  const { data: grantData } = useSelectedGrant();
+
+  const shouldExpandContent = useShouldExpandContent(currentPage);
 
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
@@ -208,8 +208,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
 
   const path = currentPage?.path ?? currentPathname;
   const path = currentPage?.path ?? currentPathname;
-  const grant = currentPage?.grant ?? grantData?.grant;
-  const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
+  // const grant = currentPage?.grant ?? grantData?.grant;
+  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
 
@@ -299,7 +299,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     <>
     <>
       <div
       <div
         className={`${styles['grw-contextual-sub-navigation']}
         className={`${styles['grw-contextual-sub-navigation']}
-          d-flex align-items-center justify-content-end px-2 py-1 gap-2 gap-md-4 d-print-none
+          d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
         `}
         `}
         data-testid="grw-contextual-sub-nav"
         data-testid="grw-contextual-sub-nav"
       >
       >
@@ -309,7 +309,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             revisionId={revisionId}
             revisionId={revisionId}
             shareLinkId={shareLinkId}
             shareLinkId={shareLinkId}
             path={path ?? currentPathname} // If the page is empty, "path" is undefined
             path={path ?? currentPathname} // If the page is empty, "path" is undefined
-            expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
+            expandContentWidth={shouldExpandContent}
             disableSeenUserInfoPopover={isSharedUser}
             disableSeenUserInfoPopover={isSharedUser}
             showPageControlDropdown={isAbleToShowPageManagement}
             showPageControlDropdown={isAbleToShowPageManagement}
             additionalMenuItemRenderer={additionalMenuItemsRenderer}
             additionalMenuItemRenderer={additionalMenuItemsRenderer}
@@ -325,8 +325,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             editorMode={editorMode}
             editorMode={editorMode}
             isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
             isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
             path={path}
             path={path}
-            grant={grant}
-            grantUserGroupId={grantUserGroupId}
+            // grant={grant}
+            // grantUserGroupId={grantUserGroupId}
           />
           />
         )}
         )}
       </div>
       </div>

+ 2 - 2
apps/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -31,7 +31,7 @@
 // == Colors
 // == Colors
 @include bs.color-mode(light) {
 @include bs.color-mode(light) {
   .grw-page-editor-mode-manager :global {
   .grw-page-editor-mode-manager :global {
-    .btn-outline-primary {
+    .btn {
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-700));
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-700));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-100));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-100));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-100-rgb));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-100-rgb));
@@ -51,7 +51,7 @@
 }
 }
 @include bs.color-mode(dark) {
 @include bs.color-mode(dark) {
   .grw-page-editor-mode-manager :global {
   .grw-page-editor-mode-manager :global {
-    .btn-outline-primary {
+    .btn {
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-300));
       $color: var(--grw-page-editor-mode-manager-btn-color, var(--grw-primary-300));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-800));
       $bg: var(--grw-page-editor-mode-manager-btn-bg, var(--grw-primary-800));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-800-rgb));
       $bg-rgb: var(--grw-page-editor-mode-manager-btn-bg-rgb, var(--grw-primary-800-rgb));

+ 7 - 5
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,5 +1,6 @@
 import React, { type ReactNode, useCallback, useState } from 'react';
 import React, { type ReactNode, useCallback, useState } from 'react';
 
 
+import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { EditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import { EditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
@@ -21,7 +22,7 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
     currentEditorMode, isBtnDisabled, editorMode, children, onClick,
     currentEditorMode, isBtnDisabled, editorMode, children, onClick,
   } = props;
   } = props;
 
 
-  const classNames = ['btn btn-outline-primary py-1 px-2 d-flex align-items-center justify-content-center'];
+  const classNames = ['btn py-1 px-2 d-flex align-items-center justify-content-center'];
   if (currentEditorMode === editorMode) {
   if (currentEditorMode === editorMode) {
     classNames.push('active');
     classNames.push('active');
   }
   }
@@ -46,7 +47,8 @@ type Props = {
   isBtnDisabled: boolean,
   isBtnDisabled: boolean,
   path?: string,
   path?: string,
   grant?: number,
   grant?: number,
-  grantUserGroupId?: string
+  // grantUserGroupId?: string
+  grantUserGroupIds?: IGrantedGroup[]
 }
 }
 
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -54,8 +56,8 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     editorMode = EditorMode.View,
     isBtnDisabled,
     isBtnDisabled,
     path,
     path,
-    grant,
-    grantUserGroupId,
+    // grant,
+    // grantUserGroupId,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -63,7 +65,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
 
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
 
-  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path, grant, grantUserGroupId);
+  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path);
   const _isBtnDisabled = isCreating || isBtnDisabled;
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType: EditorMode) => {
   const pageEditorModeButtonClickedHandler = useCallback((viewType: EditorMode) => {

+ 7 - 7
apps/app/src/components/Navbar/hooks.tsx

@@ -14,8 +14,8 @@ const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
 export const useOnPageEditorModeButtonClicked = (
 export const useOnPageEditorModeButtonClicked = (
     setIsCreating:React.Dispatch<React.SetStateAction<boolean>>,
     setIsCreating:React.Dispatch<React.SetStateAction<boolean>>,
     path?: string,
     path?: string,
-    grant?: number,
-    grantUserGroupId?: string,
+    // grant?: number,
+    // grantUserGroupId?: string,
 ): (editorMode: EditorMode) => Promise<void> => {
 ): (editorMode: EditorMode) => Promise<void> => {
   const router = useRouter();
   const router = useRouter();
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
@@ -23,7 +23,7 @@ export const useOnPageEditorModeButtonClicked = (
   const { mutate: mutateEditorMode } = useEditorMode();
   const { mutate: mutateEditorMode } = useEditorMode();
 
 
   return useCallback(async(editorMode: EditorMode) => {
   return useCallback(async(editorMode: EditorMode) => {
-    if (isNotFound == null || path == null || grant == null) {
+    if (isNotFound == null || path == null) {
       return;
       return;
     }
     }
 
 
@@ -34,9 +34,9 @@ export const useOnPageEditorModeButtonClicked = (
         const params = {
         const params = {
           isSlackEnabled: false,
           isSlackEnabled: false,
           slackChannels: '',
           slackChannels: '',
-          grant,
-          pageTags: [],
-          grantUserGroupId,
+          grant: 4,
+          // grant,
+          // grantUserGroupId,
         };
         };
 
 
         const response = await createPage(path, '', params);
         const response = await createPage(path, '', params);
@@ -54,5 +54,5 @@ export const useOnPageEditorModeButtonClicked = (
     }
     }
 
 
     mutateEditorMode(editorMode);
     mutateEditorMode(editorMode);
-  }, [grant, grantUserGroupId, isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
+  }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
 };
 };

+ 22 - 10
apps/app/src/components/Page/PageView.tsx

@@ -6,6 +6,7 @@ import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import {
 import {
@@ -70,6 +71,8 @@ export const PageView = (props: Props): JSX.Element => {
   const isNotFound = isNotFoundMeta || page?.revision == null;
   const isNotFound = isNotFoundMeta || page?.revision == null;
   const isUsersHomepagePath = isUsersHomepage(pagePath);
   const isUsersHomepagePath = isUsersHomepage(pagePath);
 
 
+  const shouldExpandContent = useShouldExpandContent(page);
+
 
 
   // ***************************  Auto Scroll  ***************************
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
   useEffect(() => {
@@ -112,14 +115,6 @@ export const PageView = (props: Props): JSX.Element => {
   const footerContents = !isIdenticalPathPage && !isNotFound
   const footerContents = !isIdenticalPathPage && !isNotFound
     ? (
     ? (
       <>
       <>
-        <div id="comments-container" ref={commentsContainerRef}>
-          <Comments
-            pageId={page._id}
-            pagePath={pagePath}
-            revision={page.revision}
-            onLoaded={() => setCommentsLoaded(true)}
-          />
-        </div>
         {(isUsersHomepagePath && page.creator != null) && (
         {(isUsersHomepagePath && page.creator != null) && (
           <UsersHomepageFooter creatorId={page.creator._id} />
           <UsersHomepageFooter creatorId={page.creator._id} />
         )}
         )}
@@ -139,16 +134,33 @@ export const PageView = (props: Props): JSX.Element => {
     return (
     return (
       <>
       <>
         <PageContentsUtilities />
         <PageContentsUtilities />
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+        <div className="flex-expand-vert justify-content-between">
+          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+          { !isIdenticalPathPage && !isNotFound && (
+            <div id="comments-container" ref={commentsContainerRef}>
+              <Comments
+                pageId={page._id}
+                pagePath={pagePath}
+                revision={page.revision}
+                onLoaded={() => setCommentsLoaded(true)}
+              />
+            </div>
+          ) }
+        </div>
       </>
       </>
     );
     );
   };
   };
 
 
+  const mobileClass = isMobile ? styles['page-mobile'] : '';
+
   return (
   return (
     <PageViewLayout
     <PageViewLayout
       headerContents={headerContents}
       headerContents={headerContents}
       sideContents={sideContents}
       sideContents={sideContents}
       footerContents={footerContents}
       footerContents={footerContents}
+      expandContentWidth={shouldExpandContent}
     >
     >
       <PageAlerts />
       <PageAlerts />
 
 
@@ -156,7 +168,7 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents == null && (
       {specialContents == null && (
         <>
         <>
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
-          <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+          <div className={`flex-expand-vert ${mobileClass}`}>
             <Contents />
             <Contents />
           </div>
           </div>
         </>
         </>

+ 12 - 10
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
 import { IPageGrantData } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
 
@@ -29,7 +29,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   } = props;
   } = props;
 
 
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
-  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
+  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
 
 
   // Alert message state
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -57,7 +59,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
         grant: selectedGrant,
-        grantedGroup: selectedGroup?._id,
+        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
       });
       });
 
 
       toastSuccess(t('Successfully updated'));
       toastSuccess(t('Successfully updated'));
@@ -86,10 +88,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
     }
 
 
     if (grantData.grant === 5) {
     if (grantData.grant === 5) {
-      if (grantData.grantedGroup == null) {
+      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
     }
     }
 
 
     throw Error('cannot get grant label'); // this error can't be throwed
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -177,15 +179,15 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                 <div className="dropdown ms-2">
                 <div className="dropdown ms-2">
                   <button
                   <button
                     type="button"
                     type="button"
-                    className="btn btn-secondary dropdown-toggle text-end w-100 border-0 shadow-none"
-                    data-bs-toggle="dropdown"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
                   >
                   >
                     <span className="float-start ms-2">
                     <span className="float-start ms-2">
                       {
                       {
                         selectedGroup == null
                         selectedGroup == null
                           ? t('fix_page_grant.modal.select_group_default_text')
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.name
+                          : selectedGroup.item.name
                       }
                       }
                     </span>
                     </span>
                   </button>
                   </button>
@@ -193,12 +195,12 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                     {
                     {
                       applicableGroups != null && applicableGroups.map(g => (
                       applicableGroups != null && applicableGroups.map(g => (
                         <button
                         <button
-                          key={g._id}
+                          key={g.item._id}
                           className="dropdown-item"
                           className="dropdown-item"
                           type="button"
                           type="button"
                           onClick={() => setSelectedGroup(g)}
                           onClick={() => setSelectedGroup(g)}
                         >
                         >
-                          {g.name}
+                          {g.item.name}
                         </button>
                         </button>
                       ))
                       ))
                     }
                     }

+ 6 - 1
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -32,7 +33,11 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
       if (pageData.grant === 5) {
         return (
         return (
           <>
           <>
-            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name}</strong>
+            <i className="icon-fw icon-organization"></i>
+            <strong>{
+              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
+            }
+            </strong>
           </>
           </>
         );
         );
       }
       }

+ 49 - 55
apps/app/src/components/PageComment.tsx

@@ -31,14 +31,13 @@ export type PageCommentProps = {
   revision: string | IRevisionHasId,
   revision: string | IRevisionHasId,
   currentUser: any,
   currentUser: any,
   isReadOnly: boolean,
   isReadOnly: boolean,
-  titleAlign?: 'center' | 'left' | 'right',
 }
 }
 
 
 export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
 export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
 
 
   const {
   const {
     rendererOptions: rendererOptionsByProps,
     rendererOptions: rendererOptionsByProps,
-    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign,
+    pageId, pagePath, revision, currentUser, isReadOnly,
   } = props;
   } = props;
 
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -112,9 +111,6 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
     return <></>;
     return <></>;
   }
   }
 
 
-  let commentTitleClasses = 'border-bottom py-3 mb-3';
-  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
-
   const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
   const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
 
 
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
@@ -156,58 +152,56 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
 
 
   return (
   return (
     <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
     <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-      <div className="container-lg">
-        <div className="page-comments">
-          <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
-          <div className="page-comments-list" id="page-comments-list">
-            {commentsExceptReply.map((comment) => {
-
-              const defaultCommentThreadClasses = 'page-comment-thread pb-5';
-              const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
-
-              let commentThreadClasses = '';
-              commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
-
-              return (
-                <div key={comment._id} className={commentThreadClasses}>
-                  {commentElement(comment)}
-                  {hasReply && replyCommentsElement(allReplies[comment._id])}
-                  {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                    <div className="d-flex flex-row-reverse">
-                      <NotAvailableForGuest>
-                        <NotAvailableForReadOnlyUser>
-                          <Button
-                            data-testid="comment-reply-button"
-                            outline
-                            color="secondary"
-                            size="sm"
-                            className="btn-comment-reply"
-                            onClick={() => onReplyButtonClickHandler(comment._id)}
-                          >
-                            <i className="icon-fw icon-action-undo"></i> Reply
-                          </Button>
-                        </NotAvailableForReadOnlyUser>
-                      </NotAvailableForGuest>
-                    </div>
-                  )}
-                  {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                    <CommentEditor
-                      pageId={pageId}
-                      replyTo={comment._id}
-                      onCancelButtonClicked={() => {
-                        removeShowEditorId(comment._id);
-                      }}
-                      onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
-                      revisionId={revisionId}
-                    />
-                  )}
-                </div>
-              );
-
-            })}
-          </div>
+      <div className="page-comments">
+        <div className="page-comments-list" id="page-comments-list">
+          {commentsExceptReply.map((comment) => {
+
+            const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+            const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
+
+            let commentThreadClasses = '';
+            commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
+
+            return (
+              <div key={comment._id} className={commentThreadClasses}>
+                {commentElement(comment)}
+                {hasReply && replyCommentsElement(allReplies[comment._id])}
+                {(!isReadOnly && !showEditorIds.has(comment._id)) && (
+                  <div className="d-flex flex-row-reverse">
+                    <NotAvailableForGuest>
+                      <NotAvailableForReadOnlyUser>
+                        <Button
+                          data-testid="comment-reply-button"
+                          outline
+                          color="secondary"
+                          size="sm"
+                          className="btn-comment-reply"
+                          onClick={() => onReplyButtonClickHandler(comment._id)}
+                        >
+                          <i className="icon-fw icon-action-undo"></i> Reply
+                        </Button>
+                      </NotAvailableForReadOnlyUser>
+                    </NotAvailableForGuest>
+                  </div>
+                )}
+                {(!isReadOnly && showEditorIds.has(comment._id)) && (
+                  <CommentEditor
+                    pageId={pageId}
+                    replyTo={comment._id}
+                    onCancelButtonClicked={() => {
+                      removeShowEditorId(comment._id);
+                    }}
+                    onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
+                    revisionId={revisionId}
+                  />
+                )}
+              </div>
+            );
+
+          })}
         </div>
         </div>
       </div>
       </div>
+
       {!isReadOnly && (
       {!isReadOnly && (
         <DeleteCommentModal
         <DeleteCommentModal
           isShown={isDeleteConfirmModalShown}
           isShown={isDeleteConfirmModalShown}

+ 9 - 0
apps/app/src/components/PageControls/PageControls.tsx

@@ -16,6 +16,7 @@ import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
@@ -32,6 +33,9 @@ import SubscribeButton from './SubscribeButton';
 
 
 import styles from './PageControls.module.scss';
 import styles from './PageControls.module.scss';
 
 
+const logger = loggerFactory('growi:components/PageControls');
+
+
 type TagsProps = {
 type TagsProps = {
   onClickEditTagsButton: () => void,
   onClickEditTagsButton: () => void,
 }
 }
@@ -199,6 +203,11 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
 
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
     if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
     if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
+      logger.warn('Could not switch content width', {
+        onClickSwitchContentWidth: onClickSwitchContentWidth == null ? 'null' : 'not null',
+        isGuestUser,
+        isReadOnlyUser,
+      });
       return;
       return;
     }
     }
     if (!isIPageInfoForEntity(pageInfo)) {
     if (!isIPageInfoForEntity(pageInfo)) {

+ 4 - 4
apps/app/src/components/PageCreateModal.jsx

@@ -281,16 +281,16 @@ const PageCreateModal = () => {
               <DropdownToggle id="template-type" caret>
               <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'children' && t('template.children.label')}
-                {template === 'decendants' && t('template.decendants.label')}
+                {template === 'descendants' && t('template.descendants.label')}
               </DropdownToggle>
               </DropdownToggle>
               <DropdownMenu>
               <DropdownMenu>
                 <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
                 <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   {t('template.children.label')} (_template)<br className="d-block d-md-none" />
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
                   <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
                 </DropdownItem>
                 </DropdownItem>
-                <DropdownItem onClick={() => onChangeTemplateHandler('decendants')}>
-                  {t('template.decendants.label')} (__template) <br className="d-block d-md-none" />
-                  <small className="text-muted">- {t('template.decendants.desc')}</small>
+                <DropdownItem onClick={() => onChangeTemplateHandler('descendants')}>
+                  {t('template.descendants.label')} (__template) <br className="d-block d-md-none" />
+                  <small className="text-muted">- {t('template.descendants.desc')}</small>
                 </DropdownItem>
                 </DropdownItem>
               </DropdownMenu>
               </DropdownMenu>
             </UncontrolledButtonDropdown>
             </UncontrolledButtonDropdown>

+ 13 - 6
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -1,5 +1,6 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
+import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { HotTable } from '@handsontable/react';
 import { HotTable } from '@handsontable/react';
 import Handsontable from 'handsontable';
 import Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -10,7 +11,7 @@ import {
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
-import mtu from '~/components/PageEditor/MarkdownTableUtil';
+import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/components/PageEditor/markdown-table-util-for-editor';
 import { useHandsontableModal } from '~/stores/modal';
 import { useHandsontableModal } from '~/stores/modal';
 
 
 import ExpandOrContractButton from '../ExpandOrContractButton';
 import ExpandOrContractButton from '../ExpandOrContractButton';
@@ -32,11 +33,13 @@ export const HandsontableModal = (): JSX.Element => {
 
 
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
   const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
   const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
+  const { data: handsontableModalForEditorData } = useHandsontableModalForEditor();
 
 
   const isOpened = handsontableModalData?.isOpened ?? false;
   const isOpened = handsontableModalData?.isOpened ?? false;
+  const isOpendInEditor = handsontableModalForEditorData?.isOpened ?? false;
   const table = handsontableModalData?.table;
   const table = handsontableModalData?.table;
   const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
   const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
-  const editor = handsontableModalData?.editor;
+  const editor = handsontableModalForEditorData?.editor;
   const onSave = handsontableModalData?.onSave;
   const onSave = handsontableModalData?.onSave;
 
 
   const defaultMarkdownTable = () => {
   const defaultMarkdownTable = () => {
@@ -102,8 +105,9 @@ export const HandsontableModal = (): JSX.Element => {
   const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
   const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
 
 
   const handleModalOpen = () => {
   const handleModalOpen = () => {
-    const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
-    setMarkdownTable(table ?? defaultMarkdownTable);
+    const markdownTableState = table == null && editor != null ? getMarkdownTable(editor) : table;
+    const initTableInstance = markdownTableState == null ? defaultMarkdownTable : markdownTableState.clone();
+    setMarkdownTable(markdownTableState ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
     setMarkdownTableOnInit(initTableInstance);
     debouncedHandleWindowExpandedChange();
     debouncedHandleWindowExpandedChange();
   };
   };
@@ -163,7 +167,10 @@ export const HandsontableModal = (): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    mtu.replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+    if (editor == null) {
+      return;
+    }
+    replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
     cancel();
     cancel();
   };
   };
 
 
@@ -441,7 +448,7 @@ export const HandsontableModal = (): JSX.Element => {
 
 
   return (
   return (
     <Modal
     <Modal
-      isOpen={isOpened}
+      isOpen={isOpened || isOpendInEditor}
       toggle={cancel}
       toggle={cancel}
       backdrop="static"
       backdrop="static"
       keyboard={false}
       keyboard={false}

+ 1 - 1
apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx

@@ -56,7 +56,7 @@ export const MarkdownTableDataImportForm = (props: MarkdownTableDataImportFormPr
         <label htmlFor="data-import-form-type-select" className="form-label">{t('select_data_format')}</label>
         <label htmlFor="data-import-form-type-select" className="form-label">{t('select_data_format')}</label>
         <select
         <select
           id="data-import-form-type-select"
           id="data-import-form-type-select"
-          className="form-control"
+          className="form-select"
           placeholder="select"
           placeholder="select"
           value={dataFormat}
           value={dataFormat}
           onChange={(e) => { return setDataFormat(e.target.value) }}
           onChange={(e) => { return setDataFormat(e.target.value) }}

+ 0 - 190
apps/app/src/components/PageEditor/MarkdownTableUtil.js

@@ -1,190 +0,0 @@
-import MarkdownTable from '~/client/models/MarkdownTable';
-
-/**
- * Utility for markdown table
- */
-class MarkdownTableUtil {
-
-  constructor() {
-    // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
-    this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
-    this.tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
-    // https://regex101.com/r/7BN2fR/10
-    this.linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
-    // https://regex101.com/r/1UuWBJ/3
-    this.emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
-
-    this.getEot = this.getEot.bind(this);
-    this.getStrFromBot = this.getStrFromBot.bind(this);
-    this.getStrToEot = this.getStrToEot.bind(this);
-    this.isInTable = this.isInTable.bind(this);
-    this.replaceFocusedMarkdownTableWithEditor = this.replaceFocusedMarkdownTableWithEditor.bind(this);
-    this.replaceMarkdownTableWithReformed = this.replaceFocusedMarkdownTableWithEditor; // alias
-  }
-
-  /**
-   * return the postion of the BOT(beginning of table)
-   * (If the cursor is not in a table, return its position)
-   */
-  getBot(editor) {
-    const curPos = editor.getCursor();
-    if (!this.isInTable(editor)) {
-      return { line: curPos.line, ch: curPos.ch };
-    }
-
-    const firstLine = editor.getDoc().firstLine();
-    let line = curPos.line - 1;
-    for (; line >= firstLine; line--) {
-      const strLine = editor.getDoc().getLine(line);
-      if (!this.linePartOfTableRE.test(strLine)) {
-        break;
-      }
-    }
-    const botLine = Math.max(firstLine, line + 1);
-    return { line: botLine, ch: 0 };
-  }
-
-  /**
-   * return the postion of the EOT(end of table)
-   * (If the cursor is not in a table, return its position)
-   */
-  getEot(editor) {
-    const curPos = editor.getCursor();
-    if (!this.isInTable(editor)) {
-      return { line: curPos.line, ch: curPos.ch };
-    }
-
-    const lastLine = editor.getDoc().lastLine();
-    let line = curPos.line + 1;
-    for (; line <= lastLine; line++) {
-      const strLine = editor.getDoc().getLine(line);
-      if (!this.linePartOfTableRE.test(strLine)) {
-        break;
-      }
-    }
-    const eotLine = Math.min(line - 1, lastLine);
-    const lineLength = editor.getDoc().getLine(eotLine).length;
-    return { line: eotLine, ch: lineLength };
-  }
-
-  /**
-   * return strings from BOT(beginning of table) to the cursor position
-   */
-  getStrFromBot(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(this.getBot(editor), curPos);
-  }
-
-  /**
-   * return strings from the cursor position to EOT(end of table)
-   */
-  getStrToEot(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(curPos, this.getEot(editor));
-  }
-
-  /**
-   * return MarkdownTable instance of the table where the cursor is
-   * (If the cursor is not in a table, return null)
-   */
-  getMarkdownTable(editor) {
-    if (!this.isInTable(editor)) {
-      return null;
-    }
-
-    const strFromBotToEot = editor.getDoc().getRange(this.getBot(editor), this.getEot(editor));
-    return MarkdownTable.fromMarkdownString(strFromBotToEot);
-  }
-
-  getMarkdownTableFromLine(markdown, bol, eol) {
-    const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
-    return MarkdownTable.fromMarkdownString(tableLines);
-  }
-
-  /**
-   * return boolean value whether the cursor position is end of line
-   */
-  isEndOfLine(editor) {
-    const curPos = editor.getCursor();
-    return (curPos.ch === editor.getDoc().getLine(curPos.line).length);
-  }
-
-  /**
-   * return boolean value whether the cursor position is in a table
-   */
-  isInTable(editor) {
-    const curPos = editor.getCursor();
-    return this.linePartOfTableRE.test(editor.getDoc().getLine(curPos.line));
-  }
-
-  /**
-   * add a row at the end
-   * (This function overwrite directory markdown table specified as argument.)
-   * @param {MarkdownTable} markdown table
-   */
-  addRowToMarkdownTable(mdtable) {
-    const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
-    const newRow = [];
-    (new Array(numCol)).forEach(() => { return newRow.push('') }); // create cols
-    mdtable.table.push(newRow);
-  }
-
-  /**
-   * return markdown table that is merged all of markdown table in array
-   * (The merged markdown table options are used for the first markdown table.)
-   * @param {Array} array of markdown table
-   */
-  mergeMarkdownTable(mdtableList) {
-    if (mdtableList == null || !(mdtableList instanceof Array)) {
-      return undefined;
-    }
-
-    let newTable = [];
-    const options = mdtableList[0].options; // use option of first markdown-table
-    mdtableList.forEach((mdtable) => {
-      newTable = newTable.concat(mdtable.table);
-    });
-    return (new MarkdownTable(newTable, options));
-  }
-
-  /**
-   * replace focused markdown table with editor
-   * (A replaced table is reformed by markdown-table.)
-   * @param {MarkdownTable} table
-   */
-  replaceFocusedMarkdownTableWithEditor(editor, table) {
-    const curPos = editor.getCursor();
-    editor.getDoc().replaceRange(table.toString(), this.getBot(editor), this.getEot(editor));
-    editor.getDoc().setCursor(curPos.line + 1, 2);
-  }
-
-  /**
-   * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
-   * @param {string} markdown
-   * @param {MarkdownTable} table
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  replaceMarkdownTableInMarkdown(table, markdown, beginLineNumber, endLineNumber) {
-    const splitMarkdown = markdown.split(/\r\n|\r|\n/);
-    const markdownBeforeTable = splitMarkdown.slice(0, beginLineNumber - 1);
-    const markdownAfterTable = splitMarkdown.slice(endLineNumber);
-
-    let newMarkdown = '';
-    if (markdownBeforeTable.length > 0) {
-      newMarkdown += `${markdownBeforeTable.join('\n')}\n`;
-    }
-    newMarkdown += table;
-    if (markdownAfterTable.length > 0) {
-      newMarkdown += `\n${markdownAfterTable.join('\n')}`;
-    }
-
-    return newMarkdown;
-  }
-
-}
-
-// singleton pattern
-const instance = new MarkdownTableUtil();
-Object.freeze(instance);
-export default instance;

+ 18 - 27
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -16,8 +16,9 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
-import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -58,11 +59,11 @@ import { PageHeader } from '../PageHeader/PageHeader';
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
 // import Editor from './Editor';
 // import Editor from './Editor';
+import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import Preview from './Preview';
 import scrollSyncHelper from './ScrollSyncHelper';
 import scrollSyncHelper from './ScrollSyncHelper';
 
 
 import '@growi/editor/dist/style.css';
 import '@growi/editor/dist/style.css';
-import EditorNavbarBottom from './EditorNavbarBottom';
 
 
 
 
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
@@ -99,7 +100,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { data: grantData } = useSelectedGrant();
   const { data: grantData } = useSelectedGrant();
-  const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+  const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
@@ -128,6 +129,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
 
 
+  const shouldExpandContent = useShouldExpandContent(currentPage);
+
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
 
@@ -215,16 +218,18 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     if (grantData == null) {
     if (grantData == null) {
       return;
       return;
     }
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       grant: grantData.grant,
-      pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      // pageTags: pageTags ?? [],
+      grantUserGroupIds: grantedGroups,
     };
     };
     return optionsToSave;
     return optionsToSave;
-  }, [grantData, isSlackEnabled, pageTags]);
+  }, [grantData, isSlackEnabled]);
 
 
 
 
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
@@ -308,10 +313,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const uploadHandler = useCallback((files: File[]) => {
   const uploadHandler = useCallback((files: File[]) => {
     files.forEach(async(file) => {
     files.forEach(async(file) => {
       try {
       try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        const resLimit: any = await apiGet('/attachments.limit', {
-          fileSize: file.size,
-        });
+        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
 
 
         if (!resLimit.isUploadable) {
         if (!resLimit.isUploadable) {
           throw new Error(resLimit.errorMessage);
           throw new Error(resLimit.errorMessage);
@@ -319,17 +321,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
 
         const formData = new FormData();
         const formData = new FormData();
         formData.append('file', file);
         formData.append('file', file);
-        if (currentPagePath != null) {
-          formData.append('path', currentPagePath);
-        }
         if (pageId != null) {
         if (pageId != null) {
           formData.append('page_id', pageId);
           formData.append('page_id', pageId);
         }
         }
-        if (pageId == null) {
-          formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
-        }
 
 
-        const resAdd: any = await apiPostForm('/attachments.add', formData);
+        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
+
         const attachment = resAdd.attachment;
         const attachment = resAdd.attachment;
         const fileName = attachment.originalName;
         const fileName = attachment.originalName;
 
 
@@ -339,23 +336,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
           // modify to "![fileName](url)" syntax
           // modify to "![fileName](url)" syntax
           insertText = `!${insertText}`;
           insertText = `!${insertText}`;
         }
         }
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.insertText(insertText);
+
         codeMirrorEditor?.insertText(insertText);
         codeMirrorEditor?.insertText(insertText);
       }
       }
       catch (e) {
       catch (e) {
         logger.error('failed to upload', e);
         logger.error('failed to upload', e);
         toastError(e);
         toastError(e);
       }
       }
-      finally {
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.terminateUploadingState();
-      }
     });
     });
 
 
-  }, [codeMirrorEditor, currentPagePath, pageId]);
+  }, [codeMirrorEditor, pageId]);
 
 
   const acceptedFileType = useMemo(() => {
   const acceptedFileType = useMemo(() => {
     if (!isUploadEnabled) {
     if (!isUploadEnabled) {
@@ -596,6 +586,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             rendererOptions={rendererOptions}
             rendererOptions={rendererOptions}
             markdown={markdownToPreview}
             markdown={markdownToPreview}
             pagePath={currentPagePath}
             pagePath={currentPagePath}
+            expandContentWidth={shouldExpandContent}
             // TODO: implement
             // TODO: implement
             // refs: https://redmine.weseek.co.jp/issues/126519
             // refs: https://redmine.weseek.co.jp/issues/126519
             // onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
             // onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}

+ 9 - 20
apps/app/src/components/PageEditor/Preview.module.scss

@@ -1,29 +1,18 @@
-@use '~/styles/variables' as var;
 @use '~/styles/mixins';
 @use '~/styles/mixins';
 
 
-@include mixins.editing(true) {
-  .page-editor-preview-body :global {
+.page-editor-preview-body :global {
+  .wiki {
+    max-width: 980px;
+    margin: 0 auto;
   }
   }
 }
 }
 
 
 // modify width for fluid layout
 // modify width for fluid layout
-@include mixins.editing(true) {
-  .dynamic-layout-root:not(.growi-layout-fluid) {
-    :local {
-      .page-editor-preview-body :global {
-        .wiki {
-          max-width: 980px;
-          margin: 0 auto;
-        }
-      }
-    }
-  }
-  .dynamic-layout-root.growi-layout-fluid {
-    :local {
-      .page-editor-preview-body :global {
-        .wiki {
-          margin: 0 auto;
-        }
+.page-editor-preview-body {
+  &:global {
+    &.fluid-layout {
+      .wiki {
+        @include mixins.fluid-layout();
       }
       }
     }
     }
   }
   }

+ 6 - 2
apps/app/src/components/PageEditor/Preview.tsx

@@ -9,13 +9,14 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 
 
 import styles from './Preview.module.scss';
 import styles from './Preview.module.scss';
 
 
-const moduleClass = styles['page-editor-preview-body'];
+const moduleClass = styles['page-editor-preview-body'] ?? '';
 
 
 
 
 type Props = {
 type Props = {
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   markdown?: string,
   markdown?: string,
   pagePath?: string | null,
   pagePath?: string | null,
+  expandContentWidth?: boolean,
   onScroll?: (scrollTop: number) => void,
   onScroll?: (scrollTop: number) => void,
 }
 }
 
 
@@ -24,11 +25,14 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
   const {
   const {
     rendererOptions,
     rendererOptions,
     markdown, pagePath,
     markdown, pagePath,
+    expandContentWidth,
   } = props;
   } = props;
 
 
+  const fluidLayoutClass = expandContentWidth ? 'fluid-layout' : '';
+
   return (
   return (
     <div
     <div
-      className={`${moduleClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
+      className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       ref={ref}
       ref={ref}
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
       onScroll={(event: SyntheticEvent<HTMLDivElement>) => {
         if (props.onScroll != null) {
         if (props.onScroll != null) {

+ 147 - 0
apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts

@@ -0,0 +1,147 @@
+import type { EditorView } from '@codemirror/view';
+
+import MarkdownTable from '~/client/models/MarkdownTable';
+
+// https://regex101.com/r/7BN2fR/10
+const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
+// https://regex101.com/r/1UuWBJ/3
+export const emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
+
+const curPos = (editor: EditorView): number => {
+  return editor.state.selection.main.head;
+};
+
+/**
+   * return boolean value whether the cursor position is in a table
+   */
+export const isInTable = (editor: EditorView): boolean => {
+  const lineText = editor.state.doc.lineAt(curPos(editor)).text;
+  return linePartOfTableRE.test(lineText);
+};
+
+/**
+   * return the postion of the BOT(beginning of table)
+   * (If the cursor is not in a table, return its position)
+   */
+const getBot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return curPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const firstLine = 1;
+  let line = doc.lineAt(curPos(editor)).number - 1;
+  for (; line >= firstLine; line--) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+  const botLine = Math.max(firstLine, line + 1);
+  return doc.line(botLine).from;
+};
+
+/**
+   * return the postion of the EOT(end of table)
+   * (If the cursor is not in a table, return its position)
+   */
+const getEot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return curPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const lastLine = doc.lines;
+  let line = doc.lineAt(curPos(editor)).number + 1;
+  for (; line <= lastLine; line++) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+  const eotLine = Math.min(line - 1, lastLine);
+  return doc.line(eotLine).to;
+};
+
+/**
+   * return strings from BOT(beginning of table) to the cursor position
+   */
+export const getStrFromBot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(getBot(editor), curPos(editor));
+};
+
+/**
+   * return strings from the cursor position to EOT(end of table)
+   */
+export const getStrToEot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(curPos(editor), getEot(editor));
+};
+
+/**
+   * return MarkdownTable instance of the table where the cursor is
+   * (If the cursor is not in a table, return null)
+   */
+export const getMarkdownTable = (editor: EditorView): MarkdownTable | undefined => {
+  if (!isInTable(editor)) {
+    return;
+  }
+
+  const strFromBotToEot = editor.state.sliceDoc(getBot(editor), getEot(editor));
+  return MarkdownTable.fromMarkdownString(strFromBotToEot);
+};
+
+/**
+   * return boolean value whether the cursor position is end of line
+   */
+export const isEndOfLine = (editor: EditorView): boolean => {
+  return curPos(editor) === editor.state.doc.lineAt(curPos(editor)).to;
+};
+
+/**
+   * add a row at the end
+   * (This function overwrite directory markdown table specified as argument.)
+   */
+export const addRowToMarkdownTable = (mdtable: MarkdownTable): any => {
+  const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
+  const newRow: string[] = [];
+  (new Array(numCol)).forEach(() => { return newRow.push('') }); // create cols
+  mdtable.table.push(newRow);
+};
+
+/**
+   * return markdown table that is merged all of markdown table in array
+   * (The merged markdown table options are used for the first markdown table.)
+   */
+export const mergeMarkdownTable = (mdtableList: MarkdownTable): MarkdownTable | undefined => {
+  if (mdtableList == null || !(mdtableList instanceof Array)) {
+    return undefined;
+  }
+
+  let newTable = [];
+  const options = mdtableList[0].options; // use option of first markdown-table
+  mdtableList.forEach((mdtable) => {
+    newTable = newTable.concat(mdtable.table);
+  });
+  return (new MarkdownTable(newTable, options));
+};
+
+/**
+   * replace focused markdown table with editor
+   * (A replaced table is reformed by markdown-table.)
+   */
+export const replaceFocusedMarkdownTableWithEditor = (editor: EditorView, table: MarkdownTable): void => {
+  const botPos = getBot(editor);
+  const eotPos = getEot(editor);
+
+  editor.dispatch({
+    changes: {
+      from: botPos,
+      to: eotPos,
+      insert: table.toString(),
+    },
+  });
+  editor.dispatch({
+    selection: { anchor: editor.state.doc.lineAt(curPos(editor)).to },
+  });
+  editor.focus();
+};

+ 26 - 0
apps/app/src/components/PageEditor/markdown-table-util-for-view.ts

@@ -0,0 +1,26 @@
+import MarkdownTable from '~/client/models/MarkdownTable';
+
+export const getMarkdownTableFromLine = (markdown: string, bol: number, eol: number): MarkdownTable => {
+  const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
+  return MarkdownTable.fromMarkdownString(tableLines);
+};
+
+/**
+   * return markdown where the markdown table specified by line number params is replaced to the markdown table specified by table param
+   */
+export const replaceMarkdownTableInMarkdown = (table: MarkdownTable, markdown: string, beginLineNumber: number, endLineNumber: number): string => {
+  const splitMarkdown = markdown.split(/\r\n|\r|\n/);
+  const markdownBeforeTable = splitMarkdown.slice(0, beginLineNumber - 1);
+  const markdownAfterTable = splitMarkdown.slice(endLineNumber);
+
+  let newMarkdown = '';
+  if (markdownBeforeTable.length > 0) {
+    newMarkdown += `${markdownBeforeTable.join('\n')}\n`;
+  }
+  newMarkdown += table;
+  if (markdownAfterTable.length > 0) {
+    newMarkdown += `\n${markdownAfterTable.join('\n')}`;
+  }
+
+  return newMarkdown;
+};

+ 10 - 5
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -9,11 +9,14 @@ import { useDeleteAttachmentModal } from '~/stores/modal';
 
 
 import styles from './RichAttachment.module.scss';
 import styles from './RichAttachment.module.scss';
 
 
-export const RichAttachment: React.FC<{
+type RichAttachmentProps = {
   attachmentId: string,
   attachmentId: string,
   url: string,
   url: string,
-  attachmentName: string
-}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  attachmentName: string,
+}
+
+export const RichAttachment = React.memo((props: RichAttachmentProps) => {
+  const { attachmentId, attachmentName } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
@@ -58,13 +61,15 @@ export const RichAttachment: React.FC<{
           </div>
           </div>
           <div className="ps-0">
           <div className="ps-0">
             <div className="d-inline-block">
             <div className="d-inline-block">
-              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+              {/* Since we need to include the "referer" to view the attachment on the shared page */}
+              {/* eslint-disable-next-line react/jsx-no-target-blank */}
+              <a target="_blank" rel="noopener" href={filePathProxied}>
                 {attachmentName || originalName}
                 {attachmentName || originalName}
               </a>
               </a>
               <a className="ms-2 attachment-download" href={downloadPathProxied}>
               <a className="ms-2 attachment-download" href={downloadPathProxied}>
                 <i className="icon-cloud-download" />
                 <i className="icon-cloud-download" />
               </a>
               </a>
-              <a className="ms-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+              <a className="ml-2 text-danger attachment-delete d-share-link-none" type="button" onClick={onClickTrashButtonHandler}>
                 <i className="icon-trash" />
                 <i className="icon-trash" />
               </a>
               </a>
             </div>
             </div>

+ 3 - 4
apps/app/src/components/SavePageControls.tsx

@@ -18,7 +18,7 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import GrantSelector from './SavePageControls/GrantSelector';
+import { GrantSelector } from './SavePageControls/GrantSelector';
 
 
 
 
 declare global {
 declare global {
@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
     return null;
   }
   }
 
 
-  const { grant, grantedGroup } = grantData;
+  const { grant, grantedGroups } = grantData;
 
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelSubmitButton = t('Update');
@@ -82,8 +82,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
             <GrantSelector
               grant={grant}
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
               disabled={isGrantSelectorDisabledPage}
-              grantGroupId={grantedGroup?.id}
-              grantGroupName={grantedGroup?.name}
+              grantedGroups={grantedGroups}
               onUpdateGrant={updateGrantHandler}
               onUpdateGrant={updateGrantHandler}
             />
             />
           </div>
           </div>

+ 27 - 37
apps/app/src/components/SavePageControls/GrantSelector.tsx → apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,7 +1,6 @@
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
 
 
-import { isPopulated } from '@growi/core';
-import type { IUserGroupHasId } from '@growi/core';
+import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   UncontrolledDropdown,
   UncontrolledDropdown,
@@ -12,8 +11,8 @@ import {
 
 
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxMyUserGroupRelations } from '~/stores/user-group';
 
 
+import { useMyUserGroups } from './use-my-user-groups';
 
 
 const AVAILABLE_GRANTS = [
 const AVAILABLE_GRANTS = [
   {
   {
@@ -35,8 +34,11 @@ const AVAILABLE_GRANTS = [
 type Props = {
 type Props = {
   disabled?: boolean,
   disabled?: boolean,
   grant: number,
   grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
+  grantedGroups?: {
+    id: string,
+    name: string,
+    type: GroupType,
+  }[]
 
 
   onUpdateGrant?: (grantData: IPageGrantData) => void,
   onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
 }
@@ -44,15 +46,14 @@ type Props = {
 /**
 /**
  * Page grant select component
  * Page grant select component
  */
  */
-const GrantSelector = (props: Props): JSX.Element => {
+export const GrantSelector = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
     disabled,
     disabled,
-    grantGroupName,
+    grantedGroups,
     onUpdateGrant,
     onUpdateGrant,
     grant: currentGrant,
     grant: currentGrant,
-    grantGroupId,
   } = props;
   } = props;
 
 
 
 
@@ -61,12 +62,12 @@ const GrantSelector = (props: Props): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
   const shouldFetch = isSelectGroupModalShown;
   const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroupRelations, mutate: mutateMyUserGroupRelations } = useSWRxMyUserGroupRelations(shouldFetch);
+  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
 
 
   const showSelectGroupModal = useCallback(() => {
   const showSelectGroupModal = useCallback(() => {
-    mutateMyUserGroupRelations();
+    updateMyUserGroups();
     setIsSelectGroupModalShown(true);
     setIsSelectGroupModalShown(true);
-  }, [mutateMyUserGroupRelations]);
+  }, [updateMyUserGroups]);
 
 
   /**
   /**
    * change event handler for grant selector
    * change event handler for grant selector
@@ -79,13 +80,13 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     if (onUpdateGrant != null) {
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroup: undefined });
+      onUpdateGrant({ grant, grantedGroups: undefined });
     }
     }
   }, [onUpdateGrant, showSelectGroupModal]);
   }, [onUpdateGrant, showSelectGroupModal]);
 
 
-  const groupListItemClickHandler = useCallback((grantGroup: IUserGroupHasId) => {
-    if (onUpdateGrant != null) {
-      onUpdateGrant({ grant: 5, grantedGroup: { id: grantGroup._id, name: grantGroup.name } });
+  const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
+    if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
+      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
     }
     }
 
 
     // hide modal
     // hide modal
@@ -101,7 +102,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
     let dropdownToggleLabelElm;
 
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantGroupId != null)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
         : opt.label;
 
 
@@ -122,11 +123,11 @@ const GrantSelector = (props: Props): JSX.Element => {
     });
     });
 
 
     // add specified group option
     // add specified group option
-    if (grantGroupId != null) {
+    if (grantedGroups != null && grantedGroups.length > 0) {
       const labelElm = (
       const labelElm = (
         <span>
         <span>
           <i className="icon icon-fw icon-organization"></i>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantGroupName}</span>
+          <span className="label">{grantedGroups[0].name}</span>
         </span>
         </span>
       );
       );
 
 
@@ -148,7 +149,7 @@ const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
         </UncontrolledDropdown>
       </div>
       </div>
     );
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantGroupId, grantGroupName, t]);
+  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
 
 
   /**
   /**
    * Render select grantgroup modal.
    * Render select grantgroup modal.
@@ -159,7 +160,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     // show spinner
     // show spinner
-    if (myUserGroupRelations == null) {
+    if (myUserGroups == null) {
       return (
       return (
         <div className="my-3 text-center">
         <div className="my-3 text-center">
           <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
           <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
@@ -167,16 +168,7 @@ const GrantSelector = (props: Props): JSX.Element => {
       );
       );
     }
     }
 
 
-    // extract IUserGroupHasId
-    const userRelatedGroups: IUserGroupHasId[] = myUserGroupRelations
-      .map((relation) => {
-        // relation.relatedGroup should be populated by server
-        return isPopulated(relation.relatedGroup) ? relation.relatedGroup : undefined;
-      })
-      // exclude undefined elements
-      .filter((elem): elem is IUserGroupHasId => elem != null);
-
-    if (userRelatedGroups.length === 0) {
+    if (myUserGroups.length === 0) {
       return (
       return (
         <div>
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
@@ -189,10 +181,11 @@ const GrantSelector = (props: Props): JSX.Element => {
 
 
     return (
     return (
       <div className="list-group">
       <div className="list-group">
-        { userRelatedGroups.map((group) => {
+        { myUserGroups.map((group) => {
           return (
           return (
-            <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5>{group.name}</h5>
+            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
+              <h5 className="d-inline-block">{group.item.name}</h5>
+              {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
             </button>
           );
           );
@@ -200,7 +193,7 @@ const GrantSelector = (props: Props): JSX.Element => {
       </div>
       </div>
     );
     );
 
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroupRelations, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t]);
 
 
   return (
   return (
     <>
     <>
@@ -223,7 +216,4 @@ const GrantSelector = (props: Props): JSX.Element => {
       ) }
       ) }
     </>
     </>
   );
   );
-
 };
 };
-
-export default GrantSelector;

+ 1 - 0
apps/app/src/components/SavePageControls/GrantSelector/index.ts

@@ -0,0 +1 @@
+export * from './GrantSelector';

+ 38 - 0
apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts

@@ -0,0 +1,38 @@
+import { GroupType } from '@growi/core';
+
+import { useSWRxMyExternalUserGroups } from '~/features/external-user-group/client/stores/external-user-group';
+import { useSWRxMyUserGroups } from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useMyUserGroups = (shouldFetch: boolean) => {
+  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch);
+  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch);
+
+  const update = () => {
+    mutateMyUserGroups();
+    mutateMyExternalUserGroups();
+  };
+
+  if (myUserGroups == null || myExternalUserGroups == null) {
+    return { data: null, update };
+  }
+
+  const myUserGroupsData = myUserGroups
+    .map((group) => {
+      return {
+        item: group,
+        type: GroupType.userGroup,
+      };
+    });
+  const myExternalUserGroupsData = myExternalUserGroups
+    .map((group) => {
+      return {
+        item: group,
+        type: GroupType.externalUserGroup,
+      };
+    });
+
+  const data = [...myUserGroupsData, ...myExternalUserGroupsData];
+
+  return { data, update };
+};

+ 8 - 0
apps/app/src/components/SearchPage/SearchResultContent.module.scss

@@ -1,2 +1,10 @@
+@use '~/styles/mixins';
+
 .search-result-content :global {
 .search-result-content :global {
 }
 }
+
+.fluid-layout :global {
+  .grw-container-convertible {
+    @include mixins.fluid-layout();
+  }
+}

+ 13 - 13
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -10,12 +10,12 @@ import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { useLayoutFluidClassName } from '~/client/services/layout';
+import { useShouldExpandContent } from '~/client/services/layout';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/toastr';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { useCurrentUser, useIsContainerFluid } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import {
 import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 } from '~/stores/modal';
@@ -32,9 +32,10 @@ import type { PageContentFooterProps } from '../PageContentFooter';
 import styles from './SearchResultContent.module.scss';
 import styles from './SearchResultContent.module.scss';
 
 
 const moduleClass = styles['search-result-content'];
 const moduleClass = styles['search-result-content'];
+const _fluidLayoutClass = styles['fluid-layout'];
 
 
 
 
-const SubNavButtons = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
+const PageControls = dynamic(() => import('../PageControls').then(mod => mod.PageControls), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
 const RevisionLoader = dynamic<RevisionLoaderProps>(() => import('../Page/RevisionLoader').then(mod => mod.RevisionLoader), { ssr: false });
 const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageComment = dynamic<PageCommentProps>(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
 const PageContentFooter = dynamic<PageContentFooterProps>(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
@@ -123,12 +124,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: rendererOptions } = useSearchResultOptions(pageWithMeta.data.path, highlightKeywords);
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
-  const { data: isContainerFluid } = useIsContainerFluid();
 
 
-  const [isExpandContentWidth, setIsExpandContentWidth] = useState(page.expandContentWidth);
-
-  // TODO: determine className by the 'expandContentWidth' from the updated page
-  const growiLayoutFluidClass = useLayoutFluidClassName(isExpandContentWidth);
+  const shouldExpandContent = useShouldExpandContent(page);
 
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -176,7 +173,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     await updateContentWidth(pageId, value);
     await updateContentWidth(pageId, value);
-    setIsExpandContentWidth(value);
+
+    // TODO: revalidate page data and update shouldExpandContent
   }, []);
   }, []);
 
 
   const RightComponent = useCallback(() => {
   const RightComponent = useCallback(() => {
@@ -188,11 +186,11 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
 
     return (
     return (
       <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
       <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
-        <SubNavButtons
+        <PageControls
           pageId={page._id}
           pageId={page._id}
           revisionId={revisionId}
           revisionId={revisionId}
           path={page.path}
           path={page.path}
-          expandContentWidth={isExpandContentWidth ?? isContainerFluid}
+          expandContentWidth={shouldExpandContent}
           showPageControlDropdown={showPageControlDropdown}
           showPageControlDropdown={showPageControlDropdown}
           forceHideMenuItems={forceHideMenuItems}
           forceHideMenuItems={forceHideMenuItems}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
           additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
@@ -203,16 +201,18 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         />
         />
       </div>
       </div>
     );
     );
-  }, [page, isExpandContentWidth, showPageControlDropdown, forceHideMenuItems, isContainerFluid,
+  }, [page, shouldExpandContent, showPageControlDropdown, forceHideMenuItems,
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
 
   const isRenderable = page != null && rendererOptions != null;
   const isRenderable = page != null && rendererOptions != null;
 
 
+  const fluidLayoutClass = shouldExpandContent ? _fluidLayoutClass : '';
+
   return (
   return (
     <div
     <div
       key={page._id}
       key={page._id}
       data-testid="search-result-content"
       data-testid="search-result-content"
-      className={`dynamic-layout-root ${growiLayoutFluidClass} ${moduleClass}`}
+      className={`dynamic-layout-root ${moduleClass} ${fluidLayoutClass}`}
     >
     >
       <RightComponent />
       <RightComponent />
 
 

+ 4 - 0
apps/app/src/components/ShareLinkPageView.tsx

@@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
+import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
@@ -44,6 +45,8 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
 
 
   const { data: viewOptions } = useViewOptions();
   const { data: viewOptions } = useViewOptions();
 
 
+  const shouldExpandContent = useShouldExpandContent(page);
+
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
 
 
   const specialContents = useMemo(() => {
   const specialContents = useMemo(() => {
@@ -93,6 +96,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     <PageViewLayout
     <PageViewLayout
       headerContents={headerContents}
       headerContents={headerContents}
       sideContents={sideContents}
       sideContents={sideContents}
+      expandContentWidth={shouldExpandContent}
     >
     >
       { specialContents }
       { specialContents }
       { specialContents == null && (
       { specialContents == null && (

+ 2 - 1
apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -40,13 +40,14 @@
   // set width for truncation
   // set width for truncation
   $grw-page-controls-width: 226px;
   $grw-page-controls-width: 226px;
   $grw-page-editor-mode-manager-width: 90px;
   $grw-page-editor-mode-manager-width: 90px;
-  $grw-contextual-subnavigation-padding-right: 8px;
+  $grw-contextual-subnavigation-padding-right: 12px;
   $gap: 8px;
   $gap: 8px;
   width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
   width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
 
 
   @include bs.media-breakpoint-up(md) {
   @include bs.media-breakpoint-up(md) {
     $grw-page-editor-mode-manager-width: 140px;
     $grw-page-editor-mode-manager-width: 140px;
     $gap: 24px;
     $gap: 24px;
+    $grw-contextual-subnavigation-padding-right: 24px;
     width: calc(100vw - #{var.$grw-sidebar-nav-width + $grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
     width: calc(100vw - #{var.$grw-sidebar-nav-width + $grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
   }
   }
 }
 }

+ 7 - 7
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -2,12 +2,13 @@ import React from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { LabelType } from '~/interfaces/template';
+
 type DropendMenuProps = {
 type DropendMenuProps = {
   todaysPath: string,
   todaysPath: string,
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
-  onClickTemplateForChildrenButtonHandler: () => Promise<void>
-  onClickTemplateForDescendantsButtonHandler: () => Promise<void>
+  onClickTemplateButtonHandler: (label: LabelType) => Promise<void>
 }
 }
 
 
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
@@ -15,8 +16,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
     todaysPath,
     todaysPath,
     onClickCreateNewPageButtonHandler,
     onClickCreateNewPageButtonHandler,
     onClickCreateTodaysButtonHandler,
     onClickCreateTodaysButtonHandler,
-    onClickTemplateForChildrenButtonHandler,
-    onClickTemplateForDescendantsButtonHandler,
+    onClickTemplateButtonHandler,
   } = props;
   } = props;
 
 
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
@@ -48,7 +48,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       <li>
       <li>
         <button
         <button
           className="dropdown-item"
           className="dropdown-item"
-          onClick={onClickTemplateForChildrenButtonHandler}
+          onClick={() => onClickTemplateButtonHandler('_template')}
           type="button"
           type="button"
         >
         >
           {t('create_page_dropdown.template.children')}
           {t('create_page_dropdown.template.children')}
@@ -57,10 +57,10 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
       <li>
       <li>
         <button
         <button
           className="dropdown-item"
           className="dropdown-item"
-          onClick={onClickTemplateForDescendantsButtonHandler}
+          onClick={() => onClickTemplateButtonHandler('__template')}
           type="button"
           type="button"
         >
         >
-          {t('create_page_dropdown.template.decendants')}
+          {t('create_page_dropdown.template.descendants')}
         </button>
         </button>
       </li>
       </li>
     </ul>
     </ul>

+ 26 - 150
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,176 +1,53 @@
-import React, { useCallback, useState } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
-import { useRouter } from 'next/router';
+import { useTranslation } from 'react-i18next';
 
 
-import { createPage, exist } from '~/client/services/page-operation';
+import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { LabelType } from '~/interfaces/template';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
 
 
-import { DropendMenu } from './DropendMenu';
 import { CreateButton } from './CreateButton';
 import { CreateButton } from './CreateButton';
+import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { DropendToggle } from './DropendToggle';
-
-const logger = loggerFactory('growi:cli:PageCreateButton');
+import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
 
 export const PageCreateButton = React.memo((): JSX.Element => {
 export const PageCreateButton = React.memo((): JSX.Element => {
-  const router = useRouter();
+  const { t } = useTranslation('commons');
+
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
   const [isHovered, setIsHovered] = useState(false);
   const [isHovered, setIsHovered] = useState(false);
-  const [isCreating, setIsCreating] = useState(false);
 
 
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  const todaysPath = `${userHomepagePath}/memo/${now}`;
+  const todaysPath = `${userHomepagePath}/${t('create_page_dropdown.todays.memo')}/${now}`;
 
 
-  const onMouseEnterHandler = () => {
-    setIsHovered(true);
-  };
-
-  const onMouseLeaveHandler = () => {
-    setIsHovered(false);
-  };
-
-  const onClickCreateNewPageButtonHandler = useCallback(async() => {
-    if (isLoading) return;
+  const { onClickHandler: onClickNewButton, isPageCreating: isNewPageCreating } = useOnNewButtonClicked(isLoading, currentPage);
+  const { onClickHandler: onClickTodaysButton, isPageCreating: isTodaysPageCreating } = useOnTodaysButtonClicked(todaysPath, currentUser);
+  const { onClickHandler: onClickTemplateButton, isPageCreating: isTemplatePageCreating } = useOnTemplateButtonClicked(currentPage?.path);
 
 
+  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
     try {
-      setIsCreating(true);
-
-      const parentPath = currentPage == null
-        ? '/'
-        : currentPage.path;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
-        shouldGeneratePath: true,
-      };
-
-      const response = await createPage(parentPath, '', params);
-
-      router.push(`${response.page.id}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-
-  const onClickCreateTodaysButtonHandler = useCallback(async() => {
-    if (currentUser == null) {
-      return;
-    }
-
-    try {
-      setIsCreating(true);
-
-      // TODO: get grant, grantUserGroupId data from parent page
-      // https://redmine.weseek.co.jp/issues/133892
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 1,
-        pageTags: [],
-      };
-
-      const res = await exist(JSON.stringify([todaysPath]));
-      if (!res.pages[todaysPath]) {
-        await createPage(todaysPath, '', params);
-      }
-
-      router.push(`${todaysPath}#edit`);
+      await onClickTemplateButton(label);
     }
     }
     catch (err) {
     catch (err) {
-      logger.warn(err);
       toastError(err);
       toastError(err);
     }
     }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentUser, router, todaysPath]);
+  }, [onClickTemplateButton]);
 
 
-  const onClickTemplateForChildrenButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
-
-      const path = currentPage == null || currentPage.path === '/'
-        ? '/_template'
-        : `${currentPage.path}/_template`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
-
-  const onClickTemplateForDescendantsButtonHandler = useCallback(async() => {
-    if (isLoading) return;
-
-    try {
-      setIsCreating(true);
-
-      const path = currentPage == null || currentPage.path === '/'
-        ? '/__template'
-        : `${currentPage.path}/__template`;
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: currentPage?.grant || 1,
-        pageTags: [],
-        grantUserGroupId: currentPage?.grantedGroup?._id,
-      };
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        await createPage(path, '', params);
-      }
+  const onMouseEnterHandler = () => {
+    setIsHovered(true);
+  };
 
 
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      logger.warn(err);
-      toastError(err);
-    }
-    finally {
-      setIsCreating(false);
-    }
-  }, [currentPage, isLoading, router]);
+  const onMouseLeaveHandler = () => {
+    setIsHovered(false);
+  };
 
 
-  // TODO: update button design
-  // https://redmine.weseek.co.jp/issues/132683
   return (
   return (
     <div
     <div
       className="d-flex flex-row"
       className="d-flex flex-row"
@@ -180,8 +57,8 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       <div className="btn-group flex-grow-1">
       <div className="btn-group flex-grow-1">
         <CreateButton
         <CreateButton
           className="z-2"
           className="z-2"
-          onClick={onClickCreateNewPageButtonHandler}
-          disabled={isCreating}
+          onClick={onClickNewButton}
+          disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
         />
         />
       </div>
       </div>
       { isHovered && (
       { isHovered && (
@@ -193,10 +70,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           />
           />
           <DropendMenu
           <DropendMenu
             todaysPath={todaysPath}
             todaysPath={todaysPath}
-            onClickCreateNewPageButtonHandler={onClickCreateNewPageButtonHandler}
-            onClickCreateTodaysButtonHandler={onClickCreateTodaysButtonHandler}
-            onClickTemplateForChildrenButtonHandler={onClickTemplateForChildrenButtonHandler}
-            onClickTemplateForDescendantsButtonHandler={onClickTemplateForDescendantsButtonHandler}
+            onClickCreateNewPageButtonHandler={onClickNewButton}
+            onClickCreateTodaysButtonHandler={onClickTodaysButton}
+            onClickTemplateButtonHandler={onClickTemplateButtonHandler}
           />
           />
         </div>
         </div>
       )}
       )}

+ 95 - 0
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -0,0 +1,95 @@
+import { useCallback, useState } from 'react';
+
+import type { Nullable, IPagePopulatedToShowRevision, IUserHasId } from '@growi/core';
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import { toastError } from '~/client/util/toastr';
+
+export const useOnNewButtonClicked = (
+    isLoading: boolean,
+    currentPage?: IPagePopulatedToShowRevision | null,
+): {
+  onClickHandler: () => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async() => {
+    if (isLoading) return;
+
+    try {
+      setIsPageCreating(true);
+
+      const parentPath = currentPage == null
+        ? '/'
+        : currentPage.path;
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+        // grant: currentPage?.grant || 1,
+        // grantUserGroupId: currentPage?.grantedGroup?._id,
+        shouldGeneratePath: true,
+      };
+
+      const response = await createPage(parentPath, '', params);
+
+      router.push(`${response.page.id}#edit`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentPage, isLoading, router]);
+
+  return { onClickHandler, isPageCreating };
+};
+
+export const useOnTodaysButtonClicked = (
+    todaysPath: string,
+    currentUser?: Nullable<IUserHasId> | undefined,
+): {
+  onClickHandler: () => Promise<void>,
+  isPageCreating: boolean
+} => {
+  const router = useRouter();
+  const [isPageCreating, setIsPageCreating] = useState(false);
+
+  const onClickHandler = useCallback(async() => {
+    if (currentUser == null) {
+      return;
+    }
+
+    try {
+      setIsPageCreating(true);
+
+      // TODO: get grant, grantUserGroupId data from parent page
+      // https://redmine.weseek.co.jp/issues/133892
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+      };
+
+      const res = await exist(JSON.stringify([todaysPath]));
+      if (!res.pages[todaysPath]) {
+        await createPage(todaysPath, '', params);
+      }
+
+      router.push(`${todaysPath}#edit`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setIsPageCreating(false);
+    }
+  }, [currentUser, router, todaysPath]);
+
+  return { onClickHandler, isPageCreating };
+};

+ 1 - 2
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -23,7 +23,7 @@ import { Ellipsis } from './Ellipsis';
 const logger = loggerFactory('growi:cli:Item');
 const logger = loggerFactory('growi:cli:Item');
 
 
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
+type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional>;
 
 
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -158,7 +158,6 @@ export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 
 
   return (
   return (
     <SimpleItem
     <SimpleItem
-      key={props.key}
       targetPathOrId={props.targetPathOrId}
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
       itemNode={props.itemNode}
       isOpen
       isOpen

+ 1 - 1
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -201,7 +201,7 @@ export const Sidebar = (): JSX.Element => {
         </DrawerToggler>
         </DrawerToggler>
       ) }
       ) }
       { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
       { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
-      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end vh-100`} data-testid="grw-sidebar">
+      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} data-testid="grw-sidebar">
         <ResizableContainer>
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
           <SidebarHead />
           <SidebarHead />

+ 1 - 1
apps/app/src/components/Sidebar/Tag.tsx

@@ -44,7 +44,7 @@ const Tag: FC = () => {
 
 
   // todo: adjust design by XD
   // todo: adjust design by XD
   return (
   return (
-    <div className="grw-container-convertible container-lg px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+    <div className="container-lg px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
       <div className="grw-sidebar-content-header py-3 d-flex">
       <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <h3 className="mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
         <SidebarHeaderReloadButton onClick={() => onReload()} />

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