mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 15:22:55 +01:00
Working on the editor component tree
This commit is contained in:
121
packages/noodl-editor/src/editor/src/hooks/useEventListener.ts
Normal file
121
packages/noodl-editor/src/editor/src/hooks/useEventListener.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MigrationNotesPanel } from './MigrationNotesPanel';
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user