Procházet zdrojové kódy

Merge branch 'master' into feat/page-bulk-export

Futa Arai před 1 rokem
rodič
revize
e2a73c7472
100 změnil soubory, kde provedl 1529 přidání a 727 odebrání
  1. 16 2
      .changeset/config.json
  2. 5 0
      .changeset/tasty-baboons-burn.md
  3. 2 1
      .devcontainer/devcontainer.json
  4. 1 0
      .eslintrc.js
  5. 5 1
      .github/dependabot.yml
  6. 2 2
      .github/workflows/ci-app-prod.yml
  7. 27 51
      .github/workflows/ci-app.yml
  8. 2 2
      .github/workflows/ci-slackbot-proxy.yml
  9. 0 13
      .github/workflows/release-rc-scheduled.yml
  10. 115 0
      .github/workflows/release-subpackages.yml
  11. 136 26
      .github/workflows/reusable-app-prod.yml
  12. 5 15
      .github/workflows/reusable-app-reg-suit.yml
  13. 74 1
      CHANGELOG.md
  14. 0 1
      apps/app/.eslintrc.js
  15. 1 1
      apps/app/docker/Dockerfile
  16. 1 1
      apps/app/docker/README.md
  17. 1 0
      apps/app/next-env.d.ts
  18. 17 1
      apps/app/next.config.js
  19. 9 0
      apps/app/nodemon.json
  20. 16 17
      apps/app/package.json
  21. 112 0
      apps/app/playwright.config.ts
  22. 0 0
      apps/app/playwright/.auth/.gitkeep
  23. 16 0
      apps/app/playwright/.eslintrc.mjs
  24. 2 0
      apps/app/playwright/.gitignore
  25. 47 0
      apps/app/playwright/10-installer/install.spec.ts
  26. 22 0
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  27. 27 0
      apps/app/playwright/20-basic-features/create-page-button.spec.ts
  28. 97 0
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  29. 122 0
      apps/app/playwright/60-home/home.spec.ts
  30. 24 0
      apps/app/playwright/auth.setup.ts
  31. 0 1
      apps/app/public/images/icons/editor/bold.svg
  32. 0 1
      apps/app/public/images/icons/editor/check.svg
  33. 0 1
      apps/app/public/images/icons/editor/code.svg
  34. 0 1
      apps/app/public/images/icons/editor/header.svg
  35. 0 1
      apps/app/public/images/icons/editor/italic.svg
  36. 0 1
      apps/app/public/images/icons/editor/list-ol.svg
  37. 0 1
      apps/app/public/images/icons/editor/list-ul.svg
  38. 0 1
      apps/app/public/images/icons/editor/picture.svg
  39. 0 1
      apps/app/public/images/icons/editor/quote.svg
  40. 0 1
      apps/app/public/images/icons/editor/strikethrough.svg
  41. 0 1
      apps/app/public/images/icons/editor/table.svg
  42. 8 0
      apps/app/public/static/locales/en_US/translation.json
  43. 2 0
      apps/app/public/static/locales/fr_FR/translation.json
  44. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  45. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  46. 0 147
      apps/app/src/client/models/MarkdownTable.js
  47. 1 2
      apps/app/src/client/services/create-page/index.ts
  48. 0 112
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  49. 143 0
      apps/app/src/client/services/create-page/use-create-page.tsx
  50. 7 6
      apps/app/src/client/services/create-page/use-create-template-page.ts
  51. 9 0
      apps/app/src/client/services/page-operation.ts
  52. 6 6
      apps/app/src/client/services/renderer/renderer.tsx
  53. 1 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  54. 2 2
      apps/app/src/components/Admin/App/AppSetting.jsx
  55. 1 1
      apps/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  56. 0 96
      apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx
  57. 79 0
      apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.tsx
  58. 10 10
      apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  59. 4 6
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  60. 3 7
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx
  61. 1 1
      apps/app/src/components/Common/PageViewLayout.tsx
  62. 1 1
      apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  63. 9 1
      apps/app/src/components/Common/SubmittableInput/use-submittable.ts
  64. 1 1
      apps/app/src/components/CreateTemplateModal.tsx
  65. 69 0
      apps/app/src/components/GrantedGroupsInheritanceSelectModal.tsx
  66. 18 19
      apps/app/src/components/InstallerForm.tsx
  67. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  68. 9 7
      apps/app/src/components/LoginForm/LoginForm.tsx
  69. 1 1
      apps/app/src/components/Me/AssociateModal.tsx
  70. 6 6
      apps/app/src/components/Me/PersonalSettings.jsx
  71. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  72. 9 6
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  73. 1 1
      apps/app/src/components/Page/markdown-table-util-for-view.ts
  74. 2 2
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  75. 3 2
      apps/app/src/components/PageComment/CommentEditor.tsx
  76. 15 15
      apps/app/src/components/PageControls/PageControls.tsx
  77. 12 9
      apps/app/src/components/PageCreateModal.tsx
  78. 3 2
      apps/app/src/components/PageEditor/ConflictDiffModal.tsx
  79. 1 2
      apps/app/src/components/PageEditor/DrawioModal.tsx
  80. 1 1
      apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx
  81. 2 2
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  82. 2 2
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  83. 1 1
      apps/app/src/components/PageEditor/MarkdownTableDataImportForm.tsx
  84. 19 6
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  85. 8 5
      apps/app/src/components/PageEditor/PageEditor.tsx
  86. 2 1
      apps/app/src/components/PageEditor/PageEditorReadOnly.tsx
  87. 2 1
      apps/app/src/components/PageEditor/ScrollSyncHelper.tsx
  88. 2 1
      apps/app/src/components/PageEditor/conflict.tsx
  89. 1 2
      apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts
  90. 12 8
      apps/app/src/components/PageEditor/page-path-rename-utils.ts
  91. 11 16
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  92. 12 10
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  93. 20 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss
  94. 13 15
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  95. 6 6
      apps/app/src/components/PrivateLegacyPages.tsx
  96. 18 4
      apps/app/src/components/ReactMarkdownComponents/Header.module.scss
  97. 14 9
      apps/app/src/components/ReactMarkdownComponents/Header.tsx
  98. 12 10
      apps/app/src/components/SavePageControls.tsx
  99. 8 8
      apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx
  100. 1 0
      apps/app/src/components/Script/DrawioViewerScript/index.ts

+ 16 - 2
.changeset/config.json

@@ -4,8 +4,22 @@
   "commit": false,
   "fixed": [],
   "linked": [],
-  "access": "restricted",
+  "access": "public",
   "baseBranch": "master",
   "updateInternalDependencies": "patch",
-  "ignore": []
+  "snapshot": {
+    "useCalculatedVersion": true,
+    "prereleaseTemplate": "{tag}.{commit}"
+  },
+  "ignore": [
+    "@growi/app",
+    "@growi/slackbot-proxy",
+    "@growi/custom-icons",
+    "@growi/editor",
+    "@growi/presentation",
+    "@growi/preset-*",
+    "@growi/remark-*",
+    "@growi/slack",
+    "@growi/ui"
+  ]
 }

+ 5 - 0
.changeset/tasty-baboons-burn.md

@@ -0,0 +1,5 @@
+---
+'@growi/pluginkit': patch
+---
+
+Update tsconfig.json module setting

+ 2 - 1
.devcontainer/devcontainer.json

@@ -26,7 +26,8 @@
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
     "stylelint.vscode-stylelint",
-    "vitest.explorer"
+    "vitest.explorer",
+    "ms-playwright.playwright"
   ],
 
   // Uncomment the next line if you want start specific services in your Docker Compose config.

+ 1 - 0
.eslintrc.js

@@ -48,6 +48,7 @@ module.exports = {
         'newlines-between': 'always',
       },
     ],
+    '@typescript-eslint/consistent-type-imports': 'warn',
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
     indent: [

+ 5 - 1
.github/dependabot.yml

@@ -4,7 +4,8 @@ updates:
     directory: '/'
     open-pull-requests-limit: 3
     schedule:
-      interval: monthly
+      interval: weekly
+      day: saturday
     labels:
       - "type/dependencies"
     commit-message:
@@ -16,6 +17,7 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: weekly
+      day: saturday
     labels:
       - "type/dependencies"
     commit-message:
@@ -27,4 +29,6 @@ updates:
       - dependency-name: "@handsontable/react"
       - dependency-name: handsontable
       - dependency-name: typeorm
+      - dependency-name: mysql2
+
 

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

@@ -52,7 +52,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 18.x
-      skip-cypress: true
+      skip-e2e-test: true
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
@@ -61,7 +61,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 20.x
-      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
+      skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       cypress-report-artifact-name-prefix: cypress-report-
       cypress-config-video: ${{ inputs.cypress-config-video || false }}
     secrets:

+ 27 - 51
.github/workflows/ci-app.yml

@@ -43,20 +43,21 @@ jobs:
         with:
           path: |
             **/node_modules
-          key: node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/app/package.json') }}
+            !**/node_modules/.cache/turbo
+          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
           restore-keys: |
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Restore dist
-        uses: actions/cache/restore@v4
+      - name: Cache/Restore dist
+        uses: actions/cache@v4
         with:
           path: |
             **/.turbo
             **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+            **/node_modules/.cache/turbo
+          key: dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
           restore-keys: |
-            dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Install dependencies
         run: |
@@ -78,14 +79,6 @@ jobs:
           isCompactMode: true
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-      - name: Cache dist
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            **/.turbo
-            **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   ci-app-test:
     runs-on: ubuntu-latest
@@ -110,25 +103,25 @@ jobs:
           cache-dependency-path: '**/yarn.lock'
 
       - name: Cache/Restore node_modules
-        id: cache-dependencies
         uses: actions/cache@v4
         with:
           path: |
             **/node_modules
-          key: node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/app/package.json') }}
+            !**/node_modules/.cache/turbo
+          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
           restore-keys: |
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Restore dist
-        uses: actions/cache/restore@v4
+      - name: Cache/Restore dist
+        uses: actions/cache@v4
         with:
           path: |
             **/.turbo
             **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+            **/node_modules/.cache/turbo
+          key: dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
           restore-keys: |
-            dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Install dependencies
         run: |
@@ -138,7 +131,7 @@ jobs:
 
       - name: Test
         run: |
-          turbo run test --filter=!@growi/slackbot-proxy
+          turbo run test --filter=!@growi/slackbot-proxy --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
@@ -160,14 +153,6 @@ jobs:
           isCompactMode: true
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-      - name: Cache dist
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            **/.turbo
-            **/dist
-          key: dist-app-7.x-ci-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   ci-app-launch-dev:
     runs-on: ubuntu-latest
@@ -192,26 +177,26 @@ jobs:
           cache-dependency-path: '**/yarn.lock'
 
       - name: Cache/Restore node_modules
-        id: cache-dependencies
         uses: actions/cache@v4
         with:
           path: |
             **/node_modules
-          key: node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('apps/app/package.json') }}
+            !**/node_modules/.cache/turbo
+          key: node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
           restore-keys: |
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}-
-            node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
+            node_modules-app-devdependencies-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Restore dist
-        uses: actions/cache/restore@v4
+      - name: Cache/Restore dist
+        uses: actions/cache@v4
         with:
           path: |
             **/.turbo
             **/dist
-            ${{ github.workspace }}/apps/app/.next
-          key: dist-app-7.x-dev-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+            **/node_modules/.cache/turbo
+          key: dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-${{ github.sha }}
           restore-keys: |
-            dist-app-7.x-dev-${{ runner.OS }}-node${{ matrix.node-version }}-
+            dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Install dependencies
         run: |
@@ -223,7 +208,7 @@ jobs:
         working-directory: ./apps/app
         run: |
           cp config/ci/.env.local.for-ci .env.development.local
-          turbo run dev:ci
+          turbo run dev:ci --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
 
@@ -236,12 +221,3 @@ jobs:
           channel: '#ci'
           isCompactMode: true
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-      - name: Cache dist
-        uses: actions/cache/save@v4
-        with:
-          path: |
-            **/.turbo
-            **/dist
-            ${{ github.workspace }}/apps/app/.next
-          key: dist-app-7.x-dev-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}

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

@@ -145,7 +145,7 @@ jobs:
       working-directory: ./apps/slackbot-proxy
       run: |
         cp config/ci/.env.local.for-ci .env.development.local
-        turbo run dev:ci
+        turbo run dev:ci --env-mode=loose
       env:
         SERVER_URI: http://localhost:8080
         TYPEORM_CONNECTION: mysql
@@ -206,7 +206,7 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/slackbot-proxy
+        turbo prune @growi/slackbot-proxy
         rm -rf apps packages
         mv out/* .
 

+ 0 - 13
.github/workflows/release-rc-scheduled.yml

@@ -65,16 +65,3 @@ jobs:
       tag-temporary: latest-rc
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
-
-  publish-image-rc-ghcr:
-    needs: [determine-tags, build-image-rc]
-
-    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
-    with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
-      registry: ghcr.io
-      image-name: weseek/growi
-      tag-temporary: latest-rc
-    secrets:
-      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
-

+ 115 - 0
.github/workflows/release-subpackages.yml

@@ -0,0 +1,115 @@
+name: Release Subpackages
+
+on:
+  push:
+    branches:
+      - master
+    paths:
+      - .changeset/**
+      - .github/workflows/release-subpackages.yml
+  workflow_run:
+    workflows: ["Node CI for app development"]
+    types:
+      - completed
+    branches:
+      - master
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  release-subpackages-snapshot:
+
+    if: "!startsWith(github.head_ref, 'changeset-release/')"
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: '20'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v4
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Install dependencies
+      run: |
+        yarn global add turbo
+        yarn global add node-gyp
+        yarn --frozen-lockfile
+
+    - name: Setup .npmrc
+      run: |
+        cat << EOF > "$HOME/.npmrc"
+          //registry.npmjs.org/:_authToken=$NPM_TOKEN
+        EOF
+      env:
+        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+    - name: Retrieve changesets information
+      id: changesets-status
+      run: |
+        yarn changeset status --output status.json
+        echo "CHANGESETS_LENGTH=$(jq -r '.changesets | length' status.json)" >> $GITHUB_OUTPUT
+        rm status.json
+
+    - name: Snapshot release to npm
+      if: steps.changesets-status.outputs.CHANGESETS_LENGTH > 0
+      run: |
+        yarn release-subpackages:snapshot
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+
+  release-subpackages:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: '20'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Cache/Restore node_modules
+      id: cache-dependencies
+      uses: actions/cache@v4
+      with:
+        path: |
+          **/node_modules
+        key: node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-release-subpackages-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Install dependencies
+      run: |
+        yarn global add turbo
+        yarn global add node-gyp
+        yarn --frozen-lockfile
+
+    - name: Create Release Pull Request or Publish to npm
+      id: changesets
+      uses: changesets/action@v1
+      with:
+        title: Release Subpackages
+        version: yarn version-subpackages
+        publish: yarn release-subpackages
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

+ 136 - 26
.github/workflows/reusable-app-prod.yml

@@ -6,7 +6,7 @@ on:
       node-version:
         required: true
         type: string
-      skip-cypress:
+      skip-e2e-test:
         type: boolean
       cypress-report-artifact-name-prefix:
         type: string
@@ -43,44 +43,43 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/app
+        turbo prune @growi/app
         rm -rf apps packages
         mv out/* .
 
     - name: Cache/Restore node_modules
-      id: cache-dependencies
       uses: actions/cache@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+          !**/node_modules/.cache/turbo
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Install dependencies
       run: |
         yarn global add node-gyp
         yarn --frozen-lockfile
 
-    - name: Restore dist
+    - name: Cache/Restore dist
       uses: actions/cache@v4
       with:
         path: |
-          node_modules/.cache/turbo
           **/.turbo
           **/dist
+          **/node_modules/.cache/turbo
           ${{ github.workspace }}/apps/app/.next
-        key: dist-app-7.x-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-${{ github.sha }}
+        key: dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.sha }}
         restore-keys: |
-          dist-app-7.x-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-
-          dist-app-7.x-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Build
       working-directory: ./apps/app
       run: |
-        turbo run build
+        turbo run build --env-mode=loose
       env:
-        ANALYZE_BUNDLE_SIZE: 1
+        ANALYZE: 1
 
     - name: Archive production files
       id: archive-prod-files
@@ -155,19 +154,19 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/app
+        turbo prune @growi/app
         rm -rf apps packages
         mv out/* .
 
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-app-7.x-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        # shared key with build-prod
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-app-7.x-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Install dependencies
       run: |
@@ -206,7 +205,7 @@ jobs:
   run-cypress:
     needs: [build-prod]
 
-    if: ${{ !inputs.skip-cypress }}
+    if: ${{ !inputs.skip-e2e-test }}
 
     runs-on: ubuntu-latest
 
@@ -214,7 +213,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '22', '23', '30', '40', '50', '60']
+        spec-group: ['20', '21', '22', '23', '30', '50']
 
     services:
       mongodb:
@@ -246,19 +245,19 @@ jobs:
 
     - name: Prune repositories
       run: |
-        turbo prune --scope=@growi/app
+        turbo prune @growi/app
         rm -rf apps packages
         mv out/* .
 
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        # saved key by build-prod
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Cache/Restore Cypress files
       uses: actions/cache@v4
@@ -339,3 +338,114 @@ jobs:
         channel: '#ci'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
+
+  run-playwright:
+    needs: [build-prod]
+
+    if: ${{ !inputs.skip-e2e-test }}
+
+    runs-on: ubuntu-latest
+    container:
+      image: mcr.microsoft.com/playwright:latest
+
+    strategy:
+      fail-fast: false
+      matrix:
+        browser: [chromium, firefox, webkit]
+        shard: [1/2, 2/2]
+
+    services:
+      mongodb:
+        image: mongo:6.0
+        ports:
+        - 27017/tcp
+      elasticsearch:
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        ports:
+        - 9200/tcp
+        env:
+          discovery.type: single-node
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: actions/setup-node@v4
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
+      with:
+        path: |
+          **/node_modules
+        # saved key by build-prod
+        key: node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          node_modules-app-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
+
+    - name: Install dependencies
+      run: |
+        yarn global add node-gyp
+        yarn --frozen-lockfile
+
+    - name: Install Playwright browsers
+      run: |
+        yarn playwright install --with-deps ${{ matrix.browser }}
+
+    - name: Download production files artifact
+      uses: actions/download-artifact@v4
+      with:
+        name: Production Files (node${{ inputs.node-version }})
+
+    - name: Extract procution files artifact
+      run: |
+        tar -xf ${{ needs.build-prod.outputs.PROD_FILES }}
+
+    - name: Copy dotenv file for ci
+      working-directory: ./apps/app
+      run: |
+        cat config/ci/.env.local.for-ci >> .env.production.local
+
+    - name: Playwright Run (--project=chromium/installer)
+      if: ${{ matrix.browser == 'chromium' }}
+      working-directory: ./apps/app
+      run: |
+        yarn playwright test --project=chromium/installer
+      env:
+        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+
+    - name: Copy dotenv file for automatic installation
+      working-directory: ./apps/app
+      run: |
+        cat config/ci/.env.local.for-auto-install >> .env.production.local
+
+    # - name: Copy dotenv file for automatic installation with allowing guest mode
+    #   if: ${{ matrix.spec-group == '21' }}
+    #   working-directory: ./apps/app
+    #   run: |
+    #     cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+
+    - name: Playwright Run
+      working-directory: ./apps/app
+      run: |
+        yarn playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
+      env:
+        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
+        MONGO_URI: mongodb://mongodb:27017/growi-playwright
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+
+    - name: Slack Notification
+      uses: weseek/ghaction-slack-notification@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*Node CI for growi - run-playwright*'
+        channel: '#ci'
+        isCompactMode: true
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 5 - 15
.github/workflows/reusable-app-reg-suit.yml

@@ -60,25 +60,15 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
-    - name: Install turbo
-      run: |
-        yarn global add turbo
-
-    - name: Prune repositories
-      run: |
-        turbo prune --scope=@growi/app
-        rm -rf apps packages
-        mv out/* .
-
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v4
+    - name: Restore node_modules
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/node_modules
-        key: node_modules-7.x-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        # saved key by launch-prod
+        key: node_modules-app-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}
         restore-keys: |
-          node_modules-7.x-${{ runner.OS }}-node${{ inputs.node-version }}-
+          node_modules-app-launch-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Install dependencies
       run: |

+ 74 - 1
CHANGELOG.md

@@ -1,9 +1,82 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.6...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.10...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.10](https://github.com/weseek/growi/compare/v7.0.9...v7.0.10) - 2024-06-13
+
+### 💎 Features
+
+* imprv: Autofocus on PageTitleHeader when edigin untitled page (#8813) @WNomunomu
+* imprv: Autofocus on PageTitleHeader when creating untitled page (#8813) @WNomunomu
+
+### 🚀 Improvement
+
+* imprv: DrawioViewerScript should respect the base path in DRAWIO_URI 2 (#8889) @yuki-takei
+* imprv: Styling icon on the side of header (#8833) @reiji-h
+* imprv: DrawioViewerScript should respect the base path in DRAWIO_URI (#8878) @yuki-takei
+* imprv: Behavior when clicking on a label in the dropdown (#8857) @maeshinshin
+* imprv(plugin): Support github tag in githuburl.ts (#8868) @reiji-h
+* imprv: Display selected tree item in page select modal as active (#8802) @WNomunomu
+
+### 🐛 Bug Fixes
+
+* fix: Match width of video tag to img tag (#8836) @TatsuyaIse
+* fix: Behaviour of input during Japanese input (#8880) @miya
+* fix: Supress warning of checkbox 2 (#8871) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Watch with nodemon (#8877) @yuki-takei
+* support: Add playwright test for installer (#8874) @yuki-takei
+* support: Upgrade turbo to v2 (#8875) @yuki-takei
+
+## [v7.0.9](https://github.com/weseek/growi/compare/v7.0.8...v7.0.9) - 2024-05-30
+
+### 🐛 Bug Fixes
+
+* fix: Supress warning of checkbox (#8865) @yuki-takei
+* fix: Editor package import/export (#8864) @yuki-takei
+
+## [v7.0.8](https://github.com/weseek/growi/compare/v7.0.7...v7.0.8) - 2024-05-30
+
+### 💎 Features
+
+* feat: Select unrelated group inheritance on child page create (#8812) @arafubeatbox
+
+### 🚀 Improvement
+
+* imprv: Design coding of search result page (#8828) @miya
+
+### 🐛 Bug Fixes
+
+* fix: Page body sometimes appears doubled up when the editor is opened (#8858) @miya
+* fix: Brackets appearance when Nord editor theme (#8852) @satof3
+* fix: Slack notification not sent on page update (#8841) @miya
+* fix: Table icon is not displayed when hovering over the table (#8830) @WNomunomu
+
+### 🧰 Maintenance
+
+* support: Reorganize editor module exports (#8846) @yuki-takei
+
+## [v7.0.7](https://github.com/weseek/growi/compare/v7.0.6...v7.0.7) - 2024-05-27
+
+### 🚀 Improvement
+
+* imprv: Behavior of dropdown toggle in groundglassbar (#8832) @maeshinshin
+* imprv: toastr location (#8831) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Do not insert initial value when input is empty in editor (#8773) @miya
+
+### 🧰 Maintenance
+
+* support: Apply changesets (#8840) @yuki-takei
+* support: Upgrade yjs packages (#8839) @yuki-takei
+* support: Upgrade stylelint (#8835) @yuki-takei
+
 ## [v7.0.6](https://github.com/weseek/growi/compare/v7.0.5...v7.0.6) - 2024-05-20
 
 ### 🐛 Bug Fixes

+ 0 - 1
apps/app/.eslintrc.js

@@ -27,7 +27,6 @@ module.exports = {
       },
     ]],
     '@typescript-eslint/no-var-requires': 'off',
-    '@typescript-eslint/consistent-type-imports': 'warn',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],

+ 1 - 1
apps/app/docker/Dockerfile

@@ -12,7 +12,7 @@ WORKDIR ${optDir}
 
 RUN yarn global add turbo
 COPY . .
-RUN turbo prune --scope=@growi/app --docker
+RUN turbo prune @growi/app --docker
 
 
 ##

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.6`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.6/apps/app/docker/Dockerfile)
+* [`7.0.10`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.10/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 1 - 0
apps/app/next-env.d.ts

@@ -1,5 +1,6 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
+/// <reference types="next/navigation-types/compat/navigation" />
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/basic-features/typescript for more information.

+ 17 - 1
apps/app/next.config.js

@@ -62,6 +62,19 @@ const getTranspilePackages = () => {
   return packages;
 };
 
+const optimizePackageImports = [
+  '@growi/core',
+  '@growi/editor',
+  '@growi/pluginkit',
+  '@growi/presentation',
+  '@growi/preset-themes',
+  '@growi/remark-attachment-refs',
+  '@growi/remark-drawio',
+  '@growi/remark-growi-directive',
+  '@growi/remark-lsx',
+  '@growi/slack',
+  '@growi/ui',
+];
 
 module.exports = async(phase, { defaultConfig }) => {
 
@@ -85,6 +98,9 @@ module.exports = async(phase, { defaultConfig }) => {
     transpilePackages: phase !== PHASE_PRODUCTION_SERVER
       ? getTranspilePackages()
       : undefined,
+    experimental: {
+      optimizePackageImports,
+    },
 
     /** @param config {import('next').NextConfig} */
     webpack(config, options) {
@@ -135,7 +151,7 @@ module.exports = async(phase, { defaultConfig }) => {
   }
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
-    enabled: phase === PHASE_PRODUCTION_BUILD || process.env.ANALYZE === 'true',
+    enabled: phase === PHASE_PRODUCTION_BUILD && process.env.ANALYZE === 'true',
   });
 
   return withBundleAnalyzer(withSuperjson()(nextConfig));

+ 9 - 0
apps/app/nodemon.json

@@ -0,0 +1,9 @@
+{
+  "ext": "js,ts,json",
+  "ignore": [
+    ".next",
+    "public/static",
+    "package.json",
+    "playwright"
+  ]
+}

+ 16 - 17
apps/app/package.json

@@ -1,13 +1,14 @@
 {
   "name": "@growi/app",
-  "version": "7.0.7-RC.0",
+  "version": "7.0.11-RC.0",
   "license": "MIT",
+  "private": "true",
   "scripts": {
     "//// for production": "",
     "build": "run-p build:*",
     "start": "yarn next start",
     "build:client": "yarn next build",
-    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server-tsc-alias.json",
+    "build:server": "yarn cross-env NODE_ENV=production tspc -p tsconfig.build.server.json",
     "postbuild:server": "shx echo \"Listing files under transpiled\" && shx ls transpiled && shx rm -rf dist && shx mv transpiled/src dist && shx rm -rf transpiled",
     "clean": "shx rm -rf dist transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
@@ -16,7 +17,7 @@
     "styles-prebuilt": "vite build -c vite.styles-prebuilt.config.ts",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up -f config/migrate-mongo-config.js",
     "//// for development": "",
-    "dev": "yarn cross-env NODE_ENV=development yarn ts-node-dev --inspect --transpile-only src/server/app.ts",
+    "dev": "yarn cross-env NODE_ENV=development nodemon --exec yarn ts-node --inspect src/server/app.ts",
     "dev:styles-prebuilt": "yarn styles-prebuilt --mode dev",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate": "yarn dev:migrate:status > tmp/cache/migration-status.out && yarn dev:migrate:up",
@@ -27,16 +28,16 @@
     "cy:run": "cypress run --browser chromium",
     "//// for CI": "",
     "dev:ci": "yarn cross-env NODE_ENV=development yarn ts-node src/server/app.ts --ci",
-    "lint:typecheck": "npx -y tsc",
+    "lint:typecheck": "npx -y tspc",
     "lint:eslint": "yarn eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
-    "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
     "test:vitest": "run-p vitest:run vitest:run:integ vitest:run:components",
-    "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
+    "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
@@ -47,8 +48,7 @@
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
-    "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
+    "ts-node": "node -r ts-node/register/transpile-only -r tsconfig-paths/register -r dotenv-flow/config",
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
@@ -134,7 +134,7 @@
     "md5": "^2.2.1",
     "mermaid": "^10.1.0",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^8.2.3",
+    "migrate-mongo": "^11.0.0",
     "mkdirp": "^1.0.3",
     "mongoose": "^6.11.3",
     "mongoose-gridfs": "^1.2.42",
@@ -166,8 +166,6 @@
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
     "react-disable": "^0.1.1",
-    "react-dnd": "^14.0.5",
-    "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
     "react-error-boundary": "^3.1.4",
     "react-i18next": "^14.1.0",
@@ -177,7 +175,6 @@
     "react-scroll": "^1.8.7",
     "react-stickynode": "^4.1.1",
     "react-syntax-highlighter": "^15.5.0",
-    "react-toastify": "^9.1.3",
     "react-use-ripple": "^1.5.2",
     "reactstrap": "^9.2.2",
     "reconnecting-websocket": "^4.4.0",
@@ -229,8 +226,8 @@
     "@handsontable/react": "=2.1.0",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
-    "@swc-node/jest": "^1.6.2",
-    "@swc/jest": "^0.2.24",
+    "@swc-node/jest": "^1.8.1",
+    "@swc/jest": "^0.2.36",
     "@testing-library/react": "^14.1.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
@@ -242,7 +239,7 @@
     "@types/throttle-debounce": "^5.0.1",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
-    "@vitejs/plugin-react": "^4.2.1",
+    "@vitejs/plugin-react": "^4.3.0",
     "@vitest/coverage-v8": "^0.34.6",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
@@ -274,9 +271,12 @@
     "pretty-bytes": "^6.1.1",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
+    "react-dnd": "^14.0.5",
+    "react-dnd-html5-backend": "^14.1.0",
     "react-dropzone": "^14.2.3",
     "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
+    "react-toastify": "^9.1.3",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "sass": "^1.53.0",
@@ -284,7 +284,6 @@
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
-    "swagger2openapi": "^7.0.8",
-    "tsc-alias": "^1.2.9"
+    "swagger2openapi": "^7.0.8"
   }
 }

+ 112 - 0
apps/app/playwright.config.ts

@@ -0,0 +1,112 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { defineConfig, devices } from '@playwright/test';
+
+const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+  expect: {
+    timeout: 7 * 1000,
+  },
+
+  testDir: './playwright',
+  outputDir: './playwright/output',
+  /* Run tests in files in parallel */
+  fullyParallel: true,
+  /* Fail the build on CI if you accidentally left test.only in the source code. */
+  forbidOnly: !!process.env.CI,
+  /* Retry on CI only */
+  retries: process.env.CI ? 2 : 0,
+  /* Opt out of parallel tests on CI. */
+  workers: process.env.CI ? 1 : undefined,
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: process.env.CI ? 'github' : 'list',
+
+  webServer: {
+    command: 'yarn server',
+    url: 'http://localhost:3000',
+    reuseExistingServer: !process.env.CI,
+    stdout: 'ignore',
+    stderr: 'pipe',
+  },
+
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    baseURL: 'http://localhost:3000',
+
+    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    trace: 'on-first-retry',
+
+    viewport: { width: 1400, height: 1024 },
+
+    // Use prepared auth state.
+    storageState: fs.existsSync(authFile) ? authFile : undefined,
+  },
+
+  /* Configure projects for major browsers */
+  projects: [
+    // Setup project
+    { name: 'setup', testMatch: /.*\.setup\.ts/, testIgnore: /auth\.setup\.ts/ },
+    { name: 'auth', testMatch: /auth\.setup\.ts/ },
+
+    {
+      name: 'chromium/installer',
+      use: { ...devices['Desktop Chrome'] },
+      testMatch: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup'],
+    },
+
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+      testIgnore: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup', 'auth'],
+    },
+
+    {
+      name: 'firefox',
+      use: { ...devices['Desktop Firefox'] },
+      testIgnore: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup', 'auth'],
+    },
+
+    {
+      name: 'webkit',
+      use: { ...devices['Desktop Safari'] },
+      testIgnore: /10-installer\/.*\.spec\.ts/,
+      dependencies: ['setup', 'auth'],
+    },
+
+    /* Test against mobile viewports. */
+    // {
+    //   name: 'Mobile Chrome',
+    //   use: { ...devices['Pixel 5'] },
+    // },
+    // {
+    //   name: 'Mobile Safari',
+    //   use: { ...devices['iPhone 12'] },
+    // },
+
+    /* Test against branded browsers. */
+    // {
+    //   name: 'Microsoft Edge',
+    //   use: { ...devices['Desktop Edge'], channel: 'msedge' },
+    // },
+    // {
+    //   name: 'Google Chrome',
+    //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+    // },
+  ],
+
+});

+ 0 - 0
apps/app/playwright/.auth/.gitkeep


+ 16 - 0
apps/app/playwright/.eslintrc.mjs

@@ -0,0 +1,16 @@
+import playwright from 'eslint-plugin-playwright';
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default [
+  {
+    ...playwright.configs['flat/recommended'],
+    files: ['./**'],
+  },
+  {
+    files: ['./**'],
+    rules: {
+      // Customize Playwright rules
+      // ...
+    },
+  },
+];

+ 2 - 0
apps/app/playwright/.gitignore

@@ -0,0 +1,2 @@
+.auth
+output

+ 47 - 0
apps/app/playwright/10-installer/install.spec.ts

@@ -0,0 +1,47 @@
+import { test, expect } from '@playwright/test';
+
+test('Installer', async({ page }) => {
+  await page.goto('/');
+  await page.waitForURL('/installer');
+
+  // show installer form
+  await expect(page.getByTestId('installerForm')).toBeVisible();
+
+  // choose Japanese
+  await page.getByTestId('dropdownLanguage').click();
+  await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
+  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
+  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toHaveAttribute('placeholder', 'ユーザーID');
+
+  // choose Chinese
+  await page.getByTestId('dropdownLanguage').click();
+  await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute('placeholder', '用户ID');
+  // // choose English
+  await page.getByTestId('dropdownLanguage').click();
+  await page.getByTestId('dropdownLanguageMenu-en_US').click();
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute('placeholder', 'User ID');
+
+  await page.getByRole('textbox', { name: 'User ID' }).focus();
+
+  // fill form
+  await page.getByLabel('User ID').fill('admin');
+  await page.getByLabel('User ID').press('Tab');
+  await expect(page.getByRole('textbox', { name: 'Name' })).toBeFocused();
+
+  await page.getByLabel('Name').fill('Admin');
+  await page.getByLabel('Name').press('Tab');
+  await expect(page.getByRole('textbox', { name: 'Email' })).toBeFocused();
+
+  await page.getByLabel('Email').fill('admin@example.com');
+  await page.getByLabel('Email').press('Tab');
+  await expect(page.getByRole('textbox', { name: 'Password' })).toBeFocused();
+
+  await page.getByLabel('Password').fill('adminadmin');
+  await page.getByLabel('Password').press('Enter');
+
+  await page.waitForURL('/', { timeout: 20000 });
+  await expect(page).toHaveTitle(/\/ - GROWI/);
+});

+ 22 - 0
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -0,0 +1,22 @@
+import { test, expect } from '@playwright/test';
+
+test('has title', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  // Expect a title "to contain" a substring.
+  await expect(page).toHaveTitle(/Sandbox/);
+});
+
+test('get h1', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  // Expects page to have a heading with the name of Installation.
+  await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
+});
+
+test('Access to /me page', async({ page }) => {
+  await page.goto('/me');
+
+  // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+});

+ 27 - 0
apps/app/playwright/20-basic-features/create-page-button.spec.ts

@@ -0,0 +1,27 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Create page button', () => {
+  test('click and autofocus to title text input', async({ page }) => {
+    await page.goto('/');
+
+    await page.getByTestId('grw-page-create-button').getByRole('button', { name: 'Create' }).click();
+
+    // should be focused
+    await expect(page.getByPlaceholder('Input page name')).toBeFocused();
+  });
+});
+
+test.describe('Create page button dropdown menu', () => {
+  test('open and create today page', async({ page }) => {
+    await page.goto('/');
+
+    // open dropdown menu
+    await page.getByTestId('grw-page-create-button').hover();
+    await expect(page.getByTestId('grw-page-create-button').getByLabel('Open create page menu')).toBeVisible();
+    await page.getByTestId('grw-page-create-button').getByLabel('Open create page menu').dispatchEvent('click'); // simulate the click
+    await page.getByRole('menuitem', { name: 'Create today page' }).click();
+
+    // should not be visible
+    await expect(page.getByPlaceholder('Input page name')).not.toBeVisible();
+  });
+});

+ 97 - 0
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -0,0 +1,97 @@
+import { test, expect } from '@playwright/test';
+
+test('admin is successfully loaded', async({ page }) => {
+  await page.goto('/admin');
+
+  await expect(page.getByTestId('admin-home')).toBeVisible();
+  await expect(page.getByTestId('admin-system-information-table')).toBeVisible();
+});
+
+test('admin/app is successfully loaded', async({ page }) => {
+  await page.goto('/admin/app');
+
+  await expect(page.getByTestId('admin-app-settings')).toBeVisible();
+  // await expect(page.getByTestId('v5-page-migration')).toBeVisible();
+  await expect(page.locator('#cbFileUpload')).toBeChecked();
+  await expect(page.locator('#isQuestionnaireEnabled')).toBeChecked();
+  await expect(page.locator('#isAppSiteUrlHashed')).not.toBeChecked();
+});
+
+test('admin/security is successfully loaded', async({ page }) => {
+  await page.goto('/admin/security');
+
+  await expect(page.getByTestId('admin-security')).toBeVisible();
+  await expect(page.locator('#isShowRestrictedByOwner')).not.toBeChecked();
+  await expect(page.locator('#isShowRestrictedByGroup')).not.toBeChecked();
+});
+
+test('admin/markdown is successfully loaded', async({ page }) => {
+  await page.goto('/admin/markdown');
+
+  await expect(page.getByTestId('admin-markdown')).toBeVisible();
+  await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
+});
+
+test('admin/customize is successfully loaded', async({ page }) => {
+  await page.goto('/admin/customize');
+
+  await expect(page.getByTestId('admin-customize')).toBeVisible();
+});
+
+test('admin/importer is successfully loaded', async({ page }) => {
+  await page.goto('/admin/importer');
+
+  await expect(page.getByTestId('admin-import-data')).toBeVisible();
+});
+
+test('admin/export is successfully loaded', async({ page }) => {
+  await page.goto('/admin/export');
+
+  await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
+});
+
+test('admin/notification is successfully loaded', async({ page }) => {
+  await page.goto('/admin/notification');
+
+  await expect(page.getByTestId('admin-notification')).toBeVisible();
+  // wait for retrieving slack integration status
+  await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
+});
+
+test('admin/slack-integration is successfully loaded', async({ page }) => {
+  await page.goto('/admin/slack-integration');
+
+  await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
+  await expect(page.locator('img.bot-difficulty-icon')).toHaveCount(3);
+  await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
+});
+
+test('admin/slack-integration-legacy is successfully loaded', async({ page }) => {
+  await page.goto('/admin/slack-integration-legacy');
+
+  await expect(page.getByTestId('admin-slack-integration-legacy')).toBeVisible();
+});
+
+test('admin/users is successfully loaded', async({ page }) => {
+  await page.goto('/admin/users');
+
+  await expect(page.getByTestId('admin-users')).toBeVisible();
+  await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
+});
+
+test('admin/user-groups is successfully loaded', async({ page }) => {
+  await page.goto('/admin/user-groups');
+
+  await expect(page.getByTestId('admin-user-groups')).toBeVisible();
+  await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
+});
+
+test('admin/search is successfully loaded', async({ page }) => {
+  await page.goto('/admin/search');
+
+  await expect(page.getByTestId('admin-full-text-search')).toBeVisible();
+
+  // Only successful in the local environment.
+  // wait for connected
+  // await expect(page.getByTestId('connection-status-badge-connected')).toBeVisible();
+});

+ 122 - 0
apps/app/playwright/60-home/home.spec.ts

@@ -0,0 +1,122 @@
+import { test, expect } from '@playwright/test';
+
+
+test('Visit User home', async({ page }) => {
+  await page.goto('dummy');
+
+  // Open PersonalDropdown
+  await page.getByTestId('personal-dropdown-button').click();
+  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+
+  // Click UserHomeMenu
+  await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
+  await expect(page.getByTestId('grw-users-info')).toBeVisible();
+});
+
+test('Vist User settings', async({ page }) => {
+  await page.goto('dummy');
+
+  // Open PersonalDropdown
+  await page.getByTestId('personal-dropdown-button').click();
+  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+
+  // Click UserSettingsMenu
+  page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+});
+
+test('Open questionnaire modal', async({ page }) => {
+  await page.goto('/dummy');
+
+  // Open PersonalDropdown
+  await page.getByTestId('personal-dropdown-button').click();
+  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+
+  // Expect the questionnaire modal to be displayed when the QuestionnaireModalToggleButton is clicked
+  await page.getByTestId('grw-proactive-questionnaire-modal-toggle-btn').click();
+  await expect(page.getByTestId('grw-proactive-questionnaire-modal')).toBeVisible();
+});
+
+test('Access User information', async({ page }) => {
+  await page.goto('/me');
+
+  // Click BasicInfoSettingUpdateButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+
+  // Expect a success toaster to be displayed when the BasicInfoSettingUpdateButton is pressed
+  await page.getByTestId('grw-besic-info-settings-update-button').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+});
+
+test('Access External account', async({ page }) => {
+  await page.goto('/me');
+
+  // Click ExternalAccountsTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('external-accounts-tab-button').first().click();
+
+  // Expect an error toaster to be displayed when the AddExternalAccountsButton is pressed
+  await page.getByTestId('grw-external-account-add-button').click();
+  await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
+  await page.getByTestId('add-external-account-button').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+  await page.locator('.Toastify__close-button').click();
+  await expect(page.locator('.Toastify__toast')).not.toBeVisible();
+});
+
+test('Access Password setting', async({ page }) => {
+  await page.goto('/me');
+
+  // Click PasswordSettingTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('password-settings-tab-button').first().click();
+
+  // Expect three error toasters to be displayed when the PasswordUpdateButton is pressed
+  await page.getByTestId('grw-password-settings-update-button').click();
+  const toastElements = page.locator('.Toastify__toast');
+
+  const toastElementsCount = await toastElements.count();
+  for (let i = 0; i < toastElementsCount; i++) {
+    // eslint-disable-next-line no-await-in-loop
+    await toastElements.nth(i).click();
+  }
+
+  await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
+});
+
+
+test('Access API setting', async({ page }) => {
+  await page.goto('/me');
+
+  // Click ApiSettingTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('api-settings-tab-button').first().click();
+
+  // Expect a success toaster to be displayed when the UpdateApiTokenButton is clicked
+  await page.getByTestId('grw-api-settings-update-button').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+});
+
+test('Access In-App Notification setting', async({ page }) => {
+  await page.goto('/me');
+
+  // Click InAppNotificationSettingTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('in-app-notification-settings-tab-button').first().click();
+
+  // Expect a success toaster to be displayed when the InAppNotificationSettingsUpdateButton is clicked
+  await page.getByTestId('grw-in-app-notification-settings-update-button').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+});
+
+test('Acccess Other setting', async({ page }) => {
+  await page.goto('/me');
+
+  // Click OtherSettingTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('other-settings-tab-button').first().click();
+
+  // Expect a success toaster to be displayed when the QuestionnaireSettingsUpdateButton is clicked
+  await page.getByTestId('grw-questionnaire-settings-update-btn').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+});

+ 24 - 0
apps/app/playwright/auth.setup.ts

@@ -0,0 +1,24 @@
+import path from 'node:path';
+
+import { test as setup, expect } from '@playwright/test';
+
+const authFile = path.resolve(__dirname, './.auth/admin.json');
+
+setup('Authenticate as the "admin" user', async({ page }) => {
+  // Perform authentication steps. Replace these actions with your own.
+  await page.goto('/admin');
+
+  const loginForm = await page.$('form#login-form');
+
+  if (loginForm != null) {
+    await page.getByLabel('Username or E-mail').fill('admin');
+    await page.getByLabel('Password').fill('adminadmin');
+    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
+  }
+
+  await page.waitForURL('/admin');
+  await expect(page).toHaveTitle(/Wiki Management Homepage/);
+
+  // End of authentication steps.
+  await page.context().storageState({ path: authFile });
+});

+ 0 - 1
apps/app/public/images/icons/editor/bold.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="109" height="140" viewBox="0 0 10.9 14"><path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/check.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="144" height="160" viewBox="0 0 14.4 16"><path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/code.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="181" height="140" viewBox="0 0 18.1 14"><path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/header.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="137" height="140" viewBox="0 0 13.7 14"><path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/italic.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="86" height="139" viewBox="0 0 8.6 13.9"><path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/list-ol.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="237" height="160" viewBox="0 0 23.7 16"><path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/list-ul.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="216" height="135" viewBox="0 0 21.6 13.5"><path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/picture.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="190" height="160" viewBox="0 0 19 16"><path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/quote.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="170" height="120" viewBox="0 0 17 12"><path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zm10-6h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/strikethrough.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="195" height="140" viewBox="0 0 19.5 14"><path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zm7 2.5a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z"/></svg>

+ 0 - 1
apps/app/public/images/icons/editor/table.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="203" height="160" viewBox="0 0 20.3 16"><path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z"/></svg>

+ 8 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -425,6 +425,12 @@
     }
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "Select groups that can access page",
+    "inherit_all_granted_groups_from_parent": "Inherit all groups that can access page from parent",
+    "only_inherit_related_groups": "Only inherit groups that you belong to from parent",
+    "create_page": "Create Page"
+  },
   "modal_putback": {
     "label": {
       "Put Back Page": "Put back page",
@@ -561,6 +567,8 @@
     "delete_completely": "Delete completely",
     "include_certain_path": "Include {{pathToInclude}} path ",
     "delete_all_selected_page": "Delete All",
+    "select_all": "Select all",
+    "delete_selected_pages": "Delete selected pages",
     "currently_not_implemented": "This is not currently implemented",
     "search_again": "Search again",
     "number_of_list_to_display": "Display",

+ 2 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -561,6 +561,8 @@
     "delete_completely": "Supprimer définitivement",
     "include_certain_path": "Inclure le chemin {{pathToInclude}} ",
     "delete_all_selected_page": "Tout supprimer",
+    "select_all": "Tout sélectionner",
+    "delete_selected_pages": "Supprimer les pages sélectionnées",
     "currently_not_implemented": "Non implémenté",
     "search_again": "Rechercher",
     "number_of_list_to_display": "Afficher",

+ 8 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -458,6 +458,12 @@
     }
   },
   "duplicated_pages": "{{fromPath}} を複製しました",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "閲覧権限のあるグループを選択",
+    "inherit_all_granted_groups_from_parent": "閲覧権限のあるグループを親ページから全て引き継ぐ",
+    "only_inherit_related_groups": "自分が所属するグループのみを親ページから引き継ぐ",
+    "create_page": "ページ作成"
+  },
   "modal_putback": {
     "label": {
       "Put Back Page": "ページを元に戻す",
@@ -594,6 +600,8 @@
     "delete_completely": "完全に削除する",
     "include_certain_path": "{{pathToInclude}}下を含む ",
     "delete_all_selected_page": "一括削除",
+    "select_all": "全件選択",
+    "delete_selected_pages": "選択したページを削除",
     "currently_not_implemented": "現在未実装の機能です",
     "search_again": "再検索",
     "number_of_list_to_display": "表示件数",

+ 8 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -415,6 +415,12 @@
     }
   },
   "duplicated_pages": "{{fromPath}} 已重复",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "Select groups that can access page",
+    "inherit_all_granted_groups_from_parent": "Inherit all groups that can access page from parent",
+    "only_inherit_related_groups": "Only inherit groups that you belong to from parent",
+    "create_page": "Create Page"
+  },
   "modal_putback": {
     "label": {
       "Put Back Page": "Put back page",
@@ -564,6 +570,8 @@
     "delete_completely": "完全删除",
     "include_certain_path": "包含 {{pathToInclude}} 路径 ",
     "delete_all_selected_page": "删除所有",
+    "select_all": "选择所有",
+    "delete_selected_pages": "删除选定页面",
     "currently_not_implemented": "这是当前未实现的功能",
     "search_again": "再次搜索",
     "number_of_list_to_display": "显示器的数量",

+ 0 - 147
apps/app/src/client/models/MarkdownTable.js

@@ -1,147 +0,0 @@
-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 default 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 - 2
apps/app/src/client/services/create-page/index.ts

@@ -1,3 +1,2 @@
-export * from './create-page';
-export * from './use-create-page-and-transit';
+export * from './use-create-page';
 export * from './use-create-template-page';

+ 0 - 112
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -1,112 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { exist } from '~/client/services/page-operation';
-import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
-import { useCurrentPagePath } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import { createPage } from './create-page';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-/**
- * Invoked when creation and transition has finished
- */
-type OnCreated = () => void;
-/**
- * Invoked when either creation or transition has aborted
- */
-type OnAborted = () => void;
-/**
- * Always invoked after processing is terminated
- */
-type OnTerminated = () => void;
-
-type CreatePageAndTransitOpts = {
-  shouldCheckPageExists?: boolean,
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onTerminated?: OnTerminated,
-}
-
-type CreatePageAndTransit = (
-  params: IApiv3PageCreateParams,
-  opts?: CreatePageAndTransitOpts,
-) => Promise<void>;
-
-type UseCreatePageAndTransit = () => {
-  isCreating: boolean,
-  createAndTransit: CreatePageAndTransit,
-};
-
-export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
-
-  const router = useRouter();
-
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const [isCreating, setCreating] = useState(false);
-
-  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
-    const {
-      shouldCheckPageExists,
-      onCreationStart, onCreated, onAborted, onTerminated,
-    } = opts;
-
-    // check the page existence
-    if (shouldCheckPageExists && params.path != null) {
-      const pagePath = params.path;
-
-      try {
-        const { isExist } = await exist(pagePath);
-
-        if (isExist) {
-          // routing
-          if (pagePath !== currentPagePath) {
-            await router.push(`${pagePath}#edit`);
-          }
-          mutateEditorMode(EditorMode.Editor);
-          onAborted?.();
-          return;
-        }
-      }
-      catch (err) {
-        throw err;
-      }
-      finally {
-        onTerminated?.();
-      }
-    }
-
-    // create and transit
-    try {
-      setCreating(true);
-      onCreationStart?.();
-
-      const response = await createPage(params);
-
-      await router.push(`/${response.page._id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-
-      onCreated?.();
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      onTerminated?.();
-      setCreating(false);
-    }
-
-  }, [currentPagePath, mutateEditorMode, router]);
-
-  return {
-    isCreating,
-    createAndTransit,
-  };
-};

+ 143 - 0
apps/app/src/client/services/create-page/use-create-page.tsx

@@ -0,0 +1,143 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+import { useTranslation } from 'react-i18next';
+
+import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
+import { toastWarning } from '~/client/util/toastr';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode, useIsUntitledPage } from '~/stores/ui';
+
+import { createPage } from './create-page';
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+export type CreatePageOpts = {
+  skipPageExistenceCheck?: boolean,
+  skipTransition?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePage = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageOpts,
+) => Promise<void>;
+
+type UseCreatePage = () => {
+  isCreating: boolean,
+  create: CreatePage,
+};
+
+export const useCreatePage: UseCreatePage = () => {
+
+  const router = useRouter();
+  const { t } = useTranslation();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+  const { mutate: mutateIsUntitledPage } = useIsUntitledPage();
+  const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModal();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const create: CreatePage = useCallback(async(params, opts = {}) => {
+    const {
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+    const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
+    const skipTransition = opts.skipTransition ?? false;
+
+    // check the page existence
+    if (!skipPageExistenceCheck && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const { isExist } = await exist(pagePath);
+
+        if (isExist) {
+          if (!skipTransition) {
+            // routing
+            if (pagePath !== currentPagePath) {
+              await router.push(`${pagePath}#edit`);
+            }
+            mutateEditorMode(EditorMode.Editor);
+          }
+          else {
+            toastWarning(t('duplicated_page_alert.same_page_name_exists', { pageName: pagePath }));
+          }
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    const _create = async(onlyInheritUserRelatedGrantedGroups?: boolean) => {
+      try {
+        setCreating(true);
+        onCreationStart?.();
+
+        params.onlyInheritUserRelatedGrantedGroups = onlyInheritUserRelatedGrantedGroups;
+        const response = await createPage(params);
+
+        closeGrantedGroupsInheritanceSelectModal();
+
+        if (!skipTransition) {
+          await router.push(`/${response.page._id}#edit`);
+          mutateEditorMode(EditorMode.Editor);
+        }
+
+        if (params.path == null) {
+          mutateIsUntitledPage(true);
+        }
+
+        onCreated?.();
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+        setCreating(false);
+      }
+    };
+
+    // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
+    if (params.parentPath != null) {
+      const { isNonUserRelatedGroupsGranted } = await getIsNonUserRelatedGroupsGranted(params.parentPath);
+      if (isNonUserRelatedGroupsGranted) {
+        // create and transit request will be made from modal
+        openGrantedGroupsInheritanceSelectModal(_create);
+        return;
+      }
+    }
+
+    await _create();
+  }, [currentPagePath, mutateEditorMode, router, t, closeGrantedGroupsInheritanceSelectModal, mutateIsUntitledPage, openGrantedGroupsInheritanceSelectModal]);
+
+  return {
+    isCreating,
+    create,
+  };
+};

+ 7 - 6
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -8,7 +8,7 @@ import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/stores/page';
 
 
-import { useCreatePageAndTransit } from './use-create-page-and-transit';
+import { useCreatePage } from './use-create-page';
 
 type UseCreateTemplatePage = () => {
   isCreatable: boolean,
@@ -20,17 +20,18 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
 
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
   const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
 
   const createTemplate = useCallback(async(label: LabelType) => {
     if (isLoadingPagePath || !isCreatable) return;
 
-    return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`), wip: false, origin: Origin.View },
-      { shouldCheckPageExists: true },
+    return create(
+      {
+        path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
+      },
     );
-  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+  }, [currentPagePath, isCreatable, isLoadingPagePath, create]);
 
   return {
     isCreatable,

+ 9 - 0
apps/app/src/client/services/page-operation.ts

@@ -156,6 +156,15 @@ export const exist = async(path: string): Promise<PageExistResponse> => {
   return res.data;
 };
 
+interface NonUserRelatedGroupsGrantedResponse {
+  isNonUserRelatedGroupsGranted: boolean,
+}
+
+export const getIsNonUserRelatedGroupsGranted = async(path: string): Promise<NonUserRelatedGroupsGrantedResponse> => {
+  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>('/page/non-user-related-groups-granted', { path });
+  return res.data;
+};
+
 export const publish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/publish`);
   return res.data;

+ 6 - 6
apps/app/src/client/services/renderer/renderer.tsx

@@ -3,7 +3,6 @@ import assert from 'assert';
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
-// eslint-disable-next-line import/extensions
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
@@ -20,8 +19,8 @@ import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
 import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
@@ -36,6 +35,7 @@ import loggerFactory from '~/utils/logger';
 
 // import EasyGrid from './PreProcessor/EasyGrid';
 
+
 import '@growi/remark-lsx/dist/client/style.css';
 import '@growi/remark-attachment-refs/dist/client/style.css';
 
@@ -71,7 +71,7 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 
@@ -132,7 +132,7 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 
@@ -184,7 +184,7 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 
@@ -277,7 +277,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
     injectCustomSanitizeOption(config);
   }
 

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

@@ -3,8 +3,8 @@ import { useCallback, useEffect } from 'react';
 import type EventEmitter from 'events';
 
 import { Origin } from '@growi/core';
+import type { MarkdownTable } from '@growi/editor';
 
-import type MarkdownTable from '~/client/models/MarkdownTable';
 import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { useShareLinkId } from '~/stores/context';

+ 2 - 2
apps/app/src/components/Admin/App/AppSetting.jsx

@@ -40,7 +40,7 @@ const AppSetting = (props) => {
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.title || ''}
+            defaletValue={adminAppContainer.state.title || ''}
             onChange={(e) => {
               adminAppContainer.changeTitle(e.target.value);
             }}
@@ -60,7 +60,7 @@ const AppSetting = (props) => {
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.confidential || ''}
+            defaultValue={adminAppContainer.state.confidential || ''}
             onChange={(e) => {
               adminAppContainer.changeConfidential(e.target.value);
             }}

+ 1 - 1
apps/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -149,7 +149,7 @@ const ElasticsearchManagement = () => {
 
   return (
     <>
-      <div data-testid="admin-full-text-search" className="row">
+      <div className="row">
         <div className="col-md-12">
           <StatusTable
             isInitialized={isInitialized}

+ 0 - 96
apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx

@@ -1,96 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
-
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class WhitelistInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.tagWhitelist = React.createRef();
-    this.attrWhitelist = React.createRef();
-
-    this.tags = sanitizeDefaultSchema.tagNames;
-    this.attrs = JSON.stringify(sanitizeDefaultSchema.attributes);
-
-    this.onClickRecommendTagButton = this.onClickRecommendTagButton.bind(this);
-    this.onClickRecommendAttrButton = this.onClickRecommendAttrButton.bind(this);
-  }
-
-  onClickRecommendTagButton() {
-    this.tagWhitelist.current.value = this.tags;
-    this.props.adminMarkDownContainer.setState({ tagWhitelist: this.tags });
-  }
-
-  onClickRecommendAttrButton() {
-    this.attrWhitelist.current.value = this.attrs;
-    this.props.adminMarkDownContainer.setState({ attrWhitelist: this.attrs });
-  }
-
-  render() {
-    const { t, adminMarkDownContainer } = this.props;
-
-    return (
-      <>
-        <div className="mt-4">
-          <div className="d-flex justify-content-between">
-            {t('markdown_settings.xss_options.tag_names')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={this.onClickRecommendTagButton}>
-              {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
-            </p>
-          </div>
-          <textarea
-            className="form-control xss-list"
-            name="recommendedTags"
-            rows="6"
-            cols="40"
-            ref={this.tagWhitelist}
-            defaultValue={adminMarkDownContainer.state.tagWhitelist}
-            onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
-          />
-        </div>
-        <div className="mt-4">
-          <div className="d-flex justify-content-between">
-            {t('markdown_settings.xss_options.tag_attributes')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={this.onClickRecommendAttrButton}>
-              {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
-            </p>
-          </div>
-          <textarea
-            className="form-control xss-list"
-            name="recommendedAttrs"
-            rows="6"
-            cols="40"
-            ref={this.attrWhitelist}
-            defaultValue={adminMarkDownContainer.state.attrWhitelist}
-            onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
-          />
-        </div>
-      </>
-    );
-  }
-
-}
-
-
-WhitelistInput.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
-
-};
-
-const PresentationFormWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-
-  return <WhitelistInput t={t} {...props} />;
-};
-
-const WhitelistWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
-
-export default WhitelistWrapper;

+ 79 - 0
apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.tsx

@@ -0,0 +1,79 @@
+import { useCallback, useRef } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
+
+type Props ={
+  adminMarkDownContainer: AdminMarkDownContainer
+}
+
+export const WhitelistInput = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+  const { adminMarkDownContainer } = props;
+
+  const tagNamesRef = useRef<HTMLTextAreaElement>(null);
+  const attrsRef = useRef<HTMLTextAreaElement>(null);
+
+  const clickRecommendTagButtonHandler = useCallback(() => {
+    if (tagNamesRef.current == null) {
+      return;
+    }
+
+    const tagWhitelist = recommendedTagNames.join(',');
+    tagNamesRef.current.value = tagWhitelist;
+    adminMarkDownContainer.setState({ tagWhitelist });
+  }, [adminMarkDownContainer]);
+
+  const clickRecommendAttrButtonHandler = useCallback(() => {
+    if (attrsRef.current == null) {
+      return;
+    }
+
+    const attrWhitelist = JSON.stringify(recommendedAttributes);
+    attrsRef.current.value = attrWhitelist;
+    adminMarkDownContainer.setState({ attrWhitelist });
+  }, [adminMarkDownContainer]);
+
+  return (
+    <>
+      <div className="mt-4">
+        <div className="d-flex justify-content-between">
+          {t('markdown_settings.xss_options.tag_names')}
+          <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={clickRecommendTagButtonHandler}>
+            {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
+          </p>
+        </div>
+        <textarea
+          ref={tagNamesRef}
+          className="form-control xss-list"
+          name="recommendedTags"
+          rows={6}
+          cols={40}
+          defaultValue={adminMarkDownContainer.state.tagWhitelist}
+          onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
+        />
+      </div>
+      <div className="mt-4">
+        <div className="d-flex justify-content-between">
+          {t('markdown_settings.xss_options.tag_attributes')}
+          <p id="btn-import-tags" className="btn btn-sm btn-primary" onClick={clickRecommendAttrButtonHandler}>
+            {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
+          </p>
+        </div>
+        <textarea
+          ref={attrsRef}
+          className="form-control xss-list"
+          name="recommendedAttrs"
+          rows={6}
+          cols={40}
+          defaultValue={adminMarkDownContainer.state.attrWhitelist}
+          onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
+        />
+      </div>
+    </>
+  );
+
+};

+ 10 - 10
apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -2,17 +2,17 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
+import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import WhitelistInput from './WhitelistInput';
+import { WhitelistInput } from './WhitelistInput';
 
 const logger = loggerFactory('growi:importer');
 
@@ -41,8 +41,8 @@ class XssForm extends React.Component {
     const { t, adminMarkDownContainer } = this.props;
     const { xssOption } = adminMarkDownContainer.state;
 
-    const rehypeRecommendedTags = sanitizeDefaultSchema.tagNames;
-    const rehypeRecommendedAttributes = JSON.stringify(sanitizeDefaultSchema.attributes);
+    const rehypeRecommendedTags = recommendedTagNames.join(',');
+    const rehypeRecommendedAttributes = JSON.stringify(recommendedAttributes);
 
     return (
       <div className="col-12 mt-3">
@@ -55,8 +55,8 @@ class XssForm extends React.Component {
                 className="form-check-input"
                 id="xssOption1"
                 name="XssOption"
-                checked={xssOption === RehypeSanitizeOption.RECOMMENDED}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.RECOMMENDED }) }}
+                checked={xssOption === RehypeSanitizeType.RECOMMENDED}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeType.RECOMMENDED }) }}
               />
               <label className="form-label form-check-label w-100" htmlFor="xssOption1">
                 <p className="fw-bold">{t('markdown_settings.xss_options.recommended_setting')}</p>
@@ -97,12 +97,12 @@ class XssForm extends React.Component {
                 className="form-check-input"
                 id="xssOption2"
                 name="XssOption"
-                checked={xssOption === RehypeSanitizeOption.CUSTOM}
-                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
+                checked={xssOption === RehypeSanitizeType.CUSTOM}
+                onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeType.CUSTOM }) }}
               />
               <label className="form-label form-check-label w-100" htmlFor="xssOption2">
                 <p className="fw-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhitelistInput customizable />
+                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} />
               </label>
             </div>
           </div>

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

@@ -1,5 +1,5 @@
 import React, {
-  useState, useCallback, useEffect, useMemo,
+  useState, useCallback, useEffect,
 } from 'react';
 
 import {
@@ -18,7 +18,6 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 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';
 import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
@@ -54,7 +53,6 @@ type Props = {
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const router = useRouter();
-  const xss = useMemo(() => new Xss(), []);
   const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
   const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
@@ -221,13 +219,13 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   const removeUserByUsername = useCallback(async(username: string) => {
     try {
       await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
-      toastSuccess(`Removed "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`);
+      toastSuccess(`Removed "${username}" from "${currentUserGroup?.name}"`);
       mutateUserGroupRelationList();
     }
     catch (err) {
-      toastError(new Error(`Unable to remove "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`));
+      toastError(new Error(`Unable to remove "${username}" from "${currentUserGroup?.name}"`));
     }
-  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList, xss]);
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelationList]);
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);

+ 3 - 7
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx

@@ -1,5 +1,5 @@
 import type { FC, KeyboardEvent } from 'react';
-import React, { useState, useRef } from 'react';
+import React, { useState } from 'react';
 
 import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
@@ -8,7 +8,6 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { SearchType } from '~/interfaces/user-group';
-import Xss from '~/services/xss';
 
 type Props = {
   userGroup: IUserGroupHasId,
@@ -25,25 +24,22 @@ export const UserGroupUserFormByInput: FC<Props> = (props) => {
   } = props;
 
   const { t } = useTranslation();
-  const typeaheadRef = useRef(null);
   const [inputUser, setInputUser] = useState<IUserHasId[]>([]);
   const [applicableUsers, setApplicableUsers] = useState<IUserHasId[]>([]);
   const [isLoading, setIsLoading] = useState(false);
   const [isSearchError, setIsSearchError] = useState(false);
 
-  const xss = new Xss();
-
   const addUserBySubmit = async() => {
     if (inputUser.length === 0) { return }
     const userName = inputUser[0].username;
 
     try {
       await onClickAddUserBtn(userName);
-      toastSuccess(`Added "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`);
+      toastSuccess(`Added "${userName}" to "${userGroup.name}"`);
       setInputUser([]);
     }
     catch (err) {
-      toastError(new Error(`Unable to add "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`));
+      toastError(new Error(`Unable to add "${userName}" to "${userGroup.name}"`));
     }
   };
 

+ 1 - 1
apps/app/src/components/Common/PageViewLayout.tsx

@@ -23,7 +23,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar`}>
+      <div className={`main ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar position-relative z-0`}>
         <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
           { headerContents != null && headerContents }
           { sideContents != null

+ 1 - 1
apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx

@@ -25,6 +25,6 @@ export const AutosizeSubmittableInput = (props: SubmittableInputProps<AutosizeIn
   const submittableProps = useSubmittable(props);
 
   return (
-    <AutosizeInput {...submittableProps} data-testid="autosize-submittable-input" />
+    <AutosizeInput {...submittableProps} type="text" data-testid="autosize-submittable-input" />
   );
 };

+ 9 - 1
apps/app/src/components/Common/SubmittableInput/use-submittable.ts

@@ -18,6 +18,7 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
   } = props;
 
   const [inputText, setInputText] = useState(value ?? '');
+  const [lastSubmittedInputText, setLastSubmittedInputText] = useState<string|undefined>(value ?? '');
   const [isComposing, setComposing] = useState(false);
 
   const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -34,6 +35,7 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
         if (isComposing) {
           return;
         }
+        setLastSubmittedInputText(inputText);
         onSubmit?.(inputText.trim());
         break;
       case 'Escape':
@@ -46,10 +48,16 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
   }, [inputText, isComposing, onCancel, onSubmit]);
 
   const blurHandler = useCallback((e) => {
+    // suppress continuous calls to submit by blur event
+    if (lastSubmittedInputText === inputText) {
+      return;
+    }
+
     // submit on blur
+    setLastSubmittedInputText(inputText);
     onSubmit?.(inputText.trim());
     onBlur?.(e);
-  }, [inputText, onSubmit, onBlur]);
+  }, [inputText, lastSubmittedInputText, onSubmit, onBlur]);
 
   const compositionStartHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
     setComposing(true);

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

@@ -65,7 +65,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [createTemplate, path, t]);
+  }, [createTemplate, onClose, path, t]);
 
   const parentPath = pathUtils.addTrailingSlash(path);
 

+ 69 - 0
apps/app/src/components/GrantedGroupsInheritanceSelectModal.tsx

@@ -0,0 +1,69 @@
+import { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+
+const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: modalData, close: closeModal } = useGrantedGroupsInheritanceSelectModal();
+  const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
+
+  const onCreateBtnClick = async() => {
+    await modalData?.onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
+    setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
+  };
+  const isOpened = modalData?.isOpened ?? false;
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={() => closeModal()}
+    >
+      <ModalHeader tag="h4" toggle={() => closeModal()}>
+        {t('modal_granted_groups_inheritance_select.select_granted_groups')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="px-3 pt-3">
+          <div className="form-check radio-primary mb-3">
+            <input
+              type="radio"
+              id="inheritAllGroupsRadio"
+              className="form-check-input"
+              form="formImageType"
+              checked={!onlyInheritUserRelatedGrantedGroups}
+              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(false) }}
+            />
+            <label className="form-check-label" htmlFor="inheritAllGroupsRadio">
+              {t('modal_granted_groups_inheritance_select.inherit_all_granted_groups_from_parent')}
+            </label>
+          </div>
+          <div className="form-check radio-primary">
+            <input
+              type="radio"
+              id="onlyInheritRelatedGroupsRadio"
+              className="form-check-input"
+              form="formImageType"
+              checked={onlyInheritUserRelatedGrantedGroups}
+              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(true) }}
+            />
+            <label className="form-check-label" htmlFor="onlyInheritRelatedGroupsRadio">
+              {t('modal_granted_groups_inheritance_select.only_inherit_related_groups')}
+            </label>
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter className="grw-modal-footer">
+        <button type="button" className="me-2 btn btn-secondary" onClick={() => closeModal()}>{t('Cancel')}</button>
+        <button className="btn btn-primary" type="button" onClick={onCreateBtnClick}>
+          {t('modal_granted_groups_inheritance_select.create_page')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default GrantedGroupsInheritanceSelectModal;

+ 18 - 19
apps/app/src/components/InstallerForm.tsx

@@ -170,11 +170,11 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className={`input-group mb-3${hasErrorClass}`}>
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">person</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('User ID')} htmlFor="tiUsername">
+              <span className="material-symbols-outlined" aria-hidden>person</span>
+            </label>
             <input
-              data-testid="tiUsername"
+              id="tiUsername"
               type="text"
               className="form-control rounded"
               placeholder={t('User ID')}
@@ -186,11 +186,11 @@ const InstallerForm = memo((): JSX.Element => {
           <p className="form-text">{ unavailableUserId }</p>
 
           <div className="input-group mb-3">
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">sell</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('Name')} htmlFor="tiName">
+              <span className="material-symbols-outlined" aria-hidden>sell</span>
+            </label>
             <input
-              data-testid="tiName"
+              id="tiName"
               type="text"
               className="form-control rounded"
               placeholder={t('Name')}
@@ -200,11 +200,11 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">mail</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('Email')} htmlFor="tiEmail">
+              <span className="material-symbols-outlined" aria-hidden>mail</span>
+            </label>
             <input
-              data-testid="tiEmail"
+              id="tiEmail"
               type="email"
               className="form-control rounded"
               placeholder={t('Email')}
@@ -214,11 +214,11 @@ const InstallerForm = memo((): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <span className="p-2 text-white opacity-75">
-              <span className="material-symbols-outlined">lock</span>
-            </span>
+            <label className="p-2 text-white opacity-75" aria-label={t('Password')} htmlFor="tiPassword">
+              <span className="material-symbols-outlined" aria-hidden>lock</span>
+            </label>
             <input
-              data-testid="tiPassword"
+              id="tiPassword"
               type="password"
               className="form-control rounded"
               placeholder={t('Password')}
@@ -229,19 +229,18 @@ const InstallerForm = memo((): JSX.Element => {
 
           <div className="input-group mt-4 justify-content-center">
             <button
-              data-testid="btnSubmit"
               type="submit"
               className="btn btn-secondary btn-register col-6 d-flex"
               disabled={isLoading}
             >
-              <span>
+              <span aria-hidden>
                 {isLoading ? (
                   <LoadingSpinner />
                 ) : (
                   <span className="material-symbols-outlined">person_add</span>
                 )}
               </span>
-              <span className="flex-grow-1">{ t('Create') }</span>
+              <label className="flex-grow-1">{ t('Create') }</label>
             </button>
           </div>
 

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -29,6 +29,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
+const GrantedGroupsInheritanceSelectModal = dynamic(() => import('../GrantedGroupsInheritanceSelectModal'), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const PageBulkExportSelectModal = dynamic(() => import('../../features/page-bulk-export/client/components/PageBulkExportSelectModal'), { ssr: false });
@@ -74,6 +75,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
 
       <ShortcutsModal />
       <PageBulkExportSelectModal />
+      <GrantedGroupsInheritanceSelectModal />
       <SystemVersion showShortcutsButton />
     </RawLayout>
   );

+ 9 - 7
apps/app/src/components/LoginForm/LoginForm.tsx

@@ -196,10 +196,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
-            <span className="text-white opacity-75 d-flex align-items-center">
-              <span className="material-symbols-outlined">person</span>
-            </span>
+            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiUsernameForLogin">
+              <span className="material-symbols-outlined" aria-label="Username or E-mail">person</span>
+            </label>
             <input
+              id="tiUsernameForLogin"
               type="text"
               className={`form-control rounded ms-2 ${isLdapStrategySetup ? 'ldap-space' : ''}`}
               data-testid="tiUsernameForLogin"
@@ -217,10 +218,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
 
           <div className="input-group">
-            <span className="text-white opacity-75 d-flex align-items-center">
-              <span className="material-symbols-outlined">lock</span>
-            </span>
+            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiPasswordForLogin">
+              <span className="material-symbols-outlined" aria-label="Password">lock</span>
+            </label>
             <input
+              id="tiPasswordForLogin"
               type="password"
               className="form-control rounded ms-2"
               data-testid="tiPasswordForLogin"
@@ -241,7 +243,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {isLoading ? (
                   <LoadingSpinner />
                 ) : (
-                  <span className="material-symbols-outlined">login</span>
+                  <span className="material-symbols-outlined" aria-label="Login">login</span>
                 )}
               </span>
               <span className="flex-grow-1">{t('Sign in')}</span>

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

@@ -112,7 +112,7 @@ const AssociateModal = (props: Props): JSX.Element => {
         </div>
       </ModalBody>
       <ModalFooter className="border-top-0">
-        <button type="button" className="btn btn-primary mt-3" onClick={clickAddLdapAccountHandler}>
+        <button type="button" className="btn btn-primary mt-3" data-testid="add-external-account-button" onClick={clickAddLdapAccountHandler}>
           <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
           {t('add')}
         </button>

+ 6 - 6
apps/app/src/components/Me/PersonalSettings.jsx

@@ -20,22 +20,22 @@ const PersonalSettings = () => {
   const navTabMapping = useMemo(() => {
     return {
       user_infomation: {
-        Icon: () => <span className="material-symbols-outlined">person</span>,
+        Icon: () => <span data-testid="user-infomation-tab-button" className="material-symbols-outlined">person</span>,
         Content: UserSettings,
         i18n: t('User Information'),
       },
       external_accounts: {
-        Icon: () => <span className="material-symbols-outlined">ungroup</span>,
+        Icon: () => <span data-testid="external-accounts-tab-button" className="material-symbols-outlined">ungroup</span>,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
       },
       password_settings: {
-        Icon: () => <span className="material-symbols-outlined">password</span>,
+        Icon: () => <span data-testid="password-settings-tab-button" className="material-symbols-outlined">password</span>,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
       },
       api_settings: {
-        Icon: () => <span className="material-symbols-outlined">api</span>,
+        Icon: () => <span data-testid="api-settings-tab-button" className="material-symbols-outlined">api</span>,
         Content: ApiSettings,
         i18n: t('API Settings'),
       },
@@ -45,12 +45,12 @@ const PersonalSettings = () => {
       //   i18n: t('editor_settings.editor_settings'),
       // },
       in_app_notification_settings: {
-        Icon: () => <span className="material-symbols-outlined">notifications</span>,
+        Icon: () => <span data-testid="in-app-notification-settings-tab-button" className="material-symbols-outlined">notifications</span>,
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
       other_settings: {
-        Icon: () => <span className="material-symbols-outlined">settings</span>,
+        Icon: () => <span data-testid="other-settings-tab-button" className="material-symbols-outlined">settings</span>,
         Content: OtherSettings,
         i18n: t('Other Settings'),
       },

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

@@ -36,7 +36,6 @@ import {
   useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
-import { CreateTemplateModal } from '../CreateTemplateModal';
 import { NotAvailable } from '../NotAvailable';
 import { Skeleton } from '../Skeleton';
 
@@ -45,6 +44,9 @@ import { GroundGlassBar } from './GroundGlassBar';
 import styles from './GrowiContextualSubNavigation.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
+
+const CreateTemplateModal = dynamic(() => import('../CreateTemplateModal').then(mod => mod.CreateTemplateModal), { ssr: false });
+
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
   { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },

+ 9 - 6
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,9 +1,10 @@
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 import { Origin } from '@growi/core';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
@@ -67,7 +68,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
   const editButtonClickedHandler = useCallback(async() => {
     if (isNotFound == null || isNotFound === false) {
@@ -76,15 +77,17 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
 
     try {
-      await createAndTransit(
-        { path, wip: shouldCreateWipPage(path), origin: Origin.View },
-        { shouldCheckPageExists: true },
+      const parentPath = path != null ? normalizePath(path.split('/').slice(0, -1).join('/')) : undefined; // does not have to exist
+      await create(
+        {
+          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
+        },
       );
     }
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
+  }, [create, isNotFound, mutateEditorMode, path, t]);
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 

+ 1 - 1
apps/app/src/components/Page/markdown-table-util-for-view.ts

@@ -1,4 +1,4 @@
-import MarkdownTable from '~/client/models/MarkdownTable';
+import { MarkdownTable } from '@growi/editor';
 
 export const getMarkdownTableFromLine = (markdown: string, bol: number, eol: number): MarkdownTable => {
   const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');

+ 2 - 2
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
-import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
@@ -17,7 +17,7 @@ type ModalProps = {
   isOpen: boolean
   pageId: string
   dataApplicableGrant: IRecordApplicableGrant
-  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  currentAndParentPageGrantData: IResGrantData
   close(): void
 }
 

+ 3 - 2
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -4,9 +4,10 @@ import React, {
   useMemo,
 } from 'react';
 
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
-  CodeMirrorEditorComment, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
-} from '@growi/editor';
+  CodeMirrorEditorComment, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
+} from '@growi/editor/dist/client';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';

+ 15 - 15
apps/app/src/components/PageControls/PageControls.tsx

@@ -1,3 +1,4 @@
+import type { MouseEventHandler } from 'react';
 import React, {
   memo, useCallback, useEffect, useMemo, useRef,
 } from 'react';
@@ -16,7 +17,7 @@ import {
   toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
-import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
 import {
   EditorMode, useEditorMode, useIsDeviceLargerThanMd, usePageControlsX,
@@ -66,7 +67,7 @@ const Tags = (props: TagsProps): JSX.Element => {
 };
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
-  onClickMenuItem: (newValue: boolean) => void,
+  onClick: () => void,
   expandContentWidth?: boolean,
 }
 
@@ -74,23 +75,19 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    onClickMenuItem, expandContentWidth,
+    onClick, expandContentWidth,
   } = props;
 
   return (
-    <DropdownItem
-      onClick={() => onClickMenuItem(!(expandContentWidth))}
-      className="grw-page-control-dropdown-item"
-    >
+    <DropdownItem className="grw-page-control-dropdown-item dropdown-item" onClick={onClick} toggle={false}>
       <div className="form-check form-switch ms-1">
         <input
-          id="switchContentWidth"
-          className="form-check-input"
+          className="form-check-input pe-none"
           type="checkbox"
           checked={expandContentWidth}
           onChange={() => {}}
         />
-        <label className="form-label form-check-label" htmlFor="switchContentWidth">
+        <label className="form-check-label pe-none">
           { t('wide_view') }
         </label>
       </div>
@@ -133,6 +130,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: editorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const { data: isSearchPage } = useIsSearchPage();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -224,7 +222,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
-  const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
+  const switchContentWidthClickHandler = useCallback(() => {
+
+    const newValue = !expandContentWidth;
     if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
       logger.warn('Could not switch content width', {
         onClickSwitchContentWidth: onClickSwitchContentWidth == null ? 'null' : 'not null',
@@ -242,7 +242,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     catch (err) {
       toastError(err);
     }
-  }, [isGuestUser, isReadOnlyUser, onClickSwitchContentWidth, pageId, pageInfo]);
+  }, [expandContentWidth, isGuestUser, isReadOnlyUser, onClickSwitchContentWidth, pageId, pageInfo]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -250,7 +250,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
     const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
 
-      return <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+      return <WideViewMenuItem {...props} onClick={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
     };
     return wideviewMenuItemRenderer;
   }, [pageInfo, switchContentWidthClickHandler, expandContentWidth]);
@@ -274,7 +274,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   return (
     <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
-      { isViewMode && isDeviceLargerThanMd && (
+      { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <SearchButton />
       )}
 
@@ -307,7 +307,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
               bookmarkCount={pageInfo.bookmarkCount}
             />
           )}
-          {revisionId != null && (
+          {revisionId != null && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
               sumOfSeenUsers={sumOfSeenUsers}

+ 12 - 9
apps/app/src/components/PageCreateModal.tsx

@@ -12,7 +12,7 @@ import {
 import { debounce } from 'throttle-debounce';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
-import { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
+import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
@@ -36,7 +36,7 @@ const PageCreateModal: React.FC = () => {
   const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
 
   const { data: isReachable } = useIsSearchServiceReachable();
@@ -94,25 +94,28 @@ const PageCreateModal: React.FC = () => {
    */
   const createTodayPage = useCallback(async() => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
-    return createAndTransit(
-      { path: joinedPath, wip: true, origin: Origin.View },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    return create(
+      {
+        path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
+      },
+      { onTerminated: closeCreateModal },
     );
-  }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
+  }, [closeCreateModal, create, todayInput, todaysParentPath]);
 
   /**
    * access input page
    */
   const createInputPage = useCallback(async() => {
-    return createAndTransit(
+    return create(
       {
         path: pageNameInput,
+        parentPath: pathname,
         wip: true,
         origin: Origin.View,
       },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+      { onTerminated: closeCreateModal },
     );
-  }, [closeCreateModal, createAndTransit, pageNameInput]);
+  }, [closeCreateModal, create, pageNameInput, pathname]);
 
   /**
    * access template page

+ 3 - 2
apps/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -3,9 +3,10 @@ import React, {
 } from 'react';
 
 import type { IUser } from '@growi/core';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
-  MergeViewer, CodeMirrorEditorDiff, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated,
-} from '@growi/editor';
+  MergeViewer, CodeMirrorEditorDiff, useCodeMirrorEditorIsolated,
+} from '@growi/editor/dist/client';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';

+ 1 - 2
apps/app/src/components/PageEditor/DrawioModal.tsx

@@ -4,8 +4,7 @@ import React, {
   useMemo,
 } from 'react';
 
-import { useCodeMirrorEditorIsolated } from '@growi/editor';
-import { useDrawioModalForEditor } from '@growi/editor/src/stores/use-drawio';
+import { useCodeMirrorEditorIsolated, useDrawioModalForEditor } from '@growi/editor/dist/client';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import {
   Modal,

+ 1 - 1
apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -30,7 +30,7 @@ export const EditingUserList: FC<Props> = ({ userList }) => {
     <div className="d-flex flex-column justify-content-start justify-content-sm-end">
       <div className="d-flex justify-content-start justify-content-sm-end">
         {firstFourUsers.map(user => (
-          <div className="ms-1">
+          <div key={user._id} className="ms-1">
             <UserPicture
               user={user}
               noLink

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

@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 
-import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
+import { MarkdownTable } from '@growi/editor';
+import { useHandsontableModalForEditor } from '@growi/editor/dist/client';
 import { HotTable } from '@handsontable/react';
 import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
@@ -10,7 +11,6 @@ import {
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import MarkdownTable from '~/client/models/MarkdownTable';
 import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/components/PageEditor/markdown-table-util-for-editor';
 import { useHandsontableModal } from '~/stores/modal';
 

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

@@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
 
 import path from 'path';
 
-import Linker from '@growi/editor/src/services/link-util/Linker';
-import { useLinkEditModal } from '@growi/editor/src/stores/use-link-edit-modal';
+import { Linker } from '@growi/editor';
+import { useLinkEditModal } from '@growi/editor/dist/client';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,

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

@@ -1,12 +1,12 @@
 import React, { useState } from 'react';
 
+import { MarkdownTable } from '@growi/editor';
 import { useTranslation } from 'next-i18next';
 import {
   Button,
   Collapse,
 } from 'reactstrap';
 
-import MarkdownTable from '~/client/models/MarkdownTable';
 
 type MarkdownTableDataImportFormProps = {
   onCancel: () => void,

+ 19 - 6
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -175,16 +175,19 @@ IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
 type SwitchItemProps = {
-  onClick: () => void,
+  inputId: string,
+  onChange: () => void,
   checked: boolean,
   text: string,
 };
 const SwitchItem = memo((props: SwitchItemProps): JSX.Element => {
-  const { onClick, checked, text } = props;
+  const {
+    inputId, onChange, checked, text,
+  } = props;
   return (
     <FormGroup switch>
-      <Input type="switch" checked={checked} onClick={onClick} />
-      <label>{text}</label>
+      <Input id={inputId} type="switch" checked={checked} onChange={onChange} />
+      <label htmlFor={inputId}>{text}</label>
     </FormGroup>
 
   );
@@ -203,7 +206,12 @@ const ConfigurationSelector = memo((): JSX.Element => {
     const isActive = editorSettings.styleActiveLine;
 
     return (
-      <SwitchItem onClick={() => update({ styleActiveLine: !isActive })} checked={isActive} text={t('page_edit.Show active line')} />
+      <SwitchItem
+        inputId="switchActiveLine"
+        onChange={() => update({ styleActiveLine: !isActive })}
+        checked={isActive}
+        text={t('page_edit.Show active line')}
+      />
     );
   }, [editorSettings, update, t]);
 
@@ -215,7 +223,12 @@ const ConfigurationSelector = memo((): JSX.Element => {
     const isActive = editorSettings.autoFormatMarkdownTable;
 
     return (
-      <SwitchItem onClick={() => update({ autoFormatMarkdownTable: !isActive })} checked={isActive} text={t('page_edit.auto_format_table')} />
+      <SwitchItem
+        inputId="switchTableAutoFormatting"
+        onChange={() => update({ autoFormatMarkdownTable: !isActive })}
+        checked={isActive}
+        text={t('page_edit.auto_format_table')}
+      />
     );
   }, [editorSettings, t, update]);
 

+ 8 - 5
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -9,10 +9,11 @@ import nodePath from 'path';
 
 import { type IPageHasId, Origin } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import {
-  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
+  CodeMirrorEditorMain,
   useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
-} from '@growi/editor';
+} from '@growi/editor/dist/client';
 import { useRect } from '@growi/ui/dist/utils';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
@@ -42,7 +43,7 @@ import { mutatePageTree } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
-  useEditorMode, useSelectedGrant,
+  useEditorMode, useIsUntitledPage, useSelectedGrant,
 } from '~/stores/ui';
 import { useEditingUsers } from '~/stores/use-editing-users';
 import { useNextThemes } from '~/stores/use-next-themes';
@@ -68,6 +69,7 @@ declare global {
 export type SaveOptions = {
   wip: boolean,
   slackChannels: string,
+  isSlackEnabled: boolean,
   overwriteScopesOfDescendants?: boolean
 }
 export type Save = (
@@ -100,6 +102,7 @@ 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: isUntitledPage } = useIsUntitledPage();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
@@ -276,10 +279,10 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   // set handler to focus
   useLayoutEffect(() => {
-    if (editorMode === EditorMode.Editor) {
+    if (editorMode === EditorMode.Editor && isUntitledPage === false) {
       codeMirrorEditor?.focus();
     }
-  }, [codeMirrorEditor, currentPage, editorMode]);
+  }, [codeMirrorEditor, editorMode, isUntitledPage]);
 
   // Detect indent size from contents (only when users are allowed to change it)
   useEffect(() => {

+ 2 - 1
apps/app/src/components/PageEditor/PageEditorReadOnly.tsx

@@ -1,6 +1,7 @@
 import react, { useMemo, useRef } from 'react';
 
-import { CodeMirrorEditorReadOnly, GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { CodeMirrorEditorReadOnly } from '@growi/editor/dist/client';
 import { throttle } from 'throttle-debounce';
 
 import { useShouldExpandContent } from '~/client/services/layout';

+ 2 - 1
apps/app/src/components/PageEditor/ScrollSyncHelper.tsx

@@ -1,6 +1,7 @@
 import { useCallback, type RefObject, useRef } from 'react';
 
-import { useCodeMirrorEditorIsolated, type GlobalCodeMirrorEditorKey } from '@growi/editor';
+import type { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client';
 
 let defaultTop = 0;
 const padding = 5;

+ 2 - 1
apps/app/src/components/PageEditor/conflict.tsx

@@ -2,7 +2,8 @@ import { useCallback, useEffect } from 'react';
 
 import { Origin } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
-import { GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated } from '@growi/editor';
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client';
 import { useTranslation } from 'react-i18next';
 
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';

+ 1 - 2
apps/app/src/components/PageEditor/markdown-table-util-for-editor.ts

@@ -1,6 +1,5 @@
 import type { EditorView } from '@codemirror/view';
-
-import MarkdownTable from '~/client/models/MarkdownTable';
+import { MarkdownTable } from '@growi/editor';
 
 // https://regex101.com/r/7BN2fR/10
 const linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;

+ 12 - 8
apps/app/src/components/PageEditor/page-path-rename-utils.ts

@@ -7,15 +7,18 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+import { useIsUntitledPage } from '~/stores/ui';
 
-type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
+
+type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void, onRenamedSkipped?: () => void) => Promise<void>
 
 export const usePagePathRenameHandler = (
     currentPage?: IPagePopulatedToShowRevision | null,
 ): PagePathRenameHandler => {
 
-  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { mutate: mutateIsUntitledPage } = useIsUntitledPage();
 
   const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
@@ -23,20 +26,21 @@ export const usePagePathRenameHandler = (
       return;
     }
 
+    if (newPagePath === currentPage.path || newPagePath === '') {
+      onRenameFinish?.();
+      return;
+    }
+
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
       mutatePageList();
+      mutateIsUntitledPage(false);
 
       if (currentPage.path === fromPath || currentPage.path === toPath) {
         mutateCurrentPage();
       }
     };
 
-    if (newPagePath === currentPage.path || newPagePath === '') {
-      onRenameFinish?.();
-      return;
-    }
-
     try {
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
@@ -53,7 +57,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       toastError(err);
     }
-  }, [currentPage, mutateCurrentPage, t]);
+  }, [currentPage, mutateCurrentPage, mutateIsUntitledPage, t]);
 
   return pagePathRenameHandler;
 };

+ 11 - 16
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,5 +1,5 @@
 import type { ChangeEvent } from 'react';
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
 
 import nodePath from 'path';
 
@@ -11,12 +11,12 @@ import { useTranslation } from 'next-i18next';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+import { EditorMode, useEditorMode, useIsUntitledPage } from '~/stores/ui';
 
 import { CopyDropdown } from '../Common/CopyDropdown';
 import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
-
 import styles from './PageTitleHeader.module.scss';
 
 const moduleClass = styles['page-title-header'] ?? '';
@@ -49,12 +49,8 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const editedPageTitle = nodePath.basename(editedPagePath);
 
-  // TODO: https://redmine.weseek.co.jp/issues/142729
-  // https://regex101.com/r/Wg2Hh6/1
-  const untitledPageRegex = /^Untitled-\d+$/;
-
-  const isNewlyCreatedPage = (currentPage.wip && currentPage.latestRevision == null && untitledPageRegex.test(editedPageTitle)) ?? false;
-
+  const { data: editorMode } = useEditorMode();
+  const { data: isUntitledPage } = useIsUntitledPage();
 
   const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
     const newPageTitle = pathUtils.removeHeadingSlash(e.target.value);
@@ -95,13 +91,12 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, [currentPagePath, isMovable]);
 
-  // TODO: auto focus when create new page
-  // https://redmine.weseek.co.jp/issues/136128
-  // useEffect(() => {
-  //   if (isNewlyCreatedPage) {
-  //     setRenameInputShown(true);
-  //   }
-  // }, [currentPage._id, isNewlyCreatedPage]);
+  useEffect(() => {
+    setEditedPagePath(currentPagePath);
+    if (isUntitledPage && editorMode === EditorMode.Editor) {
+      setRenameInputShown(true);
+    }
+  }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
 
   const isInvalid = validationResult != null;
 
@@ -116,7 +111,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
           <div className="position-relative">
             <div className="position-absolute w-100">
               <AutosizeSubmittableInput
-                value={isNewlyCreatedPage ? '' : editedPageTitle}
+                value={isUntitledPage ? '' : editedPageTitle}
                 inputClassName={`form-control fs-4 ${isInvalid ? 'is-invalid' : ''}`}
                 inputStyle={{ maxWidth: inputMaxWidth }}
                 placeholder={t('Input page name')}

+ 12 - 10
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -1,8 +1,11 @@
 import type { FC } from 'react';
-import { Suspense, useState, useCallback } from 'react';
+import {
+  Suspense, useState, useCallback,
+} from 'react';
 
 import nodePath from 'path';
 
+import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
@@ -11,7 +14,7 @@ import {
 import type { IPageForItem } from '~/interfaces/page';
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { usePageSelectModal } from '~/stores/modal';
-import { useCurrentPagePath, useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 import { ItemsTree } from '../ItemsTree';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
@@ -19,7 +22,6 @@ import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import { TreeItemForModal } from './TreeItemForModal';
 
-
 export const PageSelectModal: FC = () => {
   const {
     data: PageSelectModalData,
@@ -34,8 +36,6 @@ export const PageSelectModal: FC = () => {
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: currentPath } = useCurrentPagePath();
-  const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: currentPage } = useSWRxCurrentPage();
 
@@ -45,7 +45,7 @@ export const PageSelectModal: FC = () => {
     const parentPagePath = page.path;
 
     if (parentPagePath == null) {
-      return;
+      return <></>;
     }
 
     setClickedParentPagePath(parentPagePath);
@@ -67,12 +67,14 @@ export const PageSelectModal: FC = () => {
     closeModal();
   }, [clickedParentPagePath, closeModal, currentPage?.path, pagePathRenameHandler]);
 
-  const targetPathOrId = targetId || currentPath;
+  const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? ''));
+
+  const targetPathOrId = clickedParentPagePath || parentPagePath;
 
-  const path = currentPath || '/';
+  const targetPath = clickedParentPagePath || parentPagePath;
 
   if (isGuestUser == null) {
-    return null;
+    return <></>;
   }
 
   return (
@@ -89,7 +91,7 @@ export const PageSelectModal: FC = () => {
             CustomTreeItem={TreeItemForModal}
             isEnableActions={!isGuestUser}
             isReadOnlyUser={!!isReadOnlyUser}
-            targetPath={path}
+            targetPath={targetPath}
             targetPathOrId={targetPathOrId}
             targetAndAncestorsData={targetAndAncestorsData}
             onClickTreeItem={onClickTreeItem}

+ 20 - 0
apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss

@@ -1,5 +1,25 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
 .tree-item-for-modal :global {
   li {
     min-height: 36px;
   }
 }
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .tree-item-for-modal :global {
+    .list-group-item-action {
+      --bs-list-group-active-bg: var(--grw-primary-200);
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .tree-item-for-modal :global {
+    .list-group-item-action {
+      --bs-list-group-active-bg: var(--grw-primary-700);
+    }
+  }
+}

+ 13 - 15
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -10,33 +10,31 @@ import styles from './TreeItemForModal.module.scss';
 const moduleClass = styles['tree-item-for-modal'];
 
 
-type PageTreeItemProps = TreeItemProps & {
+type TreeItemForModalProps = TreeItemProps & {
   key?: React.Key | null,
 };
 
-export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
+export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
 
-  const { isOpen, onClick } = props;
+  const { itemNode, targetPathOrId } = props;
+  const { page } = itemNode;
 
   const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
+  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
+
+  const itemClassNames = [
+    isSelected ? 'active' : '',
+  ];
+
   return (
     <TreeItemLayout
-      key={props.key}
+      {...props}
       className={moduleClass}
-      targetPathOrId={props.targetPathOrId}
-      itemLevel={props.itemLevel}
-      itemNode={props.itemNode}
-      isOpen={isOpen}
-      isEnableActions={props.isEnableActions}
-      isReadOnlyUser={props.isReadOnlyUser}
-      onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
-      onClickDeleteMenuItem={props.onClickDeleteMenuItem}
-      onRenamed={props.onRenamed}
-      customHeadOfChildrenComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
+      itemClassName={itemClassNames.join(' ')}
+      customHeadOfChildrenComponents={[NewPageInput]}
       customHoveredEndComponents={[NewPageCreateButton]}
-      onClick={onClick}
     />
   );
 };

+ 6 - 6
apps/app/src/components/PrivateLegacyPages.tsx

@@ -348,13 +348,14 @@ const PrivateLegacyPages = (): JSX.Element => {
     );
   }, [t, openConvertModalHandler]);
 
-  const searchControlAllAction = useMemo(() => {
+  const extraControls = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
 
     return (
-      <div className="search-control d-flex align-items-center">
-        <div className="d-flex ps-md-2">
+      <div className="d-flex align-items-center">
+        <div className="d-flex">
           <OperateAllControl
+            inputClassName="me-2"
             ref={selectAllControlRef}
             isCheckboxDisabled={isCheckboxDisabled}
             onCheckboxChanged={selectAllCheckboxChangedHandler}
@@ -387,15 +388,14 @@ const PrivateLegacyPages = (): JSX.Element => {
   const searchControl = useMemo(() => {
     return (
       <SearchControl
-        isSearchServiceReachable
         isEnableSort={false}
         isEnableFilter={false}
         initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
         onSearchInvoked={searchInvokedHandler}
-        allControl={searchControlAllAction}
+        extraControls={extraControls}
       />
     );
-  }, [searchInvokedHandler, searchControlAllAction]);
+  }, [searchInvokedHandler, extraControls]);
 
   const searchResultListHead = useMemo(() => {
     if (data == null) {

+ 18 - 4
apps/app/src/components/ReactMarkdownComponents/Header.module.scss

@@ -3,18 +3,32 @@
     text-decoration: none;
   }
 
-  .revision-head-link,
-  .revision-head-edit-button {
+  .revision-head-link {
+    left: -1em;
+    width: 1em;
+    user-select: none;
+    opacity: 0;
+  }
+  .revision-head-edit-button{
     margin-left: 0.5em;
-    font-size: 0.6em;
+    font-size: 16px;
     user-select: none;
     opacity: 0;
+    .material-symbols-outlined{
+      vertical-align: middle;
+    }
   }
 }
 
 .revision-head:hover :global {
   .revision-head-link, .revision-head-edit-button {
-    opacity: 1 !important;
+    opacity: 0.5;
+  }
+  .revision-head-link:hover {
+    opacity: 1;
+  }
+  .revision-head-edit-button:hover {
+    opacity: 1;
   }
 }
 

+ 14 - 9
apps/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -16,6 +16,7 @@ import styles from './Header.module.scss';
 
 
 const logger = loggerFactory('growi:components:Header');
+const moduleClass = styles['revision-head'] ?? '';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
@@ -113,14 +114,18 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
 
   return (
-    <CustomTag id={id} className={`${styles['revision-head']} ${isActive ? styles.blink : ''}`}>
-      {children}
-      <NextLink href={`#${id}`} className="revision-head-link">
-        <span className="material-symbols-outlined">link</span>
-      </NextLink>
-      {showEditButton && (
-        <EditLink line={node.position?.start.line} />
-      )}
-    </CustomTag>
+    <>
+      <CustomTag id={id} className={`position-relative ${moduleClass} ${isActive ? styles.blink : ''} `}>
+        <NextLink href={`#${id}`} className="d-none d-md-inline revision-head-link position-absolute">
+          #
+        </NextLink>
+
+        {children}
+
+        { showEditButton && (
+          <EditLink line={node.position?.start.line} />
+        ) }
+      </CustomTag>
+    </>
   );
 };

+ 12 - 10
apps/app/src/components/SavePageControls.tsx

@@ -35,30 +35,32 @@ declare global {
 const logger = loggerFactory('growi:SavePageControls');
 
 
-const SavePageButton = (props: {slackChannels: string, isDeviceLargerThanMd?: boolean}) => {
+const SavePageButton = (props: {slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean}) => {
 
   const { t } = useTranslation();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
 
-  const { slackChannels, isDeviceLargerThanMd } = props;
+  const { slackChannels, isSlackEnabled, isDeviceLargerThanMd } = props;
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
   const save = useCallback(async(): Promise<void> => {
     // save
-    globalEmitter.emit('saveAndReturnToView', { wip: false, slackChannels });
-  }, [slackChannels]);
+    globalEmitter.emit('saveAndReturnToView', { wip: false, slackChannels, isSlackEnabled });
+  }, [isSlackEnabled, slackChannels]);
 
   const saveAndOverwriteScopesOfDescendants = useCallback(() => {
     // save
-    globalEmitter.emit('saveAndReturnToView', { wip: false, overwriteScopesOfDescendants: true, slackChannels });
-  }, [slackChannels]);
+    globalEmitter.emit('saveAndReturnToView', {
+      wip: false, overwriteScopesOfDescendants: true, slackChannels, isSlackEnabled,
+    });
+  }, [isSlackEnabled, slackChannels]);
 
   const saveAndMakeWip = useCallback(() => {
     // save
-    globalEmitter.emit('saveAndReturnToView', { wip: true, slackChannels });
-  }, [slackChannels]);
+    globalEmitter.emit('saveAndReturnToView', { wip: true, slackChannels, isSlackEnabled });
+  }, [isSlackEnabled, slackChannels]);
 
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
@@ -197,11 +199,11 @@ export const SavePageControls = (): JSX.Element | null => {
               )
             }
 
-            <SavePageButton slackChannels={slackChannels} isDeviceLargerThanMd />
+            <SavePageButton isSlackEnabled={isSlackEnabled} slackChannels={slackChannels} isDeviceLargerThanMd />
           </>
         ) : (
           <>
-            <SavePageButton slackChannels={slackChannels} />
+            <SavePageButton isSlackEnabled={isSlackEnabled} slackChannels={slackChannels} />
             <button
               type="button"
               className="btn btn-outline-neutral-secondary border-0 fs-5 p-0 ms-1 text-muted"

+ 8 - 8
apps/app/src/components/Script/DrawioViewerScript.tsx → apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx

@@ -3,15 +3,19 @@ import { useCallback } from 'react';
 import type { IGraphViewerGlobal } from '@growi/remark-drawio';
 import Head from 'next/head';
 
-import { useRendererConfig } from '~/stores/context';
+import { useViewerMinJsUrl } from './use-viewer-min-js-url';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
   var GraphViewer: IGraphViewerGlobal;
 }
 
-export const DrawioViewerScript = (): JSX.Element => {
-  const { data: rendererConfig } = useRendererConfig();
+type Props = {
+  drawioUri: string;
+}
+
+export const DrawioViewerScript = ({ drawioUri }: Props): JSX.Element => {
+  const viewerMinJsSrc = useViewerMinJsUrl(drawioUri);
 
   const loadedHandler = useCallback(() => {
     // disable useResizeSensor and checkVisibleState
@@ -32,16 +36,12 @@ export const DrawioViewerScript = (): JSX.Element => {
     GraphViewer.processElements();
   }, []);
 
-  if (rendererConfig == null) {
-    return <></>;
-  }
-
   return (
     <Head>
       <script
         type="text/javascript"
         async
-        src={(new URL('/js/viewer.min.js', rendererConfig.drawioUri)).toString()}
+        src={viewerMinJsSrc}
         onLoad={loadedHandler}
       />
     </Head>

+ 1 - 0
apps/app/src/components/Script/DrawioViewerScript/index.ts

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů