Files
OpenNoodl/dev-docs/tasks/phase-2/TASK-004B-componentsPanel-react-migration/phases/PHASE-5-INLINE-RENAME.md
2025-12-23 09:39:33 +01:00

12 KiB

Phase 5: Inline Rename

Estimated Time: 1 hour
Complexity: Medium
Prerequisites: Phase 4 complete (drag-drop working)

Overview

Implement inline rename functionality allowing users to double-click or use context menu to rename components and folders directly in the tree. Includes validation for duplicate names and proper undo support.


Goals

  • Implement rename mode state management
  • Show inline input field on rename trigger
  • Handle Enter to confirm, Escape to cancel
  • Validate name uniqueness
  • Handle focus management
  • Integrate with UndoQueue
  • Support both component and folder rename

Step 1: Create Rename Hook

1.1 Create hooks/useRenameMode.ts

/**
 * useRenameMode
 *
 * Manages inline rename state and validation.
 */

import { ToastLayer } from '@noodl-views/ToastLayer/ToastLayer';
import { useCallback, useState } from 'react';

import { ProjectModel } from '@noodl-models/projectmodel';

import { TreeNode } from '../types';

export function useRenameMode() {
  const [renamingItem, setRenamingItem] = useState<TreeNode | null>(null);
  const [renameValue, setRenameValue] = useState('');

  const startRename = useCallback((item: TreeNode) => {
    setRenamingItem(item);
    setRenameValue(item.name);
  }, []);

  const cancelRename = useCallback(() => {
    setRenamingItem(null);
    setRenameValue('');
  }, []);

  const validateName = useCallback(
    (newName: string): { valid: boolean; error?: string } => {
      if (!newName || newName.trim() === '') {
        return { valid: false, error: 'Name cannot be empty' };
      }

      if (newName === renamingItem?.name) {
        return { valid: true }; // No change
      }

      // Check for invalid characters
      if (/[<>:"|?*\\]/.test(newName)) {
        return { valid: false, error: 'Name contains invalid characters' };
      }

      // Check for duplicate name
      if (renamingItem?.type === 'component') {
        const folder = renamingItem.folder;
        const folderPath = folder.path === '/' ? '' : folder.path;
        const fullName = folderPath ? `${folderPath}/${newName}` : newName;

        if (ProjectModel.instance.getComponentWithName(fullName)) {
          return { valid: false, error: 'A component with this name already exists' };
        }
      } else if (renamingItem?.type === 'folder') {
        // Check for duplicate folder
        const parentPath = renamingItem.path.substring(0, renamingItem.path.lastIndexOf('/'));
        const newPath = parentPath ? `${parentPath}/${newName}` : newName;

        const components = ProjectModel.instance.getComponents();
        const hasConflict = components.some((comp) => comp.name.startsWith(newPath + '/'));

        if (hasConflict) {
          // Check if it's just the same folder
          if (newPath !== renamingItem.path) {
            return { valid: false, error: 'A folder with this name already exists' };
          }
        }
      }

      return { valid: true };
    },
    [renamingItem]
  );

  return {
    renamingItem,
    renameValue,
    setRenameValue,
    startRename,
    cancelRename,
    validateName
  };
}

Step 2: Create Rename Input Component

2.1 Create components/RenameInput.tsx

/**
 * RenameInput
 *
 * Inline input field for renaming components/folders.
 */

import React, { useCallback, useEffect, useRef } from 'react';

import css from '../ComponentsPanel.module.scss';

interface RenameInputProps {
  value: string;
  onChange: (value: string) => void;
  onConfirm: () => void;
  onCancel: () => void;
  level: number;
}

export function RenameInput({ value, onChange, onConfirm, onCancel, level }: RenameInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const indent = level * 12;

  // Auto-focus and select all on mount
  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.focus();
      inputRef.current.select();
    }
  }, []);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        onConfirm();
      } else if (e.key === 'Escape') {
        e.preventDefault();
        onCancel();
      }
    },
    [onConfirm, onCancel]
  );

  const handleBlur = useCallback(() => {
    // Cancel on blur
    onCancel();
  }, [onCancel]);

  return (
    <div className={css.RenameContainer} style={{ paddingLeft: `${indent + 23}px` }}>
      <input
        ref={inputRef}
        type="text"
        className={css.RenameInput}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={handleKeyDown}
        onBlur={handleBlur}
      />
    </div>
  );
}

Step 3: Integrate Rename into Tree Items

3.1 Update ComponentItem.tsx

Add double-click and rename mode:

export function ComponentItem({
  component,
  level,
  isSelected,
  onClick,
  onDragStart,
  onDoubleClick,
  isRenaming,
  renameValue,
  onRenameChange,
  onRenameConfirm,
  onRenameCancel
}: ComponentItemProps) {
  // ... existing code ...

  if (isRenaming) {
    return (
      <RenameInput
        value={renameValue}
        onChange={onRenameChange}
        onConfirm={onRenameConfirm}
        onCancel={onRenameCancel}
        level={level}
      />
    );
  }

  return (
    <div
      ref={itemRef}
      className={classNames(css.TreeItem, {
        [css.Selected]: isSelected
      })}
      style={{ paddingLeft: `${indent + 23}px` }}
      onClick={onClick}
      onDoubleClick={() => onDoubleClick?.(component)}
      onContextMenu={handleContextMenu}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      {/* ... existing content ... */}
    </div>
  );
}

3.2 Update FolderItem.tsx

Add double-click and rename mode:

export function FolderItem({
  folder,
  level,
  isExpanded,
  isSelected,
  onCaretClick,
  onClick,
  onDragStart,
  onDrop,
  canAcceptDrop,
  onDoubleClick,
  isRenaming,
  renameValue,
  onRenameChange,
  onRenameConfirm,
  onRenameCancel,
  children
}: FolderItemProps) {
  // ... existing code ...

  if (isRenaming) {
    return (
      <>
        <RenameInput
          value={renameValue}
          onChange={onRenameChange}
          onConfirm={onRenameConfirm}
          onCancel={onRenameCancel}
          level={level}
        />
        {children}
      </>
    );
  }

  return (
    <>
      <div
        ref={itemRef}
        className={classNames(css.TreeItem, {
          [css.Selected]: isSelected,
          [css.DropTarget]: isDropTarget
        })}
        style={{ paddingLeft: `${indent + 10}px` }}
        onContextMenu={handleContextMenu}
        onDoubleClick={() => onDoubleClick?.(folder)}
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDrop={handleDragDrop}
      >
        {/* ... existing content ... */}
      </div>
      {children}
    </>
  );
}

Step 4: Implement Rename Execution

4.1 Update useComponentActions.ts

Complete the rename handler:

const handleRename = useCallback((item: TreeNode, newName: string) => {
  if (item.type === 'component') {
    const component = item.component;
    const folder = item.folder;
    const folderPath = folder.path === '/' ? '' : folder.path;
    const newFullName = folderPath ? `${folderPath}/${newName}` : newName;
    const oldName = component.name;

    UndoQueue.instance.pushAndDo(
      new UndoActionGroup({
        label: `Rename ${component.name} to ${newName}`,
        do: () => {
          ProjectModel.instance.renameComponent(component, newFullName);
        },
        undo: () => {
          ProjectModel.instance.renameComponent(component, oldName);
        }
      })
    );
  } else if (item.type === 'folder') {
    // Rename folder (rename all components in folder)
    const oldPath = item.path;
    const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
    const newPath = parentPath ? `${parentPath}/${newName}` : newName;

    const components = ProjectModel.instance.getComponents();
    const componentsToRename = components.filter((comp) => comp.name.startsWith(oldPath + '/'));

    if (componentsToRename.length === 0) {
      ToastLayer.showInfo('Folder is empty');
      return;
    }

    const renames = componentsToRename.map((comp) => ({
      component: comp,
      oldName: comp.name,
      newName: comp.name.replace(oldPath, newPath)
    }));

    UndoQueue.instance.pushAndDo(
      new UndoActionGroup({
        label: `Rename folder ${item.name} to ${newName}`,
        do: () => {
          renames.forEach(({ component, newName }) => {
            ProjectModel.instance.renameComponent(component, newName);
          });
        },
        undo: () => {
          renames.forEach(({ component, oldName }) => {
            ProjectModel.instance.renameComponent(component, oldName);
          });
        }
      })
    );
  }
}, []);

Step 5: Add Rename Styles

5.1 Update ComponentsPanel.module.scss

.RenameContainer {
  display: flex;
  align-items: center;
  padding: 6px 10px;
}

.RenameInput {
  flex: 1;
  padding: 4px 8px;
  font: 11px var(--font-family-regular);
  color: var(--theme-color-fg-default);
  background-color: var(--theme-color-bg-3);
  border: 1px solid var(--theme-color-primary);
  border-radius: 3px;
  outline: none;

  &:focus {
    border-color: var(--theme-color-primary);
    box-shadow: 0 0 0 2px var(--theme-color-primary-transparent);
  }
}

Step 6: Wire Up Rename

6.1 Update ComponentsPanel.tsx

export function ComponentsPanel(props: ComponentsPanelProps) {
  const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
    hideSheets,
    lockCurrentSheetName
  });

  const { handleMakeHome, handleDelete, handleDuplicate, handleRename, handleDropOn } = useComponentActions();

  const { renamingItem, renameValue, setRenameValue, startRename, cancelRename, validateName } = useRenameMode();

  const handleRenameConfirm = useCallback(() => {
    if (!renamingItem) return;

    const validation = validateName(renameValue);
    if (!validation.valid) {
      ToastLayer.showError(validation.error || 'Invalid name');
      return;
    }

    if (renameValue !== renamingItem.name) {
      handleRename(renamingItem, renameValue);
    }

    cancelRename();
  }, [renamingItem, renameValue, validateName, handleRename, cancelRename]);

  return (
    <div className={css.ComponentsPanel}>
      {/* ... */}

      <ComponentTree
        nodes={treeData}
        expandedFolders={expandedFolders}
        selectedId={selectedId}
        onItemClick={handleItemClick}
        onCaretClick={toggleFolder}
        onDragStart={startDrag}
        onDrop={handleDrop}
        canAcceptDrop={canDrop}
        onDoubleClick={startRename}
        renamingItem={renamingItem}
        renameValue={renameValue}
        onRenameChange={setRenameValue}
        onRenameConfirm={handleRenameConfirm}
        onRenameCancel={cancelRename}
      />
    </div>
  );
}

Step 7: Testing

7.1 Verification Checklist

  • Double-click on component triggers rename
  • Double-click on folder triggers rename
  • Context menu "Rename" triggers rename
  • Input field appears with current name
  • Text is selected on focus
  • Enter confirms rename
  • Escape cancels rename
  • Click outside cancels rename
  • Empty name shows error
  • Duplicate name shows error
  • Invalid characters show error
  • Successful rename updates tree
  • Rename can be undone
  • Folder rename updates all child components

Success Criteria

Phase 5 is complete when:

  1. Double-click triggers rename mode
  2. Inline input appears with current name
  3. Enter confirms, Escape cancels
  4. Name validation works correctly
  5. Renames execute and update tree
  6. All renames can be undone
  7. No console errors

Next Phase

Phase 6: Sheet Selector - Implement sheet/tab switching functionality.