Просмотр исходного кода

feat: implement page renaming feature with validation and UI integration

Yuki Takei 4 месяцев назад
Родитель
Сommit
26175cf82b

+ 21 - 3
apps/app/src/client/components/Sidebar/PageTreeItem/SimplifiedPageTreeItem.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
-import { useCallback } from 'react';
+import { useCallback, useMemo } from 'react';
 
 import path from 'path';
 
@@ -10,7 +10,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
-import { ROOT_PAGE_VIRTUAL_ID, usePageTreeInformationUpdate } from '~/features/page-tree';
+import { ROOT_PAGE_VIRTUAL_ID, usePageTreeInformationUpdate, RenameInput } from '~/features/page-tree';
 import type { IPageForItem } from '~/interfaces/page';
 import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
 import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
@@ -21,7 +21,7 @@ import { mutateAllPageInfo } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 
-import type { TreeItemProps } from '../../TreeItem';
+import type { TreeItemProps, TreeItemToolProps } from '../../TreeItem';
 import { TreeItemLayout } from '../../TreeItem';
 
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
@@ -42,6 +42,7 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
   isWipPageShown,
   isEnableActions = false,
   isReadOnlyUser = false,
+  validateName,
   onToggle,
 }) => {
   const { t } = useTranslation();
@@ -105,6 +106,21 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
 
   const { Control } = usePageItemControl();
 
+  // Check if item is in renaming mode
+  const isRenaming = item.isRenaming?.() ?? false;
+
+  // Create RenameInput as AlternativeComponent
+  // RenameAlternative component with explicit props validation for 'item'
+  const RenameAlternative: FC<TreeItemToolProps> = useMemo(() => {
+    const RenameAlternativeComponent: FC<{ item: TreeItemToolProps['item'] }> = ({ item: itemInstance }) => (
+      <RenameInput
+        inputProps={itemInstance.getRenameInputProps()}
+        validateName={validateName}
+      />
+    );
+    return RenameAlternativeComponent;
+  }, [validateName]);
+
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) return;
 
@@ -135,6 +151,8 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customHoveredEndComponents={[Control]}
+      showAlternativeContent={isRenaming}
+      customAlternativeComponents={[RenameAlternative]}
     />
   );
 };

+ 5 - 112
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -1,24 +1,14 @@
-import type { ChangeEvent, FC } from 'react';
-import React, {
-  useCallback, useRef, useState,
-} from 'react';
-
-import nodePath from 'path';
+import type { FC } from 'react';
+import React, { useCallback } from 'react';
 
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { getIdStringForRef } 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 { debounce } from 'throttle-debounce';
 
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/client/components/Common/SubmittableInput';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { ValidationTarget, useInputValidator, type InputValidationResult } from '~/client/util/use-input-validator';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
@@ -28,15 +18,11 @@ import type { TreeItemToolProps } from '../../TreeItem';
 
 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 {
@@ -74,8 +60,9 @@ export const usePageItemControl = (): UsePageItemControl => {
     }, [onClickDuplicateMenuItem, page]);
 
     const renameMenuItemClickHandler = useCallback(() => {
-      setShowRenameInput(true);
-    }, []);
+      // Use headless-tree's renamingFeature
+      item.startRenaming();
+    }, [item]);
 
     const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
       if (onClickDeleteMenuItem == null) {
@@ -135,102 +122,8 @@ export const usePageItemControl = (): UsePageItemControl => {
   };
 
 
-  const RenameInput: FC<TreeItemToolProps> = (props) => {
-    const { item, onRenamed } = props;
-    const page = item.getItemData();
-
-    const parentRef = useRef<HTMLDivElement>(null);
-    const [parentRect] = useRect(parentRef);
-
-    const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
-
-    const inputValidator = useInputValidator(ValidationTarget.PAGE);
-
-    const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-      const validationResult = inputValidator(e.target.value);
-      setValidationResult(validationResult ?? undefined);
-    }, [inputValidator]);
-    const changeHandlerDebounced = debounce(300, changeHandler);
-
-    const cancel = useCallback(() => {
-      setValidationResult(undefined);
-      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) {
-        setValidationResult(undefined);
-        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) {
-        toastError(err);
-      }
-      finally {
-        setValidationResult(undefined);
-      }
-
-    }, [cancel, onRenamed, page._id, page.path, page.revision]);
-
-
-    if (!showRenameInput) {
-      return <></>;
-    }
-
-    const isInvalid = validationResult != null;
-
-    const maxWidth = parentRect != null
-      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
-      : undefined;
-
-    return (
-      <div ref={parentRef} className="flex-fill">
-        <AutosizeSubmittableInput
-          value={nodePath.basename(page.path ?? '')}
-          inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
-          inputStyle={{ maxWidth }}
-          placeholder={t('Input page name')}
-          aria-describedby={isInvalid ? 'rename-feedback' : undefined}
-          onChange={changeHandlerDebounced}
-          onSubmit={rename}
-          onCancel={cancel}
-          autoFocus
-        />
-        { isInvalid && (
-          <div id="rename-feedback" className="invalid-feedback d-block my-1">
-            {validationResult.message}
-          </div>
-        ) }
-      </div>
-    );
-  };
-
-
   return {
     Control,
-    RenameInput,
-    showRenameInput,
   };
 
 };

+ 10 - 0
apps/app/src/features/page-tree/client/components/RenameInput.module.scss

@@ -0,0 +1,10 @@
+@use './tree-item-variables' as vars;
+
+.rename-input {
+  width: 100%;
+
+  input {
+    height: vars.$tree-item-height;
+    font-size: 14px;
+  }
+}

+ 53 - 0
apps/app/src/features/page-tree/client/components/RenameInput.tsx

@@ -0,0 +1,53 @@
+import type { FC, InputHTMLAttributes } from 'react';
+import { useState } from 'react';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+
+import styles from './RenameInput.module.scss';
+
+const moduleClass = styles['rename-input'] ?? '';
+
+type RenameInputProps = {
+  inputProps: InputHTMLAttributes<HTMLInputElement> & { ref?: (r: HTMLInputElement | null) => void };
+  validateName?: (name: string) => InputValidationResult | null;
+  className?: string;
+};
+
+export const RenameInput: FC<RenameInputProps> = ({
+  inputProps,
+  validateName,
+  className,
+}) => {
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult | null>(null);
+
+  const validate = debounce(300, (value: string) => {
+    setValidationResult(validateName?.(value) ?? null);
+  });
+
+  const isInvalid = validationResult != null;
+
+  return (
+    <div className={`${moduleClass} ${className ?? ''} flex-fill`}>
+      <input
+        {...inputProps}
+        onChange={(e) => {
+          inputProps.onChange?.(e);
+          validate(e.target.value);
+        }}
+        onBlur={(e) => {
+          setValidationResult(null);
+          inputProps.onBlur?.(e);
+        }}
+        type="text"
+        className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+      />
+      {isInvalid && (
+        <div className="invalid-feedback d-block my-1">
+          {validationResult.message}
+        </div>
+      )}
+    </div>
+  );
+};

+ 31 - 5
apps/app/src/features/page-tree/client/components/SimplifiedItemsTree.tsx

@@ -1,6 +1,11 @@
 import type { FC } from 'react';
-import { useState } from 'react';
-import { asyncDataLoaderFeature } from '@headless-tree/core';
+import { useCallback, useState } from 'react';
+import {
+  asyncDataLoaderFeature,
+  hotkeysCoreFeature,
+  renamingFeature,
+  selectionFeature,
+} from '@headless-tree/core';
 import { useTree } from '@headless-tree/react';
 import { useVirtualizer } from '@tanstack/react-virtual';
 
@@ -9,6 +14,7 @@ import { useSWRxRootPage } from '~/stores/page-listing';
 
 import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
 import { useDataLoader } from '../hooks/use-data-loader';
+import { usePageRename } from '../hooks/use-page-rename';
 import { useScrollToSelectedItem } from '../hooks/use-scroll-to-selected-item';
 import type { TreeItemProps } from '../interfaces';
 import {
@@ -48,9 +54,22 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
 
   const dataLoader = useDataLoader(rootPageId, allPagesCount);
 
+  // Page rename hook
+  const { rename, validateName, getPageName } = usePageRename();
+
+  // onRename handler for headless-tree
+  const handleRename = useCallback(
+    async (item, newValue: string) => {
+      await rename(item, newValue);
+      // Trigger re-render after rename
+      setRebuildTrigger((prev) => prev + 1);
+    },
+    [rename],
+  );
+
   const tree = useTree<IPageForTreeItem>({
     rootItemId: ROOT_PAGE_VIRTUAL_ID,
-    getItemName: (item) => item.getItemData().path || '/',
+    getItemName: (item) => getPageName(item),
     initialState: { expandedItems: [ROOT_PAGE_VIRTUAL_ID] },
     isItemFolder: (item) => item.getItemData().descendantCount > 0,
     createLoadingItemData: () => ({
@@ -64,7 +83,13 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
       wip: false,
     }),
     dataLoader,
-    features: [asyncDataLoaderFeature],
+    onRename: handleRename,
+    features: [
+      asyncDataLoaderFeature,
+      selectionFeature,
+      hotkeysCoreFeature,
+      renamingFeature,
+    ],
   });
 
   // Track local generation number
@@ -90,7 +115,7 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
   useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
 
   return (
-    <div className="list-group">
+    <div {...tree.getContainerProps()} className="list-group">
       {virtualizer.getVirtualItems().map((virtualItem) => {
         const item = items[virtualItem.index];
         const itemData = item.getItemData();
@@ -125,6 +150,7 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
               isWipPageShown={isWipPageShown}
               isEnableActions={isEnableActions}
               isReadOnlyUser={isReadOnlyUser}
+              validateName={validateName}
               onToggle={() => {
                 // Trigger re-render to show/hide children
                 setRebuildTrigger((prev) => prev + 1);

+ 5 - 4
apps/app/src/features/page-tree/client/components/TreeItemLayout.tsx

@@ -33,6 +33,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     isReadOnlyUser,
     isWipPageShown = true,
     showAlternativeContent,
+    validateName,
     onRenamed,
     onClick,
     onClickDuplicateMenuItem,
@@ -135,8 +136,8 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
       className={`${moduleClass} ${className}`}
       style={{ paddingLeft: `${itemLevel > 0 ? indentSize * itemLevel : 0}px` }}
     >
+      {/* biome-ignore lint/a11y/useKeyWithClickEvents: tree item interaction */}
       <li
-        role="button"
         className={`list-group-item list-group-item-action
           ${isSelected ? 'active' : ''}
           ${itemClassName ?? ''}
@@ -163,7 +164,7 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
 
         {showAlternativeContent && AlternativeComponents != null ? (
           AlternativeComponents.map((AlternativeContent, index) => (
-            // eslint-disable-next-line react/no-array-index-key
+            // biome-ignore lint/suspicious/noArrayIndexKey: static component list
             <AlternativeContent key={index} {...toolProps} />
           ))
         ) : (
@@ -171,13 +172,13 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
             <SimpleItemContent page={page} />
             <div className="d-hover-none">
               {EndComponents?.map((EndComponent, index) => (
-                // eslint-disable-next-line react/no-array-index-key
+                // biome-ignore lint/suspicious/noArrayIndexKey: static component list
                 <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
+                // biome-ignore lint/suspicious/noArrayIndexKey: static component list
                 <HoveredEndContent key={index} {...toolProps} />
               ))}
             </div>

+ 1 - 0
apps/app/src/features/page-tree/client/components/_tree-item-variables.scss

@@ -1 +1,2 @@
 $btn-triangle-min-width: 35px;
+$tree-item-height: 40px;

+ 133 - 0
apps/app/src/features/page-tree/client/hooks/use-page-rename.ts

@@ -0,0 +1,133 @@
+import { useCallback } from 'react';
+import { pathUtils } from '@growi/core/dist/utils';
+import type { ItemInstance } from '@headless-tree/core';
+import { useTranslation } from 'next-i18next';
+import nodePath from 'path';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+import type { IPageForItem } from '~/interfaces/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { usePageTreeInformationUpdate } from '../states/page-tree-update';
+
+type RenameResult = {
+  success: boolean;
+  oldPath?: string;
+  newPath?: string;
+  error?: Error;
+};
+
+type UsePageRenameReturn = {
+  /**
+   * Rename a page
+   * @param item The item instance from headless-tree
+   * @param newName The new page name (basename only, not full path)
+   * @returns Promise with rename result
+   */
+  rename: (
+    item: ItemInstance<IPageForItem>,
+    newName: string,
+  ) => Promise<RenameResult>;
+
+  /**
+   * Validate page name
+   * @param name The page name to validate
+   * @returns Validation result or null if valid
+   */
+  validateName: (name: string) => InputValidationResult | null;
+
+  /**
+   * Get the current page name (basename) from item
+   */
+  getPageName: (item: ItemInstance<IPageForItem>) => string;
+};
+
+/**
+ * Hook for page rename logic
+ * Separates business logic from UI for renamingFeature integration
+ */
+export const usePageRename = (): UsePageRenameReturn => {
+  const { t } = useTranslation();
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+
+  const getPageName = useCallback(
+    (item: ItemInstance<IPageForItem>): string => {
+      const page = item.getItemData();
+      return nodePath.basename(page.path ?? '');
+    },
+    [],
+  );
+
+  const validateName = useCallback(
+    (name: string): InputValidationResult | null => {
+      const result = inputValidator(name);
+      return result ?? null;
+    },
+    [inputValidator],
+  );
+
+  const rename = useCallback(
+    async (
+      item: ItemInstance<IPageForItem>,
+      newName: string,
+    ): Promise<RenameResult> => {
+      const page = item.getItemData();
+      const oldPath = page.path;
+
+      // Trim and validate
+      const trimmedName = newName.trim();
+      if (trimmedName === '') {
+        return { success: false };
+      }
+
+      // Build new path
+      const parentPath = pathUtils.addTrailingSlash(
+        nodePath.dirname(oldPath ?? ''),
+      );
+      const newPagePath = nodePath.resolve(parentPath, trimmedName);
+
+      // No change needed
+      if (newPagePath === oldPath) {
+        return { success: true, oldPath, newPath: newPagePath };
+      }
+
+      try {
+        await apiv3Put('/pages/rename', {
+          pageId: page._id,
+          revisionId: page.revision,
+          newPagePath,
+        });
+
+        // Mutate page tree
+        mutatePageTree();
+
+        // Notify headless-tree to update
+        const parentId = item.getParent()?.getId();
+        if (parentId) {
+          notifyUpdateItems([parentId]);
+        }
+
+        toastSuccess(t('renamed_pages', { path: oldPath }));
+
+        return { success: true, oldPath, newPath: newPagePath };
+      } catch (err) {
+        toastError(err);
+        return { success: false, oldPath, error: err as Error };
+      }
+    },
+    [t, notifyUpdateItems],
+  );
+
+  return {
+    rename,
+    validateName,
+    getPageName,
+  };
+};

+ 25 - 17
apps/app/src/features/page-tree/client/interfaces/index.ts

@@ -1,35 +1,43 @@
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { ItemInstance } from '@headless-tree/core';
 
+import type { InputValidationResult } from '~/client/util/use-input-validator';
 import type { IPageForTreeItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 
 type TreeItemBaseProps = {
-  item: ItemInstance<IPageForTreeItem>,
-  isEnableActions: boolean,
-  isReadOnlyUser: boolean,
-  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
-  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
-  onRenamed?(fromPath: string | undefined, toPath: string): void,
-}
+  item: ItemInstance<IPageForTreeItem>;
+  isEnableActions: boolean;
+  isReadOnlyUser: boolean;
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void;
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void;
+  onRenamed?(fromPath: string | undefined, toPath: string): void;
+};
 
 export type TreeItemToolProps = TreeItemBaseProps & {
   stateHandlers?: {
-    setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
-  },
+    setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
+  };
 };
 
 export type TreeItemProps = TreeItemBaseProps & {
   targetPath: string;
   targetPathOrId?: string | null;
   isWipPageShown?: boolean;
-  itemClassName?: string,
-  customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customHeadOfChildrenComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  showAlternativeContent?: boolean,
-  customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  itemClassName?: string;
+  customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>;
+  customHoveredEndComponents?: Array<
+    React.FunctionComponent<TreeItemToolProps>
+  >;
+  customHeadOfChildrenComponents?: Array<
+    React.FunctionComponent<TreeItemToolProps>
+  >;
+  showAlternativeContent?: boolean;
+  customAlternativeComponents?: Array<
+    React.FunctionComponent<TreeItemToolProps>
+  >;
+  validateName?: (name: string) => InputValidationResult | null;
   onToggle?: () => void;
-  onClick?(page: IPageForTreeItem): void,
-  onWheelClick?(page: IPageForTreeItem): void,
+  onClick?(page: IPageForTreeItem): void;
+  onWheelClick?(page: IPageForTreeItem): void;
 };

+ 2 - 1
apps/app/src/features/page-tree/index.ts

@@ -1,10 +1,11 @@
 // Components
-
+export { RenameInput } from './client/components/RenameInput';
 export { SimpleItemContent } from './client/components/SimpleItemContent';
 export { SimplifiedItemsTree } from './client/components/SimplifiedItemsTree';
 export { TreeItemLayout } from './client/components/TreeItemLayout';
 // Hooks
 export { useDataLoader } from './client/hooks/use-data-loader';
+export { usePageRename } from './client/hooks/use-page-rename';
 export { useScrollToSelectedItem } from './client/hooks/use-scroll-to-selected-item';
 // Interfaces
 export * from './client/interfaces';