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

17 KiB

Phase 2: Tree Rendering

Estimated Time: 1-2 hours
Complexity: Medium
Prerequisites: Phase 1 complete (foundation set up)

Overview

Connect the ComponentsPanel to ProjectModel and render the actual component tree structure with folders, proper selection handling, and correct icons. This phase brings the panel to life with real data.


Goals

  • Subscribe to ProjectModel events for component changes
  • Build folder/component tree structure from ProjectModel
  • Implement recursive tree rendering
  • Add expand/collapse for folders
  • Implement component selection sync with NodeGraphEditor
  • Show correct icons (home, page, cloud, visual, folder)
  • Handle component warnings display

Step 1: Create Tree Rendering Components

1.1 Create components/ComponentTree.tsx

Recursive component for rendering the tree:

/**
 * ComponentTree
 *
 * Recursively renders the component/folder tree structure.
 */

import React from 'react';

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

interface ComponentTreeProps {
  nodes: TreeNode[];
  level?: number;
  onItemClick: (node: TreeNode) => void;
  onCaretClick: (folderId: string) => void;
  expandedFolders: Set<string>;
  selectedId?: string;
}

export function ComponentTree({
  nodes,
  level = 0,
  onItemClick,
  onCaretClick,
  expandedFolders,
  selectedId
}: ComponentTreeProps) {
  return (
    <>
      {nodes.map((node) => {
        if (node.type === 'folder') {
          return (
            <FolderItem
              key={node.path}
              folder={node}
              level={level}
              isExpanded={expandedFolders.has(node.path)}
              isSelected={selectedId === node.path}
              onCaretClick={() => onCaretClick(node.path)}
              onClick={() => onItemClick(node)}
            >
              {expandedFolders.has(node.path) && node.children.length > 0 && (
                <ComponentTree
                  nodes={node.children}
                  level={level + 1}
                  onItemClick={onItemClick}
                  onCaretClick={onCaretClick}
                  expandedFolders={expandedFolders}
                  selectedId={selectedId}
                />
              )}
            </FolderItem>
          );
        } else {
          return (
            <ComponentItem
              key={node.fullName}
              component={node}
              level={level}
              isSelected={selectedId === node.fullName}
              onClick={() => onItemClick(node)}
            />
          );
        }
      })}
    </>
  );
}

1.2 Create components/FolderItem.tsx

Component for rendering folder rows:

/**
 * FolderItem
 *
 * Renders a folder row with expand/collapse caret and nesting.
 */

import classNames from 'classnames';
import React from 'react';

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

import css from '../ComponentsPanel.module.scss';
import { FolderItemData } from '../types';

interface FolderItemProps {
  folder: FolderItemData;
  level: number;
  isExpanded: boolean;
  isSelected: boolean;
  onCaretClick: () => void;
  onClick: () => void;
  children?: React.ReactNode;
}

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

  return (
    <>
      <div
        className={classNames(css.TreeItem, {
          [css.Selected]: isSelected
        })}
        style={{ paddingLeft: `${indent + 10}px` }}
      >
        <div
          className={classNames(css.Caret, {
            [css.Expanded]: isExpanded
          })}
          onClick={(e) => {
            e.stopPropagation();
            onCaretClick();
          }}
        >
          
        </div>
        <div className={css.ItemContent} onClick={onClick}>
          <div className={css.Icon}>{folder.isComponentFolder ? IconName.FolderComponent : IconName.Folder}</div>
          <div className={css.Label}>{folder.name}</div>
          {folder.hasWarnings && <div className={css.Warning}>!</div>}
        </div>
      </div>
      {children}
    </>
  );
}

1.3 Create components/ComponentItem.tsx

Component for rendering component rows:

/**
 * ComponentItem
 *
 * Renders a single component row with appropriate icon.
 */

import classNames from 'classnames';
import React from 'react';

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

import css from '../ComponentsPanel.module.scss';
import { ComponentItemData } from '../types';

interface ComponentItemProps {
  component: ComponentItemData;
  level: number;
  isSelected: boolean;
  onClick: () => void;
}

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

  // Determine icon based on component type
  let icon = IconName.Component;
  if (component.isRoot) {
    icon = IconName.Home;
  } else if (component.isPage) {
    icon = IconName.Page;
  } else if (component.isCloudFunction) {
    icon = IconName.Cloud;
  } else if (component.isVisual) {
    icon = IconName.Visual;
  }

  return (
    <div
      className={classNames(css.TreeItem, {
        [css.Selected]: isSelected
      })}
      style={{ paddingLeft: `${indent + 23}px` }}
      onClick={onClick}
    >
      <div className={css.ItemContent}>
        <div className={css.Icon}>{icon}</div>
        <div className={css.Label}>{component.name}</div>
        {component.hasWarnings && <div className={css.Warning}>!</div>}
      </div>
    </div>
  );
}

Step 2: Create State Management Hook

2.1 Create hooks/useComponentsPanel.ts

Main hook for managing panel state:

/**
 * useComponentsPanel
 *
 * Main state management hook for ComponentsPanel.
 * Subscribes to ProjectModel and builds tree structure.
 */

import { useCallback, useEffect, useMemo, useState } from 'react';

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

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

interface UseComponentsPanelOptions {
  hideSheets?: string[];
  lockCurrentSheetName?: string;
}

export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
  const { hideSheets = [], lockCurrentSheetName } = options;

  // Local state
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
  const [selectedId, setSelectedId] = useState<string | undefined>();
  const [updateCounter, setUpdateCounter] = useState(0);

  // Subscribe to ProjectModel events
  useEffect(() => {
    const handleUpdate = () => {
      setUpdateCounter((c) => c + 1);
    };

    ProjectModel.instance.on('componentAdded', handleUpdate);
    ProjectModel.instance.on('componentRemoved', handleUpdate);
    ProjectModel.instance.on('componentRenamed', handleUpdate);
    ProjectModel.instance.on('rootComponentChanged', handleUpdate);

    return () => {
      ProjectModel.instance.off('componentAdded', handleUpdate);
      ProjectModel.instance.off('componentRemoved', handleUpdate);
      ProjectModel.instance.off('componentRenamed', handleUpdate);
      ProjectModel.instance.off('rootComponentChanged', handleUpdate);
    };
  }, []);

  // Build tree structure
  const treeData = useMemo(() => {
    return buildTreeFromProject(ProjectModel.instance, hideSheets, lockCurrentSheetName);
  }, [updateCounter, hideSheets, lockCurrentSheetName]);

  // Toggle folder expand/collapse
  const toggleFolder = useCallback((folderId: string) => {
    setExpandedFolders((prev) => {
      const next = new Set(prev);
      if (next.has(folderId)) {
        next.delete(folderId);
      } else {
        next.add(folderId);
      }
      return next;
    });
  }, []);

  // Handle item click
  const handleItemClick = useCallback((node: TreeNode) => {
    if (node.type === 'component') {
      setSelectedId(node.fullName);
      // TODO: Open component in NodeGraphEditor
    } else {
      setSelectedId(node.path);
    }
  }, []);

  return {
    treeData,
    expandedFolders,
    selectedId,
    toggleFolder,
    handleItemClick
  };
}

/**
 * Build tree structure from ProjectModel
 * Port logic from ComponentsPanel.ts addComponentToFolderStructure
 */
function buildTreeFromProject(project: ProjectModel, hideSheets: string[], lockSheet?: string): TreeNode[] {
  // TODO: Implement tree building logic
  // This will port the logic from legacy ComponentsPanel.ts
  // For now, return placeholder structure
  return [];
}

Step 3: Add Styles for Tree Items

3.1 Update ComponentsPanel.module.scss

Add styles for tree items:

/* Tree items */
.TreeItem {
  display: flex;
  align-items: center;
  padding: 6px 10px;
  cursor: pointer;
  font: 11px var(--font-family-regular);
  color: var(--theme-color-fg-default);
  user-select: none;
  transition: background-color 0.15s ease;

  &:hover {
    background-color: var(--theme-color-bg-3);
  }

  &.Selected {
    background-color: var(--theme-color-primary-transparent);
    color: var(--theme-color-primary);
  }
}

.Caret {
  width: 12px;
  height: 12px;
  margin-right: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 8px;
  color: var(--theme-color-fg-muted);
  transition: transform 0.15s ease;

  &.Expanded {
    transform: rotate(90deg);
  }
}

.ItemContent {
  display: flex;
  align-items: center;
  flex: 1;
  gap: 6px;
}

.Icon {
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--theme-color-fg-default);
}

.Label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.Warning {
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: var(--theme-color-warning);
  color: var(--theme-color-bg-1);
  border-radius: 50%;
  font-size: 10px;
  font-weight: bold;
}

Step 4: Integrate Tree Rendering

4.1 Update ComponentsPanel.tsx

Replace placeholder content with actual tree:

import React from 'react';

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

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

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

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

      {showSheetList && (
        <div className={css.SheetsSection}>
          <div className={css.SheetsHeader}>Sheets</div>
          <div className={css.SheetsList}>{/* TODO: Implement sheet selector in Phase 6 */}</div>
        </div>
      )}

      <div className={css.ComponentsHeader}>
        <div className={css.Title}>Components</div>
      </div>

      <div className={css.ComponentsScroller}>
        <div className={css.ComponentsList}>
          <ComponentTree
            nodes={treeData}
            expandedFolders={expandedFolders}
            selectedId={selectedId}
            onItemClick={handleItemClick}
            onCaretClick={toggleFolder}
          />
        </div>
      </div>
    </div>
  );
}

Step 5: Port Tree Building Logic

5.1 Implement buildTreeFromProject

Port logic from legacy ComponentsPanel.ts:

function buildTreeFromProject(project: ProjectModel, hideSheets: string[], lockSheet?: string): TreeNode[] {
  const rootFolder = new ComponentsPanelFolder({ path: '/', name: '' });

  // Get all components
  const components = project.getComponents();

  // Filter by sheet if specified
  const filteredComponents = components.filter((comp) => {
    const sheet = getSheetForComponent(comp.name);
    if (hideSheets.includes(sheet)) return false;
    if (lockSheet && sheet !== lockSheet) return false;
    return true;
  });

  // Add each component to folder structure
  filteredComponents.forEach((comp) => {
    addComponentToFolderStructure(rootFolder, comp, project);
  });

  // Convert folder structure to tree nodes
  return convertFolderToTreeNodes(rootFolder);
}

function addComponentToFolderStructure(
  rootFolder: ComponentsPanelFolder,
  component: ComponentModel,
  project: ProjectModel
) {
  const parts = component.name.split('/');
  let currentFolder = rootFolder;

  // Navigate/create folder structure
  for (let i = 0; i < parts.length - 1; i++) {
    const folderName = parts[i];
    let folder = currentFolder.children.find((c) => c.name === folderName);

    if (!folder) {
      folder = new ComponentsPanelFolder({
        path: parts.slice(0, i + 1).join('/'),
        name: folderName
      });
      currentFolder.children.push(folder);
    }

    currentFolder = folder;
  }

  // Add component to final folder
  currentFolder.components.push(component);
}

function convertFolderToTreeNodes(folder: ComponentsPanelFolder): TreeNode[] {
  const nodes: TreeNode[] = [];

  // Add folder children first
  folder.children.forEach((childFolder) => {
    const folderNode: FolderItemData = {
      type: 'folder',
      folder: childFolder,
      name: childFolder.name,
      path: childFolder.path,
      isOpen: false,
      isSelected: false,
      isRoot: childFolder.path === '/',
      isPage: false,
      isCloudFunction: false,
      isVisual: true,
      isComponentFolder: childFolder.components.length > 0,
      canBecomeRoot: false,
      hasWarnings: false,
      children: convertFolderToTreeNodes(childFolder)
    };
    nodes.push(folderNode);
  });

  // Add components
  folder.components.forEach((comp) => {
    const componentNode: ComponentItemData = {
      type: 'component',
      component: comp,
      folder: folder,
      name: comp.name.split('/').pop() || comp.name,
      fullName: comp.name,
      isSelected: false,
      isRoot: ProjectModel.instance.getRootComponent() === comp,
      isPage: comp.type === 'Page',
      isCloudFunction: comp.type === 'CloudFunction',
      isVisual: comp.type !== 'Logic',
      canBecomeRoot: true,
      hasWarnings: false // TODO: Implement warning detection
    };
    nodes.push(componentNode);
  });

  return nodes;
}

function getSheetForComponent(componentName: string): string {
  // Extract sheet from component name
  // Components in sheets have format: SheetName/ComponentName
  if (componentName.includes('/')) {
    return componentName.split('/')[0];
  }
  return 'default';
}

Step 6: Testing

6.1 Verification Checklist

  • Tree renders with correct folder structure
  • Components appear under correct folders
  • Clicking caret expands/collapses folders
  • Clicking component selects it
  • Home icon appears for root component
  • Page icon appears for page components
  • Cloud icon appears for cloud functions
  • Visual icon appears for visual components
  • Folder icons appear correctly
  • Folder+component icon for folders that are also components
  • Warning icons appear (when implemented)
  • No console errors

6.2 Test Edge Cases

  • Empty project (no components)
  • Deep folder nesting
  • Component names with special characters
  • Sheet filtering works correctly
  • Hidden sheets are excluded

Common Issues & Solutions

Issue: Tree doesn't update when components change

Solution: Verify ProjectModel event subscriptions are correct and updateCounter increments.

Issue: Folders don't expand

Solution: Check that expandedFolders Set is being updated correctly and ComponentTree receives updated props.

Issue: Icons not showing

Solution: Verify Icon component import and that IconName values are correct.

Issue: Selection doesn't work

Solution: Check that selectedId is being set correctly and CSS .Selected class is applied.


Success Criteria

Phase 2 is complete when:

  1. Component tree renders with actual project data
  2. Folders expand and collapse correctly
  3. Components can be selected
  4. All icons display correctly
  5. Selection highlights correctly
  6. Tree updates when project changes
  7. No console errors or warnings

Next Phase

Phase 3: Context Menus - Add context menu functionality for components and folders.