Working on the editor component tree

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

View File

@@ -100,26 +100,27 @@ class Button extends React.Component {
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
// 2. Internal packages (alphabetical by alias)
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { NodeGraphModel } from '@noodl-models/nodegraphmodel';
import { KeyCode } from '@noodl-utils/keyboard/KeyCode';
// 2. Internal packages (alphabetical by alias)
import { IconName } from '@noodl-core-ui/components/common/Icon';
import css from './Component.module.scss';
// 3. Relative imports (by depth, then alphabetical)
import { localHelper } from './helpers';
import css from './Component.module.scss';
```
### 2.4 Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Components | PascalCase | `NodeEditor.tsx` |
| Hooks | camelCase, use prefix | `useNodeSelection.ts` |
| Utils | camelCase | `formatNodeName.ts` |
| Constants | UPPER_SNAKE | `MAX_CONNECTIONS` |
| CSS Modules | kebab-case | `node-editor.module.scss` |
| Test files | Same + .test | `NodeEditor.test.tsx` |
| Type | Convention | Example |
| ----------- | --------------------- | ------------------------- |
| Components | PascalCase | `NodeEditor.tsx` |
| Hooks | camelCase, use prefix | `useNodeSelection.ts` |
| Utils | camelCase | `formatNodeName.ts` |
| Constants | UPPER_SNAKE | `MAX_CONNECTIONS` |
| CSS Modules | kebab-case | `node-editor.module.scss` |
| Test files | Same + .test | `NodeEditor.test.tsx` |
---
@@ -132,10 +133,10 @@ Every new file should have a header comment:
```typescript
/**
* NodeProcessor
*
*
* Handles the processing of node graph updates and manages
* the execution order of connected nodes.
*
*
* @module noodl-runtime
* @since 1.2.0
*/
@@ -143,31 +144,27 @@ Every new file should have a header comment:
### 3.2 Function Documentation
```typescript
````typescript
/**
* Processes a node and propagates changes to connected nodes.
*
*
* @param node - The node to process
* @param context - The execution context
* @param options - Processing options
* @param options.force - Force re-evaluation even if inputs unchanged
* @returns The processed output values
* @throws {NodeProcessingError} If the node definition is invalid
*
*
* @example
* ```typescript
* const output = processNode(myNode, context, { force: true });
* console.log(output.value);
* ```
*/
function processNode(
node: NodeInstance,
context: ExecutionContext,
options: ProcessOptions = {}
): NodeOutput {
function processNode(node: NodeInstance, context: ExecutionContext, options: ProcessOptions = {}): NodeOutput {
// ...
}
```
````
### 3.3 Complex Logic Comments
@@ -211,28 +208,28 @@ import { renderHook, act } from '@testing-library/react-hooks';
describe('useNodeSelection', () => {
// Setup
let mockContext: NodeGraphContext;
beforeEach(() => {
mockContext = createMockContext();
});
// Group related tests
describe('when selecting a single node', () => {
it('should update selection state', () => {
const { result } = renderHook(() => useNodeSelection(mockContext));
act(() => {
result.current.selectNode('node-1');
});
expect(result.current.selectedNodes).toContain('node-1');
});
it('should clear previous selection by default', () => {
// ...
});
});
describe('when multi-selecting nodes', () => {
// ...
});
@@ -241,14 +238,14 @@ describe('useNodeSelection', () => {
### 4.3 What to Test
| Priority | What to Test |
|----------|--------------|
| High | Utility functions |
| High | Data transformations |
| High | State management logic |
| Medium | React hooks |
| Medium | Component behavior |
| Low | Pure UI rendering |
| Priority | What to Test |
| -------- | ---------------------- |
| High | Utility functions |
| High | Data transformations |
| High | State management logic |
| Medium | React hooks |
| Medium | Component behavior |
| Low | Pure UI rendering |
---
@@ -392,13 +389,13 @@ model.on('updated', (data) => {
// Custom hook for model subscription
function useModel<T>(model: EventDispatcher, event: string): T {
const [state, setState] = useState<T>(model.getState());
useEffect(() => {
const handler = (newState: T) => setState(newState);
model.on(event, handler);
return () => model.off(event, handler);
}, [model, event]);
return state;
}
```
@@ -411,7 +408,7 @@ const MyNode = {
name: 'My.Custom.Node',
displayName: 'My Custom Node',
category: 'Custom',
inputs: {
inputValue: {
type: 'string',
@@ -419,21 +416,21 @@ const MyNode = {
default: ''
}
},
outputs: {
outputValue: {
type: 'string',
displayName: 'Output Value'
}
},
methods: {
setInputValue(value) {
this._internal.inputValue = value;
this.flagOutputDirty('outputValue');
}
},
getOutputValue(name) {
if (name === 'outputValue') {
return this._internal.inputValue.toUpperCase();
@@ -456,7 +453,7 @@ try {
} catch (error) {
// Log for debugging
console.error('Operation failed:', error);
// Show user-friendly message
ToastLayer.showError('Unable to complete operation. Please try again.');
}
@@ -470,7 +467,7 @@ function processNode(node: NodeInstance) {
if (!node.id) {
throw new Error(`processNode: node.id is required`);
}
if (!node.definition) {
throw new Error(`processNode: node "${node.id}" has no definition`);
}
@@ -508,7 +505,7 @@ const sortedNodes = useMemo(() => {
}, [nodes]);
// ❌ BAD: New function on every render
<Button onClick={() => onNodeSelect(node.id)} />
<Button onClick={() => onNodeSelect(node.id)} />;
```
### 9.2 Lazy Loading
@@ -544,6 +541,7 @@ unstable_batchedUpdates(() => {
## 10. Checklist Before Submitting
### Code Quality
- [ ] No `TSFixme` types added
- [ ] All new functions have JSDoc comments
- [ ] Complex logic has inline comments
@@ -551,23 +549,27 @@ unstable_batchedUpdates(() => {
- [ ] No unused imports or variables
### Testing
- [ ] Unit tests for new utility functions
- [ ] Integration tests for new features
- [ ] Existing tests still pass
- [ ] Manual testing completed
### Documentation
- [ ] README updated if needed
- [ ] JSDoc added to public APIs
- [ ] Comments explain "why", not "what"
### Git
- [ ] Meaningful commit messages
- [ ] No unrelated changes in commits
- [ ] Branch named correctly
- [ ] Based on latest main branch
### Performance
- [ ] No obvious performance regressions
- [ ] Large lists use virtualization
- [ ] Expensive computations are memoized
@@ -600,19 +602,20 @@ grep -r "any" packages/ --include="*.ts" | head -20
dev-docs/
├── reference/
├── CODEBASE-MAP.md # OpenNoodl Codebase Quick Navigation
├── COMMON-ISSUES.md # Solutions to frequently encountered problems when developing OpenNoodl.
├── NODE-PATTERNS.md # How to create and modify nodes in OpenNoodl.
│ ├── CODEBASE-MAP.md # OpenNoodl Codebase Quick Navigation
│ ├── COMMON-ISSUES.md # Solutions to frequently encountered problems when developing OpenNoodl.
│ ├── NODE-PATTERNS.md # How to create and modify nodes in OpenNoodl.
├── guidelines/
├── CODING-STANDARDS.md # This document defines the coding style and patterns for OpenNoodl development.
├── GIT-WORKFLOW.md # How to manage branches, commits, and pull requests for OpenNoodl development.
├── TASK-TEMPLATE.md # Use this template to create new task documentation. Copy the entire `TASK-XXX-template/` folder and rename it.
│ ├── CODING-STANDARDS.md # This document defines the coding style and patterns for OpenNoodl development.
│ ├── GIT-WORKFLOW.md # How to manage branches, commits, and pull requests for OpenNoodl development.
├── TASK-TEMPLATE.md # Use this template to create new task documentation. Copy the entire `TASK-XXX-template/` folder and rename it.
## 12. Institutional Learning
### Discovering & Recording Knowledge
As you work through tasks in this large codebase, you WILL discover things that aren't documented:
- Why something was built a certain way
- Hidden gotchas or edge cases
- Patterns that aren't obvious
@@ -624,6 +627,7 @@ As you work through tasks in this large codebase, you WILL discover things that
Add discoveries to: `dev-docs/reference/LEARNINGS.md`
Format each entry:
```
### [Date] - [Brief Title]
@@ -634,6 +638,7 @@ Format each entry:
```
Examples of things worth recording:
- "The `scheduleAfterInputsHaveUpdated` pattern is required when multiple inputs might change in the same frame"
- "RouterAdapter.ts secretly depends on component naming conventions - pages must be in folders"
- "React 19 automatic batching breaks the old `forceUpdate` pattern in nodegrapheditor"
@@ -648,6 +653,7 @@ Examples of things worth recording:
3. Check if someone already solved this problem
**When hitting a confusing error:**
1. Search LEARNINGS.md for the error message or related terms
2. Check `dev-docs/reference/COMMON-ISSUES.md`
3. If you solve it and it's not documented, ADD IT
@@ -655,6 +661,7 @@ Examples of things worth recording:
### What Makes Good Learnings
✅ **Worth recording:**
- Non-obvious behavior ("X only works if Y is true")
- Error solutions that took time to figure out
- Undocumented dependencies between systems
@@ -662,6 +669,7 @@ Examples of things worth recording:
- Patterns you had to reverse-engineer
❌ **Not worth recording:**
- Basic TypeScript/React knowledge
- Things already in official docs
- One-off typos or simple mistakes
@@ -670,6 +678,7 @@ Examples of things worth recording:
### Building the Knowledge Base
Over time, LEARNINGS.md may grow large. When it does:
- Group related entries under headings
- Move mature topics to dedicated docs (e.g., `LEARNINGS.md` entry about data nodes → `DATA-SYSTEM-DEEP-DIVE.md`)
- Cross-reference from COMMON-ISSUES.md
@@ -678,4 +687,58 @@ The goal: **No one should have to solve the same puzzle twice.**
---
*Last Updated: December 2024*
---
## 13. UI Styling Rules
> **CRITICAL:** Before any UI/CSS work, read `dev-docs/reference/UI-STYLING-GUIDE.md`
### 13.1 Never Use Hardcoded Colors
```scss
// ❌ BAD - copying legacy patterns
.Card {
background-color: #27272a;
color: #b8b8b8;
}
// ✅ GOOD - using design tokens
.Card {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
}
```
### 13.2 Quick Token Reference
| Purpose | Token |
| ----------------- | ------------------------------ |
| Panel backgrounds | `--theme-color-bg-2` |
| Card backgrounds | `--theme-color-bg-3` |
| Normal text | `--theme-color-fg-default` |
| Secondary text | `--theme-color-fg-default-shy` |
| Emphasized text | `--theme-color-fg-highlight` |
| Primary buttons | `--theme-color-primary` |
| Borders | `--theme-color-border-default` |
### 13.3 Legacy Files Warning
DO NOT copy patterns from these files (they have hardcoded colors):
- `packages/noodl-editor/src/editor/src/styles/popuplayer.css`
- `packages/noodl-editor/src/editor/src/styles/propertyeditor.css`
DO reference these files (they use proper patterns):
- `packages/noodl-core-ui/src/components/layout/BaseDialog/`
- `packages/noodl-core-ui/src/components/inputs/PrimaryButton/`
### 13.4 Before Completing UI Tasks
Verify:
- [ ] No hardcoded hex colors (`grep -E '#[0-9a-fA-F]{3,6}' your-file.scss`)
- [ ] All colors use `var(--theme-color-*)` tokens
- [ ] Hover/focus/disabled states defined
_Last Updated: December 2025_

View File

@@ -1,724 +1,148 @@
# OpenNoodl Development Learnings
# Project Learnings
This document records discoveries, gotchas, and non-obvious patterns found while working on OpenNoodl. Search this file before tackling complex problems.
This document captures important discoveries and gotchas encountered during OpenNoodl development.
---
## React Hooks & EventDispatcher Integration (Dec 2025)
## Project Migration & Versioning
### Problem: EventDispatcher Events Not Reaching React Hooks
### [2025-07-12] - Legacy Projects Are Already at Version 4
**Context**: During TASK-004B (ComponentsPanel React migration), discovered that `componentRenamed` events from ProjectModel weren't triggering UI updates in React components.
**Context**: Investigating what migration work is needed for legacy Noodl v1.1.0 projects.
**Root Cause**: Array reference instability causing useEffect to constantly re-subscribe/unsubscribe.
**Discovery**: Legacy projects from Noodl v1.1.0 are already at project format version "4", which is the current version expected by the editor. This significantly reduces migration scope.
**Discovery**:
**Location**:
- `packages/noodl-editor/src/editor/src/models/projectmodel.ts` - Contains `Upgraders` object for format 0→1→2→3→4
- `packages/noodl-editor/src/editor/src/models/ProjectPatches/` - Node-level patches (e.g., `RouterNavigate`)
**Key Points**:
- Project format version is stored in `project.json` as `"version": "4"`
- The existing `ProjectPatches/` system handles node-level migrations automatically on load
- No major version migration infrastructure is needed for v1.1.0→v2.0.0
- The `Upgraders` object has handlers for versions 0-4, upgrading sequentially
**Keywords**: project migration, version upgrade, legacy project, project.json, upgraders
---
### [2025-07-12] - @noodl/platform FileInfo Interface
**Context**: Writing utility functions that use `filesystem.listDirectory()`.
**Discovery**: The `listDirectory()` function returns `FileInfo[]`, not strings. Each FileInfo has:
- `name: string` - Just the filename
- `fullPath: string` - Complete path
- `isDirectory: boolean`
**Location**: `packages/noodl-platform/src/filesystem/IFilesystem.ts`
**Keywords**: filesystem, listDirectory, FileInfo, platform API
---
## Webpack DevServer & Electron
### [2025-08-12] - Webpack devServer `onListening` vs `compiler.hooks.done` Timing
**Context**: Debugging why `npm run dev` showed a black Electron window, took ages to load, and caused high CPU usage.
**Discovery**: The webpack dev configuration used `devServer.onListening()` to start Electron. This hook fires when the HTTP server port opens, NOT when webpack finishes compiling. This is a race condition:
1. `npm run dev` starts webpack-dev-server
2. Server starts listening on port 8080 → `onListening` fires
3. Electron launches and loads `http://localhost:8080/src/editor/index.bundle.js`
4. But webpack is still compiling! Bundle doesn't exist yet
5. Black screen + high CPU until compilation finishes
**Fix**: Use `devServer.compiler.hooks.done.tap()` inside `onListening` to wait for the first successful compilation before spawning Electron:
```javascript
onListening(devServer) {
devServer.compiler.hooks.done.tap('StartElectron', (stats) => {
if (!electronStarted && !stats.hasErrors()) {
electronStarted = true;
child_process.spawn('npm', ['run', 'start:_dev'], ...);
}
});
}
```
**Why It Became Noticeable**: This was a latent bug that existed from initial commit. It became visible after the Storybook 8 migration added ~91 files to process, increasing compilation time enough to consistently "lose" the race.
**Location**: `packages/noodl-editor/webpackconfigs/webpack.renderer.dev.js`
**Keywords**: webpack, devServer, onListening, electron, black screen, compilation, hooks.done, race condition, slow startup
---
### [2025-08-12] - Webpack devtool Settings Impact on Compilation Speed
**Context**: Investigating slow development startup.
**Discovery**: The `devtool: 'eval-source-map'` setting provides the most accurate sourcemaps but is very slow for large codebases. Using `'eval-cheap-module-source-map'` is significantly faster while still providing usable debugging:
| devtool | Rebuild Speed | Quality |
|---------|---------------|---------|
| `eval` | +++++ | Poor |
| `eval-cheap-source-map` | ++++ | OK |
| `eval-cheap-module-source-map` | +++ | Good |
| `eval-source-map` | + | Best |
For development where fast iteration matters more than perfect column accuracy in stack traces, `eval-cheap-module-source-map` is a good balance.
**Location**: `packages/noodl-editor/webpackconfigs/webpack.renderer.dev.js`
**Keywords**: webpack, devtool, sourcemap, performance, compilation speed, development
---
### [2025-08-12] - TypeScript Path Resolution Requires baseUrl in Child tsconfig
**Context**: Build was failing with "Cannot find module '@noodl-hooks/...' or '@noodl-core-ui/...'" errors despite webpack aliases being correctly configured.
**Discovery**: When a child tsconfig.json extends a parent and overrides the `paths` property, the paths become relative to the child's directory. However, if `baseUrl` is not explicitly set in the child, path resolution fails.
The noodl-editor's tsconfig.json had:
```json
{
"extends": "../../tsconfig.json",
"paths": {
"@noodl-core-ui/*": ["../noodl-core-ui/src/*"],
// ... other paths relative to packages/noodl-editor/
}
}
```
Without `baseUrl: "."` in the child, TypeScript couldn't resolve the relative paths correctly.
**Fix**: Always set `baseUrl` explicitly when overriding `paths` in a child tsconfig:
```json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": { ... }
}
}
```
**Location**: `packages/noodl-editor/tsconfig.json`
**Keywords**: typescript, tsconfig, paths, baseUrl, module resolution, extends, cannot find module
---
### [2025-08-12] - @ai-sdk Packages Require Zod v4 for zod/v4 Import
**Context**: After fixing webpack timing, Electron showed black screen. DevTools console showed: "Cannot find module 'zod/v4/index.cjs'"
**Discovery**: The `@ai-sdk/provider-utils`, `@ai-sdk/gateway`, and `ai` packages import from `zod/v4`. Zod version 3.25.x only has `v4-mini` folder (a transitional export), not the full `v4` folder. Only Zod 4.x has the proper `v4` subpath export.
The error chain was:
1. `ai` package loads on startup
2. It tries to `require('zod/v4')`
3. Zod 3.25.76 doesn't have `/v4` export → crash
4. Black screen because editor fails to initialize
**Fix**: Upgrade to Zod 4.x by adding it as a direct dependency in root `package.json`:
```json
"dependencies": {
"zod": "^4.1.0"
}
```
Using `overrides` for this case can conflict with other version specifications. A direct dependency with a semver range works cleanly in npm workspaces.
**Location**: Root `package.json`, affects all packages using AI SDK
**Keywords**: zod, zod/v4, @ai-sdk, ai, black screen, cannot find module, module resolution
---
## React 18/19 Migration Patterns
### [2025-12-08] - React 18+ Removed ReactDOM.render() and unmountComponentAtNode()
**Context**: After React 19 migration, node graph editor was completely broken - right-click showed grab hand instead of node picker, couldn't click nodes or drag wires.
**Discovery**: React 18 removed the legacy `ReactDOM.render()` and `ReactDOM.unmountComponentAtNode()` APIs. Code using these APIs throws errors like:
- `ReactDOM.render is not a function`
- `ReactDOM.unmountComponentAtNode is not a function`
The migration pattern is:
```javascript
// Before (React 17):
import ReactDOM from 'react-dom';
ReactDOM.render(<Component />, container);
ReactDOM.unmountComponentAtNode(container);
// After (React 18+):
import { createRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<Component />);
root.unmount();
```
**Important**: If rendering multiple times to the same container, you must:
1. Create the root only ONCE
2. Store the root reference
3. Call `root.render()` for subsequent updates
4. Call `root.unmount()` when disposing
Creating `createRoot()` on every render causes: "You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before."
**Location**:
- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.debuginspectors.js`
- `packages/noodl-editor/src/editor/src/views/commentlayer.ts`
- `packages/noodl-editor/src/editor/src/views/TextStylePicker/TextStylePicker.jsx`
**Keywords**: ReactDOM.render, createRoot, unmountComponentAtNode, React 18, React 19, migration, root.unmount
---
### [2025-12-08] - React 18+ createRoot() Renders Asynchronously
**Context**: After migrating to React 18+ createRoot, the NodePicker popup appeared offset to the bottom-right corner instead of centered.
**Discovery**: Unlike the old synchronous `ReactDOM.render()`, React 18's `createRoot().render()` is asynchronous. If code measures DOM dimensions immediately after calling `render()`, the React component hasn't painted yet.
In PopupLayer.showPopup():
```javascript
this.$('.popup-layer-popup-content').append(content);
var contentWidth = content.outerWidth(true); // Returns 0!
var contentHeight = content.outerHeight(true); // Returns 0!
```
When dimensions are zero, the centering calculation `x = this.width / 2 - 0 / 2` places the popup at the far right.
**Fix Options**:
1. **Set explicit dimensions** on the container div before React renders (recommended for fixed-size components)
2. Use `requestAnimationFrame` or `setTimeout` before measuring
3. Use a ResizeObserver to detect when content renders
For NodePicker (which has fixed 800x600 dimensions in CSS), the simplest fix was setting dimensions on the container div before React renders:
```javascript
render() {
const div = document.createElement('div');
div.style.width = '800px';
div.style.height = '600px';
this.renderReact(div); // createRoot is async
return this.el;
}
```
**Location**: `packages/noodl-editor/src/editor/src/views/createnewnodepanel.ts`
**Keywords**: createRoot, async render, dimensions, outerWidth, outerHeight, popup positioning, React 18, React 19
---
## Electron & Node.js Patterns
### [2025-12-14] - EPIPE Errors When Writing to stdout
**Context**: Editor was crashing with `Error: write EPIPE` when trying to open projects.
**Discovery**: EPIPE errors occur when a process tries to write to stdout/stderr but the receiving pipe has been closed (e.g., the terminal or parent process that spawned the subprocess is gone). In Electron apps, this happens when:
- The terminal that started `npm run dev` is closed before the app
- The parent process that spawned a child dies unexpectedly
- stdout is redirected to a file that gets closed
Cloud-function-server.js was calling `console.log()` during project operations. When the stdout pipe was broken, the error bubbled up and crashed the editor.
**Fix**: Wrap console.log calls in a try-catch:
```javascript
function safeLog(...args) {
try {
console.log(...args);
} catch (e) {
// Ignore EPIPE errors - stdout pipe may be broken
}
}
```
**Location**: `packages/noodl-editor/src/main/src/cloud-function-server.js`
**Keywords**: EPIPE, console.log, stdout, broken pipe, electron, subprocess, crash
---
## Webpack & Build Patterns
### [2025-12-14] - Webpack SCSS Cache Can Persist Old Files
**Context**: MigrationWizard.module.scss was fixed on disk but webpack kept showing errors for a removed import line.
**Discovery**: Webpack's sass-loader caches compiled SCSS files aggressively. Even after fixing a file on disk, if an old error is cached, webpack may continue to report the stale error. This is especially confusing because:
- `cat` and `grep` show the correct file contents
- But webpack reports errors for lines that no longer exist
- The webpack process may be from a previous session that cached the old content
**Fix Steps**:
1. Kill ALL webpack processes: `pkill -9 -f webpack`
2. Clear webpack cache: `rm -rf node_modules/.cache/` in the affected package
3. Touch the file to force rebuild: `touch path/to/file.scss`
4. Restart dev server fresh
**Location**: Any SCSS file processed by sass-loader
**Keywords**: webpack, sass-loader, cache, SCSS, stale error, module build failed
---
## Event-Driven UI Patterns
### [2025-12-14] - Async Detection Requires Re-render Listener
**Context**: Migration UI badges weren't showing on legacy projects even though runtime detection was working.
**Discovery**: In OpenNoodl's jQuery-based View system, the template is rendered once when `render()` is called. If data is populated asynchronously (e.g., runtime detection), the UI won't update unless you explicitly listen for a completion event and re-render.
The pattern:
1. `renderProjectItems()` is called - projects show without runtime info
2. `detectAllProjectRuntimes()` runs async in background
3. Detection completes, `runtimeDetectionComplete` event fires
4. BUT... no one was listening → UI stays stale
**Fix**: Subscribe to the async completion event in the View:
```javascript
this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);
```
This pattern applies to any async data in the jQuery View system:
- Runtime detection
- Cloud service status
- Git remote checks
- etc.
**Location**: `packages/noodl-editor/src/editor/src/views/projectsview.ts`
**Keywords**: async, re-render, event listener, runtimeDetectionComplete, jQuery View, stale UI
---
## CSS & Styling Patterns
### [2025-12-14] - BaseDialog `::after` Pseudo-Element Blocks Clicks
**Context**: Migration wizard popup buttons weren't clickable at all - no response to any interaction.
**Discovery**: The BaseDialog component uses a `::after` pseudo-element on `.VisibleDialog` to render the background color. This pseudo covers the entire dialog area:
```scss
.VisibleDialog {
&::after {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--background);
// Without pointer-events: none, this blocks all clicks!
}
}
```
The `.ChildContainer` has `z-index: 1` which should put it above the `::after`, but due to stacking context behavior with `filter: drop-shadow()` on the parent, clicks were being intercepted by the pseudo-element.
**Fix**: Add `pointer-events: none` to the `::after` pseudo-element:
```scss
&::after {
// ...existing styles...
pointer-events: none; // Allow clicks to pass through
}
```
**Location**: `packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss`
**Keywords**: BaseDialog, ::after, pointer-events, click not working, buttons broken, Modal, dialog
---
### [2025-12-14] - Theme Color Variables Are `--theme-color-*` Not `--color-*`
**Context**: Migration wizard UI appeared gray-on-gray with unreadable text.
**Discovery**: OpenNoodl's theme system uses CSS variables prefixed with `--theme-color-*`, NOT `--color-*`. Using undefined variables like `--color-grey-800` results in invalid/empty values causing display issues.
**Correct Variables:**
| Wrong | Correct |
|-------|---------|
| `--color-grey-800` | `--theme-color-bg-3` |
| `--color-grey-700` | `--theme-color-bg-2` |
| `--color-grey-400`, `--color-grey-300` | `--theme-color-secondary-as-fg` (for text!) |
| `--color-grey-200`, `--color-grey-100` | `--theme-color-fg-highlight` |
| `--color-primary` | `--theme-color-primary` |
| `--color-success-500` | `--theme-color-success` |
| `--color-warning` | `--theme-color-warning` |
| `--color-danger` | `--theme-color-danger` |
**Location**: Any SCSS module files in `@noodl-core-ui` or `noodl-editor`
**Keywords**: CSS variables, theme-color, --color, --theme-color, gray text, contrast, undefined variable, SCSS
---
### [2025-12-14] - `--theme-color-secondary` Is NOT For Text - Use `--theme-color-secondary-as-fg`
**Context**: Migration wizard text was impossible to read even after using `--theme-color-*` prefix.
**Discovery**: Two commonly misused theme variables cause text to be unreadable:
1. **`--theme-color-fg-1` doesn't exist!** The correct variable is:
- `--theme-color-fg-highlight` = `#f5f5f5` (white/light text)
- `--theme-color-fg-default` = `#b8b8b8` (normal text)
- `--theme-color-fg-default-shy` = `#9a9999` (subtle text)
- `--theme-color-fg-muted` = `#7e7d7d` (muted text)
2. **`--theme-color-secondary` is a BACKGROUND color!**
- `--theme-color-secondary` = `#005769` (dark teal - use for backgrounds only!)
- `--theme-color-secondary-as-fg` = `#7ec2cf` (light teal - use for text!)
When text appears invisible/gray, check for these common mistakes:
```scss
// WRONG - produces invisible text
color: var(--theme-color-fg-1); // Variable doesn't exist!
color: var(--theme-color-secondary); // Dark teal background color!
// CORRECT - visible text
color: var(--theme-color-fg-highlight); // White text
color: var(--theme-color-secondary-as-fg); // Light teal text
```
**Color Reference from `colors.css`:**
```css
--theme-color-bg-1: #151414; /* Darkest background */
--theme-color-bg-2: #292828;
--theme-color-bg-3: #3c3c3c;
--theme-color-bg-4: #504f4f; /* Lightest background */
--theme-color-fg-highlight: #f5f5f5; /* Bright white text */
--theme-color-fg-default-contrast: #d4d4d4; /* High contrast text */
--theme-color-fg-default: #b8b8b8; /* Normal text */
--theme-color-fg-default-shy: #9a9999; /* Subtle text */
--theme-color-fg-muted: #7e7d7d; /* Muted text */
--theme-color-secondary: #005769; /* BACKGROUND only! */
--theme-color-secondary-as-fg: #7ec2cf; /* For text */
```
**Location**: `packages/noodl-core-ui/src/styles/custom-properties/colors.css`
**Keywords**: --theme-color-fg-1, --theme-color-secondary, invisible text, gray on gray, secondary-as-fg, text color, theme variables
---
### [2025-12-14] - Flex Container Scrolling Requires `min-height: 0`
**Context**: Migration wizard content wasn't scrollable on shorter screens.
**Discovery**: When using flexbox with `overflow: auto` on a child, the child needs `min-height: 0` (or `min-width: 0` for horizontal) to allow it to shrink below its content size. Without this, the default `min-height: auto` prevents shrinking and breaks scrolling.
**Pattern:**
```scss
.Parent {
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: hidden;
}
.ScrollableChild {
flex: 1;
min-height: 0; // Critical! Allows shrinking
overflow-y: auto;
}
```
The `min-height: 0` overrides the default `min-height: auto` which would prevent the element from being smaller than its content.
**Location**: Any scrollable flex container, e.g., `MigrationWizard.module.scss`
**Keywords**: flex, overflow, scroll, min-height, flex-shrink, not scrolling, content cut off
---
### [2025-12-14] - useReducer State Must Be Initialized Before Actions Work
**Context**: Migration wizard "Start Migration" button did nothing - no errors, no state change, no visual feedback.
**Discovery**: When using `useReducer` to manage component state, all action handlers typically guard against null state:
```typescript
case 'START_SCAN':
if (!state.session) return state; // Does nothing if session is null!
return { ...state, session: { ...state.session, step: 'scanning' } };
// ❌ BAD - Creates new array on every render
useEventListener(
ProjectModel.instance,
['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'],
callback
);
// ✅ GOOD - Stable reference prevents re-subscription
const PROJECT_EVENTS = ['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'];
useEventListener(ProjectModel.instance, PROJECT_EVENTS, callback);
```
The bug pattern:
1. Component initializes with `session: null` in reducer state
2. External manager (`migrationSessionManager`) creates and stores the session
3. UI renders using `manager.getSession()` - works fine
4. Button click dispatches action to reducer
5. Reducer checks `if (!state.session)` → returns unchanged state
6. Nothing happens - no errors, no visual change
**Location**:
The fix is to dispatch a `SET_SESSION` action to initialize the reducer state:
```typescript
// In useEffect after creating session:
const session = await manager.createSession(...);
dispatch({ type: 'SET_SESSION', session }); // Initialize reducer!
- `packages/noodl-editor/src/editor/src/hooks/useEventListener.ts`
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts`
// In reducer:
case 'SET_SESSION':
return { ...state, session: action.session };
```
**Key Insight**: If using both an external manager AND useReducer, the reducer state must be explicitly synchronized with the manager's state for actions to work.
**Location**: `packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx`
**Keywords**: useReducer, dispatch, null state, button does nothing, state not updating, SET_SESSION, state synchronization
**Keywords**: EventDispatcher, React hooks, useEffect, event subscription, array reference, re-render
---
### [2025-12-14] - CoreBaseDialog vs Modal Component Patterns
## Hot Reload Issues with React Hooks (Dec 2025)
**Context**: Migration wizard popup wasn't working - clicks blocked, layout broken.
**Context**: Code changes to React hooks not taking effect despite webpack hot reload.
**Discovery**: OpenNoodl has two dialog patterns:
**Discovery**: React hooks sometimes require a **hard browser refresh** or **dev server restart** to pick up changes, especially:
1. **CoreBaseDialog** (Working, Recommended):
- Direct component from `@noodl-core-ui/components/layout/BaseDialog`
- Used by ConfirmDialog and other working dialogs
- Props: `isVisible`, `hasBackdrop`, `onClose`
- Content is passed as children
- Changes to `useEffect` dependencies
- Changes to custom hooks
- Changes to event subscription logic
2. **Modal** (Problematic):
- Wrapper component with additional complexity
- Was causing issues with click handling and layout
**Solution**:
When creating new dialogs, use the CoreBaseDialog pattern:
```tsx
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
1. Try hard refresh first: `Cmd+Shift+R` (Mac) or `Ctrl+Shift+R` (Windows)
2. If that fails, restart dev server: Stop (Ctrl+C) and `npm run dev`
3. Clear browser cache if issues persist
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
<div className={css['YourContainer']}>
{/* Your content */}
</div>
</CoreBaseDialog>
```
**Location**: Affects all React hook development
**Location**:
- Working example: `packages/noodl-editor/src/editor/src/views/ConfirmDialog/`
- `packages/noodl-core-ui/src/components/layout/BaseDialog/`
**Keywords**: CoreBaseDialog, Modal, dialog, popup, BaseDialog, modal not working, clicks blocked
**Keywords**: hot reload, React hooks, webpack, dev server, browser cache
---
## Project Migration System
## Webpack 5 Persistent Caching Issues (Dec 2025)
### [2024-12-15] - Runtime Cache Must Persist Between App Sessions
### Problem: Code Changes Not Loading Despite Dev Server Restart
**Context**: After migrating a project from React 17 to React 19, the project showed as React 19 (not legacy) immediately after migration. However, after closing and reopening the Electron app, the same project was flagged as legacy again.
**Context**: During TASK-004B, discovered that TypeScript source file changes weren't appearing in the running Electron app, even after multiple `npm run dev` restarts and cache clearing attempts.
**Discovery**: The `LocalProjectsModel` had a runtime version cache (`runtimeInfoCache`) that was stored in memory only. The cache would:
1. Correctly detect the migrated project as React 19
2. Show "React 19" badge in the UI
3. But on app restart, the cache was empty
4. Runtime detection would run again from scratch
5. During the detection delay, the project appeared as "legacy"
**Root Cause**: Webpack 5 enables aggressive persistent caching by default:
The `runtimeInfoCache` was a `Map<string, RuntimeVersionInfo>` with no persistence. Every app restart lost the cache, forcing re-detection and causing a race condition where the UI rendered before detection completed.
- **Primary cache**: `packages/noodl-editor/node_modules/.cache`
- **Electron cache**: `~/Library/Application Support/Electron` (macOS)
- **App cache**: `~/Library/Application Support/OpenNoodl` (macOS)
**Discovery**: Standard cache clearing may not be sufficient. The caches can persist across:
- Dev server restarts
- Electron restarts
- Multiple rapid development iterations
**Solution**:
```bash
# Kill any running processes first
killall node
killall Electron
# Clear all caches
cd packages/noodl-editor
rm -rf node_modules/.cache
rm -rf ~/Library/Application\ Support/Electron
rm -rf ~/Library/Application\ Support/OpenNoodl
# Start fresh
npm run dev
```
**Best Practice**: When debugging webpack/compilation issues, add module-level console.log markers at the TOP of your files to verify new code is loading:
**Fix**: Added electron-store persistence for the runtime cache:
```typescript
private runtimeCacheStore = new Store({
name: 'project_runtime_cache'
});
// At top of file
console.log('🔥 MyModule.ts LOADED - Version 2.0');
```
private loadRuntimeCache(): void {
const cached = this.runtimeCacheStore.get('cache') as Record<string, RuntimeVersionInfo>;
if (cached) {
this.runtimeInfoCache = new Map(Object.entries(cached));
}
}
If you don't see this marker in the console, your changes aren't loading - it's a cache/build issue, not a code issue.
private saveRuntimeCache(): void {
const cacheObject = Object.fromEntries(this.runtimeInfoCache.entries());
this.runtimeCacheStore.set('cache', cacheObject);
**Location**: Affects all webpack-compiled code in `packages/noodl-editor/`
**Keywords**: webpack, cache, persistent caching, hot reload, dev server, Electron
---
## React 19 useEffect with Array Dependencies (Dec 2025)
### Problem: useEffect with Array Dependency Never Executes
**Context**: During TASK-004B, discovered that passing an array as a single dependency to useEffect prevents the effect from ever running.
**Root Cause**: React 19's `Object.is()` comparison for dependencies doesn't work correctly when an array is passed as a single dependency item.
**Discovery**:
```typescript
// ❌ BROKEN - useEffect NEVER runs
const eventNames = ['event1', 'event2', 'event3'];
useEffect(() => {
console.log('This never prints!');
}, [dispatcher, eventNames]); // eventNames is an array reference
// ✅ CORRECT - Spread array into individual dependencies
const eventNames = ['event1', 'event2', 'event3'];
useEffect(() => {
console.log('This runs correctly');
}, [dispatcher, ...eventNames]); // Spreads to: [dispatcher, 'event1', 'event2', 'event3']
// ✅ ALSO CORRECT - Use stable array reference outside component
const EVENT_NAMES = ['event1', 'event2', 'event3']; // Outside component
function MyComponent() {
useEffect(() => {
// Works because EVENT_NAMES reference is stable
}, [dispatcher, ...EVENT_NAMES]);
}
```
Now the cache survives app restarts, so migrated projects stay marked as React 19 permanently.
**Critical Rule**: **Never pass an array as a dependency to useEffect. Always spread it.**
**Location**: `packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts`
**Location**: Affects `useEventListener` hook and any custom hooks with array dependencies
**Keywords**: runtime cache, persistence, electron-store, legacy flag, app restart, runtime detection, migration
**Keywords**: React 19, useEffect, dependencies, array, Object.is, spread operator, hook lifecycle
---
### [2024-12-15] - Binary Files Corrupted When Using readFile/writeFile for Copying
**Context**: After migrating a project using the migration system, font files weren't loading in the migrated project. Text appeared with default system fonts instead of custom project fonts. All other files (JSON, JS, CSS) worked correctly.
**Discovery**: The `MigrationSession.copyDirectoryRecursive()` method was copying ALL files using:
```typescript
const content = await filesystem.readFile(sourceItemPath);
await filesystem.writeFile(targetItemPath, content);
```
The `filesystem.readFile()` method reads files as UTF-8 text strings. When font files (.ttf, .woff, .woff2, .otf) are read as text:
1. Binary data gets corrupted by UTF-8 encoding
2. Invalid bytes are replaced with <20> (replacement character)
3. The resulting file is not a valid font
4. Browser's FontLoader fails silently to load the font
5. Text falls back to system fonts
Images (.png, .jpg) would have the same issue. Any binary file copied this way becomes corrupted.
**Fix**: Use `filesystem.copyFile()` which handles binary files correctly:
```typescript
// Before (corrupts binary files):
const content = await filesystem.readFile(sourceItemPath);
await filesystem.writeFile(targetItemPath, content);
// After (preserves binary files):
await filesystem.copyFile(sourceItemPath, targetItemPath);
```
The `copyFile` method in the platform API is specifically designed for copying files while preserving their binary content intact.
**How Fonts Work in Noodl**: Font files are stored in the project directory (e.g., `fonts/MyFont.ttf`). The project.json references them by filename. The FontLoader in the viewer runtime loads them at runtime with `@font-face` CSS. If the font file is corrupted, the load fails silently and system fonts are used.
**Location**:
- Bug: `packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts` (copyDirectoryRecursive method)
- Font loading: `packages/noodl-viewer-react/src/fontloader.js`
**Keywords**: binary files, font corruption, readFile, writeFile, copyFile, UTF-8, migration, fonts not working, images corrupted, binary data
---
## Preview & Web Server
### [2024-12-15] - Custom Fonts 404 Due to Missing MIME Types
**Context**: Custom fonts (TTF, OTF, WOFF, WOFF2) weren't loading in editor preview. Console showed 404 errors and "OTS parsing error: GDEF: misaligned table" messages. Users thought the dev server wasn't serving project files.
**Discovery**: The web server WAS already serving project directory files correctly (lines 166-172 in web-server.js already handle project path lookups). The real issue was the `getContentType()` function only had MIME types for `.ttf` fonts, not for modern formats:
- `.otf` → Missing
- `.woff` → Missing
- `.woff2` → Missing
When browsers requested these files, they received them with the default `text/html` content-type. Browsers then tried to parse binary font data as HTML, which fails with confusing OTS parsing errors.
Also found a bug: the `.wav` case was missing a `break;` statement, causing it to fall through to `.mp4`.
**Fix**: Add missing MIME types to the switch statement:
```javascript
case '.otf':
contentType = 'font/otf';
break;
case '.woff':
contentType = 'font/woff';
break;
case '.woff2':
contentType = 'font/woff2';
break;
```
**Key Insight**: The task documentation assumed we needed to add project file serving infrastructure (middleware, protocol handlers, etc.). The architecture was already correct - we just needed proper MIME type mapping. This turned a 4-6 hour task into a 5-minute fix.
**Location**: `packages/noodl-editor/src/main/src/web-server.js` (getContentType function)
**Keywords**: fonts, MIME types, 404, OTS parsing error, web server, preview, TTF, OTF, WOFF, WOFF2, content-type
---
### [2024-12-15] - Legacy Project Fonts Need Fallback Path Resolution
**Context**: After fixing MIME types, new projects loaded fonts correctly but legacy/migrated projects still showed 404 errors for fonts. Investigation revealed font URLs were being requested without folder prefixes.
**Discovery**: OpenNoodl stores font paths in project.json relative to the project root. The FontPicker component (fontpicker.js) generates these paths from `fileEntry.fullPath.substring(ProjectModel.instance._retainedProjectDirectory.length + 1)`:
- If font is at `/project/fonts/Inter.ttf` → stored as `fonts/Inter.ttf`
- If font is at `/project/Inter.ttf` → stored as `Inter.ttf`
Legacy projects may have fonts stored in different locations or with different path conventions. When the viewer requests a font URL like `/Inter.ttf`, the server looks for `{projectDir}/Inter.ttf`, but the font might actually be at `{projectDir}/fonts/Inter.ttf`.
**The Font Loading Chain**:
1. Node parameter stores fontFamily: `"Inter-Regular.ttf"`
2. `node-shared-port-definitions.js` calls `FontLoader.instance.loadFont(family)`
3. `fontloader.js` uses `getAbsoluteUrl(fontURL)` which prepends `Noodl.baseUrl` (usually `/`)
4. Browser requests `GET /Inter-Regular.ttf`
5. Server tries `projectDirectory + /Inter-Regular.ttf`
6. If not found → 404
**Fix**: Added font fallback mechanism in web-server.js that searches common locations when a font isn't found:
```javascript
if (fontExtensions.includes(ext)) {
const filename = path.split('/').pop();
const fallbackPaths = [
info.projectDirectory + '/fonts' + path, // /fonts/filename.ttf
info.projectDirectory + '/fonts/' + filename, // /fonts/filename.ttf
info.projectDirectory + '/' + filename, // /filename.ttf (root)
info.projectDirectory + '/assets/fonts/' + filename // /assets/fonts/filename.ttf
];
for (const fallbackPath of fallbackPaths) {
if (fs.existsSync(fallbackPath)) {
console.log(`Font fallback: ${path} -> ${fallbackPath}`);
serveFile(fallbackPath, request, response);
return;
}
}
}
```
**Key Files**:
- `packages/noodl-viewer-react/src/fontloader.js` - Runtime font loading
- `packages/noodl-viewer-react/src/node-shared-port-definitions.js` - Where loadFont is called
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/fontpicker.js` - How font paths are stored
- `packages/noodl-editor/src/main/src/web-server.js` - Server-side font resolution
**Location**: `packages/noodl-editor/src/main/src/web-server.js`
**Keywords**: fonts, legacy projects, fallback paths, font not found, 404, projectDirectory, font resolution, migration
---
## Template for Future Entries
```markdown
### [YYYY-MM-DD] - Brief Title
**Context**: What were you trying to do?
**Discovery**: What did you learn?
**Location**: What files/areas does this apply to?
**Keywords**: [searchable terms]
```

View File

@@ -0,0 +1,308 @@
# UI Styling Guide for Noodl Editor
> **For Cline:** Read this document before doing ANY UI/styling work in the editor.
## Why This Document Exists
The Noodl editor has accumulated styling debt from 2015-era development. Many components use hardcoded hex colors instead of the design token system. This guide ensures consistent, modern styling.
**Key Rule:** NEVER copy patterns from legacy CSS files. They're full of hardcoded colors.
---
## Part 1: Token System Architecture
### Token Files Location
```
packages/noodl-editor/src/editor/src/styles/custom-properties/
├── colors.css ← COLOR TOKENS (this is what's imported)
├── fonts.css ← Typography tokens
├── animations.css ← Motion tokens
├── spacing.css ← Spacing tokens (add if missing)
```
### Import Chain
The editor entry point (`packages/noodl-editor/src/editor/index.ts`) imports tokens from the editor's own copies, NOT from noodl-core-ui:
```typescript
// What's actually used:
import '../editor/src/styles/custom-properties/colors.css';
```
---
## Part 2: Design Token Reference
### Background Colors (Dark to Light)
| Token | Use For | Approximate Value |
| -------------------- | ------------------- | ----------------- |
| `--theme-color-bg-0` | Deepest black | `#000000` |
| `--theme-color-bg-1` | App/modal backdrops | `#09090b` |
| `--theme-color-bg-2` | Panel backgrounds | `#18181b` |
| `--theme-color-bg-3` | Cards, inputs | `#27272a` |
| `--theme-color-bg-4` | Elevated surfaces | `#3f3f46` |
| `--theme-color-bg-5` | Highest elevation | `#52525b` |
### Foreground Colors (Muted to Bright)
| Token | Use For |
| ----------------------------------- | --------------------------- |
| `--theme-color-fg-muted` | Disabled text, placeholders |
| `--theme-color-fg-default-shy` | Secondary/helper text |
| `--theme-color-fg-default` | Normal body text |
| `--theme-color-fg-default-contrast` | Emphasized text |
| `--theme-color-fg-highlight` | Maximum emphasis (white) |
### Brand Colors
| Token | Use For | Color |
| ----------------------------------- | -------------------------- | ---------------- |
| `--theme-color-primary` | CTA buttons, active states | Rose |
| `--theme-color-primary-highlight` | Primary hover states | Rose (lighter) |
| `--theme-color-secondary` | Secondary elements | Violet |
| `--theme-color-secondary-highlight` | Secondary hover | Violet (lighter) |
### Status Colors
| Token | Use For |
| ----------------------- | --------------------------- |
| `--theme-color-success` | Success states |
| `--theme-color-notice` | Warnings |
| `--theme-color-danger` | Errors, destructive actions |
### Border Colors
| Token | Use For |
| ------------------------------ | ------------------ |
| `--theme-color-border-subtle` | Light dividers |
| `--theme-color-border-default` | Standard borders |
| `--theme-color-border-strong` | Emphasized borders |
---
## Part 3: Hardcoded Color Replacement Map
When you encounter hardcoded hex colors, replace them using this table:
### Backgrounds
| If You See | Replace With |
| ------------------------------- | ------------------------- |
| `#000000` | `var(--theme-color-bg-0)` |
| `#0a0a0a`, `#09090b` | `var(--theme-color-bg-1)` |
| `#151515`, `#171717`, `#18181b` | `var(--theme-color-bg-2)` |
| `#1d1f20`, `#202020` | `var(--theme-color-bg-2)` |
| `#272727`, `#27272a`, `#2a2a2a` | `var(--theme-color-bg-3)` |
| `#2f3335`, `#303030` | `var(--theme-color-bg-3)` |
| `#333333`, `#383838`, `#3c3c3c` | `var(--theme-color-bg-4)` |
| `#444444`, `#4a4a4a` | `var(--theme-color-bg-5)` |
| `#555555` | `var(--theme-color-bg-5)` |
### Text/Foregrounds
| If You See | Replace With |
| ---------------------------- | ---------------------------------------- |
| `#666666`, `#6a6a6a` | `var(--theme-color-fg-muted)` |
| `#888888` | `var(--theme-color-fg-muted)` |
| `#999999`, `#9a9a9a` | `var(--theme-color-fg-default-shy)` |
| `#aaaaaa`, `#aaa` | `var(--theme-color-fg-default-shy)` |
| `#b8b8b8`, `#b9b9b9` | `var(--theme-color-fg-default)` |
| `#c4c4c4`, `#cccccc`, `#ccc` | `var(--theme-color-fg-default-contrast)` |
| `#d4d4d4`, `#ddd`, `#dddddd` | `var(--theme-color-fg-default-contrast)` |
| `#f5f5f5`, `#ffffff`, `#fff` | `var(--theme-color-fg-highlight)` |
### Legacy Brand Colors
| If You See | Replace With |
| ------------------------------------ | ---------------------------- |
| `#d49517`, `#fdb314` (orange/yellow) | `var(--theme-color-primary)` |
| `#f67465`, `#f89387` (salmon/coral) | `var(--theme-color-danger)` |
---
## Part 4: Spacing System
Use consistent spacing based on 4px/8px grid:
```scss
4px // --spacing-1 (tight)
8px // --spacing-2 (small)
12px // --spacing-3 (medium-small)
16px // --spacing-4 (default)
20px // --spacing-5 (medium)
24px // --spacing-6 (large)
32px // --spacing-8 (extra-large)
40px // --spacing-10
48px // --spacing-12
```
---
## Part 5: Typography Scale
```scss
/* Titles */
24px, weight 600, --theme-color-fg-highlight // Dialog titles
18px, weight 600, --theme-color-fg-highlight // Section titles
16px, weight 600, --theme-color-fg-default-contrast // Subsection headers
/* Body */
14px, weight 400, --theme-color-fg-default // Normal text
14px, weight 400, --theme-color-fg-default-shy // Secondary text
/* Small */
12px, weight 400, --theme-color-fg-muted // Captions, hints
```
---
## Part 6: Component Patterns
### Use CSS Modules
```
ComponentName.tsx
ComponentName.module.scss ← Use this pattern
```
### Standard Component Structure
```scss
// ComponentName.module.scss
.Root {
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-subtle);
border-radius: 8px;
overflow: hidden;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-border-subtle);
}
.Title {
font-size: 18px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
margin: 0;
}
.Content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px;
}
.Footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--theme-color-border-subtle);
}
```
### Button Patterns
```scss
// Primary Button
.PrimaryButton {
background-color: var(--theme-color-primary);
color: white;
border: none;
border-radius: 6px;
padding: 10px 20px;
font-weight: 600;
cursor: pointer;
&:hover {
background-color: var(--theme-color-primary-highlight);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Secondary Button
.SecondaryButton {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
padding: 10px 20px;
&:hover {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
}
}
```
---
## Part 7: Legacy Files to Fix
These files contain hardcoded colors and need cleanup:
### High Priority (Most Visible)
- `packages/noodl-editor/src/editor/src/styles/popuplayer.css`
- `packages/noodl-editor/src/editor/src/styles/propertyeditor.css`
### Medium Priority
- Files in `packages/noodl-editor/src/editor/src/views/nodegrapheditor/`
- `packages/noodl-editor/src/editor/src/views/ConnectionPopup/`
### Reference Files (Good Patterns)
- `packages/noodl-core-ui/src/components/layout/BaseDialog/`
- `packages/noodl-core-ui/src/components/inputs/PrimaryButton/`
---
## Part 8: Pre-Commit Checklist
Before completing any UI task, verify:
- [ ] No hardcoded hex colors (search for `#` followed by hex)
- [ ] All colors use `var(--theme-color-*)` tokens
- [ ] Spacing uses consistent values (multiples of 4px)
- [ ] Hover states defined for interactive elements
- [ ] Focus states visible for accessibility
- [ ] Disabled states handled
- [ ] Border radius consistent (6px buttons, 8px cards)
- [ ] No new global CSS selectors that could conflict
---
## Quick Grep Commands
```bash
# Find hardcoded colors in a file
grep -E '#[0-9a-fA-F]{3,6}' path/to/file.css
# Find all hardcoded colors in editor styles
grep -rE '#[0-9a-fA-F]{3,6}' packages/noodl-editor/src/editor/src/styles/
# Find usage of a specific token
grep -r "theme-color-primary" packages/
```
---
_Last Updated: December 2024_

View File

@@ -0,0 +1,227 @@
# TASK: Legacy CSS Token Migration
## Overview
Replace hardcoded hex colors with design tokens across legacy CSS files. This is mechanical find-and-replace work that dramatically improves maintainability.
**Estimated Sessions:** 3-4
**Risk:** Low (no logic changes, just color values)
**Confidence Check:** After each file, visually verify the editor still renders correctly
---
## Session 1: Foundation Check
### 1.1 Verify Token File Is Current
Check that `packages/noodl-editor/src/editor/src/styles/custom-properties/colors.css` contains the modern token definitions.
Look for these tokens (if missing, update the file first):
```css
--theme-color-bg-0
--theme-color-bg-1
--theme-color-bg-2
--theme-color-bg-3
--theme-color-bg-4
--theme-color-bg-5
--theme-color-fg-muted
--theme-color-fg-default-shy
--theme-color-fg-default
--theme-color-fg-default-contrast
--theme-color-fg-highlight
--theme-color-primary
--theme-color-primary-highlight
--theme-color-border-subtle
--theme-color-border-default
```
### 1.2 Create Spacing Tokens (If Missing)
Create `packages/noodl-editor/src/editor/src/styles/custom-properties/spacing.css`:
```css
:root {
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-8: 32px;
--spacing-10: 40px;
--spacing-12: 48px;
}
```
Add import to `packages/noodl-editor/src/editor/index.ts`:
```typescript
import '../editor/src/styles/custom-properties/spacing.css';
```
### 1.3 Verification
- [ ] Build editor: `npm run build` (or equivalent)
- [ ] Launch editor, confirm no visual regressions
- [ ] Tokens are available in DevTools
---
## Session 2: Clean popuplayer.css
**File:** `packages/noodl-editor/src/editor/src/styles/popuplayer.css`
### Replacement Map
Apply these replacements throughout the file:
```
#000000, black → var(--theme-color-bg-0)
#171717 → var(--theme-color-bg-1)
#272727, #27272a → var(--theme-color-bg-3)
#333333 → var(--theme-color-bg-4)
#555555 → var(--theme-color-bg-5)
#999999, #9a9a9a → var(--theme-color-fg-default-shy)
#aaaaaa, #aaa → var(--theme-color-fg-default-shy)
#cccccc, #ccc → var(--theme-color-fg-default-contrast)
#dddddd, #ddd → var(--theme-color-fg-default-contrast)
#d49517 → var(--theme-color-primary)
#fdb314 → var(--theme-color-primary-highlight)
#f67465 → var(--theme-color-danger)
#f89387 → var(--theme-color-danger-light) or primary-highlight
```
### Specific Sections to Update
1. `.popup-layer-blocker` - background color
2. `.popup-layer-activity-progress` - background colors
3. `.popup-title` - text color
4. `.popup-message` - text color
5. `.popup-button` - background, text colors, hover states
6. `.popup-button-grey` - background, text colors, hover states
7. `.confirm-modal` - all color references
8. `.confirm-button`, `.cancel-button` - backgrounds, text, hover
### Verification
- [ ] Open any popup/dialog in editor
- [ ] Check confirm dialogs
- [ ] Verify hover states work
- [ ] No console errors
---
## Session 3: Clean propertyeditor.css
**File:** `packages/noodl-editor/src/editor/src/styles/propertyeditor.css`
### Approach
1. Run: `grep -E '#[0-9a-fA-F]{3,6}' propertyeditor.css`
2. For each match, use the replacement map
3. Test property panel after changes
### Key Areas
- Input backgrounds
- Label colors
- Border colors
- Focus states
- Selection colors
### Verification
- [ ] Select a node in editor
- [ ] Property panel renders correctly
- [ ] Input fields have correct backgrounds
- [ ] Focus states visible
- [ ] Hover states work
---
## Session 4: Clean Additional Files
### Files to Process
Check these for hardcoded colors and fix:
1. `packages/noodl-editor/src/editor/src/views/nodegrapheditor/*.css`
2. `packages/noodl-editor/src/editor/src/views/ConnectionPopup/*.scss`
3. Any `.css` or `.scss` file that shows hardcoded colors
### Discovery Command
```bash
# Find all files with hardcoded colors
grep -rlE '#[0-9a-fA-F]{3,6}' packages/noodl-editor/src/editor/src/styles/
grep -rlE '#[0-9a-fA-F]{3,6}' packages/noodl-editor/src/editor/src/views/
```
### Process Each File
1. List hardcoded colors: `grep -E '#[0-9a-fA-F]{3,6}' filename`
2. Replace using the mapping
3. Test affected UI area
---
## Color Replacement Reference
### Backgrounds
| Hardcoded | Token |
| ------------------------------- | ------------------------- |
| `#000000`, `black` | `var(--theme-color-bg-0)` |
| `#09090b`, `#0a0a0a` | `var(--theme-color-bg-1)` |
| `#151515`, `#171717`, `#18181b` | `var(--theme-color-bg-2)` |
| `#1d1f20`, `#202020` | `var(--theme-color-bg-2)` |
| `#272727`, `#27272a`, `#2a2a2a` | `var(--theme-color-bg-3)` |
| `#2f3335`, `#303030` | `var(--theme-color-bg-3)` |
| `#333333`, `#383838`, `#3c3c3c` | `var(--theme-color-bg-4)` |
| `#444444`, `#4a4a4a` | `var(--theme-color-bg-5)` |
| `#555555` | `var(--theme-color-bg-5)` |
### Text
| Hardcoded | Token |
| ------------------------------------- | ---------------------------------------- |
| `#666666`, `#6a6a6a` | `var(--theme-color-fg-muted)` |
| `#888888` | `var(--theme-color-fg-muted)` |
| `#999999`, `#9a9a9a` | `var(--theme-color-fg-default-shy)` |
| `#aaaaaa`, `#aaa` | `var(--theme-color-fg-default-shy)` |
| `#b8b8b8`, `#b9b9b9` | `var(--theme-color-fg-default)` |
| `#c4c4c4`, `#cccccc`, `#ccc` | `var(--theme-color-fg-default-contrast)` |
| `#d4d4d4`, `#ddd`, `#dddddd` | `var(--theme-color-fg-default-contrast)` |
| `#f5f5f5`, `#ffffff`, `#fff`, `white` | `var(--theme-color-fg-highlight)` |
### Brand/Status
| Hardcoded | Token |
| -------------------- | --------------------------------------------------------------------- |
| `#d49517`, `#fdb314` | `var(--theme-color-primary)` / `var(--theme-color-primary-highlight)` |
| `#f67465`, `#f89387` | `var(--theme-color-danger)` / lighter variant |
---
## Success Criteria
After all sessions:
- [ ] `grep -rE '#[0-9a-fA-F]{3,6}' packages/noodl-editor/src/editor/src/styles/` returns minimal results (only legitimate uses like shadows)
- [ ] Editor launches without visual regressions
- [ ] All interactive states (hover, focus, disabled) still work
- [ ] Popups, dialogs, property panels render correctly
- [ ] No console errors related to CSS
---
## Notes for Cline
1. **Don't change logic** - Only replace color values
2. **Test incrementally** - After each file, verify the UI
3. **Preserve structure** - Keep selectors and properties, just change values
4. **When uncertain** - Use the closest token match; perfection isn't required
5. **Document edge cases** - If something doesn't fit the map, note it
This is grunt work but it sets up the codebase for proper theming later.

View File

@@ -0,0 +1,364 @@
# Session 2: Post-Migration UX Features - Implementation Plan
## Status: Infrastructure Complete, UI Integration Pending
### Completed ✅
1. **MigrationNotesManager.ts** - Complete helper system
- `getMigrationNote(componentId)` - Get notes for a component
- `getAllMigrationNotes(filter, includeDismissed)` - Get filtered notes
- `getMigrationNoteCounts()` - Get counts by category
- `dismissMigrationNote(componentId)` - Dismiss a note
- Status/icon helper functions
2. **MigrationNotesPanel Component** - Complete React panel
- Beautiful status-based UI with gradient headers
- Shows issues, AI suggestions, help links
- Dismiss functionality
- Full styling in MigrationNotesPanel.module.scss
3. **Design System** - Consistent with Session 1
- Status colors: warning orange, AI purple, success green
- Professional typography and spacing
- Smooth animations and transitions
### Remaining Work 🚧
#### Part 2: Component Badges (2-3 hours)
**Goal:** Add visual migration status badges to components in ComponentsPanel
**Challenge:** ComponentsPanel.ts is a legacy jQuery-based view using underscore.js templates (not React)
**Files to Modify:**
1. `packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanel.ts`
2. `packages/noodl-editor/src/editor/src/templates/componentspanel.html`
3. `packages/noodl-editor/src/editor/src/styles/componentspanel.css`
**Implementation Steps:**
**Step 2.1: Add migration data to component scopes**
In `ComponentsPanel.ts`, in the `returnComponentScopeAndSetActive` function:
```typescript
const returnComponentScopeAndSetActive = (c, f) => {
const iconType = getComponentIconType(c);
// Add migration note loading
const migrationNote = getMigrationNote(c.fullName);
const scope = {
folder: f,
comp: c,
name: c.localName,
isSelected: this.nodeGraphEditor?.getActiveComponent() === c,
isPage: iconType === ComponentIconType.Page,
isCloudFunction: iconType === ComponentIconType.CloudFunction,
isRoot: ProjectModel.instance.getRootNode() && ProjectModel.instance.getRootNode().owner.owner == c,
isVisual: iconType === ComponentIconType.Visual,
isComponentFolder: false,
canBecomeRoot: c.allowAsExportRoot,
hasWarnings: WarningsModel.instance.hasComponentWarnings(c),
// NEW: Migration data
hasMigrationNote: Boolean(migrationNote && !migrationNote.dismissedAt),
migrationStatus: migrationNote?.status || null,
migrationNote: migrationNote
};
// ... rest of function
};
```
**Step 2.2: Add badge click handler**
Add this method to ComponentsPanelView class:
```typescript
onComponentBadgeClicked(scope, el, evt) {
evt.stopPropagation(); // Prevent component selection
if (!scope.migrationNote) return;
// Import at top: const { DialogLayerModel } = require('../../DialogLayer');
// Import at top: const { MigrationNotesPanel } = require('../MigrationNotesPanel');
const ReactDOM = require('react-dom/client');
const React = require('react');
const panel = React.createElement(MigrationNotesPanel, {
component: scope.comp,
note: scope.migrationNote,
onClose: () => {
DialogLayerModel.instance.hideDialog();
this.scheduleRender(); // Refresh to show dismissed state
}
});
DialogLayerModel.instance.showDialog({
content: panel,
title: 'Migration Notes',
width: 600
});
}
```
**Step 2.3: Update HTML template**
In `componentspanel.html`, add badge markup to the `item` template after the warnings icon:
```html
<!-- Migration badge -->
<div
style="position:absolute; right:75px; top:1px; bottom:2px;"
data-class="!hasMigrationNote:hidden"
data-tooltip="View migration notes"
data-click="onComponentBadgeClicked"
>
<div
class="components-panel-migration-badge"
data-class="migrationStatus:badge-{migrationStatus},isSelected:components-panel-item-selected"
></div>
</div>
```
**Step 2.4: Add badge CSS**
In `componentspanel.css`:
```css
/* Migration badges */
.components-panel-migration-badge {
position: absolute;
width: 16px;
height: 16px;
top: 8px;
right: 0;
border-radius: 50%;
cursor: pointer;
transition: transform var(--speed-turbo) var(--easing-base);
opacity: 0.8;
}
.components-panel-migration-badge:hover {
opacity: 1;
transform: scale(1.1);
}
/* Badge colors by status */
.components-panel-migration-badge.badge-needs-review {
background-color: #f59e0b; /* warning orange */
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
}
.components-panel-migration-badge.badge-ai-migrated {
background-color: #a855f7; /* AI purple */
box-shadow: 0 0 6px rgba(168, 85, 247, 0.4);
}
.components-panel-migration-badge.badge-auto {
background-color: #10b981; /* success green */
box-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
}
.components-panel-migration-badge.badge-manually-fixed {
background-color: #10b981; /* success green */
box-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
}
/* Selected state */
.components-panel-item-selected .components-panel-migration-badge {
opacity: 1;
}
```
#### Part 3: Filter System (2-3 hours)
**Goal:** Add filter buttons to show/hide components by migration status
**Step 3.1: Add filter state**
In `ComponentsPanelView` class constructor:
```typescript
constructor(args: ComponentsPanelOptions) {
super();
// ... existing code ...
// NEW: Migration filter state
this.migrationFilter = 'all'; // 'all' | 'needs-review' | 'ai-migrated' | 'no-issues'
}
```
**Step 3.2: Add filter methods**
```typescript
setMigrationFilter(filter: MigrationFilter) {
this.migrationFilter = filter;
this.scheduleRender();
}
shouldShowComponent(scope): boolean {
// Always show if no filter
if (this.migrationFilter === 'all') return true;
const hasMigrationNote = scope.hasMigrationNote;
const status = scope.migrationStatus;
switch (this.migrationFilter) {
case 'needs-review':
return hasMigrationNote && status === 'needs-review';
case 'ai-migrated':
return hasMigrationNote && status === 'ai-migrated';
case 'no-issues':
return !hasMigrationNote;
default:
return true;
}
}
```
**Step 3.3: Apply filter in renderFolder**
In the `renderFolder` method, wrap component rendering:
```typescript
// Then component items
for (var i in folder.components) {
const c = folder.components[i];
const scope = returnComponentScopeAndSetActive(c, folder);
// NEW: Apply filter
if (!this.shouldShowComponent(scope)) continue;
this.componentScopes[c.fullName] = scope;
// ... rest of rendering ...
}
```
**Step 3.4: Add filter UI to HTML template**
Add after the Components header in `componentspanel.html`:
```html
<!-- Migration filters (show only if project has migration notes) -->
<div data-class="!hasMigrationNotes:hidden" class="components-panel-filters">
<button
data-class="migrationFilter=all:is-active"
class="components-panel-filter-btn"
data-click="onMigrationFilterClicked"
data-filter="all"
>
All
</button>
<button
data-class="migrationFilter=needs-review:is-active"
class="components-panel-filter-btn badge-needs-review"
data-click="onMigrationFilterClicked"
data-filter="needs-review"
>
Needs Review (<span data-text="needsReviewCount">0</span>)
</button>
<button
data-class="migrationFilter=ai-migrated:is-active"
class="components-panel-filter-btn badge-ai-migrated"
data-click="onMigrationFilterClicked"
data-filter="ai-migrated"
>
AI Migrated (<span data-text="aiMigratedCount">0</span>)
</button>
<button
data-class="migrationFilter=no-issues:is-active"
class="components-panel-filter-btn"
data-click="onMigrationFilterClicked"
data-filter="no-issues"
>
No Issues
</button>
</div>
```
**Step 3.5: Add filter CSS**
```css
/* Migration filters */
.components-panel-filters {
display: flex;
gap: 4px;
padding: 8px 10px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
}
.components-panel-filter-btn {
flex: 1;
padding: 6px 12px;
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-border-default);
border-radius: 4px;
cursor: pointer;
transition: all var(--speed-turbo) var(--easing-base);
}
.components-panel-filter-btn:hover {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
}
.components-panel-filter-btn.is-active {
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
border-color: var(--theme-color-secondary);
}
/* Badge-colored filters */
.components-panel-filter-btn.badge-needs-review.is-active {
background-color: #f59e0b;
border-color: #f59e0b;
}
.components-panel-filter-btn.badge-ai-migrated.is-active {
background-color: #a855f7;
border-color: #a855f7;
}
```
### Testing Checklist
Before considering Session 2 complete:
- [ ] Badges appear on migrated components
- [ ] Badge colors match status (orange=needs-review, purple=AI, green=auto)
- [ ] Clicking badge opens MigrationNotesPanel
- [ ] Dismissing note removes badge
- [ ] Filters show/hide correct components
- [ ] Filter counts update correctly
- [ ] Filter state persists during navigation
- [ ] Selected component stays visible when filtering
- [ ] No console errors
- [ ] Performance is acceptable with many components
### Notes
- **Legacy Code Warning:** ComponentsPanel uses jQuery + underscore.js templates, not React
- **Import Pattern:** Uses `require()` statements for dependencies
- **Rendering Pattern:** Uses `bindView()` with templates, not JSX
- **Event Handling:** Uses `data-click` attributes, not React onClick
- **State Management:** Uses plain object scopes, not React state
### Deferred Features
- **Code Diff Viewer:** Postponed - not critical for initial release
- Could be added later if users request it
- Would require significant UI work for side-by-side diff
- Current "AI Suggestions" text is sufficient
---
**Next Steps:** Implement Part 2 (Badges) first, test thoroughly, then implement Part 3 (Filters).

View File

@@ -0,0 +1,120 @@
# Cache Clear & Restart Guide
## ✅ Caches Cleared
The following caches have been successfully cleared:
1. ✅ Webpack cache: `packages/noodl-editor/node_modules/.cache`
2. ✅ Electron cache: `~/Library/Application Support/Electron`
3. ✅ OpenNoodl cache: `~/Library/Application Support/OpenNoodl`
## 🔄 How to Restart with Clean Slate
### Step 1: Kill Any Running Processes
Make sure to **completely stop** any running `npm run dev` process:
- Press `Ctrl+C` in the terminal where `npm run dev` is running
- Wait for it to fully stop (both webpack-dev-server AND Electron)
### Step 2: Start Fresh
```bash
cd /Users/richardosborne/vscode_projects/OpenNoodl
npm run dev
```
### Step 3: What to Look For in Console
Once Electron opens, **open the Developer Tools** (View → Toggle Developer Tools or Cmd+Option+I) and check the Console tab.
#### Expected Log Output
You should see these logs IN THIS ORDER when the app starts:
1. **Module Load Markers** (proves new code is loaded):
```
🔥🔥🔥 useEventListener.ts MODULE LOADED WITH DEBUG LOGS - Version 2.0 🔥🔥🔥
🔥🔥🔥 useComponentsPanel.ts MODULE LOADED WITH FIXES - Version 2.0 🔥🔥🔥
```
2. **useComponentsPanel Hook Initialization**:
```
🔍 useComponentsPanel: About to call useEventListener with ProjectModel.instance: [ProjectModel object]
```
3. **useEventListener useEffect Running** (THE CRITICAL LOG):
```
🚨 useEventListener useEffect RUNNING! dispatcher: [ProjectModel] eventName: ["componentAdded", "componentRemoved", "componentRenamed", "rootNodeChanged"]
```
4. **Subscription Confirmation**:
```
📡 useEventListener subscribing to: ["componentAdded", "componentRemoved", "componentRenamed", "rootNodeChanged"] on dispatcher: [ProjectModel]
```
### Step 4: Test Component Rename
1. Right-click on any component in the Components Panel
2. Choose "Rename Component"
3. Type a new name and press Enter
#### Expected Behavior After Rename
You should see these logs:
```
🔔 useEventListener received event: componentRenamed data: {...}
🎉 Event received! Updating counter...
```
AND the UI should immediately update to show the new component name.
## 🚨 Troubleshooting
### If you DON'T see the 🔥 module load markers:
The old code is still loading. Try:
1. Completely close Electron (not just Dev Tools - the whole window)
2. Stop webpack-dev-server (Ctrl+C)
3. Check for any lingering Electron processes: `ps aux | grep -i electron | grep -v grep`
4. Kill them if found: `killall Electron`
5. Run `npm run dev` again
### If you see 🔥 markers but NOT the 🚨 useEffect marker:
This means:
- The modules are loading correctly
- BUT useEffect is not running (React dependency issue)
- This would be very surprising given our fix, so please report exactly what logs you DO see
### If you see 🚨 marker but no 🔔 event logs when renaming:
This means:
- useEffect is running and subscribing
- BUT ProjectModel is not emitting events
- This would indicate the ProjectModel event system isn't working
## 📝 What to Report Back
Please check the console and let me know:
1. ✅ or ❌ Do you see the 🔥 module load markers?
2. ✅ or ❌ Do you see the 🚨 useEffect RUNNING marker?
3. ✅ or ❌ Do you see the 📡 subscription marker?
4. ✅ or ❌ When you rename a component, do you see 🔔 event received logs?
5. ✅ or ❌ Does the UI update immediately after rename?
---
**Next Steps:**
- Once this works, we'll remove all the debug logging
- Document the fix in LEARNINGS.md
- Mark TASK-004B Phase 5 (Inline Rename) as complete

View File

@@ -0,0 +1,95 @@
# TASK-005 Changelog
## Overview
This changelog tracks the implementation of the ComponentsPanel React migration, converting the legacy jQuery/underscore.js View to a modern React component.
### Implementation Sessions
1. **Session 1**: Foundation + Registration
2. **Session 2**: Tree Rendering
3. **Session 3**: Context Menus
4. **Session 4**: Drag-Drop
5. **Session 5**: Inline Rename + Sheets
6. **Session 6**: Polish + TASK-004 Prep
---
## [Date TBD] - Task Created
### Summary
Task documentation created for ComponentsPanel React migration.
### Files Created
- `dev-docs/tasks/phase-2/TASK-005-componentspanel-react/README.md` - Full task specification
- `dev-docs/tasks/phase-2/TASK-005-componentspanel-react/CHECKLIST.md` - Implementation checklist
- `dev-docs/tasks/phase-2/TASK-005-componentspanel-react/CHANGELOG.md` - This file
- `dev-docs/tasks/phase-2/TASK-005-componentspanel-react/NOTES.md` - Working notes
### Context
This task was created after TASK-004 (Runtime Migration System) reached the point where migration status badges needed to be added to ComponentsPanel. Rather than bolt React features onto a jQuery component, the decision was made to fully migrate ComponentsPanel to React first.
---
## Template for Future Entries
```markdown
## [YYYY-MM-DD] - Session N: [Phase Name]
### Summary
[Brief description of what was accomplished]
### Files Created
- `path/to/file.tsx` - [Purpose]
### Files Modified
- `path/to/file.ts` - [What changed and why]
### Technical Notes
- [Key decisions made]
- [Patterns discovered]
- [Gotchas encountered]
### Testing Notes
- [What was tested]
- [Any edge cases discovered]
### Next Steps
- [What needs to be done next]
```
---
## Progress Summary
| Phase | Status | Date Started | Date Completed |
|-------|--------|--------------|----------------|
| Phase 1: Foundation | Not Started | - | - |
| Phase 2: Tree Rendering | Not Started | - | - |
| Phase 3: Context Menus | Not Started | - | - |
| Phase 4: Drag-Drop | Not Started | - | - |
| Phase 5: Inline Rename | Not Started | - | - |
| Phase 6: Sheet Selector | Not Started | - | - |
| Phase 7: Polish & Cleanup | Not Started | - | - |
---
## Blockers Log
_Track any blockers encountered during implementation_
| Date | Blocker | Resolution | Time Lost |
|------|---------|------------|-----------|
| - | - | - | - |
---
## Performance Notes
_Track any performance observations_
| Scenario | Observation | Action Taken |
|----------|-------------|--------------|
| Large component tree | - | - |
| Rapid expand/collapse | - | - |
| Drag-drop operations | - | - |

View File

@@ -0,0 +1,337 @@
# TASK-005 Implementation Checklist
## Pre-Implementation
- [ ] Create branch `task/005-componentspanel-react`
- [ ] Read current `ComponentsPanel.ts` thoroughly
- [ ] Read `ComponentsPanelFolder.ts` for data structures
- [ ] Review `componentspanel.html` template for all UI elements
- [ ] Check `componentspanel.css` for styles to port
- [ ] Review how `SearchPanel.tsx` is structured (reference)
---
## Phase 1: Foundation
### Directory Setup
- [ ] Create `views/panels/ComponentsPanel/` directory
- [ ] Create `components/` subdirectory
- [ ] Create `hooks/` subdirectory
### Type Definitions (`types.ts`)
- [ ] Define `ComponentItemData` interface
- [ ] Define `FolderItemData` interface
- [ ] Define `ComponentsPanelProps` interface
- [ ] Define `TreeNode` union type
### Base Component (`ComponentsPanel.tsx`)
- [ ] Create function component skeleton
- [ ] Accept props from SidebarModel registration
- [ ] Add placeholder content
- [ ] Export from `index.ts`
### Registration Update
- [ ] Update `router.setup.ts` import
- [ ] Verify SidebarModel accepts React component
- [ ] Test panel mounts in sidebar
### Base Styles (`ComponentsPanel.module.scss`)
- [ ] Create file with basic container styles
- [ ] Port `.sidebar-panel` styles
- [ ] Port `.components-scroller` styles
### Checkpoint
- [ ] Panel appears when clicking Components icon
- [ ] No console errors
- [ ] Placeholder content visible
---
## Phase 2: Tree Rendering
### State Hook (`hooks/useComponentsPanel.ts`)
- [ ] Create hook function
- [ ] Subscribe to ProjectModel with `useModernModel`
- [ ] Track expanded folders in local state
- [ ] Track selected item in local state
- [ ] Build tree structure from ProjectModel components
- [ ] Return tree data and handlers
### Folder Structure Logic
- [ ] Port `addComponentToFolderStructure` logic
- [ ] Port `getFolderForComponentWithName` logic
- [ ] Port `getSheetForComponentWithName` logic
- [ ] Handle sheet filtering (`hideSheets` option)
### ComponentTree (`components/ComponentTree.tsx`)
- [ ] Create recursive tree renderer
- [ ] Accept tree data as prop
- [ ] Render FolderItem for folders
- [ ] Render ComponentItem for components
- [ ] Handle indentation via CSS/inline style
### FolderItem (`components/FolderItem.tsx`)
- [ ] Render folder row with caret icon
- [ ] Render folder name
- [ ] Handle expand/collapse on caret click
- [ ] Render children when expanded
- [ ] Show correct icon (folder vs folder-component)
- [ ] Handle "folder component" case (folder that is also a component)
### ComponentItem (`components/ComponentItem.tsx`)
- [ ] Render component row
- [ ] Render component name
- [ ] Show correct icon based on type:
- [ ] Home icon for root component
- [ ] Page icon for page components
- [ ] Cloud function icon for cloud components
- [ ] Visual icon for visual components
- [ ] Default icon for logic components
- [ ] Show warning indicator if component has warnings
- [ ] Handle selection state
### Selection Logic
- [ ] Click to select component
- [ ] Update NodeGraphEditor active component
- [ ] Expand folders to show selected item
- [ ] Sync with external selection changes
### Checkpoint
- [ ] Tree renders with correct structure
- [ ] Folders expand and collapse
- [ ] Components show correct icons
- [ ] Selection highlights correctly
- [ ] Clicking component opens it in editor
---
## Phase 3: Context Menus
### AddComponentMenu (`components/AddComponentMenu.tsx`)
- [ ] Create component with popup menu
- [ ] Get templates from `ComponentTemplates.instance`
- [ ] Filter templates by runtime type
- [ ] Render menu items for each template
- [ ] Add "Folder" menu item
- [ ] Handle template popup creation
### Header "+" Button
- [ ] Add button to panel header
- [ ] Open AddComponentMenu on click
- [ ] Position popup correctly
### Component Context Menu
- [ ] Add right-click handler to ComponentItem
- [ ] Create menu with options:
- [ ] Add (submenu with templates)
- [ ] Make home (if allowed)
- [ ] Rename
- [ ] Duplicate
- [ ] Delete
- [ ] Wire up each action
### Folder Context Menu
- [ ] Add right-click handler to FolderItem
- [ ] Create menu with options:
- [ ] Add (submenu with templates + folder)
- [ ] Make home (if folder has component)
- [ ] Rename
- [ ] Duplicate
- [ ] Delete
- [ ] Wire up each action
### Action Implementations
- [ ] Port `performAdd` logic
- [ ] Port `onRenameClicked` logic (triggers rename mode)
- [ ] Port `onDuplicateClicked` logic
- [ ] Port `onDuplicateFolderClicked` logic
- [ ] Port `onDeleteClicked` logic
- [ ] All actions use UndoQueue
### Checkpoint
- [ ] "+" button shows correct menu
- [ ] Right-click shows context menu
- [ ] All menu items work
- [ ] Undo works for all actions
- [ ] ToastLayer shows errors appropriately
---
## Phase 4: Drag-Drop
### Drag-Drop Hook (`hooks/useDragDrop.ts`)
- [ ] Create hook function
- [ ] Track drag state
- [ ] Track drop target
- [ ] Return drag handlers
### Drag Initiation
- [ ] Add mousedown/mousemove handlers to items
- [ ] Call `PopupLayer.instance.startDragging` on drag start
- [ ] Pass correct label and type
### Drop Zones
- [ ] Make folders droppable
- [ ] Make components droppable (for reorder/nesting)
- [ ] Make top-level area droppable
- [ ] Show drop indicator on valid targets
### Drop Validation
- [ ] Port `getAcceptableDropType` logic
- [ ] Cannot drop folder into its children
- [ ] Cannot drop component on itself
- [ ] Cannot create duplicate names
- [ ] Show invalid drop feedback
### Drop Execution
- [ ] Port `dropOn` logic
- [ ] Handle component → folder
- [ ] Handle folder → folder
- [ ] Handle component → component (reorder/nest)
- [ ] Create proper undo actions
- [ ] Call `PopupLayer.instance.dragCompleted`
### Checkpoint
- [ ] Dragging shows ghost label
- [ ] Valid drop targets highlight
- [ ] Invalid drops show feedback
- [ ] Drops execute correctly
- [ ] Undo reverses drops
---
## Phase 5: Inline Rename
### Rename Hook (`hooks/useRenameMode.ts`)
- [ ] Create hook function
- [ ] Track which item is in rename mode
- [ ] Track current input value
- [ ] Return rename state and handlers
### Rename UI
- [ ] Show input field when in rename mode
- [ ] Pre-fill with current name
- [ ] Select all text on focus
- [ ] Position input correctly
### Rename Actions
- [ ] Enter key confirms rename
- [ ] Escape key cancels rename
- [ ] Click outside cancels rename
- [ ] Validate name before saving
- [ ] Show error for invalid names
### Rename Execution
- [ ] Port rename logic for components
- [ ] Port rename logic for folders
- [ ] Use UndoQueue for rename action
- [ ] Update tree after rename
### Checkpoint
- [ ] Double-click triggers rename
- [ ] Menu "Rename" triggers rename
- [ ] Input appears with current name
- [ ] Enter saves correctly
- [ ] Escape cancels correctly
- [ ] Invalid names show error
---
## Phase 6: Sheet Selector
### SheetSelector (`components/SheetSelector.tsx`)
- [ ] Create component for sheet tabs
- [ ] Get sheets from ProjectModel
- [ ] Filter out hidden sheets
- [ ] Render tab for each sheet
- [ ] Handle sheet selection
### Integration
- [ ] Only render if `showSheetList` prop is true
- [ ] Update current sheet in state hook
- [ ] Filter component tree by current sheet
- [ ] Default to first visible sheet
### Checkpoint
- [ ] Sheet tabs appear (if enabled)
- [ ] Clicking tab switches sheets
- [ ] Component tree filters correctly
- [ ] Hidden sheets don't appear
---
## Phase 7: Polish & Cleanup
### Style Polish
- [ ] Match exact spacing/sizing of original
- [ ] Ensure hover states work
- [ ] Ensure focus states work
- [ ] Test in dark theme (if applicable)
### Code Cleanup
- [ ] Remove any `any` types
- [ ] Remove any `TSFixme` markers
- [ ] Add JSDoc comments to public functions
- [ ] Ensure consistent naming
### File Removal
- [ ] Verify all functionality works
- [ ] Delete `views/panels/componentspanel/ComponentsPanel.ts`
- [ ] Delete `templates/componentspanel.html`
- [ ] Update any remaining imports
### TASK-004 Preparation
- [ ] Add `migrationStatus` to ComponentItemData type
- [ ] Add placeholder for badge in ComponentItem
- [ ] Add placeholder for filter UI in header
- [ ] Document extension points
### Documentation
- [ ] Update CHANGELOG.md with changes
- [ ] Add notes to NOTES.md about patterns discovered
- [ ] Update any relevant dev-docs
### Checkpoint
- [ ] All original functionality works
- [ ] No console errors or warnings
- [ ] No TypeScript errors
- [ ] Old files removed
- [ ] Ready for TASK-004
---
## Post-Implementation
- [ ] Create PR with clear description
- [ ] Request review
- [ ] Test in multiple scenarios:
- [ ] Fresh project
- [ ] Project with many components
- [ ] Project with deep folder nesting
- [ ] Project with cloud functions
- [ ] Project with pages
- [ ] Merge and verify in main branch
---
## Quick Reference: Port These Functions
From `ComponentsPanel.ts`:
- [ ] `addComponentToFolderStructure()`
- [ ] `getFolderForComponentWithName()`
- [ ] `getSheetForComponentWithName()`
- [ ] `getAcceptableDropType()`
- [ ] `dropOn()`
- [ ] `makeDraggable()`
- [ ] `makeDroppable()`
- [ ] `performAdd()`
- [ ] `onItemClicked()`
- [ ] `onCaretClicked()`
- [ ] `onComponentActionsClicked()`
- [ ] `onFolderActionsClicked()`
- [ ] `onRenameClicked()`
- [ ] `onDeleteClicked()`
- [ ] `onDuplicateClicked()`
- [ ] `onDuplicateFolderClicked()`
- [ ] `renderFolder()` (becomes React component)
- [ ] `returnComponentScopeAndSetActive()`

View File

@@ -0,0 +1,231 @@
# TASK-005 Working Notes
## Quick Links
- Legacy implementation: `packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanel.ts`
- Template: `packages/noodl-editor/src/editor/src/templates/componentspanel.html`
- Styles: `packages/noodl-editor/src/editor/src/styles/componentspanel.css`
- Folder model: `packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanelFolder.ts`
- Templates: `packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentTemplates.ts`
- Sidebar docs: `packages/noodl-editor/docs/sidebar.md`
## Reference Components
Good patterns to follow:
- `views/SidePanel/SidePanel.tsx` - Container for sidebar panels
- `views/panels/SearchPanel/SearchPanel.tsx` - Modern React panel example
- `views/panels/VersionControlPanel/VersionControlPanel.tsx` - Another React panel
- `views/PopupLayer/PopupMenu.tsx` - Context menu component
## Key Decisions
### Decision 1: State Management Approach
**Options considered:**
1. useState + useEffect for ProjectModel subscription
2. useModernModel hook (existing pattern)
3. New Zustand store
**Decision:** Use `useModernModel` hook
**Reasoning:** Matches existing patterns in codebase, already handles subscription cleanup, proven to work with ProjectModel.
---
### Decision 2: Tree Structure Representation
**Options considered:**
1. Reuse ComponentsPanelFolder class
2. Create new TreeNode interface
3. Flat array with parent references
**Decision:** [TBD during implementation]
**Reasoning:** [TBD]
---
### Decision 3: Drag-Drop Implementation
**Options considered:**
1. Native HTML5 drag-drop with PopupLayer
2. @dnd-kit library
3. react-dnd
**Decision:** Native HTML5 with PopupLayer (initially)
**Reasoning:** Maintains consistency with existing drag-drop patterns in codebase, no new dependencies. Can upgrade to dnd-kit later if needed for DASH-003.
---
## Technical Discoveries
### ProjectModel Events
Key events to subscribe to:
```typescript
const events = [
'componentAdded',
'componentRemoved',
'componentRenamed',
'rootComponentChanged',
'projectLoaded'
];
```
### ComponentsPanelFolder Structure
The folder structure is built dynamically from component names:
```
/Component1 → root folder
/Folder1/Component2 → Folder1 contains Component2
/Folder1/ → Folder1 (folder component - both folder AND component)
```
Key insight: A folder can also BE a component. This is the "folder component" pattern where `folder.component` is set.
### Icon Type Detection
From `ComponentIcon.ts`:
```typescript
export function getComponentIconType(component: ComponentModel): ComponentIconType {
// Cloud functions
if (isComponentModel_CloudRuntime(component)) {
return ComponentIconType.CloudFunction;
}
// Pages (visual with router)
if (hasRouterChildren(component)) {
return ComponentIconType.Page;
}
// Visual components
if (isVisualComponent(component)) {
return ComponentIconType.Visual;
}
// Default: logic
return ComponentIconType.Logic;
}
```
### Sheet System
Sheets are special top-level folders that start with `#`:
- `/#__cloud__` - Cloud functions sheet (often hidden)
- `/#pages` - Pages sheet
- `/` - Default sheet (root)
The `hideSheets` option filters these from display.
### PopupLayer Drag-Drop Pattern
```typescript
// Start drag
PopupLayer.instance.startDragging({
label: 'Component Name',
type: 'component',
component: componentModel,
folder: parentFolder
});
// During drag (on drop target)
PopupLayer.instance.isDragging(); // Check if drag active
PopupLayer.instance.dragItem; // Get current drag item
PopupLayer.instance.indicateDropType('move' | 'none');
// On drop
PopupLayer.instance.dragCompleted();
```
---
## Gotchas Discovered
### Gotcha 1: Folder Component Selection
When clicking a "folder component", the folder scope should be selected, not the component scope. See `selectComponent()` in original.
### Gotcha 2: Sheet Auto-Selection
When a component is selected, its sheet should automatically become active. See `selectSheet()` calls.
### Gotcha 3: Rename Input Focus
The rename input needs careful focus management - it should select all text on focus and prevent click-through issues.
### Gotcha 4: Empty Folder Cleanup
When a folder becomes empty (no components, no subfolders), and it's a "folder component", it should revert to a regular component.
---
## Useful Commands
```bash
# Find all usages of ComponentsPanel
grep -r "ComponentsPanel" packages/noodl-editor/src/ --include="*.ts" --include="*.tsx"
# Find ProjectModel event subscriptions
grep -r "ProjectModel.instance.on" packages/noodl-editor/src/editor/
# Find useModernModel usage examples
grep -r "useModernModel" packages/noodl-editor/src/editor/
# Find PopupLayer drag-drop usage
grep -r "startDragging" packages/noodl-editor/src/editor/
# Test build
cd packages/noodl-editor && npm run build
# Type check
cd packages/noodl-editor && npx tsc --noEmit
```
---
## Debug Log
_Add entries as you work through implementation_
### [Date/Time] - Phase 1: Foundation
- Trying: [what you're attempting]
- Result: [what happened]
- Next: [what to try next]
---
## Questions to Resolve
- [ ] Does SidebarModel need changes to accept React functional components directly?
- [ ] Should we keep ComponentsPanelFolder.ts or inline the logic?
- [ ] How do we handle the `nodeGraphEditor` reference passed via options?
- [ ] What's the right pattern for context menu positioning?
---
## Discoveries for LEARNINGS.md
_Note patterns discovered that should be added to dev-docs/reference/LEARNINGS.md_
### Pattern: Migrating Legacy View to React
**Context:** Converting jQuery View classes to React components
**Pattern:**
1. Create React component with same props
2. Use useModernModel for model subscriptions
3. Replace data-click handlers with onClick props
4. Replace data-class bindings with conditional classNames
5. Replace $(selector) queries with refs or state
6. Port CSS to CSS modules
**Location:** Sidebar panels
---
### Pattern: [TBD]
**Context:** [TBD during implementation]
**Pattern:** [TBD]
**Location:** [TBD]

View File

@@ -0,0 +1,517 @@
# TASK-005: ComponentsPanel React Migration
## ⚠️ CURRENT STATUS: BLOCKED
**Last Updated:** December 22, 2025
**Status:** 🚫 BLOCKED - Webpack/Electron caching preventing testing
**Completion:** ~85% (Backend works, UI update blocked)
**📖 See [STATUS-BLOCKED.md](./STATUS-BLOCKED.md) for complete details**
### Quick Summary
- ✅ Backend rename functionality works perfectly
- ✅ Code fixes implemented correctly in source files
- ❌ Webpack 5 persistent caching prevents new code from loading
- ❌ UI doesn't update after rename because useEventListener never subscribes
**Next Action:** Requires dedicated investigation into webpack caching issue or alternative approach. See STATUS-BLOCKED.md for detailed analysis and potential solutions.
---
## Overview
Migrate the ComponentsPanel from the legacy jQuery/underscore.js View pattern to a modern React component. This eliminates tech debt, enables the migration badges/filters feature from TASK-004, and establishes a clean pattern for migrating remaining legacy panels.
**Phase:** 2 (Runtime Migration System)
**Priority:** HIGH (blocks TASK-004 parts 2 & 3)
**Effort:** 6-8 hours (Original estimate - actual time ~12 hours due to caching issues)
**Risk:** Medium → HIGH (Webpack caching complications)
---
## Background
### Current State
`ComponentsPanel.ts` is a ~800 line legacy View class that uses:
- jQuery for DOM manipulation and event handling
- Underscore.js HTML templates (`componentspanel.html`) with `data-*` attribute bindings
- Manual DOM updates via `scheduleRender()` pattern
- Complex drag-and-drop via PopupLayer integration
- Deep integration with ProjectModel, NodeGraphEditor, and sheets system
### Why Migrate Now?
1. **Blocks TASK-004**: Adding migration status badges and filters to a jQuery template creates a Frankenstein component mixing React dialogs into jQuery views
2. **Philosophy alignment**: "When we touch a component, we clean it properly"
3. **Pattern establishment**: This migration creates a template for other legacy panels
4. **Maintainability**: React components are easier to test, extend, and debug
### Prior Art
Several patterns already exist in the codebase:
- `ReactView` wrapper class for hybrid components
- `SidePanel.tsx` - the container that hosts sidebar panels (already React)
- `SidebarModel` registration pattern supports both legacy Views and React components
- `UndoQueuePanel` example in `docs/sidebar.md` shows the migration pattern
---
## Goals
1. **Full React rewrite** of ComponentsPanel with zero jQuery dependencies
2. **Feature parity** with existing functionality (drag-drop, folders, context menus, rename-in-place)
3. **Clean integration** with existing SidebarModel registration
4. **Prepare for badges/filters** - structure component to easily add TASK-004 features
5. **TypeScript throughout** - proper typing, no TSFixme
---
## Architecture
### Component Structure
```
ComponentsPanel/
├── ComponentsPanel.tsx # Main container, registered with SidebarModel
├── ComponentsPanel.module.scss # Scoped styles
├── components/
│ ├── ComponentTree.tsx # Recursive tree renderer
│ ├── ComponentItem.tsx # Single component row
│ ├── FolderItem.tsx # Folder row with expand/collapse
│ ├── SheetSelector.tsx # Sheet tabs (if showSheetList option)
│ └── AddComponentMenu.tsx # "+" button dropdown
├── hooks/
│ ├── useComponentsPanel.ts # Main state management hook
│ ├── useDragDrop.ts # Drag-drop logic
│ └── useRenameMode.ts # Inline rename handling
├── types.ts # TypeScript interfaces
└── index.ts # Exports
```
### State Management
Use React hooks with ProjectModel as source of truth:
- `useModernModel` hook to subscribe to ProjectModel events
- Local state for UI concerns (expanded folders, selection, rename mode)
- Derive tree structure from ProjectModel on each render
### Drag-Drop Strategy
Two options to evaluate:
**Option A: Native HTML5 Drag-Drop**
- Lighter weight, no dependencies
- Already used elsewhere in codebase via PopupLayer
- Requires manual drop zone management
**Option B: @dnd-kit library**
- Already planned as dependency for DASH-003 (Project Organisation)
- Better accessibility, smoother animations
- More code but cleaner abstractions
**Recommendation**: Start with Option A to maintain existing PopupLayer integration patterns. Can upgrade to dnd-kit later if needed.
---
## Implementation Phases
### Phase 1: Foundation (1-2 hours)
Create the component structure and basic rendering without interactivity.
**Files to create:**
- `ComponentsPanel.tsx` - Shell component
- `ComponentsPanel.module.scss` - Base styles (port from existing CSS)
- `types.ts` - TypeScript interfaces
- `hooks/useComponentsPanel.ts` - State hook skeleton
**Tasks:**
1. Create directory structure
2. Define TypeScript interfaces for component/folder items
3. Create basic ComponentsPanel that renders static tree
4. Register with SidebarModel (replacing legacy panel)
5. Verify it mounts without errors
**Success criteria:**
- Panel appears in sidebar
- Shows hardcoded component list
- No console errors
### Phase 2: Tree Rendering (1-2 hours)
Implement proper tree structure from ProjectModel.
**Files to create:**
- `components/ComponentTree.tsx`
- `components/ComponentItem.tsx`
- `components/FolderItem.tsx`
**Tasks:**
1. Subscribe to ProjectModel with useModernModel
2. Build folder/component tree structure (port logic from `addComponentToFolderStructure`)
3. Implement recursive tree rendering
4. Add expand/collapse for folders
5. Implement component selection
6. Add proper icons (home, page, cloud function, visual)
**Success criteria:**
- Tree matches current panel exactly
- Folders expand/collapse
- Selection highlights correctly
- Icons display correctly
### Phase 3: Context Menus (1 hour)
Port context menu functionality.
**Files to create:**
- `components/AddComponentMenu.tsx`
**Tasks:**
1. Implement header "+" button menu using existing PopupMenu
2. Implement component right-click context menu
3. Implement folder right-click context menu
4. Wire up all actions (rename, duplicate, delete, make home)
**Success criteria:**
- All context menu items work
- Actions perform correctly (components created, renamed, deleted)
- Undo/redo works for all actions
### Phase 4: Drag-Drop (2 hours)
Port the drag-drop system.
**Files to create:**
- `hooks/useDragDrop.ts`
**Tasks:**
1. Create drag-drop hook using PopupLayer.startDragging pattern
2. Implement drag initiation on component/folder rows
3. Implement drop zones on folders and between items
4. Port drop validation logic (`getAcceptableDropType`)
5. Port drop execution logic (`dropOn`)
6. Handle cross-sheet drops
**Success criteria:**
- Components can be dragged to folders
- Folders can be dragged to folders
- Invalid drops show appropriate feedback
- Drop creates undo action
### Phase 5: Inline Rename (1 hour)
Port rename-in-place functionality.
**Files to create:**
- `hooks/useRenameMode.ts`
**Tasks:**
1. Create rename mode state management
2. Implement inline input field rendering
3. Handle Enter to confirm, Escape to cancel
4. Validate name uniqueness
5. Handle focus management
**Success criteria:**
- Double-click or menu triggers rename
- Input shows with current name selected
- Enter saves, Escape cancels
- Invalid names show error
### Phase 6: Sheet Selector (30 min)
Port sheet/tab functionality (if `showSheetList` option is true).
**Files to create:**
- `components/SheetSelector.tsx`
**Tasks:**
1. Render sheet tabs
2. Handle sheet switching
3. Respect `hideSheets` option
**Success criteria:**
- Sheets display correctly
- Switching sheets filters component list
- Hidden sheets don't appear
### Phase 7: Polish & Integration (1 hour)
Final cleanup and TASK-004 preparation.
**Tasks:**
1. Remove old ComponentsPanel.ts and template
2. Update any imports/references
3. Add data attributes for testing
4. Prepare component structure for badges/filters (TASK-004)
5. Write migration notes for other legacy panels
**Success criteria:**
- No references to old files
- All tests pass
- Ready for TASK-004 badge implementation
---
## Files to Modify
### Create (New)
```
packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/
├── ComponentsPanel.tsx
├── ComponentsPanel.module.scss
├── components/
│ ├── ComponentTree.tsx
│ ├── ComponentItem.tsx
│ ├── FolderItem.tsx
│ ├── SheetSelector.tsx
│ └── AddComponentMenu.tsx
├── hooks/
│ ├── useComponentsPanel.ts
│ ├── useDragDrop.ts
│ └── useRenameMode.ts
├── types.ts
└── index.ts
```
### Modify
```
packages/noodl-editor/src/editor/src/router.setup.ts
- Update ComponentsPanel import to new location
- Verify SidebarModel.register call works with React component
packages/noodl-editor/src/editor/src/models/sidebar/sidebarmodel.tsx
- May need adjustment if React components need different handling
```
### Delete (After Migration Complete)
```
packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanel.ts
packages/noodl-editor/src/editor/src/templates/componentspanel.html
```
### Keep (Reference/Integration)
```
packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentsPanelFolder.ts
- Data structure class, can be reused or ported to types.ts
packages/noodl-editor/src/editor/src/views/panels/componentspanel/ComponentTemplates.ts
- Template definitions, used by AddComponentMenu
```
---
## Technical Notes
### SidebarModel Registration
Current registration in `router.setup.ts`:
```typescript
SidebarModel.instance.register({
id: 'components',
name: 'Components',
order: 1,
icon: IconName.Components,
onOpen: () => {
/* ... */
},
panelProps: {
options: {
showSheetList: true,
hideSheets: ['__cloud__']
}
},
panel: ComponentsPanel // Currently legacy View class
});
```
React components can be registered directly - see how `SidePanel.tsx` handles this with `SidebarModel.instance.getPanelComponent()`.
### ProjectModel Integration
Key events to subscribe to:
- `componentAdded` - New component created
- `componentRemoved` - Component deleted
- `componentRenamed` - Component name changed
- `rootComponentChanged` - Home component changed
Use `useModernModel(ProjectModel.instance, [...events])` pattern.
### Existing Patterns to Follow
Look at these files for patterns:
- `SearchPanel.tsx` - Modern React sidebar panel
- `VersionControlPanel.tsx` - Another React sidebar panel
- `useModernModel` hook - Model subscription pattern
- `PopupMenu` component - For context menus
### CSS Migration
Port styles from:
- `packages/noodl-editor/src/editor/src/styles/componentspanel.css`
To CSS modules in `ComponentsPanel.module.scss`.
---
## Testing Checklist
### Basic Rendering
- [ ] Panel appears in sidebar when Components icon clicked
- [ ] Components display with correct names
- [ ] Folders display with correct names
- [ ] Nested structure renders correctly
- [ ] Icons display correctly (home, page, cloud, visual, folder)
### Selection
- [ ] Clicking component selects it
- [ ] Clicking folder selects it
- [ ] Selection opens component in node graph editor
- [ ] Only one item selected at a time
### Folders
- [ ] Clicking caret expands/collapses folder
- [ ] Folder state persists during session
- [ ] Empty folders display correctly
- [ ] "Folder components" (folders that are also components) work
### Context Menus
- [ ] "+" button shows add menu
- [ ] Component context menu shows all options
- [ ] Folder context menu shows all options
- [ ] "Make home" option works
- [ ] "Rename" option works
- [ ] "Duplicate" option works
- [ ] "Delete" option works (with confirmation)
### Drag-Drop
- [ ] Can drag component to folder
- [ ] Can drag folder to folder
- [ ] Cannot drag folder into its own children
- [ ] Drop indicator shows correctly
- [ ] Invalid drops show feedback
- [ ] Undo works after drop
### Rename
- [ ] Double-click enables rename
- [ ] Context menu "Rename" enables rename
- [ ] Enter confirms rename
- [ ] Escape cancels rename
- [ ] Tab moves to next item (optional)
- [ ] Invalid names show error
### Sheets
- [ ] Sheet tabs display (if enabled)
- [ ] Clicking sheet filters component list
- [ ] Hidden sheets don't appear
### Integration
- [ ] Warnings icon appears for components with warnings
- [ ] Selection syncs with node graph editor
- [ ] New component appears immediately after creation
- [ ] Deleted component disappears immediately
---
## Risks & Mitigations
### Risk: Drag-drop edge cases
**Mitigation**: Port logic directly from existing implementation, test thoroughly
### Risk: Performance with large component trees
**Mitigation**: Use React.memo for tree items, virtualize if needed (future)
### Risk: Breaking existing functionality
**Mitigation**: Test all features before removing old code, keep old files until verified
### Risk: Subtle event timing issues
**Mitigation**: Use same ProjectModel subscription pattern as other panels
---
## Success Criteria
1. **Feature parity**: All existing functionality works identically
2. **No regressions**: Existing projects work correctly
3. **Clean code**: No jQuery, no TSFixme, proper TypeScript
4. **Ready for TASK-004**: Easy to add migration badges/filters
5. **Pattern established**: Can be used as template for other panel migrations
---
## Future Enhancements (Out of Scope)
- Virtualized rendering for huge component trees
- Keyboard navigation (arrow keys)
- Multi-select for bulk operations
- Search/filter within panel (separate from SearchPanel)
- Drag to reorder (not just move to folder)
---
## Dependencies
**Blocked by:** None
**Blocks:**
- TASK-004 Parts 2 & 3 (Migration Status Badges & Filters)
---
## References
- Current implementation: `views/panels/componentspanel/ComponentsPanel.ts`
- Template: `templates/componentspanel.html`
- Styles: `styles/componentspanel.css`
- Folder model: `views/panels/componentspanel/ComponentsPanelFolder.ts`
- Sidebar docs: `packages/noodl-editor/docs/sidebar.md`
- SidePanel container: `views/SidePanel/SidePanel.tsx`

View File

@@ -0,0 +1,371 @@
# ComponentsPanel Rename Testing Plan
## Overview
This document outlines the testing plan to verify that the rename functionality works correctly after integrating the `useEventListener` hook from TASK-008.
**Bug Being Fixed:** Component/folder renames not updating in the UI despite successful backend operation.
**Root Cause:** EventDispatcher events weren't reaching React hooks due to closure incompatibility.
**Solution:** Integrated `useEventListener` hook which bridges EventDispatcher and React lifecycle.
---
## Test Environment Setup
### Prerequisites
```bash
# Ensure editor is built and running
npm run dev
```
### Test Project Requirements
- Project with at least 3-5 components
- At least one folder with components inside
- Mix of root-level and nested components
---
## Test Cases
### 1. Component Rename (Basic)
**Objective:** Verify component name updates in tree immediately after rename
**Steps:**
1. Open the editor with a test project
2. In Components panel, right-click a component
3. Select "Rename" from context menu
4. Enter a new name (e.g., "MyComponent" → "RenamedComponent")
5. Press Enter or click outside to confirm
**Expected Result:**
- ✅ Component name updates immediately in the tree
- ✅ Component icon/status indicators remain correct
- ✅ No console errors
- ✅ Undo/redo works correctly
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 2. Component Rename (Double-Click)
**Objective:** Verify double-click rename flow works
**Steps:**
1. Double-click a component name in the tree
2. Enter a new name
3. Press Enter to confirm
**Expected Result:**
- ✅ Rename input appears on double-click
- ✅ Name updates immediately after Enter
- ✅ UI remains responsive
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 3. Component Rename (Cancel)
**Objective:** Verify canceling rename doesn't cause issues
**Steps:**
1. Start renaming a component
2. Press Escape or click outside without changing name
3. Start rename again and change name
4. Press Escape to cancel
**Expected Result:**
- ✅ First cancel exits rename mode cleanly
- ✅ Second cancel discards changes
- ✅ Original name remains
- ✅ UI remains stable
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 4. Component Rename (Conflict Detection)
**Objective:** Verify duplicate name validation works
**Steps:**
1. Start renaming "ComponentA"
2. Try to rename it to "ComponentB" (which already exists)
3. Press Enter
**Expected Result:**
- ✅ Error toast appears: "Component name already exists"
- ✅ Rename mode stays active (user can fix the name)
- ✅ Original name unchanged
- ✅ No console errors
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 5. Folder Rename (Basic)
**Objective:** Verify folder rename updates all child components
**Steps:**
1. Create a folder with 2-3 components inside
2. Right-click the folder
3. Select "Rename"
4. Enter new folder name (e.g., "OldFolder" → "NewFolder")
5. Press Enter
**Expected Result:**
- ✅ Folder name updates immediately in tree
- ✅ All child component paths update (e.g., "OldFolder/Comp1" → "NewFolder/Comp1")
- ✅ Child components remain accessible
- ✅ Undo/redo works for entire folder rename
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 6. Nested Component Rename
**Objective:** Verify nested component paths update correctly
**Steps:**
1. Rename a component inside a folder
2. Verify path updates (e.g., "Folder/OldName" → "Folder/NewName")
3. Verify parent folder still shows correctly
**Expected Result:**
- ✅ Nested component name updates
- ✅ Path shows correct folder
- ✅ Parent folder structure unchanged
- ✅ Component still opens correctly
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 7. Rapid Renames
**Objective:** Verify multiple rapid renames don't cause issues
**Steps:**
1. Rename a component
2. Immediately after, rename another component
3. Rename a third component
4. Verify all names updated correctly
**Expected Result:**
- ✅ All three renames succeed
- ✅ No race conditions or stale data
- ✅ UI updates consistently
- ✅ Undo/redo stack correct
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 8. Rename While Component Open
**Objective:** Verify rename works when component is currently being edited
**Steps:**
1. Open a component in the node graph editor
2. In Components panel, rename that component
3. Verify editor tab/title updates
**Expected Result:**
- ✅ Component name updates in tree
- ✅ Editor tab title updates (if applicable)
- ✅ Component remains open and editable
- ✅ No editor state lost
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 9. Undo/Redo Rename
**Objective:** Verify undo/redo works correctly
**Steps:**
1. Rename a component (e.g., "Comp1" → "Comp2")
2. Press Cmd+Z (Mac) or Ctrl+Z (Windows) to undo
3. Press Cmd+Shift+Z / Ctrl+Y to redo
**Expected Result:**
- ✅ Undo reverts name back to "Comp1"
- ✅ Tree updates immediately after undo
- ✅ Redo changes name to "Comp2"
- ✅ Tree updates immediately after redo
- ✅ Multiple undo/redo cycles work correctly
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
### 10. Special Characters in Names
**Objective:** Verify name validation handles special characters
**Steps:**
1. Try renaming with special characters: `@#$%^&*()`
2. Try renaming with spaces: "My Component Name"
3. Try renaming with only spaces: " "
**Expected Result:**
- ✅ Invalid characters rejected with appropriate message
- ✅ Spaces may or may not be allowed (based on validation rules)
- ✅ Empty/whitespace-only names rejected
- ✅ Rename mode stays active for correction
**Actual Result:**
- [ ] Pass / [ ] Fail
- Notes:
---
## Console Monitoring
While testing, monitor the browser console for:
### Expected Logs (OK to see):
- `🚀 React ComponentsPanel RENDERED`
- `🔍 handleRenameConfirm CALLED`
- `✅ Calling performRename...`
- `✅ Rename successful - canceling rename mode`
### Problematic Logs (Investigate if seen):
- ❌ Any errors related to EventDispatcher
- ❌ "performRename failed"
- ❌ Warnings about stale closures
- ❌ React errors or warnings
- ❌ "forceRefresh is not a function" (should never appear)
---
## Performance Check
### Memory Leak Test
**Steps:**
1. Perform 20-30 rapid renames
2. Open browser DevTools → Performance/Memory tab
3. Check for memory growth
**Expected Result:**
- ✅ No significant memory leaks
- ✅ Event listeners properly cleaned up
- ✅ UI remains responsive
---
## Regression Checks
Verify these existing features still work:
- [ ] Creating new components
- [ ] Deleting components
- [ ] Duplicating components
- [ ] Drag & drop to move components
- [ ] Setting component as home
- [ ] Opening components in editor
- [ ] Folder expand/collapse
- [ ] Context menu on components
- [ ] Context menu on folders
---
## Known Issues / Limitations
_Document any known issues discovered during testing:_
1.
2.
3.
---
## Test Results Summary
**Date Tested:** ******\_\_\_******
**Tester:** ******\_\_\_******
**Overall Result:** [ ] All Pass [ ] Some Failures [ ] Critical Issues
**Critical Issues Found:**
-
**Minor Issues Found:**
-
**Recommendations:**
-
---
## Sign-Off
**Ready for Production:** [ ] Yes [ ] No [ ] With Reservations
**Notes:**

View File

@@ -0,0 +1,319 @@
# TASK-005 Session Plan for Cline
## Context
You are migrating `ComponentsPanel.ts` from a legacy jQuery/underscore.js View to a modern React component. This is a prerequisite for TASK-004's migration badges feature.
**Philosophy:** "When we touch a component, we clean it properly" - full React rewrite, no jQuery, proper TypeScript.
---
## Session 1: Foundation (Start Here)
### Goal
Create the component structure and get it rendering in the sidebar.
### Steps
1. **Create directory structure:**
```
packages/noodl-editor/src/editor/src/views/panels/ComponentsPanel/
├── ComponentsPanel.tsx
├── ComponentsPanel.module.scss
├── components/
├── hooks/
├── types.ts
└── index.ts
```
2. **Define types in `types.ts`:**
```typescript
import { ComponentModel } from '@noodl-models/componentmodel';
export interface ComponentItemData {
id: string;
name: string;
localName: string;
component: ComponentModel;
isRoot: boolean;
isPage: boolean;
isCloudFunction: boolean;
isVisual: boolean;
hasWarnings: boolean;
}
export interface FolderItemData {
name: string;
path: string;
isOpen: boolean;
isComponentFolder: boolean;
component?: ComponentModel;
children: TreeNode[];
}
export type TreeNode =
| { type: 'component'; data: ComponentItemData }
| { type: 'folder'; data: FolderItemData };
export interface ComponentsPanelProps {
options?: {
showSheetList?: boolean;
hideSheets?: string[];
};
}
```
3. **Create basic `ComponentsPanel.tsx`:**
```typescript
import React from 'react';
import css from './ComponentsPanel.module.scss';
export function ComponentsPanel({ options }: ComponentsPanelProps) {
return (
<div className={css['ComponentsPanel']}>
<div className={css['Header']}>
<span className={css['Title']}>Components</span>
<button className={css['AddButton']}>+</button>
</div>
<div className={css['Tree']}>
{/* Tree will go here */}
<div style={{ padding: 16, color: '#888' }}>
ComponentsPanel React migration in progress...
</div>
</div>
</div>
);
}
```
4. **Update `router.setup.ts`:**
```typescript
// Change import
import { ComponentsPanel } from './views/panels/ComponentsPanel';
// In register call, panel should now be the React component
SidebarModel.instance.register({
id: 'components',
name: 'Components',
order: 1,
icon: IconName.Components,
onOpen: () => { /* ... */ },
panelProps: {
options: {
showSheetList: true,
hideSheets: ['__cloud__']
}
},
panel: ComponentsPanel // React component
});
```
5. **Port base styles to `ComponentsPanel.module.scss`** from `styles/componentspanel.css`
### Verify
- [ ] Panel appears when clicking Components icon in sidebar
- [ ] Placeholder text visible
- [ ] No console errors
---
## Session 2: Tree Rendering
### Goal
Render the actual component tree from ProjectModel.
### Steps
1. **Create `hooks/useComponentsPanel.ts`:**
- Subscribe to ProjectModel using `useModernModel`
- Build tree structure from components
- Track expanded folders in useState
- Track selected item in useState
2. **Port tree building logic** from `ComponentsPanel.ts`:
- `addComponentToFolderStructure()`
- `getFolderForComponentWithName()`
- Handle sheet filtering
3. **Create `components/ComponentTree.tsx`:**
- Recursive renderer
- Pass tree data and handlers
4. **Create `components/ComponentItem.tsx`:**
- Single row for component
- Icon based on type (use getComponentIconType)
- Selection state
- Warning indicator
5. **Create `components/FolderItem.tsx`:**
- Folder row with caret
- Expand/collapse on click
- Render children when expanded
### Verify
- [ ] Tree structure matches original
- [ ] Folders expand/collapse
- [ ] Selection works
- [ ] Icons correct
---
## Session 3: Context Menus
### Goal
Implement all context menu functionality.
### Steps
1. **Create `components/AddComponentMenu.tsx`:**
- Uses ComponentTemplates.instance.getTemplates()
- Renders PopupMenu with template options + Folder
2. **Wire header "+" button** to show AddComponentMenu
3. **Add context menu to ComponentItem:**
- Right-click handler
- Menu: Add submenu, Make home, Rename, Duplicate, Delete
4. **Add context menu to FolderItem:**
- Right-click handler
- Menu: Add submenu, Make home (if folder component), Rename, Duplicate, Delete
5. **Port action handlers:**
- `performAdd()` - create component/folder
- `onDeleteClicked()` - with confirmation
- `onDuplicateClicked()` / `onDuplicateFolderClicked()`
### Verify
- [ ] All menu items appear
- [ ] Actions work correctly
- [ ] Undo works
---
## Session 4: Drag-Drop
### Goal
Implement drag-drop for reorganizing components.
### Steps
1. **Create `hooks/useDragDrop.ts`:**
- Track drag state
- Integrate with PopupLayer.instance
2. **Add drag handlers to items:**
- mousedown/mousemove pattern from original
- Call PopupLayer.startDragging()
3. **Add drop zone handlers:**
- Folders are drop targets
- Top-level area is drop target
- Show visual feedback
4. **Port drop logic:**
- `getAcceptableDropType()` - validation
- `dropOn()` - execution with undo
### Verify
- [ ] Dragging shows label
- [ ] Valid targets highlight
- [ ] Invalid targets show feedback
- [ ] Drops work correctly
- [ ] Undo works
---
## Session 5: Inline Rename + Sheets
### Goal
Complete rename functionality and sheet selector.
### Steps
1. **Create `hooks/useRenameMode.ts`:**
- Track which item is being renamed
- Handle Enter/Escape/blur
2. **Add rename input UI:**
- Replaces label when in rename mode
- Auto-select text
- Validation
3. **Create `components/SheetSelector.tsx`:**
- Tab list from ProjectModel sheets
- Handle hideSheets option
- Switch current sheet on click
4. **Integrate SheetSelector:**
- Only show if options.showSheetList
- Filter tree by current sheet
### Verify
- [ ] Rename via double-click works
- [ ] Rename via menu works
- [ ] Sheets display and switch correctly
---
## Session 6: Polish + Cleanup
### Goal
Final cleanup, remove old files, prepare for TASK-004.
### Steps
1. **Style polish:**
- Match exact spacing/sizing
- Hover and focus states
2. **Code cleanup:**
- Remove any `any` types
- Add JSDoc comments
- Consistent naming
3. **Remove old files:**
- Delete `views/panels/componentspanel/ComponentsPanel.ts`
- Delete `templates/componentspanel.html`
- Update remaining imports
4. **TASK-004 preparation:**
- Add `migrationStatus` to ComponentItemData
- Add badge placeholder in ComponentItem
- Document extension points
5. **Update CHANGELOG.md**
### Verify
- [ ] All functionality works
- [ ] No errors
- [ ] Old files removed
- [ ] Ready for badges feature
---
## Key Files Reference
**Read these first:**
- `views/panels/componentspanel/ComponentsPanel.ts` - Logic to port
- `templates/componentspanel.html` - UI structure reference
- `views/panels/componentspanel/ComponentsPanelFolder.ts` - Data model
- `views/panels/componentspanel/ComponentTemplates.ts` - Template definitions
**Pattern references:**
- `views/panels/SearchPanel/SearchPanel.tsx` - Modern panel example
- `views/SidePanel/SidePanel.tsx` - Container that hosts panels
- `views/PopupLayer/PopupMenu.tsx` - Context menu component
- `hooks/useModel.ts` - useModernModel hook
---
## Confidence Checkpoints
After each session, verify:
1. No TypeScript errors: `npx tsc --noEmit`
2. App launches: `npm run dev`
3. Panel renders in sidebar
4. Previous functionality still works
**Before removing old files:** Test EVERYTHING twice.

View File

@@ -0,0 +1,345 @@
# TASK-004B ComponentsPanel React Migration - STATUS: BLOCKED
**Last Updated:** December 22, 2025
**Status:** 🚫 BLOCKED - Caching Issue Preventing Testing
**Completion:** ~85% (Backend works, UI update blocked)
---
## 🎯 Original Goal
Migrate the legacy ComponentsPanel to React while maintaining all functionality, with a focus on fixing the component rename feature that doesn't update the UI after renaming.
---
## ✅ What's Been Completed
### Phase 1-4: Foundation & Core Features ✅
- [x] React component structure created
- [x] Tree rendering implemented
- [x] Context menus working
- [x] Drag & drop functional
### Phase 5: Inline Rename - PARTIALLY COMPLETE
#### Backend Rename Logic ✅
The actual renaming **WORKS PERFECTLY**:
- Component renaming executes successfully
- Files are renamed on disk
- Project state updates correctly
- Changes are persisted (see console log: `Project saved...`)
**Evidence from console logs:**
```javascript
Calling performRename...
🔍 performRename result: true
Rename successful - canceling rename mode
Project saved Mon Dec 22 2025 22:03:56 GMT+0100
```
#### UI Update Logic - BLOCKED 🚫
The problem: **UI doesn't update after rename** because the React component never receives the `componentRenamed` event from ProjectModel.
**Root Cause:** useEventListener hook's useEffect never executes, preventing subscription to ProjectModel events.
---
## 🔍 Technical Investigation Summary
### Issue 1: React useEffect Not Running with Array Dependencies
**Problem:** When passing an array as a dependency to useEffect, React 19's `Object.is()` comparison always sees it as changed, but paradoxically, the useEffect never runs.
**Original Code (BROKEN):**
```typescript
const events = ['componentAdded', 'componentRemoved', 'componentRenamed'];
useEventListener(ProjectModel.instance, events, callback);
// Inside useEventListener:
useEffect(() => {
// Never runs!
}, [dispatcher, eventName]); // eventName is an array
```
**Solution Implemented:**
```typescript
// 1. Create stable array reference
const PROJECT_EVENTS = ['componentAdded', 'componentRemoved', 'componentRenamed'];
// 2. Spread array into individual dependencies
useEffect(() => {
// Should run now
}, [dispatcher, ...(Array.isArray(eventName) ? eventName : [eventName])]);
```
### Issue 2: Webpack 5 Persistent Caching
**Problem:** Even after fixing the code, changes don't appear in the running application.
**Root Cause:** Webpack 5 enables persistent caching by default:
- Cache location: `packages/noodl-editor/node_modules/.cache`
- Electron also caches: `~/Library/Application Support/Electron`
- Even after clearing caches and restarting `npm run dev`, old bundles persist
**Actions Taken:**
```bash
# Cleared all caches
rm -rf packages/noodl-editor/node_modules/.cache
rm -rf ~/Library/Application\ Support/Electron
rm -rf ~/Library/Application\ Support/OpenNoodl
```
**Still Blocked:** Despite cache clearing, debug markers never appear in console, indicating old code is still running.
---
## 📊 Current State Analysis
### What We KNOW Works
1. ✅ Source files contain all fixes (verified with grep)
2. ✅ Component rename backend executes successfully
3. ✅ useEventListener hook logic is correct (when it runs)
4. ✅ Debug logging is in place to verify execution
### What We KNOW Doesn't Work
1. ❌ useEventListener's useEffect never executes
2. ❌ No subscription to ProjectModel events occurs
3. ❌ UI never receives `componentRenamed` event
4. ❌ Debug markers (🔥) never appear in console
### What We DON'T Know
1. ❓ Why cache clearing doesn't force recompilation
2. ❓ If there's another cache layer we haven't found
3. ❓ If webpack-dev-server is truly recompiling on changes
4. ❓ If there's a build configuration preventing hot reload
---
## 🐛 Bonus Bug Discovered
**PopupMenu Constructor Error:**
```
Uncaught TypeError: _popuplayer__WEBPACK_IMPORTED_MODULE_3___default(...).PopupMenu is not a constructor
at ComponentItem.tsx:131:1
```
This is a **separate bug** affecting context menus (right-click). Unrelated to rename issue but should be fixed.
---
## 📁 Files Modified (With Debug Logging)
### Core Implementation Files
1. **packages/noodl-editor/src/editor/src/hooks/useEventListener.ts**
- Module load marker: `🔥 useEventListener.ts MODULE LOADED`
- useEffect marker: `🚨 useEventListener useEffect RUNNING!`
- Subscription marker: `📡 subscribing to...`
- Event received marker: `🔔 useEventListener received event`
2. **packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts**
- Module load marker: `🔥 useComponentsPanel.ts MODULE LOADED`
- Integration with useEventListener
- Stable PROJECT_EVENTS array
3. **packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx**
- Render markers
- Rename flow markers
### Documentation Files
1. **CACHE-CLEAR-RESTART-GUIDE.md** - Instructions for clearing caches
2. **RENAME-TEST-PLAN.md** - Test procedures
3. **This file** - Status documentation
---
## 🚧 Blocking Issues
### Primary Blocker: Webpack/Electron Caching
**Severity:** CRITICAL
**Impact:** Cannot test ANY changes to the code
**Symptoms:**
- Code changes in source files don't appear in running app
- Console shows NO debug markers (🔥, 🚨, 📡, 🔔)
- Multiple dev server restarts don't help
- Cache clearing doesn't help
**Possible Causes:**
1. Webpack dev server not watching TypeScript files correctly
2. Another cache layer (browser cache, service worker, etc.)
3. Electron loading from wrong bundle location
4. Build configuration preventing hot reload
5. macOS file system caching (unlikely but possible)
### Secondary Blocker: React 19 + EventDispatcher Incompatibility
**Severity:** HIGH
**Impact:** Even if caching is fixed, may need alternative approach
The useEventListener hook solution from TASK-008 may have edge cases with React 19's new behavior that weren't caught in isolation testing.
---
## 💡 Potential Solutions (Untested)
### Solution 1: Aggressive Cache Clearing Script
Create a script that:
- Kills all Node/Electron processes
- Clears all known cache directories
- Clears macOS file system cache
- Forces a clean webpack build
- Restarts with --no-cache flag
### Solution 2: Bypass useEventListener Temporarily
As a workaround, try direct subscription in component:
```typescript
useEffect(() => {
const group = { id: 'ComponentsPanel' };
const handler = () => setUpdateCounter((c) => c + 1);
ProjectModel.instance.on('componentRenamed', handler, group);
return () => ProjectModel.instance.off(group);
}, []);
```
### Solution 3: Use Polling as Temporary Fix
While not elegant, could work around the event issue:
```typescript
useEffect(() => {
const interval = setInterval(() => {
// Force re-render every 500ms when in rename mode
if (isRenaming) {
setUpdateCounter((c) => c + 1);
}
}, 500);
return () => clearInterval(interval);
}, [isRenaming]);
```
### Solution 4: Production Build Test
Build a production bundle to see if the issue is dev-only:
```bash
npm run build
# Test with production Electron app
```
---
## 📋 Next Steps for Future Developer
### Immediate Actions
1. **Verify caching issue:**
- Kill ALL node/electron processes: `killall node; killall Electron`
- Clear caches again
- Try adding a simple console.log to a DIFFERENT file to see if ANY changes load
2. **If caching persists:**
- Investigate webpack configuration in `webpackconfigs/`
- Check if there's a service worker
- Look for additional cache directories
- Consider creating a fresh dev environment in a new directory
3. **If caching resolved but useEffect still doesn't run:**
- Review React 19 useEffect behavior with array spreading
- Test useEventListener hook in isolation with a simple test case
- Consider alternative event subscription approach
### Alternative Approaches
1. **Revert to old panel temporarily** - The legacy panel works, could postpone migration
2. **Hybrid approach** - Use React for rendering but keep legacy event handling
3. **Full rewrite** - Start fresh with a different architecture pattern
---
## 🔬 Debug Checklist for Next Session
When picking this up again, verify these in order:
- [ ] Console shows 🔥 module load markers (proves new code loaded)
- [ ] Console shows 🚨 useEffect RUNNING marker (proves useEffect executes)
- [ ] Console shows 📡 subscription marker (proves ProjectModel subscription)
- [ ] Rename a component
- [ ] Console shows 🔔 event received marker (proves events are firing)
- [ ] Console shows 🎉 counter update marker (proves React re-renders)
- [ ] UI actually updates (proves the whole chain works)
**If step 1 fails:** Still a caching issue, don't proceed
**If step 1 passes, step 2 fails:** React useEffect issue, review dependency array
**If step 2 passes, step 3 fails:** EventDispatcher integration issue
**If step 3 passes, step 4 fails:** ProjectModel not emitting events
---
## 📚 Related Documentation
- **TASK-008**: EventDispatcher React Investigation (useEventListener solution)
- **LEARNINGS.md**: Webpack caching issues section (to be added)
- **CACHE-CLEAR-RESTART-GUIDE.md**: Instructions for clearing caches
- **RENAME-TEST-PLAN.md**: Test procedures for rename functionality
---
## 🎓 Key Learnings
1. **Webpack 5 caching is AGGRESSIVE** - Can persist across multiple dev server restarts
2. **React 19 + arrays in deps** - Spreading array items into deps is necessary
3. **EventDispatcher + React** - Requires careful lifecycle management
4. **Debug logging is essential** - Emoji markers made it easy to trace execution
5. **Test in isolation first** - useEventListener worked in isolation but fails in real app
---
## ⏱️ Time Investment
- Initial implementation: ~3 hours
- Debugging UI update issue: ~2 hours
- EventDispatcher investigation: ~4 hours
- Caching investigation: ~2 hours
- Documentation: ~1 hour
**Total: ~12 hours** - Majority spent on debugging caching/event issues rather than actual feature implementation.
---
## 🏁 Recommendation
**Option A (Quick Fix):** Use the legacy ComponentsPanel for now. It works, and this migration can wait.
**Option B (Workaround):** Implement one of the temporary solutions (polling or direct subscription) to unblock other work.
**Option C (Full Investigation):** Dedicate a full session to solving the caching mystery with fresh eyes, possibly in a completely new terminal/environment.
**My Recommendation:** Option A. The backend rename logic works perfectly. The UI update is a nice-to-have but not critical. Move on to more impactful work and revisit this when someone has time to fully diagnose the caching issue.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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! 🎉

View File

@@ -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).

View File

@@ -0,0 +1,131 @@
# TASK-008: EventDispatcher + React Hooks Investigation - CHANGELOG
## 2025-12-22 - Solution Implemented ✅
### Root Cause Identified
**The Problem**: EventDispatcher's context-object-based cleanup pattern is incompatible with React's closure-based lifecycle.
**Technical Details**:
- EventDispatcher uses `on(event, listener, group)` and `off(group)`
- React's useEffect creates new closures on every render
- The `group` object reference used in cleanup doesn't match the one from subscription
- This prevents proper cleanup AND somehow blocks event delivery entirely
### Solution: `useEventListener` Hook
Created a React-friendly hook at `packages/noodl-editor/src/editor/src/hooks/useEventListener.ts` that:
1. **Prevents Stale Closures**: Uses `useRef` to store callback, updated on every render
2. **Stable Group Reference**: Creates unique group object per subscription
3. **Automatic Cleanup**: Returns cleanup function that React can properly invoke
4. **Flexible Types**: Accepts EventDispatcher, Model subclasses, or any IEventEmitter
### Changes Made
#### 1. Created `useEventListener` Hook
**File**: `packages/noodl-editor/src/editor/src/hooks/useEventListener.ts`
- Main hook: `useEventListener(dispatcher, eventName, callback, deps?)`
- Convenience wrapper: `useEventListenerMultiple(dispatcher, eventNames, callback, deps?)`
- Supports both single events and arrays of events
- Optional dependency array for conditional re-subscription
#### 2. Updated ComponentsPanel
**Files**:
- `hooks/useComponentsPanel.ts`: Replaced manual subscription with `useEventListener`
- `ComponentsPanelReact.tsx`: Removed `forceRefresh` workaround
- `hooks/useComponentActions.ts`: Removed `onSuccess` callback parameter
**Before** (manual workaround):
```typescript
const [updateCounter, setUpdateCounter] = useState(0);
useEffect(() => {
const listener = { handleUpdate };
ProjectModel.instance.on('componentRenamed', () => handleUpdate('componentRenamed'), listener);
return () => ProjectModel.instance.off(listener);
}, []);
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
// In actions: performRename(item, name, () => forceRefresh());
```
**After** (clean solution):
```typescript
useEventListener(
ProjectModel.instance,
['componentAdded', 'componentRemoved', 'componentRenamed', 'rootNodeChanged'],
() => {
setUpdateCounter((c) => c + 1);
}
);
// In actions: performRename(item, name); // Events handled automatically!
```
### Benefits
**No More Manual Callbacks**: Events are properly received automatically
**No Tech Debt**: Removed workaround pattern from ComponentsPanel
**Reusable Solution**: Hook works for any EventDispatcher-based model
**Type Safe**: Proper TypeScript types with interface matching
**Scalable**: Can be used by all 56+ React components that need event subscriptions
### Testing
Verified that:
- ✅ Component rename updates UI immediately
- ✅ Folder rename updates UI immediately
- ✅ No stale closure issues
- ✅ Proper cleanup on unmount
- ✅ TypeScript compilation successful
### Impact
**Immediate**:
- ComponentsPanel now works correctly without workarounds
- Sets pattern for future React migrations
**Future**:
- 56+ existing React component subscriptions can be migrated to use this hook
- Major architectural improvement for jQuery View → React migrations
- Removes blocker for migrating more panels to React
### Files Modified
1. **Created**:
- `packages/noodl-editor/src/editor/src/hooks/useEventListener.ts`
2. **Updated**:
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts`
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx`
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts`
### Next Steps
1. ✅ Document pattern in LEARNINGS.md
2. ⬜ Create usage guide for other React components
3. ⬜ Consider migrating other components to use useEventListener
4. ⬜ Evaluate long-term migration to modern state management (Zustand/Redux)
---
## Investigation Summary
**Time Spent**: ~2 hours
**Status**: ✅ RESOLVED
**Solution Type**: React Bridge Hook (Solution 2 from POTENTIAL-SOLUTIONS.md)

View File

@@ -0,0 +1,549 @@
# Technical Notes: EventDispatcher + React Investigation
## Discovery Context
**Task**: TASK-004B ComponentsPanel React Migration, Phase 5 (Inline Rename)
**Date**: 2025-12-22
**Discovered by**: Debugging why rename UI wasn't updating after successful renames
---
## Detailed Timeline of Discovery
### Initial Problem
User renamed a component/folder in ComponentsPanel. The rename logic executed successfully:
- `performRename()` returned `true`
- ProjectModel showed the new name
- Project file saved to disk
- No errors in console
BUT: The UI didn't update to show the new name. The tree still displayed the old name until manual refresh.
### Investigation Steps
#### Step 1: Added Debug Logging
Added console.logs throughout the callback chain:
```typescript
// In RenameInput.tsx
const handleConfirm = () => {
console.log('🎯 RenameInput: Confirming rename');
onConfirm(value);
};
// In ComponentsPanelReact.tsx
onConfirm={(newName) => {
console.log('📝 ComponentsPanelReact: Rename confirmed', { newName });
const success = performRename(renamingItem, newName);
console.log('✅ ComponentsPanelReact: Rename result:', success);
}}
// In useComponentActions.ts
export function performRename(...) {
console.log('🔧 performRename: Starting', { item, newName });
// ...
console.log('✅ performRename: Success!');
return true;
}
```
**Result**: All callbacks fired, logic worked, but UI didn't update.
#### Step 2: Checked Event Subscription
The `useComponentsPanel` hook had event subscription code:
```typescript
useEffect(() => {
const handleUpdate = (eventName: string) => {
console.log('🔔 useComponentsPanel: Event received:', eventName);
setUpdateCounter((c) => c + 1);
};
const listener = { handleUpdate };
ProjectModel.instance.on('componentAdded', () => handleUpdate('componentAdded'), listener);
ProjectModel.instance.on('componentRemoved', () => handleUpdate('componentRemoved'), listener);
ProjectModel.instance.on('componentRenamed', () => handleUpdate('componentRenamed'), listener);
console.log('✅ useComponentsPanel: Event listeners registered');
return () => {
console.log('🧹 useComponentsPanel: Cleaning up event listeners');
ProjectModel.instance.off('componentAdded', listener);
ProjectModel.instance.off('componentRemoved', listener);
ProjectModel.instance.off('componentRenamed', listener);
};
}, []);
```
**Expected**: "🔔 useComponentsPanel: Event received: componentRenamed" log after rename
**Actual**: NOTHING. No event reception logs at all.
#### Step 3: Verified Event Emission
Added logging to ProjectModel.renameComponent():
```typescript
renameComponent(component, newName) {
// ... do the rename ...
console.log('📢 ProjectModel: Emitting componentRenamed event');
this.notifyListeners('componentRenamed', { component, oldName, newName });
}
```
**Result**: Event WAS being emitted! The emit log appeared, but the React hook never received it.
#### Step 4: Tried Different Subscription Patterns
Attempted various subscription patterns to see if any worked:
**Pattern A: Direct function**
```typescript
ProjectModel.instance.on('componentRenamed', () => {
console.log('Event received!');
setUpdateCounter((c) => c + 1);
});
```
Result: ❌ No event received
**Pattern B: Named function**
```typescript
function handleRenamed() {
console.log('Event received!');
setUpdateCounter((c) => c + 1);
}
ProjectModel.instance.on('componentRenamed', handleRenamed);
```
Result: ❌ No event received
**Pattern C: With useCallback**
```typescript
const handleRenamed = useCallback(() => {
console.log('Event received!');
setUpdateCounter((c) => c + 1);
}, []);
ProjectModel.instance.on('componentRenamed', handleRenamed);
```
Result: ❌ No event received
**Pattern D: Without context object**
```typescript
ProjectModel.instance.on('componentRenamed', () => {
console.log('Event received!');
});
// No third parameter (context object)
```
Result: ❌ No event received
**Pattern E: With useRef for stable reference**
```typescript
const listenerRef = useRef({ handleUpdate });
ProjectModel.instance.on('componentRenamed', listenerRef.current.handleUpdate, listenerRef.current);
```
Result: ❌ No event received
#### Step 5: Checked Legacy jQuery Views
Found that the old ComponentsPanel (jQuery-based View) subscribed to the same events:
```javascript
// In componentspanel/index.tsx (legacy)
this.projectModel.on('componentRenamed', this.onComponentRenamed, this);
```
**Question**: Does this work in the legacy View?
**Answer**: YES! Legacy Views receive events perfectly fine.
This proved:
- The events ARE being emitted correctly
- The EventDispatcher itself works
- But something about React hooks breaks the subscription
### Conclusion: Fundamental Incompatibility
After exhaustive testing, the conclusion is clear:
**EventDispatcher's pub/sub pattern does NOT work with React hooks.**
Even though:
- ✅ Events are emitted (verified with logs)
- ✅ Subscriptions are registered (no errors)
- ✅ Code looks correct
- ✅ Works fine in legacy jQuery Views
The events simply never reach React hook callbacks. This appears to be a fundamental architectural incompatibility.
---
## Workaround Implementation
Since event subscription doesn't work, implemented manual refresh callback pattern:
### Step 1: Add forceRefresh Function
In `useComponentsPanel.ts`:
```typescript
const [updateCounter, setUpdateCounter] = useState(0);
const forceRefresh = useCallback(() => {
console.log('🔄 Manual refresh triggered');
setUpdateCounter((c) => c + 1);
}, []);
return {
// ... other exports
forceRefresh
};
```
### Step 2: Add onSuccess Parameter
In `useComponentActions.ts`:
```typescript
export function performRename(
item: TreeItem,
newName: string,
onSuccess?: () => void // NEW: Success callback
): boolean {
// ... do the rename ...
if (success && onSuccess) {
console.log('✅ Calling onSuccess callback');
onSuccess();
}
return success;
}
```
### Step 3: Wire Through Component
In `ComponentsPanelReact.tsx`:
```typescript
const success = performRename(renamingItem, renameValue, () => {
console.log('✅ Rename success callback - calling forceRefresh');
forceRefresh();
});
```
### Step 4: Use Counter as Dependency
In `useComponentsPanel.ts`:
```typescript
const treeData = useMemo(() => {
console.log('🔄 Rebuilding tree (updateCounter:', updateCounter, ')');
return buildTree(ProjectModel.instance);
}, [updateCounter]); // Re-build when counter changes
```
### Bug Found: Missing Callback in Folder Rename
The folder rename branch didn't call `onSuccess()`:
```typescript
// BEFORE (bug):
if (item.type === 'folder') {
const undoGroup = new UndoGroup();
// ... rename logic ...
undoGroup.do();
return true; // ❌ Didn't call onSuccess!
}
// AFTER (fixed):
if (item.type === 'folder') {
const undoGroup = new UndoGroup();
// ... rename logic ...
undoGroup.do();
// Call success callback to trigger UI refresh
if (onSuccess) {
onSuccess();
}
return true; // ✅ Now triggers refresh
}
```
This bug meant folder renames didn't update the UI, but component renames did.
---
## EventDispatcher Implementation Details
From examining `EventDispatcher.ts`:
### How Listeners Are Stored
```typescript
class EventDispatcher {
private listeners: Map<string, Array<{ callback: Function; context: any }>>;
on(event: string, callback: Function, context?: any) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push({ callback, context });
}
off(event: string, context?: any) {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
// Remove listeners matching the context object
this.listeners.set(
event,
eventListeners.filter((l) => l.context !== context)
);
}
}
```
### How Events Are Emitted
```typescript
notifyListeners(event: string, data?: any) {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
// Call each listener
for (const listener of eventListeners) {
try {
listener.callback.call(listener.context, data);
} catch (e) {
console.error('Error in event listener:', e);
}
}
}
```
### Potential Issues with React
1. **Context Object Matching**:
- `off()` uses strict equality (`===`) to match context objects
- React's useEffect cleanup may not have the same reference
- Could prevent cleanup, leaving stale listeners
2. **Callback Invocation**:
- Uses `.call(listener.context, data)` to invoke callbacks
- If context is wrong, `this` binding might break
- React doesn't rely on `this`, so this shouldn't matter...
3. **Timing**:
- Events are emitted synchronously
- React state updates are asynchronous
- But setState in callbacks should work...
**Mystery**: Why don't the callbacks get invoked at all? The listeners should still be in the array, even if cleanup is broken.
---
## Hypotheses for Root Cause
### Hypothesis 1: React StrictMode Double-Invocation
React StrictMode (enabled in development) runs effects twice:
1. Mount → unmount → mount
This could:
- Register listener on first mount
- Remove listener on first unmount (wrong context?)
- Register listener again on second mount
- But now the old listener is gone?
**Test needed**: Try with StrictMode disabled
### Hypothesis 2: Context Object Reference Lost
```typescript
const listener = { handleUpdate };
ProjectModel.instance.on('event', handler, listener);
// Later in cleanup:
ProjectModel.instance.off('event', listener);
```
If the cleanup runs in a different closure, `listener` might be a new object, causing the filter in `off()` to not find the original listener.
But this would ACCUMULATE listeners, not prevent them from firing...
### Hypothesis 3: EventDispatcher Requires Legacy Context
EventDispatcher might have hidden dependencies on jQuery View infrastructure:
- Maybe it checks for specific properties on the context object?
- Maybe it integrates with View lifecycle somehow?
- Maybe there's initialization that React doesn't do?
**Test needed**: Deep dive into EventDispatcher implementation
### Hypothesis 4: React Rendering Phase Detection
React might be detecting that state updates are happening during render phase and silently blocking them. But our callbacks are triggered by user actions (renames), not during render...
---
## Comparison with Working jQuery Views
Legacy Views use EventDispatcher successfully:
```javascript
class ComponentsPanel extends View {
init() {
this.projectModel = ProjectModel.instance;
this.projectModel.on('componentRenamed', this.onComponentRenamed, this);
}
onComponentRenamed() {
this.render(); // Just re-render the whole view
}
dispose() {
this.projectModel.off('componentRenamed', this);
}
}
```
**Key differences**:
- Views have explicit `init()` and `dispose()` lifecycle
- Context object is `this` (the View instance), a stable reference
- Views use instance methods, not closures
- No dependency arrays or React lifecycle complexity
**Why it works**:
- The View instance is long-lived and stable
- Context object reference never changes
- Simple, predictable lifecycle
**Why React is different**:
- Functional components re-execute on every render
- Closures capture different variables each render
- useEffect cleanup might not match subscription
- No stable `this` reference
---
## Next Steps for Investigation
1. **Create minimal reproduction**:
- Simplest EventDispatcher + React hook
- Isolate the problem
- Add extensive logging
2. **Test in isolation**:
- React class component (has stable `this`)
- Without StrictMode
- Without other React features
3. **Examine EventDispatcher internals**:
- Add logging to every method
- Trace listener registration and invocation
- Check what's in the listeners array
4. **Explore solutions**:
- Can EventDispatcher be fixed?
- Should we migrate to modern state management?
- Is a React bridge possible?
---
## Workaround Pattern for Other Uses
If other React components need to react to ProjectModel changes, use this pattern:
```typescript
// 1. In hook, provide manual refresh
const [updateCounter, setUpdateCounter] = useState(0);
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
// 2. Export forceRefresh
return { forceRefresh, /* other exports */ };
// 3. In action functions, accept onSuccess callback
function performAction(data: any, onSuccess?: () => void) {
// ... do the action ...
if (success && onSuccess) {
onSuccess();
}
}
// 4. In component, wire them together
performAction(data, () => {
forceRefresh();
});
// 5. Use updateCounter as dependency
const derivedData = useMemo(() => {
return computeData();
}, [updateCounter]);
```
**Critical**: Call `onSuccess()` in ALL code paths (success, different branches, etc.)
---
## Files Changed During Discovery
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts` - Added forceRefresh
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts` - Added onSuccess callback
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/ComponentsPanelReact.tsx` - Wired forceRefresh through
- `dev-docs/reference/LEARNINGS.md` - Documented the discovery
- `dev-docs/tasks/phase-2/TASK-008-eventdispatcher-react-investigation/` - Created this investigation task
---
## Open Questions
1. Why don't the callbacks get invoked AT ALL? Even with broken cleanup, they should be in the listeners array...
2. Are there ANY React components successfully using EventDispatcher? (Need to search codebase)
3. Is this specific to ProjectModel, or do ALL EventDispatcher subclasses have this issue?
4. Does it work with React class components? (They have stable `this` reference)
5. What happens if we add extensive logging to EventDispatcher itself?
6. Is there something special about how ProjectModel emits events?
7. Could this be related to the Proxy pattern used in some models?
---
## References
- EventDispatcher: `packages/noodl-editor/src/editor/src/shared/utils/EventDispatcher.ts`
- ProjectModel: `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
- Working example (legacy View): `packages/noodl-editor/src/editor/src/views/panels/componentspanel/index.tsx`
- Workaround implementation: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/`

View File

@@ -0,0 +1,541 @@
# Potential Solutions: EventDispatcher + React Hooks
This document outlines potential solutions to the EventDispatcher incompatibility with React hooks.
---
## Solution 1: Fix EventDispatcher for React Compatibility
### Overview
Modify EventDispatcher to be compatible with React's lifecycle and closure patterns.
### Approach
1. **Remove context object requirement for React**:
- Add a new subscription method that doesn't require context matching
- Use WeakMap to track subscriptions by callback reference
- Auto-cleanup when callback is garbage collected
2. **Stable callback references**:
- Store callbacks with stable IDs
- Allow re-subscription with same ID to update callback
### Implementation Sketch
```typescript
class EventDispatcher {
private listeners: Map<string, Array<{ callback: Function; context?: any; id?: string }>>;
private nextId = 0;
// New React-friendly subscription
onReact(event: string, callback: Function): () => void {
const id = `react_${this.nextId++}`;
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push({ callback, id });
// Return cleanup function
return () => {
const eventListeners = this.listeners.get(event);
if (!eventListeners) return;
this.listeners.set(
event,
eventListeners.filter((l) => l.id !== id)
);
};
}
// Existing methods remain for backward compatibility
on(event: string, callback: Function, context?: any) {
// ... existing implementation
}
}
```
### Usage in React
```typescript
useEffect(() => {
const cleanup = ProjectModel.instance.onReact('componentRenamed', () => {
setUpdateCounter((c) => c + 1);
});
return cleanup;
}, []);
```
### Pros
- ✅ Minimal changes to existing code
- ✅ Backward compatible (doesn't break existing Views)
- ✅ Clean React-friendly API
- ✅ Automatic cleanup
### Cons
- ❌ Doesn't explain WHY current implementation fails
- ❌ Adds complexity to EventDispatcher
- ❌ Maintains legacy pattern (not modern state management)
- ❌ Still have two different APIs (confusing)
### Effort
**Estimated**: 4-8 hours
- 2 hours: Implement onReact method
- 2 hours: Test with existing components
- 2 hours: Update React components to use new API
- 2 hours: Documentation
---
## Solution 2: React Bridge Wrapper
### Overview
Create a React-specific hook that wraps EventDispatcher subscriptions.
### Implementation
```typescript
// hooks/useEventListener.ts
export function useEventListener<T = any>(
dispatcher: EventDispatcher,
eventName: string,
callback: (data?: T) => void
) {
const callbackRef = useRef(callback);
// Update ref on every render (avoid stale closures)
useEffect(() => {
callbackRef.current = callback;
});
useEffect(() => {
// Wrapper that calls current ref
const wrapper = (data?: T) => {
callbackRef.current(data);
};
// Create stable context object
const context = { id: Math.random() };
dispatcher.on(eventName, wrapper, context);
return () => {
dispatcher.off(eventName, context);
};
}, [dispatcher, eventName]);
}
```
### Usage
```typescript
function ComponentsPanel() {
const [updateCounter, setUpdateCounter] = useState(0);
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
setUpdateCounter((c) => c + 1);
});
// ... rest of component
}
```
### Pros
- ✅ Clean React API
- ✅ No changes to EventDispatcher
- ✅ Reusable across all React components
- ✅ Handles closure issues with useRef pattern
### Cons
- ❌ Still uses legacy EventDispatcher internally
- ❌ Adds indirection
- ❌ Doesn't fix the root cause
### Effort
**Estimated**: 2-4 hours
- 1 hour: Implement hook
- 1 hour: Test thoroughly
- 1 hour: Update existing React components
- 1 hour: Documentation
---
## Solution 3: Migrate to Modern State Management
### Overview
Replace EventDispatcher with a modern React state management solution.
### Option 3A: React Context + useReducer
```typescript
// contexts/ProjectContext.tsx
interface ProjectState {
components: Component[];
folders: Folder[];
version: number; // Increment on any change
}
const ProjectContext = createContext<{
state: ProjectState;
actions: {
renameComponent: (id: string, name: string) => void;
addComponent: (component: Component) => void;
removeComponent: (id: string) => void;
};
}>(null!);
export function ProjectProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(projectReducer, initialState);
const actions = useMemo(
() => ({
renameComponent: (id: string, name: string) => {
dispatch({ type: 'RENAME_COMPONENT', id, name });
ProjectModel.instance.renameComponent(id, name); // Sync with legacy
}
// ... other actions
}),
[]
);
return <ProjectContext.Provider value={{ state, actions }}>{children}</ProjectContext.Provider>;
}
export function useProject() {
return useContext(ProjectContext);
}
```
### Option 3B: Zustand
```typescript
// stores/projectStore.ts
import create from 'zustand';
interface ProjectStore {
components: Component[];
folders: Folder[];
renameComponent: (id: string, name: string) => void;
addComponent: (component: Component) => void;
removeComponent: (id: string) => void;
}
export const useProjectStore = create<ProjectStore>((set) => ({
components: [],
folders: [],
renameComponent: (id, name) => {
set((state) => ({
components: state.components.map((c) => (c.id === id ? { ...c, name } : c))
}));
ProjectModel.instance.renameComponent(id, name); // Sync with legacy
}
// ... other actions
}));
```
### Option 3C: Redux Toolkit
```typescript
// slices/projectSlice.ts
import { createSlice } from '@reduxjs/toolkit';
const projectSlice = createSlice({
name: 'project',
initialState: {
components: [],
folders: []
},
reducers: {
renameComponent: (state, action) => {
const component = state.components.find((c) => c.id === action.payload.id);
if (component) {
component.name = action.payload.name;
}
}
// ... other actions
}
});
export const { renameComponent } = projectSlice.actions;
```
### Pros
- ✅ Modern, React-native solution
- ✅ Better developer experience
- ✅ Time travel debugging (Redux DevTools)
- ✅ Predictable state updates
- ✅ Scales well for complex state
### Cons
- ❌ Major architectural change
- ❌ Need to sync with legacy ProjectModel
- ❌ High migration effort
- ❌ All React components need updating
- ❌ Risk of state inconsistencies during transition
### Effort
**Estimated**: 2-4 weeks
- Week 1: Set up state management, create stores
- Week 1-2: Implement sync layer with legacy models
- Week 2-3: Migrate all React components
- Week 3-4: Testing and bug fixes
---
## Solution 4: Proxy-based Reactive System
### Overview
Create a reactive wrapper around ProjectModel that React can subscribe to.
### Implementation
```typescript
// utils/createReactiveModel.ts
import { useSyncExternalStore } from 'react';
export function createReactiveModel<T extends EventDispatcher>(model: T) {
const subscribers = new Set<() => void>();
let version = 0;
// Listen to ALL events from the model
const eventProxy = new Proxy(model, {
get(target, prop) {
const value = target[prop];
if (prop === 'notifyListeners') {
return (...args: any[]) => {
// Call original
value.apply(target, args);
// Notify React subscribers
version++;
subscribers.forEach((callback) => callback());
};
}
return value;
}
});
return {
model: eventProxy,
subscribe: (callback: () => void) => {
subscribers.add(callback);
return () => subscribers.delete(callback);
},
getSnapshot: () => version
};
}
// Usage hook
export function useModelChanges(reactiveModel: ReturnType<typeof createReactiveModel>) {
return useSyncExternalStore(reactiveModel.subscribe, reactiveModel.getSnapshot, reactiveModel.getSnapshot);
}
```
### Usage
```typescript
// Create reactive wrapper once
const reactiveProject = createReactiveModel(ProjectModel.instance);
// In component
function ComponentsPanel() {
const version = useModelChanges(reactiveProject);
const treeData = useMemo(() => {
return buildTree(reactiveProject.model);
}, [version]);
// ... rest of component
}
```
### Pros
- ✅ Uses React 18's built-in external store API
- ✅ No changes to EventDispatcher or ProjectModel
- ✅ Automatic subscription management
- ✅ Works with any EventDispatcher-based model
### Cons
- ❌ Proxy overhead
- ❌ All events trigger re-render (no granularity)
- ❌ Requires React 18+
- ❌ Complex debugging
### Effort
**Estimated**: 1-2 days
- 4 hours: Implement reactive wrapper
- 4 hours: Test with multiple models
- 4 hours: Update React components
- 4 hours: Documentation and examples
---
## Solution 5: Manual Callbacks (Current Workaround)
### Overview
Continue using manual refresh callbacks as implemented in Task 004B.
### Pattern
```typescript
// Hook provides forceRefresh
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
// Actions accept onSuccess callback
function performAction(data: any, onSuccess?: () => void) {
// ... do work ...
if (success && onSuccess) {
onSuccess();
}
}
// Component wires them together
performAction(data, () => {
forceRefresh();
});
```
### Pros
- ✅ Already implemented and working
- ✅ Zero architectural changes
- ✅ Simple to understand
- ✅ Explicit control over refreshes
### Cons
- ❌ Tech debt accumulates
- ❌ Easy to forget callback in new code paths
- ❌ Not scalable for complex event chains
- ❌ Loses reactive benefits
### Effort
**Estimated**: Already done
- No additional work needed
- Just document the pattern
---
## Recommendation
### Short-term (0-1 month): Solution 2 - React Bridge Wrapper
Implement `useEventListener` hook to provide clean API for existing event subscriptions.
**Why**:
- Low effort, high value
- Fixes immediate problem
- Doesn't block future migrations
- Can coexist with manual callbacks
### Medium-term (1-3 months): Solution 4 - Proxy-based Reactive System
Implement reactive model wrappers using `useSyncExternalStore`.
**Why**:
- Uses modern React patterns
- Minimal changes to existing code
- Works with legacy models
- Provides automatic reactivity
### Long-term (3-6 months): Solution 3 - Modern State Management
Gradually migrate to Zustand or Redux Toolkit.
**Why**:
- Best developer experience
- Scales well
- Standard patterns
- Better tooling
### Migration Path
1. **Phase 1** (Week 1-2):
- Implement `useEventListener` hook
- Update ComponentsPanel to use it
- Document pattern
2. **Phase 2** (Month 2):
- Implement reactive model system
- Test with multiple components
- Roll out gradually
3. **Phase 3** (Month 3-6):
- Choose state management library
- Create stores for major models
- Migrate components one by one
- Maintain backward compatibility
---
## Decision Criteria
Choose solution based on:
1. **Timeline**: How urgently do we need React components?
2. **Scope**: How many Views are we migrating to React?
3. **Resources**: How much dev time is available?
4. **Risk tolerance**: Can we handle breaking changes?
5. **Long-term vision**: Are we fully moving to React?
**If migrating many Views**: Invest in Solution 3 (state management)
**If only a few React components**: Use Solution 2 (bridge wrapper)
**If unsure**: Start with Solution 2, migrate to Solution 3 later
---
## Questions to Answer
Before deciding on a solution:
1. How many jQuery Views are planned to migrate to React?
2. What's the timeline for full React migration?
3. Are there performance concerns with current EventDispatcher?
4. What state management libraries are already in the codebase?
5. Is there team expertise with modern state management?
6. What's the testing infrastructure like?
7. Can we afford breaking changes during transition?
---
## Next Actions
1. ✅ Complete this investigation documentation
2. ⬜ Present options to team
3. ⬜ Decide on solution approach
4. ⬜ Create implementation task
5. ⬜ Test POC with ComponentsPanel
6. ⬜ Roll out to other components

View File

@@ -0,0 +1,235 @@
# TASK-008: EventDispatcher + React Hooks Investigation
## Status: 🟡 Investigation Needed
**Created**: 2025-12-22
**Priority**: Medium
**Complexity**: High
---
## Overview
During Task 004B (ComponentsPanel React Migration), we discovered that the legacy EventDispatcher pub/sub pattern does not work with React hooks. Events are emitted by legacy models but never received by React components subscribed in `useEffect`. This investigation task aims to understand the root cause and propose long-term solutions.
---
## Problem Statement
### What's Broken
When a React component subscribes to ProjectModel events using the EventDispatcher pattern:
```typescript
// In useComponentsPanel.ts
useEffect(() => {
const handleUpdate = (eventName: string) => {
console.log('🔔 Event received:', eventName);
setUpdateCounter((c) => c + 1);
};
const listener = { handleUpdate };
ProjectModel.instance.on('componentAdded', () => handleUpdate('componentAdded'), listener);
ProjectModel.instance.on('componentRemoved', () => handleUpdate('componentRemoved'), listener);
ProjectModel.instance.on('componentRenamed', () => handleUpdate('componentRenamed'), listener);
return () => {
ProjectModel.instance.off('componentAdded', listener);
ProjectModel.instance.off('componentRemoved', listener);
ProjectModel.instance.off('componentRenamed', listener);
};
}, []);
```
**Expected behavior**: When `ProjectModel.renameComponent()` is called, it emits 'componentRenamed' event, and the React hook receives it.
**Actual behavior**:
- ProjectModel.renameComponent() DOES emit the event (verified with logs)
- The subscription code runs without errors
- BUT: The event handler is NEVER called
- No console logs, no state updates, complete silence
### Current Workaround
Manual refresh callback pattern (see NOTES.md for details):
1. Hook provides a `forceRefresh()` function that increments a counter
2. Action handlers accept an `onSuccess` callback parameter
3. Component passes `forceRefresh` as the callback
4. Successful actions call `onSuccess()` to trigger manual refresh
**Problem with workaround**:
- Creates tech debt
- Must remember to call `onSuccess()` in ALL code paths
- Doesn't scale to complex event chains
- Loses the benefits of reactive event-driven architecture
---
## Investigation Goals
### Primary Questions
1. **Why doesn't EventDispatcher work with React hooks?**
- Is it a closure issue?
- Is it a timing issue?
- Is it the context object pattern?
- Is it React's StrictMode double-invocation?
2. **What is the scope of the problem?**
- Does it affect ALL React components?
- Does it work in class components?
- Does it work in legacy jQuery Views?
- Are there any React components successfully using EventDispatcher?
3. **Is EventDispatcher fundamentally incompatible with React?**
- Or can it be fixed?
- What would need to change?
### Secondary Questions
4. **What are the migration implications?**
- How many places use EventDispatcher?
- How many are already React components?
- How hard would migration be?
5. **What is the best long-term solution?**
- Fix EventDispatcher?
- Replace with modern state management?
- Create a React bridge?
---
## Hypotheses
### Hypothesis 1: Context Object Reference Mismatch
EventDispatcher uses a context object for listener cleanup:
```typescript
model.on('event', handler, contextObject);
// Later:
model.off('event', contextObject); // Must be same object reference
```
React's useEffect cleanup may run in a different closure, causing the context object reference to not match, preventing proper cleanup and potentially blocking event delivery.
**How to test**: Try without context object, or use a stable ref.
### Hypothesis 2: Stale Closure
The handler function captures variables from the initial render. When the event fires later, those captured variables are stale, causing issues.
**How to test**: Use `useRef` to store the handler, update ref on every render.
### Hypothesis 3: Event Emission Timing
Events might be emitted before React components are ready to receive them, or during React's render phase when state updates are not allowed.
**How to test**: Add extensive timing logs, check React's render phase detection.
### Hypothesis 4: EventDispatcher Implementation Bug
The EventDispatcher itself may have issues with how it stores/invokes listeners, especially when mixed with React's lifecycle.
**How to test**: Deep dive into EventDispatcher.ts, add comprehensive logging.
---
## Test Plan
### Phase 1: Reproduce Minimal Case
Create the simplest possible reproduction:
1. Minimal EventDispatcher instance
2. Minimal React component with useEffect
3. Single event emission
4. Comprehensive logging at every step
### Phase 2: Comparative Testing
Test in different scenarios:
- React functional component with useEffect
- React class component with componentDidMount
- Legacy jQuery View
- React StrictMode on/off
- Development vs production build
### Phase 3: EventDispatcher Deep Dive
Examine EventDispatcher implementation:
- How are listeners stored?
- How are events emitted?
- How does context object matching work?
- Any special handling needed?
### Phase 4: Solution Prototyping
Test potential fixes:
- EventDispatcher modifications
- React bridge wrapper
- Migration to alternative patterns
---
## Success Criteria
This investigation is complete when we have:
1. ✅ Clear understanding of WHY events don't reach React hooks
2. ✅ Documented root cause with evidence
3. ✅ Evaluation of all potential solutions
4. ✅ Recommendation for long-term fix
5. ✅ Proof-of-concept implementation (if feasible)
6. ✅ Migration plan (if solution requires changes)
---
## Affected Areas
### Current Known Issues
-**ComponentsPanel**: Uses workaround (Task 004B)
### Potential Future Issues
Any React component that needs to:
- Subscribe to ProjectModel events
- Subscribe to NodeGraphModel events
- Subscribe to any EventDispatcher-based model
- React to data changes from legacy systems
### Estimated Impact
- **High**: If we continue migrating jQuery Views to React
- **Medium**: If we keep jQuery Views and only use React for new features
- **Low**: If we migrate away from EventDispatcher entirely
---
## Related Documentation
- [LEARNINGS.md](../../../reference/LEARNINGS.md#2025-12-22---eventdispatcher-events-dont-reach-react-hooks)
- [Task 004B Phase 5](../TASK-004B-componentsPanel-react-migration/phases/PHASE-5-INLINE-RENAME.md)
- EventDispatcher implementation: `packages/noodl-editor/src/editor/src/shared/utils/EventDispatcher.ts`
- Example workaround: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts`
---
## Timeline
**Status**: Not started
**Estimated effort**: 1-2 days investigation + 2-4 days implementation (depending on solution)
**Blocking**: No other tasks currently blocked
**Priority**: Should be completed before migrating more Views to React

View File

@@ -0,0 +1,344 @@
# useEventListener Hook - Usage Guide
## Overview
The `useEventListener` hook provides a React-friendly way to subscribe to EventDispatcher events. It solves the fundamental incompatibility between EventDispatcher's context-object-based cleanup and React's closure-based lifecycle.
## Location
```typescript
import { useEventListener } from '@noodl-hooks/useEventListener';
```
**File**: `packages/noodl-editor/src/editor/src/hooks/useEventListener.ts`
---
## Basic Usage
### Single Event
```typescript
import { ProjectModel } from '@noodl-models/projectmodel';
import { useEventListener } from '../../../../hooks/useEventListener';
function MyComponent() {
const [updateCounter, setUpdateCounter] = useState(0);
// Subscribe to a single event
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
setUpdateCounter((c) => c + 1);
});
return <div>Components updated {updateCounter} times</div>;
}
```
### Multiple Events
```typescript
// Subscribe to multiple events with one subscription
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () => {
console.log('Component changed');
setUpdateCounter((c) => c + 1);
});
```
### With Event Data
```typescript
interface RenameData {
component: ComponentModel;
oldName: string;
newName: string;
}
useEventListener<RenameData>(ProjectModel.instance, 'componentRenamed', (data) => {
console.log(`Renamed from ${data.oldName} to ${data.newName}`);
setUpdateCounter((c) => c + 1);
});
```
---
## Advanced Usage
### Conditional Subscription
Use the optional `deps` parameter to control when the subscription is active:
```typescript
const [isActive, setIsActive] = useState(true);
useEventListener(
isActive ? ProjectModel.instance : null, // Pass null to disable
'componentRenamed',
() => {
setUpdateCounter((c) => c + 1);
}
);
```
### With Dependencies
Re-subscribe when dependencies change:
```typescript
const [filter, setFilter] = useState('all');
useEventListener(
ProjectModel.instance,
'componentAdded',
(data) => {
// Callback uses current filter value
if (shouldShowComponent(data.component, filter)) {
addToList(data.component);
}
},
[filter] // Re-subscribe when filter changes
);
```
### Multiple Dispatchers
```typescript
function MyComponent() {
// Subscribe to ProjectModel events
useEventListener(ProjectModel.instance, 'componentRenamed', handleProjectUpdate);
// Subscribe to WarningsModel events
useEventListener(WarningsModel.instance, 'warningsChanged', handleWarningsUpdate);
// Subscribe to EventDispatcher singleton
useEventListener(EventDispatcher.instance, 'viewer-refresh', handleViewerRefresh);
}
```
---
## Common Patterns
### Pattern 1: Trigger Re-render on Model Changes
```typescript
function useComponentsPanel() {
const [updateCounter, setUpdateCounter] = useState(0);
// Re-render whenever components change
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () =>
setUpdateCounter((c) => c + 1)
);
// This will re-compute whenever updateCounter changes
const treeData = useMemo(() => {
return buildTreeFromProject(ProjectModel.instance);
}, [updateCounter]);
return { treeData };
}
```
### Pattern 2: Update Local State from Events
```typescript
function WarningsPanel() {
const [warnings, setWarnings] = useState([]);
useEventListener(WarningsModel.instance, 'warningsChanged', () => {
setWarnings(WarningsModel.instance.getWarnings());
});
return (
<div>
{warnings.map((warning) => (
<WarningItem key={warning.id} warning={warning} />
))}
</div>
);
}
```
### Pattern 3: Side Effects on Events
```typescript
function AutoSaver() {
const saveTimeoutRef = useRef<NodeJS.Timeout>();
useEventListener(ProjectModel.instance, ['componentAdded', 'componentRemoved', 'componentRenamed'], () => {
// Debounce saves
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => {
ProjectModel.instance.save();
}, 1000);
});
return null;
}
```
---
## Migration from Manual Subscriptions
### Before (Broken)
```typescript
// This doesn't work!
useEffect(() => {
const listener = { handleUpdate };
ProjectModel.instance.on('componentRenamed', () => handleUpdate(), listener);
return () => ProjectModel.instance.off(listener);
}, []);
```
### After (Working)
```typescript
// This works perfectly!
useEventListener(ProjectModel.instance, 'componentRenamed', () => {
handleUpdate();
});
```
### Before (Workaround)
```typescript
// Manual callback workaround
const [updateCounter, setUpdateCounter] = useState(0);
const forceRefresh = useCallback(() => {
setUpdateCounter((c) => c + 1);
}, []);
const performAction = (data, onSuccess) => {
// ... do action ...
if (onSuccess) onSuccess(); // Manual refresh
};
// In component:
performAction(data, () => forceRefresh());
```
### After (Clean)
```typescript
// Automatic event handling
const [updateCounter, setUpdateCounter] = useState(0);
useEventListener(ProjectModel.instance, 'actionCompleted', () => {
setUpdateCounter((c) => c + 1);
});
const performAction = (data) => {
// ... do action ...
// Event fires automatically, no callbacks needed!
};
```
---
## Type Safety
The hook is fully typed and works with TypeScript:
```typescript
interface ComponentData {
component: ComponentModel;
oldName?: string;
newName?: string;
}
// Type the event data
useEventListener<ComponentData>(ProjectModel.instance, 'componentRenamed', (data) => {
// data is typed as ComponentData | undefined
if (data) {
console.log(data.component.name); // ✅ TypeScript knows this is safe
}
});
```
---
## Supported Dispatchers
The hook works with any object that implements the `IEventEmitter` interface:
-`EventDispatcher` (and `EventDispatcher.instance`)
-`Model` subclasses (ProjectModel, WarningsModel, etc.)
- ✅ Any class with `on(event, listener, group)` and `off(group)` methods
---
## Best Practices
### ✅ DO:
- Use `useEventListener` for all EventDispatcher subscriptions in React components
- Pass `null` as dispatcher if you want to conditionally disable subscriptions
- Use the optional `deps` array when your callback depends on props/state
- Type your event data with the generic parameter for better IDE support
### ❌ DON'T:
- Don't try to use manual `on()`/`off()` subscriptions in React - they won't work
- Don't forget to handle `null` dispatchers if using conditional subscriptions
- Don't create new objects in the deps array - they'll cause infinite re-subscriptions
- Don't call `setState` directly inside event handlers without checking if component is mounted
---
## Troubleshooting
### Events Not Firing
**Problem**: Event subscription seems to work, but callback never fires.
**Solution**: Make sure you're using `useEventListener` instead of manual `on()`/`off()` calls.
### Stale Closure Issues
**Problem**: Callback uses old values of props/state.
**Solution**: The hook already handles this with `useRef`. If you still see issues, add dependencies to the `deps` array.
### Memory Leaks
**Problem**: Component unmounts but subscriptions remain.
**Solution**: The hook handles cleanup automatically. Make sure you're not holding references to the callback elsewhere.
### TypeScript Errors
**Problem**: "Type X is not assignable to EventDispatcher"
**Solution**: The hook accepts any `IEventEmitter`. Your model might need to properly extend `EventDispatcher` or `Model`.
---
## Examples in Codebase
See these files for real-world usage examples:
- `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts`
- (More examples as other components are migrated)
---
## Future Improvements
Potential enhancements for the future:
1. **Selective Re-rendering**: Only re-render when specific event data changes
2. **Event Filtering**: Built-in support for conditional event handling
3. **Debouncing**: Optional built-in debouncing for high-frequency events
4. **Event History**: Debug mode that tracks all received events
---
## Related Documentation
- [TASK-008 README](./README.md) - Investigation overview
- [CHANGELOG](./CHANGELOG.md) - Implementation details
- [NOTES](./NOTES.md) - Discovery process
- [LEARNINGS.md](../../../reference/LEARNINGS.md) - Lessons learned

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
/**
* ComponentsPanel Styles
*
* Migrated from legacy componentspanel.css to CSS modules
* Using design tokens for proper theming support
*/
.ComponentsPanel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 10px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-bg-2);
color: var(--theme-color-fg-highlight);
font: 12px var(--font-family-regular);
}
.Title {
font: 12px var(--font-family-regular);
color: var(--theme-color-fg-highlight);
}
.AddButton {
width: 24px;
height: 24px;
background-color: transparent;
color: var(--theme-color-fg-default);
border: none;
border-radius: 2px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: opacity var(--speed-turbo), background-color var(--speed-turbo);
&:hover {
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-highlight);
opacity: 1;
}
}
.Tree {
flex: 1;
overflow: hidden overlay;
display: flex;
flex-direction: column;
}
.PlaceholderMessage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--theme-color-fg-default-shy);
text-align: center;
font: 12px var(--font-family-regular);
span {
display: block;
}
}
/* Tree items */
.TreeItem {
display: flex;
align-items: center;
padding: 6px 10px;
cursor: pointer;
font: 11px var(--font-family-regular);
color: var(--theme-color-fg-default);
user-select: none;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-3);
}
&.Selected {
background-color: var(--theme-color-primary-transparent);
color: var(--theme-color-primary);
}
&.DropTarget {
background-color: var(--theme-color-primary-transparent);
border: 2px dashed var(--theme-color-primary);
border-radius: 4px;
transition: all 0.15s ease;
}
}
.Caret {
width: 12px;
height: 12px;
margin-right: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
color: var(--theme-color-fg-muted);
transition: transform 0.15s ease;
&.Expanded {
transform: rotate(90deg);
}
}
.ItemContent {
display: flex;
align-items: center;
flex: 1;
gap: 6px;
}
.Icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-fg-default);
}
.Label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Warning {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--theme-color-warning);
color: var(--theme-color-bg-1);
border-radius: 50%;
font-size: 10px;
font-weight: bold;
}
/* Rename Input */
.RenameContainer {
display: flex;
align-items: center;
padding: 6px 10px;
background-color: var(--theme-color-bg-3);
}
.RenameInput {
flex: 1;
padding: 4px 8px;
font: 11px var(--font-family-regular);
color: var(--theme-color-fg-default);
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-primary);
border-radius: 4px;
outline: none;
transition: border-color var(--speed-turbo), box-shadow var(--speed-turbo);
&:focus {
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px var(--theme-color-primary-transparent);
background-color: var(--theme-color-bg-1);
}
&::selection {
background-color: var(--theme-color-primary);
color: var(--theme-color-bg-1);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
/**
* RenameInput
*
* Inline input field for renaming components/folders.
* Auto-focuses and selects text on mount.
*/
import React, { useCallback, useEffect, useRef } from 'react';
import css from '../ComponentsPanel.module.scss';
interface RenameInputProps {
value: string;
onChange: (value: string) => void;
onConfirm: () => void;
onCancel: () => void;
level: number;
}
export function RenameInput({ value, onChange, onConfirm, onCancel, level }: RenameInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const indent = level * 12;
// Auto-focus and select all on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
console.log('🔍 RenameInput keyDown:', e.key);
if (e.key === 'Enter') {
console.log('✅ Enter pressed - calling onConfirm');
e.preventDefault();
e.stopPropagation();
onConfirm();
} else if (e.key === 'Escape') {
console.log('✅ Escape pressed - calling onCancel');
e.preventDefault();
e.stopPropagation();
onCancel();
}
},
[onConfirm, onCancel]
);
const handleBlur = useCallback(() => {
console.log('🔍 RenameInput blur - calling onConfirm');
onConfirm();
}, [onConfirm]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
[onChange]
);
return (
<div className={css['RenameContainer']} style={{ paddingLeft: `${indent + 23}px` }}>
<input
ref={inputRef}
type="text"
className={css['RenameInput']}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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