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

unify CreateInput and RenameInput to TreeNameInput

Yuki Takei пре 4 месеци
родитељ
комит
98fa9fc282

+ 5 - 4
apps/app/src/client/components/Sidebar/PageTreeItem/SimplifiedPageTreeItem.tsx

@@ -13,6 +13,7 @@ import { toastSuccess } from '~/client/util/toastr';
 import {
   CREATING_PAGE_VIRTUAL_ID,
   ROOT_PAGE_VIRTUAL_ID, usePageTreeInformationUpdate, usePageRename, usePageCreate,
+  NameInputAlternativeComponent,
 } from '~/features/page-tree';
 import type { IPageForItem } from '~/interfaces/page';
 import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
@@ -109,10 +110,10 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
   const { Control } = usePageItemControl();
 
   // Rename feature from usePageRename hook
-  const { isRenaming, RenameAlternativeComponent } = usePageRename();
+  const { isRenaming } = usePageRename();
 
   // Page create feature
-  const { CreateAlternativeComponent, cancelCreating } = usePageCreate();
+  const { cancelCreating } = usePageCreate();
 
   // Check if this is the creating placeholder node
   const isCreatingPlaceholder = itemData._id === CREATING_PAGE_VIRTUAL_ID;
@@ -177,8 +178,8 @@ export const SimplifiedPageTreeItem: FC<TreeItemProps> = ({
       onClickDeleteMenuItem={onClickDeleteMenuItem}
       customEndComponents={[CountBadgeForPageTreeItem]}
       customHoveredEndComponents={[Control]}
-      showAlternativeContent={isRenaming(item) || showCreateInput}
-      customAlternativeComponents={showCreateInput ? [CreateAlternativeComponent] : [RenameAlternativeComponent]}
+      showAlternativeContent={isRenaming(item) || isCreatingPlaceholder}
+      customAlternativeComponents={[NameInputAlternativeComponent]}
     />
   );
 };

+ 0 - 17
apps/app/src/features/page-tree/client/components/CreateInput.module.scss

@@ -1,17 +0,0 @@
-@use './tree-item-variables' as vars;
-
-.create-input-wrapper {
-  padding-top: 4px;
-  padding-right: 8px;
-  padding-bottom: 4px;
-  padding-left: vars.$indent-size;
-
-  .create-input {
-    width: 100%;
-
-    input {
-      height: vars.$tree-item-height;
-      font-size: 14px;
-    }
-  }
-}

+ 0 - 61
apps/app/src/features/page-tree/client/components/CreateInput.tsx

@@ -1,61 +0,0 @@
-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 './CreateInput.module.scss';
-
-const wrapperClass = styles['create-input-wrapper'] ?? '';
-const inputClass = styles['create-input'] ?? '';
-
-type CreateInputProps = {
-  inputProps: InputHTMLAttributes<HTMLInputElement> & {
-    ref?: (r: HTMLInputElement | null) => void;
-  };
-  validateName?: (name: string) => InputValidationResult | null;
-  className?: string;
-  placeholder?: string;
-};
-
-export const CreateInput: FC<CreateInputProps> = ({
-  inputProps,
-  validateName,
-  className,
-  placeholder = 'New Page',
-}) => {
-  const [validationResult, setValidationResult] =
-    useState<InputValidationResult | null>(null);
-
-  const validate = debounce(300, (value: string) => {
-    setValidationResult(validateName?.(value) ?? null);
-  });
-
-  const isInvalid = validationResult != null;
-
-  return (
-    <div className={`${wrapperClass} ${className ?? ''}`}>
-      <div className={`${inputClass} flex-fill`}>
-        <input
-          {...inputProps}
-          onChange={(e) => {
-            inputProps.onChange?.(e);
-            validate(e.target.value);
-          }}
-          onBlur={(e) => {
-            setValidationResult(null);
-            inputProps.onBlur?.(e);
-          }}
-          type="text"
-          placeholder={placeholder}
-          className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
-        />
-        {isInvalid && (
-          <div className="invalid-feedback d-block my-1">
-            {validationResult.message}
-          </div>
-        )}
-      </div>
-    </div>
-  );
-};

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

@@ -1,53 +0,0 @@
-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>
-  );
-};

+ 1 - 1
apps/app/src/features/page-tree/client/components/RenameInput.module.scss → apps/app/src/features/page-tree/client/components/TreeNameInput.module.scss

@@ -1,6 +1,6 @@
 @use './tree-item-variables' as vars;
 
-.rename-input {
+.tree-name-input {
   width: 100%;
 
   input {

+ 109 - 0
apps/app/src/features/page-tree/client/components/TreeNameInput.tsx

@@ -0,0 +1,109 @@
+import type { FC, InputHTMLAttributes } from 'react';
+import { useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import type { TreeItemToolProps } from '../interfaces';
+import { CREATING_PAGE_VIRTUAL_ID } from '../states/page-tree-create';
+
+import styles from './TreeNameInput.module.scss';
+
+const moduleClass = styles['tree-name-input'] ?? '';
+
+type TreeNameInputProps = {
+  /**
+   * Props from headless-tree's getRenameInputProps()
+   * Includes value, onChange, onBlur, onKeyDown, ref
+   */
+  inputProps: InputHTMLAttributes<HTMLInputElement> & {
+    ref?: (r: HTMLInputElement | null) => void;
+  };
+  /**
+   * Validation function for the input value
+   */
+  validateName?: (name: string) => InputValidationResult | null;
+  /**
+   * Placeholder text
+   */
+  placeholder?: string;
+  /**
+   * Additional CSS class
+   */
+  className?: string;
+};
+
+/**
+ * Unified input component for tree item name editing (rename/create)
+ * Uses headless-tree's renamingFeature for keyboard handling (Enter/Escape)
+ */
+export const TreeNameInput: FC<TreeNameInputProps> = ({
+  inputProps,
+  validateName,
+  placeholder,
+  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"
+        placeholder={placeholder}
+        className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+      />
+      {isInvalid && (
+        <div className="invalid-feedback d-block my-1">
+          {validationResult.message}
+        </div>
+      )}
+    </div>
+  );
+};
+
+/**
+ * Alternative component for TreeItemLayout that renders TreeNameInput
+ * Used for both rename and create operations
+ */
+export const NameInputAlternativeComponent: FC<TreeItemToolProps> = ({ item }) => {
+  const { t } = useTranslation();
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
+
+  const validateName = (name: string): InputValidationResult | null => {
+    return inputValidator(name) ?? null;
+  };
+
+  // Show placeholder only for create mode
+  const isCreating = item.getId() === CREATING_PAGE_VIRTUAL_ID;
+  const placeholder = isCreating ? t('Input page name') : undefined;
+
+  return (
+    <TreeNameInput
+      inputProps={item.getRenameInputProps()}
+      validateName={validateName}
+      placeholder={placeholder}
+      className="flex-grow-1"
+    />
+  );
+};

+ 5 - 0
apps/app/src/features/page-tree/client/hooks/use-data-loader.ts

@@ -71,6 +71,11 @@ export const useDataLoader = (
         ];
       }
 
+      // Placeholder node has no children
+      if (itemId === CREATING_PAGE_VIRTUAL_ID) {
+        return [];
+      }
+
       // For all pages (including root), fetch children using their _id
       const response = await apiv3Get<{ children: IPageForTreeItem[] }>(
         '/page-listing/children',

+ 1 - 43
apps/app/src/features/page-tree/client/hooks/use-page-create.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
-import { useCallback, useMemo } from 'react';
+import { useCallback } from 'react';
 import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import type { ItemInstance } from '@headless-tree/core';
@@ -8,16 +8,10 @@ import { join } from 'pathe';
 
 import { useCreatePage } from '~/client/services/create-page';
 import { toastError, toastSuccess, toastWarning } 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, mutateRecentlyUpdated } from '~/stores/page-listing';
 import { shouldCreateWipPage } from '~/utils/should-create-wip-page';
 
-import { CreateInput } from '../components/CreateInput';
 import type { TreeItemToolProps } from '../interfaces';
 import {
   CREATING_PAGE_VIRTUAL_ID,
@@ -55,11 +49,6 @@ type UsePageCreateReturn = {
    */
   isCreatingPlaceholder: (item: ItemInstance<IPageForItem>) => boolean;
 
-  /**
-   * Validate page name
-   */
-  validateName: (name: string) => InputValidationResult | null;
-
   /**
    * Start creating a new page under the specified parent
    */
@@ -74,12 +63,6 @@ type UsePageCreateReturn = {
    * Check if a child is being created under this item
    */
   isCreatingChild: (item: ItemInstance<IPageForItem>) => boolean;
-
-  /**
-   * Alternative component for creating a child page (used in TreeItemLayout)
-   * This renders the CreateInput using item.getRenameInputProps()
-   */
-  CreateAlternativeComponent: FC<TreeItemToolProps>;
 };
 
 /**
@@ -89,7 +72,6 @@ type UsePageCreateReturn = {
 export const usePageCreate = (): UsePageCreateReturn => {
   const { t } = useTranslation();
   const { create: createPage } = useCreatePage();
-  const inputValidator = useInputValidator(ValidationTarget.PAGE);
   const { notifyUpdateItems } = usePageTreeInformationUpdate();
   const creatingParentId = useCreatingParentId();
   const {
@@ -97,14 +79,6 @@ export const usePageCreate = (): UsePageCreateReturn => {
     cancelCreating: cancelCreatingAction,
   } = usePageTreeCreateActions();
 
-  const validateName = useCallback(
-    (name: string): InputValidationResult | null => {
-      const result = inputValidator(name);
-      return result ?? null;
-    },
-    [inputValidator],
-  );
-
   // Wrapped cancelCreating that also notifies tree to remove placeholder
   const cancelCreating = useCallback(() => {
     const parentIdToUpdate = creatingParentId;
@@ -224,28 +198,12 @@ export const usePageCreate = (): UsePageCreateReturn => {
     [create, cancelCreating],
   );
 
-  // CreateInput as alternative component for TreeItemLayout
-  // This uses item.getRenameInputProps() from headless-tree's renamingFeature
-  // Note: SimplifiedPageTreeItem ensures item.isRenaming() is true before rendering this
-  const CreateAlternativeComponent: FC<TreeItemToolProps> = useMemo(() => {
-    const Component: FC<TreeItemToolProps> = ({ item }) => (
-      <CreateInput
-        inputProps={item.getRenameInputProps()}
-        validateName={validateName}
-        className="flex-grow-1"
-      />
-    );
-    return Component;
-  }, [validateName]);
-
   return {
     create,
     createFromPlaceholder,
     isCreatingPlaceholder,
-    validateName,
     startCreating,
     cancelCreating,
     isCreatingChild,
-    CreateAlternativeComponent,
   };
 };

+ 1 - 40
apps/app/src/features/page-tree/client/hooks/use-page-rename.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
-import { useCallback, useMemo } from 'react';
+import { useCallback } from 'react';
 import { pathUtils } from '@growi/core/dist/utils';
 import type { ItemInstance } from '@headless-tree/core';
 import { useTranslation } from 'next-i18next';
@@ -7,16 +7,9 @@ import { basename, dirname, resolve } from 'pathe';
 
 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 { RenameInput } from '../components/RenameInput';
-import type { TreeItemToolProps } from '../interfaces';
 import { CREATING_PAGE_VIRTUAL_ID } from '../states/page-tree-create';
 import { usePageTreeInformationUpdate } from '../states/page-tree-update';
 
@@ -36,11 +29,6 @@ type UsePageRenameReturn = {
     newName: string,
   ) => Promise<RenameResult>;
 
-  /**
-   * Validate page name
-   */
-  validateName: (name: string) => InputValidationResult | null;
-
   /**
    * Get the current page name (basename) from item
    */
@@ -50,11 +38,6 @@ type UsePageRenameReturn = {
    * Check if item is in renaming mode
    */
   isRenaming: (item: ItemInstance<IPageForItem>) => boolean;
-
-  /**
-   * RenameInput component to use as AlternativeComponent
-   */
-  RenameAlternativeComponent: FC<TreeItemToolProps>;
 };
 
 /**
@@ -63,7 +46,6 @@ type UsePageRenameReturn = {
  */
 export const usePageRename = (): UsePageRenameReturn => {
   const { t } = useTranslation();
-  const inputValidator = useInputValidator(ValidationTarget.PAGE);
   const { notifyUpdateItems } = usePageTreeInformationUpdate();
 
   const getPageName = useCallback(
@@ -78,14 +60,6 @@ export const usePageRename = (): UsePageRenameReturn => {
     [],
   );
 
-  const validateName = useCallback(
-    (name: string): InputValidationResult | null => {
-      const result = inputValidator(name);
-      return result ?? null;
-    },
-    [inputValidator],
-  );
-
   const isRenaming = useCallback(
     (item: ItemInstance<IPageForItem>): boolean => {
       return item.isRenaming?.() ?? false;
@@ -93,17 +67,6 @@ export const usePageRename = (): UsePageRenameReturn => {
     [],
   );
 
-  // RenameInput as AlternativeComponent
-  const RenameAlternativeComponent: FC<TreeItemToolProps> = useMemo(() => {
-    const Component: FC<TreeItemToolProps> = ({ item }) => (
-      <RenameInput
-        inputProps={item.getRenameInputProps()}
-        validateName={validateName}
-      />
-    );
-    return Component;
-  }, [validateName]);
-
   const rename = useCallback(
     async (
       item: ItemInstance<IPageForItem>,
@@ -156,9 +119,7 @@ export const usePageRename = (): UsePageRenameReturn => {
 
   return {
     rename,
-    validateName,
     getPageName,
     isRenaming,
-    RenameAlternativeComponent,
   };
 };

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

@@ -1,9 +1,11 @@
 // Components
-export { CreateInput } from './client/components/CreateInput';
-export { RenameInput } from './client/components/RenameInput';
 export { SimpleItemContent } from './client/components/SimpleItemContent';
 export { SimplifiedItemsTree } from './client/components/SimplifiedItemsTree';
 export { TreeItemLayout } from './client/components/TreeItemLayout';
+export {
+  NameInputAlternativeComponent,
+  TreeNameInput,
+} from './client/components/TreeNameInput';
 // Hooks
 export { useDataLoader } from './client/hooks/use-data-loader';
 export { usePageCreate } from './client/hooks/use-page-create';