Browse Source

WIP: refactor Ellipsis to usePageItemControl

Yuki Takei 1 year ago
parent
commit
4f13387a90

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

@@ -63,12 +63,13 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
 
 
   const {
   const {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    onSubmit: _onSubmit, onCancel: _onCancel,
+    value: _value, onSubmit: _onSubmit, onCancel: _onCancel,
     ...cleanedProps
     ...cleanedProps
   } = props;
   } = props;
 
 
   return {
   return {
     ...cleanedProps,
     ...cleanedProps,
+    value: inputText,
     onChange: changeHandler,
     onChange: changeHandler,
     onKeyDown: keyDownHandler,
     onKeyDown: keyDownHandler,
     onBlur: blurHandler,
     onBlur: blurHandler,

+ 1 - 1
apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.module.scss

@@ -4,6 +4,6 @@
     // left: 0;
     // left: 0;
     // display: inline-flex;
     // display: inline-flex;
 
 
-    // width: calc(100% - 80px);
+    width: calc(100% - 35px);
   }
   }
 }
 }

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

@@ -1,193 +0,0 @@
-import type { FC } from 'react';
-import React, {
-  useCallback, useRef, useState,
-} from 'react';
-
-import nodePath from 'path';
-
-
-import type { IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useRect } from '@growi/ui/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 { AutosizeSubmittableInput } from '~/components/Common/SubmittableInput';
-import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
-import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
-import { useSWRMUTxPageInfo } from '~/stores/page';
-
-import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-import {
-  type TreeItemToolProps, NotDraggableForClosableTextInput,
-} from '../../TreeItem';
-
-
-import styles from './Ellipsis.module.scss';
-
-const renameInputContainerClass = styles['rename-input-container'] ?? '';
-
-
-export const Ellipsis: FC<TreeItemToolProps> = (props) => {
-  const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const { t } = useTranslation();
-
-  const {
-    itemNode, onRenamed, onClickDuplicateMenuItem,
-    onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
-  } = props;
-
-  const parentRef = useRef<HTMLDivElement>(null);
-  const parentRect = useRect(parentRef);
-
-  const { page } = itemNode;
-
-  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 cancel = useCallback(() => {
-    setRenameInputShown(false);
-  }, []);
-
-  const rename = useCallback(async(inputText) => {
-    if (inputText.trim() === '') {
-      return cancel();
-    }
-
-    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,
-      });
-
-      onRenamed?.(page.path, newPagePath);
-
-      toastSuccess(t('renamed_pages', { path: page.path }));
-    }
-    catch (err) {
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  }, [cancel, onRenamed, page._id, page.path, page.revision, t]);
-
-  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'));
-    }
-  };
-
-  const maxWidth = parentRect[0]?.width;
-  console.log({ parentRef });
-  console.log('maxWidth:', maxWidth);
-
-  return (
-    <>
-      {/* {isRenameInputShown || page._id === '6630d957b26dc26e85ee21a8' ? ( */}
-      {/* <NotDraggableForClosableTextInput> */}
-      <div ref={parentRef} className={`position-absolute ${renameInputContainerClass} ${isRenameInputShown || page._id === '6630d957b26dc26e85ee21a8' ? '' : 'd-none'}`}>
-        <AutosizeSubmittableInput
-          value={nodePath.basename(page.path ?? '')}
-          inputClassName="form-control"
-          inputStyle={{ maxWidth }}
-          placeholder={t('Input page name')}
-          onSubmit={rename}
-          onCancel={cancel}
-          // validationTarget={ValidationTarget.PAGE}
-          autoFocus
-        />
-      </div>
-      {/* </NotDraggableForClosableTextInput> */}
-
-      { !isRenameInputShown && (
-        <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 mr-1">
-                <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
-              </DropdownToggle>
-            </PageItemControl>
-          </div>
-        </NotAvailableForGuest>
-      ) }
-    </>
-  );
-};

+ 5 - 2
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -24,7 +24,7 @@ import {
 
 
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
 import { CreatingNewPageSpinner } from './CreatingNewPageSpinner';
 import { CreatingNewPageSpinner } from './CreatingNewPageSpinner';
-import { Ellipsis } from './Ellipsis';
+import { usePageItemControl } from './use-page-item-control';
 
 
 import styles from './PageTreeItem.module.scss';
 import styles from './PageTreeItem.module.scss';
 
 
@@ -60,6 +60,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [shouldHide, setShouldHide] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
 
 
+  const { showRenameInput, Control, RenameInput } = usePageItemControl();
   const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
   const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
@@ -189,9 +190,11 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       itemClass={PageTreeItem}
       itemClass={PageTreeItem}
       mainClassName={mainClassName}
       mainClassName={mainClassName}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customEndComponents={[CountBadgeForPageTreeItem]}
-      customHoveredEndComponents={[Ellipsis, NewPageCreateButton]}
+      customHoveredEndComponents={[Control, NewPageCreateButton]}
       customNextComponents={[NewPageInput]}
       customNextComponents={[NewPageInput]}
       customNextToChildrenComponents={[() => <CreatingNewPageSpinner show={isProcessingSubmission} />]}
       customNextToChildrenComponents={[() => <CreatingNewPageSpinner show={isProcessingSubmission} />]}
+      showAlternativeContent={showRenameInput}
+      customAlternativeComponents={[RenameInput]}
     />
     />
   );
   );
 };
 };

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

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

+ 217 - 0
apps/app/src/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -0,0 +1,217 @@
+import type { FC } from 'react';
+import React, {
+  useCallback, useRef, useState,
+} from 'react';
+
+import nodePath from 'path';
+
+import type { IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useRect } from '@growi/ui/dist/utils';
+import { useTranslation } from 'next-i18next';
+import type { AutosizeInputProps } from 'react-input-autosize';
+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 { AutosizeSubmittableInput } from '~/components/Common/SubmittableInput';
+import type { SubmittableInputProps } from '~/components/Common/SubmittableInput/types';
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import Page from '~/pages/[[...path]].page';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxPageInfo } from '~/stores/page';
+
+import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+import {
+  type TreeItemToolProps, NotDraggableForClosableTextInput,
+} from '../../TreeItem';
+
+
+import styles from './Ellipsis.module.scss';
+
+
+type UsePageItemControl = {
+  Control: FC<TreeItemToolProps>,
+  RenameInput: FC<TreeItemToolProps>,
+  showRenameInput: boolean,
+}
+
+export const usePageItemControl = (): UsePageItemControl => {
+  const { t } = useTranslation();
+
+  const [showRenameInput, setShowRenameInput] = useState(false);
+
+
+  const Control: FC<TreeItemToolProps> = (props) => {
+    const {
+      itemNode,
+      isEnableActions,
+      isReadOnlyUser,
+      onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    } = props;
+    const { page } = itemNode;
+
+    const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+    const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
+
+    const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean): Promise<void> => {
+      const bookmarkOperation = _newValue ? bookmark : unbookmark;
+      await bookmarkOperation(_pageId);
+      mutateCurrentUserBookmarks();
+      mutatePageInfo();
+    }, [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(() => {
+      setShowRenameInput(true);
+    }, []);
+
+    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 (
+      <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 mr-1">
+              <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
+            </DropdownToggle>
+          </PageItemControl>
+        </div>
+      </NotAvailableForGuest>
+    );
+  };
+
+
+  const RenameInput: FC<TreeItemToolProps> = (props) => {
+    const { itemNode, onRenamed } = props;
+    const { page } = itemNode;
+
+    const parentRef = useRef<HTMLDivElement>(null);
+    const [parentRect] = useRect(parentRef);
+
+    const cancel = useCallback(() => {
+      setShowRenameInput(false);
+    }, []);
+
+    const rename = useCallback(async(inputText) => {
+      if (inputText.trim() === '') {
+        return cancel();
+      }
+
+      const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
+      const newPagePath = nodePath.resolve(parentPath, inputText);
+
+      if (newPagePath === page.path) {
+        setShowRenameInput(false);
+        return;
+      }
+
+      try {
+        await apiv3Put('/pages/rename', {
+          pageId: page._id,
+          revisionId: page.revision,
+          newPagePath,
+        });
+
+        onRenamed?.(page.path, newPagePath);
+        setShowRenameInput(false);
+
+        toastSuccess(t('renamed_pages', { path: page.path }));
+      }
+      catch (err) {
+        setShowRenameInput(true);
+        toastError(err);
+      }
+    }, [cancel, onRenamed, page._id, page.path, page.revision]);
+
+
+    const renameInputContainerClass = styles['rename-input-container'] ?? '';
+    const maxWidth = parentRect?.width;
+
+    return (
+      <>
+        {/* <NotDraggableForClosableTextInput> */}
+        <div ref={parentRef} className={`${renameInputContainerClass}`}>
+          <AutosizeSubmittableInput
+            value={nodePath.basename(page.path ?? '')}
+            inputClassName="form-control"
+            inputStyle={{ maxWidth }}
+            placeholder={t('Input page name')}
+            onSubmit={rename}
+            onCancel={cancel}
+            // validationTarget={ValidationTarget.PAGE}
+            autoFocus
+          />
+        </div>
+        {/* </NotDraggableForClosableTextInput> */}
+      </>
+    );
+  };
+
+
+  return {
+    Control,
+    RenameInput,
+    showRenameInput,
+  };
+
+};

+ 18 - 21
apps/app/src/components/TreeItem/TreeItemLayout.tsx

@@ -45,13 +45,13 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
     itemNode, targetPathOrId, isOpen: _isOpen = false,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass, mainClassName,
     itemRef, itemClass, mainClassName,
+    showAlternativeContent,
   } = props;
   } = props;
 
 
   const { page, children } = itemNode;
   const { page, children } = itemNode;
 
 
   const [currentChildren, setCurrentChildren] = useState(children);
   const [currentChildren, setCurrentChildren] = useState(children);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
-  const [showAlternativeContent, setShowAlternativeContent] = useState(false);
 
 
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
@@ -83,10 +83,6 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
     setIsOpen(!isOpen);
     setIsOpen(!isOpen);
   }, [isOpen]);
   }, [isOpen]);
 
 
-  const onSwitchToAlternativeContent = useCallback(() => {
-    setShowAlternativeContent(!showAlternativeContent);
-  }, [showAlternativeContent]);
-
   // didMount
   // didMount
   useEffect(() => {
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
     if (hasChildren()) setIsOpen(true);
@@ -129,7 +125,6 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
   const toolProps: TreeItemToolProps = {
   const toolProps: TreeItemToolProps = {
     ...baseProps,
     ...baseProps,
     itemNode,
     itemNode,
-    onSwitchToAlternativeContent,
   };
   };
 
 
   const EndComponents = props.customEndComponents;
   const EndComponents = props.customEndComponents;
@@ -177,23 +172,25 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
               <AlternativeContent key={index} {...toolProps} />
               <AlternativeContent key={index} {...toolProps} />
             ))
             ))
           )
           )
-          : <SimpleItemContent page={page} />
+          : (
+            <>
+              <SimpleItemContent page={page} />
+              <div className="d-hover-none">
+                {EndComponents?.map((EndComponent, index) => (
+                  // eslint-disable-next-line react/no-array-index-key
+                  <EndComponent key={index} {...toolProps} />
+                ))}
+              </div>
+              <div className="d-none d-hover-flex">
+                {HoveredEndComponents?.map((HoveredEndContent, index) => (
+                  // eslint-disable-next-line react/no-array-index-key
+                  <HoveredEndContent key={index} {...toolProps} />
+                ))}
+              </div>
+            </>
+          )
         }
         }
 
 
-        <div className="d-hover-none">
-          {EndComponents?.map((EndComponent, index) => (
-            // eslint-disable-next-line react/no-array-index-key
-            <EndComponent key={index} {...toolProps} />
-          ))}
-        </div>
-
-        <div className="d-none d-hover-flex">
-          {HoveredEndComponents?.map((HoveredEndContent, index) => (
-            // eslint-disable-next-line react/no-array-index-key
-            <HoveredEndContent key={index} {...toolProps} />
-          ))}
-        </div>
-
       </li>
       </li>
 
 
       {NextComponents?.map((NextContent, index) => (
       {NextComponents?.map((NextContent, index) => (

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

@@ -19,9 +19,7 @@ type TreeItemBaseProps = {
   },
   },
 }
 }
 
 
-export type TreeItemToolProps = TreeItemBaseProps & {
-  onSwitchToAlternativeContent?(): void,
-};
+export type TreeItemToolProps = TreeItemBaseProps;
 
 
 export type TreeItemProps = TreeItemBaseProps & {
 export type TreeItemProps = TreeItemBaseProps & {
   targetPathOrId?: Nullable<string>,
   targetPathOrId?: Nullable<string>,
@@ -33,6 +31,7 @@ export type TreeItemProps = TreeItemBaseProps & {
   customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customNextComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customNextComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customNextToChildrenComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customNextToChildrenComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  showAlternativeContent?: boolean,
   customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   onClick?(page: IPageForItem): void,
   onClick?(page: IPageForItem): void,
 };
 };