Working on the editor component tree

This commit is contained in:
Richard Osborne
2025-12-23 09:39:33 +01:00
parent 89c7160de8
commit 5f8ce8d667
50 changed files with 11939 additions and 767 deletions

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef } from 'react';
/**
* useEventListener
*
* React hook for subscribing to EventDispatcher events.
*
* This hook solves the incompatibility between React's closure-based
* lifecycle and EventDispatcher's context-object-based cleanup pattern.
*
* @example
* ```tsx
* useEventListener(ProjectModel.instance, 'componentRenamed', (data) => {
* console.log('Component renamed:', data);
* setUpdateCounter(c => c + 1);
* });
* ```
*
* @see dev-docs/tasks/phase-2/TASK-008-eventdispatcher-react-investigation/
*/
// 🔥 MODULE LOAD MARKER - If you see this, the new useEventListener code is loaded!
console.log('🔥🔥🔥 useEventListener.ts MODULE LOADED WITH DEBUG LOGS - Version 2.0 🔥🔥🔥');
/**
* Interface for objects that support EventDispatcher-like subscriptions.
* This includes EventDispatcher itself and Model subclasses like ProjectModel.
*/
interface IEventEmitter {
on(event: string | string[], listener: (...args: unknown[]) => void, group: unknown): void;
off(group: unknown): void;
}
/**
* Subscribe to an EventDispatcher event with proper React lifecycle handling.
*
* Key features:
* - Prevents stale closures by using useRef for the callback
* - Creates stable group reference for proper cleanup
* - Automatically unsubscribes on unmount or when dependencies change
*
* @param dispatcher - The EventDispatcher instance to subscribe to
* @param eventName - Name of the event to listen for (or array of event names)
* @param callback - Function to call when event is emitted
* @param deps - Optional dependency array (like useEffect). If provided, re-subscribes when deps change
*/
export function useEventListener<T = unknown>(
dispatcher: IEventEmitter | null | undefined,
eventName: string | string[],
callback: (data?: T, eventName?: string) => void,
deps?: React.DependencyList
) {
// Store callback in ref to avoid stale closures
const callbackRef = useRef(callback);
// Update ref whenever callback changes
useEffect(() => {
callbackRef.current = callback;
});
// Set up subscription
useEffect(
() => {
console.log('🚨 useEventListener useEffect RUNNING! dispatcher:', dispatcher, 'eventName:', eventName);
if (!dispatcher) {
console.log('⚠️ useEventListener: dispatcher is null/undefined, returning early');
return;
}
// Create wrapper that calls the current callback ref
const wrapper = (data?: T, eventName?: string) => {
console.log('🔔 useEventListener received event:', eventName || eventName, 'data:', data);
callbackRef.current(data, eventName);
};
// Create stable group object for cleanup
// Using a unique object ensures proper unsubscription
const group = { id: `useEventListener_${Math.random()}` };
console.log('📡 useEventListener subscribing to:', eventName, 'on dispatcher:', dispatcher);
// Subscribe to event(s)
dispatcher.on(eventName, wrapper, group);
// Cleanup: unsubscribe when unmounting or dependencies change
return () => {
console.log('🔌 useEventListener unsubscribing from:', eventName);
dispatcher.off(group);
};
},
// CRITICAL: Always spread eventName array into dependencies, never pass array directly
// React's Object.is() comparison fails with arrays, causing useEffect to never run
deps
? [dispatcher, ...(Array.isArray(eventName) ? eventName : [eventName]), ...deps]
: [dispatcher, ...(Array.isArray(eventName) ? eventName : [eventName])]
);
}
/**
* Subscribe to multiple events from the same dispatcher.
*
* This is a convenience wrapper around useEventListener for cases where
* you need to subscribe to multiple events with the same callback.
*
* @example
* ```tsx
* useEventListenerMultiple(
* ProjectModel.instance,
* ['componentAdded', 'componentRemoved', 'componentRenamed'],
* () => setUpdateCounter(c => c + 1)
* );
* ```
*/
export function useEventListenerMultiple<T = unknown>(
dispatcher: IEventEmitter | null | undefined,
eventNames: string[],
callback: (data?: T, eventName?: string) => void,
deps?: React.DependencyList
) {
useEventListener(dispatcher, eventNames, callback, deps);
}

View File

@@ -0,0 +1,218 @@
/**
* MigrationNotesManager
*
* Helper functions for managing component migration notes.
* Handles loading, filtering, dismissing, and restoring migration notes.
*/
import { ProjectModel } from '../projectmodel';
import { ComponentMigrationNote, MigrationIssueType, ProjectMigrationMetadata } from './types';
export type MigrationFilter = 'all' | 'needs-review' | 'ai-migrated';
// Type helper to access migration properties
type ProjectModelWithMigration = ProjectModel & ProjectMigrationMetadata;
function getProject(): ProjectModelWithMigration | undefined {
return ProjectModel.instance as ProjectModelWithMigration | undefined;
}
export interface MigrationNoteCounts {
total: number;
needsReview: number;
aiMigrated: number;
dismissed: number;
}
/**
* Get migration notes for a specific component
*/
export function getMigrationNote(componentId: string): ComponentMigrationNote | undefined {
const notes = getProject()?.migrationNotes;
if (!notes) return undefined;
return notes[componentId];
}
/**
* Get all migration notes, optionally filtered by status
*/
export function getAllMigrationNotes(
filter: MigrationFilter = 'all',
includeDismissed: boolean = false
): Record<string, ComponentMigrationNote> {
const notes = getProject()?.migrationNotes;
if (!notes) return {};
let filtered = Object.entries(notes);
// Filter out dismissed unless requested
if (!includeDismissed) {
filtered = filtered.filter(([, note]) => !note.dismissedAt);
}
// Apply status filter
if (filter === 'needs-review') {
filtered = filtered.filter(([, note]) => note.status === 'needs-review');
} else if (filter === 'ai-migrated') {
filtered = filtered.filter(([, note]) => note.status === 'ai-migrated');
}
return Object.fromEntries(filtered);
}
/**
* Get counts of migration notes by category
*/
export function getMigrationNoteCounts(): MigrationNoteCounts {
const notes = getProject()?.migrationNotes;
if (!notes) {
return {
total: 0,
needsReview: 0,
aiMigrated: 0,
dismissed: 0
};
}
const allNotes = Object.values(notes);
const activeNotes = allNotes.filter((note) => !note.dismissedAt);
return {
total: activeNotes.length,
needsReview: activeNotes.filter((n) => n.status === 'needs-review').length,
aiMigrated: activeNotes.filter((n) => n.status === 'ai-migrated').length,
dismissed: allNotes.filter((n) => n.dismissedAt).length
};
}
/**
* Check if a component has migration notes
*/
export function hasComponentMigrationNote(componentId: string): boolean {
const note = getMigrationNote(componentId);
return Boolean(note && !note.dismissedAt);
}
/**
* Dismiss a migration note for a component
*/
export function dismissMigrationNote(componentId: string): void {
const project = getProject();
const notes = project?.migrationNotes;
if (!notes || !notes[componentId]) return;
notes[componentId] = {
...notes[componentId],
dismissedAt: new Date().toISOString()
};
(ProjectModel.instance as any).save();
}
/**
* Restore a dismissed migration note
*/
export function restoreMigrationNote(componentId: string): void {
const notes = getProject()?.migrationNotes;
if (!notes || !notes[componentId]) return;
const note = notes[componentId];
delete note.dismissedAt;
(ProjectModel.instance as any).save();
}
/**
* Get dismissed migration notes
*/
export function getDismissedMigrationNotes(): Record<string, ComponentMigrationNote> {
const notes = getProject()?.migrationNotes;
if (!notes) return {};
return Object.fromEntries(Object.entries(notes).filter(([, note]) => note.dismissedAt));
}
/**
* Get status label for display
*/
export function getStatusLabel(status: ComponentMigrationNote['status']): string {
const labels = {
auto: 'Automatically Migrated',
'ai-migrated': 'AI Migrated',
'needs-review': 'Needs Manual Review',
'manually-fixed': 'Manually Fixed'
};
return labels[status] || status;
}
/**
* Get status icon name
*/
export function getStatusIcon(status: ComponentMigrationNote['status']): string {
const icons = {
auto: 'check-circle',
'ai-migrated': 'sparkles',
'needs-review': 'warning',
'manually-fixed': 'check'
};
return icons[status] || 'info';
}
/**
* Get issue type label for display
*/
export function getIssueTypeLabel(type: MigrationIssueType): string {
const labels: Record<MigrationIssueType, string> = {
componentWillMount: 'componentWillMount',
componentWillReceiveProps: 'componentWillReceiveProps',
componentWillUpdate: 'componentWillUpdate',
unsafeLifecycle: 'Unsafe Lifecycle',
stringRef: 'String Refs',
legacyContext: 'Legacy Context',
createFactory: 'createFactory',
findDOMNode: 'findDOMNode',
reactDomRender: 'ReactDOM.render',
other: 'Other Issue'
};
return labels[type] || type;
}
/**
* Format timestamp for display
*/
export function formatMigrationDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Check if project has any migration notes
*/
export function projectHasMigrationNotes(): boolean {
const notes = getProject()?.migrationNotes;
return Boolean(notes && Object.keys(notes).length > 0);
}
/**
* Check if project was AI migrated
*/
export function projectWasAIMigrated(): boolean {
const migratedFrom = getProject()?.migratedFrom;
return Boolean(migratedFrom?.aiAssisted);
}

View File

@@ -4,7 +4,7 @@ import { ComponentIconType, getComponentIconType } from '@noodl-models/nodelibra
import { isComponentModel_CloudRuntime } from '@noodl-utils/NodeGraph';
import { IVector2, NodeGraphEditor } from './nodegrapheditor';
import { ComponentsPanelFolder } from './panels/componentspanel/ComponentsPanelFolder';
import { ComponentsPanelFolder } from './panels/ComponentsPanelNew/ComponentsPanelFolder';
import PopupLayer from './popuplayer';
// TODO: Write a full typings around this

View File

@@ -279,6 +279,19 @@ export class NodeGraphEditor extends View {
this
);
// Listen for component switch requests from ComponentsPanel
EventDispatcher.instance.on(
'ComponentPanel.SwitchToComponent',
(args: { component: ComponentModel; pushHistory?: boolean }) => {
if (args.component) {
this.switchToComponent(args.component, {
pushHistory: args.pushHistory
});
}
},
this
);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./createnewnodepanel');
}
@@ -1422,12 +1435,12 @@ export class NodeGraphEditor extends View {
updateTitle() {
const rootElem = this.el[0].querySelector('.nodegraph-component-trail-root');
// Create root only once, reuse for subsequent renders
if (!this.titleRoot) {
this.titleRoot = createRoot(rootElem);
}
if (this.activeComponent) {
const fullName = this.activeComponent.fullName;
const nameParts = fullName.split('/');

View File

@@ -0,0 +1,185 @@
/**
* ComponentsPanel Styles
*
* Migrated from legacy componentspanel.css to CSS modules
* Using design tokens for proper theming support
*/
.ComponentsPanel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 10px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-bg-2);
color: var(--theme-color-fg-highlight);
font: 12px var(--font-family-regular);
}
.Title {
font: 12px var(--font-family-regular);
color: var(--theme-color-fg-highlight);
}
.AddButton {
width: 24px;
height: 24px;
background-color: transparent;
color: var(--theme-color-fg-default);
border: none;
border-radius: 2px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: opacity var(--speed-turbo), background-color var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-highlight);
opacity: 1;
}
}
.Tree {
flex: 1;
overflow: hidden overlay;
display: flex;
flex-direction: column;
}
.PlaceholderMessage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--theme-color-fg-default-shy);
text-align: center;
font: 12px var(--font-family-regular);
span {
display: block;
}
}
/* 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);
}
&.DropTarget {
background-color: var(--theme-color-primary-transparent);
border: 2px dashed var(--theme-color-primary);
border-radius: 4px;
transition: all 0.15s ease;
}
}
.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;
}
/* Rename Input */
.RenameContainer {
display: flex;
align-items: center;
padding: 6px 10px;
background-color: var(--theme-color-bg-3);
}
.RenameInput {
flex: 1;
padding: 4px 8px;
font: 11px var(--font-family-regular);
color: var(--theme-color-fg-default);
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-primary);
border-radius: 4px;
outline: none;
transition: border-color var(--speed-turbo), box-shadow var(--speed-turbo);
&:focus {
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px var(--theme-color-primary-transparent);
background-color: var(--theme-color-bg-1);
}
&::selection {
background-color: var(--theme-color-primary);
color: var(--theme-color-bg-1);
}
}

View File

@@ -15,15 +15,16 @@ import { IconName } from '@noodl-core-ui/components/common/Icon';
import { EventDispatcher } from '../../../../../shared/utils/EventDispatcher';
import View from '../../../../../shared/view';
import { NodeGraphEditor } from '../../nodegrapheditor';
import * as NewPopupLayer from '../../PopupLayer/index';
import { ToastLayer } from '../../ToastLayer/ToastLayer';
import { ComponentsPanelFolder } from './ComponentsPanelFolder';
import { ComponentTemplates } from './ComponentTemplates';
const PopupLayer = require('@noodl-views/popuplayer');
const ComponentsPanelTemplate = require('../../../templates/componentspanel.html');
// TODO: Add these imports when implementing migration badges
// import { getMigrationNote, getMigrationNoteCounts } from '../../../models/migration/MigrationNotesManager';
// import { MigrationNotesPanel } from '../MigrationNotesPanel';
// Styles
require('../../../styles/componentspanel.css');

View File

@@ -0,0 +1,206 @@
/**
* ComponentsPanel
*
* Modern React component for displaying and managing project components.
* Migrated from legacy jQuery/underscore.js View implementation.
*
* @module noodl-editor
*/
import React, { useCallback, useEffect, useRef } from 'react';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { PopupMenu } from '../../PopupLayer/PopupMenu';
import { ComponentTree } from './components/ComponentTree';
import css from './ComponentsPanel.module.scss';
import { ComponentTemplates } from './ComponentTemplates';
import { useComponentActions } from './hooks/useComponentActions';
import { useComponentsPanel } from './hooks/useComponentsPanel';
import { useDragDrop } from './hooks/useDragDrop';
import { useRenameMode } from './hooks/useRenameMode';
import { ComponentsPanelProps } from './types';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
/**
* ComponentsPanel displays the project's component tree with folders,
* allowing users to navigate, create, rename, and organize components.
*/
export function ComponentsPanel({ options }: ComponentsPanelProps) {
console.log('🚀 React ComponentsPanel RENDERED');
const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
hideSheets: options?.hideSheets
});
const {
handleMakeHome,
handleDelete,
handleDuplicate,
performRename,
handleOpen,
handleDropOn,
handleAddComponent,
handleAddFolder
} = useComponentActions();
const { draggedItem, dropTarget, startDrag, canDrop, handleDrop, clearDrop } = useDragDrop();
const { renamingItem, renameValue, startRename, setRenameValue, cancelRename, validateName } = useRenameMode();
const addButtonRef = useRef<HTMLButtonElement>(null);
// Handle rename action from context menu
const handleRename = useCallback(
(node: TSFixme) => {
startRename(node);
},
[startRename]
);
// Handle rename confirmation
const handleRenameConfirm = useCallback(() => {
console.log('🔍 handleRenameConfirm CALLED', { renamingItem, renameValue });
if (!renamingItem || !renameValue) {
console.log('❌ Early return - missing item or value', { renamingItem, renameValue });
return;
}
// Check if name actually changed
const currentName = renamingItem.type === 'component' ? renamingItem.data.localName : renamingItem.data.name;
console.log('🔍 Current name vs new name:', { currentName, renameValue });
if (renameValue === currentName) {
// Name unchanged, just exit rename mode
console.log('⚠️ Name unchanged - canceling rename');
cancelRename();
return;
}
// Validate the NEW name
const validation = validateName(renameValue);
console.log('🔍 Name validation:', validation);
if (!validation.valid) {
console.warn('❌ Invalid name:', validation.error);
return; // Stay in rename mode so user can fix
}
// Perform the actual rename
console.log('✅ Calling performRename...');
const success = performRename(renamingItem, renameValue);
console.log('🔍 performRename result:', success);
if (success) {
console.log('✅ Rename successful - canceling rename mode');
cancelRename();
} else {
console.error('❌ Rename failed - check console for details');
// Stay in rename mode on failure
}
}, [renamingItem, renameValue, validateName, performRename, cancelRename]);
// Execute drop when both draggedItem and dropTarget are set
useEffect(() => {
if (draggedItem && dropTarget) {
handleDropOn(draggedItem, dropTarget);
clearDrop();
}
}, [draggedItem, dropTarget, handleDropOn, clearDrop]);
// Handle add button click
const handleAddClick = useCallback(() => {
console.log('🔵 ADD BUTTON CLICKED!');
try {
const templates = ComponentTemplates.instance.getTemplates({
forRuntimeType: 'browser' // Default to browser runtime for now
});
console.log('✅ Templates:', templates);
const items = templates.map((template) => ({
icon: template.icon,
label: template.label,
onClick: () => {
handleAddComponent(template);
}
}));
// Add folder option
items.push({
icon: IconName.FolderClosed,
label: 'Folder',
onClick: () => {
handleAddFolder();
}
});
console.log('✅ Menu items:', items);
// Create menu using the imported PopupMenu from TypeScript module
const menu = new PopupMenu({ items, owner: PopupLayer.instance });
// Render the menu to generate its DOM element
menu.render();
// Show popup attached to the button (wrapped in jQuery for PopupLayer compatibility)
PopupLayer.instance.showPopup({
content: menu,
attachTo: $(addButtonRef.current),
position: 'bottom'
});
console.log('✅ Popup shown successfully');
} catch (error) {
console.error('❌ Error in handleAddClick:', error);
}
}, [handleAddComponent, handleAddFolder]);
return (
<div className={css['ComponentsPanel']}>
{/* Header with title and add button */}
<div className={css['Header']}>
<span className={css['Title']}>Components</span>
<button
ref={addButtonRef}
className={css['AddButton']}
title="Add Component or Folder"
onClick={handleAddClick}
>
+
</button>
</div>
{/* Component tree */}
<div className={css['Tree']}>
{treeData.length > 0 ? (
<ComponentTree
nodes={treeData}
expandedFolders={expandedFolders}
selectedId={selectedId}
onItemClick={handleItemClick}
onCaretClick={toggleFolder}
onMakeHome={handleMakeHome}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onRename={handleRename}
onOpen={handleOpen}
onDragStart={startDrag}
onDrop={handleDrop}
canAcceptDrop={canDrop}
renamingItem={renamingItem}
renameValue={renameValue}
onRenameChange={setRenameValue}
onRenameConfirm={handleRenameConfirm}
onRenameCancel={cancelRename}
onDoubleClick={handleRename}
/>
) : (
<div className={css['PlaceholderMessage']}>
<span>No components in project</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
/**
* ComponentItem
*
* Renders a single component row with appropriate icon.
*/
import classNames from 'classnames';
import React, { useCallback, useRef } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import PopupLayer from '../../../popuplayer';
import css from '../ComponentsPanel.module.scss';
import { ComponentItemData, TreeNode } from '../types';
import { RenameInput } from './RenameInput';
interface ComponentItemProps {
component: ComponentItemData;
level: number;
isSelected: boolean;
onClick: () => void;
onMakeHome?: (node: TreeNode) => void;
onDelete?: (node: TreeNode) => void;
onDuplicate?: (node: TreeNode) => void;
onRename?: (node: TreeNode) => void;
onOpen?: (node: TreeNode) => void;
onDragStart?: (node: TreeNode, element: HTMLElement) => void;
onDoubleClick?: (node: TreeNode) => void;
isRenaming?: boolean;
renameValue?: string;
onRenameChange?: (value: string) => void;
onRenameConfirm?: () => void;
onRenameCancel?: () => void;
}
export function ComponentItem({
component,
level,
isSelected,
onClick,
onMakeHome,
onDelete,
onDuplicate,
onRename,
onOpen,
onDragStart,
onDoubleClick,
isRenaming,
renameValue,
onRenameChange,
onRenameConfirm,
onRenameCancel
}: ComponentItemProps) {
const indent = level * 12;
const itemRef = useRef<HTMLDivElement>(null);
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
// Determine icon based on component type
let icon = IconName.Component;
if (component.isRoot) {
icon = IconName.Home;
} else if (component.isPage) {
icon = IconName.PageRouter;
} else if (component.isCloudFunction) {
icon = IconName.CloudFunction;
} else if (component.isVisual) {
icon = IconName.UI;
}
// Drag handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
dragStartPos.current = { x: e.clientX, y: e.clientY };
}, []);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!dragStartPos.current || !onDragStart) return;
// Check if mouse moved enough to start drag (5px threshold)
const dx = e.clientX - dragStartPos.current.x;
const dy = e.clientY - dragStartPos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 5 && itemRef.current) {
const node: TreeNode = { type: 'component', data: component };
onDragStart(node, itemRef.current);
dragStartPos.current = null;
}
},
[component, onDragStart]
);
const handleMouseUp = useCallback(() => {
dragStartPos.current = null;
}, []);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const node: TreeNode = { type: 'component', data: component };
const items = [
{
label: 'Open',
onClick: () => onOpen?.(node)
},
{ type: 'divider' as const },
{
label: 'Make Home',
disabled: component.isRoot,
onClick: () => onMakeHome?.(node)
},
{ type: 'divider' as const },
{
label: 'Rename',
onClick: () => onRename?.(node)
},
{
label: 'Duplicate',
onClick: () => onDuplicate?.(node)
},
{ type: 'divider' as const },
{
label: 'Delete',
onClick: () => onDelete?.(node)
}
];
const menu = new PopupLayer.PopupMenu({ items });
PopupLayer.instance.showPopup({
content: menu,
attachTo: e.currentTarget as HTMLElement,
position: { x: e.clientX, y: e.clientY }
});
},
[component, onOpen, onMakeHome, onRename, onDuplicate, onDelete]
);
const handleDoubleClick = useCallback(() => {
if (onDoubleClick) {
const node: TreeNode = { type: 'component', data: component };
onDoubleClick(node);
}
}, [component, onDoubleClick]);
// Show rename input if in rename mode
if (isRenaming && renameValue !== undefined && onRenameChange && onRenameConfirm && onRenameCancel) {
console.log('🔍 ComponentItem rendering RenameInput', {
component: component.localName,
renameValue,
hasOnRenameConfirm: !!onRenameConfirm,
hasOnRenameCancel: !!onRenameCancel,
onRenameConfirm: onRenameConfirm
});
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={handleDoubleClick}
onContextMenu={handleContextMenu}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<div className={css['ItemContent']}>
<div className={css['Icon']}>
<Icon icon={icon} />
</div>
<div className={css['Label']}>{component.localName}</div>
{component.hasWarnings && <div className={css['Warning']}>!</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
/**
* 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;
onMakeHome?: (node: TreeNode) => void;
onDelete?: (node: TreeNode) => void;
onDuplicate?: (node: TreeNode) => void;
onRename?: (node: TreeNode) => void;
onOpen?: (node: TreeNode) => void;
onDragStart?: (node: TreeNode, element: HTMLElement) => void;
onDrop?: (node: TreeNode) => void;
canAcceptDrop?: (node: TreeNode) => boolean;
// Rename mode props
renamingItem?: TreeNode | null;
renameValue?: string;
onRenameChange?: (value: string) => void;
onRenameConfirm?: () => void;
onRenameCancel?: () => void;
onDoubleClick?: (node: TreeNode) => void;
}
export function ComponentTree({
nodes,
level = 0,
onItemClick,
onCaretClick,
expandedFolders,
selectedId,
onMakeHome,
onDelete,
onDuplicate,
onRename,
onOpen,
onDragStart,
onDrop,
canAcceptDrop,
renamingItem,
renameValue,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDoubleClick
}: ComponentTreeProps) {
return (
<>
{nodes.map((node) => {
// Check if this item is being renamed
const isRenaming =
renamingItem &&
((node.type === 'component' && renamingItem.type === 'component' && node.data.id === renamingItem.data.id) ||
(node.type === 'folder' && renamingItem.type === 'folder' && node.data.path === renamingItem.data.path));
if (node.type === 'folder') {
return (
<FolderItem
key={node.data.path}
folder={node.data}
level={level}
isExpanded={expandedFolders.has(node.data.path)}
isSelected={selectedId === node.data.path}
onCaretClick={() => onCaretClick(node.data.path)}
onClick={() => onItemClick(node)}
onDelete={onDelete}
onRename={onRename}
onDragStart={onDragStart}
onDrop={onDrop}
canAcceptDrop={canAcceptDrop}
onDoubleClick={onDoubleClick}
isRenaming={isRenaming}
renameValue={renameValue}
onRenameChange={onRenameChange}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
>
{expandedFolders.has(node.data.path) && node.data.children.length > 0 && (
<ComponentTree
nodes={node.data.children}
level={level + 1}
onItemClick={onItemClick}
onCaretClick={onCaretClick}
expandedFolders={expandedFolders}
selectedId={selectedId}
onMakeHome={onMakeHome}
onDelete={onDelete}
onDuplicate={onDuplicate}
onRename={onRename}
onOpen={onOpen}
onDragStart={onDragStart}
onDrop={onDrop}
canAcceptDrop={canAcceptDrop}
renamingItem={renamingItem}
renameValue={renameValue}
onRenameChange={onRenameChange}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
onDoubleClick={onDoubleClick}
/>
)}
</FolderItem>
);
} else {
return (
<ComponentItem
key={node.data.id}
component={node.data}
level={level}
isSelected={selectedId === node.data.name}
onClick={() => onItemClick(node)}
onMakeHome={onMakeHome}
onDelete={onDelete}
onDuplicate={onDuplicate}
onRename={onRename}
onOpen={onOpen}
onDragStart={onDragStart}
onDoubleClick={onDoubleClick}
isRenaming={isRenaming}
renameValue={renameValue}
onRenameChange={onRenameChange}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
/>
);
}
})}
</>
);
}

View File

@@ -0,0 +1,205 @@
/**
* FolderItem
*
* Renders a folder row with expand/collapse caret and nesting.
*/
import classNames from 'classnames';
import React, { useCallback, useRef, useState } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import PopupLayer from '../../../popuplayer';
import css from '../ComponentsPanel.module.scss';
import { FolderItemData, TreeNode } from '../types';
import { RenameInput } from './RenameInput';
interface FolderItemProps {
folder: FolderItemData;
level: number;
isExpanded: boolean;
isSelected: boolean;
onCaretClick: () => void;
onClick: () => void;
children?: React.ReactNode;
onDelete?: (node: TreeNode) => void;
onRename?: (node: TreeNode) => void;
onDragStart?: (node: TreeNode, element: HTMLElement) => void;
onDrop?: (node: TreeNode) => void;
canAcceptDrop?: (node: TreeNode) => boolean;
onDoubleClick?: (node: TreeNode) => void;
isRenaming?: boolean;
renameValue?: string;
onRenameChange?: (value: string) => void;
onRenameConfirm?: () => void;
onRenameCancel?: () => void;
}
export function FolderItem({
folder,
level,
isExpanded,
isSelected,
onCaretClick,
onClick,
children,
onDelete,
onRename,
onDragStart,
onDrop,
canAcceptDrop,
onDoubleClick,
isRenaming,
renameValue,
onRenameChange,
onRenameConfirm,
onRenameCancel
}: FolderItemProps) {
const indent = level * 12;
const itemRef = useRef<HTMLDivElement>(null);
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
const [isDropTarget, setIsDropTarget] = useState(false);
// Drag handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
dragStartPos.current = { x: e.clientX, y: e.clientY };
}, []);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!dragStartPos.current || !onDragStart) return;
// Check if mouse moved enough to start drag (5px threshold)
const dx = e.clientX - dragStartPos.current.x;
const dy = e.clientY - dragStartPos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 5 && itemRef.current) {
const node: TreeNode = { type: 'folder', data: folder };
onDragStart(node, itemRef.current);
dragStartPos.current = null;
}
},
[folder, onDragStart]
);
const handleMouseUp = useCallback(() => {
dragStartPos.current = null;
}, []);
// Drop handlers
const handleMouseEnter = useCallback(() => {
if (PopupLayer.instance.isDragging() && canAcceptDrop) {
const node: TreeNode = { type: 'folder', data: folder };
if (canAcceptDrop(node)) {
setIsDropTarget(true);
PopupLayer.instance.indicateDropType('move');
}
}
}, [folder, canAcceptDrop]);
const handleMouseLeave = useCallback(() => {
setIsDropTarget(false);
if (PopupLayer.instance.isDragging()) {
PopupLayer.instance.indicateDropType('none');
}
}, []);
const handleDrop = useCallback(() => {
if (isDropTarget && onDrop) {
const node: TreeNode = { type: 'folder', data: folder };
onDrop(node);
setIsDropTarget(false);
}
}, [isDropTarget, folder, onDrop]);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const node: TreeNode = { type: 'folder', data: folder };
const items = [
{
label: 'Rename',
onClick: () => onRename?.(node)
},
{ type: 'divider' as const },
{
label: 'Delete',
onClick: () => onDelete?.(node)
}
];
const menu = new PopupLayer.PopupMenu({ items });
PopupLayer.instance.showPopup({
content: menu,
attachTo: e.currentTarget as HTMLElement,
position: { x: e.clientX, y: e.clientY }
});
},
[folder, onRename, onDelete]
);
const handleDoubleClick = useCallback(() => {
if (onDoubleClick) {
const node: TreeNode = { type: 'folder', data: folder };
onDoubleClick(node);
}
}, [folder, onDoubleClick]);
// Show rename input if in rename mode
if (isRenaming && renameValue !== undefined && onRenameChange && onRenameConfirm && onRenameCancel) {
return (
<RenameInput
value={renameValue}
onChange={onRenameChange}
onConfirm={onRenameConfirm}
onCancel={onRenameCancel}
level={level}
/>
);
}
return (
<>
<div
ref={itemRef}
className={classNames(css['TreeItem'], {
[css['Selected']]: isSelected,
[css['DropTarget']]: isDropTarget
})}
style={{ paddingLeft: `${indent + 10}px` }}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDrop={handleDrop}
>
<div
className={classNames(css['Caret'], {
[css['Expanded']]: isExpanded
})}
onClick={(e) => {
e.stopPropagation();
onCaretClick();
}}
>
</div>
<div className={css['ItemContent']} onClick={onClick}>
<div className={css['Icon']}>
<Icon icon={folder.isComponentFolder ? IconName.ComponentWithChildren : IconName.FolderClosed} />
</div>
<div className={css['Label']}>{folder.name}</div>
</div>
</div>
{children}
</>
);
}

View File

@@ -0,0 +1,75 @@
/**
* RenameInput
*
* Inline input field for renaming components/folders.
* Auto-focuses and selects text on mount.
*/
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) => {
console.log('🔍 RenameInput keyDown:', e.key);
if (e.key === 'Enter') {
console.log('✅ Enter pressed - calling onConfirm');
e.preventDefault();
e.stopPropagation();
onConfirm();
} else if (e.key === 'Escape') {
console.log('✅ Escape pressed - calling onCancel');
e.preventDefault();
e.stopPropagation();
onCancel();
}
},
[onConfirm, onCancel]
);
const handleBlur = useCallback(() => {
console.log('🔍 RenameInput blur - calling onConfirm');
onConfirm();
}, [onConfirm]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
[onChange]
);
return (
<div className={css['RenameContainer']} style={{ paddingLeft: `${indent + 23}px` }}>
<input
ref={inputRef}
type="text"
className={css['RenameInput']}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
</div>
);
}

View File

@@ -0,0 +1,466 @@
/**
* useComponentActions
*
* Provides handlers for component/folder actions.
* Integrates with UndoQueue for all operations.
*/
import { useCallback } from 'react';
import { NodeGraphModel } from '@noodl-models/nodegraphmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
import { tracker } from '@noodl-utils/tracker';
import { guid } from '@noodl-utils/utils';
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
import { ComponentModel } from '../../../../models/componentmodel';
import { TreeNode } from '../types';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ToastLayer = require('@noodl-views/toastlayer/toastlayer');
export function useComponentActions() {
const handleMakeHome = useCallback((node: TreeNode) => {
if (node.type !== 'component') return;
const component = node.data.component;
if (!component) return;
const canDelete = ProjectModel.instance?.deleteComponentAllowed(component);
if (!canDelete?.canBeDelete) {
console.warn('Cannot set component as home:', canDelete?.reason);
return;
}
const previousRoot = ProjectModel.instance?.getRootComponent();
const undoGroup = new UndoActionGroup({
label: `Make ${component.name} home`
});
UndoQueue.instance.push(undoGroup);
undoGroup.push({
do: () => {
ProjectModel.instance?.setRootComponent(component);
},
undo: () => {
if (previousRoot) {
ProjectModel.instance?.setRootComponent(previousRoot);
} else {
ProjectModel.instance?.setRootNode(undefined);
}
}
});
undoGroup.do();
}, []);
const handleDelete = useCallback((node: TreeNode) => {
if (node.type !== 'component') {
// TODO: Implement folder deletion
console.log('Folder deletion not yet implemented');
return;
}
const component = node.data.component;
const canDelete = ProjectModel.instance?.deleteComponentAllowed(component);
if (!canDelete?.canBeDelete) {
alert(canDelete?.reason || "This component can't be deleted");
return;
}
// Confirm deletion
const confirmed = confirm(`Are you sure you want to delete "${component.localName}"?`);
if (!confirmed) return;
const undoGroup = new UndoActionGroup({
label: `Delete ${component.name}`
});
UndoQueue.instance.push(undoGroup);
undoGroup.push({
do: () => {
ProjectModel.instance?.removeComponent(component, { undo: undoGroup });
},
undo: () => {
const restored = ProjectModel.instance?.getComponentWithName(component.name);
if (!restored) {
// Component was deleted, need to recreate it
// This is handled by the removeComponent undo
}
}
});
undoGroup.do();
}, []);
const handleDuplicate = useCallback((node: TreeNode) => {
if (node.type !== 'component') {
// TODO: Implement folder duplication
console.log('Folder duplication not yet implemented');
return;
}
const component = node.data.component;
let newName = component.name + ' Copy';
// Find unique name
let counter = 1;
while (ProjectModel.instance?.getComponentWithName(newName)) {
newName = `${component.name} Copy ${counter}`;
counter++;
}
const undoGroup = new UndoActionGroup({
label: `Duplicate ${component.name}`
});
UndoQueue.instance.push(undoGroup);
let duplicatedComponent = null;
undoGroup.push({
do: () => {
ProjectModel.instance?.duplicateComponent(component, newName, {
undo: undoGroup,
rerouteComponentRefs: null
});
duplicatedComponent = ProjectModel.instance?.getComponentWithName(newName);
},
undo: () => {
if (duplicatedComponent) {
ProjectModel.instance?.removeComponent(duplicatedComponent, { undo: undoGroup });
}
}
});
undoGroup.do();
}, []);
const handleRename = useCallback((node: TreeNode) => {
// This triggers the rename UI - the actual implementation
// will be wired up in ComponentsPanelReact
console.log('Rename initiated for:', node);
}, []);
/**
* Perform the actual rename operation with undo support
*/
const performRename = useCallback((node: TreeNode, newName: string) => {
if (node.type === 'component') {
const component = node.data.component;
const oldName = component.name;
const parentPath = oldName.includes('/') ? oldName.substring(0, oldName.lastIndexOf('/')) : '';
const fullNewName = parentPath ? `${parentPath}/${newName}` : newName;
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(fullNewName)) {
ToastLayer.showError('Component name already exists. Name must be unique.');
return false;
}
const undoGroup = new UndoActionGroup({
label: `Rename ${component.localName} to ${newName}`
});
UndoQueue.instance.push(undoGroup);
undoGroup.push({
do: () => {
ProjectModel.instance?.renameComponent(component, fullNewName);
},
undo: () => {
ProjectModel.instance?.renameComponent(component, oldName);
}
});
undoGroup.do();
return true;
} else if (node.type === 'folder') {
const oldPath = node.data.path;
const parentPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/')) : '';
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
// Get all components in this folder
const componentsToRename = ProjectModel.instance
?.getComponents()
.filter((comp) => comp.name.startsWith(oldPath + '/'));
if (!componentsToRename || componentsToRename.length === 0) {
// Empty folder - just update the path (no actual operation needed)
// Folders are virtual, so we don't need to do anything
return true;
}
// Check for naming conflicts
const wouldConflict = componentsToRename.some((comp) => {
const relativePath = comp.name.substring(oldPath.length);
const newFullName = newPath + relativePath;
return (
ProjectModel.instance?.getComponentWithName(newFullName) &&
ProjectModel.instance?.getComponentWithName(newFullName) !== comp
);
});
if (wouldConflict) {
ToastLayer.showError('Folder rename would create naming conflicts');
return false;
}
const renames: Array<{ component: TSFixme; oldName: string; newName: string }> = [];
componentsToRename.forEach((comp) => {
const relativePath = comp.name.substring(oldPath.length);
const newFullName = newPath + relativePath;
renames.push({ component: comp, oldName: comp.name, newName: newFullName });
});
const undoGroup = new UndoActionGroup({
label: `Rename folder ${node.data.name} to ${newName}`
});
UndoQueue.instance.push(undoGroup);
undoGroup.push({
do: () => {
renames.forEach(({ component, newName }) => {
ProjectModel.instance?.renameComponent(component, newName);
});
},
undo: () => {
renames.forEach(({ component, oldName }) => {
ProjectModel.instance?.renameComponent(component, oldName);
});
}
});
undoGroup.do();
return true;
}
return false;
}, []);
const handleOpen = useCallback((node: TreeNode) => {
if (node.type !== 'component') return;
// TODO: Open component in NodeGraphEditor
// This requires integration with the editor's tab system
console.log('Open component:', node.data.component.name);
}, []);
/**
* Handle dropping an item onto a target
*/
/**
* Handle adding a new component using a template
*/
const handleAddComponent = useCallback((template: TSFixme, parentPath?: string) => {
const finalParentPath = parentPath || '';
const popup = template.createPopup({
onCreate: (localName: string, options?: TSFixme) => {
const componentName = finalParentPath + localName;
// Validate name
if (!localName || localName.trim() === '') {
ToastLayer.showError('Component name cannot be empty');
return;
}
if (ProjectModel.instance?.getComponentWithName(componentName)) {
ToastLayer.showError('Component name already exists. Name must be unique.');
return;
}
// Create component with undo support
const undoGroup = new UndoActionGroup({ label: 'add component' });
let component: ComponentModel;
if (template) {
component = template.createComponent(componentName, options, undoGroup);
} else {
component = new ComponentModel({
name: componentName,
graph: new NodeGraphModel(),
id: guid()
});
}
tracker.track('Component Created', {
template: template ? template.label : undefined
});
ProjectModel.instance?.addComponent(component, { undo: undoGroup });
UndoQueue.instance.push(undoGroup);
// Switch to the new component
EventDispatcher.instance.notifyListeners('ComponentPanel.SwitchToComponent', {
component,
pushHistory: true
});
PopupLayer.instance.hidePopup();
},
onCancel: () => {
PopupLayer.instance.hidePopup();
}
});
PopupLayer.instance.showPopup({
content: popup,
position: 'bottom'
});
}, []);
/**
* Handle adding a new folder
*/
const handleAddFolder = useCallback((parentPath?: string) => {
const popup = new PopupLayer.StringInputPopup({
label: 'New folder name',
okLabel: 'Add',
cancelLabel: 'Cancel',
onOk: (folderName: string) => {
// Validate name
if (!folderName || folderName.trim() === '') {
ToastLayer.showError('Folder name cannot be empty');
return;
}
// For now, just show a message that this will be implemented
// The actual folder creation requires the ComponentsPanelFolder class
// which is part of the legacy system. We'll implement this when we
// migrate the folder structure to React state.
console.log('Creating folder:', folderName, 'at path:', parentPath);
ToastLayer.showInteraction('Folder creation will be available in the next phase');
PopupLayer.instance.hidePopup();
}
});
popup.render();
PopupLayer.instance.showPopup({
content: popup,
position: 'bottom'
});
}, []);
/**
* Handle dropping an item onto a target
*/
const handleDropOn = useCallback((draggedItem: TreeNode, targetItem: TreeNode) => {
// Component → Folder
if (draggedItem.type === 'component' && targetItem.type === 'folder') {
const component = draggedItem.data.component;
const targetPath = targetItem.data.path === '/' ? '' : targetItem.data.path;
const newName = targetPath ? `${targetPath}/${component.localName}` : component.localName;
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists in that folder`);
return;
}
const oldName = component.name;
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${component.localName} to folder`,
do: () => {
ProjectModel.instance?.renameComponent(component, newName);
},
undo: () => {
ProjectModel.instance?.renameComponent(component, oldName);
}
})
);
}
// Folder → Folder
else if (draggedItem.type === 'folder' && targetItem.type === 'folder') {
const sourcePath = draggedItem.data.path;
const targetPath = targetItem.data.path === '/' ? '' : targetItem.data.path;
const newPath = targetPath ? `${targetPath}/${draggedItem.data.name}` : draggedItem.data.name;
// Get all components in source folder
const componentsToMove = ProjectModel.instance
?.getComponents()
.filter((comp) => comp.name.startsWith(sourcePath + '/'));
if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move');
return;
}
const renames: Array<{ component: TSFixme; oldName: string; newName: string }> = [];
componentsToMove.forEach((comp) => {
const relativePath = comp.name.substring(sourcePath.length + 1);
const newName = `${newPath}/${relativePath}`;
renames.push({ component: comp, oldName: comp.name, newName });
});
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${draggedItem.data.name} folder`,
do: () => {
renames.forEach(({ component, newName }) => {
ProjectModel.instance?.renameComponent(component, newName);
});
},
undo: () => {
renames.forEach(({ component, oldName }) => {
ProjectModel.instance?.renameComponent(component, oldName);
});
}
})
);
}
// Component → Component (make subcomponent)
else if (draggedItem.type === 'component' && targetItem.type === 'component') {
const component = draggedItem.data.component;
const targetComponent = targetItem.data.component;
const newName = `${targetComponent.name}/${component.localName}`;
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists`);
return;
}
const oldName = component.name;
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${component.localName} into ${targetComponent.localName}`,
do: () => {
ProjectModel.instance?.renameComponent(component, newName);
},
undo: () => {
ProjectModel.instance?.renameComponent(component, oldName);
}
})
);
}
}, []);
return {
handleMakeHome,
handleDelete,
handleDuplicate,
handleRename,
performRename,
handleOpen,
handleDropOn,
handleAddComponent,
handleAddFolder
};
}

View File

@@ -0,0 +1,264 @@
import { useCallback, useMemo, useState } from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
import { useEventListener } from '../../../../hooks/useEventListener';
import { TreeNode } from '../types';
/**
* useComponentsPanel
*
* Main state management hook for ComponentsPanel.
* Subscribes to ProjectModel and builds tree structure.
*/
// 🔥 MODULE LOAD MARKER - If you see this, the new code is loaded!
console.log('🔥🔥🔥 useComponentsPanel.ts MODULE LOADED WITH FIXES - Version 2.0 🔥🔥🔥');
// Stable array reference to prevent re-subscription on every render
const PROJECT_EVENTS = ['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'];
interface UseComponentsPanelOptions {
hideSheets?: string[];
}
interface FolderStructure {
name: string;
path: string;
components: ComponentModel[];
children: FolderStructure[];
}
export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
const { hideSheets = [] } = 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 using the new useEventListener hook
console.log(
'🔍 useComponentsPanel: About to call useEventListener with ProjectModel.instance:',
ProjectModel.instance
);
useEventListener(ProjectModel.instance, PROJECT_EVENTS, () => {
console.log('🎉 Event received! Updating counter...');
setUpdateCounter((c) => c + 1);
});
// Build tree structure
const treeData = useMemo(() => {
if (!ProjectModel.instance) return [];
return buildTreeFromProject(ProjectModel.instance, hideSheets);
}, [updateCounter, hideSheets]);
// 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.data.name);
// Open component - trigger the NodeGraphEditor to switch to this component
const component = node.data.component;
if (component) {
EventDispatcher.instance.notifyListeners('ComponentPanel.SwitchToComponent', {
component,
pushHistory: true
});
}
} else {
setSelectedId(node.data.path);
// Toggle folder if clicking on folder
toggleFolder(node.data.path);
}
},
[toggleFolder]
);
return {
treeData,
expandedFolders,
selectedId,
toggleFolder,
handleItemClick
};
}
/**
* Build tree structure from ProjectModel
*/
function buildTreeFromProject(project: ProjectModel, hideSheets: string[]): TreeNode[] {
const rootFolder: FolderStructure = {
name: '',
path: '/',
components: [],
children: []
};
// Get all components
const components = project.getComponents();
// Filter by sheet if specified
const filteredComponents = components.filter((comp) => {
const sheet = getSheetForComponent(comp.name);
return !hideSheets.includes(sheet);
});
// Add each component to folder structure
filteredComponents.forEach((comp) => {
addComponentToFolderStructure(rootFolder, comp);
});
// Convert folder structure to tree nodes
return convertFolderToTreeNodes(rootFolder);
}
/**
* Add a component to the folder structure
*/
function addComponentToFolderStructure(rootFolder: FolderStructure, component: ComponentModel) {
const parts = component.name.split('/');
let currentFolder = rootFolder;
// Navigate/create folder structure (all parts except the last one)
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 = {
name: folderName,
path: parts.slice(0, i + 1).join('/'),
components: [],
children: []
};
currentFolder.children.push(folder);
}
currentFolder = folder;
}
// Add component to final folder
currentFolder.components.push(component);
}
/**
* Convert folder structure to tree nodes
*/
function convertFolderToTreeNodes(folder: FolderStructure): TreeNode[] {
const nodes: TreeNode[] = [];
// Sort folder children alphabetically
const sortedChildren = [...folder.children].sort((a, b) => a.name.localeCompare(b.name));
// Add folder children first
sortedChildren.forEach((childFolder) => {
const folderNode: TreeNode = {
type: 'folder',
data: {
name: childFolder.name,
path: childFolder.path,
isOpen: false,
isComponentFolder: childFolder.components.length > 0,
component: undefined,
children: convertFolderToTreeNodes(childFolder)
}
};
nodes.push(folderNode);
});
// Sort components alphabetically
const sortedComponents = [...folder.components].sort((a, b) => a.localName.localeCompare(b.localName));
// Add components
sortedComponents.forEach((comp) => {
const isRoot = ProjectModel.instance?.getRootComponent() === comp;
const isPage = checkIsPage(comp);
const isCloudFunction = checkIsCloudFunction(comp);
const isVisual = checkIsVisual(comp);
const componentNode: TreeNode = {
type: 'component',
data: {
id: comp.id,
name: comp.name,
localName: comp.localName,
component: comp,
isRoot,
isPage,
isCloudFunction,
isVisual,
hasWarnings: false, // TODO: Implement warning detection
path: comp.name
}
};
nodes.push(componentNode);
});
return nodes;
}
/**
* Extract sheet name from component name
*/
function getSheetForComponent(componentName: string): string {
// Components in sheets have format: SheetName/ComponentName
if (componentName.includes('/')) {
return componentName.split('/')[0];
}
return 'default';
}
/**
* Check if component is a page
*/
function checkIsPage(component: ComponentModel): boolean {
// A component is a page if it has nodes of type 'Page' or 'PageRouter'
let isPage = false;
component.forEachNode((node) => {
if (node.type.name === 'Page' || node.typename === 'Page') {
isPage = true;
return true; // Stop iteration
}
});
return isPage;
}
/**
* Check if component is a cloud function
*/
function checkIsCloudFunction(component: ComponentModel): boolean {
// A component is a cloud function if it has nodes of type 'Cloud Function'
let isCloudFunction = false;
component.forEachNode((node) => {
if (node.type.name === 'Cloud Function' || node.typename === 'Cloud Function') {
isCloudFunction = true;
return true; // Stop iteration
}
});
return isCloudFunction;
}
/**
* Check if component is visual (has UI elements)
*/
function checkIsVisual(component: ComponentModel): boolean {
// A component is visual if it's not a cloud function and has visual nodes
// For now, we'll consider all non-cloud-function components as visual
return !checkIsCloudFunction(component);
}

View File

@@ -0,0 +1,99 @@
/**
* useDragDrop
*
* Manages drag-drop state and operations for components/folders.
* Integrates with PopupLayer.startDragging system.
*/
import { useCallback, useState } from 'react';
import PopupLayer from '../../../popuplayer';
import { TreeNode } from '../types';
export function useDragDrop() {
const [draggedItem, setDraggedItem] = useState<TreeNode | null>(null);
const [dropTarget, setDropTarget] = useState<TreeNode | null>(null);
/**
* Start dragging an item
*/
const startDrag = useCallback((item: TreeNode, sourceElement: HTMLElement) => {
setDraggedItem(item);
const label = item.type === 'component' ? item.data.localName : `📁 ${item.data.name}`;
PopupLayer.instance.startDragging({
label,
type: item.type,
dragTarget: sourceElement,
component: item.type === 'component' ? item.data.component : undefined,
folder: item.type === 'folder' ? item.data : undefined,
onDragEnd: () => {
setDraggedItem(null);
setDropTarget(null);
}
});
}, []);
/**
* Check if an item can be dropped on a target
*/
const canDrop = useCallback(
(target: TreeNode): boolean => {
if (!draggedItem) return false;
// Can't drop on self
if (draggedItem.type === 'component' && target.type === 'component') {
if (draggedItem.data.id === target.data.id) return false;
}
if (draggedItem.type === 'folder' && target.type === 'folder') {
if (draggedItem.data.path === target.data.path) return false;
}
// Folder-specific rules
if (draggedItem.type === 'folder' && target.type === 'folder') {
// Can't drop folder into its own children (descendant check)
const draggedPath = draggedItem.data.path;
const targetPath = target.data.path;
if (targetPath.startsWith(draggedPath + '/')) {
return false; // Target is a descendant of dragged folder
}
}
return true;
},
[draggedItem]
);
/**
* Handle drop on a target
*/
const handleDrop = useCallback(
(target: TreeNode) => {
if (!draggedItem || !canDrop(target)) return;
setDropTarget(target);
// Drop will be executed by parent component
// which has access to ProjectModel and UndoQueue
},
[draggedItem, canDrop]
);
/**
* Clear drop state
*/
const clearDrop = useCallback(() => {
setDropTarget(null);
}, []);
return {
draggedItem,
dropTarget,
startDrag,
canDrop,
handleDrop,
clearDrop
};
}

View File

@@ -0,0 +1,121 @@
/**
* useRenameMode
*
* Manages inline rename state and validation for components and folders.
*/
import { useCallback, useState } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { TreeNode } from '../types';
interface ValidationResult {
valid: boolean;
error?: string;
}
export function useRenameMode() {
const [renamingItem, setRenamingItem] = useState<TreeNode | null>(null);
const [renameValue, setRenameValue] = useState('');
/**
* Start rename mode for an item
*/
const startRename = useCallback((item: TreeNode) => {
setRenamingItem(item);
// Set initial value based on item type
if (item.type === 'component') {
setRenameValue(item.data.localName);
} else {
setRenameValue(item.data.name);
}
}, []);
/**
* Cancel rename mode
*/
const cancelRename = useCallback(() => {
setRenamingItem(null);
setRenameValue('');
}, []);
/**
* Validate the new name
*/
const validateName = useCallback(
(newName: string): ValidationResult => {
if (!renamingItem) {
return { valid: false, error: 'No item selected for rename' };
}
// Check for empty name
if (!newName || newName.trim() === '') {
return { valid: false, error: 'Name cannot be empty' };
}
// Check for invalid characters
const invalidChars = /[<>:"|?*\\/]/;
if (invalidChars.test(newName)) {
return { valid: false, error: 'Name contains invalid characters (< > : " | ? * \\ /)' };
}
// If name hasn't changed, it's valid (no-op)
if (renamingItem.type === 'component' && newName === renamingItem.data.localName) {
return { valid: true };
}
if (renamingItem.type === 'folder' && newName === renamingItem.data.name) {
return { valid: true };
}
// Check for duplicate names
if (renamingItem.type === 'component') {
// Build the full component name with folder path
const currentPath = renamingItem.data.path;
const pathParts = currentPath.split('/');
pathParts.pop(); // Remove current component name
const folderPath = pathParts.join('/');
const newFullName = folderPath ? `${folderPath}/${newName}` : newName;
// Check if component with this name already exists
const existingComponent = ProjectModel.instance?.getComponentWithName(newFullName);
if (existingComponent && existingComponent !== renamingItem.data.component) {
return { valid: false, error: 'A component with this name already exists' };
}
} else if (renamingItem.type === 'folder') {
// For folders, check if any component exists with this folder path
const currentPath = renamingItem.data.path;
const pathParts = currentPath.split('/');
pathParts.pop(); // Remove current folder name
const parentPath = pathParts.join('/');
const newFolderPath = parentPath ? `${parentPath}/${newName}` : newName;
// Check if any component starts with this folder path
const components = ProjectModel.instance?.getComponents() || [];
const hasConflict = components.some((comp) => {
// Check if component is in a folder with the new name
return comp.name.startsWith(newFolderPath + '/') && !comp.name.startsWith(currentPath + '/');
});
if (hasConflict) {
return { valid: false, error: 'A folder with this name already exists' };
}
}
return { valid: true };
},
[renamingItem]
);
return {
renamingItem,
renameValue,
setRenameValue,
startRename,
cancelRename,
validateName
};
}

View File

@@ -0,0 +1,11 @@
/**
* ComponentsPanel Exports
*
* Re-exports the new React ComponentsPanel implementation
*/
// Export the React component
export { ComponentsPanel } from './ComponentsPanelReact';
// Export types
export type { ComponentsPanelProps, ComponentsPanelOptions } from './types';

View File

@@ -0,0 +1,53 @@
/**
* TypeScript type definitions for ComponentsPanel
*/
import { ComponentModel } from '@noodl-models/componentmodel';
/**
* Data structure for a component item in the tree
*/
export interface ComponentItemData {
id: string;
name: string;
localName: string;
component: ComponentModel;
isRoot: boolean;
isPage: boolean;
isCloudFunction: boolean;
isVisual: boolean;
hasWarnings: boolean;
path: string;
}
/**
* Data structure for a folder item in the tree
*/
export interface FolderItemData {
name: string;
path: string;
isOpen: boolean;
isComponentFolder: boolean;
component?: ComponentModel;
children: TreeNode[];
}
/**
* Union type representing either a component or folder in the tree
*/
export type TreeNode = { type: 'component'; data: ComponentItemData } | { type: 'folder'; data: FolderItemData };
/**
* Props for ComponentsPanel component
*/
export interface ComponentsPanelProps {
options?: ComponentsPanelOptions;
}
/**
* Configuration options for ComponentsPanel
*/
export interface ComponentsPanelOptions {
showSheetList?: boolean;
hideSheets?: string[];
}

View File

@@ -0,0 +1,285 @@
// =============================================================================
// Migration Notes Panel
// =============================================================================
// Displays detailed migration information for a component including issues,
// AI suggestions, and actions. Matches the design system from Session 1.
// =============================================================================
// Design System Variables
$success: #10b981;
$warning: #f59e0b;
$danger: #ef4444;
$ai-primary: #8b5cf6;
$spacing-xs: 8px;
$spacing-sm: 12px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$radius-md: 6px;
$radius-lg: 8px;
$transition-base: 250ms ease;
$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.08);
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
// Main Container
.MigrationNotesPanel {
display: flex;
flex-direction: column;
gap: $spacing-lg;
padding: $spacing-xl;
max-width: 600px;
min-width: 500px;
}
// Status Header
.StatusHeader {
display: flex;
gap: $spacing-md;
align-items: flex-start;
padding: $spacing-lg;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-bg-3);
border-radius: $radius-lg;
transition: all $transition-base;
&[data-status='needs-review'] {
border-left: 3px solid $warning;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.05) 0%, transparent 100%);
}
&[data-status='ai-migrated'] {
border-left: 3px solid $ai-primary;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, transparent 100%);
}
&[data-status='auto'],
&[data-status='manually-fixed'] {
border-left: 3px solid $success;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, transparent 100%);
}
}
.StatusIcon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--theme-color-bg-3);
svg {
[data-status='needs-review'] & {
color: $warning;
}
[data-status='ai-migrated'] & {
color: $ai-primary;
}
[data-status='auto'] &,
[data-status='manually-fixed'] & {
color: $success;
}
}
}
.StatusText {
flex: 1;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
// Content Sections
.Section {
display: flex;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-lg;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-bg-3);
border-radius: $radius-lg;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
display: flex;
align-items: center;
gap: $spacing-xs;
&::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: var(--theme-color-primary);
border-radius: 2px;
}
}
}
.SectionTitleAI {
display: flex;
align-items: center;
gap: $spacing-xs;
color: $ai-primary;
svg {
flex-shrink: 0;
color: $ai-primary;
}
&::before {
background: $ai-primary !important;
}
}
// Issues List
.IssuesList {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.IssueItem {
display: flex;
flex-direction: column;
gap: $spacing-xs;
padding: $spacing-sm;
background: var(--theme-color-bg-3);
border-radius: $radius-md;
transition: all $transition-base;
&:hover {
background: var(--theme-color-bg-2);
transform: translateX(2px);
}
}
.IssueType {
display: inline-block;
padding: 2px 8px;
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-bg-3);
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default);
width: fit-content;
}
.IssueDescription {
font-size: 13px;
line-height: 1.5;
color: var(--theme-color-fg-default);
}
// AI Suggestion
.AISuggestion {
padding: $spacing-md;
background: var(--theme-color-bg-3);
border-left: 3px solid $ai-primary;
border-radius: $radius-md;
font-size: 13px;
line-height: 1.6;
color: var(--theme-color-fg-default);
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 12px;
}
code {
background: var(--theme-color-bg-1);
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
p {
margin: 0 0 $spacing-sm 0;
&:last-child {
margin-bottom: 0;
}
}
ul,
ol {
margin: $spacing-sm 0;
padding-left: $spacing-lg;
}
li {
margin: $spacing-xs 0;
}
strong {
color: var(--theme-color-fg-highlight);
font-weight: 600;
}
em {
color: var(--theme-color-fg-highlight);
font-style: italic;
}
}
// Help Section
.HelpSection {
padding: $spacing-md;
background: var(--theme-color-bg-3);
border-radius: $radius-md;
}
.HelpLink {
color: var(--theme-color-primary);
text-decoration: none;
font-weight: 500;
transition: all $transition-base;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 1px;
background: var(--theme-color-primary);
transform: scaleX(0);
transition: transform $transition-base;
}
&:hover {
color: var(--theme-color-primary);
&::after {
transform: scaleX(1);
}
}
}
// Actions
.Actions {
display: flex;
justify-content: flex-end;
padding-top: $spacing-md;
border-top: 1px solid var(--theme-color-bg-3);
}

View File

@@ -0,0 +1,150 @@
/**
* MigrationNotesPanel
*
* Displays detailed migration information for a component.
* Shows issues detected, AI suggestions, and actions.
*/
import React from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title } from '@noodl-core-ui/components/typography/Title';
import {
dismissMigrationNote,
getIssueTypeLabel,
getStatusIcon,
getStatusLabel
} from '../../../models/migration/MigrationNotesManager';
import { ComponentMigrationNote } from '../../../models/migration/types';
import css from './MigrationNotesPanel.module.scss';
interface MigrationNotesPanelProps {
component: ComponentModel;
note: ComponentMigrationNote;
onClose: () => void;
}
export function MigrationNotesPanel({ component, note, onClose }: MigrationNotesPanelProps) {
const statusLabel = getStatusLabel(note.status);
const statusIcon = getStatusIcon(note.status);
const handleDismiss = () => {
dismissMigrationNote(component.fullName);
onClose();
};
const renderStatusIcon = () => {
const icons = {
'check-circle': (
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
),
sparkles: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
</svg>
),
warning: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
check: (
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)
};
return icons[statusIcon] || icons.check;
};
return (
<div className={css['MigrationNotesPanel']}>
{/* Status Header */}
<div className={css['StatusHeader']} data-status={note.status}>
<div className={css['StatusIcon']}>{renderStatusIcon()}</div>
<div className={css['StatusText']}>
<Title hasBottomSpacing={false}>{statusLabel}</Title>
<Text textType={TextType.Shy}>Component: {component.localName}</Text>
</div>
</div>
{/* Content */}
<VStack hasSpacing>
{/* Issues List */}
{note.issues && note.issues.length > 0 && (
<div className={css['Section']}>
<h4>Issues Detected</h4>
<ul className={css['IssuesList']}>
{note.issues.map((issue, i) => (
<li key={i} className={css['IssueItem']}>
<span className={css['IssueType']}>{getIssueTypeLabel(issue as any)}</span>
<span className={css['IssueDescription']}>{issue}</span>
</li>
))}
</ul>
</div>
)}
{/* AI Suggestion */}
{note.aiSuggestion && (
<div className={css['Section']}>
<h4 className={css['SectionTitleAI']}>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
</svg>
Claude&apos;s Suggestion
</h4>
<div className={css['AISuggestion']}>
<pre>{note.aiSuggestion}</pre>
</div>
</div>
)}
{/* Help Link */}
<div className={css['HelpSection']}>
<Text textType={TextType.Shy}>
Need more help?{' '}
<a
href="https://docs.opennoodl.com/migration/react19"
target="_blank"
rel="noopener noreferrer"
className={css['HelpLink']}
>
View React 19 migration guide
</a>
</Text>
</div>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
{note.status === 'needs-review' && (
<PrimaryButton variant={PrimaryButtonVariant.Ghost} label="Dismiss Warning" onClick={handleDismiss} />
)}
<PrimaryButton variant={PrimaryButtonVariant.Cta} label="Close" onClick={onClose} />
</HStack>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { MigrationNotesPanel } from './MigrationNotesPanel';

View File

@@ -1,32 +1,28 @@
import { useNodeGraphContext } from '@noodl-contexts/NodeGraphContext/NodeGraphContext';
import React, { useEffect, useState } from 'react';
/**
* ComponentsPanel Wrapper
*
* Temporary wrapper that will be replaced with direct import
* from the new ComponentsPanel React component.
*/
import { Frame } from '../../common/Frame';
import { ComponentsPanelOptions, ComponentsPanelView } from './ComponentsPanel';
import React from 'react';
import { ComponentsPanel as NewComponentsPanel } from '../ComponentsPanelNew/ComponentsPanelReact';
export interface ComponentsPanelProps {
options?: ComponentsPanelOptions;
options?: {
showSheetList?: boolean;
hideSheets?: string[];
};
}
/**
* Wrapper component for ComponentsPanel
* Currently using new React implementation
*/
export function ComponentsPanel({ options }: ComponentsPanelProps) {
const [instance, setInstance] = useState<ComponentsPanelView>(null);
useEffect(() => {
const instance = new ComponentsPanelView(options);
instance.render();
setInstance(instance);
return () => {
instance.dispose();
};
}, []);
const nodeGraphContext = useNodeGraphContext();
useEffect(() => {
//make sure the node graph is kept up to date through hot reloads
instance?.setNodeGraphEditor(nodeGraphContext.nodeGraph);
}, [instance, nodeGraphContext.nodeGraph]);
return <Frame instance={instance} isFitWidth />;
return <NewComponentsPanel options={options} />;
}
// Re-export types for compatibility
export type { ComponentsPanelOptions } from '../ComponentsPanelNew/types';