Browse Source

components segmentation

WNomunomu 2 years ago
parent
commit
bf1cbaeeec

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

@@ -26,10 +26,10 @@ import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import { PageTreeItem } from '../PageTreeItem/PageTreeItem';
 import PageTreeContentSkeleton from '../Skeleton/PageTreeContentSkeleton';
 
 import { ItemNode } from './ItemNode';
-import { PageTreeItem } from './PageTreeItem';
 
 import styles from './ItemsTree.module.scss';
 

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

@@ -1,463 +0,0 @@
-import React, {
-  useCallback, useState, FC,
-} from 'react';
-
-import nodePath from 'path';
-
-
-import { pathUtils, pagePathUtils } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import { useDrag, useDrop } from 'react-dnd';
-import { DropdownToggle } from 'reactstrap';
-
-import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
-import { apiv3Post, apiv3Put } 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 {
-  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta, IPageForItem,
-} from '~/interfaces/page';
-import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
-import { useSWRMUTxPageInfo } from '~/stores/page';
-import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import ClosableTextInput from '../../Common/ClosableTextInput';
-import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-
-import { ItemNode } from './ItemNode';
-import SimpleItem, {
-  SimpleItemProps, SimpleItemToolProps, NotDraggableForClosableTextInput, SimpleItemTool,
-} from './SimpleItem';
-
-const logger = loggerFactory('growi:cli:Item');
-
-type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
-
-type EllipsisProps = SimpleItemToolProps & {page: IPageForItem};
-
-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>
-    </>
-  );
-};
-
-export const useNewPageInput = () => {
-
-  const [isNewPageInputShown, setNewPageInputShown] = useState(false);
-
-  const NewPageCreateButton = (props) => {
-    const {
-      page, children, stateHandlers,
-    } = props;
-
-    const { setIsOpen } = stateHandlers;
-
-    const currentChildren = children;
-
-    // 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]);
-
-    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>
-        )}
-      </>
-    );
-  };
-
-  const NewPageInput = (props) => {
-    const { t } = useTranslation();
-
-    const {
-      page, isEnableActions, children, stateHandlers,
-    } = props;
-
-    const { isOpen, setIsOpen, setCreating } = stateHandlers;
-
-    const currentChildren = children;
-
-    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);
-      }
-    };
-
-    return (
-      <>
-        {isEnableActions && isNewPageInputShown && (
-          <div className="flex-fill">
-            <NotDraggableForClosableTextInput>
-              <ClosableTextInput
-                placeholder={t('Input page name')}
-                onClickOutside={() => { setNewPageInputShown(false) }}
-                onPressEnter={onPressEnterForCreateHandler}
-                validationTarget={ValidationTarget.PAGE}
-              />
-            </NotDraggableForClosableTextInput>
-          </div>
-        )}
-      </>
-    );
-  };
-
-  return {
-    NewPageInput,
-    NewPageCreateButton,
-  };
-};
-
-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 { NewPageInput, NewPageCreateButton } = 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, NewPageCreateButton]}
-      customNextComponents={[NewPageInput]}
-    />
-  );
-};

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

@@ -0,0 +1,171 @@
+import React, {
+  useCallback, useState, FC,
+} from 'react';
+
+import nodePath from 'path';
+
+
+import { pathUtils } from '@growi/core';
+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 {
+  IPageInfoAll, IPageToDeleteWithMeta, 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/SimpleItem';
+
+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>
+    </>
+  );
+};

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

@@ -0,0 +1,226 @@
+import React, {
+  useCallback, useState, FC,
+} from 'react';
+
+import nodePath from 'path';
+
+
+import { pathUtils, pagePathUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import { useDrag, useDrop } from 'react-dnd';
+import { DropdownToggle } from 'reactstrap';
+
+import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
+import { apiv3Post, apiv3Put } 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 {
+  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta, IPageForItem,
+} from '~/interfaces/page';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxPageInfo } from '~/stores/page';
+import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+import ClosableTextInput from '../../Common/ClosableTextInput';
+import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+import { NewPageCreateButton } from '../../TreeItem/NewPageCreateButton';
+import { NewPageInput } from '../../TreeItem/NewPageInput';
+import SimpleItem, {
+  SimpleItemProps, SimpleItemToolProps, NotDraggableForClosableTextInput, SimpleItemTool,
+} from '../../TreeItem/SimpleItem';
+import { ItemNode } from '../PageTree/ItemNode';
+
+const logger = loggerFactory('growi:cli:Item');
+
+type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
+type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
+
+export const useNewPageInput = () => {
+
+  const [isNewPageInputShown, setNewPageInputShown] = useState(false);
+
+  const NewPageCreateButtonWrapper = (props) => {
+    return (
+      <NewPageCreateButton
+        page={props.page}
+        children={props.children}
+        stateHandlers={props.stateHandlers}
+        setNewPageInputShown={setNewPageInputShown}
+      />
+    );
+  };
+
+  const NewPageInputWrapper = (props) => {
+    return (
+      <NewPageInput
+        page={props.page}
+        isEnableActions={props.isEnableActions}
+        children={props.chilren}
+        stateHandlers={props.stateHandlers}
+        isNewPageInputShown={isNewPageInputShown}
+        setNewPageInputShown={setNewPageInputShown}
+      />
+    );
+  };
+
+
+  return {
+    NewPageInputWrapper,
+    NewPageCreateButtonWrapper,
+  };
+};
+
+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 - 1
apps/app/src/components/Sidebar/PageTree/PageTreeItemForModal.tsx → apps/app/src/components/Sidebar/PageTreeItem/PageTreeItemForModal.tsx

@@ -1,7 +1,8 @@
 import React, { FC } from 'react';
 
+import SimpleItem, { SimpleItemProps, SimpleItemTool } from '../../TreeItem/SimpleItem';
+
 import { useNewPageInput } from './PageTreeItem';
-import SimpleItem, { SimpleItemProps, SimpleItemTool } from './SimpleItem';
 
 type Optional = 'itemRef' | 'itemClass' | 'mainClassName';
 type PageTreeItemProps = Omit<SimpleItemProps, Optional> & {key};

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


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

@@ -0,0 +1,53 @@
+import React, {
+  useCallback,
+} from 'react';
+
+import { pagePathUtils } from '@growi/core';
+
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnlyUser';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+export const NewPageCreateButton = (props) => {
+  const {
+    page, children, stateHandlers, setNewPageInputShown,
+  } = props;
+
+  const { setIsOpen } = stateHandlers;
+
+  const currentChildren = children;
+
+  // 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]);
+
+  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>
+      )}
+    </>
+  );
+};

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

@@ -0,0 +1,91 @@
+import React from 'react';
+
+import nodePath from 'path';
+
+
+import { pathUtils, pagePathUtils } from '@growi/core';
+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 { NotDraggableForClosableTextInput } from './SimpleItem';
+
+export const NewPageInput = (props) => {
+  const { t } = useTranslation();
+
+  const {
+    page, isEnableActions, children, stateHandlers, isNewPageInputShown, setNewPageInputShown,
+  } = props;
+
+  const { isOpen, setIsOpen, setCreating } = stateHandlers;
+
+  const currentChildren = children;
+
+  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);
+    }
+  };
+
+  return (
+    <>
+      {isEnableActions && isNewPageInputShown && (
+        <div className="flex-fill">
+          <NotDraggableForClosableTextInput>
+            <ClosableTextInput
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setNewPageInputShown(false) }}
+              onPressEnter={onPressEnterForCreateHandler}
+              validationTarget={ValidationTarget.PAGE}
+            />
+          </NotDraggableForClosableTextInput>
+        </div>
+      )}
+    </>
+  );
+};

+ 2 - 3
apps/app/src/components/Sidebar/PageTree/SimpleItem.tsx → apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -18,9 +18,8 @@ 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';
+import CountBadge from '../Common/CountBadge';
+import { ItemNode } from '../Sidebar/PageTree/ItemNode';
 
 
 export type SimpleItemProps = {