Finished component sidebar updates, with one small bug remaining and documented

This commit is contained in:
Richard Osborne
2025-12-28 22:07:29 +01:00
parent 5f8ce8d667
commit fad9f1006d
193 changed files with 22245 additions and 506 deletions

View File

@@ -19,6 +19,9 @@ import '../editor/src/styles/custom-properties/colors.css';
import Router from './src/router';
// Build canary: Verify fresh code is loading
console.log('🔥 BUILD TIMESTAMP:', new Date().toISOString());
ipcRenderer.on('open-noodl-uri', async (event, uri) => {
if (uri.startsWith('noodl:import/http')) {
console.log('import: ', uri);

View File

@@ -61,30 +61,24 @@ export function useEventListener<T = unknown>(
// 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);
const wrapper = (data?: T, emittedEventName?: string) => {
callbackRef.current(data, emittedEventName);
};
// 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);
};
},

View File

@@ -58,6 +58,7 @@ const DEFAULT_AI_PREFERENCES: AIPreferences = {
*/
export class MigrationSessionManager extends EventDispatcher {
private session: MigrationSessionState | null = null;
private orchestrator: { abort: () => void } | null = null; // AIMigrationOrchestrator instance
/**
* Creates a new migration session for a project
@@ -339,6 +340,12 @@ export class MigrationSessionManager extends EventDispatcher {
cancelSession(): void {
if (!this.session) return;
// Abort orchestrator if running
if (this.orchestrator) {
this.orchestrator.abort();
this.orchestrator = null;
}
const session = this.session;
this.session = null;
@@ -497,34 +504,181 @@ export class MigrationSessionManager extends EventDispatcher {
}
private async executeAIAssistedPhase(): Promise<void> {
if (!this.session?.scan || !this.session.ai?.enabled) return;
if (!this.session?.scan || !this.session.ai?.enabled || !this.session.ai.apiKey) return;
this.updateProgress({ phase: 'ai-assisted' });
this.addLogEntry({
level: 'info',
message: 'Starting AI-assisted migration...'
message: 'Starting AI-assisted migration with Claude...'
});
const { needsReview } = this.session.scan.categories;
for (let i = 0; i < needsReview.length; i++) {
const component = needsReview[i];
// Dynamic import to avoid loading unless needed
const { AIMigrationOrchestrator } = await import('./AIMigrationOrchestrator');
this.updateProgress({
currentComponent: component.name
});
// Create orchestrator with budget pause callback
const orchestrator = new AIMigrationOrchestrator(
this.session.ai.apiKey,
{
maxPerSession: this.session.ai.budget.maxPerSession,
pauseIncrement: this.session.ai.budget.pauseIncrement
},
{
maxRetries: 3,
minConfidence: 0.7,
verifyMigration: true
},
async (budgetState) => {
// Emit budget pause event
return new Promise<boolean>((resolve) => {
this.notifyListeners('budget-pause-required', {
session: this.session,
budgetState,
resolve
});
});
}
);
// TODO: Implement actual AI migration using Claude API
await this.simulateDelay(200);
// Track orchestrator for abort capability
this.orchestrator = orchestrator;
try {
for (let i = 0; i < needsReview.length; i++) {
const component = needsReview[i];
this.updateProgress({
current: this.getAutomaticComponentCount() + i + 1,
currentComponent: component.name
});
this.addLogEntry({
level: 'info',
component: component.name,
message: 'Starting AI migration...'
});
// Read source code
const sourcePath = `${this.session.source.path}/${component.path}`;
let sourceCode: string;
try {
sourceCode = await filesystem.readFile(sourcePath);
} catch (error) {
this.addLogEntry({
level: 'error',
component: component.name,
message: `Failed to read source file: ${error instanceof Error ? error.message : 'Unknown error'}`
});
continue;
}
// Migrate with AI
const result = await orchestrator.migrateComponent(
component,
sourceCode,
this.session.ai.preferences,
(update) => {
// Progress callback
this.addLogEntry({
level: 'info',
component: component.name,
message: update.message
});
},
async (request) => {
// Decision callback
return new Promise((resolve) => {
this.notifyListeners('ai-decision-required', {
session: this.session,
request,
resolve
});
});
}
);
// Update budget
if (this.session.ai?.budget) {
this.session.ai.budget.spent += result.totalCost;
}
// Handle result
if (result.status === 'success' && result.migratedCode) {
// Write migrated code to target
const targetPath = `${this.session.target.path}/${component.path}`;
try {
await filesystem.writeFile(targetPath, result.migratedCode);
this.addLogEntry({
level: 'success',
component: component.name,
message: `Migrated successfully (${result.attempts} attempts)`,
cost: result.totalCost
});
} catch (error) {
this.addLogEntry({
level: 'error',
component: component.name,
message: `Failed to write migrated file: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
} else if (result.status === 'partial' && result.migratedCode) {
// Write partial migration
const targetPath = `${this.session.target.path}/${component.path}`;
try {
await filesystem.writeFile(targetPath, result.migratedCode);
this.addLogEntry({
level: 'warning',
component: component.name,
message: 'Partial migration - manual review required',
cost: result.totalCost
});
} catch (error) {
this.addLogEntry({
level: 'error',
component: component.name,
message: `Failed to write partial migration: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
} else if (result.status === 'failed') {
this.addLogEntry({
level: 'error',
component: component.name,
message: result.error || 'Migration failed',
details: result.aiSuggestion,
cost: result.totalCost
});
} else if (result.status === 'skipped') {
this.addLogEntry({
level: 'warning',
component: component.name,
message: result.warnings[0] || 'Component skipped',
cost: result.totalCost
});
}
}
this.addLogEntry({
level: 'warning',
component: component.name,
message: 'AI migration not yet implemented - marked for manual review'
level: 'success',
message: `AI migration complete. Total spent: $${this.session.ai.budget.spent.toFixed(2)}`
});
} catch (error) {
this.addLogEntry({
level: 'error',
message: `AI migration error: ${error instanceof Error ? error.message : 'Unknown error'}`
});
throw error;
} finally {
this.orchestrator = null;
}
}
private getAutomaticComponentCount(): number {
if (!this.session?.scan) return 0;
const { automatic, simpleFixes } = this.session.scan.categories;
return automatic.length + simpleFixes.length;
}
private async executeFinalizePhase(): Promise<void> {
if (!this.session) return;

View File

@@ -173,6 +173,6 @@ export default class Router
render() {
const Route = this.state.route;
return Route ? <Route {...this.state.routeArgs} /> : null;
return <>{Route ? <Route {...this.state.routeArgs} /> : null}</>;
}
}

View File

@@ -190,6 +190,9 @@
.popup-layer-popup-menu {
min-width: 208px;
cursor: default;
background-color: var(--theme-color-bg-3);
border-radius: 4px;
padding: 4px 0;
}
.popup-layer-popup-menu-divider {

View File

@@ -0,0 +1,187 @@
.DecisionDialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 32px 24px 24px;
max-width: 550px;
max-height: 80vh;
}
.Icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
color: var(--theme-color-bg-1);
opacity: 0.9;
&[data-type='warning'] {
background: var(--theme-color-warning);
}
&[data-type='help'] {
background: var(--theme-color-primary);
}
}
.Content {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
text-align: center;
overflow-y: auto;
max-height: calc(80vh - 200px);
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
text-align: left;
}
strong {
color: var(--theme-color-fg-highlight);
font-weight: 600;
}
}
.CostInfo {
padding: 12px;
background: var(--theme-color-bg-3);
border-radius: 8px;
border-left: 3px solid var(--theme-color-warning);
}
.AttemptHistory {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
}
.Attempts {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.Attempt {
padding: 12px;
background: var(--theme-color-bg-3);
border-radius: 6px;
border: 1px solid var(--theme-color-border-default);
}
.Attempt__Header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.Attempt__Number {
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.Attempt__Cost {
font-size: 11px;
font-weight: 500;
color: var(--theme-color-fg-default-shy);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.Attempt__Error {
font-size: 12px;
line-height: 1.4;
}
.Options {
margin-top: 8px;
}
.HelpContent {
display: flex;
flex-direction: column;
gap: 20px;
text-align: left;
padding: 16px;
background: var(--theme-color-bg-2);
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
}
.HelpSection {
display: flex;
flex-direction: column;
gap: 12px;
ul,
ol {
margin: 0;
padding-left: 20px;
color: var(--theme-color-fg-default);
font-size: 13px;
line-height: 1.6;
li {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
}
.AttemptSummary {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: var(--theme-color-bg-3);
border-radius: 4px;
border-left: 2px solid var(--theme-color-danger);
strong {
font-size: 12px;
}
}
.Actions {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.Actions__Row {
display: flex;
gap: 12px;
width: 100%;
justify-content: center;
}

View File

@@ -0,0 +1,171 @@
/**
* Decision Dialog
*
* Dialog shown when AI migration fails after max retries.
* Allows user to choose how to proceed with the component.
*/
import React, { useState } from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import type { AIDecisionRequest } from '../../models/migration/types';
import css from './DecisionDialog.module.scss';
interface DecisionDialogProps {
request: AIDecisionRequest;
onDecision: (action: 'retry' | 'skip' | 'manual' | 'getHelp') => void;
}
export function DecisionDialog({ request, onDecision }: DecisionDialogProps) {
const [showingHelp, setShowingHelp] = useState(false);
const handleGetHelp = () => {
setShowingHelp(true);
};
const handleBack = () => {
setShowingHelp(false);
};
if (showingHelp) {
return (
<div className={css['DecisionDialog']}>
<div className={css['Icon']} data-type="help">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" />
</svg>
</div>
<div className={css['Content']}>
<h3>AI Migration Suggestions</h3>
<Text textType={TextType.Secondary}>
The AI couldn&apos;t automatically migrate <strong>{request.componentName}</strong> after {request.attempts}{' '}
attempts. Here&apos;s what to look for:
</Text>
<div className={css['HelpContent']}>
<div className={css['HelpSection']}>
<h4>Common Issues</h4>
<ul>
<li>
<strong>Legacy Lifecycle Methods:</strong> Replace componentWillMount, componentWillReceiveProps,
componentWillUpdate with modern alternatives
</li>
<li>
<strong>String Refs:</strong> Convert ref="myRef" to ref={'{'} (el) =&gt; this.myRef = el {'}'}
</li>
<li>
<strong>findDOMNode:</strong> Use ref callbacks to access DOM nodes directly
</li>
<li>
<strong>Legacy Context:</strong> Migrate to modern Context API (createContext/useContext)
</li>
</ul>
</div>
{request.attemptHistory.length > 0 && (
<div className={css['HelpSection']}>
<h4>What the AI Tried</h4>
{request.attemptHistory.map((attempt, index) => (
<div key={index} className={css['AttemptSummary']}>
<strong>Attempt {index + 1}:</strong>
<Text textType={TextType.Shy}>{attempt.error}</Text>
</div>
))}
</div>
)}
<div className={css['HelpSection']}>
<h4>Recommended Actions</h4>
<ol>
<li>Open the component in the code editor</li>
<li>Check the console for specific error messages</li>
<li>Refer to the React 19 upgrade guide</li>
<li>Make changes incrementally and test after each change</li>
</ol>
</div>
</div>
</div>
<div className={css['Actions']}>
<PrimaryButton variant={PrimaryButtonVariant.Muted} label="Back" onClick={handleBack} />
<PrimaryButton
variant={PrimaryButtonVariant.Cta}
label="Mark for Manual Review"
onClick={() => onDecision('skip')}
/>
</div>
</div>
);
}
return (
<div className={css['DecisionDialog']}>
<div className={css['Icon']} data-type="warning">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
</div>
<div className={css['Content']}>
<h3>Migration Needs Your Help</h3>
<Text textType={TextType.Secondary}>
The AI couldn&apos;t automatically migrate <strong>{request.componentName}</strong> after {request.attempts}{' '}
attempts.
</Text>
<div className={css['CostInfo']}>
<Text textType={TextType.Shy}>
Spent so far: <strong>${request.costSpent.toFixed(2)}</strong>
{request.retryCost > 0 && (
<>
{' '}
· Retry cost: <strong>${request.retryCost.toFixed(2)}</strong>
</>
)}
</Text>
</div>
<div className={css['AttemptHistory']}>
<h4>Previous Attempts</h4>
<div className={css['Attempts']}>
{request.attemptHistory.map((attempt, index) => (
<div key={index} className={css['Attempt']}>
<div className={css['Attempt__Header']}>
<span className={css['Attempt__Number']}>Attempt {index + 1}</span>
<span className={css['Attempt__Cost']}>${attempt.cost.toFixed(3)}</span>
</div>
<Text textType={TextType.Shy} className={css['Attempt__Error']}>
{attempt.error}
</Text>
</div>
))}
</div>
</div>
<div className={css['Options']}>
<Text textType={TextType.Secondary}>What would you like to do?</Text>
</div>
</div>
<div className={css['Actions']}>
<div className={css['Actions__Row']}>
<PrimaryButton variant={PrimaryButtonVariant.Muted} label="Try Again" onClick={() => onDecision('retry')} />
<PrimaryButton variant={PrimaryButtonVariant.Muted} label="Skip for Now" onClick={() => onDecision('skip')} />
</div>
<div className={css['Actions__Row']}>
<PrimaryButton variant={PrimaryButtonVariant.Ghost} label="Get Help" onClick={handleGetHelp} />
<PrimaryButton
variant={PrimaryButtonVariant.Danger}
label="Accept Last Attempt"
onClick={() => onDecision('manual')}
/>
</div>
</div>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { BudgetState } from '../../models/migration/BudgetController';
import {
migrationSessionManager,
getStepLabel,
@@ -27,10 +28,13 @@ import {
MigrationScan,
MigrationResult,
AIBudget,
AIPreferences
AIPreferences,
AIDecisionRequest
} from '../../models/migration/types';
import { AIConfigPanel, AIConfig } from './AIConfigPanel';
import { BudgetApprovalDialog } from './BudgetApprovalDialog';
import { WizardProgress } from './components/WizardProgress';
import { DecisionDialog } from './DecisionDialog';
import css from './MigrationWizard.module.scss';
import { CompleteStep } from './steps/CompleteStep';
import { ConfirmStep } from './steps/ConfirmStep';
@@ -222,6 +226,10 @@ export function MigrationWizard({ sourcePath, projectName, onComplete, onCancel
});
const [isInitialized, setIsInitialized] = useState(false);
const [budgetApprovalRequest, setBudgetApprovalRequest] = useState<BudgetState | null>(null);
const [decisionRequest, setDecisionRequest] = useState<AIDecisionRequest | null>(null);
const [budgetApprovalResolve, setBudgetApprovalResolve] = useState<((approved: boolean) => void) | null>(null);
const [decisionResolve, setDecisionResolve] = useState<((action: string) => void) | null>(null);
// Create session on mount
useEffect(() => {
@@ -359,6 +367,47 @@ export function MigrationWizard({ sourcePath, projectName, onComplete, onCancel
console.log('Pause migration requested');
}, []);
const handleBudgetApproval = useCallback(
(approved: boolean) => {
if (budgetApprovalResolve) {
budgetApprovalResolve(approved);
setBudgetApprovalResolve(null);
}
setBudgetApprovalRequest(null);
},
[budgetApprovalResolve]
);
const handleDecision = useCallback(
(action: 'retry' | 'skip' | 'manual' | 'getHelp') => {
if (decisionResolve) {
decisionResolve(action);
setDecisionResolve(null);
}
setDecisionRequest(null);
},
[decisionResolve]
);
// Callback for orchestrator to request budget approval
const requestBudgetApproval = useCallback((state: BudgetState): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setBudgetApprovalRequest(state);
setBudgetApprovalResolve(() => resolve);
});
}, []);
// Callback for orchestrator to request decision
const requestDecision = useCallback(
(request: AIDecisionRequest): Promise<'retry' | 'skip' | 'manual' | 'getHelp'> => {
return new Promise<'retry' | 'skip' | 'manual' | 'getHelp'>((resolve) => {
setDecisionRequest(request);
setDecisionResolve(() => resolve);
});
},
[]
);
// ==========================================================================
// Render
// ==========================================================================
@@ -430,6 +479,10 @@ export function MigrationWizard({ sourcePath, projectName, onComplete, onCancel
budget={session.ai?.budget}
onAiDecision={handleAiDecision}
onPause={handlePauseMigration}
budgetApprovalRequest={budgetApprovalRequest}
onBudgetApproval={handleBudgetApproval}
decisionRequest={decisionRequest}
onDecision={handleDecision}
/>
);

View File

@@ -391,3 +391,27 @@
padding-top: 16px;
border-top: 1px solid var(--theme-color-bg-2);
}
/* Dialog Overlay */
.DialogOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -16,7 +16,10 @@ import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationProgress, AIBudget } from '../../../models/migration/types';
import { BudgetState } from '../../../models/migration/BudgetController';
import { MigrationProgress, AIBudget, AIDecisionRequest } from '../../../models/migration/types';
import { BudgetApprovalDialog } from '../BudgetApprovalDialog';
import { DecisionDialog } from '../DecisionDialog';
import css from './MigratingStep.module.scss';
export interface AiDecisionRequest {
@@ -46,6 +49,14 @@ export interface MigratingStepProps {
onAiDecision?: (decision: AiDecision) => void;
/** Called when user pauses migration */
onPause?: () => void;
/** Budget approval request from orchestrator */
budgetApprovalRequest?: BudgetState | null;
/** Called when user approves/denies budget */
onBudgetApproval?: (approved: boolean) => void;
/** Decision request from orchestrator */
decisionRequest?: AIDecisionRequest | null;
/** Called when user makes a decision */
onDecision?: (action: 'retry' | 'skip' | 'manual' | 'getHelp') => void;
}
export function MigratingStep({
@@ -54,7 +65,11 @@ export function MigratingStep({
budget,
awaitingDecision,
onAiDecision,
onPause
onPause,
budgetApprovalRequest,
onBudgetApproval,
decisionRequest,
onDecision
}: MigratingStepProps) {
const progressPercent = Math.round((progress.current / progress.total) * 100);
const budgetPercent = budget ? (budget.spent / budget.maxPerSession) * 100 : 0;
@@ -135,8 +150,26 @@ export function MigratingStep({
</div>
)}
{/* AI Decision Panel */}
{/* AI Decision Panel (legacy) */}
{awaitingDecision && onAiDecision && <AiDecisionPanel request={awaitingDecision} onDecision={onAiDecision} />}
{/* Budget Approval Dialog */}
{budgetApprovalRequest && onBudgetApproval && (
<div className={css['DialogOverlay']}>
<BudgetApprovalDialog
state={budgetApprovalRequest}
onApprove={() => onBudgetApproval(true)}
onDeny={() => onBudgetApproval(false)}
/>
</div>
)}
{/* Decision Dialog */}
{decisionRequest && onDecision && (
<div className={css['DialogOverlay']}>
<DecisionDialog request={decisionRequest} onDecision={onDecision} />
</div>
)}
</VStack>
{/* Actions */}

View File

@@ -9,7 +9,7 @@
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
overflow: visible; /* Allow popups to extend outside panel */
}
.Header {

View File

@@ -7,33 +7,35 @@
* @module noodl-editor
*/
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback } from 'react';
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { MenuDialogWidth } from '@noodl-core-ui/components/popups/MenuDialog';
import { PopupMenu } from '../../PopupLayer/PopupMenu';
import { showContextMenuInPopup } from '../../ShowContextMenuInPopup';
import { ComponentTree } from './components/ComponentTree';
import { SheetSelector } from './components/SheetSelector';
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 { useSheetManagement } from './hooks/useSheetManagement';
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 { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick, sheets, currentSheet, selectSheet } =
useComponentsPanel({
hideSheets: options?.hideSheets,
lockToSheet: options?.lockToSheet
});
const {
handleMakeHome,
@@ -42,15 +44,97 @@ export function ComponentsPanel({ options }: ComponentsPanelProps) {
performRename,
handleOpen,
handleDropOn,
handleDropOnRoot,
handleAddComponent,
handleAddFolder
} = useComponentActions();
const { draggedItem, dropTarget, startDrag, canDrop, handleDrop, clearDrop } = useDragDrop();
const { createSheet, renameSheet, deleteSheet, moveToSheet } = useSheetManagement();
const { draggedItem, startDrag, canDrop } = useDragDrop();
const { renamingItem, renameValue, startRename, setRenameValue, cancelRename, validateName } = useRenameMode();
const addButtonRef = useRef<HTMLButtonElement>(null);
// Handle creating a new sheet
const handleCreateSheet = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
const popup = new PopupLayer.StringInputPopup({
label: 'New sheet name',
okLabel: 'Create',
cancelLabel: 'Cancel',
onOk: (name: string) => {
if (createSheet(name)) {
PopupLayer.instance.hidePopup();
}
}
});
popup.render();
PopupLayer.instance.showPopup({
content: popup,
position: 'screen-center',
isBackgroundDimmed: true
});
}, [createSheet]);
// Handle renaming a sheet
const handleRenameSheet = useCallback(
(sheet: TSFixme) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
const popup = new PopupLayer.StringInputPopup({
label: 'New sheet name',
value: sheet.name,
okLabel: 'Rename',
cancelLabel: 'Cancel',
onOk: (newName: string) => {
if (renameSheet(sheet, newName)) {
PopupLayer.instance.hidePopup();
}
}
});
popup.render();
PopupLayer.instance.showPopup({
content: popup,
position: 'screen-center',
isBackgroundDimmed: true
});
},
[renameSheet]
);
// Handle deleting a sheet
const handleDeleteSheet = useCallback(
(sheet: TSFixme) => {
DialogLayerModel.instance.showConfirm({
title: `Delete sheet "${sheet.name}"?`,
text: `The ${sheet.componentCount} component(s) in this sheet will be moved to the root level.`,
onConfirm: () => {
const wasCurrentSheet = currentSheet && currentSheet.folderName === sheet.folderName;
const success = deleteSheet(sheet);
// Navigate to "All" view if we deleted the currently selected sheet
if (success && wasCurrentSheet) {
selectSheet(null);
}
}
});
},
[deleteSheet, currentSheet, selectSheet]
);
// Handle moving a component to a sheet
const handleMoveToSheet = useCallback(
(componentPath: string, sheet: TSFixme) => {
moveToSheet(componentPath, sheet);
},
[moveToSheet]
);
// Handle rename action from context menu
const handleRename = useCallback(
@@ -62,117 +146,105 @@ export function ComponentsPanel({ options }: ComponentsPanelProps) {
// 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);
console.warn('Invalid component 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();
// Direct drop handler - bypasses useDragDrop state system for immediate execution
// This matches how handleDropOnRoot works (which is reliable)
const handleDirectDrop = useCallback(
(targetNode: TSFixme) => {
if (draggedItem) {
handleDropOn(draggedItem, targetNode);
}
},
[draggedItem, handleDropOn]
);
// Handle mouse up on Tree background - this is the root drop fallback
// If an item is a valid drop target, its handleMouseUp calls stopPropagation
// So if we receive mouseUp here, it means no item claimed the drop
const handleTreeMouseUp = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
// If we're dragging and no specific item claimed the drop, it's a root drop
if (draggedItem && PopupLayer.instance.isDragging()) {
handleDropOnRoot(draggedItem);
}
}, [draggedItem, dropTarget, handleDropOn, clearDrop]);
}, [draggedItem, handleDropOnRoot]);
// Handle add button click
const handleAddClick = useCallback(() => {
console.log('🔵 ADD BUTTON CLICKED!');
// Handle right-click on empty space - Show create menu
const handleTreeContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
const templates = ComponentTemplates.instance.getTemplates({
forRuntimeType: 'browser' // Default to browser runtime for now
forRuntimeType: 'browser'
});
console.log('✅ Templates:', templates);
const items = templates.map((template) => ({
const items: TSFixme[] = templates.map((template) => ({
icon: template.icon,
label: template.label,
onClick: () => {
handleAddComponent(template);
}
label: `Create ${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'
label: 'Create Folder',
onClick: () => handleAddFolder()
});
console.log('✅ Popup shown successfully');
} catch (error) {
console.error('❌ Error in handleAddClick:', error);
}
}, [handleAddComponent, handleAddFolder]);
showContextMenuInPopup({
items,
width: MenuDialogWidth.Default
});
},
[handleAddComponent, handleAddFolder]
);
return (
<div className={css['ComponentsPanel']}>
{/* Header with title and add button */}
{/* Header with title and sheet selector */}
<div className={css['Header']}>
<span className={css['Title']}>Components</span>
<button
ref={addButtonRef}
className={css['AddButton']}
title="Add Component or Folder"
onClick={handleAddClick}
>
+
</button>
<SheetSelector
sheets={sheets}
currentSheet={currentSheet}
onSelectSheet={selectSheet}
onCreateSheet={handleCreateSheet}
onRenameSheet={handleRenameSheet}
onDeleteSheet={handleDeleteSheet}
disabled={!!options?.lockToSheet}
/>
</div>
{/* Component tree */}
<div className={css['Tree']}>
{/* Component tree - right-click for create menu, mouseUp on background triggers root drop */}
<div className={css['Tree']} onContextMenu={handleTreeContextMenu} onMouseUp={handleTreeMouseUp}>
{treeData.length > 0 ? (
<ComponentTree
nodes={treeData}
@@ -186,14 +258,18 @@ export function ComponentsPanel({ options }: ComponentsPanelProps) {
onRename={handleRename}
onOpen={handleOpen}
onDragStart={startDrag}
onDrop={handleDrop}
onDrop={handleDirectDrop}
canAcceptDrop={canDrop}
onAddComponent={handleAddComponent}
onAddFolder={handleAddFolder}
renamingItem={renamingItem}
renameValue={renameValue}
onRenameChange={setRenameValue}
onRenameConfirm={handleRenameConfirm}
onRenameCancel={cancelRename}
onDoubleClick={handleRename}
sheets={sheets}
onMoveToSheet={handleMoveToSheet}
/>
) : (
<div className={css['PlaceholderMessage']}>

View File

@@ -5,15 +5,20 @@
*/
import classNames from 'classnames';
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { MenuDialogWidth } from '@noodl-core-ui/components/popups/MenuDialog';
import PopupLayer from '../../../popuplayer';
import { showContextMenuInPopup } from '../../../ShowContextMenuInPopup';
import css from '../ComponentsPanel.module.scss';
import { ComponentItemData, TreeNode } from '../types';
import { ComponentTemplates } from '../ComponentTemplates';
import { ComponentItemData, Sheet, TreeNode } from '../types';
import { RenameInput } from './RenameInput';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
interface ComponentItemProps {
component: ComponentItemData;
level: number;
@@ -25,12 +30,19 @@ interface ComponentItemProps {
onRename?: (node: TreeNode) => void;
onOpen?: (node: TreeNode) => void;
onDragStart?: (node: TreeNode, element: HTMLElement) => void;
onDrop?: (node: TreeNode) => void;
canAcceptDrop?: (node: TreeNode) => boolean;
onDoubleClick?: (node: TreeNode) => void;
onAddComponent?: (template: TSFixme, parentPath?: string) => void;
onAddFolder?: (parentPath?: string) => void;
isRenaming?: boolean;
renameValue?: string;
onRenameChange?: (value: string) => void;
onRenameConfirm?: () => void;
onRenameCancel?: () => void;
// Sheet management
sheets?: Sheet[];
onMoveToSheet?: (componentPath: string, sheet: Sheet) => void;
}
export function ComponentItem({
@@ -44,16 +56,23 @@ export function ComponentItem({
onRename,
onOpen,
onDragStart,
onDrop,
canAcceptDrop,
onDoubleClick,
onAddComponent,
onAddFolder,
isRenaming,
renameValue,
onRenameChange,
onRenameConfirm,
onRenameCancel
onRenameCancel,
sheets,
onMoveToSheet
}: ComponentItemProps) {
const indent = level * 12;
const itemRef = useRef<HTMLDivElement>(null);
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
const [isDropTarget, setIsDropTarget] = useState(false);
// Determine icon based on component type
let icon = IconName.Component;
@@ -90,53 +109,171 @@ export function ComponentItem({
[component, onDragStart]
);
const handleMouseUp = useCallback(() => {
dragStartPos.current = null;
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
dragStartPos.current = null;
// If this item is a valid drop target, execute the drop
if (isDropTarget && onDrop) {
e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback)
const node: TreeNode = { type: 'component', data: component };
onDrop(node);
setIsDropTarget(false);
// Note: dragCompleted() is called by handleDropOn - don't call it here
}
},
[isDropTarget, component, onDrop]
);
// Drop handlers
const handleMouseEnter = useCallback(() => {
if (PopupLayer.instance.isDragging() && canAcceptDrop) {
const node: TreeNode = { type: 'component', data: component };
if (canAcceptDrop(node)) {
setIsDropTarget(true);
PopupLayer.instance.indicateDropType('move');
}
}
}, [component, canAcceptDrop]);
const handleMouseLeave = useCallback(() => {
setIsDropTarget(false);
if (PopupLayer.instance.isDragging()) {
PopupLayer.instance.indicateDropType('none');
}
}, []);
const handleDrop = useCallback(() => {
if (isDropTarget && onDrop) {
const node: TreeNode = { type: 'component', data: component };
onDrop(node);
setIsDropTarget(false);
}
}, [isDropTarget, component, onDrop]);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Clear drag state to prevent phantom drags after menu closes
dragStartPos.current = null;
const node: TreeNode = { type: 'component', data: component };
const items = [
{
label: 'Open',
onClick: () => onOpen?.(node)
},
{ type: 'divider' as const },
{
// Calculate parent path for new components (nested inside this component)
// Use the component's full path + "/" to nest inside it
const parentPath = component.path + '/';
const items: TSFixme[] = [];
// Add "Create" menu items if handlers are provided
if (onAddComponent && onAddFolder) {
// Get templates for browser runtime (default)
const templates = ComponentTemplates.instance.getTemplates({
forRuntimeType: 'browser'
});
// Add template creation items
templates.forEach((template) => {
items.push({
icon: template.icon,
label: `Create ${template.label}`,
onClick: () => onAddComponent(template, parentPath)
});
});
// Add folder creation
items.push('divider');
items.push({
icon: IconName.FolderClosed,
label: 'Create Folder',
onClick: () => onAddFolder(parentPath)
});
items.push('divider');
}
// Add existing menu items
items.push({
label: 'Open',
onClick: () => onOpen?.(node)
});
items.push('divider');
// Only show "Make Home" for pages or visual components (not logic/cloud functions)
if (component.isPage || component.isVisual) {
items.push({
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)
}
];
});
items.push('divider');
}
items.push({
label: 'Rename',
onClick: () => onRename?.(node)
});
items.push({
label: 'Duplicate',
onClick: () => onDuplicate?.(node)
});
const menu = new PopupLayer.PopupMenu({ items });
// Add "Move to" option if sheets are available
if (sheets && sheets.length > 0 && onMoveToSheet) {
items.push('divider');
PopupLayer.instance.showPopup({
content: menu,
attachTo: e.currentTarget as HTMLElement,
position: { x: e.clientX, y: e.clientY }
// "Move to" opens a separate popup with sheet options
items.push({
label: 'Move to...',
icon: IconName.FolderClosed,
onClick: () => {
// Determine which sheet this component is currently in
const currentSheetFolder = sheets.find(
(s) => !s.isDefault && component.path.startsWith('/' + s.folderName + '/')
);
const isInDefaultSheet = !currentSheetFolder;
// Create sheet selection menu items
const sheetItems: TSFixme[] = sheets.map((sheet) => {
const isCurrentSheet = sheet.isDefault
? isInDefaultSheet
: sheet.folderName === currentSheetFolder?.folderName;
return {
label: sheet.name + (isCurrentSheet ? ' (current)' : ''),
icon: sheet.isDefault ? IconName.Component : IconName.FolderClosed,
isDisabled: isCurrentSheet,
isHighlighted: isCurrentSheet,
onClick: () => {
if (!isCurrentSheet) {
onMoveToSheet(component.name, sheet);
}
}
};
});
// Show the sheet selection popup
showContextMenuInPopup({
items: sheetItems,
width: MenuDialogWidth.Default
});
}
});
}
items.push('divider');
items.push({
label: 'Delete',
onClick: () => onDelete?.(node)
});
showContextMenuInPopup({
items,
width: MenuDialogWidth.Default
});
},
[component, onOpen, onMakeHome, onRename, onDuplicate, onDelete]
[component, onOpen, onMakeHome, onRename, onDuplicate, onDelete, onAddComponent, onAddFolder, sheets, onMoveToSheet]
);
const handleDoubleClick = useCallback(() => {
@@ -148,13 +285,6 @@ export function ComponentItem({
// 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}
@@ -170,7 +300,8 @@ export function ComponentItem({
<div
ref={itemRef}
className={classNames(css['TreeItem'], {
[css['Selected']]: isSelected
[css['Selected']]: isSelected,
[css['DropTarget']]: isDropTarget
})}
style={{ paddingLeft: `${indent + 23}px` }}
onClick={onClick}
@@ -179,6 +310,9 @@ export function ComponentItem({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDrop={handleDrop}
>
<div className={css['ItemContent']}>
<div className={css['Icon']}>

View File

@@ -6,7 +6,7 @@
import React from 'react';
import { TreeNode } from '../types';
import { Sheet, TreeNode } from '../types';
import { ComponentItem } from './ComponentItem';
import { FolderItem } from './FolderItem';
@@ -25,6 +25,8 @@ interface ComponentTreeProps {
onDragStart?: (node: TreeNode, element: HTMLElement) => void;
onDrop?: (node: TreeNode) => void;
canAcceptDrop?: (node: TreeNode) => boolean;
onAddComponent?: (template: TSFixme, parentPath?: string) => void;
onAddFolder?: (parentPath?: string) => void;
// Rename mode props
renamingItem?: TreeNode | null;
renameValue?: string;
@@ -32,6 +34,9 @@ interface ComponentTreeProps {
onRenameConfirm?: () => void;
onRenameCancel?: () => void;
onDoubleClick?: (node: TreeNode) => void;
// Sheet management props
sheets?: Sheet[];
onMoveToSheet?: (componentPath: string, sheet: Sheet) => void;
}
export function ComponentTree({
@@ -49,12 +54,16 @@ export function ComponentTree({
onDragStart,
onDrop,
canAcceptDrop,
onAddComponent,
onAddFolder,
renamingItem,
renameValue,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDoubleClick
onDoubleClick,
sheets,
onMoveToSheet
}: ComponentTreeProps) {
return (
<>
@@ -81,11 +90,18 @@ export function ComponentTree({
onDrop={onDrop}
canAcceptDrop={canAcceptDrop}
onDoubleClick={onDoubleClick}
onAddComponent={onAddComponent}
onAddFolder={onAddFolder}
isRenaming={isRenaming}
renameValue={renameValue}
onRenameChange={onRenameChange}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
sheets={sheets}
onMoveToSheet={onMoveToSheet}
onOpen={onOpen}
onMakeHome={onMakeHome}
onDuplicate={onDuplicate}
>
{expandedFolders.has(node.data.path) && node.data.children.length > 0 && (
<ComponentTree
@@ -103,12 +119,16 @@ export function ComponentTree({
onDragStart={onDragStart}
onDrop={onDrop}
canAcceptDrop={canAcceptDrop}
onAddComponent={onAddComponent}
onAddFolder={onAddFolder}
renamingItem={renamingItem}
renameValue={renameValue}
onRenameChange={onRenameChange}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
onDoubleClick={onDoubleClick}
sheets={sheets}
onMoveToSheet={onMoveToSheet}
/>
)}
</FolderItem>
@@ -127,12 +147,18 @@ export function ComponentTree({
onRename={onRename}
onOpen={onOpen}
onDragStart={onDragStart}
onDrop={onDrop}
canAcceptDrop={canAcceptDrop}
onDoubleClick={onDoubleClick}
onAddComponent={onAddComponent}
onAddFolder={onAddFolder}
isRenaming={isRenaming}
renameValue={renameValue}
onRenameChange={onRenameChange}
onRenameConfirm={onRenameConfirm}
onRenameCancel={onRenameCancel}
sheets={sheets}
onMoveToSheet={onMoveToSheet}
/>
);
}

View File

@@ -1,19 +1,24 @@
/**
* FolderItem
*
* Renders a folder row with expand/collapse caret and nesting.
* Renders a folder row with caret and folder icon.
*/
import classNames from 'classnames';
import React, { useCallback, useRef, useState } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { MenuDialogWidth } from '@noodl-core-ui/components/popups/MenuDialog';
import PopupLayer from '../../../popuplayer';
import { showContextMenuInPopup } from '../../../ShowContextMenuInPopup';
import css from '../ComponentsPanel.module.scss';
import { FolderItemData, TreeNode } from '../types';
import { ComponentTemplates } from '../ComponentTemplates';
import { FolderItemData, Sheet, TreeNode } from '../types';
import { RenameInput } from './RenameInput';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
interface FolderItemProps {
folder: FolderItemData;
level: number;
@@ -28,11 +33,20 @@ interface FolderItemProps {
onDrop?: (node: TreeNode) => void;
canAcceptDrop?: (node: TreeNode) => boolean;
onDoubleClick?: (node: TreeNode) => void;
onAddComponent?: (template: TSFixme, parentPath?: string) => void;
onAddFolder?: (parentPath?: string) => void;
isRenaming?: boolean;
renameValue?: string;
onRenameChange?: (value: string) => void;
onRenameConfirm?: () => void;
onRenameCancel?: () => void;
// Sheet management
sheets?: Sheet[];
onMoveToSheet?: (componentPath: string, sheet: Sheet) => void;
// Component-folder actions (same as ComponentItem)
onOpen?: (node: TreeNode) => void;
onMakeHome?: (node: TreeNode) => void;
onDuplicate?: (node: TreeNode) => void;
}
export function FolderItem({
@@ -49,11 +63,18 @@ export function FolderItem({
onDrop,
canAcceptDrop,
onDoubleClick,
onAddComponent,
onAddFolder,
isRenaming,
renameValue,
onRenameChange,
onRenameConfirm,
onRenameCancel
onRenameCancel,
sheets,
onMoveToSheet,
onOpen,
onMakeHome,
onDuplicate
}: FolderItemProps) {
const indent = level * 12;
const itemRef = useRef<HTMLDivElement>(null);
@@ -83,9 +104,21 @@ export function FolderItem({
[folder, onDragStart]
);
const handleMouseUp = useCallback(() => {
dragStartPos.current = null;
}, []);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
dragStartPos.current = null;
// If this folder is a valid drop target, execute the drop
if (isDropTarget && onDrop) {
e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback)
const node: TreeNode = { type: 'folder', data: folder };
onDrop(node);
setIsDropTarget(false);
// Note: dragCompleted() is called by handleDropOn - don't call it here
}
},
[isDropTarget, folder, onDrop]
);
// Drop handlers
const handleMouseEnter = useCallback(() => {
@@ -118,29 +151,135 @@ export function FolderItem({
e.preventDefault();
e.stopPropagation();
// Clear drag state to prevent phantom drags after menu closes
dragStartPos.current = null;
const node: TreeNode = { type: 'folder', data: folder };
const items = [
{
label: 'Rename',
onClick: () => onRename?.(node)
},
{ type: 'divider' as const },
{
label: 'Delete',
onClick: () => onDelete?.(node)
// Parent path for new items in this folder
const parentPath = folder.path === '/' ? '/' : folder.path + '/';
const items: TSFixme[] = [];
// Add "Create" menu items if handlers are provided
if (onAddComponent && onAddFolder) {
// Get templates for browser runtime (default)
const templates = ComponentTemplates.instance.getTemplates({
forRuntimeType: 'browser'
});
// Add template creation items
templates.forEach((template) => {
items.push({
icon: template.icon,
label: `Create ${template.label}`,
onClick: () => onAddComponent(template, parentPath)
});
});
// Add folder creation
items.push('divider');
items.push({
icon: IconName.FolderClosed,
label: 'Create Folder',
onClick: () => onAddFolder(parentPath)
});
items.push('divider');
}
// For component-folders, add component-specific actions (Open, Make Home, Duplicate)
if (folder.isComponentFolder && folder.component) {
items.push({
label: 'Open',
onClick: () => onOpen?.(node)
});
items.push('divider');
// Only show "Make Home" for pages or visual components (not logic/cloud functions)
if (folder.isPage || folder.isVisual) {
items.push({
label: 'Make Home',
disabled: folder.isRoot,
onClick: () => onMakeHome?.(node)
});
items.push('divider');
}
];
}
const menu = new PopupLayer.PopupMenu({ items });
// Add rename (available for all folders)
items.push({
label: 'Rename',
onClick: () => onRename?.(node)
});
PopupLayer.instance.showPopup({
content: menu,
attachTo: e.currentTarget as HTMLElement,
position: { x: e.clientX, y: e.clientY }
// Add duplicate for component-folders
if (folder.isComponentFolder && folder.component) {
items.push({
label: 'Duplicate',
onClick: () => onDuplicate?.(node)
});
}
// Add "Move to" option for any folder that has a path and sheets are available
// Works for both component-folders and regular folders
if (folder.path && sheets && sheets.length > 0 && onMoveToSheet) {
items.push('divider');
// Use component.name for component-folders, folder.path for regular folders
const folderPath = folder.isComponentFolder && folder.component ? folder.component.name : folder.path;
// "Move to" opens a separate popup with sheet options
items.push({
label: 'Move to...',
icon: IconName.FolderClosed,
onClick: () => {
// Determine which sheet this folder is currently in
const currentSheetFolder = sheets.find(
(s) => !s.isDefault && folderPath.startsWith('/' + s.folderName + '/')
);
const isInDefaultSheet = !currentSheetFolder;
// Create sheet selection menu items
const sheetItems: TSFixme[] = sheets.map((sheet) => {
const isCurrentSheet = sheet.isDefault
? isInDefaultSheet
: sheet.folderName === currentSheetFolder?.folderName;
return {
label: sheet.name + (isCurrentSheet ? ' (current)' : ''),
icon: sheet.isDefault ? IconName.Component : IconName.FolderClosed,
isDisabled: isCurrentSheet,
isHighlighted: isCurrentSheet,
onClick: () => {
if (!isCurrentSheet) {
onMoveToSheet(folderPath, sheet);
}
}
};
});
// Show the sheet selection popup
showContextMenuInPopup({
items: sheetItems,
width: MenuDialogWidth.Default
});
}
});
}
items.push('divider');
items.push({
label: 'Delete',
onClick: () => onDelete?.(node)
});
showContextMenuInPopup({
items,
width: MenuDialogWidth.Default
});
},
[folder, onRename, onDelete]
[folder, onRename, onDelete, onAddComponent, onAddFolder, sheets, onMoveToSheet, onOpen, onMakeHome, onDuplicate]
);
const handleDoubleClick = useCallback(() => {

View File

@@ -0,0 +1,235 @@
/**
* SheetSelector Styles
*
* Dropdown selector for switching between component sheets
* Using design tokens for proper theming support
*/
.SheetSelector {
position: relative;
display: flex;
align-items: center;
}
.TriggerButton {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--theme-color-fg-default);
font: 11px var(--font-family-regular);
cursor: pointer;
transition: all var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-2);
border-color: var(--theme-color-border-default);
}
&.Open {
background-color: var(--theme-color-bg-2);
border-color: var(--theme-color-primary);
}
}
.SheetName {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ChevronIcon {
width: 10px;
height: 10px;
transition: transform var(--speed-turbo);
&.Open {
transform: rotate(180deg);
}
}
.Dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 160px;
max-width: 200px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.SheetList {
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
.SheetItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font: 11px var(--font-family-regular);
color: var(--theme-color-fg-default);
transition: background-color var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-3);
}
&.Selected {
background-color: var(--theme-color-primary-transparent);
color: var(--theme-color-primary);
}
&.AllSheets {
color: var(--theme-color-fg-default-shy);
font-style: italic;
}
}
.RadioIndicator {
width: 12px;
height: 12px;
border: 1px solid var(--theme-color-border-default);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.Selected {
border-color: var(--theme-color-primary);
&::after {
content: '';
width: 6px;
height: 6px;
background-color: var(--theme-color-primary);
border-radius: 50%;
}
}
}
.SheetLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.SheetCount {
color: var(--theme-color-fg-muted);
font-size: 10px;
}
.Divider {
height: 1px;
background-color: var(--theme-color-border-default);
margin: 4px 0;
}
.AddSheetButton {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background-color: transparent;
border: none;
cursor: pointer;
font: 11px var(--font-family-regular);
color: var(--theme-color-fg-default);
transition: background-color var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-3);
}
}
.AddIcon {
width: 12px;
height: 12px;
color: var(--theme-color-fg-default);
}
/* Sheet Actions (three-dot menu) */
.SheetActions {
position: relative;
margin-left: auto;
opacity: 0;
transition: opacity var(--speed-turbo);
&.Visible {
opacity: 1;
}
}
.SheetItem.HasActions:hover .SheetActions {
opacity: 1;
}
.ActionButton {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background-color: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--theme-color-fg-default);
transition: background-color var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-3);
}
}
.ActionMenu {
position: absolute;
bottom: 100%;
right: 0;
min-width: 100px;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
z-index: 1001;
overflow: hidden;
}
.ActionMenuItem {
display: block;
width: 100%;
padding: 6px 12px;
background-color: transparent;
border: none;
text-align: left;
cursor: pointer;
font: 11px var(--font-family-regular);
color: var(--theme-color-fg-default);
transition: background-color var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-3);
}
&.Danger {
color: var(--theme-color-danger);
&:hover {
background-color: var(--theme-color-danger-transparent);
}
}
}

View File

@@ -0,0 +1,251 @@
/**
* SheetSelector
*
* Dropdown component for selecting and managing component sheets.
* Sheets are top-level organizational folders starting with #.
*/
import classNames from 'classnames';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Sheet } from '../types';
import css from './SheetSelector.module.scss';
interface SheetSelectorProps {
/** All available sheets */
sheets: Sheet[];
/** Currently selected sheet (null = show all) */
currentSheet: Sheet | null;
/** Callback when sheet is selected */
onSelectSheet: (sheet: Sheet | null) => void;
/** Callback to create a new sheet */
onCreateSheet?: () => void;
/** Callback to rename a sheet */
onRenameSheet?: (sheet: Sheet) => void;
/** Callback to delete a sheet */
onDeleteSheet?: (sheet: Sheet) => void;
/** Whether the selector is disabled (e.g., locked to a sheet) */
disabled?: boolean;
}
/**
* SheetSelector displays a dropdown to switch between component sheets.
* When no sheet is selected, all components are shown.
*/
export function SheetSelector({
sheets,
currentSheet,
onSelectSheet,
onCreateSheet,
onRenameSheet,
onDeleteSheet,
disabled = false
}: SheetSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeSheetMenu, setActiveSheetMenu] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown and action menu when clicking outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
setActiveSheetMenu(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Close action menu when clicking elsewhere in the dropdown (but not outside)
useEffect(() => {
if (!activeSheetMenu) return;
const handleClickInDropdown = (e: MouseEvent) => {
// Check if click is inside the dropdown but outside the action menu area
const target = e.target as HTMLElement;
const isInsideActionMenu = target.closest(`.${css['ActionMenu']}`) || target.closest(`.${css['ActionButton']}`);
if (!isInsideActionMenu) {
setActiveSheetMenu(null);
}
};
document.addEventListener('mousedown', handleClickInDropdown);
return () => document.removeEventListener('mousedown', handleClickInDropdown);
}, [activeSheetMenu]);
// Close dropdown on escape
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
const handleToggle = useCallback(() => {
if (!disabled) {
setIsOpen((prev) => !prev);
}
}, [disabled]);
const handleSelectSheet = useCallback(
(sheet: Sheet | null) => {
onSelectSheet(sheet);
setIsOpen(false);
},
[onSelectSheet]
);
const handleCreateSheet = useCallback(() => {
// Close dropdown first
setIsOpen(false);
// Delay popup to allow dropdown to fully close and React to complete its render cycle
// This prevents timing conflicts between dropdown close and popup open
setTimeout(() => {
onCreateSheet?.();
}, 50);
}, [onCreateSheet]);
// Don't render if only default sheet exists and no create option
if (sheets.length <= 1 && !onCreateSheet) {
return null;
}
const displayName = currentSheet ? currentSheet.name : 'All';
return (
<div className={css['SheetSelector']} ref={dropdownRef}>
{/* Trigger Button */}
<button
className={classNames(css['TriggerButton'], { [css['Open']]: isOpen })}
onClick={handleToggle}
disabled={disabled}
title={disabled ? 'Sheet selection is locked' : 'Select sheet'}
>
<span className={css['SheetName']}>{displayName}</span>
<Icon
icon={IconName.CaretDown}
size={IconSize.Tiny}
UNSAFE_className={classNames(css['ChevronIcon'], { [css['Open']]: isOpen })}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className={css['Dropdown']}>
<div className={css['SheetList']}>
{/* "All" option - no sheet filter */}
<div
className={classNames(css['SheetItem'], css['AllSheets'], {
[css['Selected']]: currentSheet === null
})}
onClick={() => handleSelectSheet(null)}
>
<div
className={classNames(css['RadioIndicator'], {
[css['Selected']]: currentSheet === null
})}
/>
<span className={css['SheetLabel']}>All</span>
</div>
{/* Sheet items */}
{sheets.map((sheet) => (
<div
key={sheet.folderName || 'default'}
className={classNames(css['SheetItem'], {
[css['Selected']]: currentSheet?.folderName === sheet.folderName,
[css['HasActions']]: !sheet.isDefault && (onRenameSheet || onDeleteSheet)
})}
onClick={() => handleSelectSheet(sheet)}
>
<div
className={classNames(css['RadioIndicator'], {
[css['Selected']]: currentSheet?.folderName === sheet.folderName
})}
/>
<span className={css['SheetLabel']}>{sheet.name}</span>
<span className={css['SheetCount']}>({sheet.componentCount})</span>
{/* Action buttons for non-default sheets */}
{!sheet.isDefault && (onRenameSheet || onDeleteSheet) && (
<div
className={classNames(css['SheetActions'], {
[css['Visible']]: activeSheetMenu === sheet.folderName
})}
onClick={(e) => e.stopPropagation()}
>
<button
className={css['ActionButton']}
onClick={(e) => {
e.stopPropagation();
setActiveSheetMenu(activeSheetMenu === sheet.folderName ? null : sheet.folderName);
}}
title="Sheet actions"
>
<Icon icon={IconName.DotsThree} size={IconSize.Tiny} />
</button>
{activeSheetMenu === sheet.folderName && (
<div className={css['ActionMenu']}>
{onRenameSheet && (
<button
className={css['ActionMenuItem']}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
setActiveSheetMenu(null);
setTimeout(() => onRenameSheet(sheet), 50);
}}
>
Rename
</button>
)}
{onDeleteSheet && (
<button
className={classNames(css['ActionMenuItem'], css['Danger'])}
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
setActiveSheetMenu(null);
setTimeout(() => onDeleteSheet(sheet), 50);
}}
>
Delete
</button>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
{/* Add Sheet option */}
{onCreateSheet && (
<>
<div className={css['Divider']} />
<button className={css['AddSheetButton']} onClick={handleCreateSheet}>
<Icon icon={IconName.Plus} size={IconSize.Tiny} UNSAFE_className={css['AddIcon']} />
<span>Add Sheet</span>
</button>
</>
)}
</div>
)}
</div>
);
}

View File

@@ -15,18 +15,24 @@ import { guid } from '@noodl-utils/utils';
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
import { ComponentModel } from '../../../../models/componentmodel';
import { ToastLayer } from '../../../ToastLayer/ToastLayer';
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;
// Support both component nodes and folder nodes (for component-folders)
let component;
if (node.type === 'component') {
component = node.data.component;
} else if (node.type === 'folder' && node.data.isComponentFolder && node.data.component) {
component = node.data.component;
} else {
return;
}
const component = node.data.component;
if (!component) return;
const canDelete = ProjectModel.instance?.deleteComponentAllowed(component);
@@ -77,36 +83,34 @@ export function useComponentActions() {
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
// Use pushAndDo pattern - removeComponent handles its own undo internally
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Delete ${component.name}`,
do: () => {
const undoGroup = new UndoActionGroup({ label: `Delete ${component.name}` });
UndoQueue.instance.push(undoGroup);
ProjectModel.instance?.removeComponent(component, { undo: undoGroup });
},
undo: () => {
// Undo is handled internally by removeComponent
}
}
});
undoGroup.do();
})
);
}, []);
const handleDuplicate = useCallback((node: TreeNode) => {
if (node.type !== 'component') {
// TODO: Implement folder duplication
// Support both component nodes and folder nodes (for component-folders)
let component;
if (node.type === 'component') {
component = node.data.component;
} else if (node.type === 'folder' && node.data.isComponentFolder && node.data.component) {
component = node.data.component;
} else {
// TODO: Implement pure folder duplication
console.log('Folder duplication not yet implemented');
return;
}
const component = node.data.component;
let newName = component.name + ' Copy';
// Find unique name
@@ -116,31 +120,30 @@ export function useComponentActions() {
counter++;
}
// Create undo group - duplicateComponent handles its own undo registration
const undoGroup = new UndoActionGroup({
label: `Duplicate ${component.name}`
label: `Duplicate ${component.localName}`
});
// Call duplicateComponent which internally registers undo actions
ProjectModel.instance?.duplicateComponent(component, newName, {
undo: undoGroup,
rerouteComponentRefs: null
});
// Push the undo group after duplicate is done
UndoQueue.instance.push(undoGroup);
let duplicatedComponent = null;
// Switch to the new component
const duplicatedComponent = ProjectModel.instance?.getComponentWithName(newName);
if (duplicatedComponent) {
EventDispatcher.instance.notifyListeners('ComponentPanel.SwitchToComponent', {
component: duplicatedComponent,
pushHistory: true
});
}
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();
tracker.track('Component Duplicated');
}, []);
const handleRename = useCallback((node: TreeNode) => {
@@ -165,22 +168,17 @@ export function useComponentActions() {
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();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Rename ${component.localName} to ${newName}`,
do: () => {
ProjectModel.instance?.renameComponent(component, fullNewName);
},
undo: () => {
ProjectModel.instance?.renameComponent(component, oldName);
}
})
);
return true;
} else if (node.type === 'folder') {
@@ -250,11 +248,23 @@ export function useComponentActions() {
}, []);
const handleOpen = useCallback((node: TreeNode) => {
if (node.type !== 'component') return;
// Support both component nodes and folder nodes (for component-folders)
let component;
if (node.type === 'component') {
component = node.data.component;
} else if (node.type === 'folder' && node.data.isComponentFolder && node.data.component) {
component = node.data.component;
} else {
return;
}
// TODO: Open component in NodeGraphEditor
// This requires integration with the editor's tab system
console.log('Open component:', node.data.component.name);
// Open component in NodeGraphEditor by dispatching event
if (component) {
EventDispatcher.instance.notifyListeners('ComponentPanel.SwitchToComponent', {
component,
pushHistory: true
});
}
}, []);
/**
@@ -264,7 +274,8 @@ export function useComponentActions() {
* Handle adding a new component using a template
*/
const handleAddComponent = useCallback((template: TSFixme, parentPath?: string) => {
const finalParentPath = parentPath || '';
// Normalize parent path: '/' means root (empty string), otherwise use as-is
const finalParentPath = !parentPath || parentPath === '/' ? '' : parentPath;
const popup = template.createPopup({
onCreate: (localName: string, options?: TSFixme) => {
@@ -317,7 +328,8 @@ export function useComponentActions() {
PopupLayer.instance.showPopup({
content: popup,
position: 'bottom'
position: 'screen-center',
isBackgroundDimmed: true
});
}, []);
@@ -336,12 +348,56 @@ export function useComponentActions() {
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');
// Normalize parent path: ensure it starts with /
// If parentPath is undefined, empty, or '/', treat as root '/'
let normalizedPath = parentPath || '/';
if (normalizedPath === '/') {
normalizedPath = '/';
} else if (!normalizedPath.startsWith('/')) {
normalizedPath = '/' + normalizedPath;
}
// Ensure it ends with / for concatenation (unless it's just '/')
if (normalizedPath !== '/' && !normalizedPath.endsWith('/')) {
normalizedPath = normalizedPath + '/';
}
// Create folder path - component names MUST start with /
const folderPath = normalizedPath === '/' ? `/${folderName}` : `${normalizedPath}${folderName}`;
// Check if folder already exists (any component starts with this path)
const folderExists = ProjectModel.instance
?.getComponents()
.some((comp) => comp.name.startsWith(folderPath + '/'));
if (folderExists) {
ToastLayer.showError('A folder with this name already exists');
return;
}
// Create a placeholder component to make the folder visible
// The placeholder will be at {folderPath}/.placeholder
const placeholderName = `${folderPath}/.placeholder`;
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Create folder ${folderName}`,
do: () => {
const placeholder = new ComponentModel({
name: placeholderName,
graph: new NodeGraphModel(),
id: guid()
});
ProjectModel.instance?.addComponent(placeholder);
},
undo: () => {
const placeholder = ProjectModel.instance?.getComponentWithName(placeholderName);
if (placeholder) {
ProjectModel.instance?.removeComponent(placeholder);
}
}
})
);
PopupLayer.instance.hidePopup();
}
@@ -350,10 +406,110 @@ export function useComponentActions() {
PopupLayer.instance.showPopup({
content: popup,
position: 'bottom'
position: 'screen-center',
isBackgroundDimmed: true
});
}, []);
/**
* Handle dropping an item onto the root level (empty space)
*/
const handleDropOnRoot = useCallback((draggedItem: TreeNode) => {
// Component → Root
if (draggedItem.type === 'component') {
const component = draggedItem.data.component;
const newName = component.localName;
// Check if already at root
if (!component.name.includes('/')) {
console.log('Component already at root level');
PopupLayer.instance.dragCompleted();
return;
}
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists at root level`);
PopupLayer.instance.dragCompleted();
return;
}
const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${component.localName} to root`,
do: () => {
ProjectModel.instance?.renameComponent(component, newName);
},
undo: () => {
ProjectModel.instance?.renameComponent(component, oldName);
}
})
);
}
// Folder → Root (including component-folders)
else if (draggedItem.type === 'folder') {
const sourcePath = draggedItem.data.path;
const newPath = draggedItem.data.name;
// Check if already at root
if (!sourcePath.includes('/')) {
console.log('Folder already at root level');
PopupLayer.instance.dragCompleted();
return;
}
// Get all components in source folder (including the folder's component if it exists)
const componentsToMove = ProjectModel.instance
?.getComponents()
.filter((comp) => comp.name === sourcePath || comp.name.startsWith(sourcePath + '/'));
if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
return;
}
const renames: Array<{ component: TSFixme; oldName: string; newName: string }> = [];
componentsToMove.forEach((comp) => {
let newName: string;
if (comp.name === sourcePath) {
// This is the component-folder itself
newName = newPath;
} else {
// This is a nested component
const relativePath = comp.name.substring(sourcePath.length);
newName = newPath + relativePath;
}
renames.push({ component: comp, oldName: comp.name, newName });
});
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${draggedItem.data.name} to root`,
do: () => {
renames.forEach(({ component, newName }) => {
ProjectModel.instance?.renameComponent(component, newName);
});
},
undo: () => {
renames.forEach(({ component, oldName }) => {
ProjectModel.instance?.renameComponent(component, oldName);
});
}
})
);
}
}, []);
/**
* Handle dropping an item onto a target
*/
@@ -367,11 +523,16 @@ export function useComponentActions() {
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists in that folder`);
PopupLayer.instance.dragCompleted();
return;
}
const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render
// This prevents the drag state from persisting across the tree rebuild
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${component.localName} to folder`,
@@ -390,24 +551,42 @@ export function useComponentActions() {
const targetPath = targetItem.data.path === '/' ? '' : targetItem.data.path;
const newPath = targetPath ? `${targetPath}/${draggedItem.data.name}` : draggedItem.data.name;
// Get all components in source folder
// Prevent moving folder into itself
if (targetPath.startsWith(sourcePath + '/') || targetPath === sourcePath) {
alert('Cannot move folder into itself');
PopupLayer.instance.dragCompleted();
return;
}
// Get all components in source folder (including the folder's component if it exists)
const componentsToMove = ProjectModel.instance
?.getComponents()
.filter((comp) => comp.name.startsWith(sourcePath + '/'));
.filter((comp) => comp.name === sourcePath || comp.name.startsWith(sourcePath + '/'));
if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
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}`;
let newName: string;
if (comp.name === sourcePath) {
// This is the component-folder itself
newName = newPath;
} else {
// This is a nested component
const relativePath = comp.name.substring(sourcePath.length);
newName = newPath + relativePath;
}
renames.push({ component: comp, oldName: comp.name, newName });
});
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${draggedItem.data.name} folder`,
@@ -433,11 +612,15 @@ export function useComponentActions() {
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists`);
PopupLayer.instance.dragCompleted();
return;
}
const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${component.localName} into ${targetComponent.localName}`,
@@ -450,6 +633,66 @@ export function useComponentActions() {
})
);
}
// Folder → Component (treat component-folder AS a component, nest inside target)
else if (draggedItem.type === 'folder' && targetItem.type === 'component') {
const sourcePath = draggedItem.data.path;
const targetComponent = targetItem.data.component;
const newPath = `${targetComponent.name}/${draggedItem.data.name}`;
// Get all components in source folder (including the folder's component if it exists)
const componentsToMove = ProjectModel.instance
?.getComponents()
.filter((comp) => comp.name === sourcePath || comp.name.startsWith(sourcePath + '/'));
if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
return;
}
const renames: Array<{ component: TSFixme; oldName: string; newName: string }> = [];
componentsToMove.forEach((comp) => {
let newName: string;
if (comp.name === sourcePath) {
// This is the component-folder itself
newName = newPath;
} else {
// This is a nested component
const relativePath = comp.name.substring(sourcePath.length);
newName = newPath + relativePath;
}
renames.push({ component: comp, oldName: comp.name, newName });
});
// Check for conflicts
const hasConflict = renames.some(({ newName }) => ProjectModel.instance?.getComponentWithName(newName));
if (hasConflict) {
alert(`Some components would conflict with existing names`);
PopupLayer.instance.dragCompleted();
return;
}
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move ${draggedItem.data.name} into ${targetComponent.localName}`,
do: () => {
renames.forEach(({ component, newName }) => {
ProjectModel.instance?.renameComponent(component, newName);
});
},
undo: () => {
renames.forEach(({ component, oldName }) => {
ProjectModel.instance?.renameComponent(component, oldName);
});
}
})
);
}
}, []);
return {
@@ -460,6 +703,7 @@ export function useComponentActions() {
performRename,
handleOpen,
handleDropOn,
handleDropOnRoot,
handleAddComponent,
handleAddFolder
};

View File

@@ -1,27 +1,28 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, 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';
import { Sheet, TreeNode } from '../types';
/**
* useComponentsPanel
*
* Main state management hook for ComponentsPanel.
* Subscribes to ProjectModel and builds tree structure.
*
* Uses the PROVEN direct subscription pattern from UseRoutes.ts
* instead of the abstracted useEventListener hook.
*/
// 🔥 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
// Events to subscribe to on ProjectModel.instance
const PROJECT_EVENTS = ['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'];
interface UseComponentsPanelOptions {
hideSheets?: string[];
/** Lock to a specific sheet - cannot switch (e.g., for Cloud Functions panel) */
lockToSheet?: string;
}
interface FolderStructure {
@@ -32,28 +33,141 @@ interface FolderStructure {
}
export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
const { hideSheets = [] } = options;
const { hideSheets = [], lockToSheet } = options;
// Local state
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
const [selectedId, setSelectedId] = useState<string | undefined>();
const [updateCounter, setUpdateCounter] = useState(0);
const [currentSheetName, setCurrentSheetName] = useState<string | null>(lockToSheet || null);
// Subscribe to ProjectModel events using the new useEventListener hook
console.log(
'🔍 useComponentsPanel: About to call useEventListener with ProjectModel.instance:',
ProjectModel.instance
// Subscribe to ProjectModel events using DIRECT pattern (proven in UseRoutes.ts)
// This bypasses the problematic useEventListener abstraction
useEffect(() => {
if (!ProjectModel.instance) {
return;
}
// Create a group object for cleanup (same pattern as UseRoutes.ts)
const group = { id: 'useComponentsPanel' };
// Handler that triggers re-render
const handleUpdate = () => {
setUpdateCounter((c) => c + 1);
};
// Subscribe to all events (Model.on supports arrays)
ProjectModel.instance.on(PROJECT_EVENTS, handleUpdate, group);
// Cleanup: unsubscribe when unmounting
return () => {
if (ProjectModel.instance) {
ProjectModel.instance.off(group);
}
};
}, [ProjectModel.instance]); // Re-run when ProjectModel.instance changes from null to real instance
// Get all components (including placeholders) for sheet detection
// IMPORTANT: Spread to create new array reference - getComponents() may return
// the same mutated array, which would cause useMemo to skip recalculation
const rawComponents = useMemo(() => {
if (!ProjectModel.instance) return [];
return [...ProjectModel.instance.getComponents()];
}, [updateCounter]);
// Get non-placeholder components for counting and tree display
const allComponents = useMemo(() => {
return rawComponents.filter((comp) => !comp.name.endsWith('/.placeholder'));
}, [rawComponents]);
// Detect all sheets from component paths (including placeholders for empty sheet detection)
// Sheets are top-level folders starting with # (e.g., #Pages, #Components)
// Note: Component names start with leading "/" (e.g., "/#Pages/Home")
const sheets = useMemo((): Sheet[] => {
const sheetSet = new Set<string>(); // All detected sheet folder names
const sheetCounts = new Map<string, number>(); // folderName -> non-placeholder component count
// First pass: detect all sheets (including from placeholders)
rawComponents.forEach((comp) => {
const parts = comp.name.split('/').filter((p) => p !== ''); // Remove empty strings from leading /
if (parts.length > 0 && parts[0].startsWith('#')) {
const sheetFolder = parts[0];
sheetSet.add(sheetFolder);
}
});
// Second pass: count non-placeholder components per sheet
allComponents.forEach((comp) => {
const parts = comp.name.split('/').filter((p) => p !== '');
if (parts.length > 0 && parts[0].startsWith('#')) {
const sheetFolder = parts[0];
sheetCounts.set(sheetFolder, (sheetCounts.get(sheetFolder) || 0) + 1);
}
});
// Count default sheet components (not in any # folder)
const defaultCount = allComponents.filter((comp) => {
const parts = comp.name.split('/').filter((p) => p !== '');
return parts.length === 0 || !parts[0].startsWith('#');
}).length;
// Build sheet list with Default first
const result: Sheet[] = [
{
name: 'Default',
folderName: '',
isDefault: true,
componentCount: defaultCount
}
];
// Add detected sheets, filtering out hidden ones
sheetSet.forEach((folderName) => {
const displayName = folderName.substring(1); // Remove # prefix
if (!hideSheets.includes(displayName) && !hideSheets.includes(folderName)) {
result.push({
name: displayName,
folderName,
isDefault: false,
componentCount: sheetCounts.get(folderName) || 0
});
}
});
// Sort non-default sheets alphabetically
result.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return a.name.localeCompare(b.name);
});
return result;
}, [rawComponents, allComponents, hideSheets]);
// Get current sheet object
const currentSheet = useMemo((): Sheet | null => {
if (currentSheetName === null) {
// No sheet selected = show all (no filtering)
return null;
}
return sheets.find((s) => s.folderName === currentSheetName || (s.isDefault && currentSheetName === '')) || null;
}, [currentSheetName, sheets]);
// Select a sheet
const selectSheet = useCallback(
(sheet: Sheet | null) => {
// Don't allow switching if locked
if (lockToSheet !== undefined) return;
setCurrentSheetName(sheet ? sheet.folderName : null);
},
[lockToSheet]
);
useEventListener(ProjectModel.instance, PROJECT_EVENTS, () => {
console.log('🎉 Event received! Updating counter...');
setUpdateCounter((c) => c + 1);
});
// Build tree structure
// Build tree structure with optional sheet filtering
const treeData = useMemo(() => {
if (!ProjectModel.instance) return [];
return buildTreeFromProject(ProjectModel.instance, hideSheets);
}, [updateCounter, hideSheets]);
return buildTreeFromProject(ProjectModel.instance, hideSheets, currentSheet);
}, [updateCounter, hideSheets, currentSheet]);
// Toggle folder expand/collapse
const toggleFolder = useCallback((folderId: string) => {
@@ -95,14 +209,22 @@ export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
expandedFolders,
selectedId,
toggleFolder,
handleItemClick
handleItemClick,
// Sheet system
sheets,
currentSheet,
selectSheet
};
}
/**
* Build tree structure from ProjectModel
*
* @param project - The project model
* @param hideSheets - Sheet names to hide (filter out)
* @param currentSheet - If provided, filter to only show components in this sheet
*/
function buildTreeFromProject(project: ProjectModel, hideSheets: string[]): TreeNode[] {
function buildTreeFromProject(project: ProjectModel, hideSheets: string[], currentSheet: Sheet | null): TreeNode[] {
const rootFolder: FolderStructure = {
name: '',
path: '/',
@@ -113,15 +235,47 @@ function buildTreeFromProject(project: ProjectModel, hideSheets: string[]): Tree
// Get all components
const components = project.getComponents();
// Filter by sheet if specified
const filteredComponents = components.filter((comp) => {
// First pass: Build folder structure from ALL components (including placeholders)
// This ensures empty folders created via placeholders are visible
components.forEach((comp) => {
// Filter by hideSheets
const sheet = getSheetForComponent(comp.name);
return !hideSheets.includes(sheet);
});
if (hideSheets.includes(sheet)) {
return;
}
// Add each component to folder structure
filteredComponents.forEach((comp) => {
addComponentToFolderStructure(rootFolder, comp);
// Apply current sheet filtering
if (currentSheet !== null) {
const parts = comp.name.split('/').filter((p) => p !== '');
const firstPart = parts.length > 0 ? parts[0] : '';
const isInSheetFolder = firstPart.startsWith('#');
if (currentSheet.isDefault) {
if (isInSheetFolder) return;
} else {
if (firstPart !== currentSheet.folderName) return;
}
}
// Determine display path (strip sheet prefix if needed)
let displayPath = comp.name;
if (currentSheet === null) {
const parts = comp.name.split('/').filter((p) => p !== '');
if (parts.length > 0 && parts[0].startsWith('#')) {
displayPath = '/' + parts.slice(1).join('/');
}
} else if (!currentSheet.isDefault) {
const sheetPrefix = '/' + currentSheet.folderName + '/';
if (comp.name.startsWith(sheetPrefix)) {
displayPath = '/' + comp.name.substring(sheetPrefix.length);
}
}
if (displayPath && displayPath !== '/') {
// For placeholders: create folder structure but don't add to components array
const isPlaceholder = comp.name.endsWith('/.placeholder');
addComponentToFolderStructure(rootFolder, comp, displayPath, isPlaceholder);
}
});
// Convert folder structure to tree nodes
@@ -130,9 +284,18 @@ function buildTreeFromProject(project: ProjectModel, hideSheets: string[]): Tree
/**
* Add a component to the folder structure
* @param displayPath - Optional override path for tree building (used when stripping sheet prefix)
* @param skipAddComponent - If true, only create folder structure but don't add component (for placeholders)
*/
function addComponentToFolderStructure(rootFolder: FolderStructure, component: ComponentModel) {
const parts = component.name.split('/');
function addComponentToFolderStructure(
rootFolder: FolderStructure,
component: ComponentModel,
displayPath?: string,
skipAddComponent?: boolean
) {
// Use displayPath for tree structure, but keep original component reference
const pathForTree = displayPath || component.name;
const parts = pathForTree.split('/');
let currentFolder = rootFolder;
// Navigate/create folder structure (all parts except the last one)
@@ -153,8 +316,10 @@ function addComponentToFolderStructure(rootFolder: FolderStructure, component: C
currentFolder = folder;
}
// Add component to final folder
currentFolder.components.push(component);
// Add component to final folder (unless it's a placeholder - we only want the folder structure)
if (!skipAddComponent) {
currentFolder.components.push(component);
}
}
/**
@@ -163,20 +328,47 @@ function addComponentToFolderStructure(rootFolder: FolderStructure, component: C
function convertFolderToTreeNodes(folder: FolderStructure): TreeNode[] {
const nodes: TreeNode[] = [];
// Build a set of folder paths for quick lookup
const folderPaths = new Set(folder.children.map((child) => child.path));
// Sort folder children alphabetically
const sortedChildren = [...folder.children].sort((a, b) => a.name.localeCompare(b.name));
// Add folder children first
sortedChildren.forEach((childFolder) => {
// Skip root folder (empty name) from rendering as a folder item
// The root should be transparent - just show its contents directly
if (childFolder.name === '') {
nodes.push(...convertFolderToTreeNodes(childFolder));
return;
}
// Check if there's a component with the same path as this folder
// This happens when a component has nested children (e.g., /test1 with /test1/child)
const matchingComponent = folder.components.find((comp) => comp.name === childFolder.path);
// A folder is only a "component-folder" if there's an actual component with the same path.
// Having children (components inside) does NOT make it a component-folder - that's just a regular folder.
const isComponentFolder = matchingComponent !== undefined;
const isRoot = matchingComponent ? ProjectModel.instance?.getRootComponent() === matchingComponent : false;
const isPage = matchingComponent ? checkIsPage(matchingComponent) : false;
const isCloudFunction = matchingComponent ? checkIsCloudFunction(matchingComponent) : false;
const isVisual = matchingComponent ? checkIsVisual(matchingComponent) : false;
const folderNode: TreeNode = {
type: 'folder',
data: {
name: childFolder.name,
path: childFolder.path,
isOpen: false,
isComponentFolder: childFolder.components.length > 0,
component: undefined,
children: convertFolderToTreeNodes(childFolder)
isComponentFolder,
component: matchingComponent, // Attach the component if it exists
children: convertFolderToTreeNodes(childFolder),
// Component type flags (only meaningful when isComponentFolder && matchingComponent exists)
isRoot,
isPage,
isCloudFunction,
isVisual
}
};
nodes.push(folderNode);
@@ -185,8 +377,13 @@ function convertFolderToTreeNodes(folder: FolderStructure): TreeNode[] {
// Sort components alphabetically
const sortedComponents = [...folder.components].sort((a, b) => a.localName.localeCompare(b.localName));
// Add components
// Add components (but skip any that are also folder paths)
sortedComponents.forEach((comp) => {
// Skip components that match folder paths - they're already rendered as folders
if (folderPaths.has(comp.name)) {
return;
}
const isRoot = ProjectModel.instance?.getRootComponent() === comp;
const isPage = checkIsPage(comp);
const isCloudFunction = checkIsCloudFunction(comp);
@@ -215,11 +412,12 @@ function convertFolderToTreeNodes(folder: FolderStructure): TreeNode[] {
/**
* Extract sheet name from component name
* Note: Component names start with leading "/" (e.g., "/#Pages/Home")
*/
function getSheetForComponent(componentName: string): string {
// Components in sheets have format: SheetName/ComponentName
if (componentName.includes('/')) {
return componentName.split('/')[0];
const parts = componentName.split('/').filter((p) => p !== '');
if (parts.length > 0 && parts[0].startsWith('#')) {
return parts[0].substring(1); // Return sheet name without # prefix
}
return 'default';
}

View File

@@ -7,9 +7,11 @@
import { useCallback, useState } from 'react';
import PopupLayer from '../../../popuplayer';
import { TreeNode } from '../types';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const PopupLayer = require('@noodl-views/popuplayer');
export function useDragDrop() {
const [draggedItem, setDraggedItem] = useState<TreeNode | null>(null);
const [dropTarget, setDropTarget] = useState<TreeNode | null>(null);

View File

@@ -0,0 +1,345 @@
import { useCallback } from 'react';
import { ComponentModel } from '@noodl-models/componentmodel';
import { NodeGraphModel } from '@noodl-models/nodegraphmodel';
import { ProjectModel } from '@noodl-models/projectmodel';
import { UndoActionGroup, UndoQueue } from '@noodl-models/undo-queue-model';
import { guid } from '@noodl-utils/utils';
import { ToastLayer } from '../../../ToastLayer/ToastLayer';
import { Sheet } from '../types';
/**
* useSheetManagement
*
* Hook for managing sheets (top-level organizational folders starting with #).
* Provides CRUD operations with full undo/redo support.
*/
export function useSheetManagement() {
/**
* Create a new sheet with the given name.
* Creates a placeholder component at #SheetName/.placeholder to establish the folder.
*/
const createSheet = useCallback((name: string): boolean => {
if (!ProjectModel.instance) {
ToastLayer.showError('No project open');
return false;
}
// Validate name
const trimmedName = name.trim();
if (!trimmedName) {
ToastLayer.showError('Sheet name cannot be empty');
return false;
}
// Check for invalid characters
if (trimmedName.includes('/') || trimmedName.includes('#')) {
ToastLayer.showError('Sheet name cannot contain / or #');
return false;
}
// Build the folder name (with # prefix)
const folderName = `#${trimmedName}`;
// Check if sheet already exists
const existingComponents = ProjectModel.instance.getComponents();
const sheetExists = existingComponents.some((comp) => comp.name.startsWith(folderName + '/'));
if (sheetExists) {
ToastLayer.showError(`Sheet "${trimmedName}" already exists`);
return false;
}
// Create placeholder to establish the folder
// Component names start with "/" to match project naming convention
const placeholderName = `/${folderName}/.placeholder`;
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Create sheet "${trimmedName}"`,
do: () => {
const placeholder = new ComponentModel({
name: placeholderName,
graph: new NodeGraphModel(),
id: guid()
});
ProjectModel.instance?.addComponent(placeholder);
},
undo: () => {
const placeholder = ProjectModel.instance?.getComponentWithName(placeholderName);
if (placeholder) {
ProjectModel.instance?.removeComponent(placeholder);
}
}
})
);
ToastLayer.showSuccess(`Created sheet "${trimmedName}"`);
return true;
}, []);
/**
* Rename a sheet and update all component paths within it.
*/
const renameSheet = useCallback((sheet: Sheet, newName: string): boolean => {
if (!ProjectModel.instance) {
ToastLayer.showError('No project open');
return false;
}
if (sheet.isDefault) {
ToastLayer.showError('Cannot rename the default sheet');
return false;
}
// Validate new name
const trimmedNewName = newName.trim();
if (!trimmedNewName) {
ToastLayer.showError('Sheet name cannot be empty');
return false;
}
if (trimmedNewName.includes('/') || trimmedNewName.includes('#')) {
ToastLayer.showError('Sheet name cannot contain / or #');
return false;
}
const oldFolderName = sheet.folderName;
const newFolderName = `#${trimmedNewName}`;
// If the name hasn't changed, nothing to do
if (oldFolderName === newFolderName) {
return true;
}
// Check if target name already exists
// Components start with "/" so we need to check for "/#{NewName}/"
const oldPrefix = '/' + oldFolderName + '/';
const newPrefix = '/' + newFolderName + '/';
const existingComponents = ProjectModel.instance.getComponents();
const targetExists = existingComponents.some((comp) => comp.name.startsWith(newPrefix));
if (targetExists) {
ToastLayer.showError(`Sheet "${trimmedNewName}" already exists`);
return false;
}
// Find all components in this sheet (components start with "/")
const componentsInSheet = existingComponents.filter((comp) => comp.name.startsWith(oldPrefix));
if (componentsInSheet.length === 0) {
ToastLayer.showError('Sheet has no components to rename');
return false;
}
// Build the rename map with old/new name STRINGS (not component references for undo)
const renameMap: Array<{ oldName: string; newName: string }> = [];
componentsInSheet.forEach((comp) => {
// Replace the old prefix with new prefix
const newComponentName = comp.name.replace(oldPrefix, newPrefix);
renameMap.push({
oldName: comp.name,
newName: newComponentName
});
});
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Rename sheet "${sheet.name}" to "${trimmedNewName}"`,
do: () => {
// Find and rename each component by its current name
renameMap.forEach(({ oldName, newName }) => {
const comp = ProjectModel.instance?.getComponentWithName(oldName);
if (comp) {
ProjectModel.instance?.renameComponent(comp, newName);
}
});
},
undo: () => {
// Rename in reverse order - find by NEW name and rename back to OLD name
[...renameMap].reverse().forEach(({ oldName, newName }) => {
const comp = ProjectModel.instance?.getComponentWithName(newName);
if (comp) {
ProjectModel.instance?.renameComponent(comp, oldName);
}
});
}
})
);
ToastLayer.showSuccess(`Renamed sheet to "${trimmedNewName}"`);
return true;
}, []);
/**
* Delete a sheet by moving all its components to the default sheet (root level).
* Components are preserved - only the sheet organization is removed.
*/
const deleteSheet = useCallback((sheet: Sheet): boolean => {
if (!ProjectModel.instance) {
ToastLayer.showError('No project open');
return false;
}
if (sheet.isDefault) {
ToastLayer.showError('Cannot delete the default sheet');
return false;
}
// Find all components in this sheet (including placeholders)
const componentsInSheet = ProjectModel.instance
.getComponents()
.filter((comp) => comp.name.startsWith('/' + sheet.folderName + '/'));
if (componentsInSheet.length === 0) {
ToastLayer.showError('Sheet is already empty');
return false;
}
// Build rename map using STRINGS only (not component references for undo)
// e.g., "/#Pages/MyPage" becomes "/MyPage"
const renameMap: Array<{ oldName: string; newName: string }> = [];
const placeholderNames: string[] = [];
componentsInSheet.forEach((comp) => {
if (comp.name.endsWith('/.placeholder')) {
// Mark placeholders for deletion (they're only needed for empty folders)
placeholderNames.push(comp.name);
} else {
// Calculate new name by removing sheet prefix
const sheetPrefix = '/' + sheet.folderName;
const newName = comp.name.replace(sheetPrefix, '');
renameMap.push({
oldName: comp.name,
newName
});
}
});
// Check for naming conflicts
for (const { newName } of renameMap) {
const existing = ProjectModel.instance.getComponentWithName(newName);
if (existing) {
ToastLayer.showError(`Cannot delete sheet: "${newName.split('/').pop()}" already exists at root level`);
return false;
}
}
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Delete sheet "${sheet.name}"`,
do: () => {
// Remove placeholders first (find by name)
placeholderNames.forEach((placeholderName) => {
const placeholder = ProjectModel.instance?.getComponentWithName(placeholderName);
if (placeholder) {
ProjectModel.instance?.removeComponent(placeholder);
}
});
// Rename components to remove sheet prefix (find by OLD name)
renameMap.forEach(({ oldName, newName }) => {
const comp = ProjectModel.instance?.getComponentWithName(oldName);
if (comp) {
ProjectModel.instance?.renameComponent(comp, newName);
}
});
},
undo: () => {
// Rename components back to include sheet prefix (find by NEW name)
[...renameMap].reverse().forEach(({ oldName, newName }) => {
const comp = ProjectModel.instance?.getComponentWithName(newName);
if (comp) {
ProjectModel.instance?.renameComponent(comp, oldName);
}
});
// Restore placeholders
placeholderNames.forEach((placeholderName) => {
const restoredPlaceholder = new ComponentModel({
name: placeholderName,
graph: new NodeGraphModel(),
id: guid()
});
ProjectModel.instance?.addComponent(restoredPlaceholder);
});
}
})
);
const componentCount = renameMap.length;
ToastLayer.showSuccess(
`Deleted sheet "${sheet.name}" - ${componentCount} component${componentCount !== 1 ? 's' : ''} moved to root`
);
return true;
}, []);
/**
* Move a component to a different sheet.
*/
const moveToSheet = useCallback((componentName: string, targetSheet: Sheet): boolean => {
if (!ProjectModel.instance) {
ToastLayer.showError('No project open');
return false;
}
const component = ProjectModel.instance.getComponentWithName(componentName);
if (!component) {
ToastLayer.showError('Component not found');
return false;
}
// Determine the component's local name (without any folder prefix)
const parts = componentName.split('/');
const localName = parts[parts.length - 1];
// Build the new name - component names must start with "/"
let newName: string;
if (targetSheet.isDefault) {
// Moving to default sheet - use "/" + local name
newName = `/${localName}`;
} else {
// Moving to named sheet - "/#SheetName/localName"
newName = `/${targetSheet.folderName}/${localName}`;
}
// Check if name already exists in target
if (ProjectModel.instance.getComponentWithName(newName)) {
ToastLayer.showError(`A component named "${localName}" already exists in ${targetSheet.name}`);
return false;
}
const oldName = componentName;
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
label: `Move "${localName}" to sheet "${targetSheet.name}"`,
do: () => {
// Find component by current name, not stale reference
const comp = ProjectModel.instance?.getComponentWithName(oldName);
if (comp) {
ProjectModel.instance?.renameComponent(comp, newName);
}
},
undo: () => {
// Find component by NEW name to rename back
const comp = ProjectModel.instance?.getComponentWithName(newName);
if (comp) {
ProjectModel.instance?.renameComponent(comp, oldName);
}
}
})
);
ToastLayer.showSuccess(`Moved "${localName}" to ${targetSheet.name}`);
return true;
}, []);
return {
createSheet,
renameSheet,
deleteSheet,
moveToSheet
};
}

View File

@@ -30,6 +30,11 @@ export interface FolderItemData {
isComponentFolder: boolean;
component?: ComponentModel;
children: TreeNode[];
// Component type flags (only set when isComponentFolder is true)
isRoot?: boolean;
isPage?: boolean;
isCloudFunction?: boolean;
isVisual?: boolean;
}
/**
@@ -50,4 +55,21 @@ export interface ComponentsPanelProps {
export interface ComponentsPanelOptions {
showSheetList?: boolean;
hideSheets?: string[];
/** Lock to a specific sheet (e.g., for Cloud Functions panel) */
lockToSheet?: string;
}
/**
* Represents a sheet (top-level organizational folder)
* Sheets are folders with names starting with # (e.g., #Pages, #Components)
*/
export interface Sheet {
/** Display name (without # prefix) */
name: string;
/** Original folder name with # prefix, empty string for default sheet */
folderName: string;
/** Whether this is the default sheet (components not in any # folder) */
isDefault: boolean;
/** Number of components in this sheet */
componentCount: number;
}

View File

@@ -11,7 +11,8 @@ module.exports = {
loader: 'babel-loader',
options: {
babelrc: false,
cacheDirectory: true,
// Disable cache in development to ensure fresh code loads
cacheDirectory: false,
presets: ['@babel/preset-react']
}
}