Files
OpenNoodl/dev-docs/tasks/phase-2-react-migration/TASK-004B-componentsPanel-react-migration/phases/PHASE-3-CONTEXT-MENUS.md

13 KiB

Phase 3: Context Menus

Estimated Time: 1 hour
Complexity: Low
Prerequisites: Phase 2 complete (tree rendering working)

Overview

Add context menu functionality for components and folders, including add component menu, rename, duplicate, delete, and make home actions. All actions should integrate with UndoQueue for proper undo/redo support.


Goals

  • Implement header "+" button menu
  • Implement component right-click context menu
  • Implement folder right-click context menu
  • Wire up add component action
  • Wire up rename action
  • Wire up duplicate action
  • Wire up delete action
  • Wire up make home action
  • All actions use UndoQueue

Step 1: Create Add Component Menu

1.1 Create components/AddComponentMenu.tsx

Menu for adding new components/folders:

/**
 * AddComponentMenu
 *
 * Dropdown menu for adding new components or folders.
 * Integrates with ComponentTemplates system.
 */

import PopupLayer from '@noodl-views/popuplayer';
import React, { useCallback } from 'react';

import { IconName } from '@noodl-core-ui/components/common/Icon';

import { ComponentTemplates } from '../../componentspanel/ComponentTemplates';

interface AddComponentMenuProps {
  targetElement: HTMLElement;
  onClose: () => void;
  parentPath?: string;
}

export function AddComponentMenu({ targetElement, onClose, parentPath = '' }: AddComponentMenuProps) {
  const handleAddComponent = useCallback(
    (templateId: string) => {
      const template = ComponentTemplates.instance.getTemplate(templateId);
      if (!template) return;

      // TODO: Create component with template
      // This will integrate with ProjectModel
      console.log('Add component:', templateId, 'at path:', parentPath);

      onClose();
    },
    [parentPath, onClose]
  );

  const handleAddFolder = useCallback(() => {
    // TODO: Create new folder
    console.log('Add folder at path:', parentPath);
    onClose();
  }, [parentPath, onClose]);

  // Build menu items from templates
  const templates = ComponentTemplates.instance.getTemplates();
  const menuItems = templates.map((template) => ({
    icon: template.icon || IconName.Component,
    label: template.displayName || template.name,
    onClick: () => handleAddComponent(template.id)
  }));

  // Add folder option
  menuItems.push(
    { type: 'divider' as const },
    {
      icon: IconName.Folder,
      label: 'Folder',
      onClick: handleAddFolder
    }
  );

  // Show popup menu
  const menu = new PopupLayer.PopupMenu({ items: menuItems });

  PopupLayer.instance.showPopup({
    content: menu,
    attachTo: targetElement,
    position: 'bottom'
  });

  return null;
}

Step 2: Add Context Menu Handlers

2.1 Update ComponentItem.tsx

Add right-click handler:

export function ComponentItem({ component, level, isSelected, onClick }: ComponentItemProps) {
  const indent = level * 12;

  const handleContextMenu = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      e.stopPropagation();

      const menuItems = buildComponentContextMenu(component);
      const menu = new PopupLayer.PopupMenu({ items: menuItems });

      PopupLayer.instance.showPopup({
        content: menu,
        attachTo: e.currentTarget as HTMLElement,
        position: { x: e.clientX, y: e.clientY }
      });
    },
    [component]
  );

  // ... existing code ...

  return (
    <div
      className={classNames(css.TreeItem, {
        [css.Selected]: isSelected
      })}
      style={{ paddingLeft: `${indent + 23}px` }}
      onClick={onClick}
      onContextMenu={handleContextMenu}
    >
      {/* ... existing content ... */}
    </div>
  );
}

function buildComponentContextMenu(component: ComponentItemData) {
  return [
    {
      icon: IconName.Plus,
      label: 'Add',
      onClick: () => {
        // TODO: Show add submenu
      }
    },
    { type: 'divider' as const },
    {
      icon: IconName.Home,
      label: 'Make Home',
      disabled: component.isRoot || !component.canBecomeRoot,
      onClick: () => {
        // TODO: Make component home
      }
    },
    { type: 'divider' as const },
    {
      icon: IconName.Edit,
      label: 'Rename',
      onClick: () => {
        // TODO: Enable rename mode
      }
    },
    {
      icon: IconName.Copy,
      label: 'Duplicate',
      onClick: () => {
        // TODO: Duplicate component
      }
    },
    { type: 'divider' as const },
    {
      icon: IconName.Trash,
      label: 'Delete',
      onClick: () => {
        // TODO: Delete component
      }
    }
  ];
}

2.2 Update FolderItem.tsx

Add right-click handler:

export function FolderItem({
  folder,
  level,
  isExpanded,
  isSelected,
  onCaretClick,
  onClick,
  children
}: FolderItemProps) {
  const indent = level * 12;

  const handleContextMenu = useCallback(
    (e: React.MouseEvent) => {
      e.preventDefault();
      e.stopPropagation();

      const menuItems = buildFolderContextMenu(folder);
      const menu = new PopupLayer.PopupMenu({ items: menuItems });

      PopupLayer.instance.showPopup({
        content: menu,
        attachTo: e.currentTarget as HTMLElement,
        position: { x: e.clientX, y: e.clientY }
      });
    },
    [folder]
  );

  return (
    <>
      <div
        className={classNames(css.TreeItem, {
          [css.Selected]: isSelected
        })}
        style={{ paddingLeft: `${indent + 10}px` }}
        onContextMenu={handleContextMenu}
      >
        {/* ... existing content ... */}
      </div>
      {children}
    </>
  );
}

function buildFolderContextMenu(folder: FolderItemData) {
  return [
    {
      icon: IconName.Plus,
      label: 'Add',
      onClick: () => {
        // TODO: Show add submenu at folder path
      }
    },
    { type: 'divider' as const },
    {
      icon: IconName.Home,
      label: 'Make Home',
      disabled: !folder.isComponentFolder || !folder.canBecomeRoot,
      onClick: () => {
        // TODO: Make folder component home
      }
    },
    { type: 'divider' as const },
    {
      icon: IconName.Edit,
      label: 'Rename',
      onClick: () => {
        // TODO: Enable rename mode for folder
      }
    },
    {
      icon: IconName.Copy,
      label: 'Duplicate',
      onClick: () => {
        // TODO: Duplicate folder
      }
    },
    { type: 'divider' as const },
    {
      icon: IconName.Trash,
      label: 'Delete',
      onClick: () => {
        // TODO: Delete folder and contents
      }
    }
  ];
}

Step 3: Implement Action Handlers

3.1 Create hooks/useComponentActions.ts

Hook for handling component actions:

/**
 * useComponentActions
 *
 * Provides handlers for component/folder actions.
 * Integrates with UndoQueue for all operations.
 */

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

import { ProjectModel } from '@noodl-models/projectmodel';
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';

import { ComponentItemData, FolderItemData } from '../types';

export function useComponentActions() {
  const handleMakeHome = useCallback((item: ComponentItemData | FolderItemData) => {
    const componentName = item.type === 'component' ? item.fullName : item.path;
    const component = ProjectModel.instance.getComponentWithName(componentName);

    if (!component) {
      ToastLayer.showError('Component not found');
      return;
    }

    const previousRoot = ProjectModel.instance.getRootComponent();

    UndoQueue.instance.pushAndDo(
      new UndoActionGroup({
        label: `Make ${component.name} home`,
        do: () => {
          ProjectModel.instance.setRootComponent(component);
        },
        undo: () => {
          if (previousRoot) {
            ProjectModel.instance.setRootComponent(previousRoot);
          }
        }
      })
    );
  }, []);

  const handleDelete = useCallback((item: ComponentItemData | FolderItemData) => {
    const itemName = item.type === 'component' ? item.name : item.name;

    // Confirm deletion
    const confirmed = confirm(`Are you sure you want to delete "${itemName}"?`);
    if (!confirmed) return;

    if (item.type === 'component') {
      const component = item.component;

      UndoQueue.instance.pushAndDo(
        new UndoActionGroup({
          label: `Delete ${component.name}`,
          do: () => {
            ProjectModel.instance.removeComponent(component);
          },
          undo: () => {
            ProjectModel.instance.addComponent(component);
          }
        })
      );
    } else {
      // TODO: Delete folder and all contents
      ToastLayer.showInfo('Folder deletion not yet implemented');
    }
  }, []);

  const handleDuplicate = useCallback((item: ComponentItemData | FolderItemData) => {
    if (item.type === 'component') {
      const component = item.component;
      const newName = ProjectModel.instance.findUniqueComponentName(component.name + ' Copy');

      UndoQueue.instance.pushAndDo(
        new UndoActionGroup({
          label: `Duplicate ${component.name}`,
          do: () => {
            const duplicated = ProjectModel.instance.duplicateComponent(component, newName);
            return duplicated;
          },
          undo: (duplicated) => {
            if (duplicated) {
              ProjectModel.instance.removeComponent(duplicated);
            }
          }
        })
      );
    } else {
      // TODO: Duplicate folder and all contents
      ToastLayer.showInfo('Folder duplication not yet implemented');
    }
  }, []);

  const handleRename = useCallback((item: ComponentItemData | FolderItemData) => {
    // This will be implemented in Phase 5: Inline Rename
    console.log('Rename:', item);
  }, []);

  return {
    handleMakeHome,
    handleDelete,
    handleDuplicate,
    handleRename
  };
}

Step 4: Wire Up Actions

4.1 Update ComponentsPanel.tsx

Integrate action handlers:

import React, { useCallback, useState } from 'react';

import { ComponentTree } from './components/ComponentTree';
import css from './ComponentsPanel.module.scss';
import { useComponentActions } from './hooks/useComponentActions';
import { useComponentsPanel } from './hooks/useComponentsPanel';
import { ComponentsPanelProps } from './types';

export function ComponentsPanel(props: ComponentsPanelProps) {
  const { showSheetList = true, hideSheets = [], componentTitle = 'Components', lockCurrentSheetName } = props;

  const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
    hideSheets,
    lockCurrentSheetName
  });

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

  const [addButtonRef, setAddButtonRef] = useState<HTMLButtonElement | null>(null);
  const [showAddMenu, setShowAddMenu] = useState(false);

  const handleAddButtonClick = useCallback(() => {
    setShowAddMenu(true);
  }, []);

  return (
    <div className={css.ComponentsPanel}>
      <div className={css.Header}>
        <div className={css.Title}>{componentTitle}</div>
        <button ref={setAddButtonRef} className={css.AddButton} title="Add component" onClick={handleAddButtonClick}>
          <div className={css.AddIcon}>+</div>
        </button>
      </div>

      {/* ... rest of component ... */}

      {showAddMenu && addButtonRef && (
        <AddComponentMenu targetElement={addButtonRef} onClose={() => setShowAddMenu(false)} parentPath="" />
      )}
    </div>
  );
}

Step 5: Testing

5.1 Verification Checklist

  • Header "+" button shows add menu
  • Add menu includes all component templates
  • Add menu includes "Folder" option
  • Right-click on component shows context menu
  • Right-click on folder shows context menu
  • "Make Home" action works (and is disabled appropriately)
  • "Rename" action triggers (implementation in Phase 5)
  • "Duplicate" action works
  • "Delete" action works with confirmation
  • All actions can be undone
  • All actions can be redone
  • No console errors

5.2 Test Edge Cases

  • Try to make home on component that can't be home
  • Try to delete root component (should prevent or handle)
  • Duplicate component with same name (should auto-rename)
  • Delete last component in folder
  • Context menu closes when clicking outside

Common Issues & Solutions

Issue: Context menu doesn't appear

Solution: Check that onContextMenu handler is attached and e.preventDefault() is called.

Issue: Menu appears in wrong position

Solution: Verify PopupLayer position parameters. Use { x: e.clientX, y: e.clientY } for mouse position.

Issue: Actions don't work

Solution: Check that ProjectModel methods are being called correctly and UndoQueue integration is proper.

Issue: Undo doesn't work

Solution: Verify that UndoActionGroup is created correctly with both do and undo functions.


Success Criteria

Phase 3 is complete when:

  1. Header "+" button shows add menu
  2. All context menus work correctly
  3. Make home action works
  4. Delete action works with confirmation
  5. Duplicate action works
  6. All actions integrate with UndoQueue
  7. Undo/redo works for all actions
  8. No console errors

Next Phase

Phase 4: Drag-Drop - Implement drag-drop functionality for reorganizing components and folders.