فهرست منبع

Merge branch 'dev/7.0.x' into imprv/140199-141158-all-option-in-editor-config

reiji-h 2 سال پیش
والد
کامیت
7a967ad466
100فایلهای تغییر یافته به همراه550 افزوده شده و 438 حذف شده
  1. 1 1
      .devcontainer/Dockerfile
  2. 6 6
      .github/workflows/ci-app-prod.yml
  3. 3 3
      .github/workflows/ci-app.yml
  4. 3 3
      .github/workflows/ci-slackbot-proxy.yml
  5. 1 1
      .github/workflows/list-unhealthy-branches.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 2 2
      .github/workflows/release.yml
  8. 3 3
      .mergify.yml
  9. 1 1
      README.md
  10. 1 1
      README_JP.md
  11. 0 0
      apps/app/_obsolete/src/components/PageEditor/AbstractEditor.tsx
  12. 0 0
      apps/app/_obsolete/src/components/PageEditor/Editor.module.scss
  13. 0 0
      apps/app/_obsolete/src/components/PageEditor/Editor.tsx
  14. 0 0
      apps/app/_obsolete/src/components/PageEditor/EditorIcon.jsx
  15. 0 0
      apps/app/_obsolete/src/components/PageEditor/PasteHelper.js
  16. 0 0
      apps/app/_obsolete/src/components/PageEditor/PreventMarkdownListInterceptor.js
  17. 0 0
      apps/app/_obsolete/src/components/PageEditor/TextAreaEditor.jsx
  18. 4 4
      apps/app/docker/Dockerfile
  19. 5 5
      apps/app/package.json
  20. 3 2
      apps/app/public/static/locales/en_US/admin.json
  21. 3 2
      apps/app/public/static/locales/ja_JP/admin.json
  22. 3 2
      apps/app/public/static/locales/zh_CN/admin.json
  23. 1 1
      apps/app/src/client/models/MarkdownTable.js
  24. 18 0
      apps/app/src/client/services/use-toastr-on-error.tsx
  25. 2 2
      apps/app/src/components/Admin/AdminHome/AdminHome.jsx
  26. 1 1
      apps/app/src/components/Admin/App/AppSettingsPageContents.tsx
  27. 2 2
      apps/app/src/components/Admin/App/MaskedInput.tsx
  28. 4 3
      apps/app/src/components/Admin/AuditLog/ActivityTable.tsx
  29. 3 2
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  30. 3 2
      apps/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  31. 5 3
      apps/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  32. 1 1
      apps/app/src/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  33. 1 1
      apps/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx
  34. 2 2
      apps/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  35. 2 2
      apps/app/src/components/Admin/G2GDataTransferExportForm.tsx
  36. 4 4
      apps/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  37. 2 2
      apps/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  38. 7 2
      apps/app/src/components/Admin/Notification/NotificationTypeIcon.tsx
  39. 1 1
      apps/app/src/components/Admin/Notification/UserTriggerNotification.jsx
  40. 2 2
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  41. 4 4
      apps/app/src/components/Admin/Security/SecurityManagementContents.jsx
  42. 1 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  43. 4 4
      apps/app/src/components/Admin/SlackIntegration/Bridge.jsx
  44. 3 3
      apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  45. 2 2
      apps/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  46. 22 23
      apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  47. 4 2
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  48. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  49. 4 3
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  50. 8 4
      apps/app/src/components/Admin/Users/SortIcons.tsx
  51. 2 2
      apps/app/src/components/Admin/Users/UserMenu.module.scss
  52. 5 2
      apps/app/src/components/Admin/Users/UserMenu.tsx
  53. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  54. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  55. 1 1
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  56. 3 2
      apps/app/src/components/Common/CustomCopyToClipBoard.tsx
  57. 1 1
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  58. 2 2
      apps/app/src/components/Me/AssociateModal.tsx
  59. 3 3
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  60. 2 3
      apps/app/src/components/PageControls/BookmarkButtons.tsx
  61. 4 2
      apps/app/src/components/PageControls/LikeButtons.tsx
  62. 2 1
      apps/app/src/components/PageControls/SeenUserInfo.tsx
  63. 3 10
      apps/app/src/components/PageControls/user-list-popover.module.scss
  64. 54 90
      apps/app/src/components/PageCreateModal.tsx
  65. 1 1
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  66. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  67. 2 2
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  68. 28 32
      apps/app/src/components/PageEditor/PageEditor.tsx
  69. 1 0
      apps/app/src/components/PageEditor/Preview.module.scss
  70. 7 10
      apps/app/src/components/PageEditor/Preview.tsx
  71. 53 0
      apps/app/src/components/PageHeader/EditingUserList.tsx
  72. 17 4
      apps/app/src/components/PageHeader/PageHeader.tsx
  73. 5 0
      apps/app/src/components/PageHeader/user-list-popover.module.scss
  74. 1 1
      apps/app/src/components/PageManagement/ApiErrorMessage.jsx
  75. 1 1
      apps/app/src/components/PageRenameModal.tsx
  76. 17 20
      apps/app/src/components/SearchTypeahead.tsx
  77. 2 17
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  78. 1 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  79. 3 3
      apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  80. 1 1
      apps/app/src/components/SystemVersion.tsx
  81. 3 2
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  82. 8 5
      apps/app/src/components/TreeItem/SimpleItem.tsx
  83. 1 1
      apps/app/src/components/UsersHomepageFooter.tsx
  84. 7 7
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  85. 6 3
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  86. 8 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  87. 10 3
      apps/app/src/features/search/client/components/SearchForm.tsx
  88. 10 10
      apps/app/src/features/search/client/components/SearchHelp.tsx
  89. 1 1
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  90. 22 16
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  91. 4 3
      apps/app/src/features/search/client/components/SearchModal.tsx
  92. 5 6
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  93. 3 0
      apps/app/src/interfaces/user-group.ts
  94. 2 1
      apps/app/src/pages/trash.page.tsx
  95. 0 15
      apps/app/src/server/models/obsolete-page.js
  96. 37 1
      apps/app/src/server/models/page.ts
  97. 6 3
      apps/app/src/server/models/user-group.ts
  98. 6 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  99. 12 13
      apps/app/src/server/service/page/index.ts
  100. 20 10
      apps/app/src/server/service/user-group.ts

+ 1 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-20
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update

+ 6 - 6
.github/workflows/ci-app-prod.yml

@@ -51,16 +51,16 @@ jobs:
   test-prod-node16:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@dev/7.0.x
     with:
-      node-version: 16.x
+      node-version: 18.x
       skip-cypress: true
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  test-prod-node18:
+  test-prod-node20:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@dev/7.0.x
     with:
-      node-version: 18.x
+      node-version: 20.x
       skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       cypress-report-artifact-name: Cypress report
       cypress-config-video: ${{ inputs.cypress-config-video || false }}
@@ -68,15 +68,15 @@ jobs:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  run-reg-suit-node18:
-    needs: [test-prod-node18]
+  run-reg-suit-node20:
+    needs: [test-prod-node20]
 
     uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@dev/7.0.x
 
     if: always()
 
     with:
-      node-version: 18.x
+      node-version: 20.x
       skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:

+ 3 - 3
.github/workflows/ci-app.yml

@@ -27,7 +27,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     steps:
       - uses: actions/checkout@v3
@@ -92,7 +92,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mongodb:
@@ -174,7 +174,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mongodb:

+ 3 - 3
.github/workflows/ci-slackbot-proxy.yml

@@ -29,7 +29,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     steps:
     - uses: actions/checkout@v3
@@ -94,7 +94,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mysql:
@@ -179,7 +179,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mysql:

+ 1 - 1
.github/workflows/list-unhealthy-branches.yml

@@ -16,7 +16,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '16'
+        node-version: '18'
 
     - name: List branches
       id: list-branches

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -102,7 +102,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '16'
+        node-version: '18'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 

+ 2 - 2
.github/workflows/release.yml

@@ -24,7 +24,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '18'
+        node-version: '20'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
@@ -189,7 +189,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '18'
+        node-version: '20'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 

+ 3 - 3
.mergify.yml

@@ -3,9 +3,9 @@ pull_request_rules:
     conditions:
       - author = dependabot[bot]
       - '#approved-reviews-by >= 1'
-      - check-success = "lint (18.x)"
-      - check-success = "test (18.x)"
-      - check-success = "launch-dev (18.x)"
+      - check-success = "lint (20.x)"
+      - check-success = "test (20.x)"
+      - check-success = "launch-dev (20.x)"
       - check-success = "test-prod-node16 / launch-prod"
       - check-success = "test-prod-node18 / launch-prod"
     actions:

+ 1 - 1
README.md

@@ -79,7 +79,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 ## Dependencies
 
-- Node.js v16.x or v18.x
+- Node.js v18.x or v20.x
 - npm 6.x
 - yarn
 - [Turborepo](https://turbo.build/repo)

+ 1 - 1
README_JP.md

@@ -78,7 +78,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 ## 依存関係
 
-- Node.js v16.x or v18.x
+- Node.js v18.x or v20.x
 - npm 6.x
 - yarn
 - [Turborepo](https://turbo.build/repo)

+ 0 - 0
apps/app/src/components/PageEditor/AbstractEditor.tsx → apps/app/_obsolete/src/components/PageEditor/AbstractEditor.tsx


+ 0 - 0
apps/app/src/components/PageEditor/Editor.module.scss → apps/app/_obsolete/src/components/PageEditor/Editor.module.scss


+ 0 - 0
apps/app/src/components/PageEditor/Editor.tsx → apps/app/_obsolete/src/components/PageEditor/Editor.tsx


+ 0 - 0
apps/app/src/components/PageEditor/EditorIcon.jsx → apps/app/_obsolete/src/components/PageEditor/EditorIcon.jsx


+ 0 - 0
apps/app/src/components/PageEditor/PasteHelper.js → apps/app/_obsolete/src/components/PageEditor/PasteHelper.js


+ 0 - 0
apps/app/src/components/PageEditor/PreventMarkdownListInterceptor.js → apps/app/_obsolete/src/components/PageEditor/PreventMarkdownListInterceptor.js


+ 0 - 0
apps/app/src/components/PageEditor/TextAreaEditor.jsx → apps/app/_obsolete/src/components/PageEditor/TextAreaEditor.jsx


+ 4 - 4
apps/app/docker/Dockerfile

@@ -4,7 +4,7 @@
 ##
 ## base
 ##
-FROM node:18-slim AS base
+FROM node:20-slim AS base
 
 ENV optDir /opt
 
@@ -18,7 +18,7 @@ RUN turbo prune --scope=@growi/app --docker
 ##
 ## deps-resolver
 ##
-FROM node:18-slim AS deps-resolver
+FROM node:20-slim AS deps-resolver
 
 ENV optDir /opt
 
@@ -62,7 +62,7 @@ RUN tar -cf node_modules.tar \
 ##
 ## builder
 ##
-FROM node:18-slim AS builder
+FROM node:20-slim AS builder
 
 ENV optDir /opt
 
@@ -107,7 +107,7 @@ RUN tar -cf packages.tar \
 ##
 ## release
 ##
-FROM node:18-slim
+FROM node:20-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production

+ 5 - 5
apps/app/package.json

@@ -62,7 +62,7 @@
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@aws-sdk/client-s3": "3.454.0",
     "@aws-sdk/s3-request-presigner": "3.454.0",
-    "@azure/identity": "^3.3.2",
+    "@azure/identity": "^4.0.1",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
@@ -101,7 +101,7 @@
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
-    "csv-to-markdown-table": "^1.1.0",
+    "csv-to-markdown-table": "^1.4.1",
     "date-fns": "^2.23.0",
     "dayjs": "^1.11.7",
     "detect-indent": "^7.0.0",
@@ -131,7 +131,7 @@
     "is-iso-date": "^0.0.1",
     "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
-    "markdown-table": "^1.1.1",
+    "markdown-table": "^3.0.3",
     "md5": "^2.2.1",
     "mermaid": "^10.1.0",
     "method-override": "^3.0.0",
@@ -214,7 +214,7 @@
     "xss": "^1.0.14",
     "y-mongodb-provider": "^0.1.7",
     "y-socket.io": "^1.1.0",
-    "yjs": "^13.6.7"
+    "yjs": "^13.6.12"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -268,7 +268,7 @@
     "pretty-bytes": "^6.1.1",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
-    "react-dropzone": "^11.2.4",
+    "react-dropzone": "^14.2.3",
     "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
     "rehype-rewrite": "^3.0.6",

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

@@ -834,9 +834,10 @@
       "dropdown_desc": "Choose an action for private pages",
       "select_group": "Select a group",
       "no_groups": "No groups to select",
-      "publish_pages": "Publish all",
+      "publish_pages": "Publish pages that are publishable",
       "delete_pages": "Delete all",
-      "transfer_pages": "Transfer to another group"
+      "transfer_pages": "Transfer to another group",
+      "option_explanation": "A \"publishable\" page is a page visible only to the group you want to delete. Pages that can be viewed by other groups will not be published."
     },
     "update_parent_confirm_modal": {
       "header": "The parent of the group will be changed",

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

@@ -844,9 +844,10 @@
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "select_group": "グループを選択してください",
       "no_groups": "グループがありません",
-      "publish_pages": "全て公開する",
+      "publish_pages": "公開可能なページを公開する",
       "delete_pages": "全て削除する",
-      "transfer_pages": "全て他のグループに移譲する"
+      "transfer_pages": "全て他のグループに移譲する",
+      "option_explanation": "「公開可能なページ」とは、削除するグループにのみ限定公開されているページを指します。他のグループも閲覧可能なページは公開対象となりません。"
     },
     "update_parent_confirm_modal": {
       "header": "グループの親が変更されます",

+ 3 - 2
apps/app/public/static/locales/zh_CN/admin.json

@@ -843,9 +843,10 @@
       "dropdown_desc": "为私人页选择操作",
       "select_group": "选择组",
       "no_groups": "没有可选择的组",
-      "publish_pages": "全部发布",
+      "publish_pages": "发布可以发布的页面",
       "delete_pages": "全部删除",
-      "transfer_pages": "转移到另一组"
+      "transfer_pages": "转移到另一组",
+      "option_explanation": "\"可发布页面\"是指仅对您要删除的群组可见的页面。其他群组可以查看的页面将不会被发布。"
     },
     "update_parent_confirm_modal": {
       "header": "该组的父组被改变",

+ 1 - 1
apps/app/src/client/models/MarkdownTable.js

@@ -1,5 +1,5 @@
 import csvToMarkdown from 'csv-to-markdown-table';
-import markdownTable from 'markdown-table';
+import { markdownTable } from 'markdown-table';
 import stringWidth from 'string-width';
 
 // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83

+ 18 - 0
apps/app/src/client/services/use-toastr-on-error.tsx

@@ -0,0 +1,18 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/toastr';
+
+export const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
+  const { t } = useTranslation('commons');
+
+  return useCallback(async(param) => {
+    try {
+      return await method?.(param);
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: 'a page' }));
+    }
+  }, [method, t]);
+};

+ 2 - 2
apps/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -52,7 +52,7 @@ const AdminHome = (props) => {
             </p>
             <hr />
             <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-              <i className="fa fa-link ms-1" aria-hidden="true"></i>
+              <span className="material-symbols-outlined ms-1" aria-hidden="true">link</span>
               <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
             </a>
           </div>
@@ -65,7 +65,7 @@ const AdminHome = (props) => {
           <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
             {t('admin:v5_page_migration.migration_desc')}
             <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-              <i className="fa fa-link ms-1" aria-hidden="true"></i>
+              <span className="material-symbols-outlined ms-1" aria-hidden="true">link</span>
               <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
             </a>
           </div>

+ 1 - 1
apps/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -62,7 +62,7 @@ const AppSettingsPageContents = (props: Props) => {
             </p>
             <hr />
             <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
-              <i className="fa fa-fw fa-arrow-down ms-1" aria-hidden="true"></i>
+              <span className="material-symbols-outlined ms-1" aria-hidden="true">expand_more</span>
               <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
             </a>
           </div>

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

@@ -33,9 +33,9 @@ export default function MaskedInput(props: Props): JSX.Element {
       />
       <span onClick={togglePassword} className={styles.PasswordReveal}>
         {passwordShown ? (
-          <i className="fa fa-eye" />
+          <span className="material-symbols-outlined">visibility</span>
         ) : (
-          <i className="fa fa-eye-slash" />
+          <span className="material-symbols-outlined">visibility_off</span>
         )}
       </span>
     </div>

+ 4 - 3
apps/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
@@ -7,7 +8,7 @@ import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
 import { Tooltip } from 'reactstrap';
 
-import { IActivityHasId } from '~/interfaces/activity';
+import type { IActivityHasId } from '~/interfaces/activity';
 
 type Props = {
   activityList: IActivityHasId[]
@@ -64,7 +65,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                   {activity.endpoint}
                   <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
                     <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
-                      <i className="fa fa-clipboard" aria-hidden="true"></i>
+                      <span className="material-symbols-outlined" aria-hidden="true">content_paste</span>
                     </button>
                   </CopyToClipboard>
                   <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">

+ 3 - 2
apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState } from 'react';
+import type { FC } from 'react';
+import React, { useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
@@ -54,7 +55,7 @@ export const AuditLogSettings: FC = () => {
       </p>
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
-          <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>
+          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'fa-rotate-90' : ''}`}>navigate_next</span>
           { t('admin:audit_log_management.action_list') }
         </button>
       </p>

+ 3 - 2
apps/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,4 +1,5 @@
-import React, { FC, forwardRef, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { forwardRef, useCallback } from 'react';
 
 import { addDays, format } from 'date-fns';
 import DatePicker from 'react-datepicker';
@@ -19,7 +20,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: Custo
   return (
     <div className="input-group admin-audit-log">
       <span className="input-group-text">
-        <i className="fa fa-fw fa-calendar" />
+        <span className="material-symbols-outlined me-1">calendar_month</span>
       </span>
       <input
         ref={ref}

+ 5 - 3
apps/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -1,9 +1,11 @@
-import React, { FC, useMemo, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useMemo, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
+import type { SupportedActionType, SupportedActionCategoryType } from '~/interfaces/activity';
 import {
-  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  SupportedActionCategory,
   PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
 } from '~/interfaces/activity';
 
@@ -78,7 +80,7 @@ export const SelectActionDropdown: FC<Props> = (props: Props) => {
   return (
     <div className="btn-group me-2 admin-audit-log">
       <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown">
-        <i className="fa fa-fw fa-bolt" />{t('admin:audit_log_management.action')}
+        <span className="material-symbols-outlined me-1">bolt</span>{t('admin:audit_log_management.action')}
       </button>
       <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
         {dropdownItems.map(item => (

+ 1 - 1
apps/app/src/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -69,7 +69,7 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
             aria-expanded="false"
             aria-controls="collapseExampleHtml"
           >
-            <i className="fa fa-fw fa-chevron-right" aria-hidden="true"></i>
+            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
             Example for Google Tag Manager
           </a>
           <div className="collapse" id="collapseExampleHtml">

+ 1 - 1
apps/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -66,7 +66,7 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             aria-expanded="false"
             aria-controls="collapseExampleScript"
           >
-            <i className="fa fa-fw fa-chevron-right" aria-hidden="true"></i>
+            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
             Example for Google Tag Manager
           </a>
           <div className="collapse" id="collapseExampleScript">

+ 2 - 2
apps/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -166,10 +166,10 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
           <div className="row">
             <div className="col-sm-12">
               <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={checkAll}>
-                <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+                <span className="material-symbols-outlined">check_box</span> {t('admin:export_management.check_all')}
               </button>
               <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={uncheckAll}>
-                <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+                <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
               </button>
             </div>
           </div>

+ 2 - 2
apps/app/src/components/Admin/G2GDataTransferExportForm.tsx

@@ -202,12 +202,12 @@ const G2GDataTransferExportForm = (props: Props): JSX.Element => {
       <form className="mt-3 row row-cols-lg-auto g-3 align-items-center">
         <div className="col-12">
           <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={checkAll}>
-            <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+            <span className="material-symbols-outlined">check_box</span>, {t('admin:export_management.check_all')}
           </button>
         </div>
         <div className="col-12">
           <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={uncheckAll}>
-            <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+            <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
           </button>
         </div>
       </form>

+ 4 - 4
apps/app/src/components/Admin/G2GDataTransferStatusIcon.tsx

@@ -21,23 +21,23 @@ const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.
 
   if (status === G2G_PROGRESS_STATUS.COMPLETED) {
     return (
-      <i className={`fa fa-check-circle-o fa-fw text-info ${className}`} aria-label="completed" {...props} />
+      <span className={`material-symbols-outlined text-info ${className}`} aria-label="completed" {...props}>check_circle</span>
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.ERROR) {
     return (
-      <i className={`fa fa-exclamation-circle fa-fw text-danger ${className}`} aria-label="error" {...props} />
+      <span className={`material-symbols-outlined text-danger ${className}`} aria-label="error" {...props}>error</span>
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.SKIPPED) {
     return (
-      <i className={`fa fa-ban fa-fw ${className}`} aria-label="skipped" {...props} />
+      <span className={`material-symbols-outlined ${className}`} aria-label="skipped" {...props}>block</span>
     );
   }
 
-  return <i className={`fa fa-circle-o fa-fw ${className}`} aria-label="pending" {...props} />;
+  return <span className={`material-symbols-outlined ${className}`} aria-label="pending" {...props}>circle</span>;
 };
 
 export default G2GDataTransferStatusIcon;

+ 2 - 2
apps/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -449,12 +449,12 @@ class ImportForm extends React.Component {
         <form className="row row-cols-lg-auto g-3 align-items-center">
           <div className="col-12">
             <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={this.checkAll}>
-              <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+              <span className="material-symbols-outlined">check_box</span> {t('admin:export_management.check_all')}
             </button>
           </div>
           <div className="col-12">
             <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={this.uncheckAll}>
-              <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+              <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
             </button>
           </div>
         </form>

+ 7 - 2
apps/app/src/components/Admin/Notification/NotificationTypeIcon.tsx

@@ -23,8 +23,13 @@ export const NotificationTypeIcon = (props: NotificationTypeIconProps): JSX.Elem
   }
 
   const elemId = `notification-${type}-${_id}`;
-  const className = type === 'mail' ? 'icon-fw fa fa-envelope-o' : 'icon-fw fa fa-hashtag';
+  const iconName = type === 'mail' ? 'mail' : 'tag';
   const toolChip = type === 'mail' ? 'Mail' : 'Slack';
 
-  return <><i id={elemId} className={className}></i><UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip></>;
+  return (
+    <>
+      <span id={elemId} className="material-symbols-outlined me-1">{iconName}</span>
+      <UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip>
+    </>
+  );
 };

+ 1 - 1
apps/app/src/components/Admin/Notification/UserTriggerNotification.jsx

@@ -112,7 +112,7 @@ class UserTriggerNotification extends React.Component {
               <td>
                 <div className="input-group notify-to-option" id="slack-input">
                   <div>
-                    <span className="input-group-text"><i className="fa fa-hashtag" /></span>
+                    <span className="input-group-text"><span className="material-symbols-outlined">tag</span></span>
                   </div>
                   <input
                     className="form-control"

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

@@ -181,7 +181,7 @@ class LocalSecuritySettingContents extends React.Component {
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
                     <Link href="/admin/app#mail-settings">
-                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                      <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
                     </Link>
                   </div>
                 )}
@@ -210,7 +210,7 @@ class LocalSecuritySettingContents extends React.Component {
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.please_enable_mailer')}</span>
                     <Link href="/admin/app#mail-settings">
-                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                      <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
                     </Link>
                   </div>
                 )}

+ 4 - 4
apps/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -30,19 +30,19 @@ const SecurityManagementContents = () => {
   const navTabMapping = useMemo(() => {
     return {
       passport_local: {
-        Icon: () => <i className="fa fa-users" />,
+        Icon: () => <span className="material-symbols-outlined">groups</span>,
         i18n: 'ID/Pass',
       },
       passport_ldap: {
-        Icon: () => <i className="fa fa-sitemap" />,
+        Icon: () => <span className="material-symbols-outlined">network_node</span>,
         i18n: 'LDAP',
       },
       passport_saml: {
-        Icon: () => <i className="fa fa-key" />,
+        Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'SAML',
       },
       passport_oidc: {
-        Icon: () => <i className="fa fa-key" />,
+        Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'OIDC',
       },
       passport_google: {

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

@@ -296,7 +296,7 @@ class SecuritySetting extends React.Component {
                     aria-expanded="false"
                     onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
                   >
-                    <i className={`fa fa-fw fa-arrow-right ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}></i>
+                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}>navigate_next</span>
                     { t('security_settings.other_options') }
                   </button>
                   <Collapse isOpen={expantDeleteOptionsState}>

+ 4 - 4
apps/app/src/components/Admin/SlackIntegration/Bridge.jsx

@@ -72,15 +72,15 @@ const Bridge = (props) => {
   // all green
   else if (errorCount === 0) {
     description = t('admin:slack_integration.integration_sentence.integration_successful');
-    iconClass = 'fa fa-check text-success';
-    iconName = '';
+    iconClass = 'material-symbols-outlined text-success';
+    iconName = 'check';
     hrClass = 'border-success admin-border-success';
   }
   // some of them failed
   else {
     description = t('admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete');
-    iconClass = 'fa fa-check text-warning';
-    iconName = '';
+    iconClass = 'material-symbols-outlined text-warning';
+    iconName = 'check';
     hrClass = 'border-warning admin-border-failed';
   }
 

+ 3 - 3
apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -115,7 +115,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.REGISTER_SLACK_CONFIGURATION)}
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">3</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isEnterdSecretAndToken && <i className="ms-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="me-3">3</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isEnterdSecretAndToken && <span className="material-symbols-outlined ms-3 text-success">check</span>}</>}
       >
         <CustomBotWithoutProxySecretTokenSection
           onUpdatedSecretToken={props.onUpdatedSecretToken}
@@ -138,7 +138,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">5</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <i className="ms-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="me-3">5</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <span className="material-symbols-outlined ms-3 text-success">check</span>}</>}
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <p className="text-center text-warning">
@@ -148,7 +148,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
           <form className="align-items-center" onSubmit={e => submitForm(e)}>
             <div className="input-group col-8">
               <div>
-                <span className="input-group-text" id="slack-channel-addon"><i className="fa fa-hashtag" /></span>
+                <span className="input-group-text" id="slack-channel-addon"><span className="material-symbols-outlined">tag</span></span>
               </div>
               <input
                 className="form-control"

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

@@ -246,7 +246,7 @@ const TestProcess = ({
         <form className="justify-content-center" onSubmit={e => submitForm(e)}>
           <div className="input-group col-8">
             <div>
-              <span className="input-group-text" id="slack-channel-addon"><i className="fa fa-hashtag" /></span>
+              <span className="input-group-text" id="slack-channel-addon"><span className="material-symbols-outlined">tag</span></span>
             </div>
             <input
               className="form-control"
@@ -391,7 +391,7 @@ const WithProxyAccordions = (props) => {
               <>
                 <span className="me-3">{key}</span>
                 {t(`admin:slack_integration.accordion.${value.title}`)}
-                {value.title === 'test_connection' && isLatestConnectionSuccess && <i className="ms-3 text-success fa fa-check"></i>}
+                {value.title === 'test_connection' && isLatestConnectionSuccess && <span className="material-symbols-outlined ms-3 text-success">check</span>}
               </>
             )}
             key={key}

+ 22 - 23
apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useCallback, useState, useMemo,
-} from 'react';
+import type { FC } from 'react';
+import React, { useCallback, useState, useMemo } from 'react';
 
 import type { IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -8,6 +7,8 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
+
 
 /**
  * Delete User Group Select component
@@ -19,26 +20,19 @@ import {
 type Props = {
   userGroups: IUserGroupHasId[],
   deleteUserGroup?: IUserGroupHasId,
-  onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
+  onDelete?: (deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
   onHide?: () => Promise<void> | void,
 };
 
 type AvailableOption = {
   id: number,
-  actionForPages: string,
+  actionForPages: PageActionOnGroupDelete,
   iconClass: string,
   styleClass: string,
   label: string,
 };
 
-// actionName master constants
-const actionForPages = {
-  public: 'public',
-  delete: 'delete',
-  transfer: 'transfer',
-};
-
 export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
@@ -51,21 +45,21 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     return [
       {
         id: 1,
-        actionForPages: actionForPages.public,
+        actionForPages: PageActionOnGroupDelete.publicize,
         iconClass: 'icon-people',
         styleClass: '',
         label: t('admin:user_group_management.delete_modal.publish_pages'),
       },
       {
         id: 2,
-        actionForPages: actionForPages.delete,
+        actionForPages: PageActionOnGroupDelete.delete,
         iconClass: 'icon-trash',
         styleClass: 'text-danger',
         label: t('admin:user_group_management.delete_modal.delete_pages'),
       },
       {
         id: 3,
-        actionForPages: actionForPages.transfer,
+        actionForPages: PageActionOnGroupDelete.transfer,
         iconClass: 'icon-options',
         styleClass: '',
         label: t('admin:user_group_management.delete_modal.transfer_pages'),
@@ -76,14 +70,14 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   /*
    * State
    */
-  const [actionName, setActionName] = useState<string>('');
+  const [actionName, setActionName] = useState<PageActionOnGroupDelete | null>(null);
   const [transferToUserGroupId, setTransferToUserGroupId] = useState<string>('');
 
   /*
    * Function
    */
   const resetStates = useCallback(() => {
-    setActionName('');
+    setActionName(null);
     setTransferToUserGroupId('');
   }, []);
 
@@ -107,7 +101,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, []);
 
   const handleSubmit = useCallback((e) => {
-    if (onDelete == null || deleteUserGroup == null) {
+    if (onDelete == null || deleteUserGroup == null || actionName == null) {
       return;
     }
 
@@ -130,7 +124,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         name="actionName"
         className="form-control"
         placeholder="select"
-        value={actionName}
+        value={actionName ?? ''}
         onChange={handleActionChange}
       >
         <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
@@ -158,7 +152,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     return (
       <select
         name="transferToUserGroupId"
-        className={`form-control ${actionName === actionForPages.transfer ? '' : 'd-none'}`}
+        className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
         value={transferToUserGroupId}
         onChange={handleGroupChange}
       >
@@ -171,10 +165,10 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   const validateForm = useCallback(() => {
     let isValid = true;
 
-    if (actionName === '') {
+    if (actionName === null) {
       isValid = false;
     }
-    else if (actionName === actionForPages.transfer) {
+    else if (actionName === PageActionOnGroupDelete.transfer) {
       isValid = transferToUserGroupId !== '';
     }
 
@@ -196,7 +190,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       </ModalBody>
       <ModalFooter>
         <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
-          <div className="d-flex mb-0">
+          <div className="d-flex mb-0 me-3">
             {renderPageActionSelector()}
             {renderGroupSelector()}
           </div>
@@ -204,6 +198,11 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
             <span className="material-symbols-outlined">delete_forever</span> {t('Delete')}
           </button>
         </form>
+        {actionName === PageActionOnGroupDelete.publicize && (
+          <div className="form-text text-muted">
+            <small>{t('admin:user_group_management.delete_modal.option_explanation')}</small>
+          </div>
+        )}
       </ModalFooter>
     </Modal>
   );

+ 4 - 2
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import type { IUserGroup, IUserGroupHasId } from '@growi/core';
 import dynamic from 'next/dynamic';
@@ -9,6 +10,7 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
@@ -126,7 +128,7 @@ export const UserGroupPage: FC = () => {
     }
   }, [t, mutateUserGroups, hideUpdateModal]);
 
-  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     try {
       await apiv3Delete(`/user-groups/${deleteGroupId}`, {
         actionName,

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

@@ -214,7 +214,7 @@ export const UserGroupTable: FC<Props> = ({
                           {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')}
+                              <span className="material-symbols-outlined me-1">group_remove</span> {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}>

+ 4 - 3
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -13,8 +13,9 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 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 type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
+import { SearchTypes } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
@@ -296,7 +297,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     setDeleteModalShown(false);
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
-  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
       const res = await apiv3Delete(url, {

+ 8 - 4
apps/app/src/components/Admin/Users/SortIcons.tsx

@@ -13,15 +13,19 @@ export const SortIcons = (props: SortIconsProps): JSX.Element => {
   return (
     <div className="d-flex flex-column text-center">
       <a
-        className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
+        className={`${isSelected && isAsc ? 'text-primary' : 'text-muted'}`}
         aria-hidden="true"
         onClick={() => onClick('asc')}
-      />
+      >
+        <span className="material-symbols-outlined">expand_less</span>
+      </a>
       <a
-        className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
+        className={`${isSelected && !isAsc ? 'text-primary' : 'text-muted'}`}
         aria-hidden="true"
         onClick={() => onClick('desc')}
-      />
+      >
+        <span className="material-symbols-outlined">expand_more</span>
+      </a>
     </div>
   );
 };

+ 2 - 2
apps/app/src/components/Admin/Users/UserMenu.module.scss

@@ -1,5 +1,5 @@
 .grw-usermenu-notification-icon :global {
   position: absolute;
-  top: -4px;
-  left: 30px;
+  top: -6px;
+  left: 3px;
 }

+ 5 - 2
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -95,10 +95,13 @@ const UserMenu = (props: UserMenuProps) => {
   return (
     <UncontrolledDropdown id="userMenu" size="sm">
       <DropdownToggle caret color="secondary" outline>
-        {/* TODO:fontsize: 20px */}
         <span className="material-symbols-outlined fs-5">settings</span>
         {(user.status === USER_STATUS.INVITED && !isInvitationEmailSended)
-        && <i className={`fa fa-circle text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`} />}
+        && (
+          <span className={`material-symbols-outlined fill fs-6 text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`}>
+            circle
+          </span>
+        )}
       </DropdownToggle>
       <DropdownMenu strategy="fixed">
         {renderEditMenu()}

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -35,7 +35,7 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickMoveToRoot}
             className="grw-page-control-dropdown-item"
           >
-            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
             {t('bookmark_folder.move_to_root')}
           </DropdownItem>
         )}

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -118,7 +118,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           onClick={onUnbookmarkHandler}
           className="grw-bookmark-folder-menu-item text-danger"
         >
-          <i className="fa fa-bookmark"></i>{' '}
+          <span className="material-symbols-outlined">bookmark</span>{' '}
           <span className="mx-2">
             {t('bookmark_folder.cancel_bookmark')}
           </span>

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -15,7 +15,7 @@ export const BookmarkMoveToRootBtn: React.FC<{
       className="grw-page-control-dropdown-item"
       data-testid="add-remove-bookmark-btn"
     >
-      <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+      <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
       {t('bookmark_folder.move_to_root')}
     </DropdownItem>
   );

+ 3 - 2
apps/app/src/components/Common/CustomCopyToClipBoard.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
@@ -25,7 +26,7 @@ const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
     <>
       <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
         <div className="btn input-group-text" id="tooltipTarget">
-          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">content_paste</span>
         </div>
       </CopyToClipboard>
       <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>

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

@@ -170,7 +170,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
             className="grw-page-control-dropdown-item"
             data-testid="add-remove-bookmark-btn"
           >
-            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
         ) }

+ 2 - 2
apps/app/src/components/Me/AssociateModal.tsx

@@ -66,7 +66,7 @@ const AssociateModal = (props: Props): JSX.Element => {
               className={activeTab === 1 ? 'active' : ''}
               onClick={() => setActiveTab(1)}
             >
-              <i className="fa fa-sitemap"></i> LDAP
+              <span className="material-symbols-outlined">network_node</span> LDAP
             </NavLink>
             <NavLink
               className={activeTab === 2 ? 'active' : ''}
@@ -113,7 +113,7 @@ const AssociateModal = (props: Props): JSX.Element => {
       </ModalBody>
       <ModalFooter className="border-top-0">
         <button type="button" className="btn btn-primary mt-3" onClick={clickAddLdapAccountHandler}>
-          <i className="fa fa-plus-circle" aria-hidden="true"></i>
+          <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
           {t('add')}
         </button>
       </ModalFooter>

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

@@ -322,14 +322,14 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         )}
 
         { isGuestUser && (
-          <>
-            <Link href="/login#register" className="btn" prefetch={false}>
+          <div className="mt-2">
+            <Link href="/login#register" className="btn me-2" prefetch={false}>
               <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
             </Link>
             <Link href="/login#login" className="btn btn-primary" prefetch={false}>
               <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
             </Link>
-          </>
+          </div>
         ) }
       </div>
 

+ 2 - 3
apps/app/src/components/PageControls/BookmarkButtons.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import DropdownToggle from 'reactstrap/esm/DropdownToggle';

+ 4 - 2
apps/app/src/components/PageControls/LikeButtons.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -8,6 +9,7 @@ import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import UserPictureList from '../Common/UserPictureList';
 
 import styles from './LikeButtons.module.scss';
+import popoverStyles from './user-list-popover.module.scss';
 
 type LikeButtonsProps = {
 
@@ -65,7 +67,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         {sumOfLikers}
       </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className="user-list-popover">
+        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
           <div className="px-2 text-end user-list-content text-truncate text-muted">
             {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
           </div>

+ 2 - 1
apps/app/src/components/PageControls/SeenUserInfo.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState } from 'react';
+import type { FC } from 'react';
+import React, { useState } from 'react';
 
 import type { IUser } from '@growi/core';
 import { FootstampIcon } from '@growi/ui/dist/components';

+ 3 - 10
apps/app/src/components/PageControls/user-list-popover.module.scss

@@ -1,12 +1,5 @@
-.user-list-popover :global {
-  --bs-popover-max-width: 200px;
-  --bs-popover-body-padding-x: .5rem;
-  --bs-popover-body-padding-y: .5rem;
+@use '@growi/ui/scss/molecules/user-list-popover';
 
-  .user-list-content {
-    direction: rtl;
-  }
-  .cls-1 {
-    isolation: isolate;
-  }
+.user-list-popover :global {
+  @extend %user-list-popover
 }

+ 54 - 90
apps/app/src/components/PageCreateModal.jsx → apps/app/src/components/PageCreateModal.tsx

@@ -5,33 +5,38 @@ import React, {
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import {
   Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { toastError } from '~/client/util/toastr';
+import { useCreateTemplatePage } from '~/client/services/create-page';
+import { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
+import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode } from '~/stores/ui';
+
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 import styles from './PageCreateModal.module.scss';
 
 const {
-  isCreatablePage, generateEditorPath, isUsersHomepage,
+  isCreatablePage, isUsersHomepage,
 } = pagePathUtils;
 
-const PageCreateModal = () => {
+const PageCreateModal: React.FC = () => {
   const { t } = useTranslation();
-  const router = useRouter();
 
   const { data: currentUser } = useCurrentUser();
 
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
-  const { isOpened, path } = pageCreateModalData;
+
+  const path = pageCreateModalData?.path;
+  const isOpened = pageCreateModalData?.isOpened ?? false;
+
+  const { createAndTransit } = useCreatePageAndTransit();
+  const { createTemplate } = useCreateTemplatePage();
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const pathname = path || '';
@@ -39,26 +44,13 @@ const PageCreateModal = () => {
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
+  const todaysParentPath = [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/');
 
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const [todayInput1, setTodayInput1] = useState(t('Memo'));
-  const [todayInput2, setTodayInput2] = useState('');
+  const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
   const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
 
-  // ensure pageNameInput is synced with selectedPagePath || currentPagePath
-  useEffect(() => {
-    if (isOpened) {
-      setPageNameInput(isCreatable ? pathUtils.addTrailingSlash(pathname) : '/');
-    }
-  }, [isOpened, pathname, isCreatable]);
-
-  useEffect(() => {
-    setTodayInput1(t('Memo'));
-  }, [t]);
-
   const checkIsUsersHomepageDebounce = useMemo(() => {
     const checkIsUsersHomepage = () => {
       setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
@@ -69,10 +61,11 @@ const PageCreateModal = () => {
 
   useEffect(() => {
     if (isOpened) {
-      checkIsUsersHomepageDebounce(pageNameInput);
+      checkIsUsersHomepageDebounce();
     }
   }, [isOpened, checkIsUsersHomepageDebounce, pageNameInput]);
 
+
   function transitBySubmitEvent(e, transitHandler) {
     // prevent page transition by submit
     e.preventDefault();
@@ -80,19 +73,11 @@ const PageCreateModal = () => {
   }
 
   /**
-   * change todayInput1
-   * @param {string} value
-   */
-  function onChangeTodayInput1Handler(value) {
-    setTodayInput1(value);
-  }
-
-  /**
-   * change todayInput2
+   * change todayInput
    * @param {string} value
    */
-  function onChangeTodayInput2Handler(value) {
-    setTodayInput2(value);
+  function onChangeTodayInputHandler(value) {
+    setTodayInput(value);
   }
 
   /**
@@ -103,53 +88,41 @@ const PageCreateModal = () => {
     setTemplate(value);
   }
 
-  /**
-   * join path, check if creatable, then redirect
-   * @param {string} paths
-   */
-  const redirectToEditor = useCallback(async(...paths) => {
-    try {
-      const editorPath = generateEditorPath(...paths);
-      await router.push(editorPath);
-      mutateEditorMode(EditorMode.Editor);
-
-      // close modal
-      closeCreateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [closeCreateModal, mutateEditorMode, router]);
-
   /**
    * access today page
    */
-  function createTodayPage() {
-    let tmpTodayInput1 = todayInput1;
-    if (tmpTodayInput1 === '') {
-      tmpTodayInput1 = t('Memo');
-    }
-    redirectToEditor(userHomepagePath, tmpTodayInput1, now, todayInput2);
-  }
+  const createTodayPage = useCallback(async() => {
+    const joinedPath = [todaysParentPath, todayInput].join('/');
+    return createAndTransit(
+      { path: joinedPath, wip: true },
+      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    );
+  }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
 
   /**
    * access input page
    */
-  function createInputPage() {
-    redirectToEditor(pageNameInput);
-  }
-
-  const ppacSubmitHandler = useCallback((input) => {
-    redirectToEditor(input);
-  }, [redirectToEditor]);
+  const createInputPage = useCallback(async() => {
+    return createAndTransit(
+      { path: pageNameInput, optionalParentPath: '/', wip: true },
+      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    );
+  }, [closeCreateModal, createAndTransit, pageNameInput]);
 
   /**
    * access template page
    */
-  function createTemplatePage(e) {
-    const pageName = (template === 'children') ? '_template' : '__template';
-    redirectToEditor(pathname, pageName);
-  }
+  const createTemplatePage = useCallback(async() => {
+
+    const label = (template === 'children') ? '_template' : '__template';
+
+    await createTemplate?.(label);
+    closeCreateModal();
+  }, [closeCreateModal, createTemplate, template]);
+
+  const createTodaysMemoWithToastr = useToastrOnError(createTodayPage);
+  const createInputPageWithToastr = useToastrOnError(createInputPage);
+  const createTemplateWithToastr = useToastrOnError(createTemplatePage);
 
   function renderCreateTodayForm() {
     if (!isOpened) {
@@ -158,31 +131,22 @@ const PageCreateModal = () => {
     return (
       <div className="row">
         <fieldset className="col-12 mb-4">
-          <h3 className="grw-modal-head pb-2">{t("Create today's")}</h3>
+          <h3 className="grw-modal-head pb-2">{t('create_page_dropdown.todays.desc', { ns: 'commons' })}</h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
 
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
-              <div className="d-flex align-items-center">
-                <span>{userHomepagePath}/</span>
-                <form onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
-                  <input
-                    type="text"
-                    className="page-today-input1 form-control text-center mx-2"
-                    value={todayInput1}
-                    onChange={e => onChangeTodayInput1Handler(e.target.value)}
-                  />
-                </form>
-                <span className="page-today-suffix">/{now}/</span>
+              <div className="d-flex align-items-center text-nowrap">
+                <span>{todaysParentPath}/</span>
               </div>
-              <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
+              <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={(e) => { transitBySubmitEvent(e, createTodaysMemoWithToastr) }}>
                 <input
                   type="text"
                   className="page-today-input2 form-control w-100"
                   id="page-today-input2"
                   placeholder={t('Input page name (optional)')}
-                  value={todayInput2}
-                  onChange={e => onChangeTodayInput2Handler(e.target.value)}
+                  value={todayInput}
+                  onChange={e => onChangeTodayInputHandler(e.target.value)}
                 />
               </form>
             </div>
@@ -192,7 +156,7 @@ const PageCreateModal = () => {
                 type="button"
                 data-testid="btn-create-memo"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
-                onClick={createTodayPage}
+                onClick={createTodaysMemoWithToastr}
               >
                 <span className="material-symbols-outlined">description</span>{t('Create')}
               </button>
@@ -221,13 +185,13 @@ const PageCreateModal = () => {
                   <PagePathAutoComplete
                     initializedPath={pageNameInputInitialValue}
                     addTrailingSlash
-                    onSubmit={ppacSubmitHandler}
+                    onSubmit={createInputPageWithToastr}
                     onInputChange={value => setPageNameInput(value)}
                     autoFocus
                   />
                 )
                 : (
-                  <form onSubmit={e => transitBySubmitEvent(e, createInputPage)}>
+                  <form onSubmit={(e) => { transitBySubmitEvent(e, createInputPageWithToastr) }}>
                     <input
                       type="text"
                       value={pageNameInput}
@@ -245,7 +209,7 @@ const PageCreateModal = () => {
                 type="button"
                 data-testid="btn-create-page-under-below"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
-                onClick={createInputPage}
+                onClick={createInputPageWithToastr}
                 disabled={isMatchedWithUserHomepagePath}
               >
                 <span className="material-symbols-outlined">description</span>{t('Create')}
@@ -300,7 +264,7 @@ const PageCreateModal = () => {
                 data-testid="grw-btn-edit-page"
                 type="button"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
-                onClick={createTemplatePage}
+                onClick={createTemplateWithToastr}
                 disabled={template == null}
               >
                 <span className="material-symbols-outlined">description</span>{t('Edit')}

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

@@ -91,7 +91,7 @@ const EditorNavbarBottom = (): JSX.Element => {
             >
               <div className="grw-slack-logo">
                 <SlackLogo />
-                <span className="grw-btn-slack-triangle fa fa-caret-up ms-2"></span>
+                <span className="grw-btn-slack-triangle material-symbols-outlined ms-2">arrow_drop_up</span>
               </div>
             </Button>
           ) : (

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

@@ -471,7 +471,7 @@ export const HandsontableModal = (): JSX.Element => {
             onClick={toggleDataImportArea}
           >
             <span className="me-3">{t('handsontable_modal.data_import')}</span>
-            <i className={isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
+            <span className="material-symbols-outlined">{isDataImportAreaExpanded ? 'expand_less' : 'expand_more'}</span>
           </button>
           <div role="group" className="btn-group">
             <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('l') }}>

+ 2 - 2
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -180,8 +180,8 @@ export const LinkEditModal = (): JSX.Element => {
         </div>
         <div className="d-flex align-items-center justify-content-center">
           <span className="lead mx-3">
-            <i className="d-none d-sm-block fa fa-caret-right"></i>
-            <i className="d-sm-none fa fa-caret-down"></i>
+            <span className="d-none d-sm-block material-symbols-outlined">arrow_right</span>
+            <span className="d-sm-none material-symbols-outlined">arrow_drop_down</span>
           </span>
         </div>
         <div className="card w-100 p-1 mb-0">

+ 28 - 32
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -1,3 +1,4 @@
+import type { CSSProperties } from 'react';
 import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
@@ -5,16 +6,16 @@ import React, {
 import type EventEmitter from 'events';
 import nodePath from 'path';
 
-import type { IPageHasId } from '@growi/core';
+import type { IPageHasId, IUserHasId } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
   useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
 } from '@growi/editor';
+import { useRect } from '@growi/ui/dist/utils';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
 
@@ -31,7 +32,7 @@ import {
 } from '~/stores/context';
 import {
   useEditorSettings,
-  useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
+  useCurrentIndentSize, usePageTagsForEditors,
   useIsConflict,
   useEditingMarkdown,
   useWaitingSaveProcessing,
@@ -52,6 +53,7 @@ import {
   EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
+import { useEditingUsers } from '~/stores/use-editing-users';
 import { useNextThemes } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
@@ -59,7 +61,6 @@ import { PageHeader } from '../PageHeader/PageHeader';
 
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
-// import Editor from './Editor';
 import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import { scrollEditor, scrollPreview } from './ScrollSyncHelper';
@@ -86,9 +87,9 @@ type Props = {
 export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const { t } = useTranslation();
-  const router = useRouter();
 
   const previewRef = useRef<HTMLDivElement>(null);
+  const [previewRect] = useRect(previewRef);
 
   const { data: isNotFound } = useIsNotFound();
   const { data: pageId } = useCurrentPageId();
@@ -105,7 +106,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { mutate: mutateWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
@@ -118,6 +118,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: user } = useCurrentUser();
+  const { onEditorsUpdated } = useEditingUsers();
 
   const { data: socket } = useGlobalSocket();
 
@@ -158,21 +159,13 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     // set to ref
     initialValueRef.current = initialValue;
   }, [initialValue]);
-
-
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
   })), []);
-  // const mutateIsEnabledUnsavedWarningWithDebounce = useMemo(() => debounce(600, throttle(900, (value: string) => {
-  //   // Displays an unsaved warning alert
-  //   mutateIsEnabledUnsavedWarning(value !== initialValueRef.current);
-  // })), [mutateIsEnabledUnsavedWarning]);
 
   const markdownChangedHandler = useCallback((value: string) => {
     setMarkdownPreviewWithDebounce(value);
-    // mutateIsEnabledUnsavedWarningWithDebounce(value);
-  // }, [mutateIsEnabledUnsavedWarningWithDebounce, setMarkdownPreviewWithDebounce]);
   }, [setMarkdownPreviewWithDebounce]);
 
 
@@ -315,7 +308,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
     isOriginOfScrollSyncEditor = true;
     scrollEditor(codeMirrorEditor.view.scrollDOM, previewRef.current);
-  }, [codeMirrorEditor, previewRef]);
+  }, [codeMirrorEditor]);
 
   const scrollEditorHandlerThrottle = useMemo(() => throttle(25, scrollEditorHandler), [scrollEditorHandler]);
 
@@ -331,7 +324,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
     isOriginOfScrollSyncPreview = true;
     scrollPreview(codeMirrorEditor.view.scrollDOM, previewRef.current);
-  }, [codeMirrorEditor, previewRef]);
+  }, [codeMirrorEditor]);
 
   const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
 
@@ -419,6 +412,17 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   //   };
   // }, [onRouterChangeComplete, router.events]);
 
+  const pastEndStyle: CSSProperties | undefined = useMemo(() => {
+    if (previewRect == null) {
+      return undefined;
+    }
+
+    const previewRectHeight = previewRect.height;
+
+    // containerHeight - 1.5 line height
+    return { paddingBottom: `calc(${previewRectHeight}px - 2em)` };
+  }, [previewRect]);
+
   if (!isEditable) {
     return <></>;
   }
@@ -434,18 +438,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       </div>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
         <div className="page-editor-editor-container flex-expand-vert">
-          {/* <Editor
-            ref={editorRef}
-            value={initialValue}
-            isUploadable={isUploadable}
-            isUploadAllFileAllowed={isUploadAllFileAllowed}
-            indentSize={currentIndentSize}
-            onScroll={editorScrolledHandler}
-            onScrollCursorIntoView={editorScrollCursorIntoViewHandler}
-            onChange={markdownChangedHandler}
-            onUpload={uploadHandler}
-            onSave={saveWithShortcut}
-          /> */}
           <CodeMirrorEditorMain
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
@@ -453,21 +445,25 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             acceptedUploadFileType={acceptedUploadFileType}
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
-            userName={user?.name}
+            user={user ?? undefined}
             pageId={pageId ?? undefined}
             initialValue={initialValue}
             onOpenEditor={markdown => setMarkdownToPreview(markdown)}
             editorSettings={editorSettings}
+            onEditorsUpdated={onEditorsUpdated}
           />
         </div>
-        <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">
+        <div
+          ref={previewRef}
+          onScroll={scrollPreviewHandlerThrottle}
+          className="page-editor-preview-container flex-expand-vert d-none d-lg-flex"
+        >
           <Preview
             rendererOptions={rendererOptions}
             markdown={markdownToPreview}
             pagePath={currentPagePath}
             expandContentWidth={shouldExpandContent}
-            // TODO: Dynamic changes by height or resizing the last element
-            pastEnd={500}
+            style={pastEndStyle}
           />
         </div>
         {/*

+ 1 - 0
apps/app/src/components/PageEditor/Preview.module.scss

@@ -3,6 +3,7 @@
 .page-editor-preview-body :global {
   .wiki {
     max-width: 980px;
+    padding: 0px 15px;
     margin: 0 auto;
   }
 }

+ 7 - 10
apps/app/src/components/PageEditor/Preview.tsx

@@ -1,6 +1,5 @@
-import React, {
-  SyntheticEvent, RefObject,
-} from 'react';
+import type { CSSProperties } from 'react';
+import React from 'react';
 
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
@@ -17,15 +16,15 @@ type Props = {
   markdown?: string,
   pagePath?: string | null,
   expandContentWidth?: boolean,
-  pastEnd?: number,
+  style?: CSSProperties,
   onScroll?: (scrollTop: number) => void,
 }
 
-const Preview = React.forwardRef((props: Props): JSX.Element => {
+const Preview = (props: Props): JSX.Element => {
 
   const {
     rendererOptions,
-    markdown, pagePath, pastEnd,
+    markdown, pagePath, style,
     expandContentWidth,
   } = props;
 
@@ -34,7 +33,7 @@ const Preview = React.forwardRef((props: Props): JSX.Element => {
   return (
     <div
       className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
-      style={{ paddingBottom: pastEnd }}
+      style={style}
     >
       { markdown != null && (
         <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown}></RevisionRenderer>
@@ -42,8 +41,6 @@ const Preview = React.forwardRef((props: Props): JSX.Element => {
     </div>
   );
 
-});
-
-Preview.displayName = 'Preview';
+};
 
 export default Preview;

+ 53 - 0
apps/app/src/components/PageHeader/EditingUserList.tsx

@@ -0,0 +1,53 @@
+import { type FC, useState } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui/dist/components';
+import { Popover, PopoverBody } from 'reactstrap';
+
+import UserPictureList from '../Common/UserPictureList';
+
+import popoverStyles from './user-list-popover.module.scss';
+
+type Props = {
+  className: string,
+  userList: IUserHasId[]
+}
+
+export const EditingUserList: FC<Props> = ({ className, userList }) => {
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  const firstFourUsers = userList.slice(0, 4);
+  const remainingUsers = userList.slice(4);
+
+  return (
+    <div className={className}>
+      {userList.length > 0 && (
+        <div className="d-flex justify-content-end">
+          {firstFourUsers.map(user => (
+            <div className="ms-1">
+              <UserPicture
+                user={user}
+                noLink
+                additionalClassName="border border-info"
+              />
+            </div>
+          ))}
+          {remainingUsers.length > 0 && (
+            <div className="ms-1">
+              <button type="button" id="btn-editing-user" className="btn border-0 bg-info-subtle rounded-pill p-0">
+                <span className="fw-bold text-info p-1">+{remainingUsers.length}</span>
+              </button>
+              <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-editing-user" toggle={togglePopover} trigger="legacy">
+                <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
+                  <UserPictureList users={remainingUsers} />
+                </PopoverBody>
+              </Popover>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+};

+ 17 - 4
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,7 +1,11 @@
 import type { FC } from 'react';
 
+import { DevidedPagePath } from '@growi/core/dist/models';
+
 import { useSWRxCurrentPage } from '~/stores/page';
+import { useEditingUsers } from '~/stores/use-editing-users';
 
+import { EditingUserList } from './EditingUserList';
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 
@@ -11,20 +15,29 @@ const moduleClass = styles['page-header'] ?? '';
 
 export const PageHeader: FC = () => {
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: editingUsers } = useEditingUsers();
 
   if (currentPage == null) {
     return <></>;
   }
 
+  const dPagePath = new DevidedPagePath(currentPage.path, true);
+
   return (
     <div className={moduleClass}>
       <PagePathHeader
         currentPage={currentPage}
       />
-      <PageTitleHeader
-        className="mt-2"
-        currentPage={currentPage}
-      />
+      <div className="row mt-2">
+        <PageTitleHeader
+          className="col"
+          currentPage={currentPage}
+        />
+        <EditingUserList
+          className={`${dPagePath.isRoot ? 'mt-1' : 'col mt-2'}`}
+          userList={editingUsers?.userList ?? []}
+        />
+      </div>
     </div>
   );
 };

+ 5 - 0
apps/app/src/components/PageHeader/user-list-popover.module.scss

@@ -0,0 +1,5 @@
+@use '@growi/ui/scss/molecules/user-list-popover';
+
+.user-list-popover :global {
+  @extend %user-list-popover;
+}

+ 1 - 1
apps/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -39,7 +39,7 @@ const ApiErrorMessage = (props) => {
           <>
             <strong><span className="material-symbols-outlined me-1">lightbulb</span> { t('page_api_error.outdated') }</strong>
             <a className="btn-link" onClick={reload}>
-              <i className="fa fa-angle-double-right"></i> { t('Load latest') }
+              <span className="material-symbols-outlined">keyboard_double_arrow_right</span> { t('Load latest') }
             </a>
           </>
         );

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

@@ -287,7 +287,7 @@ const PageRenameModal = (): JSX.Element => {
 
         <p className="mt-2">
           <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
-            <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'fa-rotate-90' : ''}`}>navigate_next</span>
             { t('modal_rename.label.Other options') }
           </button>
         </p>

+ 17 - 20
apps/app/src/components/SearchTypeahead.tsx

@@ -7,6 +7,7 @@ import React, {
 } from 'react';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
+import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
 import type { IFocusable } from '~/client/interfaces/focusable';
@@ -46,15 +47,6 @@ type Props = TypeaheadProps & {
   helpElement?: React.ReactNode,
 };
 
-// see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
-type TypeaheadInstance = {
-  setState(input: { text: string | undefined; }): void;
-  clear: () => void,
-  focus: () => void,
-  toggleMenu: () => void,
-  state: { selected: IPageWithSearchMeta[] }
-}
-
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
   const {
     onSearchError, onSearch, onInputChange, onChange, onSubmit,
@@ -64,7 +56,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
   const [input, setInput] = useState(keywordOnInit);
   const [searchKeyword, setSearchKeyword] = useState('');
-  const [isForcused, setFocused] = useState(false);
+  const [isFocused, setFocused] = useState(false);
 
   const { data: searchResult, error: searchError, isLoading } = useSWRxSearch(
     disableIncrementalSearch ? null : searchKeyword,
@@ -72,7 +64,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     { limit: 10 },
   );
 
-  const typeaheadRef = useRef<TypeaheadInstance>(null);
+  const typeaheadRef = useRef<TypeaheadRef>(null);
 
   const focusToTypeahead = () => {
     const instance = typeaheadRef.current;
@@ -144,14 +136,18 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     if (selectedItems.length > 0) {
       setInput(selectedItems[0].data.path);
 
+      if (onInputChange != null) {
+        onInputChange(selectedItems[0].data.path);
+      }
+
       if (onChange != null) {
         onChange(selectedItems);
       }
     }
-  }, [onChange]);
+  }, [onChange, onInputChange]);
 
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
-    if (event.keyCode === 13) { // Enter key
+    if (event.key === 'Enter') {
       if (onSubmit != null && input != null && input.length > 0) {
         // schedule to submit with 100ms delay
         timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);
@@ -171,19 +167,20 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   useEffect(() => {
     // update input with Next Link
     // update input workaround. see: https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
-    if (typeaheadRef.current != null) {
+    if (typeaheadRef.current != null && keywordOnInit != null) {
       typeaheadRef.current.setState({
         text: keywordOnInit,
       });
     }
   }, [keywordOnInit]);
 
-  const labelKey = useCallback((option?: IPageWithSearchMeta) => {
-    return option?.data.path ?? '';
+
+  const labelKey = useCallback((option: IPageWithSearchMeta) => {
+    return option.data.path ?? '';
   }, []);
 
   const renderMenu = useCallback((options: IPageWithSearchMeta[], menuProps) => {
-    if (!isForcused) {
+    if (!isFocused) {
       return <></>;
     }
 
@@ -219,7 +216,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         ))}
       </Menu>
     );
-  }, [disableIncrementalSearch, helpElement, input, isForcused]);
+  }, [disableIncrementalSearch, helpElement, input, isFocused]);
 
   const isOpenAlways = helpElement != null;
 
@@ -228,12 +225,12 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
       <AsyncTypeahead
         {...props}
         id="search-typeahead-asynctypeahead"
-        // ref={typeaheadRef}
+        ref={typeaheadRef}
         delay={400}
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         inputProps={{ autoComplete: 'off', ...(inputProps as any ?? {}) }}
         isLoading={isLoading}
-        // labelKey={labelKey}
+        labelKey={labelKey}
         defaultInputValue={keywordOnInit}
         options={searchResult?.data ?? []} // Search result (Some page names)
         align="left"

+ 2 - 17
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,10 +1,9 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState } from 'react';
 
-import { useTranslation } from 'react-i18next';
 import { Dropdown } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
-import { toastError } from '~/client/util/toastr';
+import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
@@ -12,20 +11,6 @@ import { DropendToggle } from './DropendToggle';
 import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
 
-const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
-  const { t } = useTranslation('commons');
-
-  return useCallback(async(param) => {
-    try {
-      return await method?.(param);
-    }
-    catch (err) {
-      toastError(t('toaster.create_failed', { target: 'a page' }));
-    }
-  }, [method, t]);
-};
-
-
 export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
 

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

@@ -136,7 +136,7 @@ const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Elemen
   return (
     <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
       <Nav onPrimaryItemHover={primaryItemHoverHandler} />
-      <div className={`sidebar-contents-container flex-grow-1 overflow-y-auto ${openClass}`} style={{ width: collapsibleContentsWidth }}>
+      <div className={`sidebar-contents-container flex-grow-1 overflow-y-auto overflow-x-hidden ${openClass}`} style={{ width: collapsibleContentsWidth }}>
         {children}
       </div>
     </div>

+ 3 - 3
apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -54,11 +54,11 @@ export const PersonalDropdown = (): JSX.Element => {
           data-testid="personal-dropdown-menu"
         >
           <DropdownItem header>
-            <div className="mt-2 mb-3">
+            <div className="mt-2">
               <UserPicture user={currentUser} size="lg" noLink noTooltip />
             </div>
-            <h5 className="ms-1">{currentUser.name}</h5>
-            <div className="d-flex align-items-center">
+            <div className="mt-3 ms-1 fs-5">{currentUser.name}</div>
+            <div className="mt-2 d-flex align-items-center">
               <span className="material-symbols-outlined me-1">person</span>
               {currentUser.username}
             </div>

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

@@ -29,7 +29,7 @@ const SystemVersion = (props: Props): JSX.Element => {
         </span>
         { showShortcutsButton && (
           <button type="button" className="btn btn-link ms-2 p-0" onClick={() => openShortcutsModal()}>
-            <i className="fa fa-keyboard-o"></i>&nbsp;<span className={`cmd-key ${os}`}></span>-/
+            <span className="material-symbols-outlined">keyboard</span>&nbsp;<span className={`cmd-key ${os}`}></span>-/
           </button>
         ) }
       </div>

+ 3 - 2
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,7 +1,7 @@
 import React, { useState, type FC, useCallback } from 'react';
 
 import { createPage } from '~/client/services/page-operation';
-import { useSWRxPageChildren } from '~/stores/page-listing';
+import { useSWRxPageChildren, mutatePageTree } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
 import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
@@ -10,6 +10,7 @@ import type { TreeItemToolProps } from '../interfaces';
 import { NewPageCreateButton } from './NewPageCreateButton';
 import { NewPageInput } from './NewPageInput';
 
+
 type UseNewPageInput = {
   Input: FC<TreeItemToolProps>,
   CreateButton: FC<TreeItemToolProps>,
@@ -77,7 +78,7 @@ export const useNewPageInput = (): UseNewPageInput => {
         wip: shouldCreateWipPage(newPagePath),
       });
 
-      mutateChildren();
+      mutatePageTree();
 
       if (!hasDescendants) {
         stateHandlers?.setIsOpen(true);

+ 8 - 5
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -47,10 +47,13 @@ const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
   const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
   return (
-    <div className="flex-grow-1 d-flex align-items-center pe-none">
+    <div
+      className="flex-grow-1 d-flex align-items-center pe-none"
+      style={{ minWidth: 0 }}
+    >
       {shouldShowAttentionIcon && (
         <>
-          <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
+          <span id="path-recovery" className="material-symbols-outlined mr-2 text-warning">warning</span>
           <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
             {t('tooltip.operation.attention.rename')}
           </UncontrolledTooltip>
@@ -59,9 +62,9 @@ const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
       {page != null && page.path != null && page._id != null && (
         <div className="grw-pagetree-title-anchor flex-grow-1">
           <div className="d-flex align-items-center">
-            <span className={`text-truncate ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</span>
+            <span className={`text-truncate me-1 ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</span>
             { page.wip && (
-              <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
+              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">WIP</span>
             )}
           </div>
         </div>
@@ -206,7 +209,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
           {hasDescendants && (
             <button
               type="button"
-              className={`grw-pagetree-triangle-btn btn ${isOpen ? 'grw-pagetree-open' : ''}`}
+              className={`grw-pagetree-triangle-btn btn p-0 ${isOpen ? 'grw-pagetree-open' : ''}`}
               onClick={onClickLoadChildren}
             >
               <div className="d-flex justify-content-center">

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

@@ -27,7 +27,7 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
       <div className="grw-user-page-list-m d-edit-none">
         <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
-          <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
+          <span style={{ fontSize: '1.3em' }} className="material-symbols-outlined">bookmark</span>
           {t('footer.bookmarks')}
           <span className="ms-auto ps-2 ">
             <button

+ 7 - 7
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,6 +1,5 @@
-import {
-  FC, useCallback, useMemo, useState,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
@@ -11,13 +10,14 @@ import { UserGroupDeleteModal } from '~/components/Admin/UserGroup/UserGroupDele
 import { UserGroupModal } from '~/components/Admin/UserGroup/UserGroupModal';
 import { UserGroupTable } from '~/components/Admin/UserGroup/UserGroupTable';
 import CustomNav from '~/components/CustomNavigation/CustomNav';
-import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
 import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
 
 import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 export const ExternalGroupManagement: FC = () => {
   const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
@@ -93,7 +93,7 @@ export const ExternalGroupManagement: FC = () => {
     }
   }, [t, mutateExternalUserGroups, hideUpdateModal]);
 
-  const deleteExternalUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteExternalUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     try {
       await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
         actionName,
@@ -120,11 +120,11 @@ export const ExternalGroupManagement: FC = () => {
   const navTabMapping = useMemo(() => {
     return {
       ldap: {
-        Icon: () => <i className="fa fa-sitemap" />,
+        Icon: () => <span className="material-symbols-outlined">network_node</span>,
         i18n: 'LDAP',
       },
       keycloak: {
-        Icon: () => <i className="fa fa-key" />,
+        Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'Keycloak',
       },
     };

+ 6 - 3
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -1,7 +1,8 @@
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
-import { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
 import UserGroup from '~/server/models/user-group';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
@@ -12,7 +13,9 @@ export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument>
 
   PAGE_ITEMS: 10,
 
-  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
+  findGroupsWithDescendantsRecursively: (
+    groups: ExternalUserGroupDocument[], descendants?: ExternalUserGroupDocument[]
+  ) => Promise<ExternalUserGroupDocument[]>,
 }
 
 const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({

+ 8 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -1,6 +1,7 @@
 import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { Router, Request } from 'express';
+import type { Request } from 'express';
+import { Router } from 'express';
 import {
   body, param, query, validationResult,
 } from 'express-validator';
@@ -8,13 +9,14 @@ import {
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
-import Crowi from '~/server/crowi';
+import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
+import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
-import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
-import UserGroupService from '~/server/service/user-group';
+import type UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
@@ -148,7 +150,8 @@ module.exports = (crowi: Crowi): Router => {
   router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id: deleteGroupId } = req.params;
-      const { actionName, transferToUserGroupId } = req.query;
+      const { transferToUserGroupId } = req.query;
+      const actionName = req.query.actionName as PageActionOnGroupDelete;
 
       const transferGroupInfo = transferToUserGroupId != null ? {
         item: transferToUserGroupId as string,

+ 10 - 3
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -2,7 +2,7 @@ import React, {
   useCallback, useRef, useEffect, useMemo,
 } from 'react';
 
-import { GetInputProps } from '../interfaces/downshift';
+import type { GetInputProps } from '../interfaces/downshift';
 
 type Props = {
   searchKeyword: string,
@@ -35,7 +35,7 @@ export const SearchForm = (props: Props): JSX.Element => {
 
   const inputOptions = useMemo(() => {
     return getInputProps({
-      type: 'search',
+      type: 'text',
       placeholder: 'Search...',
       className: 'form-control',
       ref: inputRef,
@@ -52,11 +52,18 @@ export const SearchForm = (props: Props): JSX.Element => {
 
   return (
     <form
-      className="w-100"
+      className="w-100 position-relative"
       onSubmit={submitHandler}
       data-testid="search-form"
     >
       <input {...inputOptions} />
+      <button
+        type="button"
+        className="btn btn-neutral-secondary text-muted position-absolute bottom-0 end-0 w-auto h-100 border-0"
+        onClick={() => { onChange?.('') }}
+      >
+        <span className="material-symbols-outlined p-0">cancel</span>
+      </button>
     </form>
   );
 };

+ 10 - 10
apps/app/src/features/search/client/components/SearchHelp.tsx

@@ -11,40 +11,40 @@ export const SearchHelp = (): JSX.Element => {
   return (
     <>
       <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
-        <span className="material-symbols-outlined me-2">help</span>
-        { t('search_help.title') }
-        <span className="material-symbols-outlined ms-2">{isOpen ? 'expand_less' : 'expand_more'}</span>
+        <span className="material-symbols-outlined me-2 p-0">help</span>
+        <span>{t('search_help.title')}</span>
+        <span className="material-symbols-outlined ms-2 p-0">{isOpen ? 'expand_less' : 'expand_more'}</span>
       </button>
       <Collapse isOpen={isOpen}>
-        <table className="table m-0">
+        <table className="table table-borderless m-0">
           <tbody>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2">
                 <code>word1</code> <code>word2</code><br />
                 <small className="text-muted">({ t('search_help.and.syntax help') })</small>
               </th>
               <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2">
                 <code>&quot;This is GROWI&quot;</code><br />
                 <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
               </th>
               <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>-keyword</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>prefix:/user/</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>-prefix:/user/</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
             </tr>
-            <tr>
+            <tr className="border-bottom">
               <th className="py-2"><code>tag:wiki</code></th>
               <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
             </tr>

+ 1 - 1
apps/app/src/features/search/client/components/SearchMenuItem.tsx

@@ -21,7 +21,7 @@ export const SearchMenuItem = (props: Props): JSX.Element => {
     getItemProps({
       index,
       item: { url },
-      className: `d-flex p-1 text-muted ${isActive ? 'active' : ''}`,
+      className: `d-flex align-items-center px-2 py-1 text-muted ${isActive ? 'active' : ''}`,
     })
   );
 

+ 22 - 16
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentPagePath } from '~/stores/page';
@@ -23,10 +24,15 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
 
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const dPagePath = (new DevidedPagePath(currentPagePath ?? '', true, true));
+  const currentPageName = `
+  ${(!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : '')}/${(dPagePath.isRoot ? '' : `${dPagePath.latter}/`)}
+  `;
+
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
 
   return (
-    <>
+    <div>
       { shouldShowMenuItem && (
         <div data-testid="search-all-menu-item">
           <SearchMenuItem
@@ -35,15 +41,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
             getItemProps={getItemProps}
             url={`/_search?q=${searchKeyword}`}
           >
-            <span className="material-symbols-outlined fs-4 me-3">search</span>
-            <span>{searchKeyword}</span>
-            <div className="ms-auto">
-              <span>{t('search_method_menu_item.search_in_all')}</span>
+            <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+            <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+              <span className="text-break me-auto">{searchKeyword}</span>
+              <span className="small text-body-tertiary">{t('search_method_menu_item.search_in_all')}</span>
             </div>
           </SearchMenuItem>
         </div>
       )}
-
       <div data-testid="search-prefix-menu-item">
         <SearchMenuItem
           index={shouldShowMenuItem ? 1 : 0}
@@ -51,11 +56,11 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
         >
-          <span className="material-symbols-outlined fs-4 me-3">search</span>
-          <code>prefix: {currentPagePath}</code>
-          <span className="ms-2">{searchKeyword}</span>
-          <div className="ms-auto">
-            <span>{t('search_method_menu_item.only_children_of_this_tree')}</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+            <code className="text-break">{currentPageName}</code>
+            <span className="ms-md-2 text-break me-auto">{searchKeyword}</span>
+            <span className="small text-body-tertiary">{t('search_method_menu_item.only_children_of_this_tree')}</span>
           </div>
         </SearchMenuItem>
       </div>
@@ -67,13 +72,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           url={`/_search?q="${searchKeyword}"`}
         >
-          <span className="material-symbols-outlined fs-4 me-3">search</span>
-          <span>{`"${searchKeyword}"`}</span>
-          <div className="ms-auto">
-            <span>{t('search_method_menu_item.exact_mutch')}</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
+            <span className="text-break me-auto">{`"${searchKeyword}"`}</span>
+            <span className="small text-body-tertiary">{t('search_method_menu_item.exact_mutch')}</span>
           </div>
         </SearchMenuItem>
       ) }
-    </>
+    </div>
+
   );
 };

+ 4 - 3
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -56,7 +56,7 @@ const SearchModal = (): JSX.Element => {
 
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">
-      <ModalBody>
+      <ModalBody className="pb-2">
         <Downshift
           onSelect={selectSearchMenuItemHandler}
           stateReducer={stateReducer}
@@ -83,11 +83,11 @@ const SearchModal = (): JSX.Element => {
                   className="btn border-0 d-flex justify-content-center p-0"
                   onClick={closeSearchModal}
                 >
-                  <span className="material-symbols-outlined fs-4 ms-3">close</span>
+                  <span className="material-symbols-outlined fs-4 ms-3 py-0">close</span>
                 </button>
               </div>
 
-              <ul {...getMenuProps()} className="list-unstyled">
+              <ul {...getMenuProps()} className="list-unstyled m-0">
                 <div className="border-top mt-3 mb-2" />
                 <SearchMethodMenuItem
                   activeIndex={highlightedIndex}
@@ -100,6 +100,7 @@ const SearchModal = (): JSX.Element => {
                   searchKeyword={searchKeyword}
                   getItemProps={getItemProps}
                 />
+                <div className="border-top mt-2 mb-2" />
               </ul>
             </div>
           )}

+ 5 - 6
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -46,7 +46,7 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
   }
 
   return (
-    <>
+    <div>
       {searchResult?.data
         .map((item, index) => (
           <SearchMenuItem
@@ -62,14 +62,13 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
               <PagePathLabel path={item.data.path} />
             </span>
 
-            <span className="ms-2 d-flex justify-content-center align-items-center">
-              <span className="material-symbols-outlined fs-5">footprint</span>
-              <span>{item.data.seenUsers.length}</span>
+            <span className="text-body-tertiary ms-2 d-flex justify-content-center align-items-center">
+              <span className="material-symbols-outlined fs-6 p-0">footprint</span>
+              <span className="fs-6">{item.data.seenUsers.length}</span>
             </span>
           </SearchMenuItem>
         ))
       }
-      <div className="border-top mt-2 mb-2" />
-    </>
+    </div>
   );
 };

+ 3 - 0
apps/app/src/interfaces/user-group.ts

@@ -5,3 +5,6 @@ export const SearchTypes = {
 } as const;
 
 export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];
+
+export const PageActionOnGroupDelete = { publicize: 'publicize', delete: 'delete', transfer: 'transfer' } as const;
+export type PageActionOnGroupDelete = typeof PageActionOnGroupDelete[keyof typeof PageActionOnGroupDelete];

+ 2 - 1
apps/app/src/pages/trash.page.tsx

@@ -1,4 +1,5 @@
-import React, { ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React from 'react';
 
 import type { IUser, IUserHasId } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';

+ 0 - 15
apps/app/src/server/models/obsolete-page.js

@@ -646,21 +646,6 @@ export const getPageSchema = (crowi) => {
     return await queryBuilder.query.exec();
   };
 
-  pageSchema.statics.publicizePages = async function(pages) {
-    const operationsToPublicize = pages.map((page) => {
-      return {
-        updateOne: {
-          filter: { _id: page._id },
-          update: {
-            grantedGroups: [],
-            grant: this.GRANT_PUBLIC,
-          },
-        },
-      };
-    });
-    await this.bulkWrite(operationsToPublicize);
-  };
-
   /**
    * transfer pages grant to specified user group
    * @param {Page[]} pages

+ 37 - 1
apps/app/src/server/models/page.ts

@@ -7,7 +7,7 @@ import {
   type IPage,
   GroupType, type HasObjectId,
 } from '@growi/core';
-import { isPopulated } from '@growi/core/dist/interfaces';
+import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
@@ -18,6 +18,7 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type { IOptionsForCreate } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
@@ -27,6 +28,7 @@ import { collectAncestorPaths } from '../util/collect-ancestor-paths';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
+import type { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
@@ -82,6 +84,7 @@ export interface PageModel extends Model<PageDocument> {
     templateBody?: string,
     templateTags?: string[],
   }>
+  removeGroupsToDeleteFromPages(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]): Promise<void>
 
   PageQueryBuilder: typeof PageQueryBuilder
 
@@ -1038,6 +1041,39 @@ schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promi
   return ancestors[0];
 };
 
+schema.statics.removeGroupsToDeleteFromPages = async function(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]) {
+  const groupsToDeleteIds = groupsToDelete.map(group => group._id.toString());
+  const pageGroups = pages.reduce((acc: { canPublicize: PageDocument[], cannotPublicize: PageDocument[] }, page) => {
+    const canPublicize = page.grantedGroups.every(group => groupsToDeleteIds.includes(getIdForRef(group.item).toString()));
+    acc[canPublicize ? 'canPublicize' : 'cannotPublicize'].push(page);
+    return acc;
+  }, { canPublicize: [], cannotPublicize: [] });
+
+  // Only publicize pages that can only be accessed by the groups to be deleted
+  const publicizeQueries = pageGroups.canPublicize.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: {
+          grantedGroups: [],
+          grant: this.GRANT_PUBLIC,
+        },
+      },
+    };
+  });
+  // Remove the groups to be deleted from the grantedGroups of the pages that can be accessed by other groups
+  const removeFromGrantedGroupsQueries = pageGroups.cannotPublicize.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: { $set: { grantedGroups: page.grantedGroups.filter(group => !groupsToDeleteIds.includes(getIdForRef(group.item).toString())) } },
+      },
+    };
+  });
+
+  await this.bulkWrite([...publicizeQueries, ...removeFromGrantedGroupsQueries]);
+};
+
 /*
  * get latest revision body length
  */

+ 6 - 3
apps/app/src/server/models/user-group.ts

@@ -1,5 +1,6 @@
 import type { IUserGroup } from '@growi/core';
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -12,7 +13,7 @@ export interface UserGroupModel extends Model<UserGroupDocument> {
 
   PAGE_ITEMS: 10,
 
-  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
+  findGroupsWithDescendantsRecursively: (groups: UserGroupDocument[], descendants?: UserGroupDocument[]) => Promise<UserGroupDocument[]>,
 }
 
 /*
@@ -109,7 +110,9 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
  * @param descendants UserGroupDocument[]
  * @returns UserGroupDocument[]
  */
-schema.statics.findGroupsWithDescendantsRecursively = async function(groups, descendants = groups) {
+schema.statics.findGroupsWithDescendantsRecursively = async function(
+    groups: UserGroupDocument[], descendants: UserGroupDocument[] = groups,
+): Promise<UserGroupDocument[]> {
   const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
 
   if (nextGroups.length === 0) {

+ 6 - 1
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -2,7 +2,7 @@ import type {
   IPage, IUser, IUserHasId,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { isCreatablePage, isUserPage, isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { attachTitleHeader, normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
@@ -63,6 +63,11 @@ async function determinePath(_parentPath?: string, _path?: string, optionalParen
   if (_parentPath != null) {
     const parentPath = normalizePath(_parentPath);
 
+    // when parentPath is user's homepage
+    if (isUsersHomepage(parentPath)) {
+      return generateUntitledPath(parentPath, basePathname);
+    }
+
     // when parentPath is valid
     if (isCreatablePage(parentPath)) {
       return generateUntitledPath(parentPath, basePathname);

+ 12 - 13
apps/app/src/server/service/page/index.ts

@@ -18,6 +18,7 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
 import { Comment } from '~/features/comment/server';
+import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
@@ -30,6 +31,7 @@ import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { CreateMethod } from '~/server/models/page';
 import {
@@ -37,6 +39,7 @@ import {
 } from '~/server/models/page';
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
+import type { UserGroupDocument } from '~/server/models/user-group';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import loggerFactory from '~/utils/logger';
@@ -2504,23 +2507,19 @@ class PageService implements IPageService {
   }
 
 
-  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup: IGrantedGroup, user) {
-    const Page = this.crowi.model('Page');
-    const pages = await Page.find({
-      grantedGroups: {
-        $elemMatch: {
-          item: { $in: groupsToDelete },
-        },
-      },
-    });
+  async handlePrivatePagesForGroupsToDelete(
+      groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[], action: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup, user,
+  ): Promise<void> {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const pages = await Page.find({ grantedGroups: { $elemMatch: { item: { $in: groupsToDelete } } } });
 
     switch (action) {
-      case 'public':
-        await Page.publicizePages(pages);
+      case PageActionOnGroupDelete.publicize:
+        await Page.removeGroupsToDeleteFromPages(pages, groupsToDelete);
         break;
-      case 'delete':
+      case PageActionOnGroupDelete.delete:
         return this.deleteMultipleCompletely(pages, user);
-      case 'transfer':
+      case PageActionOnGroupDelete.transfer:
         await Page.transferPagesToGroup(pages, transferToUserGroup);
         break;
       default:

+ 20 - 10
apps/app/src/server/service/user-group.ts

@@ -1,21 +1,31 @@
 import type { IUser, IGrantedGroup } from '@growi/core';
-import { DeleteResult } from 'mongodb';
-import { Model } from 'mongoose';
+import type { DeleteResult } from 'mongodb';
+import type { Model } from 'mongoose';
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import UserGroup, { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
-import UserGroupRelation, { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
+import type { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
+import UserGroupRelation from '../models/user-group-relation';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
+export interface IUserGroupService {
+  init(): Promise<void>;
+  updateGroup(id: ObjectIdLike, name?: string, description?: string, parentId?: ObjectIdLike | null, forceUpdateParents?: boolean): Promise<UserGroupDocument>;
+  removeCompletelyByRootGroupId(deleteRootGroupId: ObjectIdLike, action: string, user: IUser, transferToUserGroup?: IGrantedGroup): Promise<DeleteResult>;
+  removeUserByUsername(userGroupId: ObjectIdLike, username: string): Promise<{user: IUser, deletedGroupsCount: number}>;
+}
+
 /**
  * the service class of UserGroupService
  */
-class UserGroupService {
+class UserGroupService implements IUserGroupService {
 
   crowi: any;
 
@@ -23,13 +33,13 @@ class UserGroupService {
     this.crowi = crowi;
   }
 
-  async init() {
+  async init(): Promise<void> {
     logger.debug('removing all invalid relations');
     return UserGroupRelation.removeAllInvalidRelations();
   }
 
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
-  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false) {
+  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false): Promise<UserGroupDocument> {
     const userGroup = await UserGroup.findById(id);
     if (userGroup == null) {
       throw new Error('The group does not exist');
@@ -115,7 +125,7 @@ class UserGroupService {
   }
 
   async removeCompletelyByRootGroupId(
-      deleteRootGroupId, action, user, transferToUserGroup?: IGrantedGroup,
+      deleteRootGroupId, action: PageActionOnGroupDelete, user, transferToUserGroup?: IGrantedGroup,
       userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
       userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
   ): Promise<DeleteResult> {
@@ -144,7 +154,7 @@ class UserGroupService {
       User.findUserByUsername(username),
     ]);
 
-    const groupsOfRelationsToDelete = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
+    const groupsOfRelationsToDelete = userGroup != null ? await UserGroup.findGroupsWithDescendantsRecursively([userGroup]) : [];
     const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map(g => g._id);
 
     const deleteManyRes = await UserGroupRelation.deleteMany({ relatedUser: user._id, relatedGroup: { $in: relatedGroupIdsToDelete } });

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است