Browse Source

Merge branch 'dev/7.0.x' into feat/141319-when-updating-a-page-from-the-editor-the-latest-revision-is-not-required

Shun Miyazawa 2 years ago
parent
commit
016ea94bc7
69 changed files with 1056 additions and 435 deletions
  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. 4 4
      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. 22 23
      apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  24. 4 2
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  25. 4 3
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  26. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  27. 21 27
      apps/app/src/components/PageEditor/PageEditor.tsx
  28. 1 0
      apps/app/src/components/PageEditor/Preview.module.scss
  29. 7 10
      apps/app/src/components/PageEditor/Preview.tsx
  30. 17 20
      apps/app/src/components/SearchTypeahead.tsx
  31. 3 3
      apps/app/src/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  32. 3 2
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  33. 5 5
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  34. 6 3
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  35. 8 5
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  36. 10 3
      apps/app/src/features/search/client/components/SearchForm.tsx
  37. 10 10
      apps/app/src/features/search/client/components/SearchHelp.tsx
  38. 1 1
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  39. 22 16
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  40. 4 3
      apps/app/src/features/search/client/components/SearchModal.tsx
  41. 5 6
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  42. 3 0
      apps/app/src/interfaces/user-group.ts
  43. 0 15
      apps/app/src/server/models/obsolete-page.js
  44. 37 1
      apps/app/src/server/models/page.ts
  45. 6 3
      apps/app/src/server/models/user-group.ts
  46. 6 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  47. 12 13
      apps/app/src/server/service/page/index.ts
  48. 20 10
      apps/app/src/server/service/user-group.ts
  49. 3 6
      apps/app/src/services/renderer/remark-plugins/xsv-to-table.ts
  50. 8 8
      apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts
  51. 2 0
      apps/app/test/integration/models/v5.page.test.js
  52. 240 116
      apps/app/test/integration/service/user-groups.test.ts
  53. 4 4
      apps/slackbot-proxy/docker/Dockerfile
  54. 2 2
      package.json
  55. 1 1
      packages/core/tsconfig.json
  56. 8 1
      packages/editor/package.json
  57. 1 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  58. 7 1
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx
  59. 16 2
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  60. 7 5
      packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts
  61. 13 14
      packages/editor/src/services/list-util/insert-newline-continue-markup.ts
  62. 0 0
      packages/editor/src/services/paste-util/paste-markdown-util.ts
  63. 1 0
      packages/editor/src/services/table-util/index.ts
  64. 202 0
      packages/editor/src/services/table-util/insert-new-row-to-table-markdown.ts
  65. 24 0
      packages/editor/src/services/table-util/markdown-table.d.ts
  66. 147 0
      packages/editor/src/services/table-util/markdown-table.js
  67. 1 0
      packages/ui/src/utils/index.ts
  68. 52 0
      packages/ui/src/utils/use-rect.ts
  69. 40 52
      yarn.lock

+ 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

+ 4 - 4
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.",

+ 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": "该组的父组被改变",

+ 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,

+ 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, {

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

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
 
 import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
+import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import {
   Collapse,

+ 21 - 27
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';
@@ -12,9 +13,9 @@ 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,
@@ -59,7 +60,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';
@@ -87,9 +87,8 @@ type Props = {
 export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const { t } = useTranslation();
-  const router = useRouter();
 
-  const previewRef = useRef<HTMLDivElement>(null);
+  const [previewRect, previewRef] = useRect();
 
   const { data: isNotFound } = useIsNotFound();
   const { data: pageId } = useCurrentPageId();
@@ -106,7 +105,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();
@@ -165,15 +163,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   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]);
 
 
@@ -421,6 +413,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 <></>;
   }
@@ -436,18 +439,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}
@@ -463,14 +454,17 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             editorKeymap={editorSettings?.keymapMode}
           />
         </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;

+ 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"

+ 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>

+ 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);

+ 5 - 5
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,

+ 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];

+ 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 } });

+ 3 - 6
apps/app/src/services/renderer/remark-plugins/xsv-to-table.ts

@@ -2,8 +2,8 @@ import csvToMarkdownTable from 'csv-to-markdown-table';
 import { fromMarkdown } from 'mdast-util-from-markdown';
 import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
 import { gfmTable } from 'micromark-extension-gfm-table';
-import { Plugin } from 'unified';
-import { Node } from 'unist';
+import type { Plugin } from 'unified';
+import type { Node } from 'unist';
 import { visit } from 'unist-util-visit';
 
 type Lang = 'csv' | 'csv-h' | 'tsv' | 'tsv-h';
@@ -12,13 +12,10 @@ function isXsv(lang: unknown): lang is Lang {
   return /^(csv|csv-h|tsv|tsv-h)$/.test(lang as string);
 }
 
-// workaround for the broken type definition of csv-to-markdown-table -- 2022.09.15 Yuki Takei
-const csvToMarkdown = csvToMarkdownTable.csvToMarkdown ?? csvToMarkdownTable;
-
 function rewriteNode(node: Node, lang: Lang) {
   const tableContents = node.value as string;
 
-  const tableDoc = csvToMarkdown(
+  const tableDoc = csvToMarkdownTable(
     tableContents,
     lang === 'csv' || lang === 'csv-h' ? ',' : '\t',
     lang === 'csv-h' || lang === 'tsv-h',

+ 8 - 8
apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts

@@ -123,7 +123,7 @@ context('Search all pages', () => {
     cy.waitUntil(() => {
       // do
       cy.getByTestid('grw-tag-labels').as('tagLabels').should('be.visible');
-      cy.get('@tagLabels').find('a.btn').as('btn').click();
+      cy.get('@tagLabels').find('button').first().as('btn').click({force: true});
       // wait until
       return cy.get('body').within(() => {
         return Cypress.$('.modal.show').is(':visible');
@@ -225,23 +225,23 @@ context('Sort with dropdown', () => {
     // open sort dropdown
     cy.waitUntil(() => {
       // do
-      cy.get('.grw-search-page-nav').within(() => {
-        cy.get('button.dropdown-toggle').first().click({force: true});
+      cy.get('.search-control').within(() => {
+        cy.get('button').first().click({force: true});
       });
       // wait until
-      return cy.get('.grw-search-page-nav').within(() => {
+      return cy.get('.search-control').within(() => {
         return Cypress.$('.dropdown-menu.show').is(':visible');
       });
     });
   });
 
   it('Open sort dropdown', () => {
-    cy.get('.grw-search-page-nav .dropdown-menu.show').should('be.visible');
+    cy.get('.search-control .dropdown-menu.show').should('be.visible');
       cy.screenshot(`${ssPrefix}2-open-sort-dropdown`);
   });
 
   it('Sort by relevance', () => {
-    cy.get('.grw-search-page-nav .dropdown-menu.show').should('be.visible').within(() => {
+    cy.get('.search-control .dropdown-menu.show').should('be.visible').within(() => {
       cy.get('button:nth-child(1)').click({force: true});
     });
     cy.getByTestid('search-result-base').should('be.visible');
@@ -256,7 +256,7 @@ context('Sort with dropdown', () => {
   });
 
   it('Sort by creation date', () => {
-    cy.get('.grw-search-page-nav .dropdown-menu.show').should('be.visible').within(() => {
+    cy.get('.search-control .dropdown-menu.show').should('be.visible').within(() => {
       cy.get('button:nth-child(2)').click({force: true});
     });
     cy.getByTestid('search-result-base').should('be.visible');
@@ -271,7 +271,7 @@ context('Sort with dropdown', () => {
   });
 
   it('Sort by last update date', () => {
-    cy.get('.grw-search-page-nav .dropdown-menu.show').should('be.visible').within(() => {
+    cy.get('.search-control .dropdown-menu.show').should('be.visible').within(() => {
       cy.get('button:nth-child(3)').click({force: true});
     });
     cy.getByTestid('search-result-base').should('be.visible');

+ 2 - 0
apps/app/test/integration/models/v5.page.test.js

@@ -1531,6 +1531,7 @@ describe('Page', () => {
             // userB group remains, although options does not include it
             { item: userGroupIdPModelB, type: GroupType.userGroup },
           ]));
+          expect(normalizeGrantedGroups(page.grantedGroups).length).toBe(3);
         });
       });
     });
@@ -1671,6 +1672,7 @@ describe('Page', () => {
         { item: upodUserGroupIdB, type: GroupType.userGroup },
         { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
       ]));
+      expect(normalizeGrantedGroups(upodPagegAgBUpdated.grantedGroups).length).toBe(4);
 
       // Not changed
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);

+ 240 - 116
apps/app/test/integration/service/user-groups.test.ts

@@ -1,13 +1,23 @@
 
+import type { IGrantedGroup } from '@growi/core';
+import {
+  PageGrant, type IPage, GroupType, getIdForRef,
+} from '@growi/core';
 import mongoose from 'mongoose';
 
+import type { PageDocument, PageModel } from '../../../src/server/models/page';
+import UserGroup from '../../../src/server/models/user-group';
+import UserGroupRelation from '../../../src/server/models/user-group-relation';
+import type { IUserGroupService } from '../../../src/server/service/user-group';
 import { getInstance } from '../setup-crowi';
+import { PageActionOnGroupDelete } from '../../../src/interfaces/user-group';
 
 describe('UserGroupService', () => {
   let crowi;
   let User;
-  let UserGroup;
-  let UserGroupRelation;
+  let Page: PageModel;
+
+  let userGroupService: IUserGroupService;
 
   const groupId1 = new mongoose.Types.ObjectId();
   const groupId2 = new mongoose.Types.ObjectId();
@@ -21,14 +31,33 @@ describe('UserGroupService', () => {
   const groupId10 = new mongoose.Types.ObjectId();
   const groupId11 = new mongoose.Types.ObjectId();
   const groupId12 = new mongoose.Types.ObjectId();
+  const groupId13 = new mongoose.Types.ObjectId();
+  const groupId14 = new mongoose.Types.ObjectId();
+  const groupId15 = new mongoose.Types.ObjectId();
 
   const userId1 = new mongoose.Types.ObjectId();
+  let user1;
+
+  const pageId1 = new mongoose.Types.ObjectId();
+  const pageId2 = new mongoose.Types.ObjectId();
+
+  let rootPage: PageDocument | null;
+
+  // normalize for result comparison
+  const normalizeGrantedGroups = (grantedGroups: IGrantedGroup[] | undefined) => {
+    if (grantedGroups == null) { return null }
+    return grantedGroups.map((group) => {
+      return { item: getIdForRef(group.item), type: group.type };
+    });
+  };
 
   beforeAll(async() => {
     crowi = await getInstance();
     User = mongoose.model('User');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
+    Page = mongoose.model<IPage, PageModel>('Page');
+
+    rootPage = await Page.findOne({ path: '/' });
+    userGroupService = crowi.userGroupService;
 
     await User.insertMany([
       // ug -> User Group
@@ -36,6 +65,7 @@ describe('UserGroupService', () => {
         _id: userId1, name: 'ug_test_user1', username: 'ug_test_user1', email: 'ug_test_user1@example.com',
       },
     ]);
+    user1 = await User.findOne({ _id: userId1 });
 
 
     // Create Groups
@@ -111,6 +141,24 @@ describe('UserGroupService', () => {
         description: 'description12',
         parent: groupId11,
       },
+      // for removeCompletelyByRootGroupId test
+      {
+        _id: groupId13,
+        name: 'v5_group13',
+        description: 'description13',
+      },
+      {
+        _id: groupId14,
+        name: 'v5_group14',
+        description: 'description14',
+        parent: groupId13,
+      },
+      {
+        _id: groupId15,
+        name: 'v5_group15',
+        description: 'description15',
+        parent: groupId15,
+      },
     ]);
 
     // Create UserGroupRelations
@@ -145,139 +193,215 @@ describe('UserGroupService', () => {
       },
     ]);
 
+    await Page.insertMany([
+      {
+        _id: pageId1,
+        path: '/canBePublicized',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: userId1,
+        lastUpdateUser: userId1,
+        grantedGroups: [
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId14, type: GroupType.userGroup },
+        ],
+        parent: rootPage?._id,
+      },
+      {
+        _id: pageId2,
+        path: '/cannotBePublicized',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: userId1,
+        lastUpdateUser: userId1,
+        grantedGroups: [
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId15, type: GroupType.userGroup },
+        ],
+        parent: rootPage?._id,
+      },
+    ]);
+
   });
 
   /*
     * Update UserGroup
     */
-  test('Updated values should be reflected. (name, description, parent)', async() => {
-    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
+  describe('updateGroup', () => {
+    test('Updated values should be reflected. (name, description, parent)', async() => {
+      const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
-    const newGroupName = 'v5_group1_new';
-    const newGroupDescription = 'description1_new';
-    const newParentId = userGroup2._id;
+      const newGroupName = 'v5_group1_new';
+      const newGroupDescription = 'description1_new';
+      const newParentId = userGroup2?._id;
 
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(groupId1, newGroupName, newGroupDescription, newParentId);
+      const updatedUserGroup = await userGroupService.updateGroup(groupId1, newGroupName, newGroupDescription, newParentId);
 
-    expect(updatedUserGroup.name).toBe(newGroupName);
-    expect(updatedUserGroup.description).toBe(newGroupDescription);
-    expect(updatedUserGroup.parent).toStrictEqual(newParentId);
-  });
+      expect(updatedUserGroup.name).toBe(newGroupName);
+      expect(updatedUserGroup.description).toBe(newGroupDescription);
+      expect(updatedUserGroup.parent).toStrictEqual(newParentId);
+    });
 
-  test('Should throw an error when trying to set existing group name', async() => {
+    test('Should throw an error when trying to set existing group name', async() => {
 
-    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
+      const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
-    const result = crowi.userGroupService.updateGroup(groupId1, userGroup2.name);
+      const result = userGroupService.updateGroup(groupId1, userGroup2?.name);
 
-    await expect(result).rejects.toThrow('The group name is already taken');
-  });
+      await expect(result).rejects.toThrow('The group name is already taken');
+    });
 
-  test('Parent should be null when parent group is released', async() => {
-    const userGroup = await UserGroup.findOne({ _id: groupId3 });
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(userGroup._id, userGroup.name, userGroup.description, null);
+    test('Parent should be null when parent group is released', async() => {
+      const userGroup = await UserGroup.findOne({ _id: groupId3 });
+      const updatedUserGroup = await userGroupService.updateGroup(userGroup?._id, userGroup?.name, userGroup?.description, null);
 
-    expect(updatedUserGroup.parent).toBeNull();
-  });
-
-  /*
-  * forceUpdateParents: false
-  */
-  test('Should throw an error when users in child group do not exist in parent group', async() => {
-    const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
-    const result = crowi.userGroupService.updateGroup(userGroup4._id, userGroup4.name, userGroup4.description, groupId5);
+      expect(updatedUserGroup.parent).toBeNull();
+    });
 
-    await expect(result).rejects.toThrow('The parent group does not contain the users in this group.');
-  });
+    /*
+    * forceUpdateParents: false
+    */
+    test('Should throw an error when users in child group do not exist in parent group', async() => {
+      const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
+      const result = userGroupService.updateGroup(userGroup4?._id, userGroup4?.name, userGroup4?.description, groupId5);
 
-  /*
-  * forceUpdateParents: true
-  */
-  test('User should be included to parent group (2 groups ver)', async() => {
-    const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
-    const userGroup5 = await UserGroup.findOne({ _id: groupId5, parent: null });
-    // userGroup4 has userId1
-    const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup4, relatedUser: userId1 });
-    expect(userGroupRelation4BeforeUpdate).not.toBeNull();
-
-    // userGroup5 has not userId1
-    const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup5, relatedUser: userId1 });
-    expect(userGroupRelation5BeforeUpdate).toBeNull();
-
-    // update userGroup4's parent with userGroup5 (forceUpdate: true)
-    const forceUpdateParents = true;
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(
-      userGroup4._id, userGroup4.name, userGroup4.description, groupId5, forceUpdateParents,
-    );
-
-    expect(updatedUserGroup.parent).toStrictEqual(groupId5);
-    // userGroup5 should have userId1
-    const userGroupRelation5AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId5, relatedUser: userGroupRelation4BeforeUpdate.relatedUser });
-    expect(userGroupRelation5AfterUpdate).not.toBeNull();
-  });
+      await expect(result).rejects.toThrow('The parent group does not contain the users in this group.');
+    });
 
-  test('User should be included to parent group (3 groups ver)', async() => {
-    const userGroup8 = await UserGroup.findOne({ _id: groupId8, parent: null });
-
-    // userGroup7 has not userId1
-    const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId6, relatedUser: userId1 });
-    const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId7, relatedUser: userId1 });
-    const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId8, relatedUser: userId1 });
-    expect(userGroupRelation6BeforeUpdate).not.toBeNull();
-    // userGroup7 does not have userId1
-    expect(userGroupRelation7BeforeUpdate).toBeNull();
-    expect(userGroupRelation8BeforeUpdate).not.toBeNull();
-
-    // update userGroup8's parent with userGroup7 (forceUpdate: true)
-    const forceUpdateParents = true;
-    await crowi.userGroupService.updateGroup(
-      userGroup8._id, userGroup8.name, userGroup8.description, groupId7, forceUpdateParents,
-    );
-
-    const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId6, relatedUser: userId1 });
-    const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId7, relatedUser: userId1 });
-    const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId8, relatedUser: userId1 });
-    expect(userGroupRelation6AfterUpdate).not.toBeNull();
-    // userGroup7 should have userId1
-    expect(userGroupRelation7AfterUpdate).not.toBeNull();
-    expect(userGroupRelation8AfterUpdate).not.toBeNull();
+    /*
+    * forceUpdateParents: true
+    */
+    test('User should be included to parent group (2 groups ver)', async() => {
+      const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
+      const userGroup5 = await UserGroup.findOne({ _id: groupId5, parent: null });
+      // userGroup4 has userId1
+      const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup4, relatedUser: userId1 });
+      expect(userGroupRelation4BeforeUpdate).not.toBeNull();
+
+      // userGroup5 has not userId1
+      const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup5, relatedUser: userId1 });
+      expect(userGroupRelation5BeforeUpdate).toBeNull();
+
+      // update userGroup4's parent with userGroup5 (forceUpdate: true)
+      const forceUpdateParents = true;
+      const updatedUserGroup = await userGroupService.updateGroup(
+        userGroup4?._id, userGroup4?.name, userGroup4?.description, groupId5, forceUpdateParents,
+      );
+
+      expect(updatedUserGroup.parent).toStrictEqual(groupId5);
+      // userGroup5 should have userId1
+      const userGroupRelation5AfterUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId5, relatedUser: userGroupRelation4BeforeUpdate?.relatedUser,
+      });
+      expect(userGroupRelation5AfterUpdate).not.toBeNull();
+    });
+
+    test('User should be included to parent group (3 groups ver)', async() => {
+      const userGroup8 = await UserGroup.findOne({ _id: groupId8, parent: null });
+
+      // userGroup7 has not userId1
+      const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId6, relatedUser: userId1 });
+      const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId7, relatedUser: userId1 });
+      const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId8, relatedUser: userId1 });
+      expect(userGroupRelation6BeforeUpdate).not.toBeNull();
+      // userGroup7 does not have userId1
+      expect(userGroupRelation7BeforeUpdate).toBeNull();
+      expect(userGroupRelation8BeforeUpdate).not.toBeNull();
+
+      // update userGroup8's parent with userGroup7 (forceUpdate: true)
+      const forceUpdateParents = true;
+      await userGroupService.updateGroup(
+        userGroup8?._id, userGroup8?.name, userGroup8?.description, groupId7, forceUpdateParents,
+      );
+
+      const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId6, relatedUser: userId1 });
+      const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId7, relatedUser: userId1 });
+      const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId8, relatedUser: userId1 });
+      expect(userGroupRelation6AfterUpdate).not.toBeNull();
+      // userGroup7 should have userId1
+      expect(userGroupRelation7AfterUpdate).not.toBeNull();
+      expect(userGroupRelation8AfterUpdate).not.toBeNull();
+    });
+
+    test('Should throw an error when trying to choose parent from descendant groups.', async() => {
+      const userGroup9 = await UserGroup.findOne({ _id: groupId9, parent: null });
+      const userGroup10 = await UserGroup.findOne({ _id: groupId10, parent: groupId9 });
+
+      const userGroupRelation9BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup9?._id, relatedUser: userId1 });
+      const userGroupRelation10BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup10?._id, relatedUser: userId1 });
+      expect(userGroupRelation9BeforeUpdate).not.toBeNull();
+      expect(userGroupRelation10BeforeUpdate).not.toBeNull();
+
+      const result = userGroupService.updateGroup(
+        userGroup9?._id, userGroup9?.name, userGroup9?.description, userGroup10?._id,
+      );
+      await expect(result).rejects.toThrow('It is not allowed to choose parent from descendant groups.');
+    });
   });
 
-  test('Should throw an error when trying to choose parent from descendant groups.', async() => {
-    const userGroup9 = await UserGroup.findOne({ _id: groupId9, parent: null });
-    const userGroup10 = await UserGroup.findOne({ _id: groupId10, parent: groupId9 });
-
-    const userGroupRelation9BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup9._id, relatedUser: userId1 });
-    const userGroupRelation10BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup10._id, relatedUser: userId1 });
-    expect(userGroupRelation9BeforeUpdate).not.toBeNull();
-    expect(userGroupRelation10BeforeUpdate).not.toBeNull();
-
-    const result = crowi.userGroupService.updateGroup(
-      userGroup9._id, userGroup9.name, userGroup9.description, userGroup10._id,
-    );
-    await expect(result).rejects.toThrow('It is not allowed to choose parent from descendant groups.');
+  describe('removeUserByUsername', () => {
+    test('User should be deleted from child groups when the user excluded from the parent group', async() => {
+      const userGroup11 = await UserGroup.findOne({ _id: groupId11, parent: null });
+      const userGroup12 = await UserGroup.findOne({ _id: groupId12, parent: groupId11 });
+
+      // Both groups have user1
+      const userGroupRelation11BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11?._id, relatedUser: userId1 });
+      const userGroupRelation12BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12?._id, relatedUser: userId1 });
+      expect(userGroupRelation11BeforeRemove).not.toBeNull();
+      expect(userGroupRelation12BeforeRemove).not.toBeNull();
+
+      // remove user1 from the parent group
+      await userGroupService.removeUserByUsername(
+        userGroup11?._id, 'ug_test_user1',
+      );
+
+      // Both groups have not user1
+      const userGroupRelation11AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11?._id, relatedUser: userId1 });
+      const userGroupRelation12AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12?._id, relatedUser: userId1 });
+      await expect(userGroupRelation11AfterRemove).toBeNull();
+      await expect(userGroupRelation12AfterRemove).toBeNull();
+    });
   });
 
-  test('User should be deleted from child groups when the user excluded from the parent group', async() => {
-    const userGroup11 = await UserGroup.findOne({ _id: groupId11, parent: null });
-    const userGroup12 = await UserGroup.findOne({ _id: groupId12, parent: groupId11 });
-
-    // Both groups have user1
-    const userGroupRelation11BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11._id, relatedUser: userId1 });
-    const userGroupRelation12BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12._id, relatedUser: userId1 });
-    expect(userGroupRelation11BeforeRemove).not.toBeNull();
-    expect(userGroupRelation12BeforeRemove).not.toBeNull();
-
-    // remove user1 from the parent group
-    await crowi.userGroupService.removeUserByUsername(
-      userGroup11._id, 'ug_test_user1',
-    );
-
-    // Both groups have not user1
-    const userGroupRelation11AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11._id, relatedUser: userId1 });
-    const userGroupRelation12AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12._id, relatedUser: userId1 });
-    await expect(userGroupRelation11AfterRemove).toBeNull();
-    await expect(userGroupRelation12AfterRemove).toBeNull();
+  describe('removeCompletelyByRootGroupId', () => {
+    describe('when action is public', () => {
+      test('Should remove the group and its descendants and publicize pages that are only visible to the groups to be removed', async() => {
+        const userGroup13 = await UserGroup.findOne({ _id: groupId13 });
+        const userGroup14 = await UserGroup.findOne({ _id: groupId14 });
+        expect(userGroup13).not.toBeNull();
+        expect(userGroup14).not.toBeNull();
+
+        const canBePublicized = await Page.findOne({ _id: pageId1 });
+        const cannotBePublicized = await Page.findOne({ _id: pageId2 });
+        expect(canBePublicized?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)).toEqual(expect.arrayContaining([
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId14, type: GroupType.userGroup },
+        ]));
+        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)?.length).toBe(2);
+        expect(cannotBePublicized?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+        expect(normalizeGrantedGroups(cannotBePublicized?.grantedGroups)).toEqual(expect.arrayContaining([
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId15, type: GroupType.userGroup },
+        ]));
+        expect(normalizeGrantedGroups(cannotBePublicized?.grantedGroups)?.length).toBe(2);
+
+        await userGroupService.removeCompletelyByRootGroupId(groupId13, PageActionOnGroupDelete.publicize, user1);
+
+        const userGroup13AfterDeleteProcess = await UserGroup.findOne({ _id: groupId13 });
+        const userGroup14AfterDeleteProcess = await UserGroup.findOne({ _id: groupId14 });
+        expect(userGroup13AfterDeleteProcess).toBeNull();
+        expect(userGroup14AfterDeleteProcess).toBeNull();
+
+        const canBePublicizedAfterDeleteProcess = await Page.findOne({ _id: pageId1 });
+        const cannotBePublicizedAfterDeleteProcess = await Page.findOne({ _id: pageId2 });
+        expect(canBePublicizedAfterDeleteProcess?.grant).toBe(PageGrant.GRANT_PUBLIC);
+        expect(normalizeGrantedGroups(canBePublicizedAfterDeleteProcess?.grantedGroups)).toEqual([]);
+        expect(cannotBePublicizedAfterDeleteProcess?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+        expect(normalizeGrantedGroups(cannotBePublicizedAfterDeleteProcess?.grantedGroups)).toEqual(expect.arrayContaining([
+          { item: groupId15, type: GroupType.userGroup },
+        ]));
+        expect(normalizeGrantedGroups(cannotBePublicizedAfterDeleteProcess?.grantedGroups)?.length).toBe(1);
+      });
+    });
   });
 
 });

+ 4 - 4
apps/slackbot-proxy/docker/Dockerfile

@@ -3,7 +3,7 @@
 ##
 ## base
 ##
-FROM node:18-slim AS base
+FROM node:20-slim AS base
 
 ENV optDir /opt
 
@@ -17,7 +17,7 @@ RUN turbo prune --scope=@growi/slackbot-proxy --docker
 ##
 ## deps-resolver
 ##
-FROM node:18-slim AS deps-resolver
+FROM node:20-slim AS deps-resolver
 
 ENV optDir /opt
 
@@ -57,7 +57,7 @@ RUN tar -cf node_modules.tar \
 ##
 ## builder
 ##
-FROM node:18-slim AS builder
+FROM node:20-slim AS builder
 
 ENV optDir /opt
 
@@ -95,7 +95,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

+ 2 - 2
package.json

@@ -96,8 +96,8 @@
     "vitest-mock-extended": "^1.1.3"
   },
   "engines": {
-    "node": "^16 || ^18",
-    "npm": ">=8.5 < 9",
+    "node": "^18 || ^20",
+    "npm": ">=8.5 < 9.6.6",
     "yarn": ">=1.22 <2"
   }
 }

+ 1 - 1
packages/core/tsconfig.json

@@ -12,5 +12,5 @@
   },
   "include": [
     "src", "test"
-, "../../apps/app/src/server/util/collect-ancestor-paths.ts"  ]
+  ]
 }

+ 8 - 1
packages/editor/package.json

@@ -17,9 +17,13 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "dependencies": {
+    "markdown-table": "^3.0.3",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
+  "// comments for devDependencies": {
+    "string-width": "5.0.0 or above exports only ESM."
+  },
   "devDependencies": {
     "@codemirror/lang-markdown": "^6.2.0",
     "@codemirror/language": "^6.8.0",
@@ -41,16 +45,19 @@
     "cm6-theme-material-dark": "^0.2.0",
     "cm6-theme-nord": "^0.2.0",
     "codemirror": "^6.0.1",
+    "csv-to-markdown-table": "^1.4.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",
+    "markdown-table": "^3.0.3",
     "react-dropzone": "^14.2.3",
     "react-hook-form": "^7.45.4",
     "react-toastify": "^9.1.3",
     "reactstrap": "^9.2.0",
+    "string-width": "=4.2.2",
     "swr": "^2.2.2",
     "ts-deepmerge": "^6.2.0",
     "y-codemirror.next": "^0.3.2",
     "y-socket.io": "^1.1.0",
-    "yjs": "^13.6.7"
+    "yjs": "^13.6.12"
   }
 }

+ 1 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -14,7 +14,7 @@ import {
 } from '../../services';
 import {
   adjustPasteData, getStrFromBol,
-} from '../../services/list-util/markdown-list-util';
+} from '../../services/paste-util/paste-markdown-util';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 import { Toolbar } from './Toolbar';

+ 7 - 1
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropdownItem.tsx

@@ -25,7 +25,13 @@ export const AttachmentsDropdownItem = (props: Props): JSX.Element => {
     getRootProps,
     getInputProps,
     open,
-  } = useFileDropzone({ onUpload, acceptedUploadFileType });
+  } = useFileDropzone({
+    onUpload,
+    acceptedUploadFileType,
+    dropzoneOpts: {
+      noClick: true, noDrag: true, noKeyboard: true,
+    },
+  });
 
   return (
     <div {...getRootProps()} className="dropzone">

+ 16 - 2
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -10,6 +10,7 @@ import {
   EditorState, Prec, type Extension,
 } from '@codemirror/state';
 import { keymap, EditorView } from '@codemirror/view';
+import type { Command } from '@codemirror/view';
 import { tags } from '@lezer/highlight';
 import { useCodeMirror, type UseCodeMirror } from '@uiw/react-codemirror';
 import deepmerge from 'ts-deepmerge';
@@ -19,6 +20,8 @@ import deepmerge from 'ts-deepmerge';
 import { yUndoManagerKeymap } from 'y-codemirror.next';
 
 import { emojiAutocompletionSettings } from '../../extensions/emojiAutocompletionSettings';
+import { insertNewlineContinueMarkup } from '../../list-util/insert-newline-continue-markup';
+import { insertNewRowToMarkdownTable, isInTable } from '../../table-util/insert-new-row-to-table-markdown';
 
 import { useAppendExtensions, type AppendExtensions } from './utils/append-extensions';
 import { useFocus, type Focus } from './utils/focus';
@@ -26,18 +29,29 @@ import { FoldDrawio, useFoldDrawio } from './utils/fold-drawio';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
 import { useInsertMarkdownElements, type InsertMarkdowElements } from './utils/insert-markdown-elements';
-import { insertNewlineContinueMarkup } from './utils/insert-newline-continue-markup';
 import { useInsertPrefix, type InsertPrefix } from './utils/insert-prefix';
 import { useInsertText, type InsertText } from './utils/insert-text';
 import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 
 
+const onPressEnter: Command = (editor) => {
+
+  if (isInTable(editor)) {
+    insertNewRowToMarkdownTable(editor);
+    return true;
+  }
+
+  insertNewlineContinueMarkup(editor);
+
+  return true;
+};
+
 // set new markdownKeymap instead of default one
 // https://github.com/codemirror/lang-markdown/blob/main/src/index.ts#L17
 const markdownKeymap = [
   { key: 'Backspace', run: deleteCharBackward },
-  { key: 'Enter', run: insertNewlineContinueMarkup },
+  { key: 'Enter', run: onPressEnter },
 ];
 
 const markdownHighlighting = HighlightStyle.define([

+ 7 - 5
packages/editor/src/services/file-dropzone/use-file-dropzone/use-file-dropzone.ts

@@ -35,11 +35,13 @@ export const useFileDropzone = (props: Props): FileDropzoneState => {
 
   }, [onUpload, setIsUploading, acceptedUploadFileType]);
 
-  const accept: Accept | undefined = acceptedUploadFileType === AcceptedUploadFileType.IMAGE
-    ? {
-      'image/*': [],
-    }
-    : undefined;
+  let accept: Accept | undefined;
+  if (acceptedUploadFileType === AcceptedUploadFileType.ALL) {
+    accept = { 'application/*': [] };
+  }
+  else if (acceptedUploadFileType === AcceptedUploadFileType.IMAGE) {
+    accept = { 'image/*': [] };
+  }
 
   const dzState = useDropzone({
     onDrop: dropHandler,

+ 13 - 14
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-newline-continue-markup.ts → packages/editor/src/services/list-util/insert-newline-continue-markup.ts

@@ -1,25 +1,26 @@
-import type { ChangeSpec, StateCommand } from '@codemirror/state';
+import type { ChangeSpec } from '@codemirror/state';
+import { EditorView } from '@codemirror/view';
 
 // https://regex101.com/r/7BN2fR/5
 const indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
 const indentAndMarkOnlyRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
 
-export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) => {
+export const insertNewlineContinueMarkup = (editor: EditorView): void => {
 
   const changes: ChangeSpec[] = [];
 
   let selection;
 
-  const curPos = state.selection.main.head;
+  const curPos = editor.state.selection.main.head;
 
-  const aboveLine = state.doc.lineAt(curPos).number;
-  const bolPos = state.doc.line(aboveLine).from;
+  const aboveLine = editor.state.doc.lineAt(curPos).number;
+  const bolPos = editor.state.doc.line(aboveLine).from;
 
-  const strFromBol = state.sliceDoc(bolPos, curPos);
+  const strFromBol = editor.state.sliceDoc(bolPos, curPos);
 
   // If the text before the cursor is only markdown symbols
   if (indentAndMarkOnlyRE.test(strFromBol)) {
-    const insert = state.lineBreak;
+    const insert = editor.state.lineBreak;
 
     changes.push({
       from: bolPos,
@@ -33,10 +34,10 @@ export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) =
     const indentAndMark = strFromBol.match(indentAndMarkRE)?.[0];
 
     if (indentAndMark == null) {
-      return false;
+      return;
     }
 
-    const insert = state.lineBreak + indentAndMark;
+    const insert = editor.state.lineBreak + indentAndMark;
     const nextCurPos = curPos + insert.length;
 
     selection = { anchor: nextCurPos };
@@ -49,7 +50,7 @@ export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) =
 
   // If the text before the cursor is regular text
   else {
-    const insert = state.lineBreak;
+    const insert = editor.state.lineBreak;
     const nextCurPos = curPos + insert.length;
 
     selection = { anchor: nextCurPos };
@@ -60,11 +61,9 @@ export const insertNewlineContinueMarkup: StateCommand = ({ state, dispatch }) =
     });
   }
 
-  dispatch(state.update({
+  editor.dispatch({
     changes,
     selection,
     userEvent: 'input',
-  }));
-
-  return true;
+  });
 };

+ 0 - 0
packages/editor/src/services/list-util/markdown-list-util.ts → packages/editor/src/services/paste-util/paste-markdown-util.ts


+ 1 - 0
packages/editor/src/services/table-util/index.ts

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

+ 202 - 0
packages/editor/src/services/table-util/insert-new-row-to-table-markdown.ts

@@ -0,0 +1,202 @@
+import { EditorView } from '@codemirror/view';
+
+import { MarkdownTable } from './markdown-table';
+
+// https://regex101.com/r/7BN2fR/10
+const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
+// https://regex101.com/r/1UuWBJ/3
+export const emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
+
+const getCurPos = (editor: EditorView): number => {
+  return editor.state.selection.main.head;
+};
+
+export const isInTable = (editor: EditorView): boolean => {
+  const curPos = getCurPos(editor);
+  const lineText = editor.state.doc.lineAt(curPos).text;
+  return linePartOfTableRE.test(lineText);
+};
+
+const getBot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return getCurPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const firstLine = 1;
+  let line = doc.lineAt(getCurPos(editor)).number - 1;
+  for (; line >= firstLine; line--) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+  const botLine = Math.max(firstLine, line + 1);
+  return doc.line(botLine).from;
+};
+
+const getEot = (editor: EditorView): number => {
+  if (!isInTable(editor)) {
+    return getCurPos(editor);
+  }
+
+  const doc = editor.state.doc;
+  const lastLine = doc.lines;
+
+  let line = doc.lineAt(getCurPos(editor)).number + 1;
+
+  for (; line <= lastLine; line++) {
+    const strLine = doc.line(line).text;
+    if (!linePartOfTableRE.test(strLine)) {
+      break;
+    }
+  }
+
+  const eotLine = line - 1;
+
+  return doc.line(eotLine).to;
+};
+
+const getStrFromBot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(getBot(editor), getCurPos(editor));
+};
+
+const getStrToEot = (editor: EditorView): string => {
+  return editor.state.sliceDoc(getCurPos(editor), getEot(editor));
+};
+
+const addRowToMarkdownTable = (mdtable: MarkdownTable): any => {
+  const numCol = mdtable.table.length > 0 ? mdtable.table[0].length : 1;
+  const newRow: string[] = new Array(numCol);
+
+  newRow.fill('');
+
+  mdtable.table.push(newRow);
+};
+
+export const mergeMarkdownTable = (mdtableList: MarkdownTable[]): MarkdownTable => {
+  let newTable: any[] = [];
+  const options = mdtableList[0].options;
+  mdtableList.forEach((mdtable) => {
+    newTable = newTable.concat(mdtable.table);
+  });
+  return (new MarkdownTable(newTable, options));
+};
+
+const addRow = (editor: EditorView) => {
+  const strFromBot = getStrFromBot(editor);
+
+  let table = MarkdownTable.fromMarkdownString(strFromBot);
+
+  addRowToMarkdownTable(table);
+
+  const strToEot = getStrToEot(editor);
+
+  const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
+
+  if (tableBottom.table.length > 0) {
+    table = mergeMarkdownTable([table, tableBottom]);
+  }
+
+  const curPos = getCurPos(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+  const nextLine = curLine + 1;
+
+  const botPos = getBot(editor);
+  const eotPos = getEot(editor);
+
+  editor.dispatch({
+    changes: {
+      from: botPos,
+      to: eotPos,
+      insert: table.toString(),
+    },
+  });
+
+  const nextCurPos = editor.state.doc.line(nextLine).from + 2;
+
+  editor.dispatch({
+    selection: { anchor: nextCurPos },
+  });
+};
+
+const removeRow = (editor: EditorView) => {
+
+  const curPos = getCurPos(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+
+  const bolPos = editor.state.doc.line(curLine).from;
+  const eolPos = editor.state.doc.line(curLine).to;
+
+  const nextCurPos = editor.state.doc.lineAt(getCurPos(editor)).to + 1;
+
+  editor.dispatch({
+    changes: {
+      from: bolPos,
+      to: eolPos,
+    },
+  });
+
+  editor.dispatch({
+    selection: { anchor: nextCurPos },
+  });
+};
+
+const reformTable = (editor: EditorView) => {
+  const tableStr = getStrFromBot(editor) + getStrToEot(editor);
+  const table = MarkdownTable.fromMarkdownString(tableStr);
+
+  const curPos = getCurPos(editor);
+  const botPos = getBot(editor);
+  const eotPos = getEot(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+  const nextLine = curLine + 1;
+
+  const eolPos = editor.state.doc.line(curLine).to;
+  const strToEol = editor.state.sliceDoc(curPos, eolPos);
+
+  const isLastRow = getStrToEot(editor) === strToEol;
+
+  editor.dispatch({
+    changes: {
+      from: botPos,
+      to: eotPos,
+      insert: table.toString(),
+    },
+  });
+
+  const nextCurPos = isLastRow ? editor.state.doc.line(curLine).to : editor.state.doc.line(nextLine).from + 2;
+
+  editor.dispatch({
+    selection: { anchor: nextCurPos },
+  });
+};
+
+export const insertNewRowToMarkdownTable = (editor: EditorView): void => {
+
+  const curPos = getCurPos(editor);
+
+  const curLine = editor.state.doc.lineAt(curPos).number;
+
+  const bolPos = editor.state.doc.line(curLine).from;
+  const eolPos = editor.state.doc.line(curLine).to;
+
+  const strFromBol = editor.state.sliceDoc(bolPos, curPos);
+  const strToEol = editor.state.sliceDoc(curPos, eolPos);
+
+  const isLastRow = getStrToEot(editor) === strToEol;
+  const isEndOfLine = curPos === eolPos;
+
+  if (isEndOfLine) {
+    addRow(editor);
+  }
+  else if (isLastRow && emptyLineOfTableRE.test(strFromBol + strToEol)) {
+    removeRow(editor);
+  }
+  else {
+    reformTable(editor);
+  }
+};

+ 24 - 0
packages/editor/src/services/table-util/markdown-table.d.ts

@@ -0,0 +1,24 @@
+export declare class MarkdownTable {
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  static fromHTMLTableTag(str: any): MarkdownTable;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  static fromDSV(str: any, delimiter: any): MarkdownTable;
+
+  static fromMarkdownString(str: string): MarkdownTable;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(table: any, options: any);
+
+  table: any;
+
+  options: any;
+
+  toString(): any;
+
+  clone(): MarkdownTable;
+
+  normalizeCells(): MarkdownTable;
+
+}

+ 147 - 0
packages/editor/src/services/table-util/markdown-table.js

@@ -0,0 +1,147 @@
+import csvToMarkdown from 'csv-to-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
+// https://regex101.com/r/7BN2fR/7
+const tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
+const tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
+const linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
+
+const defaultOptions = { stringLength: stringWidth };
+
+/**
+ * markdown table class for markdown-table module
+ *   ref. https://github.com/wooorm/markdown-table
+ */
+export class MarkdownTable {
+
+  constructor(table, options) {
+    this.table = table || [];
+    this.options = Object.assign(options || {}, defaultOptions);
+
+    this.toString = this.toString.bind(this);
+  }
+
+  toString() {
+    return markdownTable(this.table, this.options);
+  }
+
+  /**
+   * returns cloned Markdowntable instance
+   * (This method clones only the table field.)
+   */
+  clone() {
+    const newTable = [];
+    for (let i = 0; i < this.table.length; i++) {
+      newTable.push([].concat(this.table[i]));
+    }
+    return new MarkdownTable(newTable, this.options);
+  }
+
+  /**
+   * normalize all cell data(trim & convert the newline character to space or pad '' if cell data is null)
+   */
+  normalizeCells() {
+    for (let i = 0; i < this.table.length; i++) {
+      for (let j = 0; j < this.table[i].length; j++) {
+        if (this.table[i][j] != null) {
+          this.table[i][j] = this.table[i][j].trim().replace(/\r?\n/g, ' ');
+        }
+        else {
+          this.table[i][j] = '';
+        }
+      }
+    }
+
+    return this;
+  }
+
+  /**
+   * return a MarkdownTable instance made from a string of HTML table tag
+   *
+   * If a parser error occurs, an error object with an error message is thrown.
+   * The error message is a innerHTML, so must not assign it into element.innerHTML because it can lead to Mutation-based XSS
+   */
+  static fromHTMLTableTag(str) {
+    // set up DOMParser
+    const domParser = new (window.DOMParser)();
+
+    // use DOMParser to prevent DOM based XSS (https://developer.mozilla.org/en-US/docs/Web/API/DOMParser)
+    const dom = domParser.parseFromString(str, 'application/xml');
+
+    if (dom.querySelector('parsererror')) {
+      throw new Error(dom.documentElement.innerHTML);
+    }
+
+    const tableElement = dom.querySelector('table');
+    const trElements = tableElement.querySelectorAll('tr');
+
+    const table = [];
+    let maxRowSize = 0;
+    for (let i = 0; i < trElements.length; i++) {
+      const row = [];
+      const cellElements = trElements[i].querySelectorAll('th,td');
+      for (let j = 0; j < cellElements.length; j++) {
+        row.push(cellElements[j].innerHTML);
+      }
+      table.push(row);
+
+      if (maxRowSize < row.length) maxRowSize = row.length;
+    }
+
+    const align = [];
+    for (let i = 0; i < maxRowSize; i++) {
+      align.push('');
+    }
+
+    return new MarkdownTable(table, { align });
+  }
+
+  /**
+   * return a MarkdownTable instance made from a string of delimiter-separated values
+   */
+  static fromDSV(str, delimiter) {
+    return MarkdownTable.fromMarkdownString(csvToMarkdown(str, delimiter, true));
+  }
+
+  /**
+   * return a MarkdownTable instance
+   *   ref. https://github.com/wooorm/markdown-table
+   * @param {string} str markdown string
+   */
+  static fromMarkdownString(str) {
+    const arrMDTableLines = str.split(/(\r\n|\r|\n)/);
+    const contents = [];
+    let aligns = [];
+    for (let n = 0; n < arrMDTableLines.length; n++) {
+      const line = arrMDTableLines[n];
+
+      if (tableAlignmentLineRE.test(line) && !tableAlignmentLineNegRE.test(line)) {
+        // parse line which described alignment
+        const alignRuleRE = [
+          { align: 'c', regex: /^:-+:$/ },
+          { align: 'l', regex: /^:-+$/ },
+          { align: 'r', regex: /^-+:$/ },
+        ];
+        let lineText = '';
+        lineText = line.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        lineText = lineText.replace(/\s*/g, '');
+        aligns = lineText.split(/\|/).map((col) => {
+          const rule = alignRuleRE.find((rule) => { return col.match(rule.regex) });
+          return (rule != null) ? rule.align : '';
+        });
+      }
+      else if (linePartOfTableRE.test(line)) {
+        // parse line whether header or body
+        let lineText = '';
+        lineText = line.replace(/\s*\|\s*/g, '|');
+        lineText = lineText.replace(/^\||\|$/g, ''); // strip off pipe charactor which is placed head of line and last of line.
+        const row = lineText.split(/\|/);
+        contents.push(row);
+      }
+    }
+    return (new MarkdownTable(contents, { align: aligns }));
+  }
+
+}

+ 1 - 0
packages/ui/src/utils/index.ts

@@ -1,2 +1,3 @@
 export * from './browser-utils';
 export * from './use-fullscreen';
+export * from './use-rect';

+ 52 - 0
packages/ui/src/utils/use-rect.ts

@@ -0,0 +1,52 @@
+// ref: https://gist.github.com/morajabi/523d7a642d8c0a2f71fcfa0d8b3d2846?permalink_comment_id=4688158#gistcomment-4688158
+
+import { useState, useRef, useEffect } from 'react';
+
+type MutableRefObject<T> = {
+  current: T
+}
+
+type EventType = 'resize' | 'scroll'
+
+const useEffectInEvent = (
+    event: EventType,
+    useCapture?: boolean,
+    set?: () => void,
+) => {
+  useEffect(() => {
+    if (set) {
+      set();
+      window.addEventListener(event, set, useCapture);
+
+      return () => window.removeEventListener(event, set, useCapture);
+    }
+  }, [event, set, useCapture]);
+};
+
+export const useRect = <T extends HTMLDivElement | null>(
+  event: EventType = 'resize',
+): [DOMRect | undefined, MutableRefObject<T | null>, number] => {
+  const [rect, setRect] = useState<DOMRect>();
+
+  const reference = useRef<T>(null);
+
+  const [screenHeight, setScreenHeight] = useState(window.innerHeight);
+
+  const set = (): void => {
+    setRect(reference.current?.getBoundingClientRect());
+  };
+
+  useEffectInEvent(event, true, set);
+  const handleResize = () => {
+    setScreenHeight(window.innerHeight);
+  };
+
+  useEffect(() => {
+    window.addEventListener(event, handleResize);
+    return () => {
+      window.removeEventListener(event, handleResize);
+    };
+  }, [event]);
+
+  return [rect, reference, screenHeight];
+};

+ 40 - 52
yarn.lock

@@ -844,27 +844,25 @@
     "@azure/abort-controller" "^1.0.0"
     tslib "^2.2.0"
 
-"@azure/identity@^3.3.2":
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-3.3.2.tgz#052c33f1e5f952fd4701fb5cffc4da82994a5f28"
-  integrity sha512-aDLwgMXpNBEXOlfCP9r5Rn+inmbnTbadlOnrKI2dPS9Lpf4gHvpYBV+DEZKttakfJ+qn4iWWb7zONQSO3A4XSA==
+"@azure/identity@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@azure/identity/-/identity-4.0.1.tgz#16a885d384fd06447a21da92c08960df492fe91e"
+  integrity sha512-yRdgF03SFLqUMZZ1gKWt0cs0fvrDIkq2bJ6Oidqcoo5uM85YMBnXWMzYKK30XqIT76lkFyAaoAAy5knXhrG4Lw==
   dependencies:
     "@azure/abort-controller" "^1.0.0"
     "@azure/core-auth" "^1.5.0"
     "@azure/core-client" "^1.4.0"
     "@azure/core-rest-pipeline" "^1.1.0"
     "@azure/core-tracing" "^1.0.0"
-    "@azure/core-util" "^1.0.0"
+    "@azure/core-util" "^1.3.0"
     "@azure/logger" "^1.0.0"
-    "@azure/msal-browser" "^2.37.1"
-    "@azure/msal-common" "^13.1.0"
-    "@azure/msal-node" "^1.17.3"
+    "@azure/msal-browser" "^3.5.0"
+    "@azure/msal-node" "^2.5.1"
     events "^3.0.0"
     jws "^4.0.0"
     open "^8.0.0"
     stoppable "^1.1.0"
     tslib "^2.2.0"
-    uuid "^8.3.0"
 
 "@azure/logger@^1.0.0":
   version "1.0.4"
@@ -873,24 +871,24 @@
   dependencies:
     tslib "^2.2.0"
 
-"@azure/msal-browser@^2.37.1":
-  version "2.38.3"
-  resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-2.38.3.tgz#2f131fa9b7a8a9546fc8d34e5d99ce4c18b04147"
-  integrity sha512-2WuLFnWWPR1IdvhhysT18cBbkXx1z0YIchVss5AwVA95g7CU5CpT3d+5BcgVGNXDXbUU7/5p0xYHV99V5z8C/A==
+"@azure/msal-browser@^3.5.0":
+  version "3.10.0"
+  resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.10.0.tgz#8925659e8d1a4bd21e389cca4683eb52658c778e"
+  integrity sha512-mnmi8dCXVNZI+AGRq0jKQ3YiodlIC4W9npr6FCB9WN6NQT+6rq+cIlxgUb//BjLyzKsnYo+i4LROGeMyU+6v1A==
   dependencies:
-    "@azure/msal-common" "13.3.1"
+    "@azure/msal-common" "14.7.1"
 
-"@azure/msal-common@13.3.1", "@azure/msal-common@^13.1.0":
-  version "13.3.1"
-  resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-13.3.1.tgz#012465bf940d12375dc47387b754ccf9d6b92180"
-  integrity sha512-Lrk1ozoAtaP/cp53May3v6HtcFSVxdFrg2Pa/1xu5oIvsIwhxW6zSPibKefCOVgd5osgykMi5jjcZHv8XkzZEQ==
+"@azure/msal-common@14.7.1":
+  version "14.7.1"
+  resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.7.1.tgz#b13443fbacc87ce2019a91e81a6582ea73847c75"
+  integrity sha512-v96btzjM7KrAu4NSEdOkhQSTGOuNUIIsUdB8wlyB9cdgl5KqEKnTonHUZ8+khvZ6Ap542FCErbnTyDWl8lZ2rA==
 
-"@azure/msal-node@^1.17.3":
-  version "1.18.4"
-  resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-1.18.4.tgz#c921b0447c92fb3b0cb1ebf5a9a76fcad2ec7c21"
-  integrity sha512-Kc/dRvhZ9Q4+1FSfsTFDME/v6+R2Y1fuMty/TfwqE5p9GTPw08BPbKgeWinE8JRHRp+LemjQbUZsn4Q4l6Lszg==
+"@azure/msal-node@^2.5.1":
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.6.4.tgz#457bd86a52461178ab2d1ba3d9d6705d95b2186e"
+  integrity sha512-nNvEPx009/80UATCToF+29NZYocn01uKrB91xtFr7bSqkqO1PuQGXRyYwryWRztUrYZ1YsSbw9A+LmwOhpVvcg==
   dependencies:
-    "@azure/msal-common" "13.3.1"
+    "@azure/msal-common" "14.7.1"
     jsonwebtoken "^9.0.0"
     uuid "^8.3.0"
 
@@ -1844,6 +1842,7 @@
 "@growi/editor@link:packages/editor":
   version "6.2.0-RC.0"
   dependencies:
+    markdown-table "^3.0.3"
     react "^18.2.0"
     react-dom "^18.2.0"
 
@@ -2865,14 +2864,7 @@
   resolved "https://registry.yarnpkg.com/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz#cc9b9092db5afb9800fda5a03801b4f6600b427e"
   integrity sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==
 
-"@restart/hooks@^0.4.0":
-  version "0.4.5"
-  resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.5.tgz#e7acbea237bfc9e479970500cf87538b41a1ed02"
-  integrity sha512-tLGtY0aHeIfT7aPwUkvQuhIy3+q3w4iqmUzFLPlOAf/vNUacLaBt1j/S//jv/dQhenRh8jvswyMojCwmLvJw8A==
-  dependencies:
-    dequal "^2.0.2"
-
-"@restart/hooks@^0.4.7":
+"@restart/hooks@^0.4.0", "@restart/hooks@^0.4.7":
   version "0.4.16"
   resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.16.tgz#95ae8ac1cc7e2bd4fed5e39800ff85604c6d59fb"
   integrity sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==
@@ -6760,10 +6752,10 @@ csurf@^1.11.0:
     csrf "3.1.0"
     http-errors "~1.7.3"
 
-csv-to-markdown-table@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.1.0.tgz#1c4546b4a6d7265d7715df51825c1852a7286247"
-  integrity sha512-gsnCustJ+9ckvdsivA8pRkBSUbr7vaMK5uuXU+gn5df93hUe2EqGPTazAJFGjc3vy0R9hjKHoLRjphTFy04bPg==
+csv-to-markdown-table@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-1.4.1.tgz#7167eb16cf76da45abd54e13993e99f029c05754"
+  integrity sha512-jhLkfM7LXGQCuhxCwIw0QmpHCbMXy8ouC+T8KKoKaZ43DQAezpHCxNl74j2S9Sb4SEnVgMK8/RqJfNUk6xMHRQ==
 
 cubic2quad@^1.2.1:
   version "1.2.1"
@@ -11522,10 +11514,10 @@ levn@^0.4.1:
     prelude-ls "^1.2.1"
     type-check "~0.4.0"
 
-lib0@^0.2.31, lib0@^0.2.42, lib0@^0.2.52, lib0@^0.2.74, lib0@^0.2.82:
-  version "0.2.85"
-  resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.85.tgz#2ccc3b6e02bd6165a4b8e68f89db5f9e7787dfc5"
-  integrity sha512-vtAhVttLXCu3ps2OIsTz8CdKYKdcMo7ds1MNBIcSXz6vrY8sxASqpTi4vmsAIn7xjWvyT7haKcWW6woP6jebjQ==
+lib0@^0.2.31, lib0@^0.2.42, lib0@^0.2.52, lib0@^0.2.82, lib0@^0.2.86:
+  version "0.2.89"
+  resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.89.tgz#f695ba69be34e28f73b3eeb5da92006f3897a470"
+  integrity sha512-5j19vcCjsQhvLG6mcDD+nprtJUCbmqLz5Hzt5xgi9SV6RIW/Dty7ZkVZHGBuPOADMKjQuKDvuQTH495wsmw8DQ==
   dependencies:
     isomorphic.js "^0.2.4"
 
@@ -11964,14 +11956,10 @@ markdown-it@^13.0.1:
     mdurl "^1.0.1"
     uc.micro "^1.0.5"
 
-markdown-table@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.1.tgz#4b3dd3a133d1518b8ef0dbc709bf2a1b4824bc8c"
-
-markdown-table@^3.0.0:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.2.tgz#9b59eb2c1b22fe71954a65ff512887065a7bb57c"
-  integrity sha512-y8j3a5/DkJCmS5x4dMCQL+OR0+2EAq3DOtio1COSHsmW2BGXnNCK3v12hJt1LrUz5iZH5g0LmuYOjDdI+czghA==
+markdown-table@^3.0.0, markdown-table@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd"
+  integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==
 
 material-icons@^1.11.3:
   version "1.13.12"
@@ -18655,12 +18643,12 @@ yauzl@^2.10.0:
     buffer-crc32 "~0.2.3"
     fd-slicer "~1.1.0"
 
-yjs@^13.6.7:
-  version "13.6.7"
-  resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.7.tgz#f1176c37f65eb566cf390bd813e2099d598795f4"
-  integrity sha512-mCZTh4kjvUS2DnaktsYN6wLH3WZCJBLqrTdkWh1bIDpA/sB/GNFaLA/dyVJj2Hc7KwONuuoC/vWe9bwBBosZLQ==
+yjs@^13.6.12:
+  version "13.6.12"
+  resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.12.tgz#dc8be640270f04c4bb92c1984fdabbc13fc9c49f"
+  integrity sha512-KOT8ILoyVH2f/PxPadeu5kVVS055D1r3x1iFfJVJzFdnN98pVGM8H07NcKsO+fG3F7/0tf30Vnokf5YIqhU/iw==
   dependencies:
-    lib0 "^0.2.74"
+    lib0 "^0.2.86"
 
 yn@3.1.1:
   version "3.1.1"