mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
Working on the editor component tree
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
# Phase 1: Foundation
|
||||
|
||||
**Estimated Time:** 1-2 hours
|
||||
**Complexity:** Low
|
||||
**Prerequisites:** None
|
||||
|
||||
## Overview
|
||||
|
||||
Set up the basic directory structure, TypeScript interfaces, and a minimal React component that can be registered with SidebarModel. By the end of this phase, the panel should mount in the sidebar showing placeholder content.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Create directory structure for new React component
|
||||
- ✅ Define TypeScript interfaces for component data
|
||||
- ✅ Create minimal ComponentsPanel React component
|
||||
- ✅ Register component with SidebarModel
|
||||
- ✅ Port base CSS styles to SCSS module
|
||||
- ✅ Verify panel mounts without errors
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Directory Structure
|
||||
|
||||
### 1.1 Create Main Directory
|
||||
|
||||
```bash
|
||||
mkdir -p packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel
|
||||
cd packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel
|
||||
```
|
||||
|
||||
### 1.2 Create Subdirectories
|
||||
|
||||
```bash
|
||||
mkdir components
|
||||
mkdir hooks
|
||||
```
|
||||
|
||||
### Final Structure
|
||||
|
||||
```
|
||||
ComponentsPanel/
|
||||
├── components/ # UI components
|
||||
├── hooks/ # React hooks
|
||||
├── ComponentsPanel.tsx
|
||||
├── ComponentsPanel.module.scss
|
||||
├── types.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Define TypeScript Interfaces
|
||||
|
||||
### 2.1 Create `types.ts`
|
||||
|
||||
Create comprehensive type definitions:
|
||||
|
||||
```typescript
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { ComponentsPanelFolder } from '../componentspanel/ComponentsPanelFolder';
|
||||
|
||||
/**
|
||||
* Props accepted by ComponentsPanel component
|
||||
*/
|
||||
export interface ComponentsPanelProps {
|
||||
/** Current node graph editor instance */
|
||||
nodeGraphEditor?: TSFixme;
|
||||
|
||||
/** Lock to a specific sheet */
|
||||
lockCurrentSheetName?: string;
|
||||
|
||||
/** Show the sheet section */
|
||||
showSheetList: boolean;
|
||||
|
||||
/** List of sheets we want to hide */
|
||||
hideSheets?: string[];
|
||||
|
||||
/** Change the title of the component header */
|
||||
componentTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for rendering a component item
|
||||
*/
|
||||
export interface ComponentItemData {
|
||||
type: 'component';
|
||||
component: ComponentModel;
|
||||
folder: ComponentsPanelFolder;
|
||||
name: string;
|
||||
fullName: string;
|
||||
isSelected: boolean;
|
||||
isRoot: boolean;
|
||||
isPage: boolean;
|
||||
isCloudFunction: boolean;
|
||||
isVisual: boolean;
|
||||
canBecomeRoot: boolean;
|
||||
hasWarnings: boolean;
|
||||
// Future: migration status for TASK-004
|
||||
// migrationStatus?: 'needs-review' | 'ai-migrated' | 'auto' | 'manually-fixed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for rendering a folder item
|
||||
*/
|
||||
export interface FolderItemData {
|
||||
type: 'folder';
|
||||
folder: ComponentsPanelFolder;
|
||||
name: string;
|
||||
path: string;
|
||||
isOpen: boolean;
|
||||
isSelected: boolean;
|
||||
isRoot: boolean;
|
||||
isPage: boolean;
|
||||
isCloudFunction: boolean;
|
||||
isVisual: boolean;
|
||||
isComponentFolder: boolean; // Folder that also has a component
|
||||
canBecomeRoot: boolean;
|
||||
hasWarnings: boolean;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree node can be either component or folder
|
||||
*/
|
||||
export type TreeNode = ComponentItemData | FolderItemData;
|
||||
|
||||
/**
|
||||
* Sheet/tab information
|
||||
*/
|
||||
export interface SheetData {
|
||||
name: string;
|
||||
displayName: string;
|
||||
folder: ComponentsPanelFolder;
|
||||
isDefault: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu item configuration
|
||||
*/
|
||||
export interface ContextMenuItem {
|
||||
icon?: string;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
type?: 'divider';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Create Base Component
|
||||
|
||||
### 3.1 Create `ComponentsPanel.tsx`
|
||||
|
||||
Start with a minimal shell:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* ComponentsPanel
|
||||
*
|
||||
* Modern React implementation of the components sidebar panel.
|
||||
* Displays project component hierarchy with folders, allows drag-drop reorganization,
|
||||
* and provides context menus for component/folder operations.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import css from './ComponentsPanel.module.scss';
|
||||
import { ComponentsPanelProps } from './types';
|
||||
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
const {
|
||||
nodeGraphEditor,
|
||||
showSheetList = true,
|
||||
hideSheets = [],
|
||||
componentTitle = 'Components',
|
||||
lockCurrentSheetName
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={css.ComponentsPanel}>
|
||||
<div className={css.Header}>
|
||||
<div className={css.Title}>{componentTitle}</div>
|
||||
<button className={css.AddButton} title="Add component">
|
||||
<div className={css.AddIcon}>+</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSheetList && (
|
||||
<div className={css.SheetsSection}>
|
||||
<div className={css.SheetsHeader}>Sheets</div>
|
||||
<div className={css.SheetsList}>
|
||||
{/* Sheet tabs will go here */}
|
||||
<div className={css.SheetItem}>Default</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css.ComponentsHeader}>
|
||||
<div className={css.Title}>Components</div>
|
||||
</div>
|
||||
|
||||
<div className={css.ComponentsScroller}>
|
||||
<div className={css.ComponentsList}>
|
||||
{/* Placeholder content */}
|
||||
<div className={css.PlaceholderItem}>📁 Folder 1</div>
|
||||
<div className={css.PlaceholderItem}>📄 Component 1</div>
|
||||
<div className={css.PlaceholderItem}>📄 Component 2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Create Base Styles
|
||||
|
||||
### 4.1 Create `ComponentsPanel.module.scss`
|
||||
|
||||
Port essential styles from the legacy CSS:
|
||||
|
||||
```scss
|
||||
/**
|
||||
* ComponentsPanel Styles
|
||||
* Ported from legacy componentspanel.css
|
||||
*/
|
||||
|
||||
.ComponentsPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header sections */
|
||||
.Header,
|
||||
.SheetsHeader,
|
||||
.ComponentsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
font: 11px var(--font-family-bold);
|
||||
color: var(--theme-color-fg-default);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.Title {
|
||||
flex: 1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.AddButton {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--theme-color-fg-default);
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.AddIcon {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Sheets section */
|
||||
.SheetsSection {
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.SheetsList {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.SheetItem {
|
||||
padding: 8px 10px 8px 30px;
|
||||
font: 11px var(--font-family-regular);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Components list */
|
||||
.ComponentsScroller {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ComponentsList {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Placeholder items (temporary for Phase 1) */
|
||||
.PlaceholderItem {
|
||||
padding: 8px 10px 8px 23px;
|
||||
font: 11px var(--font-family-regular);
|
||||
color: var(--theme-color-fg-default);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.ComponentsScroller::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.ComponentsScroller::-webkit-scrollbar-track {
|
||||
background: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
.ComponentsScroller::-webkit-scrollbar-thumb {
|
||||
background: var(--theme-color-bg-4);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-fg-muted);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Create Export File
|
||||
|
||||
### 5.1 Create `index.ts`
|
||||
|
||||
```typescript
|
||||
export { ComponentsPanel } from './ComponentsPanel';
|
||||
export * from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Register with SidebarModel
|
||||
|
||||
### 6.1 Update `router.setup.ts`
|
||||
|
||||
Find the existing ComponentsPanel registration and update it:
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
const ComponentsPanel = require('./views/panels/componentspanel/ComponentsPanel').ComponentsPanelView;
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { ComponentsPanel } from './views/panels/ComponentsPanel';
|
||||
```
|
||||
|
||||
**Registration (should already exist, just verify):**
|
||||
|
||||
```typescript
|
||||
SidebarModel.instance.register({
|
||||
id: 'components',
|
||||
name: 'Components',
|
||||
order: 1,
|
||||
icon: IconName.Components,
|
||||
onOpen: (args) => {
|
||||
const panel = new ComponentsPanel({
|
||||
nodeGraphEditor: args.context.nodeGraphEditor,
|
||||
showSheetList: true,
|
||||
hideSheets: ['__cloud__']
|
||||
});
|
||||
panel.render();
|
||||
return panel.el;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Update to:**
|
||||
|
||||
```typescript
|
||||
SidebarModel.instance.register({
|
||||
id: 'components',
|
||||
name: 'Components',
|
||||
order: 1,
|
||||
icon: IconName.Components,
|
||||
panel: ComponentsPanel,
|
||||
panelProps: {
|
||||
nodeGraphEditor: undefined, // Will be set by SidePanel
|
||||
showSheetList: true,
|
||||
hideSheets: ['__cloud__']
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** Check how `SidebarModel` handles React components. You may need to look at how `SearchPanel.tsx` or other React panels are registered.
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Testing
|
||||
|
||||
### 7.1 Build and Run
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 7.2 Verification Checklist
|
||||
|
||||
- [ ] No TypeScript compilation errors
|
||||
- [ ] Application starts without errors
|
||||
- [ ] Clicking "Components" icon in sidebar shows panel
|
||||
- [ ] Panel displays with header "Components"
|
||||
- [ ] "+" button appears in header
|
||||
- [ ] Placeholder items are visible
|
||||
- [ ] If `showSheetList` is true, "Sheets" section appears
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] Styles look consistent with other sidebar panels
|
||||
|
||||
### 7.3 Test Edge Cases
|
||||
|
||||
- [ ] Panel resizes correctly with window
|
||||
- [ ] Scrollbar appears if content overflows
|
||||
- [ ] Panel switches correctly with other sidebar panels
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: Panel doesn't appear
|
||||
|
||||
**Solution:** Check that `SidebarModel` registration is correct. Look at how other React panels like `SearchPanel` are registered.
|
||||
|
||||
### Issue: Styles not applying
|
||||
|
||||
**Solution:** Verify CSS module import path is correct and webpack is configured to handle `.module.scss` files.
|
||||
|
||||
### Issue: TypeScript errors with ComponentModel
|
||||
|
||||
**Solution:** Ensure all `@noodl-models` imports are available. Check `tsconfig.json` paths.
|
||||
|
||||
### Issue: "nodeGraphEditor" prop undefined
|
||||
|
||||
**Solution:** `SidePanel` should inject this. Check that prop passing matches other panels.
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
|
||||
**Legacy Implementation:**
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanel.ts`
|
||||
- `packages/noodl-editor/src/editor/src/templates/componentspanel.html`
|
||||
- `packages/noodl-editor/src/editor/src/styles/componentspanel.css`
|
||||
|
||||
**React Panel Examples:**
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/SearchPanel/SearchPanel.tsx`
|
||||
- `packages/noodl-editor/src/editor/src/views/VersionControlPanel/VersionControlPanel.tsx`
|
||||
|
||||
**SidebarModel:**
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 1 is complete when:**
|
||||
|
||||
1. New directory structure exists
|
||||
2. TypeScript types are defined
|
||||
3. ComponentsPanel React component renders
|
||||
4. Component is registered with SidebarModel
|
||||
5. Panel appears when clicking Components icon
|
||||
6. Placeholder content is visible
|
||||
7. No console errors
|
||||
8. All TypeScript compiles without errors
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
**Phase 2: Tree Rendering** - Connect to ProjectModel and render actual component tree structure.
|
||||
@@ -0,0 +1,668 @@
|
||||
# Phase 2: Tree Rendering
|
||||
|
||||
**Estimated Time:** 1-2 hours
|
||||
**Complexity:** Medium
|
||||
**Prerequisites:** Phase 1 complete (foundation set up)
|
||||
|
||||
## Overview
|
||||
|
||||
Connect the ComponentsPanel to ProjectModel and render the actual component tree structure with folders, proper selection handling, and correct icons. This phase brings the panel to life with real data.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Subscribe to ProjectModel events for component changes
|
||||
- ✅ Build folder/component tree structure from ProjectModel
|
||||
- ✅ Implement recursive tree rendering
|
||||
- ✅ Add expand/collapse for folders
|
||||
- ✅ Implement component selection sync with NodeGraphEditor
|
||||
- ✅ Show correct icons (home, page, cloud, visual, folder)
|
||||
- ✅ Handle component warnings display
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Tree Rendering Components
|
||||
|
||||
### 1.1 Create `components/ComponentTree.tsx`
|
||||
|
||||
Recursive component for rendering the tree:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* ComponentTree
|
||||
*
|
||||
* Recursively renders the component/folder tree structure.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { TreeNode } from '../types';
|
||||
import { ComponentItem } from './ComponentItem';
|
||||
import { FolderItem } from './FolderItem';
|
||||
|
||||
interface ComponentTreeProps {
|
||||
nodes: TreeNode[];
|
||||
level?: number;
|
||||
onItemClick: (node: TreeNode) => void;
|
||||
onCaretClick: (folderId: string) => void;
|
||||
expandedFolders: Set<string>;
|
||||
selectedId?: string;
|
||||
}
|
||||
|
||||
export function ComponentTree({
|
||||
nodes,
|
||||
level = 0,
|
||||
onItemClick,
|
||||
onCaretClick,
|
||||
expandedFolders,
|
||||
selectedId
|
||||
}: ComponentTreeProps) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => {
|
||||
if (node.type === 'folder') {
|
||||
return (
|
||||
<FolderItem
|
||||
key={node.path}
|
||||
folder={node}
|
||||
level={level}
|
||||
isExpanded={expandedFolders.has(node.path)}
|
||||
isSelected={selectedId === node.path}
|
||||
onCaretClick={() => onCaretClick(node.path)}
|
||||
onClick={() => onItemClick(node)}
|
||||
>
|
||||
{expandedFolders.has(node.path) && node.children.length > 0 && (
|
||||
<ComponentTree
|
||||
nodes={node.children}
|
||||
level={level + 1}
|
||||
onItemClick={onItemClick}
|
||||
onCaretClick={onCaretClick}
|
||||
expandedFolders={expandedFolders}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
)}
|
||||
</FolderItem>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ComponentItem
|
||||
key={node.fullName}
|
||||
component={node}
|
||||
level={level}
|
||||
isSelected={selectedId === node.fullName}
|
||||
onClick={() => onItemClick(node)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Create `components/FolderItem.tsx`
|
||||
|
||||
Component for rendering folder rows:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* FolderItem
|
||||
*
|
||||
* Renders a folder row with expand/collapse caret and nesting.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import css from '../ComponentsPanel.module.scss';
|
||||
import { FolderItemData } from '../types';
|
||||
|
||||
interface FolderItemProps {
|
||||
folder: FolderItemData;
|
||||
level: number;
|
||||
isExpanded: boolean;
|
||||
isSelected: boolean;
|
||||
onCaretClick: () => void;
|
||||
onClick: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
folder,
|
||||
level,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
onCaretClick,
|
||||
onClick,
|
||||
children
|
||||
}: FolderItemProps) {
|
||||
const indent = level * 12;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 10}px` }}
|
||||
>
|
||||
<div
|
||||
className={classNames(css.Caret, {
|
||||
[css.Expanded]: isExpanded
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCaretClick();
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</div>
|
||||
<div className={css.ItemContent} onClick={onClick}>
|
||||
<div className={css.Icon}>{folder.isComponentFolder ? IconName.FolderComponent : IconName.Folder}</div>
|
||||
<div className={css.Label}>{folder.name}</div>
|
||||
{folder.hasWarnings && <div className={css.Warning}>!</div>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Create `components/ComponentItem.tsx`
|
||||
|
||||
Component for rendering component rows:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* ComponentItem
|
||||
*
|
||||
* Renders a single component row with appropriate icon.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import css from '../ComponentsPanel.module.scss';
|
||||
import { ComponentItemData } from '../types';
|
||||
|
||||
interface ComponentItemProps {
|
||||
component: ComponentItemData;
|
||||
level: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ComponentItem({ component, level, isSelected, onClick }: ComponentItemProps) {
|
||||
const indent = level * 12;
|
||||
|
||||
// Determine icon based on component type
|
||||
let icon = IconName.Component;
|
||||
if (component.isRoot) {
|
||||
icon = IconName.Home;
|
||||
} else if (component.isPage) {
|
||||
icon = IconName.Page;
|
||||
} else if (component.isCloudFunction) {
|
||||
icon = IconName.Cloud;
|
||||
} else if (component.isVisual) {
|
||||
icon = IconName.Visual;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 23}px` }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={css.ItemContent}>
|
||||
<div className={css.Icon}>{icon}</div>
|
||||
<div className={css.Label}>{component.name}</div>
|
||||
{component.hasWarnings && <div className={css.Warning}>!</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create State Management Hook
|
||||
|
||||
### 2.1 Create `hooks/useComponentsPanel.ts`
|
||||
|
||||
Main hook for managing panel state:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* useComponentsPanel
|
||||
*
|
||||
* Main state management hook for ComponentsPanel.
|
||||
* Subscribes to ProjectModel and builds tree structure.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { ComponentsPanelFolder } from '../../componentspanel/ComponentsPanelFolder';
|
||||
import { ComponentItemData, FolderItemData, TreeNode } from '../types';
|
||||
|
||||
interface UseComponentsPanelOptions {
|
||||
hideSheets?: string[];
|
||||
lockCurrentSheetName?: string;
|
||||
}
|
||||
|
||||
export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
|
||||
const { hideSheets = [], lockCurrentSheetName } = options;
|
||||
|
||||
// Local state
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const [updateCounter, setUpdateCounter] = useState(0);
|
||||
|
||||
// Subscribe to ProjectModel events
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
setUpdateCounter((c) => c + 1);
|
||||
};
|
||||
|
||||
ProjectModel.instance.on('componentAdded', handleUpdate);
|
||||
ProjectModel.instance.on('componentRemoved', handleUpdate);
|
||||
ProjectModel.instance.on('componentRenamed', handleUpdate);
|
||||
ProjectModel.instance.on('rootComponentChanged', handleUpdate);
|
||||
|
||||
return () => {
|
||||
ProjectModel.instance.off('componentAdded', handleUpdate);
|
||||
ProjectModel.instance.off('componentRemoved', handleUpdate);
|
||||
ProjectModel.instance.off('componentRenamed', handleUpdate);
|
||||
ProjectModel.instance.off('rootComponentChanged', handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build tree structure
|
||||
const treeData = useMemo(() => {
|
||||
return buildTreeFromProject(ProjectModel.instance, hideSheets, lockCurrentSheetName);
|
||||
}, [updateCounter, hideSheets, lockCurrentSheetName]);
|
||||
|
||||
// Toggle folder expand/collapse
|
||||
const toggleFolder = useCallback((folderId: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) {
|
||||
next.delete(folderId);
|
||||
} else {
|
||||
next.add(folderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle item click
|
||||
const handleItemClick = useCallback((node: TreeNode) => {
|
||||
if (node.type === 'component') {
|
||||
setSelectedId(node.fullName);
|
||||
// TODO: Open component in NodeGraphEditor
|
||||
} else {
|
||||
setSelectedId(node.path);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
treeData,
|
||||
expandedFolders,
|
||||
selectedId,
|
||||
toggleFolder,
|
||||
handleItemClick
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tree structure from ProjectModel
|
||||
* Port logic from ComponentsPanel.ts addComponentToFolderStructure
|
||||
*/
|
||||
function buildTreeFromProject(project: ProjectModel, hideSheets: string[], lockSheet?: string): TreeNode[] {
|
||||
// TODO: Implement tree building logic
|
||||
// This will port the logic from legacy ComponentsPanel.ts
|
||||
// For now, return placeholder structure
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Add Styles for Tree Items
|
||||
|
||||
### 3.1 Update `ComponentsPanel.module.scss`
|
||||
|
||||
Add styles for tree items:
|
||||
|
||||
```scss
|
||||
/* Tree items */
|
||||
.TreeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font: 11px var(--font-family-regular);
|
||||
color: var(--theme-color-fg-default);
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&.Selected {
|
||||
background-color: var(--theme-color-primary-transparent);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Caret {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
color: var(--theme-color-fg-muted);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.Expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.Label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Warning {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--theme-color-warning);
|
||||
color: var(--theme-color-bg-1);
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Integrate Tree Rendering
|
||||
|
||||
### 4.1 Update `ComponentsPanel.tsx`
|
||||
|
||||
Replace placeholder content with actual tree:
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
|
||||
import { ComponentTree } from './components/ComponentTree';
|
||||
import css from './ComponentsPanel.module.scss';
|
||||
import { useComponentsPanel } from './hooks/useComponentsPanel';
|
||||
import { ComponentsPanelProps } from './types';
|
||||
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
const {
|
||||
nodeGraphEditor,
|
||||
showSheetList = true,
|
||||
hideSheets = [],
|
||||
componentTitle = 'Components',
|
||||
lockCurrentSheetName
|
||||
} = props;
|
||||
|
||||
const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
|
||||
hideSheets,
|
||||
lockCurrentSheetName
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={css.ComponentsPanel}>
|
||||
<div className={css.Header}>
|
||||
<div className={css.Title}>{componentTitle}</div>
|
||||
<button className={css.AddButton} title="Add component">
|
||||
<div className={css.AddIcon}>+</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSheetList && (
|
||||
<div className={css.SheetsSection}>
|
||||
<div className={css.SheetsHeader}>Sheets</div>
|
||||
<div className={css.SheetsList}>{/* TODO: Implement sheet selector in Phase 6 */}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css.ComponentsHeader}>
|
||||
<div className={css.Title}>Components</div>
|
||||
</div>
|
||||
|
||||
<div className={css.ComponentsScroller}>
|
||||
<div className={css.ComponentsList}>
|
||||
<ComponentTree
|
||||
nodes={treeData}
|
||||
expandedFolders={expandedFolders}
|
||||
selectedId={selectedId}
|
||||
onItemClick={handleItemClick}
|
||||
onCaretClick={toggleFolder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Port Tree Building Logic
|
||||
|
||||
### 5.1 Implement `buildTreeFromProject`
|
||||
|
||||
Port logic from legacy `ComponentsPanel.ts`:
|
||||
|
||||
```typescript
|
||||
function buildTreeFromProject(project: ProjectModel, hideSheets: string[], lockSheet?: string): TreeNode[] {
|
||||
const rootFolder = new ComponentsPanelFolder({ path: '/', name: '' });
|
||||
|
||||
// Get all components
|
||||
const components = project.getComponents();
|
||||
|
||||
// Filter by sheet if specified
|
||||
const filteredComponents = components.filter((comp) => {
|
||||
const sheet = getSheetForComponent(comp.name);
|
||||
if (hideSheets.includes(sheet)) return false;
|
||||
if (lockSheet && sheet !== lockSheet) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add each component to folder structure
|
||||
filteredComponents.forEach((comp) => {
|
||||
addComponentToFolderStructure(rootFolder, comp, project);
|
||||
});
|
||||
|
||||
// Convert folder structure to tree nodes
|
||||
return convertFolderToTreeNodes(rootFolder);
|
||||
}
|
||||
|
||||
function addComponentToFolderStructure(
|
||||
rootFolder: ComponentsPanelFolder,
|
||||
component: ComponentModel,
|
||||
project: ProjectModel
|
||||
) {
|
||||
const parts = component.name.split('/');
|
||||
let currentFolder = rootFolder;
|
||||
|
||||
// Navigate/create folder structure
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const folderName = parts[i];
|
||||
let folder = currentFolder.children.find((c) => c.name === folderName);
|
||||
|
||||
if (!folder) {
|
||||
folder = new ComponentsPanelFolder({
|
||||
path: parts.slice(0, i + 1).join('/'),
|
||||
name: folderName
|
||||
});
|
||||
currentFolder.children.push(folder);
|
||||
}
|
||||
|
||||
currentFolder = folder;
|
||||
}
|
||||
|
||||
// Add component to final folder
|
||||
currentFolder.components.push(component);
|
||||
}
|
||||
|
||||
function convertFolderToTreeNodes(folder: ComponentsPanelFolder): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
|
||||
// Add folder children first
|
||||
folder.children.forEach((childFolder) => {
|
||||
const folderNode: FolderItemData = {
|
||||
type: 'folder',
|
||||
folder: childFolder,
|
||||
name: childFolder.name,
|
||||
path: childFolder.path,
|
||||
isOpen: false,
|
||||
isSelected: false,
|
||||
isRoot: childFolder.path === '/',
|
||||
isPage: false,
|
||||
isCloudFunction: false,
|
||||
isVisual: true,
|
||||
isComponentFolder: childFolder.components.length > 0,
|
||||
canBecomeRoot: false,
|
||||
hasWarnings: false,
|
||||
children: convertFolderToTreeNodes(childFolder)
|
||||
};
|
||||
nodes.push(folderNode);
|
||||
});
|
||||
|
||||
// Add components
|
||||
folder.components.forEach((comp) => {
|
||||
const componentNode: ComponentItemData = {
|
||||
type: 'component',
|
||||
component: comp,
|
||||
folder: folder,
|
||||
name: comp.name.split('/').pop() || comp.name,
|
||||
fullName: comp.name,
|
||||
isSelected: false,
|
||||
isRoot: ProjectModel.instance.getRootComponent() === comp,
|
||||
isPage: comp.type === 'Page',
|
||||
isCloudFunction: comp.type === 'CloudFunction',
|
||||
isVisual: comp.type !== 'Logic',
|
||||
canBecomeRoot: true,
|
||||
hasWarnings: false // TODO: Implement warning detection
|
||||
};
|
||||
nodes.push(componentNode);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function getSheetForComponent(componentName: string): string {
|
||||
// Extract sheet from component name
|
||||
// Components in sheets have format: SheetName/ComponentName
|
||||
if (componentName.includes('/')) {
|
||||
return componentName.split('/')[0];
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Testing
|
||||
|
||||
### 6.1 Verification Checklist
|
||||
|
||||
- [ ] Tree renders with correct folder structure
|
||||
- [ ] Components appear under correct folders
|
||||
- [ ] Clicking caret expands/collapses folders
|
||||
- [ ] Clicking component selects it
|
||||
- [ ] Home icon appears for root component
|
||||
- [ ] Page icon appears for page components
|
||||
- [ ] Cloud icon appears for cloud functions
|
||||
- [ ] Visual icon appears for visual components
|
||||
- [ ] Folder icons appear correctly
|
||||
- [ ] Folder+component icon for folders that are also components
|
||||
- [ ] Warning icons appear (when implemented)
|
||||
- [ ] No console errors
|
||||
|
||||
### 6.2 Test Edge Cases
|
||||
|
||||
- [ ] Empty project (no components)
|
||||
- [ ] Deep folder nesting
|
||||
- [ ] Component names with special characters
|
||||
- [ ] Sheet filtering works correctly
|
||||
- [ ] Hidden sheets are excluded
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: Tree doesn't update when components change
|
||||
|
||||
**Solution:** Verify ProjectModel event subscriptions are correct and updateCounter increments.
|
||||
|
||||
### Issue: Folders don't expand
|
||||
|
||||
**Solution:** Check that `expandedFolders` Set is being updated correctly and ComponentTree receives updated props.
|
||||
|
||||
### Issue: Icons not showing
|
||||
|
||||
**Solution:** Verify Icon component import and that IconName values are correct.
|
||||
|
||||
### Issue: Selection doesn't work
|
||||
|
||||
**Solution:** Check that `selectedId` is being set correctly and CSS `.Selected` class is applied.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 2 is complete when:**
|
||||
|
||||
1. Component tree renders with actual project data
|
||||
2. Folders expand and collapse correctly
|
||||
3. Components can be selected
|
||||
4. All icons display correctly
|
||||
5. Selection highlights correctly
|
||||
6. Tree updates when project changes
|
||||
7. No console errors or warnings
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
**Phase 3: Context Menus** - Add context menu functionality for components and folders.
|
||||
@@ -0,0 +1,526 @@
|
||||
# Phase 3: Context Menus
|
||||
|
||||
**Estimated Time:** 1 hour
|
||||
**Complexity:** Low
|
||||
**Prerequisites:** Phase 2 complete (tree rendering working)
|
||||
|
||||
## Overview
|
||||
|
||||
Add context menu functionality for components and folders, including add component menu, rename, duplicate, delete, and make home actions. All actions should integrate with UndoQueue for proper undo/redo support.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Implement header "+" button menu
|
||||
- ✅ Implement component right-click context menu
|
||||
- ✅ Implement folder right-click context menu
|
||||
- ✅ Wire up add component action
|
||||
- ✅ Wire up rename action
|
||||
- ✅ Wire up duplicate action
|
||||
- ✅ Wire up delete action
|
||||
- ✅ Wire up make home action
|
||||
- ✅ All actions use UndoQueue
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Add Component Menu
|
||||
|
||||
### 1.1 Create `components/AddComponentMenu.tsx`
|
||||
|
||||
Menu for adding new components/folders:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* AddComponentMenu
|
||||
*
|
||||
* Dropdown menu for adding new components or folders.
|
||||
* Integrates with ComponentTemplates system.
|
||||
*/
|
||||
|
||||
import PopupLayer from '@noodl-views/popuplayer';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { ComponentTemplates } from '../../componentspanel/ComponentTemplates';
|
||||
|
||||
interface AddComponentMenuProps {
|
||||
targetElement: HTMLElement;
|
||||
onClose: () => void;
|
||||
parentPath?: string;
|
||||
}
|
||||
|
||||
export function AddComponentMenu({ targetElement, onClose, parentPath = '' }: AddComponentMenuProps) {
|
||||
const handleAddComponent = useCallback(
|
||||
(templateId: string) => {
|
||||
const template = ComponentTemplates.instance.getTemplate(templateId);
|
||||
if (!template) return;
|
||||
|
||||
// TODO: Create component with template
|
||||
// This will integrate with ProjectModel
|
||||
console.log('Add component:', templateId, 'at path:', parentPath);
|
||||
|
||||
onClose();
|
||||
},
|
||||
[parentPath, onClose]
|
||||
);
|
||||
|
||||
const handleAddFolder = useCallback(() => {
|
||||
// TODO: Create new folder
|
||||
console.log('Add folder at path:', parentPath);
|
||||
onClose();
|
||||
}, [parentPath, onClose]);
|
||||
|
||||
// Build menu items from templates
|
||||
const templates = ComponentTemplates.instance.getTemplates();
|
||||
const menuItems = templates.map((template) => ({
|
||||
icon: template.icon || IconName.Component,
|
||||
label: template.displayName || template.name,
|
||||
onClick: () => handleAddComponent(template.id)
|
||||
}));
|
||||
|
||||
// Add folder option
|
||||
menuItems.push(
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Folder,
|
||||
label: 'Folder',
|
||||
onClick: handleAddFolder
|
||||
}
|
||||
);
|
||||
|
||||
// Show popup menu
|
||||
const menu = new PopupLayer.PopupMenu({ items: menuItems });
|
||||
|
||||
PopupLayer.instance.showPopup({
|
||||
content: menu,
|
||||
attachTo: targetElement,
|
||||
position: 'bottom'
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Add Context Menu Handlers
|
||||
|
||||
### 2.1 Update `ComponentItem.tsx`
|
||||
|
||||
Add right-click handler:
|
||||
|
||||
```typescript
|
||||
export function ComponentItem({ component, level, isSelected, onClick }: ComponentItemProps) {
|
||||
const indent = level * 12;
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const menuItems = buildComponentContextMenu(component);
|
||||
const menu = new PopupLayer.PopupMenu({ items: menuItems });
|
||||
|
||||
PopupLayer.instance.showPopup({
|
||||
content: menu,
|
||||
attachTo: e.currentTarget as HTMLElement,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
},
|
||||
[component]
|
||||
);
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 23}px` }}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* ... existing content ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildComponentContextMenu(component: ComponentItemData) {
|
||||
return [
|
||||
{
|
||||
icon: IconName.Plus,
|
||||
label: 'Add',
|
||||
onClick: () => {
|
||||
// TODO: Show add submenu
|
||||
}
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Home,
|
||||
label: 'Make Home',
|
||||
disabled: component.isRoot || !component.canBecomeRoot,
|
||||
onClick: () => {
|
||||
// TODO: Make component home
|
||||
}
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Edit,
|
||||
label: 'Rename',
|
||||
onClick: () => {
|
||||
// TODO: Enable rename mode
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: IconName.Copy,
|
||||
label: 'Duplicate',
|
||||
onClick: () => {
|
||||
// TODO: Duplicate component
|
||||
}
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Trash,
|
||||
label: 'Delete',
|
||||
onClick: () => {
|
||||
// TODO: Delete component
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Update `FolderItem.tsx`
|
||||
|
||||
Add right-click handler:
|
||||
|
||||
```typescript
|
||||
export function FolderItem({
|
||||
folder,
|
||||
level,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
onCaretClick,
|
||||
onClick,
|
||||
children
|
||||
}: FolderItemProps) {
|
||||
const indent = level * 12;
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const menuItems = buildFolderContextMenu(folder);
|
||||
const menu = new PopupLayer.PopupMenu({ items: menuItems });
|
||||
|
||||
PopupLayer.instance.showPopup({
|
||||
content: menu,
|
||||
attachTo: e.currentTarget as HTMLElement,
|
||||
position: { x: e.clientX, y: e.clientY }
|
||||
});
|
||||
},
|
||||
[folder]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 10}px` }}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* ... existing content ... */}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buildFolderContextMenu(folder: FolderItemData) {
|
||||
return [
|
||||
{
|
||||
icon: IconName.Plus,
|
||||
label: 'Add',
|
||||
onClick: () => {
|
||||
// TODO: Show add submenu at folder path
|
||||
}
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Home,
|
||||
label: 'Make Home',
|
||||
disabled: !folder.isComponentFolder || !folder.canBecomeRoot,
|
||||
onClick: () => {
|
||||
// TODO: Make folder component home
|
||||
}
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Edit,
|
||||
label: 'Rename',
|
||||
onClick: () => {
|
||||
// TODO: Enable rename mode for folder
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: IconName.Copy,
|
||||
label: 'Duplicate',
|
||||
onClick: () => {
|
||||
// TODO: Duplicate folder
|
||||
}
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: IconName.Trash,
|
||||
label: 'Delete',
|
||||
onClick: () => {
|
||||
// TODO: Delete folder and contents
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Implement Action Handlers
|
||||
|
||||
### 3.1 Create `hooks/useComponentActions.ts`
|
||||
|
||||
Hook for handling component actions:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* useComponentActions
|
||||
*
|
||||
* Provides handlers for component/folder actions.
|
||||
* Integrates with UndoQueue for all operations.
|
||||
*/
|
||||
|
||||
import { ToastLayer } from '@noodl-views/ToastLayer/ToastLayer';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
|
||||
|
||||
import { ComponentItemData, FolderItemData } from '../types';
|
||||
|
||||
export function useComponentActions() {
|
||||
const handleMakeHome = useCallback((item: ComponentItemData | FolderItemData) => {
|
||||
const componentName = item.type === 'component' ? item.fullName : item.path;
|
||||
const component = ProjectModel.instance.getComponentWithName(componentName);
|
||||
|
||||
if (!component) {
|
||||
ToastLayer.showError('Component not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousRoot = ProjectModel.instance.getRootComponent();
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Make ${component.name} home`,
|
||||
do: () => {
|
||||
ProjectModel.instance.setRootComponent(component);
|
||||
},
|
||||
undo: () => {
|
||||
if (previousRoot) {
|
||||
ProjectModel.instance.setRootComponent(previousRoot);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback((item: ComponentItemData | FolderItemData) => {
|
||||
const itemName = item.type === 'component' ? item.name : item.name;
|
||||
|
||||
// Confirm deletion
|
||||
const confirmed = confirm(`Are you sure you want to delete "${itemName}"?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (item.type === 'component') {
|
||||
const component = item.component;
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Delete ${component.name}`,
|
||||
do: () => {
|
||||
ProjectModel.instance.removeComponent(component);
|
||||
},
|
||||
undo: () => {
|
||||
ProjectModel.instance.addComponent(component);
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// TODO: Delete folder and all contents
|
||||
ToastLayer.showInfo('Folder deletion not yet implemented');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDuplicate = useCallback((item: ComponentItemData | FolderItemData) => {
|
||||
if (item.type === 'component') {
|
||||
const component = item.component;
|
||||
const newName = ProjectModel.instance.findUniqueComponentName(component.name + ' Copy');
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Duplicate ${component.name}`,
|
||||
do: () => {
|
||||
const duplicated = ProjectModel.instance.duplicateComponent(component, newName);
|
||||
return duplicated;
|
||||
},
|
||||
undo: (duplicated) => {
|
||||
if (duplicated) {
|
||||
ProjectModel.instance.removeComponent(duplicated);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// TODO: Duplicate folder and all contents
|
||||
ToastLayer.showInfo('Folder duplication not yet implemented');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRename = useCallback((item: ComponentItemData | FolderItemData) => {
|
||||
// This will be implemented in Phase 5: Inline Rename
|
||||
console.log('Rename:', item);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleMakeHome,
|
||||
handleDelete,
|
||||
handleDuplicate,
|
||||
handleRename
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Wire Up Actions
|
||||
|
||||
### 4.1 Update `ComponentsPanel.tsx`
|
||||
|
||||
Integrate action handlers:
|
||||
|
||||
```typescript
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { ComponentTree } from './components/ComponentTree';
|
||||
import css from './ComponentsPanel.module.scss';
|
||||
import { useComponentActions } from './hooks/useComponentActions';
|
||||
import { useComponentsPanel } from './hooks/useComponentsPanel';
|
||||
import { ComponentsPanelProps } from './types';
|
||||
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
const { showSheetList = true, hideSheets = [], componentTitle = 'Components', lockCurrentSheetName } = props;
|
||||
|
||||
const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
|
||||
hideSheets,
|
||||
lockCurrentSheetName
|
||||
});
|
||||
|
||||
const { handleMakeHome, handleDelete, handleDuplicate, handleRename } = useComponentActions();
|
||||
|
||||
const [addButtonRef, setAddButtonRef] = useState<HTMLButtonElement | null>(null);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
|
||||
const handleAddButtonClick = useCallback(() => {
|
||||
setShowAddMenu(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={css.ComponentsPanel}>
|
||||
<div className={css.Header}>
|
||||
<div className={css.Title}>{componentTitle}</div>
|
||||
<button ref={setAddButtonRef} className={css.AddButton} title="Add component" onClick={handleAddButtonClick}>
|
||||
<div className={css.AddIcon}>+</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ... rest of component ... */}
|
||||
|
||||
{showAddMenu && addButtonRef && (
|
||||
<AddComponentMenu targetElement={addButtonRef} onClose={() => setShowAddMenu(false)} parentPath="" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Testing
|
||||
|
||||
### 5.1 Verification Checklist
|
||||
|
||||
- [ ] Header "+" button shows add menu
|
||||
- [ ] Add menu includes all component templates
|
||||
- [ ] Add menu includes "Folder" option
|
||||
- [ ] Right-click on component shows context menu
|
||||
- [ ] Right-click on folder shows context menu
|
||||
- [ ] "Make Home" action works (and is disabled appropriately)
|
||||
- [ ] "Rename" action triggers (implementation in Phase 5)
|
||||
- [ ] "Duplicate" action works
|
||||
- [ ] "Delete" action works with confirmation
|
||||
- [ ] All actions can be undone
|
||||
- [ ] All actions can be redone
|
||||
- [ ] No console errors
|
||||
|
||||
### 5.2 Test Edge Cases
|
||||
|
||||
- [ ] Try to make home on component that can't be home
|
||||
- [ ] Try to delete root component (should prevent or handle)
|
||||
- [ ] Duplicate component with same name (should auto-rename)
|
||||
- [ ] Delete last component in folder
|
||||
- [ ] Context menu closes when clicking outside
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: Context menu doesn't appear
|
||||
|
||||
**Solution:** Check that `onContextMenu` handler is attached and `e.preventDefault()` is called.
|
||||
|
||||
### Issue: Menu appears in wrong position
|
||||
|
||||
**Solution:** Verify PopupLayer position parameters. Use `{ x: e.clientX, y: e.clientY }` for mouse position.
|
||||
|
||||
### Issue: Actions don't work
|
||||
|
||||
**Solution:** Check that ProjectModel methods are being called correctly and UndoQueue integration is proper.
|
||||
|
||||
### Issue: Undo doesn't work
|
||||
|
||||
**Solution:** Verify that UndoActionGroup is created correctly with both `do` and `undo` functions.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 3 is complete when:**
|
||||
|
||||
1. Header "+" button shows add menu
|
||||
2. All context menus work correctly
|
||||
3. Make home action works
|
||||
4. Delete action works with confirmation
|
||||
5. Duplicate action works
|
||||
6. All actions integrate with UndoQueue
|
||||
7. Undo/redo works for all actions
|
||||
8. No console errors
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
**Phase 4: Drag-Drop** - Implement drag-drop functionality for reorganizing components and folders.
|
||||
@@ -0,0 +1,549 @@
|
||||
# Phase 4: Drag-Drop
|
||||
|
||||
**Estimated Time:** 2 hours
|
||||
**Complexity:** High
|
||||
**Prerequisites:** Phase 3 complete (context menus working)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement drag-drop functionality for reorganizing components and folders. Users should be able to drag components into folders, drag folders into other folders, and reorder items. The system should integrate with existing PopupLayer drag system and UndoQueue.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Implement drag initiation on mouse down + move
|
||||
- ✅ Show drag ghost with item name
|
||||
- ✅ Implement drop zones on folders and components
|
||||
- ✅ Validate drop targets (prevent invalid drops)
|
||||
- ✅ Execute drop operations
|
||||
- ✅ Create undo actions for all drops
|
||||
- ✅ Handle cross-sheet drops
|
||||
- ✅ Show visual feedback for valid/invalid drops
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Drag-Drop Hook
|
||||
|
||||
### 1.1 Create `hooks/useDragDrop.ts`
|
||||
|
||||
Hook for managing drag-drop state:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* useDragDrop
|
||||
*
|
||||
* Manages drag-drop state and operations for components/folders.
|
||||
* Integrates with PopupLayer.startDragging system.
|
||||
*/
|
||||
|
||||
import PopupLayer from '@noodl-views/popuplayer';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { ComponentItemData, FolderItemData, TreeNode } from '../types';
|
||||
|
||||
export function useDragDrop() {
|
||||
const [draggedItem, setDraggedItem] = useState<TreeNode | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<TreeNode | null>(null);
|
||||
|
||||
// Start dragging
|
||||
const startDrag = useCallback((item: TreeNode, sourceElement: HTMLElement) => {
|
||||
setDraggedItem(item);
|
||||
|
||||
const label = item.type === 'component' ? item.name : `📁 ${item.name}`;
|
||||
|
||||
PopupLayer.instance.startDragging({
|
||||
label,
|
||||
dragTarget: sourceElement,
|
||||
onDragEnd: () => {
|
||||
setDraggedItem(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check if drop is valid
|
||||
const canDrop = useCallback(
|
||||
(target: TreeNode): boolean => {
|
||||
if (!draggedItem) return false;
|
||||
|
||||
// Can't drop on self
|
||||
if (draggedItem === target) return false;
|
||||
|
||||
// Folder-specific rules
|
||||
if (draggedItem.type === 'folder') {
|
||||
// Can't drop folder into its own children
|
||||
if (target.type === 'folder' && isDescendant(target, draggedItem)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Component can be dropped on folder
|
||||
if (draggedItem.type === 'component' && target.type === 'folder') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Folder can be dropped on folder
|
||||
if (draggedItem.type === 'folder' && target.type === 'folder') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[draggedItem]
|
||||
);
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = useCallback(
|
||||
(target: TreeNode) => {
|
||||
if (!draggedItem || !canDrop(target)) return;
|
||||
|
||||
setDropTarget(target);
|
||||
|
||||
// Drop will be executed by parent component
|
||||
// which has access to ProjectModel and UndoQueue
|
||||
},
|
||||
[draggedItem, canDrop]
|
||||
);
|
||||
|
||||
return {
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
startDrag,
|
||||
canDrop,
|
||||
handleDrop,
|
||||
clearDrop: () => setDropTarget(null)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if targetFolder is a descendant of sourceFolder
|
||||
*/
|
||||
function isDescendant(targetFolder: FolderItemData, sourceFolder: FolderItemData): boolean {
|
||||
if (targetFolder.path.startsWith(sourceFolder.path + '/')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Add Drag Handlers to Components
|
||||
|
||||
### 2.1 Update `ComponentItem.tsx`
|
||||
|
||||
Add drag initiation:
|
||||
|
||||
```typescript
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function ComponentItem({ component, level, isSelected, onClick, onDragStart }: ComponentItemProps) {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Track mouse down position
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY };
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!dragStartPos.current) return;
|
||||
|
||||
// Check if mouse moved enough to start drag
|
||||
const dx = e.clientX - dragStartPos.current.x;
|
||||
const dy = e.clientY - dragStartPos.current.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance > 5 && itemRef.current) {
|
||||
onDragStart?.(component, itemRef.current);
|
||||
dragStartPos.current = null;
|
||||
}
|
||||
},
|
||||
[component, onDragStart]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
dragStartPos.current = null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 23}px` }}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<div className={css.ItemContent}>
|
||||
<div className={css.Icon}>{icon}</div>
|
||||
<div className={css.Label}>{component.name}</div>
|
||||
{component.hasWarnings && <div className={css.Warning}>!</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Update `FolderItem.tsx`
|
||||
|
||||
Add drag initiation and drop zone:
|
||||
|
||||
```typescript
|
||||
export function FolderItem({
|
||||
folder,
|
||||
level,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
onCaretClick,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
canAcceptDrop,
|
||||
children
|
||||
}: FolderItemProps) {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const [isDropTarget, setIsDropTarget] = useState(false);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY };
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!dragStartPos.current) return;
|
||||
|
||||
const dx = e.clientX - dragStartPos.current.x;
|
||||
const dy = e.clientY - dragStartPos.current.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance > 5 && itemRef.current) {
|
||||
onDragStart?.(folder, itemRef.current);
|
||||
dragStartPos.current = null;
|
||||
}
|
||||
},
|
||||
[folder, onDragStart]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
dragStartPos.current = null;
|
||||
}, []);
|
||||
|
||||
const handleDragEnter = useCallback(() => {
|
||||
if (canAcceptDrop?.(folder)) {
|
||||
setIsDropTarget(true);
|
||||
}
|
||||
}, [folder, canAcceptDrop]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDropTarget(false);
|
||||
}, []);
|
||||
|
||||
const handleDragDrop = useCallback(() => {
|
||||
if (canAcceptDrop?.(folder)) {
|
||||
onDrop?.(folder);
|
||||
setIsDropTarget(false);
|
||||
}
|
||||
}, [folder, canAcceptDrop, onDrop]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected,
|
||||
[css.DropTarget]: isDropTarget
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 10}px` }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDragDrop}
|
||||
>
|
||||
<div
|
||||
className={classNames(css.Caret, {
|
||||
[css.Expanded]: isExpanded
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCaretClick();
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</div>
|
||||
<div className={css.ItemContent} onClick={onClick}>
|
||||
<div className={css.Icon}>{folder.isComponentFolder ? IconName.FolderComponent : IconName.Folder}</div>
|
||||
<div className={css.Label}>{folder.name}</div>
|
||||
{folder.hasWarnings && <div className={css.Warning}>!</div>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Implement Drop Execution
|
||||
|
||||
### 3.1 Create Drop Handler in `useComponentActions.ts`
|
||||
|
||||
Add drop execution logic:
|
||||
|
||||
```typescript
|
||||
export function useComponentActions() {
|
||||
// ... existing handlers ...
|
||||
|
||||
const handleDropOn = useCallback((draggedItem: TreeNode, targetItem: TreeNode) => {
|
||||
if (draggedItem.type === 'component' && targetItem.type === 'folder') {
|
||||
// Move component to folder
|
||||
const component = draggedItem.component;
|
||||
const targetPath = targetItem.path === '/' ? '' : targetItem.path;
|
||||
const newName = targetPath ? `${targetPath}/${draggedItem.name}` : draggedItem.name;
|
||||
|
||||
// Check for naming conflicts
|
||||
if (ProjectModel.instance.getComponentWithName(newName)) {
|
||||
ToastLayer.showError(`Component "${newName}" already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldName = component.name;
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Move ${component.name} to ${targetItem.name}`,
|
||||
do: () => {
|
||||
ProjectModel.instance.renameComponent(component, newName);
|
||||
},
|
||||
undo: () => {
|
||||
ProjectModel.instance.renameComponent(component, oldName);
|
||||
}
|
||||
})
|
||||
);
|
||||
} else if (draggedItem.type === 'folder' && targetItem.type === 'folder') {
|
||||
// Move folder to folder
|
||||
const sourcePath = draggedItem.path;
|
||||
const targetPath = targetItem.path === '/' ? '' : targetItem.path;
|
||||
const newPath = targetPath ? `${targetPath}/${draggedItem.name}` : draggedItem.name;
|
||||
|
||||
// Get all components in source folder
|
||||
const componentsToMove = getComponentsInFolder(sourcePath);
|
||||
|
||||
if (componentsToMove.length === 0) {
|
||||
ToastLayer.showInfo('Folder is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const renames: Array<{ component: ComponentModel; oldName: string; newName: string }> = [];
|
||||
|
||||
componentsToMove.forEach((comp) => {
|
||||
const relativePath = comp.name.substring(sourcePath.length + 1);
|
||||
const newName = `${newPath}/${relativePath}`;
|
||||
renames.push({ component: comp, oldName: comp.name, newName });
|
||||
});
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Move ${draggedItem.name} to ${targetItem.name}`,
|
||||
do: () => {
|
||||
renames.forEach(({ component, newName }) => {
|
||||
ProjectModel.instance.renameComponent(component, newName);
|
||||
});
|
||||
},
|
||||
undo: () => {
|
||||
renames.forEach(({ component, oldName }) => {
|
||||
ProjectModel.instance.renameComponent(component, oldName);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleMakeHome,
|
||||
handleDelete,
|
||||
handleDuplicate,
|
||||
handleRename,
|
||||
handleDropOn
|
||||
};
|
||||
}
|
||||
|
||||
function getComponentsInFolder(folderPath: string): ComponentModel[] {
|
||||
const components = ProjectModel.instance.getComponents();
|
||||
return components.filter((comp) => {
|
||||
return comp.name.startsWith(folderPath + '/');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Add Drop Zone Styles
|
||||
|
||||
### 4.1 Update `ComponentsPanel.module.scss`
|
||||
|
||||
Add drop target styling:
|
||||
|
||||
```scss
|
||||
.TreeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font: 11px var(--font-family-regular);
|
||||
color: var(--theme-color-fg-default);
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&.Selected {
|
||||
background-color: var(--theme-color-primary-transparent);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&.DropTarget {
|
||||
background-color: var(--theme-color-primary-transparent);
|
||||
border: 2px dashed var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.DragOver {
|
||||
background-color: var(--theme-color-primary-transparent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Integrate with ComponentsPanel
|
||||
|
||||
### 5.1 Update `ComponentsPanel.tsx`
|
||||
|
||||
Wire up drag-drop:
|
||||
|
||||
```typescript
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
const { showSheetList = true, hideSheets = [], componentTitle = 'Components', lockCurrentSheetName } = props;
|
||||
|
||||
const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
|
||||
hideSheets,
|
||||
lockCurrentSheetName
|
||||
});
|
||||
|
||||
const { handleMakeHome, handleDelete, handleDuplicate, handleRename, handleDropOn } = useComponentActions();
|
||||
|
||||
const { draggedItem, startDrag, canDrop, handleDrop, clearDrop } = useDragDrop();
|
||||
|
||||
// Handle drop completion
|
||||
useEffect(() => {
|
||||
if (draggedItem && dropTarget) {
|
||||
handleDropOn(draggedItem, dropTarget);
|
||||
clearDrop();
|
||||
}
|
||||
}, [draggedItem, dropTarget, handleDropOn, clearDrop]);
|
||||
|
||||
return (
|
||||
<div className={css.ComponentsPanel}>
|
||||
{/* ... header ... */}
|
||||
|
||||
<div className={css.ComponentsScroller}>
|
||||
<div className={css.ComponentsList}>
|
||||
<ComponentTree
|
||||
nodes={treeData}
|
||||
expandedFolders={expandedFolders}
|
||||
selectedId={selectedId}
|
||||
onItemClick={handleItemClick}
|
||||
onCaretClick={toggleFolder}
|
||||
onDragStart={startDrag}
|
||||
onDrop={handleDrop}
|
||||
canAcceptDrop={canDrop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Testing
|
||||
|
||||
### 6.1 Verification Checklist
|
||||
|
||||
- [ ] Can drag component to folder
|
||||
- [ ] Can drag folder to folder
|
||||
- [ ] Cannot drag folder into its own children
|
||||
- [ ] Cannot drag item onto itself
|
||||
- [ ] Drop target highlights correctly
|
||||
- [ ] Invalid drops show no feedback
|
||||
- [ ] Drop executes correctly
|
||||
- [ ] Component moves to new location
|
||||
- [ ] Folder with all contents moves
|
||||
- [ ] Undo reverses drop
|
||||
- [ ] Redo re-applies drop
|
||||
- [ ] No console errors
|
||||
|
||||
### 6.2 Test Edge Cases
|
||||
|
||||
- [ ] Drag to root level (no folder)
|
||||
- [ ] Drag component with same name (should error)
|
||||
- [ ] Drag empty folder
|
||||
- [ ] Drag folder with deeply nested components
|
||||
- [ ] Cancel drag (mouse up without drop)
|
||||
- [ ] Drag across sheets
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: Drag doesn't start
|
||||
|
||||
**Solution:** Check that mouse down + move distance calculation is correct. Ensure PopupLayer.startDragging is called.
|
||||
|
||||
### Issue: Drop doesn't work
|
||||
|
||||
**Solution:** Verify that drop zone event handlers are attached. Check canDrop logic.
|
||||
|
||||
### Issue: Folder moves but children don't
|
||||
|
||||
**Solution:** Ensure getComponentsInFolder finds all nested components and renames them correctly.
|
||||
|
||||
### Issue: Undo breaks after drop
|
||||
|
||||
**Solution:** Verify that undo action captures all renamed components and restores original names.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 4 is complete when:**
|
||||
|
||||
1. Components can be dragged to folders
|
||||
2. Folders can be dragged to folders
|
||||
3. Invalid drops are prevented
|
||||
4. Drop target shows visual feedback
|
||||
5. Drops execute correctly
|
||||
6. All drops can be undone
|
||||
7. No console errors
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
**Phase 5: Inline Rename** - Implement rename-in-place with validation.
|
||||
@@ -0,0 +1,500 @@
|
||||
# Phase 5: Inline Rename
|
||||
|
||||
**Estimated Time:** 1 hour
|
||||
**Complexity:** Medium
|
||||
**Prerequisites:** Phase 4 complete (drag-drop working)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement inline rename functionality allowing users to double-click or use context menu to rename components and folders directly in the tree. Includes validation for duplicate names and proper undo support.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Implement rename mode state management
|
||||
- ✅ Show inline input field on rename trigger
|
||||
- ✅ Handle Enter to confirm, Escape to cancel
|
||||
- ✅ Validate name uniqueness
|
||||
- ✅ Handle focus management
|
||||
- ✅ Integrate with UndoQueue
|
||||
- ✅ Support both component and folder rename
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Rename Hook
|
||||
|
||||
### 1.1 Create `hooks/useRenameMode.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* useRenameMode
|
||||
*
|
||||
* Manages inline rename state and validation.
|
||||
*/
|
||||
|
||||
import { ToastLayer } from '@noodl-views/ToastLayer/ToastLayer';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import { TreeNode } from '../types';
|
||||
|
||||
export function useRenameMode() {
|
||||
const [renamingItem, setRenamingItem] = useState<TreeNode | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const startRename = useCallback((item: TreeNode) => {
|
||||
setRenamingItem(item);
|
||||
setRenameValue(item.name);
|
||||
}, []);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingItem(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
const validateName = useCallback(
|
||||
(newName: string): { valid: boolean; error?: string } => {
|
||||
if (!newName || newName.trim() === '') {
|
||||
return { valid: false, error: 'Name cannot be empty' };
|
||||
}
|
||||
|
||||
if (newName === renamingItem?.name) {
|
||||
return { valid: true }; // No change
|
||||
}
|
||||
|
||||
// Check for invalid characters
|
||||
if (/[<>:"|?*\\]/.test(newName)) {
|
||||
return { valid: false, error: 'Name contains invalid characters' };
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
if (renamingItem?.type === 'component') {
|
||||
const folder = renamingItem.folder;
|
||||
const folderPath = folder.path === '/' ? '' : folder.path;
|
||||
const fullName = folderPath ? `${folderPath}/${newName}` : newName;
|
||||
|
||||
if (ProjectModel.instance.getComponentWithName(fullName)) {
|
||||
return { valid: false, error: 'A component with this name already exists' };
|
||||
}
|
||||
} else if (renamingItem?.type === 'folder') {
|
||||
// Check for duplicate folder
|
||||
const parentPath = renamingItem.path.substring(0, renamingItem.path.lastIndexOf('/'));
|
||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
||||
|
||||
const components = ProjectModel.instance.getComponents();
|
||||
const hasConflict = components.some((comp) => comp.name.startsWith(newPath + '/'));
|
||||
|
||||
if (hasConflict) {
|
||||
// Check if it's just the same folder
|
||||
if (newPath !== renamingItem.path) {
|
||||
return { valid: false, error: 'A folder with this name already exists' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
},
|
||||
[renamingItem]
|
||||
);
|
||||
|
||||
return {
|
||||
renamingItem,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
startRename,
|
||||
cancelRename,
|
||||
validateName
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Rename Input Component
|
||||
|
||||
### 2.1 Create `components/RenameInput.tsx`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* RenameInput
|
||||
*
|
||||
* Inline input field for renaming components/folders.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import css from '../ComponentsPanel.module.scss';
|
||||
|
||||
interface RenameInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function RenameInput({ value, onChange, onConfirm, onCancel, level }: RenameInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const indent = level * 12;
|
||||
|
||||
// Auto-focus and select all on mount
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onConfirm, onCancel]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
// Cancel on blur
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className={css.RenameContainer} style={{ paddingLeft: `${indent + 23}px` }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={css.RenameInput}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Integrate Rename into Tree Items
|
||||
|
||||
### 3.1 Update `ComponentItem.tsx`
|
||||
|
||||
Add double-click and rename mode:
|
||||
|
||||
```typescript
|
||||
export function ComponentItem({
|
||||
component,
|
||||
level,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDoubleClick,
|
||||
isRenaming,
|
||||
renameValue,
|
||||
onRenameChange,
|
||||
onRenameConfirm,
|
||||
onRenameCancel
|
||||
}: ComponentItemProps) {
|
||||
// ... existing code ...
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<RenameInput
|
||||
value={renameValue}
|
||||
onChange={onRenameChange}
|
||||
onConfirm={onRenameConfirm}
|
||||
onCancel={onRenameCancel}
|
||||
level={level}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 23}px` }}
|
||||
onClick={onClick}
|
||||
onDoubleClick={() => onDoubleClick?.(component)}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{/* ... existing content ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Update `FolderItem.tsx`
|
||||
|
||||
Add double-click and rename mode:
|
||||
|
||||
```typescript
|
||||
export function FolderItem({
|
||||
folder,
|
||||
level,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
onCaretClick,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDrop,
|
||||
canAcceptDrop,
|
||||
onDoubleClick,
|
||||
isRenaming,
|
||||
renameValue,
|
||||
onRenameChange,
|
||||
onRenameConfirm,
|
||||
onRenameCancel,
|
||||
children
|
||||
}: FolderItemProps) {
|
||||
// ... existing code ...
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<>
|
||||
<RenameInput
|
||||
value={renameValue}
|
||||
onChange={onRenameChange}
|
||||
onConfirm={onRenameConfirm}
|
||||
onCancel={onRenameCancel}
|
||||
level={level}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={classNames(css.TreeItem, {
|
||||
[css.Selected]: isSelected,
|
||||
[css.DropTarget]: isDropTarget
|
||||
})}
|
||||
style={{ paddingLeft: `${indent + 10}px` }}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={() => onDoubleClick?.(folder)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDragDrop}
|
||||
>
|
||||
{/* ... existing content ... */}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Implement Rename Execution
|
||||
|
||||
### 4.1 Update `useComponentActions.ts`
|
||||
|
||||
Complete the rename handler:
|
||||
|
||||
```typescript
|
||||
const handleRename = useCallback((item: TreeNode, newName: string) => {
|
||||
if (item.type === 'component') {
|
||||
const component = item.component;
|
||||
const folder = item.folder;
|
||||
const folderPath = folder.path === '/' ? '' : folder.path;
|
||||
const newFullName = folderPath ? `${folderPath}/${newName}` : newName;
|
||||
const oldName = component.name;
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Rename ${component.name} to ${newName}`,
|
||||
do: () => {
|
||||
ProjectModel.instance.renameComponent(component, newFullName);
|
||||
},
|
||||
undo: () => {
|
||||
ProjectModel.instance.renameComponent(component, oldName);
|
||||
}
|
||||
})
|
||||
);
|
||||
} else if (item.type === 'folder') {
|
||||
// Rename folder (rename all components in folder)
|
||||
const oldPath = item.path;
|
||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
||||
|
||||
const components = ProjectModel.instance.getComponents();
|
||||
const componentsToRename = components.filter((comp) => comp.name.startsWith(oldPath + '/'));
|
||||
|
||||
if (componentsToRename.length === 0) {
|
||||
ToastLayer.showInfo('Folder is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const renames = componentsToRename.map((comp) => ({
|
||||
component: comp,
|
||||
oldName: comp.name,
|
||||
newName: comp.name.replace(oldPath, newPath)
|
||||
}));
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: `Rename folder ${item.name} to ${newName}`,
|
||||
do: () => {
|
||||
renames.forEach(({ component, newName }) => {
|
||||
ProjectModel.instance.renameComponent(component, newName);
|
||||
});
|
||||
},
|
||||
undo: () => {
|
||||
renames.forEach(({ component, oldName }) => {
|
||||
ProjectModel.instance.renameComponent(component, oldName);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Add Rename Styles
|
||||
|
||||
### 5.1 Update `ComponentsPanel.module.scss`
|
||||
|
||||
```scss
|
||||
.RenameContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.RenameInput {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font: 11px var(--font-family-regular);
|
||||
color: var(--theme-color-fg-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-primary);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--theme-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--theme-color-primary-transparent);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Wire Up Rename
|
||||
|
||||
### 6.1 Update `ComponentsPanel.tsx`
|
||||
|
||||
```typescript
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
const { treeData, expandedFolders, selectedId, toggleFolder, handleItemClick } = useComponentsPanel({
|
||||
hideSheets,
|
||||
lockCurrentSheetName
|
||||
});
|
||||
|
||||
const { handleMakeHome, handleDelete, handleDuplicate, handleRename, handleDropOn } = useComponentActions();
|
||||
|
||||
const { renamingItem, renameValue, setRenameValue, startRename, cancelRename, validateName } = useRenameMode();
|
||||
|
||||
const handleRenameConfirm = useCallback(() => {
|
||||
if (!renamingItem) return;
|
||||
|
||||
const validation = validateName(renameValue);
|
||||
if (!validation.valid) {
|
||||
ToastLayer.showError(validation.error || 'Invalid name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (renameValue !== renamingItem.name) {
|
||||
handleRename(renamingItem, renameValue);
|
||||
}
|
||||
|
||||
cancelRename();
|
||||
}, [renamingItem, renameValue, validateName, handleRename, cancelRename]);
|
||||
|
||||
return (
|
||||
<div className={css.ComponentsPanel}>
|
||||
{/* ... */}
|
||||
|
||||
<ComponentTree
|
||||
nodes={treeData}
|
||||
expandedFolders={expandedFolders}
|
||||
selectedId={selectedId}
|
||||
onItemClick={handleItemClick}
|
||||
onCaretClick={toggleFolder}
|
||||
onDragStart={startDrag}
|
||||
onDrop={handleDrop}
|
||||
canAcceptDrop={canDrop}
|
||||
onDoubleClick={startRename}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
onRenameChange={setRenameValue}
|
||||
onRenameConfirm={handleRenameConfirm}
|
||||
onRenameCancel={cancelRename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Testing
|
||||
|
||||
### 7.1 Verification Checklist
|
||||
|
||||
- [ ] Double-click on component triggers rename
|
||||
- [ ] Double-click on folder triggers rename
|
||||
- [ ] Context menu "Rename" triggers rename
|
||||
- [ ] Input field appears with current name
|
||||
- [ ] Text is selected on focus
|
||||
- [ ] Enter confirms rename
|
||||
- [ ] Escape cancels rename
|
||||
- [ ] Click outside cancels rename
|
||||
- [ ] Empty name shows error
|
||||
- [ ] Duplicate name shows error
|
||||
- [ ] Invalid characters show error
|
||||
- [ ] Successful rename updates tree
|
||||
- [ ] Rename can be undone
|
||||
- [ ] Folder rename updates all child components
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 5 is complete when:**
|
||||
|
||||
1. Double-click triggers rename mode
|
||||
2. Inline input appears with current name
|
||||
3. Enter confirms, Escape cancels
|
||||
4. Name validation works correctly
|
||||
5. Renames execute and update tree
|
||||
6. All renames can be undone
|
||||
7. No console errors
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
**Phase 6: Sheet Selector** - Implement sheet/tab switching functionality.
|
||||
@@ -0,0 +1,379 @@
|
||||
# Phase 6: Sheet Selector
|
||||
|
||||
**Estimated Time:** 30 minutes
|
||||
**Complexity:** Low
|
||||
**Prerequisites:** Phase 5 complete (inline rename working)
|
||||
|
||||
## Overview
|
||||
|
||||
Implement sheet/tab switching functionality. The sheet selector displays tabs for different sheets and filters the component tree to show only components from the selected sheet. Respects `hideSheets` and `lockCurrentSheetName` props.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Display sheet tabs from ProjectModel
|
||||
- ✅ Filter component tree by selected sheet
|
||||
- ✅ Handle sheet selection
|
||||
- ✅ Respect `hideSheets` prop
|
||||
- ✅ Respect `lockCurrentSheetName` prop
|
||||
- ✅ Show/hide based on `showSheetList` prop
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Sheet Selector Component
|
||||
|
||||
### 1.1 Create `components/SheetSelector.tsx`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* SheetSelector
|
||||
*
|
||||
* Displays tabs for project sheets and handles sheet selection.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import css from '../ComponentsPanel.module.scss';
|
||||
import { SheetData } from '../types';
|
||||
|
||||
interface SheetSelectorProps {
|
||||
sheets: SheetData[];
|
||||
selectedSheet: string;
|
||||
onSheetSelect: (sheetName: string) => void;
|
||||
}
|
||||
|
||||
export function SheetSelector({ sheets, selectedSheet, onSheetSelect }: SheetSelectorProps) {
|
||||
if (sheets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.SheetsSection}>
|
||||
<div className={css.SheetsHeader}>Sheets</div>
|
||||
<div className={css.SheetsList}>
|
||||
{sheets.map((sheet) => (
|
||||
<div
|
||||
key={sheet.name}
|
||||
className={classNames(css.SheetItem, {
|
||||
[css.Selected]: sheet.name === selectedSheet
|
||||
})}
|
||||
onClick={() => onSheetSelect(sheet.name)}
|
||||
>
|
||||
{sheet.displayName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Update Panel State Hook
|
||||
|
||||
### 2.1 Update `hooks/useComponentsPanel.ts`
|
||||
|
||||
Add sheet management:
|
||||
|
||||
```typescript
|
||||
export function useComponentsPanel(options: UseComponentsPanelOptions = {}) {
|
||||
const { hideSheets = [], lockCurrentSheetName } = options;
|
||||
|
||||
// Local state
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const [updateCounter, setUpdateCounter] = useState(0);
|
||||
const [currentSheet, setCurrentSheet] = useState<string>(() => {
|
||||
if (lockCurrentSheetName) return lockCurrentSheetName;
|
||||
return 'default'; // Or get from ProjectModel
|
||||
});
|
||||
|
||||
// Subscribe to ProjectModel events
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
setUpdateCounter((c) => c + 1);
|
||||
};
|
||||
|
||||
ProjectModel.instance.on('componentAdded', handleUpdate);
|
||||
ProjectModel.instance.on('componentRemoved', handleUpdate);
|
||||
ProjectModel.instance.on('componentRenamed', handleUpdate);
|
||||
ProjectModel.instance.on('rootComponentChanged', handleUpdate);
|
||||
|
||||
return () => {
|
||||
ProjectModel.instance.off('componentAdded', handleUpdate);
|
||||
ProjectModel.instance.off('componentRemoved', handleUpdate);
|
||||
ProjectModel.instance.off('componentRenamed', handleUpdate);
|
||||
ProjectModel.instance.off('rootComponentChanged', handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build sheets list
|
||||
const sheets = useMemo(() => {
|
||||
return buildSheetsList(ProjectModel.instance, hideSheets);
|
||||
}, [updateCounter, hideSheets]);
|
||||
|
||||
// Build tree structure (filtered by current sheet)
|
||||
const treeData = useMemo(() => {
|
||||
return buildTreeFromProject(ProjectModel.instance, hideSheets, currentSheet);
|
||||
}, [updateCounter, hideSheets, currentSheet]);
|
||||
|
||||
// Handle sheet selection
|
||||
const handleSheetSelect = useCallback(
|
||||
(sheetName: string) => {
|
||||
if (!lockCurrentSheetName) {
|
||||
setCurrentSheet(sheetName);
|
||||
}
|
||||
},
|
||||
[lockCurrentSheetName]
|
||||
);
|
||||
|
||||
return {
|
||||
treeData,
|
||||
expandedFolders,
|
||||
selectedId,
|
||||
sheets,
|
||||
currentSheet,
|
||||
toggleFolder,
|
||||
handleItemClick,
|
||||
handleSheetSelect
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of sheets from ProjectModel
|
||||
*/
|
||||
function buildSheetsList(project: ProjectModel, hideSheets: string[]): SheetData[] {
|
||||
const sheets: SheetData[] = [];
|
||||
const components = project.getComponents();
|
||||
|
||||
// Extract unique sheet names
|
||||
const sheetNames = new Set<string>();
|
||||
components.forEach((comp) => {
|
||||
const sheetName = getSheetForComponent(comp.name);
|
||||
if (!hideSheets.includes(sheetName)) {
|
||||
sheetNames.add(sheetName);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to SheetData array
|
||||
sheetNames.forEach((sheetName) => {
|
||||
sheets.push({
|
||||
name: sheetName,
|
||||
displayName: sheetName === 'default' ? 'Default' : sheetName,
|
||||
isDefault: sheetName === 'default',
|
||||
isSelected: false // Will be set by parent
|
||||
});
|
||||
});
|
||||
|
||||
// Sort: default first, then alphabetical
|
||||
sheets.sort((a, b) => {
|
||||
if (a.isDefault) return -1;
|
||||
if (b.isDefault) return 1;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
|
||||
return sheets;
|
||||
}
|
||||
|
||||
function getSheetForComponent(componentName: string): string {
|
||||
if (componentName.includes('/')) {
|
||||
const parts = componentName.split('/');
|
||||
// Check if first part is a sheet name
|
||||
// Sheets typically start with uppercase or have specific patterns
|
||||
return parts[0];
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Integrate Sheet Selector
|
||||
|
||||
### 3.1 Update `ComponentsPanel.tsx`
|
||||
|
||||
Add sheet selector to panel:
|
||||
|
||||
```typescript
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
const { showSheetList = true, hideSheets = [], componentTitle = 'Components', lockCurrentSheetName } = props;
|
||||
|
||||
const {
|
||||
treeData,
|
||||
expandedFolders,
|
||||
selectedId,
|
||||
sheets,
|
||||
currentSheet,
|
||||
toggleFolder,
|
||||
handleItemClick,
|
||||
handleSheetSelect
|
||||
} = useComponentsPanel({
|
||||
hideSheets,
|
||||
lockCurrentSheetName
|
||||
});
|
||||
|
||||
// ... other hooks ...
|
||||
|
||||
return (
|
||||
<div className={css.ComponentsPanel}>
|
||||
<div className={css.Header}>
|
||||
<div className={css.Title}>{componentTitle}</div>
|
||||
<button ref={setAddButtonRef} className={css.AddButton} title="Add component" onClick={handleAddButtonClick}>
|
||||
<div className={css.AddIcon}>+</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSheetList && sheets.length > 0 && (
|
||||
<SheetSelector sheets={sheets} selectedSheet={currentSheet} onSheetSelect={handleSheetSelect} />
|
||||
)}
|
||||
|
||||
<div className={css.ComponentsHeader}>
|
||||
<div className={css.Title}>Components</div>
|
||||
</div>
|
||||
|
||||
<div className={css.ComponentsScroller}>
|
||||
<div className={css.ComponentsList}>
|
||||
<ComponentTree
|
||||
nodes={treeData}
|
||||
expandedFolders={expandedFolders}
|
||||
selectedId={selectedId}
|
||||
onItemClick={handleItemClick}
|
||||
onCaretClick={toggleFolder}
|
||||
onDragStart={startDrag}
|
||||
onDrop={handleDrop}
|
||||
canAcceptDrop={canDrop}
|
||||
onDoubleClick={startRename}
|
||||
renamingItem={renamingItem}
|
||||
renameValue={renameValue}
|
||||
onRenameChange={setRenameValue}
|
||||
onRenameConfirm={handleRenameConfirm}
|
||||
onRenameCancel={cancelRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddMenu && addButtonRef && (
|
||||
<AddComponentMenu targetElement={addButtonRef} onClose={() => setShowAddMenu(false)} parentPath="" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Add Sheet Styles
|
||||
|
||||
### 4.1 Update `ComponentsPanel.module.scss`
|
||||
|
||||
Add sheet selection styling:
|
||||
|
||||
```scss
|
||||
.SheetsSection {
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.SheetsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
font: 11px var(--font-family-bold);
|
||||
color: var(--theme-color-fg-default);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.SheetsList {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.SheetItem {
|
||||
padding: 8px 10px 8px 30px;
|
||||
font: 11px var(--font-family-regular);
|
||||
color: var(--theme-color-fg-default);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&.Selected {
|
||||
background-color: var(--theme-color-primary-transparent);
|
||||
color: var(--theme-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Testing
|
||||
|
||||
### 5.1 Verification Checklist
|
||||
|
||||
- [ ] Sheet tabs appear when `showSheetList` is true
|
||||
- [ ] Sheet tabs hidden when `showSheetList` is false
|
||||
- [ ] Correct sheets displayed (excluding hidden sheets)
|
||||
- [ ] Clicking sheet selects it
|
||||
- [ ] Selected sheet highlights correctly
|
||||
- [ ] Component tree filters by selected sheet
|
||||
- [ ] Default sheet displays first
|
||||
- [ ] `lockCurrentSheetName` locks to specific sheet
|
||||
- [ ] No console errors
|
||||
|
||||
### 5.2 Test Edge Cases
|
||||
|
||||
- [ ] Project with no sheets (only default)
|
||||
- [ ] Project with many sheets
|
||||
- [ ] Switching sheets with expanded folders
|
||||
- [ ] Switching sheets with selected component
|
||||
- [ ] Locked sheet (should not allow switching)
|
||||
- [ ] Hidden sheets don't appear
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: Sheets don't appear
|
||||
|
||||
**Solution:** Check that `showSheetList` prop is true and that ProjectModel has components in sheets.
|
||||
|
||||
### Issue: Sheet filtering doesn't work
|
||||
|
||||
**Solution:** Verify `buildTreeFromProject` correctly filters components by sheet name.
|
||||
|
||||
### Issue: Hidden sheets still appear
|
||||
|
||||
**Solution:** Check that `hideSheets` array includes the correct sheet names.
|
||||
|
||||
### Issue: Can't switch sheets when locked
|
||||
|
||||
**Solution:** This is expected behavior when `lockCurrentSheetName` is set.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 6 is complete when:**
|
||||
|
||||
1. Sheet tabs display correctly
|
||||
2. Sheet selection works
|
||||
3. Component tree filters by selected sheet
|
||||
4. Hidden sheets are excluded
|
||||
5. Locked sheet prevents switching
|
||||
6. showSheetList prop controls visibility
|
||||
7. No console errors
|
||||
|
||||
---
|
||||
|
||||
## Next Phase
|
||||
|
||||
**Phase 7: Polish & Cleanup** - Final cleanup, remove legacy files, and prepare for TASK-004.
|
||||
@@ -0,0 +1,491 @@
|
||||
# Phase 7: Polish & Cleanup
|
||||
|
||||
**Estimated Time:** 1 hour
|
||||
**Complexity:** Low
|
||||
**Prerequisites:** Phase 6 complete (sheet selector working)
|
||||
|
||||
## Overview
|
||||
|
||||
Final polish, remove legacy files, ensure all functionality works correctly, and prepare the component for TASK-004 (migration status badges). This phase ensures the migration is complete and production-ready.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- ✅ Polish UI/UX (spacing, hover states, focus states)
|
||||
- ✅ Remove legacy files
|
||||
- ✅ Clean up code (remove TODOs, add missing JSDoc)
|
||||
- ✅ Verify all functionality works
|
||||
- ✅ Prepare extension points for TASK-004
|
||||
- ✅ Update documentation
|
||||
- ✅ Final testing pass
|
||||
|
||||
---
|
||||
|
||||
## Step 1: UI Polish
|
||||
|
||||
### 1.1 Review All Styles
|
||||
|
||||
Check and fix any styling inconsistencies:
|
||||
|
||||
```scss
|
||||
// Verify all spacing is consistent
|
||||
.TreeItem {
|
||||
padding: 6px 10px; // Should match across all items
|
||||
}
|
||||
|
||||
// Verify hover states work
|
||||
.TreeItem:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
// Verify selection states are clear
|
||||
.TreeItem.Selected {
|
||||
background-color: var(--theme-color-primary-transparent);
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
// Verify focus states for accessibility
|
||||
.RenameInput:focus {
|
||||
border-color: var(--theme-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--theme-color-primary-transparent);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Test Color Tokens
|
||||
|
||||
Verify all colors use design tokens (no hardcoded hex values):
|
||||
|
||||
```bash
|
||||
# Search for hardcoded colors
|
||||
grep -r "#[0-9a-fA-F]\{3,6\}" packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/
|
||||
```
|
||||
|
||||
If any found, replace with appropriate tokens from `--theme-color-*`.
|
||||
|
||||
### 1.3 Test Dark Theme (if applicable)
|
||||
|
||||
If OpenNoodl supports theme switching, test the panel in dark theme to ensure all colors are legible.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Code Cleanup
|
||||
|
||||
### 2.1 Remove TODO Comments
|
||||
|
||||
Search for and resolve all TODO comments:
|
||||
|
||||
```bash
|
||||
grep -rn "TODO" packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/
|
||||
```
|
||||
|
||||
Either implement the TODOs or remove them if they're no longer relevant.
|
||||
|
||||
### 2.2 Remove TSFixme Types
|
||||
|
||||
Ensure no TSFixme types were added:
|
||||
|
||||
```bash
|
||||
grep -rn "TSFixme" packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/
|
||||
```
|
||||
|
||||
Replace any with proper types.
|
||||
|
||||
### 2.3 Add JSDoc Comments
|
||||
|
||||
Ensure all exported functions and components have JSDoc:
|
||||
|
||||
````typescript
|
||||
/**
|
||||
* ComponentsPanel
|
||||
*
|
||||
* Modern React implementation of the components sidebar panel.
|
||||
* Displays project component hierarchy with folders, allows drag-drop reorganization,
|
||||
* and provides context menus for component/folder operations.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ComponentsPanel
|
||||
* nodeGraphEditor={editor}
|
||||
* showSheetList={true}
|
||||
* hideSheets={['__cloud__']}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ComponentsPanel(props: ComponentsPanelProps) {
|
||||
// ...
|
||||
}
|
||||
````
|
||||
|
||||
### 2.4 Clean Up Imports
|
||||
|
||||
Remove unused imports and organize them:
|
||||
|
||||
```typescript
|
||||
// External packages (alphabetical)
|
||||
|
||||
import PopupLayer from '@noodl-views/popuplayer';
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { UndoQueue } from '@noodl-models/undo-queue-model';
|
||||
|
||||
// Internal packages (alphabetical by alias)
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
// Relative imports
|
||||
import { ComponentTree } from './components/ComponentTree';
|
||||
import css from './ComponentsPanel.module.scss';
|
||||
import { useComponentsPanel } from './hooks/useComponentsPanel';
|
||||
import { ComponentsPanelProps } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Remove Legacy Files
|
||||
|
||||
### 3.1 Verify All Functionality Works
|
||||
|
||||
Before removing legacy files, thoroughly test the new implementation:
|
||||
|
||||
- [ ] All features from old panel work in new panel
|
||||
- [ ] No regressions identified
|
||||
- [ ] All tests pass
|
||||
|
||||
### 3.2 Update Imports
|
||||
|
||||
Find all files that import the old ComponentsPanel:
|
||||
|
||||
```bash
|
||||
grep -r "from.*componentspanel/ComponentsPanel" packages/noodl-editor/src/
|
||||
```
|
||||
|
||||
Update to import from new location:
|
||||
|
||||
```typescript
|
||||
// Old
|
||||
|
||||
// New
|
||||
import { ComponentsPanel } from './views/panels/ComponentsPanel';
|
||||
import { ComponentsPanelView } from './views/panels/componentspanel/ComponentsPanel';
|
||||
```
|
||||
|
||||
### 3.3 Delete Legacy Files
|
||||
|
||||
Once all imports are updated and verified:
|
||||
|
||||
```bash
|
||||
# Delete old implementation (DO NOT run this until 100% sure)
|
||||
# rm packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanel.ts
|
||||
# rm packages/noodl-editor/src/editor/src/templates/componentspanel.html
|
||||
```
|
||||
|
||||
**IMPORTANT:** Keep `ComponentsPanelFolder.ts` and `ComponentTemplates.ts` as they're reused.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Prepare for TASK-004
|
||||
|
||||
### 4.1 Add Migration Status Type
|
||||
|
||||
In `types.ts`, add placeholder for migration status:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Migration status for components (for TASK-004)
|
||||
*/
|
||||
export type MigrationStatus = 'needs-review' | 'ai-migrated' | 'auto' | 'manually-fixed' | null;
|
||||
|
||||
export interface ComponentItemData {
|
||||
type: 'component';
|
||||
component: ComponentModel;
|
||||
folder: ComponentsPanelFolder;
|
||||
name: string;
|
||||
fullName: string;
|
||||
isSelected: boolean;
|
||||
isRoot: boolean;
|
||||
isPage: boolean;
|
||||
isCloudFunction: boolean;
|
||||
isVisual: boolean;
|
||||
canBecomeRoot: boolean;
|
||||
hasWarnings: boolean;
|
||||
|
||||
// Migration status (for TASK-004)
|
||||
migrationStatus?: MigrationStatus;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Add Badge Placeholder in ComponentItem
|
||||
|
||||
```typescript
|
||||
export function ComponentItem({ component, level, isSelected, onClick }: ComponentItemProps) {
|
||||
// ... existing code ...
|
||||
|
||||
return (
|
||||
<div className={css.TreeItem} onClick={onClick}>
|
||||
<div className={css.ItemContent}>
|
||||
<div className={css.Icon}>{icon}</div>
|
||||
<div className={css.Label}>{component.name}</div>
|
||||
|
||||
{/* Migration badge (for TASK-004) */}
|
||||
{component.migrationStatus && (
|
||||
<div className={css.MigrationBadge} data-status={component.migrationStatus}>
|
||||
{/* Badge will be implemented in TASK-004 */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.hasWarnings && <div className={css.Warning}>!</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Add Filter Placeholder in Panel Header
|
||||
|
||||
```typescript
|
||||
<div className={css.Header}>
|
||||
<div className={css.Title}>{componentTitle}</div>
|
||||
|
||||
{/* Filter button (for TASK-004) */}
|
||||
{/* <button className={css.FilterButton} title="Filter components">
|
||||
<IconName.Filter />
|
||||
</button> */}
|
||||
|
||||
<button className={css.AddButton} title="Add component" onClick={handleAddButtonClick}>
|
||||
<div className={css.AddIcon}>+</div>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Documentation
|
||||
|
||||
### 5.1 Update CHANGELOG.md
|
||||
|
||||
Add final entry to CHANGELOG:
|
||||
|
||||
```markdown
|
||||
## [2024-12-21] - Migration Complete
|
||||
|
||||
### Summary
|
||||
|
||||
Completed ComponentsPanel React migration. All 7 phases implemented and tested.
|
||||
|
||||
### Files Created
|
||||
|
||||
- All files in `views/panels/ComponentsPanel/` directory
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `router.setup.ts` - Updated ComponentsPanel import
|
||||
|
||||
### Files Removed
|
||||
|
||||
- `views/panels/componentspanel/ComponentsPanel.ts` (legacy)
|
||||
- `templates/componentspanel.html` (legacy)
|
||||
|
||||
### Technical Notes
|
||||
|
||||
- Full feature parity achieved
|
||||
- All functionality uses UndoQueue
|
||||
- Ready for TASK-004 badges/filters integration
|
||||
|
||||
### Testing Notes
|
||||
|
||||
- All manual tests passed
|
||||
- No console errors
|
||||
- Performance is good even with large component trees
|
||||
|
||||
### Next Steps
|
||||
|
||||
- TASK-004 Part 2: Add migration status badges
|
||||
- TASK-004 Part 3: Add filter system
|
||||
```
|
||||
|
||||
### 5.2 Create Migration Pattern Document
|
||||
|
||||
Document the pattern for future panel migrations:
|
||||
|
||||
**File:** `dev-docs/reference/PANEL-MIGRATION-PATTERN.md`
|
||||
|
||||
```markdown
|
||||
# Panel Migration Pattern
|
||||
|
||||
Based on ComponentsPanel React migration (TASK-004B).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Foundation** - Create directory, types, basic component
|
||||
2. **Data Integration** - Connect to models, subscribe to events
|
||||
3. **UI Features** - Implement interactions (menus, selection, etc.)
|
||||
4. **Advanced Features** - Implement complex features (drag-drop, inline editing)
|
||||
5. **Polish** - Clean up, remove legacy files
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Model Subscription
|
||||
|
||||
Use `useEffect` with cleanup:
|
||||
|
||||
\`\`\`typescript
|
||||
useEffect(() => {
|
||||
const handler = () => setUpdateCounter(c => c + 1);
|
||||
Model.instance.on('event', handler);
|
||||
return () => Model.instance.off('event', handler);
|
||||
}, []);
|
||||
\`\`\`
|
||||
|
||||
### UndoQueue Integration
|
||||
|
||||
All mutations should use UndoQueue:
|
||||
|
||||
\`\`\`typescript
|
||||
UndoQueue.instance.pushAndDo(new UndoActionGroup({
|
||||
label: 'Action description',
|
||||
do: () => { /_ perform action _/ },
|
||||
undo: () => { /_ reverse action _/ }
|
||||
}));
|
||||
\`\`\`
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
[Add lessons from ComponentsPanel migration]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Final Testing
|
||||
|
||||
### 6.1 Comprehensive Testing Checklist
|
||||
|
||||
Test all features end-to-end:
|
||||
|
||||
#### Basic Functionality
|
||||
|
||||
- [ ] Panel appears in sidebar
|
||||
- [ ] Component tree renders correctly
|
||||
- [ ] Folders expand/collapse
|
||||
- [ ] Components can be selected
|
||||
- [ ] Selection opens in editor
|
||||
|
||||
#### Context Menus
|
||||
|
||||
- [ ] Header "+" menu works
|
||||
- [ ] Component context menu works
|
||||
- [ ] Folder context menu works
|
||||
- [ ] All menu actions work
|
||||
|
||||
#### Drag-Drop
|
||||
|
||||
- [ ] Can drag components
|
||||
- [ ] Can drag folders
|
||||
- [ ] Invalid drops prevented
|
||||
- [ ] Drops execute correctly
|
||||
- [ ] Undo works
|
||||
|
||||
#### Rename
|
||||
|
||||
- [ ] Double-click triggers rename
|
||||
- [ ] Inline input works
|
||||
- [ ] Validation works
|
||||
- [ ] Enter/Escape work correctly
|
||||
|
||||
#### Sheets
|
||||
|
||||
- [ ] Sheet tabs display
|
||||
- [ ] Sheet selection works
|
||||
- [ ] Tree filters by sheet
|
||||
|
||||
#### Undo/Redo
|
||||
|
||||
- [ ] All actions can be undone
|
||||
- [ ] All actions can be redone
|
||||
- [ ] Undo queue labels are clear
|
||||
|
||||
### 6.2 Edge Case Testing
|
||||
|
||||
- [ ] Empty project
|
||||
- [ ] Very large project (100+ components)
|
||||
- [ ] Deep nesting (10+ levels)
|
||||
- [ ] Special characters in names
|
||||
- [ ] Rapid clicking/operations
|
||||
- [ ] Browser back/forward buttons
|
||||
|
||||
### 6.3 Performance Testing
|
||||
|
||||
- [ ] Large tree renders quickly
|
||||
- [ ] Expand/collapse is smooth
|
||||
- [ ] Drag-drop is responsive
|
||||
- [ ] No memory leaks (check dev tools)
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Update Task Status
|
||||
|
||||
### 7.1 Update README
|
||||
|
||||
Mark task as complete in main README.
|
||||
|
||||
### 7.2 Update CHECKLIST
|
||||
|
||||
Check off all items in CHECKLIST.md.
|
||||
|
||||
### 7.3 Commit Changes
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat(editor): migrate ComponentsPanel to React
|
||||
|
||||
- Implement all 7 migration phases
|
||||
- Full feature parity with legacy implementation
|
||||
- Ready for TASK-004 badges/filters
|
||||
- Remove legacy jQuery-based ComponentsPanel
|
||||
|
||||
BREAKING CHANGE: ComponentsPanel now requires React"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ **Phase 7 is complete when:**
|
||||
|
||||
1. All UI polish is complete
|
||||
2. Code is clean (no TODOs, TSFixme, unused code)
|
||||
3. Legacy files are removed
|
||||
4. All imports are updated
|
||||
5. Documentation is updated
|
||||
6. All tests pass
|
||||
7. TASK-004 extension points are in place
|
||||
8. Ready for production use
|
||||
|
||||
---
|
||||
|
||||
## Final Checklist
|
||||
|
||||
- [ ] All styling uses design tokens
|
||||
- [ ] All functions have JSDoc comments
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] TypeScript compiles without errors
|
||||
- [ ] All manual tests pass
|
||||
- [ ] Legacy files removed
|
||||
- [ ] All imports updated
|
||||
- [ ] Documentation complete
|
||||
- [ ] Git commit made
|
||||
- [ ] Task marked complete
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
After completing this phase:
|
||||
|
||||
1. **TASK-004 Part 2** - Add migration status badges to components
|
||||
2. **TASK-004 Part 3** - Add filter dropdown to show/hide migrated components
|
||||
3. **Pattern Documentation** - Document patterns for future migrations
|
||||
4. **Team Review** - Share migration approach with team
|
||||
|
||||
Congratulations on completing the ComponentsPanel React migration! 🎉
|
||||
@@ -0,0 +1,227 @@
|
||||
# TASK-004B Implementation Phases
|
||||
|
||||
This directory contains detailed implementation guides for each phase of the ComponentsPanel React migration.
|
||||
|
||||
## Phase Overview
|
||||
|
||||
| Phase | Name | Time | Complexity | Status |
|
||||
| ----- | ----------------------------------------------- | ---- | ---------- | -------------- |
|
||||
| 1 | [Foundation](./PHASE-1-FOUNDATION.md) | 1-2h | Low | ✅ Ready |
|
||||
| 2 | [Tree Rendering](./PHASE-2-TREE-RENDERING.md) | 1-2h | Medium | 📝 In Progress |
|
||||
| 3 | [Context Menus](./PHASE-3-CONTEXT-MENUS.md) | 1h | Low | ⏳ Pending |
|
||||
| 4 | [Drag-Drop](./PHASE-4-DRAG-DROP.md) | 2h | High | ⏳ Pending |
|
||||
| 5 | [Inline Rename](./PHASE-5-INLINE-RENAME.md) | 1h | Medium | ⏳ Pending |
|
||||
| 6 | [Sheet Selector](./PHASE-6-SHEET-SELECTOR.md) | 30m | Low | ⏳ Pending |
|
||||
| 7 | [Polish & Cleanup](./PHASE-7-POLISH-CLEANUP.md) | 1h | Low | ⏳ Pending |
|
||||
|
||||
**Total Estimated Time:** 6-8 hours
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Sequential Implementation (Recommended)
|
||||
|
||||
Implement phases in order 1→7. Each phase builds on the previous:
|
||||
|
||||
- Phase 1 creates the foundation
|
||||
- Phase 2 adds data display
|
||||
- Phase 3 adds user interactions
|
||||
- Phase 4 adds drag-drop
|
||||
- Phase 5 adds inline editing
|
||||
- Phase 6 adds sheet switching
|
||||
- Phase 7 polishes and prepares for TASK-004
|
||||
|
||||
### Parallel Implementation (Advanced)
|
||||
|
||||
If working with multiple developers:
|
||||
|
||||
- **Developer A:** Phases 1, 2, 6 (core rendering)
|
||||
- **Developer B:** Phases 3, 5 (user interactions)
|
||||
- **Developer C:** Phase 4 (drag-drop)
|
||||
- **Developer D:** Phase 7 (polish)
|
||||
|
||||
Merge in order: 1 → 2 → 6 → 3 → 5 → 4 → 7
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Read [Phase 1: Foundation](./PHASE-1-FOUNDATION.md)
|
||||
2. Implement and test Phase 1
|
||||
3. Verify all Phase 1 success criteria
|
||||
4. Move to next phase
|
||||
5. Repeat until complete
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### After Each Phase
|
||||
|
||||
- Run `npm run dev`
|
||||
- Manually test new features
|
||||
- Check console for errors
|
||||
- Verify TypeScript compiles
|
||||
|
||||
### Integration Testing
|
||||
|
||||
After Phase 7, test:
|
||||
|
||||
- All context menu actions
|
||||
- Drag-drop all scenarios
|
||||
- Rename validation
|
||||
- Sheet switching
|
||||
- Selection persistence
|
||||
- Undo/redo for all operations
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### ProjectModel Integration
|
||||
|
||||
```typescript
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
// Subscribe to events
|
||||
useEffect(() => {
|
||||
const handleComponentAdded = (args) => {
|
||||
// Handle addition
|
||||
};
|
||||
|
||||
ProjectModel.instance.on('componentAdded', handleComponentAdded);
|
||||
|
||||
return () => {
|
||||
ProjectModel.instance.off('componentAdded', handleComponentAdded);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### UndoQueue Pattern
|
||||
|
||||
```typescript
|
||||
import { UndoQueue, UndoActionGroup } from '@noodl-models/undo-queue-model';
|
||||
|
||||
UndoQueue.instance.pushAndDo(
|
||||
new UndoActionGroup({
|
||||
label: 'action description',
|
||||
do: () => {
|
||||
// Perform action
|
||||
},
|
||||
undo: () => {
|
||||
// Reverse action
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### PopupMenu Pattern
|
||||
|
||||
```typescript
|
||||
import PopupLayer from '@noodl-views/popuplayer';
|
||||
|
||||
const menu = new PopupLayer.PopupMenu({
|
||||
items: [
|
||||
{
|
||||
icon: IconName.Plus,
|
||||
label: 'Add Component',
|
||||
onClick: () => {
|
||||
/* handler */
|
||||
}
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
icon: IconName.Trash,
|
||||
label: 'Delete',
|
||||
onClick: () => {
|
||||
/* handler */
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
PopupLayer.instance.showPopup({
|
||||
content: menu,
|
||||
attachTo: buttonElement,
|
||||
position: 'bottom'
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Phase 1 Issues
|
||||
|
||||
- **Panel doesn't appear:** Check SidebarModel registration
|
||||
- **Styles not loading:** Verify webpack CSS module config
|
||||
- **TypeScript errors:** Check @noodl-models imports
|
||||
|
||||
### Phase 2 Issues
|
||||
|
||||
- **Tree not updating:** Verify ProjectModel event subscriptions
|
||||
- **Wrong components shown:** Check sheet filtering logic
|
||||
- **Selection not working:** Verify NodeGraphEditor integration
|
||||
|
||||
### Phase 3 Issues
|
||||
|
||||
- **Menu doesn't show:** Check PopupLayer z-index
|
||||
- **Actions fail:** Verify UndoQueue integration
|
||||
- **Icons missing:** Check IconName imports
|
||||
|
||||
### Phase 4 Issues
|
||||
|
||||
- **Drag not starting:** Verify PopupLayer.startDragging call
|
||||
- **Drop validation wrong:** Check getAcceptableDropType logic
|
||||
- **Undo broken:** Verify undo action includes all state changes
|
||||
|
||||
### Phase 5 Issues
|
||||
|
||||
- **Rename input not appearing:** Check CSS positioning
|
||||
- **Name validation failing:** Verify ProjectModel.getComponentWithName
|
||||
- **Focus lost:** Ensure input autoFocus and blur handlers
|
||||
|
||||
### Phase 6 Issues
|
||||
|
||||
- **Sheets not filtering:** Check currentSheet state
|
||||
- **Hidden sheets appear:** Verify hideSheets prop filtering
|
||||
|
||||
### Phase 7 Issues
|
||||
|
||||
- **Old panel still showing:** Remove old require() in router.setup.ts
|
||||
- **Tests failing:** Update test imports to new location
|
||||
|
||||
## Resources
|
||||
|
||||
### Legacy Code References
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanel.ts`
|
||||
- `packages/noodl-editor/src/editor/src/templates/componentspanel.html`
|
||||
- `packages/noodl-editor/src/editor/src/styles/componentspanel.css`
|
||||
|
||||
### React Panel Examples
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/SearchPanel/`
|
||||
- `packages/noodl-editor/src/editor/src/views/VersionControlPanel/`
|
||||
|
||||
### Documentation
|
||||
|
||||
- `packages/noodl-editor/docs/sidebar.md` - Sidebar panel registration
|
||||
- `dev-docs/reference/UI-STYLING-GUIDE.md` - Styling guidelines
|
||||
- `dev-docs/guidelines/CODING-STANDARDS.md` - Code standards
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The migration is complete when:
|
||||
|
||||
- ✅ All 7 phases implemented
|
||||
- ✅ All existing functionality works
|
||||
- ✅ No console errors
|
||||
- ✅ TypeScript compiles without errors
|
||||
- ✅ All tests pass
|
||||
- ✅ Legacy files removed
|
||||
- ✅ Ready for TASK-004 badges/filters
|
||||
|
||||
## Next Steps After Completion
|
||||
|
||||
Once all phases are complete:
|
||||
|
||||
1. **TASK-004 Part 2:** Add migration status badges
|
||||
2. **TASK-004 Part 3:** Add filter system
|
||||
3. **Documentation:** Update migration learnings
|
||||
4. **Pattern Sharing:** Use as template for other panel migrations
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Check the individual phase documents or refer to the main [README.md](../README.md).
|
||||
Reference in New Issue
Block a user