mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Finished component sidebar updates, with one small bug remaining and documented
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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't automatically migrate <strong>{request.componentName}</strong> after {request.attempts}{' '}
|
||||
attempts. Here'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) => 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible; /* Allow popups to extend outside panel */
|
||||
}
|
||||
|
||||
.Header {
|
||||
|
||||
@@ -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']}>
|
||||
|
||||
@@ -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']}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user