Browse Source

Merge branch 'dev/7.0.x' into support/131644-apply-colors-in-login-page

soumaeda 2 years ago
parent
commit
bba01f182d
71 changed files with 1463 additions and 1312 deletions
  1. 1 0
      .devcontainer/Dockerfile
  2. 1 1
      .devcontainer/devcontainer.json
  3. 1 1
      .github/workflows/auto-labeling.yml
  4. 5 2
      .github/workflows/ci-app.yml
  5. 3 0
      .github/workflows/ci-slackbot-proxy.yml
  6. 1 0
      .github/workflows/release-slackbot-proxy.yml
  7. 4 8
      .github/workflows/release.yml
  8. 1 1
      .github/workflows/reusable-app-build-image.yml
  9. 5 3
      .github/workflows/reusable-app-prod.yml
  10. 1 0
      .github/workflows/reusable-app-reg-suit.yml
  11. 25 1
      CHANGELOG.md
  12. 2 2
      README.md
  13. 2 2
      README_JP.md
  14. 1 1
      apps/app/bin/github-actions/update-readme.sh
  15. 1 0
      apps/app/docker/Dockerfile
  16. 3 3
      apps/app/docker/README.md
  17. 1 0
      apps/app/package.json
  18. 0 0
      apps/app/src/components/ItemsTree/Item.module.scss
  19. 1 1
      apps/app/src/components/ItemsTree/ItemNode.ts
  20. 0 0
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  21. 7 9
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  22. 2 2
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  23. 2 0
      apps/app/src/components/ItemsTree/index.ts
  24. 1 0
      apps/app/src/components/Layout/BasicLayout.tsx
  25. 32 55
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  26. 0 4
      apps/app/src/components/Navbar/GrowiSubNavigation.module.scss
  27. 2 22
      apps/app/src/components/Navbar/GrowiSubNavigation.tsx
  28. 1 0
      apps/app/src/components/PageEditor/Editor.tsx
  29. 58 57
      apps/app/src/components/PageEditor/PageEditor.tsx
  30. 65 0
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  31. 30 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  32. 0 0
      apps/app/src/components/PageSideContents/PageSideContents.module.scss
  33. 71 7
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  34. 1 0
      apps/app/src/components/PageSideContents/index.ts
  35. 3 4
      apps/app/src/components/PageTags/PageTags.tsx
  36. 0 0
      apps/app/src/components/PageTags/RenderTagLabels.tsx
  37. 1 1
      apps/app/src/components/PageTags/TagEditModal.tsx
  38. 0 0
      apps/app/src/components/PageTags/TagLabels.module.scss
  39. 1 3
      apps/app/src/components/PageTags/TagsInput.tsx
  40. 2 0
      apps/app/src/components/PageTags/index.ts
  41. 0 569
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  42. 3 3
      apps/app/src/components/Sidebar/PageTree/PageTree.tsx
  43. 5 1
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  44. 170 0
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  45. 177 0
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  46. 2 0
      apps/app/src/components/Sidebar/PageTreeItem/index.ts
  47. 1 0
      apps/app/src/components/Sidebar/SidebarContents.tsx
  48. 18 0
      apps/app/src/components/TreeItem/ItemNode.ts
  49. 72 0
      apps/app/src/components/TreeItem/NewPageCreateButton.tsx
  50. 103 0
      apps/app/src/components/TreeItem/NewPageInput.tsx
  51. 278 0
      apps/app/src/components/TreeItem/SimpleItem.tsx
  52. 43 0
      apps/app/src/components/TreeItem/UseNewPageInput.tsx
  53. 3 0
      apps/app/src/components/TreeItem/index.ts
  54. 1 0
      apps/app/src/interfaces/ui.ts
  55. 36 2
      apps/app/src/stores/modal.tsx
  56. 0 4
      apps/app/src/styles/_editor.scss
  57. 1 0
      apps/slackbot-proxy/docker/Dockerfile
  58. 2 3
      package.json
  59. 1 0
      packages/editor/package.json
  60. 51 3
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  61. 9 3
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx
  62. 8 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx
  63. 3 1
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  64. 14 0
      packages/editor/src/components/playground/Playground.tsx
  65. 15 1
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  66. 24 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-text.ts
  67. 15 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/replace-text.ts
  68. 1 0
      packages/editor/src/services/file-dropzone/index.ts
  69. 27 0
      packages/editor/src/services/file-dropzone/use-file-dropzone.ts
  70. 1 0
      packages/editor/src/services/index.ts
  71. 41 530
      yarn.lock

+ 1 - 0
.devcontainer/Dockerfile

@@ -51,6 +51,7 @@ RUN apt-get update \
 ENV DEBIAN_FRONTEND=dialog
 
 RUN yarn global add turbo
+RUN yarn global add node-gyp
 
 # Uncomment to default to non-root user
 # USER $USER_UID

+ 1 - 1
.devcontainer/devcontainer.json

@@ -34,7 +34,7 @@
   // "shutdownAction": "none",
 
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "yarn global add turbo && yarn install",
+  "postCreateCommand": "yarn global add turbo node-gyp && yarn install",
 
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "node"

+ 1 - 1
.github/workflows/auto-labeling.yml

@@ -37,7 +37,7 @@ jobs:
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
-      - uses: amannn/action-semantic-pull-request@v5.0.2
+      - uses: amannn/action-semantic-pull-request@v5
         with:
           types: |
             feat

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

@@ -61,6 +61,7 @@ jobs:
       - name: Install dependencies
         run: |
           yarn global add turbo
+          yarn global add node-gyp
           yarn --frozen-lockfile
 
       - name: Lint
@@ -95,7 +96,7 @@ jobs:
 
     services:
       mongodb:
-        image: mongo:4.4
+        image: mongo:6.0
         ports:
           - 27017/tcp
 
@@ -132,6 +133,7 @@ jobs:
       - name: Install dependencies
         run: |
           yarn global add turbo
+          yarn global add node-gyp
           yarn --frozen-lockfile
 
       - name: Test
@@ -176,7 +178,7 @@ jobs:
 
     services:
       mongodb:
-        image: mongo:4.4
+        image: mongo:6.0
         ports:
           - 27017/tcp
 
@@ -214,6 +216,7 @@ jobs:
       - name: Install dependencies
         run: |
           yarn global add turbo
+          yarn global add node-gyp
           yarn --frozen-lockfile
 
       - name: turbo run dev:ci

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

@@ -63,6 +63,7 @@ jobs:
     - name: Install dependencies
       run: |
         yarn global add turbo
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Lint
@@ -137,6 +138,7 @@ jobs:
     - name: Install dependencies
       run: |
         yarn global add turbo
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: yarn dev:ci
@@ -220,6 +222,7 @@ jobs:
 
     - name: Install dependencies
       run: |
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Restore dist

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

@@ -109,6 +109,7 @@ jobs:
     - name: Install dependencies
       run: |
         yarn global add turbo
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Bump versions for next RC

+ 4 - 8
.github/workflows/release.yml

@@ -31,6 +31,7 @@ jobs:
     - name: Install dependencies
       run: |
         yarn global add turbo
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Bump versions
@@ -153,7 +154,7 @@ jobs:
 
 
   post-publish:
-    needs: [publish-image, publish-image-ghcr]
+    needs: [create-github-release, publish-image, publish-image-ghcr]
     runs-on: ubuntu-latest
 
     steps:
@@ -176,15 +177,9 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         created_tag: 'v${{ needs.create-github-release.outputs.RELEASED_VERSION }}'
 
-    - name: Check whether workspace is clean
-      run: |
-        STATUS=`git status --porcelain`
-        if [ -z "$STATUS" ]; then exit 0; else exit 1; fi
-
-
 
   create-pr-for-next-rc:
-    needs: [publish-image, publish-image-ghcr]
+    needs: [create-github-release, publish-image, publish-image-ghcr]
     runs-on: ubuntu-latest
 
     steps:
@@ -201,6 +196,7 @@ jobs:
     - name: Install dependencies
       run: |
         yarn global add turbo
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Bump versions for next RC

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -36,7 +36,7 @@ jobs:
     - uses: actions/checkout@v3
 
     - name: Configure AWS Credentials
-      uses: aws-actions/configure-aws-credentials@v2
+      uses: aws-actions/configure-aws-credentials@v4
       with:
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}

+ 5 - 3
.github/workflows/reusable-app-prod.yml

@@ -56,6 +56,7 @@ jobs:
 
     - name: Install dependencies
       run: |
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Restore dist
@@ -126,7 +127,7 @@ jobs:
 
     services:
       mongodb:
-        image: mongo:4.4
+        image: mongo:6.0
         ports:
         - 27017/tcp
       elasticsearch:
@@ -214,7 +215,7 @@ jobs:
 
     services:
       mongodb:
-        image: mongo:4.4
+        image: mongo:6.0
         ports:
         - 27017/tcp
       elasticsearch:
@@ -267,6 +268,7 @@ jobs:
 
     - name: Install dependencies
       run: |
+        yarn global add node-gyp
         yarn --frozen-lockfile
         yarn cypress install
 
@@ -303,7 +305,7 @@ jobs:
         cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
 
     - name: Cypress Run
-      uses: cypress-io/github-action@v5
+      uses: cypress-io/github-action@v6
       with:
         browser: chromium
         working-directory: ./apps/app

+ 1 - 0
.github/workflows/reusable-app-reg-suit.yml

@@ -82,6 +82,7 @@ jobs:
 
     - name: Install dependencies
       run: |
+        yarn global add node-gyp
         yarn --frozen-lockfile
 
     - name: Download screenshots taken by cypress

+ 25 - 1
CHANGELOG.md

@@ -1,9 +1,33 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.2.1](https://github.com/weseek/growi/compare/v6.2.0...v6.2.1) - 2023-10-03
+
+### BREAKING CHANGES
+
+- support: Omit promster (#8105) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Download a markdown file using the page name as the file name (#8061) @soumaeda
+- imprv: Admin customize presentation form (#8083) @meiri-k
+- imprv: i18n for marp settings (#8110) @moekumasaka
+- imprv: i18n "Create /Sidebar page" label (#8085) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Marp is enabled incorrectly problem (#8100) @reiji-h
+- fix: Do not work img tag if use style property 62x (#8092) @jam411
+
+### 🧰 Maintenance
+
+- support: Internationalization USER_REGISTRATION_APPROVAL_REQUEST label for v62x (#8130) @jam411
+- ci(deps): bump get-func-name from 2.0.0 to 2.0.2 (#8119) @dependabot
+- support: Omit promster (#8105) @yuki-takei
+
 ## [v6.2.0](https://github.com/weseek/growi/compare/v6.1.12...v6.2.0) - 2023-09-14
 
 ### 💎 Features

+ 2 - 2
README.md

@@ -83,12 +83,12 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 - npm 6.x
 - yarn
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 4.x
+- MongoDB 4.4 or above
 
 ### Optional Dependencies
 
 - Redis 3.x
-- ElasticSearch 6.x or 7.x (needed when using Full-text search)
+- ElasticSearch 7.x or 8.x (needed when using Full-text search)
   - **CAUTION: Following plugins are required**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 2 - 2
README_JP.md

@@ -82,12 +82,12 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 - npm 6.x
 - yarn
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 4.x
+- MongoDB 4.4 以上
 
 ### オプションの依存関係
 
 - Redis 3.x
-- ElasticSearch 6.x or 7.x (needed when using Full-text search)
+- ElasticSearch 7.x or 8.x (needed when using Full-text search)
   - **注意: 次のプラグインが必要です**
     - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
     - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)

+ 1 - 1
apps/app/bin/github-actions/update-readme.sh

@@ -2,4 +2,4 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`7\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/packages\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`7\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/apps\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md

+ 1 - 0
apps/app/docker/Dockerfile

@@ -34,6 +34,7 @@ COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
 
 # setup (with network-timeout = 1 hour)
 RUN yarn config set network-timeout 3600000
+RUN yarn global add node-gyp
 RUN yarn --frozen-lockfile
 
 # make artifacts

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

@@ -11,8 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
-* [`6.2.0`, `6.2`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.0/apps/app/docker/Dockerfile)
-* [`6.1.0`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.8/apps/app/docker/Dockerfile)
+* [`6.2.1`, `6.2`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.1/apps/app/docker/Dockerfile)
+* [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 
 
 What is GROWI?
@@ -30,7 +30,7 @@ Requirements
 
 ### Optional Dependencies
 
-* ElasticSearch (>= 6.6)
+* ElasticSearch (>= 7.17)
     * Japanese (kuromoji) Analysis plugin
     * ICU Analysis Plugin
 

+ 1 - 0
apps/app/package.json

@@ -242,6 +242,7 @@
     "null-loader": "^4.0.1",
     "plantuml-encoder": "^1.2.5",
     "prettier": "^1.19.1",
+    "pretty-bytes": "^6.1.1",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",

+ 0 - 0
apps/app/src/components/Sidebar/PageTree/Item.module.scss → apps/app/src/components/ItemsTree/Item.module.scss


+ 1 - 1
apps/app/src/components/Sidebar/PageTree/ItemNode.ts → apps/app/src/components/ItemsTree/ItemNode.ts

@@ -1,4 +1,4 @@
-import { IPageForItem } from '../../../interfaces/page';
+import { IPageForItem } from '../../interfaces/page';
 
 export class ItemNode {
 

+ 0 - 0
apps/app/src/components/Sidebar/PageTree/ItemsTree.module.scss → apps/app/src/components/ItemsTree/ItemsTree.module.scss


+ 7 - 9
apps/app/src/components/Sidebar/PageTree/ItemsTree.tsx → apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -25,10 +25,9 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import { ItemNode, SimpleItemProps } from '../TreeItem';
 
-import Item from './Item';
-import { ItemNode } from './ItemNode';
-import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
 import styles from './ItemsTree.module.scss';
 
@@ -93,14 +92,15 @@ type ItemsTreeProps = {
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
+  CustomTreeItem: React.FunctionComponent<SimpleItemProps>
 }
 
 /*
  * ItemsTree
  */
-const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
+export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem,
   } = props;
 
   const { t } = useTranslation();
@@ -272,7 +272,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   if (initialItemNode != null) {
     return (
       <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
-        <Item
+        <CustomTreeItem
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
           itemNode={initialItemNode}
@@ -287,7 +287,5 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     );
   }
 
-  return <PageTreeContentSkeleton />;
+  return <ItemsTreeContentSkeleton />;
 };
-
-export default ItemsTree;

+ 2 - 2
apps/app/src/components/Sidebar/PageTree/PageTreeContentSkeleton.tsx → apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx

@@ -4,7 +4,7 @@ import { Skeleton } from '~/components/Skeleton';
 
 import styles from './ItemsTree.module.scss';
 
-const PageTreeContentSkeleton = (): JSX.Element => {
+const ItemsTreeContentSkeleton = (): JSX.Element => {
 
   return (
     <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`}>
@@ -15,4 +15,4 @@ const PageTreeContentSkeleton = (): JSX.Element => {
   );
 };
 
-export default PageTreeContentSkeleton;
+export default ItemsTreeContentSkeleton;

+ 2 - 0
apps/app/src/components/ItemsTree/index.ts

@@ -0,0 +1,2 @@
+export { ItemNode } from './ItemNode';
+export * from './ItemsTree';

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

@@ -30,6 +30,7 @@ type Props = {
   className?: string
 }
 
+
 export const BasicLayout = ({ children, className }: Props): JSX.Element => {
   return (
     <RawLayout className={className ?? ''}>

+ 32 - 55
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { isPopulated } from '@growi/core';
 import type {
@@ -11,25 +11,22 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
-import { exportAsMarkdown, updateContentWidth, useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { apiPost } from '~/client/util/apiv1-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentPathname,
   useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId, useIsContainerFluid, useIsIdenticalPath,
 } from '~/stores/context';
-import { usePageTagsForEditors } from '~/stores/editor';
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
 } from '~/stores/modal';
 import {
-  useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData, useSWRxPageInfo,
+  useSWRMUTxCurrentPage, useCurrentPageId, useIsNotFound, useSWRxPageInfo,
 } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
 import {
-  EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
+  EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 
@@ -217,38 +214,39 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isContainerFluid } = useIsContainerFluid();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
-  const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
-
+  // TODO: implement tags for editor
+  // refs: https://redmine.weseek.co.jp/issues/132125
   // eslint-disable-next-line max-len
-  const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
+  // const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
+  // const { data: templateTagData } = useTemplateTagData();
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { data: templateTagData } = useTemplateTagData();
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
-  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
-
   const path = currentPage?.path ?? currentPathname;
 
-  useEffect(() => {
-    // Run only when tagsInfoData has been updated
-    if (templateTagData == null) {
-      syncPageTagsForEditors();
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [tagsInfoData?.tags]);
-
-  useEffect(() => {
-    if (pageId === null && templateTagData != null) {
-      mutatePageTagsForEditors(templateTagData);
-    }
-  }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
+  // TODO: implement tags for editor
+  // refs: https://redmine.weseek.co.jp/issues/132125
+  // useEffect(() => {
+  //   // Run only when tagsInfoData has been updated
+  //   if (templateTagData == null) {
+  //     syncPageTagsForEditors();
+  //   }
+  //   // eslint-disable-next-line react-hooks/exhaustive-deps
+  // }, [tagsInfoData?.tags]);
+
+  // TODO: implement tags for editor
+  // refs: https://redmine.weseek.co.jp/issues/132125
+  // useEffect(() => {
+  //   if (pageId === null && templateTagData != null) {
+  //     mutatePageTagsForEditors(templateTagData);
+  //   }
+  // }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
@@ -257,30 +255,13 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const isViewMode = editorMode === EditorMode.View;
 
 
-  const tagsUpdatedHandlerForViewMode = useCallback(async(newTags: string[]) => {
-    if (currentPage == null) {
-      return;
-    }
-
-    const { _id: pageId, revision: revisionId } = currentPage;
-    try {
-      await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
-
-      updateStateAfterSave?.();
-
-      toastSuccess('updated tags successfully');
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }, [currentPage, updateStateAfterSave]);
-
-  const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
-    // It will not be reflected in the DB until the page is refreshed
-    mutatePageTagsForEditors(newTags);
-    return;
-  }, [mutatePageTagsForEditors]);
+  // TODO: implement tags for editor
+  // refs: https://redmine.weseek.co.jp/issues/132125
+  // const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
+  //   // It will not be reflected in the DB until the page is refreshed
+  //   mutatePageTagsForEditors(newTags);
+  //   return;
+  // }, [mutatePageTagsForEditors]);
 
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
@@ -440,12 +421,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       pagePath={pagePath}
       pageId={currentPage?._id}
       showDrawerToggler={isDrawerMode}
-      showTagLabel={isAbleToShowTagLabel}
-      isTagLabelsDisabled={!!isGuestUser || !!isReadOnlyUser}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
-      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
       rightComponent={RightComponent}
       additionalClasses={['container-fluid']}
     />

+ 0 - 4
apps/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -33,10 +33,6 @@
       line-height: 1.4em;
     }
 
-    .grw-taglabels-container {
-      margin-bottom: 0.5rem;
-    }
-
     .grw-page-path-nav {
       .separator {
         margin-right: 0.2em;

+ 2 - 22
apps/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -1,12 +1,9 @@
 import React from 'react';
 
-import dynamic from 'next/dynamic';
-
 import {
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 
-import { TagLabelsSkeleton } from '../Page/TagLabels';
 import PagePathNav from '../PagePathNav';
 
 import DrawerToggler from './DrawerToggler';
@@ -15,23 +12,15 @@ import DrawerToggler from './DrawerToggler';
 import styles from './GrowiSubNavigation.module.scss';
 
 
-const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
-  ssr: false,
-  loading: TagLabelsSkeleton,
-});
-
-
 export type GrowiSubNavigationProps = {
   pagePath?: string,
   pageId?: string,
   isNotFound?: boolean,
   showDrawerToggler?: boolean,
-  showTagLabel?: boolean,
   isTagLabelsDisabled?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
   tags?: string[],
-  tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
   rightComponent?: React.FunctionComponent,
   additionalClasses?: string[],
 }
@@ -42,9 +31,8 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
 
   const {
     pageId, pagePath,
-    showDrawerToggler, showTagLabel,
-    isTagLabelsDisabled, isDrawerMode, isCompactMode,
-    tags, tagsUpdatedHandler,
+    showDrawerToggler,
+    isDrawerMode, isCompactMode,
     rightComponent: RightComponent,
     additionalClasses = [],
   } = props;
@@ -67,14 +55,6 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
           </div>
         ) }
         <div className="grw-path-nav-container">
-          { (showTagLabel && !isCompactMode) && (
-            <div className="grw-taglabels-container">
-              { tags != null
-                ? <TagLabels tags={tags} isTagLabelsDisabled={isTagLabelsDisabled ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
-                : <TagLabelsSkeleton />
-              }
-            </div>
-          ) }
           { pagePath != null && (
             <PagePathNav pageId={pageId} pagePath={pagePath} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
           ) }

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

@@ -24,6 +24,7 @@ import { Cheatsheet } from './Cheatsheet';
 import pasteHelper from './PasteHelper';
 import TextAreaEditor from './TextAreaEditor';
 
+
 import styles from './Editor.module.scss';
 
 export type EditorPropsType = {

+ 58 - 57
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -294,67 +294,67 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [isNotFound, mutateEditorMode, router, save, t, updateStateAfterSave]);
 
 
-  /**
-   * the upload event handler
-   * @param {any} file
-   */
-  const uploadHandler = useCallback(async(file) => {
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      let res: any = await apiGet('/attachments.limit', {
-        fileSize: file.size,
-      });
-
-      if (!res.isUploadable) {
-        throw new Error(res.errorMessage);
+  // the upload event handler
+  const uploadHandler = useCallback((files: File[]) => {
+    files.forEach(async(file) => {
+      try {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        const resLimit: any = await apiGet('/attachments.limit', {
+          fileSize: file.size,
+        });
+
+        if (!resLimit.isUploadable) {
+          throw new Error(resLimit.errorMessage);
+        }
+
+        const formData = new FormData();
+        formData.append('file', file);
+        if (currentPagePath != null) {
+          formData.append('path', currentPagePath);
+        }
+        if (pageId != null) {
+          formData.append('page_id', pageId);
+        }
+        if (pageId == null) {
+          formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
+        }
+
+        const resAdd: any = await apiPostForm('/attachments.add', formData);
+        const attachment = resAdd.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${attachment.filePathProxied})\n`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+        // TODO: implement
+        // refs: https://redmine.weseek.co.jp/issues/126528
+        // editorRef.current.insertText(insertText);
+        codeMirrorEditor?.insertText(insertText);
+
+        // when if created newly
+        // Not using 'mutateGrant' to inherit the grant of the parent page
+        if (resAdd.pageCreated) {
+          logger.info('Page is created', resAdd.page._id);
+          mutateIsLatestRevision(true);
+          setCreatedPageRevisionIdWithAttachment(resAdd.page.revision);
+          await mutateCurrentPageId(resAdd.page._id);
+          await mutateCurrentPage();
+        }
       }
-
-      const formData = new FormData();
-      // const { pageId, path } = pageContainer.state;
-      formData.append('file', file);
-      if (currentPagePath != null) {
-        formData.append('path', currentPagePath);
-      }
-      if (pageId != null) {
-        formData.append('page_id', pageId);
+      catch (e) {
+        logger.error('failed to upload', e);
+        toastError(e);
       }
-      if (pageId == null) {
-        formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
+      finally {
+        // TODO: implement
+        // refs: https://redmine.weseek.co.jp/issues/126528
+        // editorRef.current.terminateUploadingState();
       }
+    });
 
-      res = await apiPostForm('/attachments.add', formData);
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
-      }
-      // TODO: implement
-      // refs: https://redmine.weseek.co.jp/issues/126528
-      // editorRef.current.insertText(insertText);
-
-      // when if created newly
-      // Not using 'mutateGrant' to inherit the grant of the parent page
-      if (res.pageCreated) {
-        logger.info('Page is created', res.page._id);
-        mutateIsLatestRevision(true);
-        setCreatedPageRevisionIdWithAttachment(res.page.revision);
-        await mutateCurrentPageId(res.page._id);
-        await mutateCurrentPage();
-      }
-    }
-    catch (e) {
-      logger.error('failed to upload', e);
-      toastError(e);
-    }
-    finally {
-      // TODO: implement
-      // refs: https://redmine.weseek.co.jp/issues/126528
-      // editorRef.current.terminateUploadingState();
-    }
   }, [codeMirrorEditor, currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
 
@@ -574,6 +574,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         <CodeMirrorEditorMain
           onChange={markdownChangedHandler}
           onSave={saveWithShortcut}
+          onUpload={uploadHandler}
           indentSize={currentIndentSize ?? defaultIndentSize}
         />
       </div>

+ 65 - 0
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -0,0 +1,65 @@
+import React from 'react';
+
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter, Button,
+} from 'reactstrap';
+
+import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { usePageSelectModal } from '~/stores/modal';
+import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
+
+import { ItemsTree } from '../ItemsTree';
+
+import { TreeItemForModal } from './TreeItemForModal';
+
+
+export const PageSelectModal = () => {
+  const {
+    data: PageSelectModalData,
+    close: closeModal,
+  } = usePageSelectModal();
+
+  const isOpened = PageSelectModalData?.isOpened ?? false;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: targetId } = useCurrentPageId();
+  const { data: targetAndAncestorsData } = useTargetAndAncestors();
+
+  const targetPathOrId = targetId || currentPath;
+
+  if (isGuestUser == null) {
+    return null;
+  }
+
+  const path = currentPath || '/';
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={() => closeModal()}
+      centered
+    >
+      <ModalHeader toggle={() => closeModal()}>modal</ModalHeader>
+      <ModalBody>
+        <ItemsTree
+          CustomTreeItem={TreeItemForModal}
+          isEnableActions={!isGuestUser}
+          isReadOnlyUser={!!isReadOnlyUser}
+          targetPath={path}
+          targetPathOrId={targetPathOrId}
+          targetAndAncestorsData={targetAndAncestorsData}
+        />
+      </ModalBody>
+      <ModalFooter>
+        <Button color="primary">
+          Do Something
+        </Button>{' '}
+        <Button color="secondary">
+          Cancel
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 30 - 0
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -0,0 +1,30 @@
+import React, { FC } from 'react';
+
+import {
+  SimpleItem, SimpleItemProps, SimpleItemTool, useNewPageInput,
+} from '../TreeItem';
+
+type Optional = 'itemRef' | 'itemClass' | 'mainClassName';
+type PageTreeItemProps = Omit<SimpleItemProps, Optional> & {key};
+
+export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
+
+  const { NewPageInputWrapper, NewPageCreateButtonWrapper } = useNewPageInput();
+
+  return (
+    <SimpleItem
+      key={props.key}
+      targetPathOrId={props.targetPathOrId}
+      itemNode={props.itemNode}
+      isOpen
+      isEnableActions={props.isEnableActions}
+      isReadOnlyUser={props.isReadOnlyUser}
+      onRenamed={props.onRenamed}
+      onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
+      onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      customNextComponents={[NewPageInputWrapper]}
+      itemClass={TreeItemForModal}
+      customEndComponents={[SimpleItemTool, NewPageCreateButtonWrapper]}
+    />
+  );
+};

+ 0 - 0
apps/app/src/components/PageSideContents.module.scss → apps/app/src/components/PageSideContents/PageSideContents.module.scss


+ 71 - 7
apps/app/src/components/PageSideContents.tsx → apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,17 +1,24 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
-import type { IPageHasId, IPageInfoForOperation } from '@growi/core';
+import { getIdForRef, type IPageHasId, type IPageInfoForOperation } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { apiPost } from '~/client/util/apiv1-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
-import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
+import { useIsAbleToShowTagLabel } from '~/stores/ui';
 
-import CountBadge from './Common/CountBadge';
-import { ContentLinkButtons } from './ContentLinkButtons';
-import PageListIcon from './Icons/PageListIcon';
-import TableOfContents from './TableOfContents';
+import CountBadge from '../Common/CountBadge';
+import { ContentLinkButtons } from '../ContentLinkButtons';
+import PageListIcon from '../Icons/PageListIcon';
+import { PageTagsSkeleton } from '../PageTags';
+import TableOfContents from '../TableOfContents';
 
 import styles from './PageSideContents.module.scss';
 
@@ -19,6 +26,59 @@ import styles from './PageSideContents.module.scss';
 const { isTopPage, isUsersHomepage, isTrashPage } = pagePathUtils;
 
 
+const PageTags = dynamic(() => import('../PageTags').then(mod => mod.PageTags), {
+  ssr: false,
+  loading: PageTagsSkeleton,
+});
+
+
+type TagsProps = {
+  pageId: string,
+  revisionId: string,
+}
+
+const Tags = (props: TagsProps): JSX.Element => {
+  const { pageId, revisionId } = props;
+
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+
+  const { data: showTagLabel } = useIsAbleToShowTagLabel();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId);
+
+  const tagsUpdatedHandler = useCallback(async(newTags: string[]) => {
+    try {
+      await apiPost('/tags.update', { pageId, revisionId, tags: newTags });
+
+      updateStateAfterSave?.();
+
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [pageId, revisionId, updateStateAfterSave]);
+
+  if (!showTagLabel) {
+    return <></>;
+  }
+
+  const isTagLabelsDisabled = !!isGuestUser || !!isReadOnlyUser;
+
+  return (
+    <div className="grw-taglabels-container">
+      { tagsInfoData?.tags != null
+        ? <PageTags tags={tagsInfoData.tags} isTagLabelsDisabled={isTagLabelsDisabled ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+        : <PageTagsSkeleton />
+      }
+    </div>
+  );
+};
+
+
 export type PageSideContentsProps = {
   page: IPageHasId,
   isSharedUser?: boolean,
@@ -38,8 +98,12 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const isUsersHomepagePath = isUsersHomepage(pagePath);
   const isTrash = isTrashPage(pagePath);
 
+
   return (
     <>
+      {/* Tags */}
+      <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+
       {/* Page list */}
       <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
         {!isSharedUser && (

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

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

+ 3 - 4
apps/app/src/components/Page/TagLabels.tsx → apps/app/src/components/PageTags/PageTags.tsx

@@ -13,11 +13,11 @@ type Props = {
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
 }
 
-export const TagLabelsSkeleton = (): JSX.Element => {
+export const PageTagsSkeleton = (): JSX.Element => {
   return <Skeleton additionalClass={`${styles['grw-tag-labels-skeleton']} py-1`} />;
 };
 
-export const TagLabels:FC<Props> = (props: Props) => {
+export const PageTags:FC<Props> = (props: Props) => {
   const { tags, isTagLabelsDisabled, tagsUpdateInvoked } = props;
 
   const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
@@ -31,13 +31,12 @@ export const TagLabels:FC<Props> = (props: Props) => {
   };
 
   if (tags == null) {
-    return <TagLabelsSkeleton />;
+    return <PageTagsSkeleton />;
   }
 
   return (
     <>
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
-        <i className="tag-icon icon-tag me-2" />
         <RenderTagLabels
           tags={tags}
           openEditorModal={openEditorModal}

+ 0 - 0
apps/app/src/components/Page/RenderTagLabels.tsx → apps/app/src/components/PageTags/RenderTagLabels.tsx


+ 1 - 1
apps/app/src/components/Page/TagEditModal.tsx → apps/app/src/components/PageTags/TagEditModal.tsx

@@ -5,7 +5,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import TagsInput from './TagsInput';
+import { TagsInput } from './TagsInput';
 
 type Props = {
   tags: string[],

+ 0 - 0
apps/app/src/components/Page/TagLabels.module.scss → apps/app/src/components/PageTags/TagLabels.module.scss


+ 1 - 3
apps/app/src/components/Page/TagsInput.tsx → apps/app/src/components/PageTags/TagsInput.tsx

@@ -20,7 +20,7 @@ type Props = {
   onTagsUpdated: (tags: string[]) => void,
 }
 
-const TagsInput: FC<Props> = (props: Props) => {
+export const TagsInput: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const tagsInputRef = useRef<TypeaheadInstance>(null);
 
@@ -79,5 +79,3 @@ const TagsInput: FC<Props> = (props: Props) => {
     </div>
   );
 };
-
-export default TagsInput;

+ 2 - 0
apps/app/src/components/PageTags/index.ts

@@ -0,0 +1,2 @@
+export * from './PageTags';
+export * from './TagsInput';

+ 0 - 569
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -1,569 +0,0 @@
-import React, {
-  useCallback, useState, FC, useEffect, ReactNode,
-} from 'react';
-
-import nodePath from 'path';
-
-import type {
-  Nullable,
-  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
-} from '@growi/core';
-import {
-  pathUtils, pagePathUtils,
-} from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-import { useDrag, useDrop } from 'react-dnd';
-import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
-
-import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
-import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
-import { ValidationTarget } from '~/client/util/input-validator';
-import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnlyUser';
-import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
-import { IPageForPageDuplicateModal } from '~/stores/modal';
-import { useSWRMUTxPageInfo } from '~/stores/page';
-import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-import { shouldRecoverPagePaths } from '~/utils/page-operation';
-
-import ClosableTextInput from '../../Common/ClosableTextInput';
-import CountBadge from '../../Common/CountBadge';
-import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-
-import { ItemNode } from './ItemNode';
-
-
-import styles from './Item.module.scss';
-
-
-const logger = loggerFactory('growi:cli:Item');
-
-
-interface ItemProps {
-  isEnableActions: boolean
-  isReadOnlyUser: boolean
-  itemNode: ItemNode
-  targetPathOrId?: Nullable<string>
-  isOpen?: boolean
-  onRenamed?(fromPath: string | undefined, toPath: string): void
-  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
-  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
-}
-
-// Utility to mark target
-const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
-  if (targetPathOrId == null) {
-    return;
-  }
-
-  children.forEach((node) => {
-    if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
-      node.page.isTarget = true;
-    }
-    else {
-      node.page.isTarget = false;
-    }
-    return node;
-  });
-};
-
-/**
- * Return new page path after the droppedPagePath is moved under the newParentPagePath
- * @param droppedPagePath
- * @param newParentPagePath
- * @returns
- */
-const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
-  const pageTitle = nodePath.basename(droppedPagePath);
-  return nodePath.join(newParentPagePath, pageTitle);
-};
-
-/**
- * Return whether the fromPage could be moved under the newParentPage
- * @param fromPage
- * @param newParentPage
- * @param printLog
- * @returns
- */
-const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
-  if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
-    if (printLog) {
-      logger.warn('Any of page, page.path or droppedPage.path is null');
-    }
-    return false;
-  }
-
-  const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
-  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
-};
-
-// Component wrapper to make a child element not draggable
-// https://github.com/react-dnd/react-dnd/issues/335
-type NotDraggableProps = {
-  children: ReactNode,
-};
-const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
-  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
-};
-
-
-const Item: FC<ItemProps> = (props: ItemProps) => {
-  const { t } = useTranslation();
-  const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
-  } = props;
-
-  const { page, children } = itemNode;
-
-  const [currentChildren, setCurrentChildren] = useState(children);
-  const [isOpen, setIsOpen] = useState(_isOpen);
-  const [isNewPageInputShown, setNewPageInputShown] = useState(false);
-  const [shouldHide, setShouldHide] = useState(false);
-  const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const [isCreating, setCreating] = useState(false);
-
-  const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
-
-  // descendantCount
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-
-  // hasDescendants flag
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  // to re-show hidden item when useDrag end() callback
-  const displayDroppedItemByPageId = useCallback((pageId) => {
-    const target = document.getElementById(`pagetree-item-${pageId}`);
-    if (target == null) {
-      return;
-    }
-
-    // wait 500ms to avoid removing before d-none is set by useDrag end() callback
-    setTimeout(() => {
-      target.classList.remove('d-none');
-    }, 500);
-  }, []);
-
-  const [, drag] = useDrag({
-    type: 'PAGE_TREE',
-    item: { page },
-    canDrag: () => {
-      if (page.path == null) {
-        return false;
-      }
-      return !pagePathUtils.isUsersProtectedPages(page.path);
-    },
-    end: (item, monitor) => {
-      // in order to set d-none to dropped Item
-      const dropResult = monitor.getDropResult();
-      if (dropResult != null) {
-        setShouldHide(true);
-      }
-    },
-    collect: monitor => ({
-      isDragging: monitor.isDragging(),
-      canDrag: monitor.canDrag(),
-    }),
-  });
-
-  const pageItemDropHandler = async(item: ItemNode) => {
-    const { page: droppedPage } = item;
-
-    if (!isDroppable(droppedPage, page, true)) {
-      return;
-    }
-
-    if (droppedPage.path == null || page.path == null) {
-      return;
-    }
-
-    const newPagePath = getNewPathAfterMoved(droppedPage.path, page.path);
-
-    try {
-      await apiv3Put('/pages/rename', {
-        pageId: droppedPage._id,
-        revisionId: droppedPage.revision,
-        newPagePath,
-        isRenameRedirect: false,
-        updateMetadata: true,
-      });
-
-      await mutatePageTree();
-      await mutateChildren();
-
-      if (onRenamed != null) {
-        onRenamed(page.path, newPagePath);
-      }
-
-      // force open
-      setIsOpen(true);
-    }
-    catch (err) {
-      // display the dropped item
-      displayDroppedItemByPageId(droppedPage._id);
-
-      if (err.code === 'operation__blocked') {
-        toastWarning(t('pagetree.you_cannot_move_this_page_now'));
-      }
-      else {
-        toastError(t('pagetree.something_went_wrong_with_moving_page'));
-      }
-    }
-  };
-
-  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(
-    () => ({
-      accept: 'PAGE_TREE',
-      drop: pageItemDropHandler,
-      hover: (item, monitor) => {
-        // when a drag item is overlapped more than 1 sec, the drop target item will be opened.
-        if (monitor.isOver()) {
-          setTimeout(() => {
-            if (monitor.isOver()) {
-              setIsOpen(true);
-            }
-          }, 600);
-        }
-      },
-      canDrop: (item) => {
-        const { page: droppedPage } = item;
-        return isDroppable(droppedPage, page);
-      },
-      collect: monitor => ({
-        isOver: monitor.isOver(),
-      }),
-    }),
-    [page],
-  );
-
-
-  const hasChildren = useCallback((): boolean => {
-    return currentChildren != null && currentChildren.length > 0;
-  }, [currentChildren]);
-
-  const onClickLoadChildren = useCallback(async() => {
-    setIsOpen(!isOpen);
-  }, [isOpen]);
-
-  const onClickPlusButton = useCallback(() => {
-    setNewPageInputShown(true);
-
-    if (hasDescendants) {
-      setIsOpen(true);
-    }
-  }, [hasDescendants]);
-
-  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
-    const bookmarkOperation = _newValue ? bookmark : unbookmark;
-    await bookmarkOperation(_pageId);
-    mutateCurrentUserBookmarks();
-    mutatePageInfo();
-  };
-
-  const duplicateMenuItemClickHandler = useCallback((): void => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-
-    const { _id: pageId, path } = page;
-
-    if (pageId == null || path == null) {
-      throw Error('Any of _id and path must not be null.');
-    }
-
-    const pageToDuplicate = { pageId, path };
-
-    onClickDuplicateMenuItem(pageToDuplicate);
-  }, [onClickDuplicateMenuItem, page]);
-
-  const renameMenuItemClickHandler = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
-
-  const onPressEnterForRenameHandler = async(inputText: string) => {
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText);
-
-    if (newPagePath === page.path) {
-      setRenameInputShown(false);
-      return;
-    }
-
-    try {
-      setRenameInputShown(false);
-      await apiv3Put('/pages/rename', {
-        pageId: page._id,
-        revisionId: page.revision,
-        newPagePath,
-      });
-
-      if (onRenamed != null) {
-        onRenamed(page.path, newPagePath);
-      }
-
-      toastSuccess(t('renamed_pages', { path: page.path }));
-    }
-    catch (err) {
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  };
-
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
-    if (onClickDeleteMenuItem == null) {
-      return;
-    }
-
-    if (page._id == null || page.path == null) {
-      throw Error('_id and path must not be null.');
-    }
-
-    const pageToDelete: IPageToDeleteWithMeta = {
-      data: {
-        _id: page._id,
-        revision: page.revision as string,
-        path: page.path,
-      },
-      meta: pageInfo,
-    };
-
-    onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, page]);
-
-  const onPressEnterForCreateHandler = async(inputText: string) => {
-    setNewPageInputShown(false);
-    const parentPath = pathUtils.addTrailingSlash(page.path as string);
-    const newPagePath = nodePath.resolve(parentPath, inputText);
-    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
-
-    if (!isCreatable) {
-      toastWarning(t('you_can_not_create_page_with_this_name'));
-      return;
-    }
-
-    try {
-      setCreating(true);
-
-      await apiv3Post('/pages/', {
-        path: newPagePath,
-        body: undefined,
-        grant: page.grant,
-        grantUserGroupId: page.grantedGroup,
-      });
-
-      mutateChildren();
-
-      if (!hasDescendants) {
-        setIsOpen(true);
-      }
-
-      toastSuccess(t('successfully_saved_the_page'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      setCreating(false);
-    }
-  };
-
-
-  /**
-   * Users do not need to know if all pages have been renamed.
-   * Make resuming rename operation appears to be working fine to allow users for a seamless operation.
-   */
-  const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
-    try {
-      await resumeRenameOperation(pageId);
-      toastSuccess(t('page_operation.paths_recovered'));
-    }
-    catch {
-      toastError(t('page_operation.path_recovery_failed'));
-    }
-  };
-
-  // didMount
-  useEffect(() => {
-    if (hasChildren()) setIsOpen(true);
-  }, [hasChildren]);
-
-  /*
-   * Make sure itemNode.children and currentChildren are synced
-   */
-  useEffect(() => {
-    if (children.length > currentChildren.length) {
-      markTarget(children, targetPathOrId);
-      setCurrentChildren(children);
-    }
-  }, [children, currentChildren.length, targetPathOrId]);
-
-  /*
-   * When swr fetch succeeded
-   */
-  useEffect(() => {
-    if (isOpen && data != null) {
-      const newChildren = ItemNode.generateNodesFromPages(data.children);
-      markTarget(newChildren, targetPathOrId);
-      setCurrentChildren(newChildren);
-    }
-  }, [data, isOpen, targetPathOrId]);
-
-  // Rename process
-  // Icon that draw attention from users for some actions
-  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
-
-  return (
-    <div
-      id={`pagetree-item-${page._id}`}
-      data-testid="grw-pagetree-item-container"
-      className={`grw-pagetree-item-container ${isOver ? 'grw-pagetree-is-over' : ''}
-        pagetree-item ${styles['pagetree-item']}
-        ${shouldHide ? 'd-none' : ''}`
-      }
-    >
-      <li
-        ref={(c) => { drag(c); drop(c) }}
-        className={`list-group-item list-group-item-action border-0 py-0 pe-3 d-flex align-items-center
-        ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
-        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
-      >
-        <div className="grw-triangle-container d-flex justify-content-center">
-          {hasDescendants && (
-            <button
-              type="button"
-              className={`grw-pagetree-triangle-btn btn ${isOpen ? 'grw-pagetree-open' : ''}`}
-              onClick={onClickLoadChildren}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-icons-round">arrow_right</span>
-              </div>
-            </button>
-          )}
-        </div>
-        {isRenameInputShown
-          ? (
-            <div className="flex-fill">
-              <NotDraggableForClosableTextInput>
-                <ClosableTextInput
-                  value={nodePath.basename(page.path ?? '')}
-                  placeholder={t('Input page name')}
-                  onClickOutside={() => { setRenameInputShown(false) }}
-                  onPressEnter={onPressEnterForRenameHandler}
-                  validationTarget={ValidationTarget.PAGE}
-                />
-              </NotDraggableForClosableTextInput>
-            </div>
-          )
-          : (
-            <>
-              {shouldShowAttentionIcon && (
-                <>
-                  <i id="path-recovery" className="fa fa-warning me-2 text-warning"></i>
-                  <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
-                    {t('tooltip.operation.attention.rename')}
-                  </UncontrolledTooltip>
-                </>
-              )}
-              {page != null && page.path != null && page._id != null && (
-                <Link
-                  href={pathUtils.returnPathForURL(page.path, page._id)}
-                  className="grw-pagetree-title-anchor flex-grow-1"
-                  prefetch={false}
-                >
-                  <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-                </Link>
-              )}
-            </>
-          )}
-        {descendantCount > 0 && !isRenameInputShown && (
-          <div className="grw-pagetree-count-wrapper">
-            <CountBadge count={descendantCount} />
-          </div>
-        )}
-        <NotAvailableForGuest>
-          <div className="grw-pagetree-control d-flex">
-            <PageItemControl
-              pageId={page._id}
-              isEnableActions={isEnableActions}
-              isReadOnlyUser={isReadOnlyUser}
-              onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-              onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-              onClickRenameMenuItem={renameMenuItemClickHandler}
-              onClickDeleteMenuItem={deleteMenuItemClickHandler}
-              onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
-              isInstantRename
-              // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-              operationProcessData={page.processData}
-            >
-              {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
-              <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
-                <i id="option-button-in-page-tree" className="icon-options fa fa-rotate-90 p-1"></i>
-              </DropdownToggle>
-            </PageItemControl>
-          </div>
-        </NotAvailableForGuest>
-
-        {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
-          <NotAvailableForGuest>
-            <NotAvailableForReadOnlyUser>
-              <button
-                id="page-create-button-in-page-tree"
-                type="button"
-                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-                onClick={onClickPlusButton}
-              >
-                <i className="icon-plus d-block p-0" />
-              </button>
-            </NotAvailableForReadOnlyUser>
-          </NotAvailableForGuest>
-        )}
-      </li>
-
-      {isEnableActions && isNewPageInputShown && (
-        <div className="flex-fill">
-          <NotDraggableForClosableTextInput>
-            <ClosableTextInput
-              placeholder={t('Input page name')}
-              onClickOutside={() => { setNewPageInputShown(false) }}
-              onPressEnter={onPressEnterForCreateHandler}
-              validationTarget={ValidationTarget.PAGE}
-            />
-          </NotDraggableForClosableTextInput>
-        </div>
-      )}
-      {
-        isOpen && hasChildren() && currentChildren.map((node, index) => (
-          <div key={node.page._id} className="grw-pagetree-item-children">
-            <Item
-              isEnableActions={isEnableActions}
-              isReadOnlyUser={isReadOnlyUser}
-              itemNode={node}
-              isOpen={false}
-              targetPathOrId={targetPathOrId}
-              onRenamed={onRenamed}
-              onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-              onClickDeleteMenuItem={onClickDeleteMenuItem}
-            />
-            {isCreating && (currentChildren.length - 1 === index) && (
-              <div className="text-muted text-center">
-                <i className="fa fa-spinner fa-pulse me-1"></i>
-              </div>
-            )}
-          </div>
-        ))
-      }
-    </div>
-  );
-
-};
-
-export default Item;

+ 3 - 3
apps/app/src/components/Sidebar/PageTree/PageTree.tsx

@@ -3,12 +3,12 @@ import { Suspense } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
-import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
 import { PageTreeHeader } from './PageTreeSubstance';
 
 const PageTreeContent = dynamic(
   () => import('./PageTreeSubstance').then(mod => mod.PageTreeContent),
-  { ssr: false, loading: PageTreeContentSkeleton },
+  { ssr: false, loading: ItemsTreeContentSkeleton },
 );
 
 
@@ -24,7 +24,7 @@ export const PageTree = (): JSX.Element => {
         </Suspense>
       </div>
 
-      <Suspense fallback={<PageTreeContentSkeleton />}>
+      <Suspense fallback={<ItemsTreeContentSkeleton />}>
         <PageTreeContent />
       </Suspense>
     </div>

+ 5 - 1
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -6,9 +6,10 @@ import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stor
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { mutatePageTree, useSWRxRootPage, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
+import { ItemsTree } from '../../ItemsTree/ItemsTree';
+import { PageTreeItem } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
-import ItemsTree from './ItemsTree';
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
 
@@ -44,6 +45,8 @@ const PageTreeUnavailable = () => {
 };
 
 export const PageTreeContent = memo(() => {
+  const { t } = useTranslation();
+
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
@@ -75,6 +78,7 @@ export const PageTreeContent = memo(() => {
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetAndAncestorsData={targetAndAncestorsData}
+        CustomTreeItem={PageTreeItem}
       />
 
       {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (

+ 170 - 0
apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx

@@ -0,0 +1,170 @@
+import React, {
+  useCallback, useState, FC,
+} from 'react';
+
+import nodePath from 'path';
+
+
+import type { IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { DropdownToggle } from 'reactstrap';
+
+import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { ValidationTarget } from '~/client/util/input-validator';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import { IPageForItem } from '~/interfaces/page';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxPageInfo } from '~/stores/page';
+
+import ClosableTextInput from '../../Common/ClosableTextInput';
+import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+import {
+  SimpleItemToolProps, NotDraggableForClosableTextInput, SimpleItemTool,
+} from '../../TreeItem';
+
+type EllipsisProps = SimpleItemToolProps & {page: IPageForItem};
+
+export const Ellipsis: FC<EllipsisProps> = (props) => {
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const { t } = useTranslation();
+
+  const {
+    page, onRenamed, onClickDuplicateMenuItem,
+    onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+  } = props;
+
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
+
+  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
+    const bookmarkOperation = _newValue ? bookmark : unbookmark;
+    await bookmarkOperation(_pageId);
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
+  };
+
+  const duplicateMenuItemClickHandler = useCallback((): void => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+
+    const { _id: pageId, path } = page;
+
+    if (pageId == null || path == null) {
+      throw Error('Any of _id and path must not be null.');
+    }
+
+    const pageToDuplicate = { pageId, path };
+
+    onClickDuplicateMenuItem(pageToDuplicate);
+  }, [onClickDuplicateMenuItem, page]);
+
+  const renameMenuItemClickHandler = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const onPressEnterForRenameHandler = async(inputText: string) => {
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
+    const newPagePath = nodePath.resolve(parentPath, inputText);
+
+    if (newPagePath === page.path) {
+      setRenameInputShown(false);
+      return;
+    }
+
+    try {
+      setRenameInputShown(false);
+      await apiv3Put('/pages/rename', {
+        pageId: page._id,
+        revisionId: page.revision,
+        newPagePath,
+      });
+
+      if (onRenamed != null) {
+        onRenamed(page.path, newPagePath);
+      }
+
+      toastSuccess(t('renamed_pages', { path: page.path }));
+    }
+    catch (err) {
+      setRenameInputShown(true);
+      toastError(err);
+    }
+  };
+
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+    if (onClickDeleteMenuItem == null) {
+      return;
+    }
+
+    if (page._id == null || page.path == null) {
+      throw Error('_id and path must not be null.');
+    }
+
+    const pageToDelete: IPageToDeleteWithMeta = {
+      data: {
+        _id: page._id,
+        revision: page.revision as string,
+        path: page.path,
+      },
+      meta: pageInfo,
+    };
+
+    onClickDeleteMenuItem(pageToDelete);
+  }, [onClickDeleteMenuItem, page]);
+
+  const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
+    try {
+      await resumeRenameOperation(pageId);
+      toastSuccess(t('page_operation.paths_recovered'));
+    }
+    catch {
+      toastError(t('page_operation.path_recovery_failed'));
+    }
+  };
+
+  return (
+    <>
+      {isRenameInputShown ? (
+        <div className="flex-fill">
+          <NotDraggableForClosableTextInput>
+            <ClosableTextInput
+              value={nodePath.basename(page.path ?? '')}
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setRenameInputShown(false) }}
+              onPressEnter={onPressEnterForRenameHandler}
+              validationTarget={ValidationTarget.PAGE}
+            />
+          </NotDraggableForClosableTextInput>
+        </div>
+      ) : (
+        <SimpleItemTool page={page} isEnableActions={false} isReadOnlyUser={false} />
+      )}
+      <NotAvailableForGuest>
+        <div className="grw-pagetree-control d-flex">
+          <PageItemControl
+            pageId={page._id}
+            isEnableActions={isEnableActions}
+            isReadOnlyUser={isReadOnlyUser}
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+            isInstantRename
+            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+            operationProcessData={page.processData}
+          >
+            {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+              <i id="option-button-in-page-tree" className="icon-options fa fa-rotate-90 p-1"></i>
+            </DropdownToggle>
+          </PageItemControl>
+        </div>
+      </NotAvailableForGuest>
+    </>
+  );
+};

+ 177 - 0
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -0,0 +1,177 @@
+import React, {
+  useCallback, useState, FC,
+} from 'react';
+
+import nodePath from 'path';
+
+import type { IPageHasId } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { useDrag, useDrop } from 'react-dnd';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastWarning, toastError } from '~/client/util/toastr';
+import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
+import loggerFactory from '~/utils/logger';
+
+import {
+  SimpleItem, type SimpleItemProps, useNewPageInput, ItemNode,
+} from '../../TreeItem';
+
+import { Ellipsis } from './Ellipsis';
+
+const logger = loggerFactory('growi:cli:Item');
+
+type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
+type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
+
+export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
+  const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
+    const pageTitle = nodePath.basename(droppedPagePath);
+    return nodePath.join(newParentPagePath, pageTitle);
+  };
+
+  const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
+    if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
+      if (printLog) {
+        logger.warn('Any of page, page.path or droppedPage.path is null');
+      }
+      return false;
+    }
+
+    const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
+    return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
+  };
+
+  const { t } = useTranslation();
+
+  const {
+    itemNode, isOpen: _isOpen = false, onRenamed,
+  } = props;
+
+  const { page } = itemNode;
+  const [isOpen, setIsOpen] = useState(_isOpen);
+  const [shouldHide, setShouldHide] = useState(false);
+
+  const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+
+  const displayDroppedItemByPageId = useCallback((pageId) => {
+    const target = document.getElementById(`pagetree-item-${pageId}`);
+    if (target == null) {
+      return;
+    }
+    //   // wait 500ms to avoid removing before d-none is set by useDrag end() callback
+    setTimeout(() => {
+      target.classList.remove('d-none');
+    }, 500);
+  }, []);
+
+  const [, drag] = useDrag({
+    type: 'PAGE_TREE',
+    item: { page },
+    canDrag: () => {
+      if (page.path == null) {
+        return false;
+      }
+      return !pagePathUtils.isUsersProtectedPages(page.path);
+    },
+    end: (item, monitor) => {
+      // in order to set d-none to dropped Item
+      const dropResult = monitor.getDropResult();
+      if (dropResult != null) {
+        setShouldHide(true);
+      }
+    },
+    collect: monitor => ({
+      isDragging: monitor.isDragging(),
+      canDrag: monitor.canDrag(),
+    }),
+  });
+
+  const pageItemDropHandler = async(item: ItemNode) => {
+    const { page: droppedPage } = item;
+    if (!isDroppable(droppedPage, page, true)) {
+      return;
+    }
+    if (droppedPage.path == null || page.path == null) {
+      return;
+    }
+    const newPagePath = getNewPathAfterMoved(droppedPage.path, page.path);
+    try {
+      await apiv3Put('/pages/rename', {
+        pageId: droppedPage._id,
+        revisionId: droppedPage.revision,
+        newPagePath,
+        isRenameRedirect: false,
+        updateMetadata: true,
+      });
+      await mutatePageTree();
+      await mutateChildren();
+      if (onRenamed != null) {
+        onRenamed(page.path, newPagePath);
+      }
+      // force open
+      setIsOpen(true);
+    }
+    catch (err) {
+      // display the dropped item
+      displayDroppedItemByPageId(droppedPage._id);
+      if (err.code === 'operation__blocked') {
+        toastWarning(t('pagetree.you_cannot_move_this_page_now'));
+      }
+      else {
+        toastError(t('pagetree.something_went_wrong_with_moving_page'));
+      }
+    }
+  };
+
+  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(
+    () => ({
+      accept: 'PAGE_TREE',
+      drop: pageItemDropHandler,
+      hover: (item, monitor) => {
+        // when a drag item is overlapped more than 1 sec, the drop target item will be opened.
+        if (monitor.isOver()) {
+          setTimeout(() => {
+            if (monitor.isOver()) {
+              setIsOpen(true);
+            }
+          }, 600);
+        }
+      },
+      canDrop: (item) => {
+        const { page: droppedPage } = item;
+        return isDroppable(droppedPage, page);
+      },
+      collect: monitor => ({
+        isOver: monitor.isOver(),
+      }),
+    }),
+    [page],
+  );
+
+  const itemRef = (c) => { drag(c); drop(c) };
+
+  const mainClassName = `${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`;
+
+  const { NewPageInputWrapper, NewPageCreateButtonWrapper } = useNewPageInput();
+
+  return (
+    <SimpleItem
+      key={props.key}
+      targetPathOrId={props.targetPathOrId}
+      itemNode={props.itemNode}
+      isOpen
+      isEnableActions={props.isEnableActions}
+      isReadOnlyUser={props.isReadOnlyUser}
+      onRenamed={props.onRenamed}
+      onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
+      onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      itemRef={itemRef}
+      itemClass={PageTreeItem}
+      mainClassName={mainClassName}
+      customEndComponents={[Ellipsis, NewPageCreateButtonWrapper]}
+      customNextComponents={[NewPageInputWrapper]}
+    />
+  );
+};

+ 2 - 0
apps/app/src/components/Sidebar/PageTreeItem/index.ts

@@ -0,0 +1,2 @@
+export * from './PageTreeItem';
+export * from './Ellipsis';

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

@@ -3,6 +3,7 @@ import React, { memo } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
+
 import { Bookmarks } from './Bookmarks';
 import { CustomSidebar } from './Custom';
 import { PageTree } from './PageTree';

+ 18 - 0
apps/app/src/components/TreeItem/ItemNode.ts

@@ -0,0 +1,18 @@
+import { IPageForItem } from '../../interfaces/page';
+
+export class ItemNode {
+
+  page: IPageForItem;
+
+  children: ItemNode[];
+
+  constructor(page: IPageForItem, children: ItemNode[] = []) {
+    this.page = page;
+    this.children = children;
+  }
+
+  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
+    return pages.map(page => new ItemNode(page));
+  }
+
+}

+ 72 - 0
apps/app/src/components/TreeItem/NewPageCreateButton.tsx

@@ -0,0 +1,72 @@
+import React, {
+  useCallback, FC,
+} from 'react';
+
+import { pagePathUtils } from '@growi/core/dist/utils';
+
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnlyUser';
+import { IPageForItem } from '~/interfaces/page';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+import { ItemNode } from './ItemNode';
+
+type StateHandlersType = {
+  isOpen: boolean,
+  setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
+  isCreating: boolean,
+  setCreating: React.Dispatch<React.SetStateAction<boolean>>,
+};
+
+export type NewPageCreateButtonProps = {
+  page: IPageForItem,
+  currentChildren: ItemNode[],
+  stateHandlers: StateHandlersType,
+  isNewPageInputShown?: boolean,
+  setNewPageInputShown: React.Dispatch<React.SetStateAction<boolean>>,
+};
+
+export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
+  const {
+    page, currentChildren, stateHandlers, setNewPageInputShown,
+  } = props;
+
+  const { setIsOpen } = stateHandlers;
+
+  // descendantCount
+  const { getDescCount } = usePageTreeDescCountMap();
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+  const isChildrenLoaded = currentChildren?.length > 0;
+  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+
+  const onClickPlusButton = useCallback(() => {
+    setNewPageInputShown(true);
+
+    if (hasDescendants) {
+      setIsOpen(true);
+    }
+  }, [hasDescendants, setIsOpen]);
+
+  const test = pagePathUtils;
+  console.dir(test);
+
+  return (
+    <>
+      {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
+        <NotAvailableForGuest>
+          <NotAvailableForReadOnlyUser>
+            <button
+              id="page-create-button-in-page-tree"
+              type="button"
+              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+              onClick={onClickPlusButton}
+            >
+              <i className="icon-plus d-block p-0" />
+            </button>
+          </NotAvailableForReadOnlyUser>
+        </NotAvailableForGuest>
+      )}
+    </>
+  );
+};

+ 103 - 0
apps/app/src/components/TreeItem/NewPageInput.tsx

@@ -0,0 +1,103 @@
+import React, { FC, useCallback, useEffect } from 'react';
+
+import nodePath from 'path';
+
+
+import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { ValidationTarget } from '~/client/util/input-validator';
+import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
+import ClosableTextInput from '~/components/Common/ClosableTextInput';
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+import { NewPageCreateButtonProps } from './NewPageCreateButton';
+import { NotDraggableForClosableTextInput } from './SimpleItem';
+
+type NewPageInputProps = NewPageCreateButtonProps & {isEnableActions: boolean};
+
+export const NewPageInput: FC<NewPageInputProps> = (props) => {
+  const { t } = useTranslation();
+
+  const {
+    page, isEnableActions, currentChildren, stateHandlers, isNewPageInputShown, setNewPageInputShown,
+  } = props;
+
+  const { isOpen, setIsOpen, setCreating } = stateHandlers;
+
+  const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+
+  const { getDescCount } = usePageTreeDescCountMap();
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+  const isChildrenLoaded = currentChildren?.length > 0;
+  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+
+  const onPressEnterForCreateHandler = async(inputText: string) => {
+    setNewPageInputShown(false);
+    // closeNewPageInput();
+    const parentPath = pathUtils.addTrailingSlash(page.path as string);
+    const newPagePath = nodePath.resolve(parentPath, inputText);
+    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+
+    if (!isCreatable) {
+      toastWarning(t('you_can_not_create_page_with_this_name'));
+      return;
+    }
+
+    try {
+      setCreating(true);
+
+      await apiv3Post('/pages/', {
+        path: newPagePath,
+        body: undefined,
+        grant: page.grant,
+        grantUserGroupId: page.grantedGroup,
+      });
+
+      mutateChildren();
+
+      if (!hasDescendants) {
+        setIsOpen(true);
+      }
+
+      toastSuccess(t('successfully_saved_the_page'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setCreating(false);
+    }
+  };
+
+  const onPressEscHandler = useCallback((event) => {
+    if (event.keyCode === 27) {
+      setNewPageInputShown(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    document.addEventListener('keydown', onPressEscHandler, false);
+    return () => {
+      document.removeEventListener('keydown', onPressEscHandler, false);
+    };
+  }, [onPressEscHandler]);
+
+  return (
+    <>
+      {isEnableActions && isNewPageInputShown && (
+        <NotDraggableForClosableTextInput>
+          <ClosableTextInput
+            placeholder={t('Input page name')}
+            onClickOutside={() => { setNewPageInputShown(false) }}
+            onPressEnter={onPressEnterForCreateHandler}
+            validationTarget={ValidationTarget.PAGE}
+          />
+        </NotDraggableForClosableTextInput>
+      )}
+    </>
+  );
+};

+ 278 - 0
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -0,0 +1,278 @@
+import React, {
+  useCallback, useState, FC, useEffect, ReactNode,
+} from 'react';
+
+import nodePath from 'path';
+
+import type { Nullable, IPageToDeleteWithMeta } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { IPageForItem } from '~/interfaces/page';
+import { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
+
+import CountBadge from '../Common/CountBadge';
+
+import { ItemNode } from './ItemNode';
+
+
+export type SimpleItemProps = {
+  isEnableActions: boolean
+  isReadOnlyUser: boolean
+  itemNode: ItemNode
+  targetPathOrId?: Nullable<string>
+  isOpen?: boolean
+  onRenamed?(fromPath: string | undefined, toPath: string): void
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void
+  itemRef?
+  itemClass?: React.FunctionComponent<SimpleItemProps>
+  mainClassName?: string
+  customEndComponents?: Array<React.FunctionComponent<SimpleItemToolProps>>
+  customNextComponents?: Array<React.FunctionComponent<SimpleItemToolProps>>
+};
+
+// Utility to mark target
+const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
+  if (targetPathOrId == null) {
+    return;
+  }
+
+  children.forEach((node) => {
+    if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
+      node.page.isTarget = true;
+    }
+    else {
+      node.page.isTarget = false;
+    }
+    return node;
+  });
+};
+
+/**
+ * Return new page path after the droppedPagePath is moved under the newParentPagePath
+ * @param droppedPagePath
+ * @param newParentPagePath
+ * @returns
+ */
+
+/**
+ * Return whether the fromPage could be moved under the newParentPage
+ * @param fromPage
+ * @param newParentPage
+ * @param printLog
+ * @returns
+ */
+
+// Component wrapper to make a child element not draggable
+// https://github.com/react-dnd/react-dnd/issues/335
+type NotDraggableProps = {
+  children: ReactNode,
+};
+export const NotDraggableForClosableTextInput = (props: NotDraggableProps): JSX.Element => {
+  return <div draggable onDragStart={e => e.preventDefault()}>{props.children}</div>;
+};
+
+type SimpleItemToolPropsOptional = 'itemNode' | 'targetPathOrId' | 'isOpen' | 'itemRef' | 'itemClass' | 'mainClassName';
+export type SimpleItemToolProps = Omit<SimpleItemProps, SimpleItemToolPropsOptional> & {page: IPageForItem};
+
+export const SimpleItemTool: FC<SimpleItemToolProps> = (props) => {
+  const { t } = useTranslation();
+  const router = useRouter();
+  const { getDescCount } = usePageTreeDescCountMap();
+
+  const page = props.page;
+
+  const pageName = nodePath.basename(page.path ?? '') || '/';
+
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
+
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+  const pageTreeItemClickHandler = (e) => {
+    e.preventDefault();
+
+    if (page.path == null || page._id == null) {
+      return;
+    }
+
+    const link = pathUtils.returnPathForURL(page.path, page._id);
+
+    router.push(link);
+  };
+
+  return (
+    <>
+      {shouldShowAttentionIcon && (
+        <>
+          <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
+          <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
+            {t('tooltip.operation.attention.rename')}
+          </UncontrolledTooltip>
+        </>
+      )}
+      {page != null && page.path != null && page._id != null && (
+        <div className="grw-pagetree-title-anchor flex-grow-1">
+          <p onClick={pageTreeItemClickHandler} className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
+        </div>
+      )}
+      {descendantCount > 0 && (
+        <div className="grw-pagetree-count-wrapper">
+          <CountBadge count={descendantCount} />
+        </div>
+      )}
+    </>
+  );
+};
+
+export const SimpleItem: FC<SimpleItemProps> = (props) => {
+  const {
+    itemNode, targetPathOrId, isOpen: _isOpen = false,
+    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+    itemRef, itemClass, mainClassName,
+  } = props;
+
+  const { page, children } = itemNode;
+
+  const [currentChildren, setCurrentChildren] = useState(children);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+  const [isCreating, setCreating] = useState(false);
+
+  const { data } = useSWRxPageChildren(isOpen ? page._id : null);
+
+  const stateHandlers = {
+    isOpen,
+    setIsOpen,
+    isCreating,
+    setCreating,
+  };
+
+  // descendantCount
+  const { getDescCount } = usePageTreeDescCountMap();
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+
+  // hasDescendants flag
+  const isChildrenLoaded = currentChildren?.length > 0;
+  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+
+  const hasChildren = useCallback((): boolean => {
+    return currentChildren != null && currentChildren.length > 0;
+  }, [currentChildren]);
+
+  const onClickLoadChildren = useCallback(async() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+
+  // didMount
+  useEffect(() => {
+    if (hasChildren()) setIsOpen(true);
+  }, [hasChildren]);
+
+  /*
+   * Make sure itemNode.children and currentChildren are synced
+   */
+  useEffect(() => {
+    if (children.length > currentChildren.length) {
+      markTarget(children, targetPathOrId);
+      setCurrentChildren(children);
+    }
+  }, [children, currentChildren.length, targetPathOrId]);
+
+  /*
+   * When swr fetch succeeded
+   */
+  useEffect(() => {
+    if (isOpen && data != null) {
+      const newChildren = ItemNode.generateNodesFromPages(data.children);
+      markTarget(newChildren, targetPathOrId);
+      setCurrentChildren(newChildren);
+    }
+  }, [data, isOpen, targetPathOrId]);
+
+  const ItemClassFixed = itemClass ?? SimpleItem;
+
+  const commonProps = {
+    isEnableActions,
+    isReadOnlyUser,
+    isOpen: false,
+    targetPathOrId,
+    onRenamed,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    stateHandlers,
+  };
+
+  const CustomEndComponents = props.customEndComponents;
+
+  const SimpleItemContent = CustomEndComponents ?? [SimpleItemTool];
+
+  const SimpleItemContentProps = {
+    itemNode,
+    page,
+    onRenamed,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    isEnableActions,
+    isReadOnlyUser,
+    children,
+    stateHandlers,
+  };
+
+  const CustomNextComponents = props.customNextComponents;
+
+
+  return (
+    <div
+      id={`pagetree-item-${page._id}`}
+      data-testid="grw-pagetree-item-container"
+      className={`grw-pagetree-item-container ${mainClassName}`}
+    >
+      <li
+        ref={itemRef}
+        className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center
+        ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
+        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
+      >
+        <div className="grw-triangle-container d-flex justify-content-center">
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`grw-pagetree-triangle-btn btn ${isOpen ? 'grw-pagetree-open' : ''}`}
+              onClick={onClickLoadChildren}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-icons-round">arrow_right</span>
+              </div>
+            </button>
+          )}
+        </div>
+        {SimpleItemContent.map(ItemContent => (
+          <ItemContent {...SimpleItemContentProps} />
+        ))}
+      </li>
+
+      {CustomNextComponents?.map(UnderItemContent => (
+        <UnderItemContent {...SimpleItemContentProps} />
+      ))}
+
+      {
+        isOpen && hasChildren() && currentChildren.map((node, index) => (
+          <div key={node.page._id} className="grw-pagetree-item-children">
+            <ItemClassFixed itemNode={node} {...commonProps} />
+            {isCreating && (currentChildren.length - 1 === index) && (
+              <div className="text-muted text-center">
+                <i className="fa fa-spinner fa-pulse mr-1"></i>
+              </div>
+            )}
+          </div>
+        ))
+      }
+    </div>
+  );
+};

+ 43 - 0
apps/app/src/components/TreeItem/UseNewPageInput.tsx

@@ -0,0 +1,43 @@
+import React, { useState, FC } from 'react';
+
+import { ItemNode } from './ItemNode';
+import { NewPageCreateButton } from './NewPageCreateButton';
+import { NewPageInput } from './NewPageInput';
+import { SimpleItemToolProps } from './SimpleItem';
+
+type UseNewPageInputProps = SimpleItemToolProps & {children: ItemNode[], stateHandlers};
+
+export const useNewPageInput = () => {
+
+  const [isNewPageInputShown, setNewPageInputShown] = useState(false);
+
+  const NewPageCreateButtonWrapper: FC<UseNewPageInputProps> = (props) => {
+    return (
+      <NewPageCreateButton
+        page={props.page}
+        currentChildren={props.children}
+        stateHandlers={props.stateHandlers}
+        setNewPageInputShown={setNewPageInputShown}
+      />
+    );
+  };
+
+  const NewPageInputWrapper = (props) => {
+    return (
+      <NewPageInput
+        page={props.page}
+        isEnableActions={props.isEnableActions}
+        currentChildren={props.chilren}
+        stateHandlers={props.stateHandlers}
+        isNewPageInputShown={isNewPageInputShown}
+        setNewPageInputShown={setNewPageInputShown}
+      />
+    );
+  };
+
+
+  return {
+    NewPageInputWrapper,
+    NewPageCreateButtonWrapper,
+  };
+};

+ 3 - 0
apps/app/src/components/TreeItem/index.ts

@@ -0,0 +1,3 @@
+export { useNewPageInput } from './UseNewPageInput';
+export * from './SimpleItem';
+export * from './ItemNode';

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

@@ -26,3 +26,4 @@ export type OnRenamedFunction = (path: string) => void;
 export type OnDuplicatedFunction = (fromPath: string, toPath: string) => void;
 export type OnPutBackedFunction = (path: string) => void;
 export type onDeletedBookmarkFolderFunction = (bookmarkFolderId: string) => void;
+export type OnSelectedFunction = () => void;

+ 36 - 2
apps/app/src/stores/modal.tsx

@@ -8,8 +8,8 @@ import { SWRResponse } from 'swr';
 import Linker from '~/client/models/Linker';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
-import {
-  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction,
+import type {
+  OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
 } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -739,3 +739,37 @@ export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & Li
     },
   });
 };
+
+/*
+* PageSelectModal
+*/
+export type IPageSelectModalOption = {
+  onSelected?: OnSelectedFunction,
+}
+
+type PageSelectModalStatus = {
+  isOpened: boolean
+  opts?: IPageSelectModalOption
+}
+
+type PageSelectModalStatusUtils = {
+  open(): Promise<PageSelectModalStatus | undefined>
+  close(): Promise<PageSelectModalStatus | undefined>
+}
+
+export const usePageSelectModal = (
+    status?: PageSelectModalStatus,
+): SWRResponse<PageSelectModalStatus, Error> & PageSelectModalStatusUtils => {
+  const initialStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<PageSelectModalStatus, Error>('PageSelectModal', status, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: (
+        opts?: IPageSelectModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, opts,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};

+ 0 - 4
apps/app/src/styles/_editor.scss

@@ -96,10 +96,6 @@
       height: 38px;
       font-size: 18px;
     }
-
-    .grw-taglabels-container {
-      margin-bottom: 0;
-    }
   }
 
   // ellipsis .grw-page-path-hierarchical-link

+ 1 - 0
apps/slackbot-proxy/docker/Dockerfile

@@ -29,6 +29,7 @@ COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
 
 # setup (with network-timeout = 1 hour)
 RUN yarn config set network-timeout 3600000
+RUN yarn global add node-gyp
 RUN yarn --frozen-lockfile
 
 # make artifacts

+ 2 - 3
package.json

@@ -51,7 +51,6 @@
     "@swc-node/register": "^1.6.2",
     "@swc/core": "^1.3.36",
     "@swc/helpers": "^0.4.14",
-    "@testing-library/cypress": "^9.0.0",
     "@types/css-modules": "^1.0.2",
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
@@ -62,8 +61,8 @@
     "@vitejs/plugin-react": "^4.0.3",
     "@vitest/coverage-c8": "^0.31.1",
     "@vitest/ui": "^0.31.1",
-    "cypress": "^12.17.2",
-    "cypress-wait-until": "^1.7.2",
+    "cypress": "^13.3.0",
+    "cypress-wait-until": "^2.0.1",
     "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",

+ 1 - 0
packages/editor/package.json

@@ -33,6 +33,7 @@
     "codemirror": "^6.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",
     "material-icons": "^1.13.10",
+    "react-dropzone": "^14.2.3",
     "react-hook-form": "^7.45.4",
     "react-toastify": "^9.1.3",
     "reactstrap": "^9.2.0",

+ 51 - 3
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -3,9 +3,11 @@ import {
 } from 'react';
 
 import { indentUnit } from '@codemirror/language';
+import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey } from '../../consts';
+import { useFileDropzone } from '../../services';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
 import { Toolbar } from './Toolbar';
@@ -18,10 +20,10 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement>((props, ref) => {
   );
 });
 
-
 type Props = {
   editorKey: string | GlobalCodeMirrorEditorKey,
   onChange?: (value: string) => void,
+  onUpload?: (files: File[]) => void,
   indentSize?: number,
 }
 
@@ -29,6 +31,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   const {
     editorKey,
     onChange,
+    onUpload,
     indentSize,
   } = props;
 
@@ -52,10 +55,55 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [codeMirrorEditor, indentSize]);
 
+  useEffect(() => {
+    const handlePaste = (event: ClipboardEvent) => {
+      event.preventDefault();
+
+      if (event.clipboardData == null) {
+        return;
+      }
+
+      if (onUpload != null && event.clipboardData.types.includes('Files')) {
+        onUpload(Array.from(event.clipboardData.files));
+      }
+
+      if (event.clipboardData.types.includes('text/plain')) {
+        const textData = event.clipboardData.getData('text/plain');
+        codeMirrorEditor?.replaceText(textData);
+      }
+    };
+
+    const extension = EditorView.domEventHandlers({
+      paste: handlePaste,
+    });
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
+    return cleanupFunction;
+
+  }, [codeMirrorEditor, onUpload]);
+
+  useEffect(() => {
+
+    const handleDrop = (event: DragEvent) => {
+      // prevents conflicts between codemirror and react-dropzone during file drops.
+      event.preventDefault();
+    };
+
+    const extension = EditorView.domEventHandlers({
+      drop: handleDrop,
+    });
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
+    return cleanupFunction;
+
+  }, [codeMirrorEditor]);
+
+  const { getRootProps, open } = useFileDropzone({ onUpload });
+
   return (
-    <div className="flex-expand-vert">
+    <div {...getRootProps()} className="flex-expand-vert">
       <CodeMirrorEditorContainer ref={containerRef} />
-      <Toolbar />
+      <Toolbar onFileOpen={open} />
     </div>
   );
 };

+ 9 - 3
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -5,7 +5,13 @@ import {
   DropdownItem,
 } from 'reactstrap';
 
-export const AttachmentsDropup = (): JSX.Element => {
+type Props = {
+  onFileOpen: () => void,
+}
+
+export const AttachmentsDropup = (props: Props): JSX.Element => {
+
+  const { onFileOpen } = props;
   return (
     <>
       <UncontrolledDropdown direction="up" className="lh-1">
@@ -18,11 +24,11 @@ export const AttachmentsDropup = (): JSX.Element => {
             Attachments
           </DropdownItem>
           <DropdownItem divider />
-          <DropdownItem className="d-flex gap-1 align-items-center">
+          <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
             <span className="material-icons-outlined fs-5">attach_file</span>
             Files
           </DropdownItem>
-          <DropdownItem className="d-flex gap-1 align-items-center">
+          <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
             <span className="material-icons-outlined fs-5">image</span>
             Images
           </DropdownItem>

+ 8 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -9,10 +9,16 @@ import { TextFormatTools } from './TextFormatTools';
 
 import styles from './Toolbar.module.scss';
 
-export const Toolbar = memo((): JSX.Element => {
+type Props = {
+  onFileOpen: () => void,
+}
+
+export const Toolbar = memo((props: Props): JSX.Element => {
+
+  const { onFileOpen } = props;
   return (
     <div className={`d-flex gap-2 p-2 codemirror-editor-toolbar ${styles['codemirror-editor-toolbar']}`}>
-      <AttachmentsDropup />
+      <AttachmentsDropup onFileOpen={onFileOpen} />
       <TextFormatTools />
       <EmojiButton />
       <TableButton />

+ 3 - 1
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -17,12 +17,13 @@ const additionalExtensions: Extension[] = [
 type Props = {
   onChange?: (value: string) => void,
   onSave?: () => void,
+  onUpload?: (files: File[]) => void,
   indentSize?: number,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, indentSize,
+    onSave, onChange, onUpload, indentSize,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -61,6 +62,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     <CodeMirrorEditor
       editorKey={GlobalCodeMirrorEditorKey.MAIN}
       onChange={onChange}
+      onUpload={onUpload}
       indentSize={indentSize}
     />
   );

+ 14 - 0
packages/editor/src/components/playground/Playground.tsx

@@ -37,6 +37,18 @@ export const Playground = (): JSX.Element => {
     toast.success('Saved.', { autoClose: 2000 });
   }, [codeMirrorEditor]);
 
+  // the upload event handler
+  // demo of uploading a file.
+  const uploadHandler = useCallback((files: File[]) => {
+    files.forEach((file) => {
+      // set dummy file name.
+      const insertText = `[${file.name}](/attachment/aaaabbbbccccdddd)\n`;
+      codeMirrorEditor?.insertText(insertText);
+    });
+
+  }, [codeMirrorEditor]);
+
+
   return (
     <>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '83px' }}>
@@ -47,6 +59,8 @@ export const Playground = (): JSX.Element => {
           <CodeMirrorEditorMain
             onSave={saveHandler}
             onChange={setMarkdownToPreview}
+            onUpload={uploadHandler}
+            indentSize={4}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">

+ 15 - 1
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -12,6 +12,8 @@ import { useAppendExtensions, type AppendExtensions } from './utils/append-exten
 import { useFocus, type Focus } from './utils/focus';
 import { useGetDoc, type GetDoc } from './utils/get-doc';
 import { useInitDoc, type InitDoc } from './utils/init-doc';
+import { useInsertText, type InsertText } from './utils/insert-text';
+import { useReplaceText, type ReplaceText } from './utils/replace-text';
 import { useSetCaretLine, type SetCaretLine } from './utils/set-caret-line';
 
 type UseCodeMirrorEditorUtils = {
@@ -20,6 +22,8 @@ type UseCodeMirrorEditorUtils = {
   getDoc: GetDoc,
   focus: Focus,
   setCaretLine: SetCaretLine,
+  insertText: InsertText,
+  replaceText: ReplaceText,
 }
 export type UseCodeMirrorEditor = {
   state: EditorState | undefined;
@@ -41,11 +45,17 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
       {
         extensions: defaultExtensions,
         // Reset settings of react-codemirror.
-        // The extension defined first will be used, so it must be disabled here.
+        // Extensions are defined first will be used if they have the same priority.
+        // If extensions conflict, disable them here.
+        // And add them to defaultExtensions: Extension[] with a lower priority.
+        // ref: https://codemirror.net/examples/config/
+        // ------- Start -------
         indentWithTab: false,
         basicSetup: {
           defaultKeymap: false,
+          dropCursor: false,
         },
+        // ------- End -------
       },
     );
   }, [props]);
@@ -57,6 +67,8 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
   const getDoc = useGetDoc(view);
   const focus = useFocus(view);
   const setCaretLine = useSetCaretLine(view);
+  const insertText = useInsertText(view);
+  const replaceText = useReplaceText(view);
 
   return {
     state,
@@ -66,5 +78,7 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
     getDoc,
     focus,
     setCaretLine,
+    insertText,
+    replaceText,
   };
 };

+ 24 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/insert-text.ts

@@ -0,0 +1,24 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+export type InsertText = (text: string) => void;
+
+export const useInsertText = (view?: EditorView): InsertText => {
+
+  return useCallback((text) => {
+    if (view == null) {
+      return;
+    }
+    const insertPos = view.state.selection.main.head;
+    view.dispatch({
+      changes: {
+        from: insertPos,
+        to: insertPos,
+        insert: text,
+      },
+      selection: { anchor: insertPos },
+    });
+  }, [view]);
+
+};

+ 15 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/utils/replace-text.ts

@@ -0,0 +1,15 @@
+import { useCallback } from 'react';
+
+import { EditorView } from '@codemirror/view';
+
+export type ReplaceText = (text: string) => void;
+
+export const useReplaceText = (view?: EditorView): ReplaceText => {
+
+  return useCallback((text) => {
+    view?.dispatch(
+      view?.state.replaceSelection(text),
+    );
+  }, [view]);
+
+};

+ 1 - 0
packages/editor/src/services/file-dropzone/index.ts

@@ -0,0 +1 @@
+export * from './use-file-dropzone';

+ 27 - 0
packages/editor/src/services/file-dropzone/use-file-dropzone.ts

@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+import { useDropzone } from 'react-dropzone';
+import type { DropzoneState } from 'react-dropzone';
+
+type DropzoneEditor = {
+  onUpload?: (files: File[]) => void,
+}
+
+export const useFileDropzone = (props: DropzoneEditor): DropzoneState => {
+
+  const { onUpload } = props;
+
+  const dropHandler = useCallback((acceptedFiles: File[]) => {
+    if (onUpload == null) {
+      return;
+    }
+    onUpload(acceptedFiles);
+  }, [onUpload]);
+
+  return useDropzone({
+    noKeyboard: true,
+    noClick: true,
+    onDrop: dropHandler,
+  });
+
+};

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

@@ -1 +1,2 @@
 export * from './codemirror-editor';
+export * from './file-dropzone';

File diff suppressed because it is too large
+ 41 - 530
yarn.lock


Some files were not shown because too many files changed in this diff