Преглед изворни кода

Merge branch 'feat/create-modal-for-parent-page-selection' into dev/7.0.x

WNomunomu пре 2 година
родитељ
комит
0462ca01b6
28 измењених фајлова са 1017 додато и 593 уклоњено
  1. 2 2
      apps/app/public/static/locales/en_US/admin.json
  2. 2 2
      apps/app/public/static/locales/ja_JP/admin.json
  3. 2 2
      apps/app/public/static/locales/zh_CN/admin.json
  4. 2 2
      apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx
  5. 0 0
      apps/app/src/components/ItemsTree/Item.module.scss
  6. 1 1
      apps/app/src/components/ItemsTree/ItemNode.ts
  7. 0 0
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  8. 7 9
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  9. 2 2
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  10. 2 0
      apps/app/src/components/ItemsTree/index.ts
  11. 1 0
      apps/app/src/components/Layout/BasicLayout.tsx
  12. 1 0
      apps/app/src/components/PageEditor/Editor.tsx
  13. 65 0
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  14. 30 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  15. 0 569
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  16. 3 3
      apps/app/src/components/Sidebar/PageTree/PageTree.tsx
  17. 5 1
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  18. 170 0
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  19. 177 0
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  20. 2 0
      apps/app/src/components/Sidebar/PageTreeItem/index.ts
  21. 1 0
      apps/app/src/components/Sidebar/SidebarContents.tsx
  22. 18 0
      apps/app/src/components/TreeItem/ItemNode.ts
  23. 72 0
      apps/app/src/components/TreeItem/NewPageCreateButton.tsx
  24. 103 0
      apps/app/src/components/TreeItem/NewPageInput.tsx
  25. 278 0
      apps/app/src/components/TreeItem/SimpleItem.tsx
  26. 43 0
      apps/app/src/components/TreeItem/UseNewPageInput.tsx
  27. 3 0
      apps/app/src/components/TreeItem/index.ts
  28. 25 0
      apps/app/src/stores/modal.tsx

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

@@ -488,8 +488,8 @@
       "enable_marp_desc": "Marp can be used in presentation preview. This option may make you vulnerable to XSS.",
       "enable_marp_desc": "Marp can be used in presentation preview. This option may make you vulnerable to XSS.",
       "marp_official_site": "The Marp Official Site",
       "marp_official_site": "The Marp Official Site",
       "marp_official_site_link": "https://marp.app",
       "marp_official_site_link": "https://marp.app",
-      "presentation_docs" : "GROWI Docs - Create slides for a presentation",
-      "presentation_docs_link": "https://docs.growi.org/en/guide/features/presentation.html"
+      "marp_in_growi" : "GROWI Docs - Create slide using Marp",
+      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
     },
     },
     "custom_title": "Custom title",
     "custom_title": "Custom title",
     "custom_title_detail": "You can customize <code>&lt;title&gt;</code> tag. Following placeholders will be automatically replaced:",
     "custom_title_detail": "You can customize <code>&lt;title&gt;</code> tag. Following placeholders will be automatically replaced:",

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

@@ -497,8 +497,8 @@
       "enable_marp_desc": "プレゼンテーション表示に Marp を利用できるようになります。ただし、XSS に対して脆弱になる恐れがあります。",
       "enable_marp_desc": "プレゼンテーション表示に Marp を利用できるようになります。ただし、XSS に対して脆弱になる恐れがあります。",
       "marp_official_site": "参考:Marp 公式サイト",
       "marp_official_site": "参考:Marp 公式サイト",
       "marp_official_site_link": "https://marp.app",
       "marp_official_site_link": "https://marp.app",
-      "presentation_docs" : "参考:GROWI Docs - プレゼンテーション機能を使う",
-      "presentation_docs_link": "https://docs.growi.org/ja/guide/features/presentation.html"
+      "marp_in_growi" : "参考:GROWI Docs - Marp でスライドを作成する",
+      "marp_in_growi_link": "https://docs.growi.org/ja/guide/features/marp.html"
     },
     },
     "custom_title": "カスタム Title",
     "custom_title": "カスタム Title",
     "custom_title_detail": "<code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。以下のプレースホルダーは自動的に置換されます:",
     "custom_title_detail": "<code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。以下のプレースホルダーは自動的に置換されます:",

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

@@ -496,8 +496,8 @@
       "enable_marp_desc": "Marp 可在演示视图中使用。该选项可能会使您受到 XSS 的攻击。",
       "enable_marp_desc": "Marp 可在演示视图中使用。该选项可能会使您受到 XSS 的攻击。",
       "marp_official_site": "参考资料:Marp 官方网站",
       "marp_official_site": "参考资料:Marp 官方网站",
       "marp_official_site_link": "https://marp.app",
       "marp_official_site_link": "https://marp.app",
-      "presentation_docs" : "参考资料:GROWI Docs - Create slides for a presentation",
-      "presentation_docs_link": "https://docs.growi.org/en/guide/features/presentation.html"
+      "marp_in_growi" : "参考资料:GROWI Docs - Create slide using Marp",
+      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
     },
     },
     "custom_title": "自定义标题",
     "custom_title": "自定义标题",
     "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
     "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",

+ 2 - 2
apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx

@@ -51,10 +51,10 @@ const CustomizePresentationSetting = (props: Props): JSX.Element => {
               </a>
               </a>
               <br></br>
               <br></br>
               <a
               <a
-                href={`${t('admin:customize_settings.presentation_options.presentation_docs_link')}`}
+                href={`${t('admin:customize_settings.presentation_options.marp_in_gorwi_link')}`}
                 target="_blank"
                 target="_blank"
                 rel="noopener noreferrer"
                 rel="noopener noreferrer"
-              >{`${t('admin:customize_settings.presentation_options.presentation_docs')}`}
+              >{`${t('admin:customize_settings.presenattion_options.marp_in_growi')}`}
               </a>
               </a>
             </p>
             </p>
           </CustomizePresentationOption>
           </CustomizePresentationOption>

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

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

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

+ 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 - 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 dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import PageTreeContentSkeleton from './PageTreeContentSkeleton';
+import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
 import { PageTreeHeader } from './PageTreeSubstance';
 import { PageTreeHeader } from './PageTreeSubstance';
 
 
 const PageTreeContent = dynamic(
 const PageTreeContent = dynamic(
   () => import('./PageTreeSubstance').then(mod => mod.PageTreeContent),
   () => import('./PageTreeSubstance').then(mod => mod.PageTreeContent),
-  { ssr: false, loading: PageTreeContentSkeleton },
+  { ssr: false, loading: ItemsTreeContentSkeleton },
 );
 );
 
 
 
 
@@ -24,7 +24,7 @@ export const PageTree = (): JSX.Element => {
         </Suspense>
         </Suspense>
       </div>
       </div>
 
 
-      <Suspense fallback={<PageTreeContentSkeleton />}>
+      <Suspense fallback={<ItemsTreeContentSkeleton />}>
         <PageTreeContent />
         <PageTreeContent />
       </Suspense>
       </Suspense>
     </div>
     </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 { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { mutatePageTree, useSWRxRootPage, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import { mutatePageTree, useSWRxRootPage, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 
+import { ItemsTree } from '../../ItemsTree/ItemsTree';
+import { PageTreeItem } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 
-import ItemsTree from './ItemsTree';
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
 
 
 
@@ -44,6 +45,8 @@ const PageTreeUnavailable = () => {
 };
 };
 
 
 export const PageTreeContent = memo(() => {
 export const PageTreeContent = memo(() => {
+  const { t } = useTranslation();
+
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
@@ -75,6 +78,7 @@ export const PageTreeContent = memo(() => {
         targetPath={path}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetPathOrId={targetPathOrId}
         targetAndAncestorsData={targetAndAncestorsData}
         targetAndAncestorsData={targetAndAncestorsData}
+        CustomTreeItem={PageTreeItem}
       />
       />
 
 
       {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
       {!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 { SidebarContentsType } from '~/interfaces/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 
+
 import { Bookmarks } from './Bookmarks';
 import { Bookmarks } from './Bookmarks';
 import { CustomSidebar } from './Custom';
 import { CustomSidebar } from './Custom';
 import { PageTree } from './PageTree';
 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';

+ 25 - 0
apps/app/src/stores/modal.tsx

@@ -739,3 +739,28 @@ export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & Li
     },
     },
   });
   });
 };
 };
+
+/*
+* PageSelectModal
+*/
+type PageSelectModalStatus = {
+  isOpened: boolean;
+}
+
+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: () => swrResponse.mutate({ isOpened: true }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};