mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Finished inital project migration workflow
This commit is contained in:
688
dev-docs/future-projects/CANVAS-MODERNISATION-PROJECT.md
Normal file
688
dev-docs/future-projects/CANVAS-MODERNISATION-PROJECT.md
Normal file
@@ -0,0 +1,688 @@
|
||||
# Project: Node Canvas Editor Modernization
|
||||
|
||||
## Overview
|
||||
|
||||
**Goal:** Transform the custom node canvas editor from an opaque, monolithic legacy system into a well-documented, modular, and testable architecture that the team can confidently extend and maintain.
|
||||
|
||||
**Why this matters:**
|
||||
- The canvas is the core developer UX - every user interaction flows through it
|
||||
- Current ~2000+ line monolith (`nodegrapheditor.ts`) is intimidating for contributors
|
||||
- AI-assisted coding works dramatically better with smaller, focused files
|
||||
- Enables future features (minimap, connection tracing, better comments) without fear
|
||||
- Establishes patterns for modernizing other legacy parts of the codebase
|
||||
|
||||
**Out of scope (for now):**
|
||||
- Migration to React Flow or other library
|
||||
- Runtime/execution changes
|
||||
- New feature implementation (those come after this foundation)
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Core Files
|
||||
|
||||
| File | Lines (est.) | Responsibility | Coupling Level |
|
||||
|------|--------------|----------------|----------------|
|
||||
| `nodegrapheditor.ts` | ~2000+ | Everything: rendering, interaction, selection, pan/zoom, connections, undo, clipboard | Extreme - God object |
|
||||
| `NodeGraphEditorNode.ts` | ~600 | Node rendering, layout, port drawing | High - tied to parent |
|
||||
| `NodeGraphEditorConnection.ts` | ~300 | Connection/noodle rendering, hit testing | Medium |
|
||||
| `commentlayer.ts` | ~400 | Comment system orchestration | Medium - React bridge |
|
||||
| `CommentLayer/*.tsx` | ~500 total | Comment React components | Lower - mostly isolated |
|
||||
|
||||
### Key Integration Points
|
||||
|
||||
The canvas talks to these systems (will need interface boundaries):
|
||||
- `ProjectModel.instance` - Project state singleton
|
||||
- `NodeLibrary.instance` - Node type definitions, color schemes
|
||||
- `DebugInspector.InspectorsModel` - Data inspection/pinning
|
||||
- `WarningsModel.instance` - Node warning states
|
||||
- `UndoQueue.instance` - Undo/redo management
|
||||
- `EventDispatcher.instance` - Global event bus
|
||||
- `PopupLayer.instance` - Context menus, tooltips
|
||||
- `ToastLayer` - User notifications
|
||||
|
||||
### Current Rendering Pipeline
|
||||
|
||||
```
|
||||
paint() called
|
||||
→ clearRect()
|
||||
→ scale & translate context
|
||||
→ paintHierarchy() - parent/child lines
|
||||
→ paint connections (normal)
|
||||
→ paint connections (highlighted - second pass for z-order)
|
||||
→ paint nodes
|
||||
→ paint drag indicators
|
||||
→ paint multiselect box
|
||||
→ paint dragging connection preview
|
||||
```
|
||||
|
||||
### Current Interaction Handling
|
||||
|
||||
All mouse events funnel through single `mouse(type, pos, evt)` method with massive switch/if chains handling:
|
||||
- Node selection (single, multi, add-to)
|
||||
- Node dragging
|
||||
- Connection creation
|
||||
- Pan (right-click, middle-click, space+left)
|
||||
- Zoom (wheel)
|
||||
- Context menus
|
||||
- Insert location indicators
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
views/
|
||||
└── NodeGraphEditor/
|
||||
├── index.ts # Public API export
|
||||
├── NodeGraphEditor.ts # Main orchestrator (slim)
|
||||
├── ARCHITECTURE.md # Living documentation
|
||||
│
|
||||
├── core/
|
||||
│ ├── CanvasRenderer.ts # Canvas 2D rendering pipeline
|
||||
│ ├── ViewportManager.ts # Pan, zoom, scale, bounds
|
||||
│ ├── GraphLayout.ts # Node positioning, AABB calculations
|
||||
│ └── types.ts # Shared interfaces and types
|
||||
│
|
||||
├── interaction/
|
||||
│ ├── InteractionManager.ts # Mouse/keyboard event routing
|
||||
│ ├── SelectionManager.ts # Single/multi select, highlight state
|
||||
│ ├── DragManager.ts # Node dragging, drop targets
|
||||
│ ├── ConnectionDragManager.ts # Creating new connections
|
||||
│ └── PanZoomHandler.ts # Viewport manipulation
|
||||
│
|
||||
├── rendering/
|
||||
│ ├── NodeRenderer.ts # Individual node painting
|
||||
│ ├── ConnectionRenderer.ts # Connection/noodle painting
|
||||
│ ├── HierarchyRenderer.ts # Parent-child relationship lines
|
||||
│ └── OverlayRenderer.ts # Selection boxes, drag previews
|
||||
│
|
||||
├── features/
|
||||
│ ├── ClipboardManager.ts # Cut, copy, paste
|
||||
│ ├── UndoIntegration.ts # UndoQueue bridge
|
||||
│ ├── ContextMenus.ts # Right-click menus
|
||||
│ └── ConnectionTracer.ts # NEW: Connection chain navigation
|
||||
│
|
||||
├── comments/ # Existing React layer (enhance)
|
||||
│ ├── CommentLayer.ts
|
||||
│ ├── CommentLayerView.tsx
|
||||
│ ├── CommentForeground.tsx
|
||||
│ ├── CommentBackground.tsx
|
||||
│ └── CommentStyles.ts # NEW: Extended styling options
|
||||
│
|
||||
└── __tests__/
|
||||
├── CanvasRenderer.test.ts
|
||||
├── ViewportManager.test.ts
|
||||
├── SelectionManager.test.ts
|
||||
├── ConnectionRenderer.test.ts
|
||||
└── integration/
|
||||
└── NodeGraphEditor.integration.test.ts
|
||||
```
|
||||
|
||||
### Key Interfaces
|
||||
|
||||
```typescript
|
||||
// core/types.ts
|
||||
|
||||
export interface IViewport {
|
||||
readonly pan: { x: number; y: number };
|
||||
readonly scale: number;
|
||||
readonly bounds: AABB;
|
||||
|
||||
setPan(x: number, y: number): void;
|
||||
setScale(scale: number, focalPoint?: Point): void;
|
||||
screenToCanvas(screenPoint: Point): Point;
|
||||
canvasToScreen(canvasPoint: Point): Point;
|
||||
fitToContent(padding?: number): void;
|
||||
}
|
||||
|
||||
export interface ISelectionManager {
|
||||
readonly selectedNodes: ReadonlyArray<NodeGraphEditorNode>;
|
||||
readonly highlightedNode: NodeGraphEditorNode | null;
|
||||
readonly highlightedConnection: NodeGraphEditorConnection | null;
|
||||
|
||||
select(nodes: NodeGraphEditorNode[]): void;
|
||||
addToSelection(node: NodeGraphEditorNode): void;
|
||||
removeFromSelection(node: NodeGraphEditorNode): void;
|
||||
clearSelection(): void;
|
||||
setHighlight(node: NodeGraphEditorNode | null): void;
|
||||
isSelected(node: NodeGraphEditorNode): boolean;
|
||||
|
||||
// Events
|
||||
on(event: 'selectionChanged', handler: (nodes: NodeGraphEditorNode[]) => void): void;
|
||||
}
|
||||
|
||||
export interface IConnectionTracer {
|
||||
// Start tracing from a connection
|
||||
startTrace(connection: NodeGraphEditorConnection): void;
|
||||
|
||||
// Navigate along the trace
|
||||
nextConnection(): NodeGraphEditorConnection | null;
|
||||
previousConnection(): NodeGraphEditorConnection | null;
|
||||
|
||||
// Get all connections in current trace
|
||||
getTraceChain(): ReadonlyArray<NodeGraphEditorConnection>;
|
||||
|
||||
// Clear trace state
|
||||
clearTrace(): void;
|
||||
|
||||
// Visual state
|
||||
readonly activeTrace: ReadonlyArray<NodeGraphEditorConnection>;
|
||||
}
|
||||
|
||||
export interface IRenderContext {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
viewport: IViewport;
|
||||
paintRect: AABB;
|
||||
theme: ColorScheme;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Documentation & Analysis (3-4 days)
|
||||
|
||||
**Goal:** Fully understand and document current system before changing anything.
|
||||
|
||||
**Tasks:**
|
||||
1. Create `ARCHITECTURE.md` documenting:
|
||||
- Current file responsibilities
|
||||
- Data flow diagrams
|
||||
- Event flow diagrams
|
||||
- Integration point catalog
|
||||
- Known quirks and gotchas
|
||||
|
||||
2. Add inline documentation to existing code:
|
||||
- JSDoc for all public methods
|
||||
- Explain non-obvious logic
|
||||
- Mark technical debt with `// TODO(canvas-refactor):`
|
||||
|
||||
3. Create dependency graph visualization
|
||||
|
||||
**Deliverables:**
|
||||
- `NodeGraphEditor/ARCHITECTURE.md`
|
||||
- Fully documented `nodegrapheditor.ts` (comments only, no code changes)
|
||||
- Mermaid diagram of component interactions
|
||||
|
||||
**Confidence checkpoint:** Can explain any part of the canvas system to a new developer.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Testing Foundation (4-5 days)
|
||||
|
||||
**Goal:** Establish testing infrastructure before refactoring.
|
||||
|
||||
**Tasks:**
|
||||
1. Set up testing environment for canvas code:
|
||||
- Jest configuration for canvas mocking
|
||||
- Helper utilities for creating test nodes/connections
|
||||
- Snapshot testing for render output (optional)
|
||||
|
||||
2. Write characterization tests for current behavior:
|
||||
- Selection behavior (single click, shift+click, ctrl+click, marquee)
|
||||
- Pan/zoom behavior
|
||||
- Connection creation
|
||||
- Clipboard operations
|
||||
- Undo/redo integration
|
||||
|
||||
3. Create test fixtures:
|
||||
- Sample graph configurations
|
||||
- Mock ProjectModel, NodeLibrary, etc.
|
||||
|
||||
**Deliverables:**
|
||||
- `__tests__/` directory structure
|
||||
- Test utilities and fixtures
|
||||
- 70%+ characterization test coverage for interaction logic
|
||||
- CI integration for canvas tests
|
||||
|
||||
**Confidence checkpoint:** Tests catch regressions when code is modified.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Extract Core Modules (5-6 days)
|
||||
|
||||
**Goal:** Pull out clearly separable concerns without changing behavior.
|
||||
|
||||
**Order of extraction (lowest risk first):**
|
||||
|
||||
1. **ViewportManager** (~1 day)
|
||||
- Extract: `getPanAndScale`, `setPanAndScale`, `clampPanAndScale`, `updateZoomLevel`, `centerToFit`
|
||||
- Pure calculations, minimal dependencies
|
||||
- Easy to test independently
|
||||
|
||||
2. **GraphLayout** (~1 day)
|
||||
- Extract: `calculateNodesAABB`, `getCenterPanAndScale`, `getCenterRootPanAndScale`, AABB utilities
|
||||
- Pure geometry calculations
|
||||
- Easy to test
|
||||
|
||||
3. **SelectionManager** (~1.5 days)
|
||||
- Extract: `selector` object, highlight state, multi-select logic
|
||||
- Currently scattered across mouse handlers
|
||||
- Introduce event emitter for state changes
|
||||
|
||||
4. **ClipboardManager** (~1 day)
|
||||
- Extract: `copySelected`, `paste`, `getNodeSetFromClipboard`, `insertNodeSet`
|
||||
- Relatively self-contained
|
||||
|
||||
5. **Types & Interfaces** (~0.5 days)
|
||||
- Create `types.ts` with all shared interfaces
|
||||
- Migrate inline types
|
||||
|
||||
**Approach for each extraction:**
|
||||
```
|
||||
1. Create new file with extracted code
|
||||
2. Import into nodegrapheditor.ts
|
||||
3. Delegate calls to new module
|
||||
4. Run tests - verify no behavior change
|
||||
5. Commit
|
||||
```
|
||||
|
||||
**Deliverables:**
|
||||
- `core/ViewportManager.ts` with tests
|
||||
- `core/GraphLayout.ts` with tests
|
||||
- `interaction/SelectionManager.ts` with tests
|
||||
- `features/ClipboardManager.ts` with tests
|
||||
- `core/types.ts`
|
||||
|
||||
**Confidence checkpoint:** `nodegrapheditor.ts` reduced by ~400-500 lines, all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Extract Rendering Pipeline (4-5 days)
|
||||
|
||||
**Goal:** Separate what we draw from when/why we draw it.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **CanvasRenderer** (~1.5 days)
|
||||
- Extract: `paint()` method orchestration
|
||||
- Introduce `IRenderContext` for dependency injection
|
||||
- Make rendering stateless (receives state, outputs pixels)
|
||||
|
||||
2. **NodeRenderer** (~1 day)
|
||||
- Extract from `NodeGraphEditorNode.paint()`
|
||||
- Parameterize colors, sizes for future customization
|
||||
- Document the rendering anatomy of a node
|
||||
|
||||
3. **ConnectionRenderer** (~1 day)
|
||||
- Extract from `NodeGraphEditorConnection.paint()`
|
||||
- Prepare for future routing algorithms
|
||||
- Add support for trace highlighting (prep for Phase 6)
|
||||
|
||||
4. **OverlayRenderer** (~0.5 days)
|
||||
- Extract: multiselect box, drag preview, insert indicators
|
||||
- These are temporary visual states
|
||||
|
||||
**Deliverables:**
|
||||
- `rendering/` module with all renderers
|
||||
- Renderer unit tests
|
||||
- Clear separation: state management ≠ rendering
|
||||
|
||||
**Confidence checkpoint:** Can modify node appearance without touching interaction code.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Extract Interaction Handling (4-5 days)
|
||||
|
||||
**Goal:** Untangle the mouse event spaghetti.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **InteractionManager** (~1 day)
|
||||
- Central event router
|
||||
- Delegates to specialized handlers based on state
|
||||
- Manages interaction modes (normal, panning, dragging, connecting)
|
||||
|
||||
2. **DragManager** (~1 day)
|
||||
- Node drag start/move/end
|
||||
- Drop target detection
|
||||
- Insert location indicators
|
||||
|
||||
3. **ConnectionDragManager** (~1 day)
|
||||
- New connection creation flow
|
||||
- Port detection and highlighting
|
||||
- Connection preview rendering
|
||||
|
||||
4. **PanZoomHandler** (~0.5 days)
|
||||
- Mouse wheel zoom
|
||||
- Right/middle click pan
|
||||
- Space+drag pan
|
||||
|
||||
5. **Refactor main mouse() method** (~0.5 days)
|
||||
- Reduce to simple routing logic
|
||||
- Each handler owns its interaction mode
|
||||
|
||||
**Deliverables:**
|
||||
- `interaction/` module complete
|
||||
- Interaction tests (simulate mouse events)
|
||||
- `nodegrapheditor.ts` mouse handling reduced to ~50 lines
|
||||
|
||||
**Confidence checkpoint:** Can add new interaction modes without touching existing handlers.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Feature Enablement - Connection Tracer (3-4 days)
|
||||
|
||||
**Goal:** Implement connection tracing as proof that the new architecture works.
|
||||
|
||||
**Feature spec:**
|
||||
- Click a connection to start tracing
|
||||
- Highlighted connection chain shows the data flow path
|
||||
- Keyboard navigation (Tab/Shift+Tab) to walk the chain
|
||||
- Visual distinction for traced connections (glow, thicker line, different color)
|
||||
- Click elsewhere or Escape to clear trace
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **ConnectionTracer module** (~1.5 days)
|
||||
- Graph traversal logic
|
||||
- Find upstream/downstream connections from a node's port
|
||||
- Handle cycles gracefully
|
||||
|
||||
2. **Visual integration** (~1 day)
|
||||
- Extend `ConnectionRenderer` for trace state
|
||||
- Add trace highlight color to theme
|
||||
- Subtle animation for active trace (optional)
|
||||
|
||||
3. **Interaction integration** (~1 day)
|
||||
- Add to `InteractionManager`
|
||||
- Keyboard handler for navigation
|
||||
- Context menu option: "Trace connection"
|
||||
|
||||
**Deliverables:**
|
||||
- `features/ConnectionTracer.ts` with full tests
|
||||
- Working connection tracing feature
|
||||
- Documentation for how to add similar features
|
||||
|
||||
**Confidence checkpoint:** Feature works, and implementation was straightforward given new architecture.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Feature Enablement - Comment Enhancements (2-3 days)
|
||||
|
||||
**Goal:** Improve comment system as second proof point.
|
||||
|
||||
**Feature spec:**
|
||||
- More color options
|
||||
- Border style options (solid, dashed, none)
|
||||
- Font size options (small, medium, large, extra-large)
|
||||
- Opacity control for filled comments
|
||||
- Corner radius options
|
||||
- Z-index control (send to back, bring to front)
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. **Extend comment model** (~0.5 days)
|
||||
- Add new properties: borderStyle, fontSize, opacity, cornerRadius, zIndex
|
||||
- Migration for existing comments (defaults)
|
||||
|
||||
2. **Update CommentForeground controls** (~1 day)
|
||||
- Extended toolbar UI
|
||||
- New control components
|
||||
|
||||
3. **Update rendering** (~0.5 days)
|
||||
- Apply new styles in CommentBackground
|
||||
- CSS updates
|
||||
|
||||
4. **Tests** (~0.5 days)
|
||||
- Comment styling tests
|
||||
- Backward compatibility tests
|
||||
|
||||
**Deliverables:**
|
||||
- Enhanced comment styling options
|
||||
- Updated `CommentStyles.ts`
|
||||
- Tests for new functionality
|
||||
|
||||
---
|
||||
|
||||
## File Change Summary
|
||||
|
||||
### Files to Create
|
||||
|
||||
```
|
||||
views/NodeGraphEditor/
|
||||
├── ARCHITECTURE.md
|
||||
├── core/
|
||||
│ ├── CanvasRenderer.ts
|
||||
│ ├── ViewportManager.ts
|
||||
│ ├── GraphLayout.ts
|
||||
│ └── types.ts
|
||||
├── interaction/
|
||||
│ ├── InteractionManager.ts
|
||||
│ ├── SelectionManager.ts
|
||||
│ ├── DragManager.ts
|
||||
│ ├── ConnectionDragManager.ts
|
||||
│ └── PanZoomHandler.ts
|
||||
├── rendering/
|
||||
│ ├── NodeRenderer.ts
|
||||
│ ├── ConnectionRenderer.ts
|
||||
│ ├── HierarchyRenderer.ts
|
||||
│ └── OverlayRenderer.ts
|
||||
├── features/
|
||||
│ ├── ClipboardManager.ts
|
||||
│ ├── UndoIntegration.ts
|
||||
│ ├── ContextMenus.ts
|
||||
│ └── ConnectionTracer.ts
|
||||
├── comments/
|
||||
│ └── CommentStyles.ts
|
||||
└── __tests__/
|
||||
└── [comprehensive test suite]
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `nodegrapheditor.ts` → Slim orchestrator importing modules
|
||||
- `NodeGraphEditorNode.ts` → Delegate rendering to NodeRenderer
|
||||
- `NodeGraphEditorConnection.ts` → Delegate rendering to ConnectionRenderer
|
||||
- `CommentLayerView.tsx` → Extended styling UI
|
||||
- `CommentForeground.tsx` → New controls
|
||||
- `CommentBackground.tsx` → New style application
|
||||
|
||||
### Files Unchanged
|
||||
|
||||
- `commentlayer.ts` → Keep as bridge layer (minor updates)
|
||||
- Model files (ProjectModel, NodeLibrary, etc.) → Interface boundaries only
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Each extracted module gets comprehensive unit tests:
|
||||
|
||||
```typescript
|
||||
// Example: ViewportManager.test.ts
|
||||
|
||||
describe('ViewportManager', () => {
|
||||
describe('screenToCanvas', () => {
|
||||
it('converts screen coordinates at scale 1', () => {
|
||||
const viewport = new ViewportManager({ width: 800, height: 600 });
|
||||
viewport.setPan(100, 50);
|
||||
|
||||
const result = viewport.screenToCanvas({ x: 200, y: 150 });
|
||||
|
||||
expect(result).toEqual({ x: 100, y: 100 });
|
||||
});
|
||||
|
||||
it('accounts for scale when converting', () => {
|
||||
const viewport = new ViewportManager({ width: 800, height: 600 });
|
||||
viewport.setScale(0.5);
|
||||
viewport.setPan(100, 50);
|
||||
|
||||
const result = viewport.screenToCanvas({ x: 200, y: 150 });
|
||||
|
||||
expect(result).toEqual({ x: 300, y: 250 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitToContent', () => {
|
||||
it('adjusts pan and scale to show all nodes', () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test module interactions:
|
||||
|
||||
```typescript
|
||||
// Example: Selection + Rendering integration
|
||||
|
||||
describe('Selection rendering integration', () => {
|
||||
it('renders selection box around selected nodes', () => {
|
||||
const graph = createTestGraph([
|
||||
{ id: 'node1', x: 0, y: 0 },
|
||||
{ id: 'node2', x: 200, y: 0 }
|
||||
]);
|
||||
const selection = new SelectionManager();
|
||||
const renderer = new CanvasRenderer();
|
||||
|
||||
selection.select([graph.nodes[0], graph.nodes[1]]);
|
||||
renderer.render(graph, selection);
|
||||
|
||||
expect(renderer.getLastRenderCall()).toContainOverlay('multiselect-box');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Characterization Tests
|
||||
|
||||
Capture current behavior before refactoring:
|
||||
|
||||
```typescript
|
||||
// Example: Existing pan behavior
|
||||
|
||||
describe('Pan behavior (characterization)', () => {
|
||||
it('right-click drag pans the viewport', async () => {
|
||||
const editor = await createTestEditor();
|
||||
const initialPan = editor.getPanAndScale();
|
||||
|
||||
await editor.simulateMouseEvent('down', { x: 100, y: 100, button: 2 });
|
||||
await editor.simulateMouseEvent('move', { x: 150, y: 120 });
|
||||
await editor.simulateMouseEvent('up', { x: 150, y: 120, button: 2 });
|
||||
|
||||
const finalPan = editor.getPanAndScale();
|
||||
expect(finalPan.x - initialPan.x).toBe(50);
|
||||
expect(finalPan.y - initialPan.y).toBe(20);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Quantitative
|
||||
|
||||
- [ ] `nodegrapheditor.ts` reduced from ~2000 to <500 lines
|
||||
- [ ] No single file >400 lines in new structure
|
||||
- [ ] Test coverage >80% for new modules
|
||||
- [ ] All existing functionality preserved (zero regressions)
|
||||
|
||||
### Qualitative
|
||||
|
||||
- [ ] New developer can understand canvas architecture in <30 minutes
|
||||
- [ ] Adding a new interaction mode takes <2 hours
|
||||
- [ ] Adding a new visual effect takes <1 hour
|
||||
- [ ] AI coding assistants can work effectively with individual modules
|
||||
- [ ] `ARCHITECTURE.md` accurately describes the system
|
||||
|
||||
### Feature Validation
|
||||
|
||||
- [ ] Connection tracing works as specified
|
||||
- [ ] Comment enhancements work as specified
|
||||
- [ ] Both features implemented using new architecture patterns
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Hidden dependencies break during extraction | Medium | High | Extensive characterization tests before any changes |
|
||||
| Performance regression from module overhead | Low | Medium | Benchmark critical paths, keep hot loops tight |
|
||||
| Over-engineering abstractions | Medium | Medium | Extract only what exists, don't pre-build for imagined needs |
|
||||
| Scope creep into features | Medium | Medium | Strict phase gates, no features until Phase 6 |
|
||||
| Breaking existing user workflows | Low | High | Full test coverage, careful rollout |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Phase | Duration | Dependencies |
|
||||
|-------|----------|--------------|
|
||||
| Phase 1: Documentation | 3-4 days | None |
|
||||
| Phase 2: Testing Foundation | 4-5 days | Phase 1 |
|
||||
| Phase 3: Core Modules | 5-6 days | Phase 2 |
|
||||
| Phase 4: Rendering | 4-5 days | Phase 3 |
|
||||
| Phase 5: Interaction | 4-5 days | Phase 3, 4 |
|
||||
| Phase 6: Connection Tracer | 3-4 days | Phase 5 |
|
||||
| Phase 7: Comment Enhancements | 2-3 days | Phase 4 |
|
||||
|
||||
**Total: 26-32 days** (5-7 weeks at sustainable pace)
|
||||
|
||||
Phases 6 and 7 can be done in parallel or interleaved with other work.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Create feature branch: `feature/canvas-editor-modernization`
|
||||
2. Start with Phase 1 - no code changes, just documentation
|
||||
3. Review `ARCHITECTURE.md` with team before proceeding
|
||||
4. Set up CI for canvas tests before Phase 3
|
||||
5. Small, frequent commits with clear messages
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Current Code Locations
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/
|
||||
├── nodegrapheditor.ts # Main canvas (THE MONOLITH)
|
||||
├── nodegrapheditor/
|
||||
│ ├── NodeGraphEditorNode.ts # Node rendering
|
||||
│ └── NodeGraphEditorConnection.ts # Connection rendering
|
||||
├── commentlayer.ts # Comment orchestration
|
||||
├── CommentLayer/
|
||||
│ ├── CommentLayer.css
|
||||
│ ├── CommentLayerView.tsx
|
||||
│ ├── CommentForeground.tsx
|
||||
│ └── CommentBackground.tsx
|
||||
└── documents/EditorDocument/
|
||||
└── hooks/
|
||||
├── UseCanvasView.ts
|
||||
└── UseImportNodeset.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for AI-Assisted Development
|
||||
|
||||
When working with Cline or similar tools on this refactoring:
|
||||
|
||||
1. **Single module focus**: Work on one module at a time, complete with tests
|
||||
2. **Confidence checks**: After each extraction, verify tests pass before continuing
|
||||
3. **Small commits**: Each extraction should be a single, reviewable commit
|
||||
4. **Documentation first**: Update `ARCHITECTURE.md` as you go
|
||||
5. **No premature optimization**: Extract what exists, optimize later if needed
|
||||
|
||||
Example prompt structure for Phase 3 extractions:
|
||||
```
|
||||
"Extract ViewportManager from nodegrapheditor.ts:
|
||||
1. Identify all pan/zoom/scale related code
|
||||
2. Create core/ViewportManager.ts with those methods
|
||||
3. Create interface IViewport in types.ts
|
||||
4. Add comprehensive unit tests
|
||||
5. Update nodegrapheditor.ts to use ViewportManager
|
||||
6. Verify all existing tests still pass
|
||||
7. Confidence score before committing?"
|
||||
```
|
||||
@@ -239,6 +239,305 @@ render() {
|
||||
|
||||
---
|
||||
|
||||
## 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' } };
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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!
|
||||
|
||||
// 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
|
||||
|
||||
---
|
||||
|
||||
### [2025-12-14] - CoreBaseDialog vs Modal Component Patterns
|
||||
|
||||
**Context**: Migration wizard popup wasn't working - clicks blocked, layout broken.
|
||||
|
||||
**Discovery**: OpenNoodl has two dialog patterns:
|
||||
|
||||
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
|
||||
|
||||
2. **Modal** (Problematic):
|
||||
- Wrapper component with additional complexity
|
||||
- Was causing issues with click handling and layout
|
||||
|
||||
When creating new dialogs, use the CoreBaseDialog pattern:
|
||||
```tsx
|
||||
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
|
||||
|
||||
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
|
||||
<div className={css['YourContainer']}>
|
||||
{/* Your content */}
|
||||
</div>
|
||||
</CoreBaseDialog>
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## Template for Future Entries
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -2,6 +2,388 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Session 8: Migration Marker Fix
|
||||
|
||||
#### 2024-12-15
|
||||
|
||||
**Fixed:**
|
||||
- **Migrated Projects Still Showing as Legacy** (`MigrationSession.ts`):
|
||||
- Root cause: `executeFinalizePhase()` was a placeholder with just `await this.simulateDelay(200)` and never updated project.json
|
||||
- The runtime detection system checks for `runtimeVersion` or `migratedFrom` fields in project.json
|
||||
- Without these markers, migrated projects were still detected as legacy React 17
|
||||
- Implemented actual finalization that:
|
||||
1. Reads the project.json from the target path
|
||||
2. Adds `runtimeVersion: "react19"` field
|
||||
3. Adds `migratedFrom` metadata object with:
|
||||
- `version: "react17"` - what it was migrated from
|
||||
- `date` - ISO timestamp of migration
|
||||
- `originalPath` - path to source project
|
||||
- `aiAssisted` - whether AI was used
|
||||
4. Writes the updated project.json back
|
||||
- Migrated projects now correctly identified as React 19 in project list
|
||||
|
||||
**Technical Notes:**
|
||||
- Runtime detection checks these fields in order:
|
||||
1. `runtimeVersion` field (highest confidence)
|
||||
2. `migratedFrom` field (indicates already migrated)
|
||||
3. `editorVersion` comparison to 1.2.0
|
||||
4. Legacy pattern scanning
|
||||
5. Creation date heuristic (lowest confidence)
|
||||
- Adding `runtimeVersion: "react19"` provides "high" confidence detection
|
||||
|
||||
**Files Modified:**
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Session 7: Complete Migration Implementation
|
||||
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
- **Text Color Invisible (Gray on Gray)** (All migration SCSS files):
|
||||
- Root cause: SCSS files used non-existent CSS variables like `--theme-color-fg-1` and `--theme-color-secondary` for text
|
||||
- `--theme-color-fg-1` doesn't exist in the theme - it's `--theme-color-fg-highlight`
|
||||
- `--theme-color-secondary` is a dark teal color (`#005769`) meant for backgrounds, not text
|
||||
- For text, should use `--theme-color-secondary-as-fg` which is a visible teal (`#7ec2cf`)
|
||||
- Updated all migration SCSS files with correct variable names:
|
||||
- `--theme-color-fg-1` → `--theme-color-fg-highlight` (white text, `#f5f5f5`)
|
||||
- `--theme-color-secondary` (when used for text color) → `--theme-color-secondary-as-fg` (readable teal, `#7ec2cf`)
|
||||
- Text is now visible with proper contrast against dark backgrounds
|
||||
|
||||
- **Migration Does Not Create Project Folder** (`MigrationSession.ts`):
|
||||
- Root cause: `executeCopyPhase()` was a placeholder that never actually copied files
|
||||
- Implemented actual file copying using `@noodl/platform` filesystem API
|
||||
- New `copyDirectoryRecursive()` method recursively copies all project files
|
||||
- Skips `node_modules` and `.git` directories for efficiency
|
||||
- Checks if target directory exists before copying (prevents overwrites)
|
||||
|
||||
- **"Open Migrated Project" Button Does Nothing** (`projectsview.ts`):
|
||||
- Root cause: `onComplete` callback didn't receive or use the target path
|
||||
- Updated callback signature to receive `targetPath: string` parameter
|
||||
- Now opens the migrated project from the correct target path
|
||||
- Shows success toast and updates project list
|
||||
|
||||
**Technical Notes:**
|
||||
- Theme color variable naming conventions:
|
||||
- `--theme-color-bg-*` for backgrounds (bg-1 through bg-4, darker to lighter)
|
||||
- `--theme-color-fg-*` for foreground/text (fg-highlight, fg-default, fg-default-shy, fg-muted)
|
||||
- `--theme-color-secondary` is `#005769` (dark teal) - background only!
|
||||
- `--theme-color-secondary-as-fg` is `#7ec2cf` (light teal) - use for text
|
||||
- filesystem API:
|
||||
- `filesystem.exists(path)` - check if path exists
|
||||
- `filesystem.makeDirectory(path)` - create directory
|
||||
- `filesystem.listDirectory(path)` - list contents (returns entries with `fullPath`, `name`, `isDirectory`)
|
||||
- `filesystem.readFile(path)` - read file contents
|
||||
- `filesystem.writeFile(path, content)` - write file contents
|
||||
|
||||
**Files Modified:**
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
|
||||
packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.module.scss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Session 6: Dialog Pattern Fix & Button Functionality
|
||||
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
- **"Start Migration" Button Does Nothing** (`MigrationWizard.tsx`):
|
||||
- Root cause: useReducer `state.session` was never initialized
|
||||
- Component used two sources of truth:
|
||||
1. `migrationSessionManager.getSession()` for rendering - worked fine
|
||||
2. `state.session` in reducer for actions - always null!
|
||||
- All action handlers checked `if (!state.session) return state;` and returned unchanged
|
||||
- Added `SET_SESSION` action type to initialize reducer state after session creation
|
||||
- Button clicks now properly dispatch actions and update state
|
||||
|
||||
- **Switched from Modal to CoreBaseDialog** (`MigrationWizard.tsx`):
|
||||
- Modal component was causing layout and interaction issues
|
||||
- CoreBaseDialog is the pattern used by working dialogs like ConfirmDialog
|
||||
- Changed import and component usage to use CoreBaseDialog directly
|
||||
- Props: `isVisible`, `hasBackdrop`, `onClose`
|
||||
|
||||
- **Fixed duplicate variable declaration** (`MigrationWizard.tsx`):
|
||||
- Had two `const session = migrationSessionManager.getSession()` declarations
|
||||
- Renamed one to `currentSession` to avoid redeclaration error
|
||||
|
||||
**Technical Notes:**
|
||||
- When using both an external manager AND useReducer, reducer state must be explicitly synchronized
|
||||
- CoreBaseDialog is the preferred pattern for dialogs - simpler and more reliable than Modal
|
||||
- Pattern for initializing reducer with async data:
|
||||
```tsx
|
||||
// In useEffect after async operation:
|
||||
dispatch({ type: 'SET_SESSION', session: createdSession });
|
||||
|
||||
// In reducer:
|
||||
case 'SET_SESSION':
|
||||
return { ...state, session: action.session };
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Session 5: Critical UI Bug Fixes
|
||||
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
- **Migration Wizard Buttons Not Clickable** (`BaseDialog.module.scss`):
|
||||
- Root cause: The `::after` pseudo-element on `.VisibleDialog` was covering the entire dialog
|
||||
- This overlay had no `pointer-events: none`, blocking all click events
|
||||
- Added `pointer-events: none` to `::after` pseudo-element
|
||||
- All buttons, icons, and interactive elements now work correctly
|
||||
|
||||
- **Migration Wizard Not Scrollable** (`MigrationWizard.module.scss`):
|
||||
- Root cause: Missing proper flex layout and overflow settings
|
||||
- Added `display: flex`, `flex-direction: column`, and `overflow: hidden` to `.MigrationWizard`
|
||||
- Added `flex: 1`, `min-height: 0`, and `overflow-y: auto` to `.WizardContent`
|
||||
- Modal content now scrolls properly on shorter screen heights
|
||||
|
||||
- **Gray-on-Gray Text (Low Contrast)** (All step SCSS modules):
|
||||
- Root cause: SCSS files used undefined CSS variables like `--color-grey-800`, `--color-grey-400`, etc.
|
||||
- The theme only defines `--theme-color-*` variables, causing undefined values
|
||||
- Updated all migration wizard SCSS files to use proper theme variables:
|
||||
- `--theme-color-bg-1`, `--theme-color-bg-2`, `--theme-color-bg-3` for backgrounds
|
||||
- `--theme-color-fg-1` for primary text
|
||||
- `--theme-color-secondary` for secondary text
|
||||
- `--theme-color-primary`, `--theme-color-success`, `--theme-color-warning`, `--theme-color-danger` for status colors
|
||||
- Text now has proper contrast against modal background
|
||||
|
||||
**Technical Notes:**
|
||||
- BaseDialog uses a `::after` pseudo-element for background color rendering
|
||||
- Without `pointer-events: none`, this pseudo covers content and blocks interaction
|
||||
- Theme color variables follow pattern: `--theme-color-{semantic-name}`
|
||||
- Custom color variables like `--color-grey-*` don't exist - always use theme variables
|
||||
- Flex containers need `min-height: 0` on children to allow proper shrinking/scrolling
|
||||
|
||||
**Files Modified:**
|
||||
```
|
||||
packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.module.scss
|
||||
packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Session 4: Bug Fixes & Polish
|
||||
|
||||
#### 2024-12-14
|
||||
|
||||
**Fixed:**
|
||||
- **EPIPE Error on Project Open** (`cloud-function-server.js`):
|
||||
- Added `safeLog()` wrapper function that catches and ignores EPIPE errors
|
||||
- EPIPE occurs when stdout pipe is broken (e.g., terminal closed)
|
||||
- All console.log calls in cloud-function-server now use safeLog
|
||||
- Prevents editor crash when output pipe becomes unavailable
|
||||
|
||||
- **Runtime Detection Defaulting** (`ProjectScanner.ts`):
|
||||
- Changed fallback runtime version from `'unknown'` to `'react17'`
|
||||
- Projects without explicit markers now correctly identified as legacy
|
||||
- Ensures old Noodl projects trigger migration UI even without version flags
|
||||
- Updated indicator message: "No React 19 markers found - assuming legacy React 17 project"
|
||||
|
||||
- **Migration UI Not Showing** (`projectsview.ts`):
|
||||
- Added listener for `'runtimeDetectionComplete'` event
|
||||
- Project list now re-renders after async runtime detection completes
|
||||
- Legacy badges and migrate buttons appear correctly for React 17 projects
|
||||
|
||||
- **SCSS Import Error** (`MigrationWizard.module.scss`):
|
||||
- Removed invalid `@use '../../../../styles/utils/colors' as *;` import
|
||||
- File was referencing non-existent styles/utils/colors.scss
|
||||
- Webpack cache required clearing after fix
|
||||
|
||||
**Technical Notes:**
|
||||
- safeLog pattern: `try { console.log(...args); } catch (e) { /* ignore EPIPE */ }`
|
||||
- Runtime detection is async - UI must re-render after detection completes
|
||||
- Webpack caches SCSS files aggressively - cache clearing may be needed after SCSS fixes
|
||||
- The `runtimeDetectionComplete` event fires after `detectAllProjectRuntimes()` completes
|
||||
|
||||
**Files Modified:**
|
||||
```
|
||||
packages/noodl-editor/src/main/src/cloud-function-server.js
|
||||
packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
|
||||
packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Session 3: Projects View Integration
|
||||
|
||||
#### 2024-12-14
|
||||
|
||||
**Added:**
|
||||
- Extended `DialogLayerModel.tsx` with generic `showDialog()` method:
|
||||
- Accepts render function `(close: () => void) => JSX.Element`
|
||||
- Options include `onClose` callback for cleanup
|
||||
- Enables mounting custom React components (like MigrationWizard) as dialogs
|
||||
- Type: `ShowDialogOptions` interface added
|
||||
|
||||
- Extended `LocalProjectsModel.ts` with runtime detection:
|
||||
- `RuntimeVersionInfo` import from migration/types
|
||||
- `detectRuntimeVersion` import from migration/ProjectScanner
|
||||
- `ProjectItemWithRuntime` interface extending ProjectItem with runtimeInfo
|
||||
- In-memory cache: `runtimeInfoCache: Map<string, RuntimeVersionInfo>`
|
||||
- Detection tracking: `detectingProjects: Set<string>`
|
||||
- New methods:
|
||||
- `getRuntimeInfo(projectPath)` - Get cached runtime info
|
||||
- `isDetectingRuntime(projectPath)` - Check if detection in progress
|
||||
- `getProjectsWithRuntime()` - Get all projects with runtime info
|
||||
- `detectProjectRuntime(projectPath)` - Detect and cache runtime version
|
||||
- `detectAllProjectRuntimes()` - Background detection for all projects
|
||||
- `isLegacyProject(projectPath)` - Check if project is React 17
|
||||
- `clearRuntimeCache(projectPath)` - Clear cache after migration
|
||||
|
||||
- Updated `projectsview.html` template with legacy project indicators:
|
||||
- `data-class="isLegacy:projects-item--legacy"` conditional styling
|
||||
- Legacy badge with warning SVG icon (positioned top-right)
|
||||
- Legacy actions overlay with "Migrate Project" and "Open Read-Only" buttons
|
||||
- Click handlers: `data-click="onMigrateProjectClicked"`, `data-click="onOpenReadOnlyClicked"`
|
||||
- Detecting spinner with `data-class="isDetecting:projects-item-detecting"`
|
||||
|
||||
- Added CSS styles in `projectsview.css`:
|
||||
- `.projects-item--legacy` - Orange border for legacy projects
|
||||
- `.projects-item-legacy-badge` - Top-right warning badge
|
||||
- `.projects-item-legacy-actions` - Hover overlay with migration buttons
|
||||
- `.projects-item-migrate-btn` - Primary orange CTA button
|
||||
- `.projects-item-readonly-btn` - Secondary ghost button
|
||||
- `.projects-item-detecting` - Loading spinner animation
|
||||
- `.hidden` utility class
|
||||
|
||||
- Updated `projectsview.ts` with migration handler logic:
|
||||
- Imports for React, MigrationWizard, ProjectItemWithRuntime
|
||||
- Extended `ProjectItemScope` type with `isLegacy` and `isDetecting` flags
|
||||
- Updated `renderProjectItems()` to:
|
||||
- Check `isLegacyProject()` and `isDetectingRuntime()` for each project
|
||||
- Include flags in template scope for conditional rendering
|
||||
- Trigger `detectAllProjectRuntimes()` on render
|
||||
- New handlers:
|
||||
- `onMigrateProjectClicked()` - Opens MigrationWizard via DialogLayerModel.showDialog()
|
||||
- `onOpenReadOnlyClicked()` - Opens project normally (banner display deferred)
|
||||
|
||||
**Technical Notes:**
|
||||
- DialogLayerModel uses existing Modal wrapper pattern with custom render function
|
||||
- Runtime detection uses in-memory cache to avoid persistence to localStorage
|
||||
- Template binding uses jQuery-based View system with `data-*` attributes
|
||||
- CSS hover overlay only shows for legacy projects
|
||||
- Tracker analytics integrated for "Migration Wizard Opened" and "Legacy Project Opened Read-Only"
|
||||
- ToastLayer.showSuccess() used for migration completion notification
|
||||
|
||||
**Files Modified:**
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx
|
||||
packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts
|
||||
packages/noodl-editor/src/editor/src/templates/projectsview.html
|
||||
packages/noodl-editor/src/editor/src/styles/projectsview.css
|
||||
packages/noodl-editor/src/editor/src/views/projectsview.ts
|
||||
```
|
||||
|
||||
**Remaining for Future Sessions:**
|
||||
- EditorBanner component for legacy read-only mode warning (Post-Migration UX)
|
||||
- wire open project flow for legacy detection (auto-detect on existing project open)
|
||||
|
||||
---
|
||||
|
||||
### Session 2: Wizard UI (Basic Flow)
|
||||
|
||||
#### 2024-12-14
|
||||
|
||||
**Added:**
|
||||
- Created `packages/noodl-editor/src/editor/src/views/migration/` directory with:
|
||||
- `MigrationWizard.tsx` - Main wizard container component:
|
||||
- Uses Modal component from @noodl-core-ui
|
||||
- useReducer for local state management
|
||||
- Integrates with migrationSessionManager from Session 1
|
||||
- Renders step components based on current session.step
|
||||
- `components/WizardProgress.tsx` - Visual step progress indicator:
|
||||
- Shows 5 steps with check icons for completed
|
||||
- Connectors between steps with completion status
|
||||
- `steps/ConfirmStep.tsx` - Step 1: Confirm source/target paths:
|
||||
- Source path locked (read-only)
|
||||
- Target path editable with filesystem.exists() validation
|
||||
- Warning about original project being safe
|
||||
- `steps/ScanningStep.tsx` - Step 2 & 4: Progress display:
|
||||
- Reused for both scanning and migrating phases
|
||||
- Progress bar with percentage
|
||||
- Activity log with color-coded entries (info/success/warning/error)
|
||||
- `steps/ReportStep.tsx` - Step 3: Scan results report:
|
||||
- Stats row with automatic/simpleFixes/needsReview counts
|
||||
- Collapsible category sections with component lists
|
||||
- AI prompt section (disabled - future session)
|
||||
- `steps/CompleteStep.tsx` - Step 5: Final summary:
|
||||
- Stats cards (migrated/needsReview/failed)
|
||||
- Duration and AI cost display
|
||||
- Source/target path display
|
||||
- Next steps guidance
|
||||
- `steps/FailedStep.tsx` - Error handling step:
|
||||
- Error details display
|
||||
- Contextual suggestions (network/permission/general)
|
||||
- Safety notice about original project
|
||||
|
||||
- Created SCSS modules for all components:
|
||||
- `MigrationWizard.module.scss`
|
||||
- `components/WizardProgress.module.scss`
|
||||
- `steps/ConfirmStep.module.scss`
|
||||
- `steps/ScanningStep.module.scss`
|
||||
- `steps/ReportStep.module.scss`
|
||||
- `steps/CompleteStep.module.scss`
|
||||
- `steps/FailedStep.module.scss`
|
||||
|
||||
**Technical Notes:**
|
||||
- Text component uses `className` not `UNSAFE_className` for styling
|
||||
- Text component uses `textType` prop (TextType.Secondary, TextType.Shy) not variants
|
||||
- TextInput onChange expects standard React ChangeEventHandler<HTMLInputElement>
|
||||
- PrimaryButtonVariant has: Cta (default), Muted, Ghost, Danger (NO "Secondary")
|
||||
- Using @noodl/platform filesystem.exists() for path checking
|
||||
- VStack/HStack from @noodl-core-ui/components/layout/Stack for layout
|
||||
- SVG icons defined inline in each component for self-containment
|
||||
|
||||
**Files Created:**
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/migration/
|
||||
├── MigrationWizard.tsx
|
||||
├── MigrationWizard.module.scss
|
||||
├── components/
|
||||
│ ├── WizardProgress.tsx
|
||||
│ └── WizardProgress.module.scss
|
||||
└── steps/
|
||||
├── ConfirmStep.tsx
|
||||
├── ConfirmStep.module.scss
|
||||
├── ScanningStep.tsx
|
||||
├── ScanningStep.module.scss
|
||||
├── ReportStep.tsx
|
||||
├── ReportStep.module.scss
|
||||
├── CompleteStep.tsx
|
||||
├── CompleteStep.module.scss
|
||||
├── FailedStep.tsx
|
||||
└── FailedStep.module.scss
|
||||
```
|
||||
|
||||
**Remaining for Session 2:**
|
||||
- DialogLayerModel integration for showing wizard (deferred to Session 3)
|
||||
|
||||
---
|
||||
|
||||
### Session 1: Foundation + Detection
|
||||
|
||||
#### 2024-12-13
|
||||
|
||||
@@ -10,20 +10,29 @@
|
||||
- [x] Create index.ts module exports
|
||||
|
||||
## Session 2: Wizard UI (Basic Flow)
|
||||
- [ ] MigrationWizard.tsx container
|
||||
- [ ] ConfirmStep.tsx component
|
||||
- [ ] ScanningStep.tsx component
|
||||
- [ ] ReportStep.tsx component
|
||||
- [ ] CompleteStep.tsx component
|
||||
- [ ] MigrationExecutor.ts (project copy + basic fixes)
|
||||
- [ ] DialogLayerModel integration for showing wizard
|
||||
- [x] MigrationWizard.tsx container
|
||||
- [x] WizardProgress.tsx component
|
||||
- [x] ConfirmStep.tsx component
|
||||
- [x] ScanningStep.tsx component
|
||||
- [x] ReportStep.tsx component
|
||||
- [x] CompleteStep.tsx component
|
||||
- [x] FailedStep.tsx component
|
||||
- [x] SCSS module files (MigrationWizard, WizardProgress, ConfirmStep, ScanningStep, ReportStep, CompleteStep, FailedStep)
|
||||
- [ ] MigrationExecutor.ts (project copy + basic fixes) - deferred to Session 4
|
||||
- [x] DialogLayerModel integration for showing wizard (completed in Session 3)
|
||||
|
||||
## Session 3: Projects View Integration
|
||||
- [ ] Update projectsview.ts to detect and show legacy badges
|
||||
- [ ] Add "Migrate Project" button to project cards
|
||||
- [ ] Add "Open Read-Only" button to project cards
|
||||
- [ ] Create EditorBanner.tsx for read-only mode warning
|
||||
- [ ] Wire open project flow to detect legacy projects
|
||||
- [x] DialogLayerModel.showDialog() generic method
|
||||
- [x] LocalProjectsModel runtime detection with cache
|
||||
- [x] Update projectsview.html template with legacy badges
|
||||
- [x] Add CSS styles for legacy project indicators
|
||||
- [x] Update projectsview.ts to detect and show legacy badges
|
||||
- [x] Add "Migrate Project" button to project cards
|
||||
- [x] Add "Open Read-Only" button to project cards
|
||||
- [x] onMigrateProjectClicked handler (opens MigrationWizard)
|
||||
- [x] onOpenReadOnlyClicked handler (opens project normally)
|
||||
- [ ] Create EditorBanner.tsx for read-only mode warning - deferred to Post-Migration UX
|
||||
- [ ] Wire auto-detect on existing project open - deferred to Post-Migration UX
|
||||
|
||||
## Session 4: AI Migration + Polish
|
||||
- [ ] claudeClient.ts (Anthropic API integration)
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
# Task: React 19 Node Modernization
|
||||
|
||||
## Overview
|
||||
|
||||
Update all frontend visual nodes in `noodl-viewer-react` to take advantage of React 19 features, remove deprecated patterns, and prepare the infrastructure for future React 19-only features like View Transitions.
|
||||
|
||||
**Priority:** High
|
||||
**Estimated Effort:** 16-24 hours
|
||||
**Branch:** `feature/react19-node-modernization`
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
With the editor upgraded to React 19 and the runtime to React 18.3 (95% compatible), we have an opportunity to modernize our node infrastructure. This work removes technical debt, simplifies code, and prepares the foundation for React 19-exclusive features.
|
||||
|
||||
### React 19 Changes That Affect Nodes
|
||||
|
||||
1. **`ref` as a regular prop** - No more `forwardRef` wrapper needed
|
||||
2. **Improved `useTransition`** - Can now handle async functions
|
||||
3. **`useDeferredValue` with initial value** - New parameter for better loading states
|
||||
4. **Native document metadata** - `<title>`, `<meta>` render directly
|
||||
5. **Better Suspense** - Works with more scenarios
|
||||
6. **`use()` hook** - Read resources in render (promises, context)
|
||||
7. **Form actions** - `useActionState`, `useFormStatus`, `useOptimistic`
|
||||
8. **Cleaner cleanup** - Ref cleanup functions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure Updates
|
||||
|
||||
### 1.1 Update `createNodeFromReactComponent` Wrapper
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/react-component-node.js` (or `.ts`)
|
||||
|
||||
**Changes:**
|
||||
- Remove automatic `forwardRef` wrapping logic
|
||||
- Add support for `ref` as a standard prop
|
||||
- Add optional `useTransition` integration for state updates
|
||||
- Add optional `useDeferredValue` wrapper for specified inputs
|
||||
|
||||
**New Options:**
|
||||
```javascript
|
||||
createNodeFromReactComponent({
|
||||
// ... existing options
|
||||
|
||||
// NEW: React 19 options
|
||||
react19: {
|
||||
// Enable transition wrapping for specified inputs
|
||||
transitionInputs: ['items', 'filter'],
|
||||
|
||||
// Enable deferred value for specified inputs
|
||||
deferredInputs: ['searchQuery'],
|
||||
|
||||
// Enable form action support
|
||||
formActions: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 1.2 Update Base Node Classes
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/visual-base.js`
|
||||
- Any shared base classes for visual nodes
|
||||
|
||||
**Changes:**
|
||||
- Remove `forwardRef` patterns
|
||||
- Update ref handling to use callback ref pattern
|
||||
- Add utility methods for transitions:
|
||||
- `this.startTransition(callback)` - wrap updates in transition
|
||||
- `this.getDeferredValue(inputName)` - get deferred version of input
|
||||
|
||||
### 1.3 Update TypeScript Definitions
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
|
||||
- Any relevant `.d.ts` files
|
||||
|
||||
**Changes:**
|
||||
- Update component prop types to include `ref` as regular prop
|
||||
- Add types for new React 19 hooks
|
||||
- Update `Noodl` namespace types if needed
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Visual Nodes
|
||||
|
||||
### 2.1 Group Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/group.js`
|
||||
|
||||
**Current Issues:**
|
||||
- Likely uses `forwardRef` or class component with ref forwarding
|
||||
- May have legacy lifecycle patterns
|
||||
|
||||
**Updates:**
|
||||
- Convert to functional component with `ref` as prop
|
||||
- Use `useEffect` cleanup returns properly
|
||||
- Add optional `useDeferredValue` for children rendering (large lists)
|
||||
|
||||
**New Capabilities:**
|
||||
- `Defer Children` input (boolean) - uses `useDeferredValue` for smoother updates
|
||||
- `Is Updating` output - true when deferred update pending
|
||||
|
||||
### 2.2 Text Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/text.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Simplify ref handling
|
||||
|
||||
### 2.3 Image Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/image.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Add resource preloading hints for React 19's `preload()` API (future enhancement slot)
|
||||
|
||||
### 2.4 Video Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/video.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Ensure ref cleanup is proper
|
||||
|
||||
### 2.5 Circle Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/circle.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
|
||||
### 2.6 Icon Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/icon.js` (or `net.noodl.visual.icon`)
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI Control Nodes
|
||||
|
||||
### 3.1 Button Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/button.js` (or `net.noodl.controls.button`)
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Add form action support preparation:
|
||||
- `formAction` input (string) - for future form integration
|
||||
- `Is Pending` output - when used in form with pending action
|
||||
|
||||
### 3.2 Text Input Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/textinput.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Consider `useDeferredValue` for `onChange` value updates
|
||||
- Add form integration preparation
|
||||
|
||||
**New Capabilities (Optional):**
|
||||
- `Defer Updates` input - delays `Value` output updates for performance
|
||||
- `Immediate Value` output - non-deferred value for UI feedback
|
||||
|
||||
### 3.3 Checkbox Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/checkbox.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Add optimistic update preparation (`useOptimistic` slot)
|
||||
|
||||
### 3.4 Radio Button / Radio Button Group
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/radiobutton.js`
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/radiobuttongroup.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrappers
|
||||
- Ensure proper group state management
|
||||
|
||||
### 3.5 Options/Dropdown Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/options.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Consider `useDeferredValue` for large option lists
|
||||
|
||||
### 3.6 Range/Slider Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/range.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- `useDeferredValue` for value output (prevent render thrashing during drag)
|
||||
|
||||
**New Capabilities:**
|
||||
- `Deferred Value` output - smoothed value for expensive downstream renders
|
||||
- `Immediate Value` output - raw value for UI display
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Navigation Nodes
|
||||
|
||||
### 4.1 Page Router / Router Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/router.js`
|
||||
|
||||
**Updates:**
|
||||
- Add `useTransition` wrapping for navigation
|
||||
- Prepare for View Transitions API integration
|
||||
|
||||
**New Capabilities:**
|
||||
- `Is Transitioning` output - true during page transition
|
||||
- `Use Transition` input (boolean, default true) - wrap navigation in React transition
|
||||
|
||||
### 4.2 Router Navigate Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/routernavigate.js`
|
||||
|
||||
**Updates:**
|
||||
- Wrap navigation in `startTransition`
|
||||
|
||||
**New Capabilities:**
|
||||
- `Is Pending` output - navigation in progress
|
||||
- `Transition Priority` input (enum: 'normal', 'urgent') - for future prioritization
|
||||
|
||||
### 4.3 Page Stack / Component Stack
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/pagestack.js`
|
||||
|
||||
**Updates:**
|
||||
- Add `useTransition` for push/pop operations
|
||||
|
||||
**New Capabilities:**
|
||||
- `Is Transitioning` output
|
||||
- Prepare for animation coordination with View Transitions
|
||||
|
||||
### 4.4 Page Inputs Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/pageinputs.js`
|
||||
|
||||
**Updates:**
|
||||
- Standard cleanup, ensure no deprecated patterns
|
||||
|
||||
### 4.5 Popup Nodes
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/showpopup.js`
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/closepopup.js`
|
||||
|
||||
**Updates:**
|
||||
- Consider `useTransition` for popup show/hide
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Layout Nodes
|
||||
|
||||
### 5.1 Columns Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/columns.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Remove `React.cloneElement` if present (React 19 has better patterns)
|
||||
- Consider using CSS Grid native features
|
||||
|
||||
### 5.2 Repeater (For Each) Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/foreach.js`
|
||||
|
||||
**Critical Updates:**
|
||||
- Add `useDeferredValue` for items array
|
||||
- Add `useTransition` for item updates
|
||||
|
||||
**New Capabilities:**
|
||||
- `Defer Updates` input (boolean) - uses deferred value for items
|
||||
- `Is Updating` output - true when deferred update pending
|
||||
- `Transition Updates` input (boolean) - wrap updates in transition
|
||||
|
||||
**Why This Matters:**
|
||||
Large list updates currently cause jank. With these options:
|
||||
- User toggles `Defer Updates` → list updates don't block UI
|
||||
- `Is Updating` output → can show loading indicator
|
||||
|
||||
### 5.3 Component Children Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/componentchildren.js`
|
||||
|
||||
**Updates:**
|
||||
- Standard cleanup
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Data/Object Nodes
|
||||
|
||||
### 6.1 Component Object Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/componentobject.js`
|
||||
|
||||
**Updates:**
|
||||
- Consider context-based implementation for React 19
|
||||
- `use(Context)` can now be called conditionally in React 19
|
||||
|
||||
### 6.2 Parent Component Object Node
|
||||
|
||||
**File:** Similar location
|
||||
|
||||
**Updates:**
|
||||
- Same as Component Object
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: SEO/Document Nodes (New Capability)
|
||||
|
||||
### 7.1 Update Page Node for Document Metadata
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/page.js`
|
||||
|
||||
**New Capabilities:**
|
||||
React 19 allows rendering `<title>`, `<meta>`, `<link>` directly in components and they hoist to `<head>`.
|
||||
|
||||
**New Inputs:**
|
||||
- `Page Title` - renders `<title>` (already exists, but implementation changes)
|
||||
- `Meta Description` - renders `<meta name="description">`
|
||||
- `Meta Keywords` - renders `<meta name="keywords">`
|
||||
- `Canonical URL` - renders `<link rel="canonical">`
|
||||
- `OG Title` - renders `<meta property="og:title">`
|
||||
- `OG Description` - renders `<meta property="og:description">`
|
||||
- `OG Image` - renders `<meta property="og:image">`
|
||||
|
||||
**Implementation:**
|
||||
```jsx
|
||||
function PageComponent({ title, description, ogTitle, ...props }) {
|
||||
return (
|
||||
<>
|
||||
{title && <title>{title}</title>}
|
||||
{description && <meta name="description" content={description} />}
|
||||
{ogTitle && <meta property="og:title" content={ogTitle} />}
|
||||
{/* ... rest of component */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This replaces the hacky SSR string replacement currently in `packages/noodl-viewer-react/static/ssr/index.js`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Testing & Validation
|
||||
|
||||
### 8.1 Unit Tests
|
||||
|
||||
**Update/Create Tests For:**
|
||||
- `createNodeFromReactComponent` with new options
|
||||
- Each updated node renders correctly
|
||||
- Ref forwarding works without `forwardRef`
|
||||
- Deferred values update correctly
|
||||
- Transitions wrap updates properly
|
||||
|
||||
### 8.2 Integration Tests
|
||||
|
||||
- Page navigation with transitions
|
||||
- Repeater with large datasets
|
||||
- Form interactions with new patterns
|
||||
|
||||
### 8.3 Visual Regression Tests
|
||||
|
||||
- Ensure no visual changes from modernization
|
||||
- Test all visual states (hover, pressed, disabled)
|
||||
- Test variants still work
|
||||
|
||||
### 8.4 Performance Benchmarks
|
||||
|
||||
**Before/After Metrics:**
|
||||
- Repeater with 1000 items - render time
|
||||
- Page navigation - transition smoothness
|
||||
- Text input rapid typing - lag measurement
|
||||
|
||||
---
|
||||
|
||||
## File List Summary
|
||||
|
||||
### Infrastructure Files
|
||||
```
|
||||
packages/noodl-viewer-react/src/
|
||||
├── react-component-node.js # Main wrapper factory
|
||||
├── nodes/std-library/
|
||||
│ └── visual-base.js # Base class for visual nodes
|
||||
```
|
||||
|
||||
### Visual Element Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── group.js
|
||||
├── text.js
|
||||
├── image.js
|
||||
├── video.js
|
||||
├── circle.js
|
||||
├── icon.js (or net.noodl.visual.icon)
|
||||
```
|
||||
|
||||
### UI Control Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── button.js (or net.noodl.controls.button)
|
||||
├── textinput.js
|
||||
├── checkbox.js
|
||||
├── radiobutton.js
|
||||
├── radiobuttongroup.js
|
||||
├── options.js
|
||||
├── range.js
|
||||
```
|
||||
|
||||
### Navigation Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── router.js
|
||||
├── routernavigate.js
|
||||
├── pagestack.js
|
||||
├── pageinputs.js
|
||||
├── showpopup.js
|
||||
├── closepopup.js
|
||||
```
|
||||
|
||||
### Layout Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── columns.js
|
||||
├── foreach.js
|
||||
├── componentchildren.js
|
||||
```
|
||||
|
||||
### Data Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── componentobject.js
|
||||
├── parentcomponentobject.js
|
||||
```
|
||||
|
||||
### Page/SEO Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── page.js
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
```
|
||||
packages/noodl-viewer-react/static/viewer/
|
||||
├── global.d.ts.keep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Foundation
|
||||
1. Update `createNodeFromReactComponent` infrastructure
|
||||
2. Update base classes
|
||||
3. Update Group node (most used, good test case)
|
||||
4. Update Text node
|
||||
5. Create test suite for modernized patterns
|
||||
|
||||
### Week 2: Controls & Navigation
|
||||
6. Update all UI Control nodes (Button, TextInput, etc.)
|
||||
7. Update Navigation nodes with transition support
|
||||
8. Update Repeater with deferred value support
|
||||
9. Test navigation flow end-to-end
|
||||
|
||||
### Week 3: Polish & New Features
|
||||
10. Update remaining nodes (Columns, Component Object, etc.)
|
||||
11. Add Page metadata support
|
||||
12. Performance testing and optimization
|
||||
13. Documentation updates
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Must Have
|
||||
- [ ] All nodes render correctly after updates
|
||||
- [ ] No `forwardRef` usage in visual nodes
|
||||
- [ ] All refs work correctly (DOM access, focus, etc.)
|
||||
- [ ] No breaking changes to existing projects
|
||||
- [ ] Tests pass
|
||||
|
||||
### Should Have
|
||||
- [ ] Repeater has `Defer Updates` option
|
||||
- [ ] Page Router has `Is Transitioning` output
|
||||
- [ ] Page node has SEO metadata inputs
|
||||
|
||||
### Nice to Have
|
||||
- [ ] Performance improvement measurable in benchmarks
|
||||
- [ ] Text Input deferred value option
|
||||
- [ ] Range slider deferred value option
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
These changes should be **fully backward compatible**:
|
||||
- Existing projects continue to work unchanged
|
||||
- New features are opt-in via new inputs
|
||||
- No changes to how nodes are wired together
|
||||
|
||||
### Runtime Considerations
|
||||
|
||||
Since runtime is React 18.3:
|
||||
- `useTransition` works (available since React 18)
|
||||
- `useDeferredValue` works (available since React 18)
|
||||
- `ref` as prop works (React 18.3 forward-ported this)
|
||||
- Native metadata hoisting does NOT work (React 19 only)
|
||||
- For runtime, metadata nodes will need polyfill/fallback
|
||||
|
||||
**Strategy:** Build features for React 19 editor, provide graceful degradation for React 18.3 runtime. Eventually upgrade runtime to React 19.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Before: forwardRef Pattern
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return React.forwardRef((props, ref) => {
|
||||
return <div ref={ref} style={props.style}>{props.children}</div>;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### After: ref as Prop Pattern
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return function GroupComponent({ ref, style, children }) {
|
||||
return <div ref={ref} style={style}>{children}</div>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Deferred Value Support
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return function RepeaterComponent({ items, deferUpdates, onIsUpdating }) {
|
||||
const deferredItems = React.useDeferredValue(items);
|
||||
const isStale = items !== deferredItems;
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsUpdating?.(isStale);
|
||||
}, [isStale, onIsUpdating]);
|
||||
|
||||
const itemsToRender = deferUpdates ? deferredItems : items;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{itemsToRender.map(item => /* render item */)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Transition Support
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return function RouterComponent({ onNavigate, onIsTransitioning }) {
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsTransitioning?.(isPending);
|
||||
}, [isPending, onIsTransitioning]);
|
||||
|
||||
const handleNavigate = (target) => {
|
||||
startTransition(() => {
|
||||
onNavigate(target);
|
||||
});
|
||||
};
|
||||
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions for Implementation
|
||||
|
||||
1. **File locations:** Need to verify actual file paths in `noodl-viewer-react` - the paths above are educated guesses based on patterns.
|
||||
|
||||
2. **Runtime compatibility:** Should we add feature detection to gracefully degrade on React 18.3 runtime, or assume eventual runtime upgrade?
|
||||
|
||||
3. **New inputs/outputs:** Should new capabilities (like `Defer Updates`) be hidden by default and exposed via a "React 19 Features" toggle in project settings?
|
||||
|
||||
4. **Breaking changes policy:** If we find any patterns that would break (unlikely), what's the policy? Migration path vs versioning?
|
||||
|
||||
---
|
||||
|
||||
## Related Future Work
|
||||
|
||||
This modernization enables but does not include:
|
||||
- **Magic Transition Node** - View Transitions API wrapper
|
||||
- **AI Component Node** - Generative UI with streaming
|
||||
- **Async Boundary Node** - Suspense wrapper with error boundaries
|
||||
- **Form Action Node** - React 19 form actions
|
||||
|
||||
These will be separate tasks building on this foundation.
|
||||
|
||||
|
||||
# React 19 Node Modernization - Implementation Checklist
|
||||
|
||||
Quick reference checklist for implementation. See full spec for details.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Flight Checks
|
||||
|
||||
- [ ] Verify React 19 is installed in editor package
|
||||
- [ ] Verify React 18.3 is installed in runtime package
|
||||
- [ ] Create feature branch: `feature/react19-node-modernization`
|
||||
- [ ] Locate all node files in `packages/noodl-viewer-react/src/nodes/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure
|
||||
|
||||
### createNodeFromReactComponent
|
||||
- [ ] Find file: `packages/noodl-viewer-react/src/react-component-node.js`
|
||||
- [ ] Remove automatic forwardRef wrapping
|
||||
- [ ] Add `ref` prop passthrough to components
|
||||
- [ ] Add optional `react19.transitionInputs` config
|
||||
- [ ] Add optional `react19.deferredInputs` config
|
||||
- [ ] Test: Basic node still renders
|
||||
- [ ] Test: Ref forwarding works
|
||||
|
||||
### Base Classes
|
||||
- [ ] Find visual-base.js or equivalent
|
||||
- [ ] Add `this.startTransition()` utility method
|
||||
- [ ] Add `this.getDeferredValue()` utility method
|
||||
- [ ] Update TypeScript definitions if applicable
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Visual Nodes
|
||||
|
||||
### Group Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Use `ref` as regular prop
|
||||
- [ ] Test: Renders correctly
|
||||
- [ ] Test: Ref accessible for DOM manipulation
|
||||
- [ ] Optional: Add `Defer Children` input
|
||||
- [ ] Optional: Add `Is Updating` output
|
||||
|
||||
### Text Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Image Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Video Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Ensure proper ref cleanup
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Circle Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Icon Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI Control Nodes
|
||||
|
||||
### Button Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Click events work
|
||||
- [ ] Test: Visual states work (hover, pressed, disabled)
|
||||
- [ ] Optional: Add `Is Pending` output for forms
|
||||
|
||||
### Text Input Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Value binding works
|
||||
- [ ] Test: Focus/blur events work
|
||||
- [ ] Optional: Add `Defer Updates` input
|
||||
- [ ] Optional: Add `Immediate Value` output
|
||||
|
||||
### Checkbox Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Checked state works
|
||||
|
||||
### Radio Button Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Selection works
|
||||
|
||||
### Radio Button Group Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Group behavior works
|
||||
|
||||
### Options/Dropdown Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Selection works
|
||||
- [ ] Optional: useDeferredValue for large option lists
|
||||
|
||||
### Range/Slider Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Value updates work
|
||||
- [ ] Optional: Add `Deferred Value` output
|
||||
- [ ] Optional: Add `Immediate Value` output
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Navigation Nodes
|
||||
|
||||
### Router Node
|
||||
- [ ] Remove forwardRef if present
|
||||
- [ ] Add useTransition for navigation
|
||||
- [ ] Add `Is Transitioning` output
|
||||
- [ ] Test: Page navigation works
|
||||
- [ ] Test: Is Transitioning output fires correctly
|
||||
|
||||
### Router Navigate Node
|
||||
- [ ] Wrap navigation in startTransition
|
||||
- [ ] Add `Is Pending` output
|
||||
- [ ] Test: Navigation triggers correctly
|
||||
|
||||
### Page Stack Node
|
||||
- [ ] Add useTransition for push/pop
|
||||
- [ ] Add `Is Transitioning` output
|
||||
- [ ] Test: Stack operations work
|
||||
|
||||
### Page Inputs Node
|
||||
- [ ] Standard cleanup
|
||||
- [ ] Test: Parameters pass correctly
|
||||
|
||||
### Show Popup Node
|
||||
- [ ] Consider useTransition
|
||||
- [ ] Test: Popup shows/hides
|
||||
|
||||
### Close Popup Node
|
||||
- [ ] Standard cleanup
|
||||
- [ ] Test: Popup closes
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Layout Nodes
|
||||
|
||||
### Columns Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Remove React.cloneElement if present
|
||||
- [ ] Test: Column layout works
|
||||
|
||||
### Repeater (For Each) Node ⭐ HIGH VALUE
|
||||
- [ ] Remove forwardRef if present
|
||||
- [ ] Add useDeferredValue for items
|
||||
- [ ] Add useTransition for updates
|
||||
- [ ] Add `Defer Updates` input
|
||||
- [ ] Add `Is Updating` output
|
||||
- [ ] Add `Transition Updates` input
|
||||
- [ ] Test: Basic rendering works
|
||||
- [ ] Test: Large list performance improved
|
||||
- [ ] Test: Is Updating output fires correctly
|
||||
|
||||
### Component Children Node
|
||||
- [ ] Standard cleanup
|
||||
- [ ] Test: Children render correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Data Nodes
|
||||
|
||||
### Component Object Node
|
||||
- [ ] Review implementation
|
||||
- [ ] Consider React 19 context patterns
|
||||
- [ ] Test: Object access works
|
||||
|
||||
### Parent Component Object Node
|
||||
- [ ] Same as Component Object
|
||||
- [ ] Test: Parent access works
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Page/SEO Node ⭐ HIGH VALUE
|
||||
|
||||
### Page Node
|
||||
- [ ] Add `Page Title` input → renders `<title>`
|
||||
- [ ] Add `Meta Description` input → renders `<meta name="description">`
|
||||
- [ ] Add `Canonical URL` input → renders `<link rel="canonical">`
|
||||
- [ ] Add `OG Title` input → renders `<meta property="og:title">`
|
||||
- [ ] Add `OG Description` input
|
||||
- [ ] Add `OG Image` input
|
||||
- [ ] Test: Metadata renders in head
|
||||
- [ ] Test: SSR works correctly
|
||||
- [ ] Provide fallback for React 18.3 runtime
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Testing
|
||||
|
||||
### Unit Tests
|
||||
- [ ] createNodeFromReactComponent tests
|
||||
- [ ] Ref forwarding tests
|
||||
- [ ] Deferred value tests
|
||||
- [ ] Transition tests
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Full navigation flow
|
||||
- [ ] Repeater with large data
|
||||
- [ ] Form interactions
|
||||
|
||||
### Visual Tests
|
||||
- [ ] All nodes render same as before
|
||||
- [ ] Visual states work
|
||||
- [ ] Variants work
|
||||
|
||||
### Performance Tests
|
||||
- [ ] Benchmark: Repeater 1000 items
|
||||
- [ ] Benchmark: Page navigation
|
||||
- [ ] Benchmark: Text input typing
|
||||
|
||||
---
|
||||
|
||||
## Final Steps
|
||||
|
||||
- [ ] Update documentation
|
||||
- [ ] Update changelog
|
||||
- [ ] Create PR
|
||||
- [ ] Test in sample projects
|
||||
- [ ] Deploy to staging
|
||||
- [ ] User testing
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Pattern Changes
|
||||
|
||||
### forwardRef Removal
|
||||
|
||||
**Before:**
|
||||
```jsx
|
||||
React.forwardRef((props, ref) => <div ref={ref} />)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```jsx
|
||||
function Component({ ref, ...props }) { return <div ref={ref} /> }
|
||||
```
|
||||
|
||||
### Adding Deferred Value
|
||||
|
||||
```jsx
|
||||
function Component({ items, deferUpdates, onIsUpdating }) {
|
||||
const deferredItems = React.useDeferredValue(items);
|
||||
const isStale = items !== deferredItems;
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsUpdating?.(isStale);
|
||||
}, [isStale]);
|
||||
|
||||
return /* render deferUpdates ? deferredItems : items */;
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Transitions
|
||||
|
||||
```jsx
|
||||
function Component({ onNavigate, onIsPending }) {
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsPending?.(isPending);
|
||||
}, [isPending]);
|
||||
|
||||
const handleNav = (target) => {
|
||||
startTransition(() => onNavigate(target));
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Document Metadata (React 19)
|
||||
|
||||
```jsx
|
||||
function Page({ title, description }) {
|
||||
return (
|
||||
<>
|
||||
{title && <title>{title}</title>}
|
||||
{description && <meta name="description" content={description} />}
|
||||
{/* rest of page */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- High value items marked with ⭐
|
||||
- Start with infrastructure, then Group node as test case
|
||||
- Test frequently - small iterations
|
||||
- Keep backward compatibility - no breaking changes
|
||||
@@ -0,0 +1,111 @@
|
||||
# Responsive Breakpoints System
|
||||
|
||||
## Feature Overview
|
||||
|
||||
A built-in responsive breakpoint system that works like visual states (hover/pressed/disabled) but for viewport widths. Users can define breakpoint-specific property values directly in the property panel without wiring up states nodes.
|
||||
|
||||
**Current Pain Point:**
|
||||
Users must manually wire `[Screen Width] → [States Node] → [Visual Node]` for every responsive property, cluttering the node graph and making responsive design tedious.
|
||||
|
||||
**Solution:**
|
||||
In the property panel, a breakpoint selector lets users switch between Desktop/Tablet/Phone/Small Phone views. When a breakpoint is selected, users see and edit that breakpoint's values. Values cascade down (desktop → tablet → phone) unless explicitly overridden.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Terminology | "Breakpoints" |
|
||||
| Default breakpoints | Desktop (≥1024px), Tablet (768-1023px), Phone (320-767px), Small Phone (<320px) |
|
||||
| Cascade direction | Configurable (desktop-first default, mobile-first option) |
|
||||
| Editor preview sync | Independent (changing breakpoint doesn't resize preview, and vice versa) |
|
||||
|
||||
## Breakpoint-Aware Properties
|
||||
|
||||
Only layout/dimension properties support breakpoints (not colors/shadows):
|
||||
|
||||
**✅ Supported:**
|
||||
- **Dimensions**: width, height, minWidth, maxWidth, minHeight, maxHeight
|
||||
- **Spacing**: marginTop/Right/Bottom/Left, paddingTop/Right/Bottom/Left, gap
|
||||
- **Typography**: fontSize, lineHeight, letterSpacing
|
||||
- **Layout**: flexDirection, alignItems, justifyContent, flexWrap, flexGrow, flexShrink
|
||||
- **Visibility**: visible, mounted
|
||||
|
||||
**❌ Not Supported:**
|
||||
- Colors (backgroundColor, borderColor, textColor, etc.)
|
||||
- Borders (borderWidth, borderRadius, borderStyle)
|
||||
- Shadows (boxShadow)
|
||||
- Effects (opacity, transform)
|
||||
|
||||
## Data Model
|
||||
|
||||
```javascript
|
||||
// Node model storage
|
||||
{
|
||||
parameters: {
|
||||
marginTop: '40px', // desktop (default breakpoint)
|
||||
},
|
||||
breakpointParameters: {
|
||||
tablet: { marginTop: '24px' },
|
||||
phone: { marginTop: '16px' },
|
||||
smallPhone: { marginTop: '12px' }
|
||||
},
|
||||
// Optional: combined visual state + breakpoint
|
||||
stateBreakpointParameters: {
|
||||
'hover:tablet': { /* ... */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Project settings
|
||||
{
|
||||
breakpoints: {
|
||||
desktop: { minWidth: 1024, isDefault: true },
|
||||
tablet: { minWidth: 768, maxWidth: 1023 },
|
||||
phone: { minWidth: 320, maxWidth: 767 },
|
||||
smallPhone: { minWidth: 0, maxWidth: 319 }
|
||||
},
|
||||
breakpointOrder: ['desktop', 'tablet', 'phone', 'smallPhone'],
|
||||
cascadeDirection: 'desktop-first' // or 'mobile-first'
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
| Phase | Name | Estimate | Dependencies |
|
||||
|-------|------|----------|--------------|
|
||||
| 1 | Foundation - Data Model | 2-3 days | None |
|
||||
| 2 | Editor UI - Property Panel | 3-4 days | Phase 1 |
|
||||
| 3 | Runtime - Viewport Detection | 2-3 days | Phase 1 |
|
||||
| 4 | Variants Integration | 1-2 days | Phases 1-3 |
|
||||
| 5 | Visual States Combo | 2 days | Phases 1-4 |
|
||||
|
||||
**Total Estimate: 10-14 days**
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Users can set different margin/padding/width values per breakpoint without any node wiring
|
||||
2. Values cascade automatically (tablet inherits desktop unless overridden)
|
||||
3. Property panel clearly shows inherited vs overridden values
|
||||
4. Runtime automatically applies correct values based on viewport width
|
||||
5. Variants support breakpoint-specific values
|
||||
6. Project settings allow customizing breakpoint thresholds
|
||||
7. Both desktop-first and mobile-first workflows supported
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
tasks/responsive-breakpoints/
|
||||
├── 00-OVERVIEW.md (this file)
|
||||
├── 01-FOUNDATION.md (Phase 1: Data model)
|
||||
├── 02-EDITOR-UI.md (Phase 2: Property panel)
|
||||
├── 03-RUNTIME.md (Phase 3: Viewport detection)
|
||||
├── 04-VARIANTS.md (Phase 4: Variants integration)
|
||||
└── 05-VISUAL-STATES-COMBO.md (Phase 5: Combined states)
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `codebase/nodes/visual-states.md` - Existing visual states system (pattern to follow)
|
||||
- `codebase/nodes/variants.md` - Existing variants system
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/` - Property panel implementation
|
||||
- `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` - Node model
|
||||
- `packages/noodl-runtime/src/models/nodemodel.js` - Runtime node model
|
||||
@@ -0,0 +1,369 @@
|
||||
# Phase 1: Foundation - Data Model
|
||||
|
||||
## Overview
|
||||
|
||||
Establish the data structures and model layer support for responsive breakpoints. This phase adds `breakpointParameters` storage to nodes, extends the model proxy, and adds project-level breakpoint configuration.
|
||||
|
||||
**Estimate:** 2-3 days
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `breakpointParameters` field to NodeGraphNode model
|
||||
2. Extend NodeModel (runtime) with breakpoint parameter support
|
||||
3. Add breakpoint configuration to project settings
|
||||
4. Extend ModelProxy to handle breakpoint context
|
||||
5. Add `allowBreakpoints` flag support to node definitions
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Data Storage Pattern
|
||||
|
||||
Following the existing visual states pattern (`stateParameters`), we add parallel `breakpointParameters`:
|
||||
|
||||
```javascript
|
||||
// NodeGraphNode / NodeModel
|
||||
{
|
||||
id: 'group-1',
|
||||
type: 'Group',
|
||||
parameters: {
|
||||
marginTop: '40px', // base/default breakpoint value
|
||||
backgroundColor: '#fff' // non-breakpoint property
|
||||
},
|
||||
stateParameters: { // existing - visual states
|
||||
hover: { backgroundColor: '#eee' }
|
||||
},
|
||||
breakpointParameters: { // NEW - breakpoints
|
||||
tablet: { marginTop: '24px' },
|
||||
phone: { marginTop: '16px' },
|
||||
smallPhone: { marginTop: '12px' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Project Settings Schema
|
||||
|
||||
```javascript
|
||||
// project.settings.responsiveBreakpoints
|
||||
{
|
||||
enabled: true,
|
||||
cascadeDirection: 'desktop-first', // or 'mobile-first'
|
||||
defaultBreakpoint: 'desktop',
|
||||
breakpoints: [
|
||||
{ id: 'desktop', name: 'Desktop', minWidth: 1024, icon: 'desktop' },
|
||||
{ id: 'tablet', name: 'Tablet', minWidth: 768, maxWidth: 1023, icon: 'tablet' },
|
||||
{ id: 'phone', name: 'Phone', minWidth: 320, maxWidth: 767, icon: 'phone' },
|
||||
{ id: 'smallPhone', name: 'Small Phone', minWidth: 0, maxWidth: 319, icon: 'phone-small' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Node Definition Flag
|
||||
|
||||
```javascript
|
||||
// In node definition
|
||||
{
|
||||
inputs: {
|
||||
marginTop: {
|
||||
type: { name: 'number', units: ['px', '%'], defaultUnit: 'px' },
|
||||
allowBreakpoints: true, // NEW flag
|
||||
group: 'Margin and Padding'
|
||||
},
|
||||
backgroundColor: {
|
||||
type: 'color',
|
||||
allowVisualStates: true,
|
||||
allowBreakpoints: false // colors don't support breakpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Extend NodeGraphNode Model
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
// Add to class properties
|
||||
breakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
// Add to constructor/initialization
|
||||
this.breakpointParameters = args.breakpointParameters || {};
|
||||
|
||||
// Add new methods
|
||||
hasBreakpointParameter(name: string, breakpoint: string): boolean {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
|
||||
}
|
||||
|
||||
getBreakpointParameter(name: string, breakpoint: string): any {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name];
|
||||
}
|
||||
|
||||
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any): void {
|
||||
// Similar pattern to setParameter but for breakpoint-specific values
|
||||
// Include undo support
|
||||
}
|
||||
|
||||
// Extend getParameter to support breakpoint context
|
||||
getParameter(name: string, args?: { state?: string, breakpoint?: string }): any {
|
||||
// If breakpoint specified, check breakpointParameters first
|
||||
// Then cascade to larger breakpoints
|
||||
// Finally fall back to base parameters
|
||||
}
|
||||
|
||||
// Extend toJSON to include breakpointParameters
|
||||
toJSON(): object {
|
||||
return {
|
||||
...existingFields,
|
||||
breakpointParameters: this.breakpointParameters
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Extend Runtime NodeModel
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/nodemodel.js`
|
||||
|
||||
```javascript
|
||||
// Add breakpointParameters storage
|
||||
NodeModel.prototype.setBreakpointParameter = function(name, value, breakpoint) {
|
||||
if (!this.breakpointParameters) this.breakpointParameters = {};
|
||||
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.breakpointParameters[breakpoint][name];
|
||||
} else {
|
||||
this.breakpointParameters[breakpoint][name] = value;
|
||||
}
|
||||
|
||||
this.emit("breakpointParameterUpdated", { name, value, breakpoint });
|
||||
};
|
||||
|
||||
NodeModel.prototype.setBreakpointParameters = function(breakpointParameters) {
|
||||
this.breakpointParameters = breakpointParameters;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Add Project Settings Schema
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
|
||||
|
||||
```typescript
|
||||
// Add default breakpoint settings
|
||||
const DEFAULT_BREAKPOINT_SETTINGS = {
|
||||
enabled: true,
|
||||
cascadeDirection: 'desktop-first',
|
||||
defaultBreakpoint: 'desktop',
|
||||
breakpoints: [
|
||||
{ id: 'desktop', name: 'Desktop', minWidth: 1024, icon: 'DeviceDesktop' },
|
||||
{ id: 'tablet', name: 'Tablet', minWidth: 768, maxWidth: 1023, icon: 'DeviceTablet' },
|
||||
{ id: 'phone', name: 'Phone', minWidth: 320, maxWidth: 767, icon: 'DevicePhone' },
|
||||
{ id: 'smallPhone', name: 'Small Phone', minWidth: 0, maxWidth: 319, icon: 'DevicePhone' }
|
||||
]
|
||||
};
|
||||
|
||||
// Add helper methods
|
||||
getBreakpointSettings(): BreakpointSettings {
|
||||
return this.settings.responsiveBreakpoints || DEFAULT_BREAKPOINT_SETTINGS;
|
||||
}
|
||||
|
||||
setBreakpointSettings(settings: BreakpointSettings): void {
|
||||
this.setSetting('responsiveBreakpoints', settings);
|
||||
}
|
||||
|
||||
getBreakpointForWidth(width: number): string {
|
||||
const settings = this.getBreakpointSettings();
|
||||
const breakpoints = settings.breakpoints;
|
||||
|
||||
// Find matching breakpoint based on width
|
||||
for (const bp of breakpoints) {
|
||||
const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
|
||||
const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
|
||||
if (minMatch && maxMatch) return bp.id;
|
||||
}
|
||||
|
||||
return settings.defaultBreakpoint;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Extend ModelProxy
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
|
||||
|
||||
```typescript
|
||||
export class ModelProxy {
|
||||
model: NodeGraphNode;
|
||||
editMode: string;
|
||||
visualState: string;
|
||||
breakpoint: string; // NEW
|
||||
|
||||
constructor(args) {
|
||||
this.model = args.model;
|
||||
this.visualState = 'neutral';
|
||||
this.breakpoint = 'desktop'; // NEW - default breakpoint
|
||||
}
|
||||
|
||||
setBreakpoint(breakpoint: string) {
|
||||
this.breakpoint = breakpoint;
|
||||
}
|
||||
|
||||
// Extend getParameter to handle breakpoints
|
||||
getParameter(name: string) {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const port = this.model.getPort(name, 'input');
|
||||
|
||||
// Check if this property supports breakpoints
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
// Check for breakpoint-specific value
|
||||
const breakpointValue = source.getBreakpointParameter(name, this.breakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
|
||||
// Cascade to larger breakpoints (desktop-first)
|
||||
// TODO: Support mobile-first cascade
|
||||
}
|
||||
|
||||
// Check visual state
|
||||
if (this.visualState && this.visualState !== 'neutral') {
|
||||
// existing visual state logic
|
||||
}
|
||||
|
||||
// Fall back to base parameters
|
||||
return source.getParameter(name, { state: this.visualState });
|
||||
}
|
||||
|
||||
// Extend setParameter to handle breakpoints
|
||||
setParameter(name: string, value: any, args: any = {}) {
|
||||
const port = this.model.getPort(name, 'input');
|
||||
|
||||
// If setting a breakpoint-specific value
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
args.breakpoint = this.breakpoint;
|
||||
}
|
||||
|
||||
// existing state handling
|
||||
args.state = this.visualState;
|
||||
|
||||
const target = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
|
||||
if (args.breakpoint) {
|
||||
target.setBreakpointParameter(name, value, args.breakpoint, args);
|
||||
} else {
|
||||
target.setParameter(name, value, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current value is inherited or explicitly set
|
||||
isBreakpointValueInherited(name: string): boolean {
|
||||
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
|
||||
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
return !source.hasBreakpointParameter(name, this.breakpoint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Node Type Registration
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodelibrary/nodelibrary.ts`
|
||||
|
||||
```typescript
|
||||
// When registering node types, process allowBreakpoints flag
|
||||
// Similar to how allowVisualStates is handled
|
||||
|
||||
processNodeType(nodeType) {
|
||||
// existing processing...
|
||||
|
||||
// Process allowBreakpoints for inputs
|
||||
if (nodeType.inputs) {
|
||||
for (const [name, input] of Object.entries(nodeType.inputs)) {
|
||||
if (input.allowBreakpoints) {
|
||||
// Mark this port as breakpoint-aware
|
||||
// This will be used by property panel to show breakpoint controls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update GraphModel (Runtime)
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
|
||||
|
||||
```javascript
|
||||
// Add method to update breakpoint parameters
|
||||
GraphModel.prototype.updateNodeBreakpointParameter = function(
|
||||
nodeId,
|
||||
parameterName,
|
||||
parameterValue,
|
||||
breakpoint
|
||||
) {
|
||||
const node = this.getNodeWithId(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
node.setBreakpointParameter(parameterName, parameterValue, breakpoint);
|
||||
};
|
||||
|
||||
// Extend project settings handling
|
||||
GraphModel.prototype.getBreakpointSettings = function() {
|
||||
return this.settings?.responsiveBreakpoints || DEFAULT_BREAKPOINT_SETTINGS;
|
||||
};
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add breakpointParameters field, getter/setter methods |
|
||||
| `packages/noodl-editor/src/editor/src/models/projectmodel.ts` | Add breakpoint settings helpers |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Add breakpoint context, extend get/setParameter |
|
||||
| `packages/noodl-runtime/src/models/nodemodel.js` | Add breakpoint parameter methods |
|
||||
| `packages/noodl-runtime/src/models/graphmodel.js` | Add breakpoint settings handling |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/breakpointSettings.ts` | TypeScript interfaces for breakpoint settings |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] NodeGraphNode can store and retrieve breakpointParameters
|
||||
- [ ] NodeGraphNode serializes breakpointParameters to JSON correctly
|
||||
- [ ] NodeGraphNode loads breakpointParameters from JSON correctly
|
||||
- [ ] ModelProxy correctly returns breakpoint-specific values
|
||||
- [ ] ModelProxy correctly identifies inherited vs explicit values
|
||||
- [ ] Project settings store and load breakpoint configuration
|
||||
- [ ] Cascade works correctly (tablet falls back to desktop)
|
||||
- [ ] Undo/redo works for breakpoint parameter changes
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Can programmatically set `node.setBreakpointParameter('marginTop', '24px', 'tablet')`
|
||||
2. ✅ Can retrieve with `node.getBreakpointParameter('marginTop', 'tablet')`
|
||||
3. ✅ Project JSON includes breakpointParameters when saved
|
||||
4. ✅ Project JSON loads breakpointParameters when opened
|
||||
5. ✅ ModelProxy returns correct value based on current breakpoint context
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Undo Support**: Make sure breakpoint parameter changes are undoable. Follow the same pattern as `setParameter` with undo groups.
|
||||
|
||||
2. **Cascade Order**: Desktop-first means `tablet` inherits from `desktop`, `phone` inherits from `tablet`, `smallPhone` inherits from `phone`. Mobile-first reverses this.
|
||||
|
||||
3. **Default Breakpoint**: When `breakpoint === 'desktop'` (or whatever the default is), we should NOT use breakpointParameters - use base parameters instead.
|
||||
|
||||
4. **Parameter Migration**: Existing projects won't have breakpointParameters. Handle gracefully (undefined → empty object).
|
||||
|
||||
5. **Port Flag**: The `allowBreakpoints` flag on ports determines which properties show breakpoint controls in the UI. This is read-only metadata, not stored per-node.
|
||||
|
||||
## Confidence Checkpoints
|
||||
|
||||
After completing each step, verify:
|
||||
|
||||
| Step | Checkpoint |
|
||||
|------|------------|
|
||||
| 1 | Can add/get breakpoint params in editor console |
|
||||
| 2 | Runtime node model accepts breakpoint params |
|
||||
| 3 | Project settings UI shows breakpoint config |
|
||||
| 4 | ModelProxy returns correct value per breakpoint |
|
||||
| 5 | Saving/loading project preserves breakpoint data |
|
||||
@@ -0,0 +1,600 @@
|
||||
# Phase 2: Editor UI - Property Panel
|
||||
|
||||
## Overview
|
||||
|
||||
Add the breakpoint selector UI to the property panel and implement the visual feedback for inherited vs overridden values. Users should be able to switch between breakpoints and see/edit breakpoint-specific values.
|
||||
|
||||
**Estimate:** 3-4 days
|
||||
|
||||
**Dependencies:** Phase 1 (Foundation)
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add breakpoint selector component to property panel
|
||||
2. Show inherited vs overridden values with visual distinction
|
||||
3. Add reset button to clear breakpoint-specific overrides
|
||||
4. Show badge summary of overrides per breakpoint
|
||||
5. Add breakpoint configuration section to Project Settings
|
||||
6. Filter property panel to only show breakpoint controls on `allowBreakpoints` properties
|
||||
|
||||
## UI Design
|
||||
|
||||
### Property Panel with Breakpoint Selector
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Group │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Breakpoint: [🖥️] [💻] [📱] [📱] │
|
||||
│ Des Tab Pho Sml │
|
||||
│ ───────────────────── │
|
||||
│ ▲ selected │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ┌─ Dimensions ────────────────────────────────┐ │
|
||||
│ │ Width [100%] │ │
|
||||
│ │ Height [auto] (inherited) [↺] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Margin and Padding ────────────────────────┐ │
|
||||
│ │ Margin Top [24px] ● changed │ │
|
||||
│ │ Padding [16px] (inherited) [↺] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Style ─────────────────────────────────────┐ │
|
||||
│ │ Background [#ffffff] (no breakpoints) │ │
|
||||
│ │ Border [1px solid] (no breakpoints) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 💻 2 overrides 📱 3 overrides 📱 1 override │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Visual States
|
||||
|
||||
| State | Appearance |
|
||||
|-------|------------|
|
||||
| Base value (desktop) | Normal text, no indicator |
|
||||
| Inherited from larger breakpoint | Dimmed/italic text, "(inherited)" label |
|
||||
| Explicitly set for this breakpoint | Normal text, filled dot indicator (●) |
|
||||
| Reset button | Shows on hover for overridden values |
|
||||
|
||||
### Project Settings - Breakpoints Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Responsive Breakpoints │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ☑ Enable responsive breakpoints │
|
||||
│ │
|
||||
│ Cascade direction: [Desktop-first ▼] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ Name Min Width Max Width │ │
|
||||
│ │ ─────────────────────────────────────────│ │
|
||||
│ │ 🖥️ Desktop 1024px — [Default]│ │
|
||||
│ │ 💻 Tablet 768px 1023px │ │
|
||||
│ │ 📱 Phone 320px 767px │ │
|
||||
│ │ 📱 Small Phone 0px 319px │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [+ Add Breakpoint] [Reset to Defaults] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create BreakpointSelector Component
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.tsx`
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
|
||||
import css from './BreakpointSelector.module.scss';
|
||||
|
||||
export interface Breakpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: IconName;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export interface BreakpointSelectorProps {
|
||||
breakpoints: Breakpoint[];
|
||||
selectedBreakpoint: string;
|
||||
overrideCounts: Record<string, number>; // { tablet: 2, phone: 3 }
|
||||
onBreakpointChange: (breakpointId: string) => void;
|
||||
}
|
||||
|
||||
export function BreakpointSelector({
|
||||
breakpoints,
|
||||
selectedBreakpoint,
|
||||
overrideCounts,
|
||||
onBreakpointChange
|
||||
}: BreakpointSelectorProps) {
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<span className={css.Label}>Breakpoint:</span>
|
||||
<div className={css.ButtonGroup}>
|
||||
{breakpoints.map((bp) => (
|
||||
<Tooltip
|
||||
key={bp.id}
|
||||
content={`${bp.name}${bp.minWidth ? ` (${bp.minWidth}px+)` : ''}`}
|
||||
>
|
||||
<button
|
||||
className={classNames(css.Button, {
|
||||
[css.isSelected]: selectedBreakpoint === bp.id,
|
||||
[css.hasOverrides]: overrideCounts[bp.id] > 0
|
||||
})}
|
||||
onClick={() => onBreakpointChange(bp.id)}
|
||||
>
|
||||
<Icon icon={getIconForBreakpoint(bp.icon)} />
|
||||
{overrideCounts[bp.id] > 0 && (
|
||||
<span className={css.OverrideCount}>{overrideCounts[bp.id]}</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIconForBreakpoint(icon: string): IconName {
|
||||
switch (icon) {
|
||||
case 'desktop': return IconName.DeviceDesktop;
|
||||
case 'tablet': return IconName.DeviceTablet;
|
||||
case 'phone':
|
||||
case 'phone-small':
|
||||
default: return IconName.DevicePhone;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.module.scss`
|
||||
|
||||
```scss
|
||||
.Root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--theme-color-bg-3);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.Label {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ButtonGroup {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.Button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
background-color: var(--theme-color-primary);
|
||||
|
||||
svg path {
|
||||
fill: var(--theme-color-on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
svg path {
|
||||
fill: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.OverrideCount {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-on-primary);
|
||||
background-color: var(--theme-color-secondary);
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create Inherited Value Indicator
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.tsx`
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
|
||||
import css from './InheritedIndicator.module.scss';
|
||||
|
||||
export interface InheritedIndicatorProps {
|
||||
isInherited: boolean;
|
||||
inheritedFrom?: string; // 'desktop', 'tablet', etc.
|
||||
isBreakpointAware: boolean;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function InheritedIndicator({
|
||||
isInherited,
|
||||
inheritedFrom,
|
||||
isBreakpointAware,
|
||||
onReset
|
||||
}: InheritedIndicatorProps) {
|
||||
if (!isBreakpointAware) {
|
||||
return null; // Don't show anything for non-breakpoint properties
|
||||
}
|
||||
|
||||
if (isInherited) {
|
||||
return (
|
||||
<Tooltip content={`Inherited from ${inheritedFrom}`}>
|
||||
<span className={css.Inherited}>
|
||||
(inherited)
|
||||
{onReset && (
|
||||
<button className={css.ResetButton} onClick={onReset}>
|
||||
<Icon icon={IconName.Undo} size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="Value set for this breakpoint">
|
||||
<span className={css.Changed}>
|
||||
<span className={css.Dot}>●</span>
|
||||
{onReset && (
|
||||
<button className={css.ResetButton} onClick={onReset}>
|
||||
<Icon icon={IconName.Undo} size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Integrate into Property Editor
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts`
|
||||
|
||||
```typescript
|
||||
// Add to existing property editor
|
||||
|
||||
import { BreakpointSelector } from './components/BreakpointSelector';
|
||||
|
||||
// In render method, add breakpoint selector after visual states
|
||||
renderBreakpointSelector() {
|
||||
const node = this.model;
|
||||
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
|
||||
|
||||
if (!hasBreakpointPorts) return; // Don't show if no breakpoint-aware properties
|
||||
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const overrideCounts = this.calculateOverrideCounts();
|
||||
|
||||
const props = {
|
||||
breakpoints: settings.breakpoints.map(bp => ({
|
||||
id: bp.id,
|
||||
name: bp.name,
|
||||
icon: bp.icon,
|
||||
minWidth: bp.minWidth,
|
||||
maxWidth: bp.maxWidth
|
||||
})),
|
||||
selectedBreakpoint: this.modelProxy.breakpoint || settings.defaultBreakpoint,
|
||||
overrideCounts,
|
||||
onBreakpointChange: this.onBreakpointChanged.bind(this)
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
React.createElement(BreakpointSelector, props),
|
||||
this.$('.breakpoint-selector')[0]
|
||||
);
|
||||
}
|
||||
|
||||
onBreakpointChanged(breakpointId: string) {
|
||||
this.modelProxy.setBreakpoint(breakpointId);
|
||||
this.scheduleRenderPortsView();
|
||||
}
|
||||
|
||||
hasBreakpointAwarePorts(): boolean {
|
||||
const ports = this.model.getPorts('input');
|
||||
return ports.some(p => p.allowBreakpoints);
|
||||
}
|
||||
|
||||
calculateOverrideCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
|
||||
for (const bp of settings.breakpoints) {
|
||||
if (bp.id === settings.defaultBreakpoint) continue;
|
||||
|
||||
const overrides = this.model.breakpointParameters?.[bp.id];
|
||||
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Property Panel Row Component
|
||||
|
||||
**File:** `packages/noodl-core-ui/src/components/property-panel/PropertyPanelRow/PropertyPanelRow.tsx`
|
||||
|
||||
```tsx
|
||||
// Extend PropertyPanelRow to show inherited indicator
|
||||
|
||||
export interface PropertyPanelRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
|
||||
// NEW props for breakpoint support
|
||||
isBreakpointAware?: boolean;
|
||||
isInherited?: boolean;
|
||||
inheritedFrom?: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function PropertyPanelRow({
|
||||
label,
|
||||
children,
|
||||
isBreakpointAware,
|
||||
isInherited,
|
||||
inheritedFrom,
|
||||
onReset
|
||||
}: PropertyPanelRowProps) {
|
||||
return (
|
||||
<div className={classNames(css.Root, { [css.isInherited]: isInherited })}>
|
||||
<label className={css.Label}>{label}</label>
|
||||
<div className={css.InputContainer}>
|
||||
{children}
|
||||
{isBreakpointAware && (
|
||||
<InheritedIndicator
|
||||
isInherited={isInherited}
|
||||
inheritedFrom={inheritedFrom}
|
||||
isBreakpointAware={isBreakpointAware}
|
||||
onReset={!isInherited ? onReset : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Ports View
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts`
|
||||
|
||||
```typescript
|
||||
// Extend the Ports view to pass breakpoint info to each property row
|
||||
|
||||
renderPort(port) {
|
||||
const isBreakpointAware = port.allowBreakpoints;
|
||||
const currentBreakpoint = this.modelProxy.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
let isInherited = false;
|
||||
let inheritedFrom = null;
|
||||
|
||||
if (isBreakpointAware && currentBreakpoint !== defaultBreakpoint) {
|
||||
isInherited = this.modelProxy.isBreakpointValueInherited(port.name);
|
||||
inheritedFrom = this.getInheritedFromBreakpoint(port.name, currentBreakpoint);
|
||||
}
|
||||
|
||||
// Pass these to the PropertyPanelRow component
|
||||
return {
|
||||
...existingPortRenderData,
|
||||
isBreakpointAware,
|
||||
isInherited,
|
||||
inheritedFrom,
|
||||
onReset: isBreakpointAware && !isInherited
|
||||
? () => this.resetBreakpointValue(port.name, currentBreakpoint)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
resetBreakpointValue(portName: string, breakpoint: string) {
|
||||
this.modelProxy.setParameter(portName, undefined, {
|
||||
breakpoint,
|
||||
undo: true,
|
||||
label: `reset ${portName} for ${breakpoint}`
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
getInheritedFromBreakpoint(portName: string, currentBreakpoint: string): string {
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const breakpointOrder = settings.breakpoints.map(bp => bp.id);
|
||||
const currentIndex = breakpointOrder.indexOf(currentBreakpoint);
|
||||
|
||||
// Walk up the cascade to find where value comes from
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (this.model.hasBreakpointParameter(portName, bp)) {
|
||||
return settings.breakpoints.find(b => b.id === bp)?.name || bp;
|
||||
}
|
||||
}
|
||||
|
||||
return settings.breakpoints[0]?.name || 'Desktop'; // Default
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Add Breakpoint Settings to Project Settings Panel
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/BreakpointSettingsSection.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
|
||||
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
|
||||
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
|
||||
export function BreakpointSettingsSection() {
|
||||
const [settings, setSettings] = useState(
|
||||
ProjectModel.instance.getBreakpointSettings()
|
||||
);
|
||||
|
||||
function handleEnabledChange(enabled: boolean) {
|
||||
const newSettings = { ...settings, enabled };
|
||||
setSettings(newSettings);
|
||||
ProjectModel.instance.setBreakpointSettings(newSettings);
|
||||
}
|
||||
|
||||
function handleCascadeDirectionChange(direction: string) {
|
||||
const newSettings = { ...settings, cascadeDirection: direction };
|
||||
setSettings(newSettings);
|
||||
ProjectModel.instance.setBreakpointSettings(newSettings);
|
||||
}
|
||||
|
||||
function handleBreakpointChange(index: number, field: string, value: any) {
|
||||
const newBreakpoints = [...settings.breakpoints];
|
||||
newBreakpoints[index] = { ...newBreakpoints[index], [field]: value };
|
||||
|
||||
const newSettings = { ...settings, breakpoints: newBreakpoints };
|
||||
setSettings(newSettings);
|
||||
ProjectModel.instance.setBreakpointSettings(newSettings);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableSection title="Responsive Breakpoints" hasGutter>
|
||||
<PropertyPanelRow label="Enable breakpoints">
|
||||
<PropertyPanelCheckbox
|
||||
value={settings.enabled}
|
||||
onChange={handleEnabledChange}
|
||||
/>
|
||||
</PropertyPanelRow>
|
||||
|
||||
<PropertyPanelRow label="Cascade direction">
|
||||
<PropertyPanelSelectInput
|
||||
value={settings.cascadeDirection}
|
||||
onChange={handleCascadeDirectionChange}
|
||||
options={[
|
||||
{ label: 'Desktop-first', value: 'desktop-first' },
|
||||
{ label: 'Mobile-first', value: 'mobile-first' }
|
||||
]}
|
||||
/>
|
||||
</PropertyPanelRow>
|
||||
|
||||
<div className={css.BreakpointList}>
|
||||
{settings.breakpoints.map((bp, index) => (
|
||||
<BreakpointRow
|
||||
key={bp.id}
|
||||
breakpoint={bp}
|
||||
isDefault={bp.id === settings.defaultBreakpoint}
|
||||
onChange={(field, value) => handleBreakpointChange(index, field, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Add Template to Property Editor HTML
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/templates/propertyeditor.html`
|
||||
|
||||
Add breakpoint selector container:
|
||||
|
||||
```html
|
||||
<!-- Add after visual-states div -->
|
||||
<div class="breakpoint-selector"></div>
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts` | Add breakpoint selector rendering, integrate with ModelProxy |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass breakpoint info to property rows |
|
||||
| `packages/noodl-editor/src/editor/src/templates/propertyeditor.html` | Add breakpoint selector container |
|
||||
| `packages/noodl-core-ui/src/components/property-panel/PropertyPanelRow/PropertyPanelRow.tsx` | Add inherited indicator support |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/ProjectSettingsPanel.tsx` | Add breakpoint settings section |
|
||||
| `packages/noodl-editor/src/editor/src/styles/propertyeditor/` | Add breakpoint-related styles |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.tsx` | Main breakpoint selector component |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.module.scss` | Styles for breakpoint selector |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/index.ts` | Export |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.tsx` | Inherited value indicator |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.module.scss` | Styles |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/index.ts` | Export |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/BreakpointSettingsSection.tsx` | Project settings UI |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Breakpoint selector appears in property panel for nodes with breakpoint-aware properties
|
||||
- [ ] Breakpoint selector does NOT appear for nodes without breakpoint-aware properties
|
||||
- [ ] Clicking breakpoint buttons switches the current breakpoint
|
||||
- [ ] Property values update to show breakpoint-specific values when switching
|
||||
- [ ] Inherited values show dimmed with "(inherited)" label
|
||||
- [ ] Override values show with dot indicator (●)
|
||||
- [ ] Reset button appears on hover for overridden values
|
||||
- [ ] Clicking reset removes the breakpoint-specific value
|
||||
- [ ] Override count badges show correct counts
|
||||
- [ ] Project Settings shows breakpoint configuration
|
||||
- [ ] Can change cascade direction in project settings
|
||||
- [ ] Can modify breakpoint thresholds in project settings
|
||||
- [ ] Changes persist after saving and reloading project
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Users can switch between breakpoints in property panel
|
||||
2. ✅ Clear visual distinction between inherited and overridden values
|
||||
3. ✅ Can set breakpoint-specific values by editing while breakpoint is selected
|
||||
4. ✅ Can reset breakpoint-specific values to inherit from larger breakpoint
|
||||
5. ✅ Override counts visible at a glance
|
||||
6. ✅ Project settings allow breakpoint customization
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Visual States Coexistence**: The breakpoint selector should appear ABOVE the visual states selector (if present). They're independent axes.
|
||||
|
||||
2. **Port Filtering**: Only ports with `allowBreakpoints: true` should show the inherited/override indicators. Non-breakpoint properties look normal.
|
||||
|
||||
3. **Connected Ports**: If a port is connected (has a wire), it shouldn't show breakpoint controls - the connection takes precedence.
|
||||
|
||||
4. **Performance**: Calculating override counts could be expensive if done on every render. Consider caching or only recalculating when breakpointParameters change.
|
||||
|
||||
5. **Mobile-First Logic**: When cascade direction is mobile-first, the inheritance flows the OTHER direction (phone → tablet → desktop). Make sure the `getInheritedFromBreakpoint` logic handles both.
|
||||
|
||||
6. **Keyboard Navigation**: Consider adding keyboard shortcuts to switch breakpoints (e.g., Ctrl+1/2/3/4).
|
||||
|
||||
## UI/UX Refinements (Optional)
|
||||
|
||||
- Animate the transition when switching breakpoints
|
||||
- Add tooltips showing the pixel range for each breakpoint
|
||||
- Consider a "copy to all breakpoints" action
|
||||
- Add visual preview of how values differ across breakpoints
|
||||
@@ -0,0 +1,619 @@
|
||||
# Phase 3: Runtime - Viewport Detection
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the runtime system that detects viewport width changes and applies the correct breakpoint-specific values to nodes. This includes creating a BreakpointManager singleton, wiring up resize listeners, and ensuring nodes reactively update when the breakpoint changes.
|
||||
|
||||
**Estimate:** 2-3 days
|
||||
|
||||
**Dependencies:** Phase 1 (Foundation)
|
||||
|
||||
## Goals
|
||||
|
||||
1. Create BreakpointManager singleton for viewport detection
|
||||
2. Implement viewport resize listener with debouncing
|
||||
3. Wire nodes to respond to breakpoint changes
|
||||
4. Implement value resolution with cascade logic
|
||||
5. Support both desktop-first and mobile-first cascades
|
||||
6. Ensure smooth transitions when breakpoint changes
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### BreakpointManager
|
||||
|
||||
Central singleton that:
|
||||
- Monitors `window.innerWidth`
|
||||
- Determines current breakpoint based on project settings
|
||||
- Notifies subscribers when breakpoint changes
|
||||
- Handles both desktop-first and mobile-first cascade
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ BreakpointManager │
|
||||
├─────────────────────────────────────────┤
|
||||
│ - currentBreakpoint: string │
|
||||
│ - settings: BreakpointSettings │
|
||||
│ - listeners: Set<Function> │
|
||||
├─────────────────────────────────────────┤
|
||||
│ + initialize(settings) │
|
||||
│ + getCurrentBreakpoint(): string │
|
||||
│ + getBreakpointForWidth(width): string │
|
||||
│ + subscribe(callback): unsubscribe │
|
||||
│ + getCascadeOrder(): string[] │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│ notifies
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Visual Nodes │
|
||||
│ (subscribe to breakpoint changes) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Value Resolution Flow
|
||||
|
||||
```
|
||||
getResolvedValue(propertyName)
|
||||
│
|
||||
▼
|
||||
Is property breakpoint-aware?
|
||||
│
|
||||
├─ No → return parameters[propertyName]
|
||||
│
|
||||
└─ Yes → Get current breakpoint
|
||||
│
|
||||
▼
|
||||
Check breakpointParameters[currentBreakpoint]
|
||||
│
|
||||
├─ Has value → return it
|
||||
│
|
||||
└─ No value → Cascade to next breakpoint
|
||||
│
|
||||
▼
|
||||
(repeat until found or reach default)
|
||||
│
|
||||
▼
|
||||
return parameters[propertyName]
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create BreakpointManager
|
||||
|
||||
**File:** `packages/noodl-runtime/src/breakpointmanager.js`
|
||||
|
||||
```javascript
|
||||
'use strict';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: true,
|
||||
cascadeDirection: 'desktop-first',
|
||||
defaultBreakpoint: 'desktop',
|
||||
breakpoints: [
|
||||
{ id: 'desktop', minWidth: 1024 },
|
||||
{ id: 'tablet', minWidth: 768, maxWidth: 1023 },
|
||||
{ id: 'phone', minWidth: 320, maxWidth: 767 },
|
||||
{ id: 'smallPhone', minWidth: 0, maxWidth: 319 }
|
||||
]
|
||||
};
|
||||
|
||||
class BreakpointManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.settings = DEFAULT_SETTINGS;
|
||||
this.currentBreakpoint = DEFAULT_SETTINGS.defaultBreakpoint;
|
||||
this._resizeTimeout = null;
|
||||
this._boundHandleResize = this._handleResize.bind(this);
|
||||
|
||||
// Don't auto-initialize - wait for settings from project
|
||||
}
|
||||
|
||||
initialize(settings) {
|
||||
this.settings = settings || DEFAULT_SETTINGS;
|
||||
this.currentBreakpoint = this.settings.defaultBreakpoint;
|
||||
|
||||
// Set up resize listener
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this._boundHandleResize);
|
||||
window.addEventListener('resize', this._boundHandleResize);
|
||||
|
||||
// Initial detection
|
||||
this._updateBreakpoint(window.innerWidth);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this._boundHandleResize);
|
||||
}
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
_handleResize() {
|
||||
// Debounce resize events
|
||||
if (this._resizeTimeout) {
|
||||
clearTimeout(this._resizeTimeout);
|
||||
}
|
||||
|
||||
this._resizeTimeout = setTimeout(() => {
|
||||
this._updateBreakpoint(window.innerWidth);
|
||||
}, 100); // 100ms debounce
|
||||
}
|
||||
|
||||
_updateBreakpoint(width) {
|
||||
const newBreakpoint = this.getBreakpointForWidth(width);
|
||||
|
||||
if (newBreakpoint !== this.currentBreakpoint) {
|
||||
const previousBreakpoint = this.currentBreakpoint;
|
||||
this.currentBreakpoint = newBreakpoint;
|
||||
|
||||
this.emit('breakpointChanged', {
|
||||
breakpoint: newBreakpoint,
|
||||
previousBreakpoint,
|
||||
width
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getBreakpointForWidth(width) {
|
||||
if (!this.settings.enabled) {
|
||||
return this.settings.defaultBreakpoint;
|
||||
}
|
||||
|
||||
const breakpoints = this.settings.breakpoints;
|
||||
|
||||
for (const bp of breakpoints) {
|
||||
const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
|
||||
const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
|
||||
|
||||
if (minMatch && maxMatch) {
|
||||
return bp.id;
|
||||
}
|
||||
}
|
||||
|
||||
return this.settings.defaultBreakpoint;
|
||||
}
|
||||
|
||||
getCurrentBreakpoint() {
|
||||
return this.currentBreakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cascade order for value inheritance.
|
||||
* Desktop-first: ['desktop', 'tablet', 'phone', 'smallPhone']
|
||||
* Mobile-first: ['smallPhone', 'phone', 'tablet', 'desktop']
|
||||
*/
|
||||
getCascadeOrder() {
|
||||
const breakpointIds = this.settings.breakpoints.map(bp => bp.id);
|
||||
|
||||
if (this.settings.cascadeDirection === 'mobile-first') {
|
||||
return breakpointIds.slice().reverse();
|
||||
}
|
||||
|
||||
return breakpointIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breakpoints that a given breakpoint inherits from.
|
||||
* For desktop-first with current='phone':
|
||||
* returns ['tablet', 'desktop'] (phone inherits from tablet, which inherits from desktop)
|
||||
*/
|
||||
getInheritanceChain(breakpointId) {
|
||||
const cascadeOrder = this.getCascadeOrder();
|
||||
const currentIndex = cascadeOrder.indexOf(breakpointId);
|
||||
|
||||
if (currentIndex <= 0) return []; // First in cascade inherits from nothing
|
||||
|
||||
return cascadeOrder.slice(0, currentIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to breakpoint changes.
|
||||
* Returns unsubscribe function.
|
||||
*/
|
||||
subscribe(callback) {
|
||||
this.on('breakpointChanged', callback);
|
||||
return () => this.off('breakpointChanged', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a breakpoint (for testing/preview).
|
||||
* Pass null to return to auto-detection.
|
||||
*/
|
||||
forceBreakpoint(breakpointId) {
|
||||
if (breakpointId === null) {
|
||||
// Return to auto-detection
|
||||
if (typeof window !== 'undefined') {
|
||||
this._updateBreakpoint(window.innerWidth);
|
||||
}
|
||||
} else {
|
||||
const previousBreakpoint = this.currentBreakpoint;
|
||||
this.currentBreakpoint = breakpointId;
|
||||
|
||||
this.emit('breakpointChanged', {
|
||||
breakpoint: breakpointId,
|
||||
previousBreakpoint,
|
||||
forced: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const breakpointManager = new BreakpointManager();
|
||||
|
||||
module.exports = breakpointManager;
|
||||
```
|
||||
|
||||
### Step 2: Integrate with GraphModel
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
|
||||
|
||||
```javascript
|
||||
const breakpointManager = require('../breakpointmanager');
|
||||
|
||||
// In setSettings method, initialize breakpoint manager
|
||||
GraphModel.prototype.setSettings = function(settings) {
|
||||
this.settings = settings;
|
||||
|
||||
// Initialize breakpoint manager with project settings
|
||||
if (settings.responsiveBreakpoints) {
|
||||
breakpointManager.initialize(settings.responsiveBreakpoints);
|
||||
}
|
||||
|
||||
this.emit('projectSettingsChanged', settings);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Add Value Resolution to Node Base
|
||||
|
||||
**File:** `packages/noodl-runtime/src/nodes/nodebase.js` (or equivalent base class)
|
||||
|
||||
```javascript
|
||||
const breakpointManager = require('../breakpointmanager');
|
||||
|
||||
// Add to node initialization
|
||||
{
|
||||
_initializeBreakpointSupport() {
|
||||
// Subscribe to breakpoint changes
|
||||
this._breakpointUnsubscribe = breakpointManager.subscribe(
|
||||
this._onBreakpointChanged.bind(this)
|
||||
);
|
||||
},
|
||||
|
||||
_disposeBreakpointSupport() {
|
||||
if (this._breakpointUnsubscribe) {
|
||||
this._breakpointUnsubscribe();
|
||||
this._breakpointUnsubscribe = null;
|
||||
}
|
||||
},
|
||||
|
||||
_onBreakpointChanged({ breakpoint, previousBreakpoint }) {
|
||||
// Re-apply all breakpoint-aware properties
|
||||
this._applyBreakpointValues();
|
||||
},
|
||||
|
||||
_applyBreakpointValues() {
|
||||
const ports = this.getPorts ? this.getPorts('input') : [];
|
||||
|
||||
for (const port of ports) {
|
||||
if (port.allowBreakpoints) {
|
||||
const value = this.getResolvedParameterValue(port.name);
|
||||
this._applyParameterValue(port.name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Force re-render if this is a React node
|
||||
if (this.forceUpdate) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the resolved value for a parameter, considering breakpoints and cascade.
|
||||
*/
|
||||
getResolvedParameterValue(name) {
|
||||
const port = this.getPort ? this.getPort(name, 'input') : null;
|
||||
|
||||
// If not breakpoint-aware, just return the base value
|
||||
if (!port || !port.allowBreakpoints) {
|
||||
return this.getParameterValue(name);
|
||||
}
|
||||
|
||||
const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
|
||||
const settings = breakpointManager.settings;
|
||||
|
||||
// If at default breakpoint, use base parameters
|
||||
if (currentBreakpoint === settings.defaultBreakpoint) {
|
||||
return this.getParameterValue(name);
|
||||
}
|
||||
|
||||
// Check for value at current breakpoint
|
||||
if (this._model.breakpointParameters?.[currentBreakpoint]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[currentBreakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade: check inheritance chain
|
||||
const inheritanceChain = breakpointManager.getInheritanceChain(currentBreakpoint);
|
||||
|
||||
for (const bp of inheritanceChain.reverse()) { // Check from closest to furthest
|
||||
if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to base parameters
|
||||
return this.getParameterValue(name);
|
||||
},
|
||||
|
||||
_applyParameterValue(name, value) {
|
||||
// Override in specific node types to apply the value
|
||||
// For visual nodes, this might update CSS properties
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Integrate with Visual Nodes
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/visual/visualbase.js` (or equivalent)
|
||||
|
||||
```javascript
|
||||
const breakpointManager = require('@noodl/runtime/src/breakpointmanager');
|
||||
|
||||
// In visual node base
|
||||
|
||||
{
|
||||
initialize() {
|
||||
// ... existing initialization
|
||||
|
||||
// Set up breakpoint support
|
||||
this._initializeBreakpointSupport();
|
||||
},
|
||||
|
||||
_onNodeDeleted() {
|
||||
// ... existing cleanup
|
||||
|
||||
this._disposeBreakpointSupport();
|
||||
},
|
||||
|
||||
// Override to apply CSS property values
|
||||
_applyParameterValue(name, value) {
|
||||
// Map parameter name to CSS property
|
||||
const cssProperty = this._getCSSPropertyForParameter(name);
|
||||
|
||||
if (cssProperty && this._internal.element) {
|
||||
this._internal.element.style[cssProperty] = value;
|
||||
}
|
||||
|
||||
// Or if using React, set state/props
|
||||
if (this._internal.reactComponent) {
|
||||
// Trigger re-render with new value
|
||||
this.forceUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
_getCSSPropertyForParameter(name) {
|
||||
// Map Noodl parameter names to CSS properties
|
||||
const mapping = {
|
||||
marginTop: 'marginTop',
|
||||
marginRight: 'marginRight',
|
||||
marginBottom: 'marginBottom',
|
||||
marginLeft: 'marginLeft',
|
||||
paddingTop: 'paddingTop',
|
||||
paddingRight: 'paddingRight',
|
||||
paddingBottom: 'paddingBottom',
|
||||
paddingLeft: 'paddingLeft',
|
||||
width: 'width',
|
||||
height: 'height',
|
||||
minWidth: 'minWidth',
|
||||
maxWidth: 'maxWidth',
|
||||
minHeight: 'minHeight',
|
||||
maxHeight: 'maxHeight',
|
||||
fontSize: 'fontSize',
|
||||
lineHeight: 'lineHeight',
|
||||
letterSpacing: 'letterSpacing',
|
||||
flexDirection: 'flexDirection',
|
||||
alignItems: 'alignItems',
|
||||
justifyContent: 'justifyContent',
|
||||
flexWrap: 'flexWrap',
|
||||
gap: 'gap'
|
||||
};
|
||||
|
||||
return mapping[name];
|
||||
},
|
||||
|
||||
// Override getStyle to use resolved breakpoint values
|
||||
getStyle(name) {
|
||||
// Check if this is a breakpoint-aware property
|
||||
const port = this.getPort(name, 'input');
|
||||
|
||||
if (port?.allowBreakpoints) {
|
||||
return this.getResolvedParameterValue(name);
|
||||
}
|
||||
|
||||
// Fall back to existing behavior
|
||||
return this._existingGetStyle(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update React Component Props
|
||||
|
||||
**File:** For React-based visual nodes, update how props are computed
|
||||
|
||||
```javascript
|
||||
// In the React component wrapper
|
||||
|
||||
getReactProps() {
|
||||
const props = {};
|
||||
const ports = this.getPorts('input');
|
||||
|
||||
for (const port of ports) {
|
||||
// Use resolved value for breakpoint-aware properties
|
||||
if (port.allowBreakpoints) {
|
||||
props[port.name] = this.getResolvedParameterValue(port.name);
|
||||
} else {
|
||||
props[port.name] = this.getParameterValue(port.name);
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Add Transition Support (Optional Enhancement)
|
||||
|
||||
**File:** `packages/noodl-runtime/src/breakpointmanager.js`
|
||||
|
||||
```javascript
|
||||
// Add transition support for smooth breakpoint changes
|
||||
|
||||
class BreakpointManager extends EventEmitter {
|
||||
// ... existing code
|
||||
|
||||
_updateBreakpoint(width) {
|
||||
const newBreakpoint = this.getBreakpointForWidth(width);
|
||||
|
||||
if (newBreakpoint !== this.currentBreakpoint) {
|
||||
const previousBreakpoint = this.currentBreakpoint;
|
||||
this.currentBreakpoint = newBreakpoint;
|
||||
|
||||
// Add CSS class for transitions
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.classList.add('noodl-breakpoint-transitioning');
|
||||
|
||||
// Remove after transition completes
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove('noodl-breakpoint-transitioning');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
this.emit('breakpointChanged', {
|
||||
breakpoint: newBreakpoint,
|
||||
previousBreakpoint,
|
||||
width
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CSS:** Add to runtime styles
|
||||
|
||||
```css
|
||||
/* Smooth transitions when breakpoint changes */
|
||||
.noodl-breakpoint-transitioning * {
|
||||
transition:
|
||||
margin 0.2s ease-out,
|
||||
padding 0.2s ease-out,
|
||||
width 0.2s ease-out,
|
||||
height 0.2s ease-out,
|
||||
font-size 0.2s ease-out,
|
||||
gap 0.2s ease-out !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Editor-Runtime Communication
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts`
|
||||
|
||||
```typescript
|
||||
// When breakpoint settings change in editor, sync to runtime
|
||||
|
||||
onBreakpointSettingsChanged(settings: BreakpointSettings) {
|
||||
this.tryWebviewCall(() => {
|
||||
this.webview.executeJavaScript(`
|
||||
if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
|
||||
window.NoodlRuntime.breakpointManager.initialize(${JSON.stringify(settings)});
|
||||
}
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally: Force breakpoint for preview purposes
|
||||
forceRuntimeBreakpoint(breakpointId: string | null) {
|
||||
this.tryWebviewCall(() => {
|
||||
this.webview.executeJavaScript(`
|
||||
if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
|
||||
window.NoodlRuntime.breakpointManager.forceBreakpoint(${JSON.stringify(breakpointId)});
|
||||
}
|
||||
`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-runtime/src/models/graphmodel.js` | Initialize breakpointManager with settings |
|
||||
| `packages/noodl-runtime/src/nodes/nodebase.js` | Add breakpoint value resolution |
|
||||
| `packages/noodl-viewer-react/src/nodes/visual/visualbase.js` | Wire up breakpoint subscriptions |
|
||||
| `packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts` | Add breakpoint sync to runtime |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-runtime/src/breakpointmanager.js` | Central breakpoint detection and management |
|
||||
| `packages/noodl-runtime/src/styles/breakpoints.css` | Optional transition styles |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] BreakpointManager correctly detects breakpoint from window width
|
||||
- [ ] BreakpointManager fires 'breakpointChanged' event on resize
|
||||
- [ ] Debouncing prevents excessive events during resize drag
|
||||
- [ ] Nodes receive breakpoint change notifications
|
||||
- [ ] Nodes apply correct breakpoint-specific values
|
||||
- [ ] Cascade works correctly (tablet inherits desktop values)
|
||||
- [ ] Mobile-first cascade works when configured
|
||||
- [ ] Values update smoothly during breakpoint transitions
|
||||
- [ ] `forceBreakpoint` works for testing/preview
|
||||
- [ ] Memory cleanup works (no leaks on node deletion)
|
||||
- [ ] Works in both editor preview and deployed app
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Resizing browser window changes applied breakpoint
|
||||
2. ✅ Visual nodes update their dimensions/spacing instantly
|
||||
3. ✅ Values cascade correctly when not overridden
|
||||
4. ✅ Both desktop-first and mobile-first work
|
||||
5. ✅ No performance issues with many nodes
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **SSR Considerations**: If Noodl supports SSR, `window` won't exist on server. Guard all window access with `typeof window !== 'undefined'`.
|
||||
|
||||
2. **Performance**: With many nodes subscribed, breakpoint changes could cause many re-renders. Consider:
|
||||
- Batch updates using requestAnimationFrame
|
||||
- Only re-render nodes whose values actually changed
|
||||
|
||||
3. **Debounce Tuning**: 100ms debounce is a starting point. May need adjustment based on feel.
|
||||
|
||||
4. **Transition Timing**: The CSS transition duration (0.2s) should match user expectations. Could make configurable.
|
||||
|
||||
5. **Initial Load**: On first load, breakpoint should be set BEFORE first render to avoid flash of wrong layout.
|
||||
|
||||
6. **Testing Breakpoints**: Add `breakpointManager.forceBreakpoint()` to allow testing different breakpoints without resizing window.
|
||||
|
||||
7. **React Strict Mode**: If using React Strict Mode, ensure subscriptions are properly cleaned up (may fire twice in dev).
|
||||
|
||||
## Performance Optimization Ideas
|
||||
|
||||
1. **Selective Updates**: Track which properties actually differ between breakpoints, only update those.
|
||||
|
||||
2. **CSS Variables**: Consider using CSS custom properties for breakpoint values, letting browser handle changes:
|
||||
```javascript
|
||||
// Set CSS variable per breakpoint
|
||||
document.documentElement.style.setProperty('--node-123-margin-top', '24px');
|
||||
```
|
||||
|
||||
3. **Batch Notifications**: Collect all changed nodes and update in single batch:
|
||||
```javascript
|
||||
requestAnimationFrame(() => {
|
||||
changedNodes.forEach(node => node.forceUpdate());
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,511 @@
|
||||
# Phase 4: Variants Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing Variants system to support breakpoint-specific values. When a user creates a variant (e.g., "Big Blue Button"), they should be able to define different margin/padding/width values for each breakpoint within that variant.
|
||||
|
||||
**Estimate:** 1-2 days
|
||||
|
||||
**Dependencies:** Phases 1-3
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `breakpointParameters` to VariantModel
|
||||
2. Extend variant editing UI to show breakpoint selector
|
||||
3. Implement value resolution hierarchy: Variant breakpoint → Variant base → Node base
|
||||
4. Ensure variant updates propagate to all nodes using that variant
|
||||
|
||||
## Value Resolution Hierarchy
|
||||
|
||||
When a node uses a variant, values are resolved in this order:
|
||||
|
||||
```
|
||||
1. Node instance breakpointParameters[currentBreakpoint][property]
|
||||
↓ (if undefined)
|
||||
2. Node instance parameters[property]
|
||||
↓ (if undefined)
|
||||
3. Variant breakpointParameters[currentBreakpoint][property]
|
||||
↓ (if undefined, cascade to larger breakpoints)
|
||||
4. Variant parameters[property]
|
||||
↓ (if undefined)
|
||||
5. Node type default
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
// Variant "Big Blue Button"
|
||||
{
|
||||
name: 'Big Blue Button',
|
||||
typename: 'net.noodl.visual.controls.button',
|
||||
parameters: {
|
||||
paddingLeft: '24px', // base padding
|
||||
paddingRight: '24px'
|
||||
},
|
||||
breakpointParameters: {
|
||||
tablet: { paddingLeft: '16px', paddingRight: '16px' },
|
||||
phone: { paddingLeft: '12px', paddingRight: '12px' }
|
||||
}
|
||||
}
|
||||
|
||||
// Node instance using this variant
|
||||
{
|
||||
variantName: 'Big Blue Button',
|
||||
parameters: {}, // no instance overrides
|
||||
breakpointParameters: {
|
||||
phone: { paddingLeft: '8px' } // only override phone left padding
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution for paddingLeft on phone:
|
||||
// 1. Check node.breakpointParameters.phone.paddingLeft → '8px' ✓ (use this)
|
||||
|
||||
// Resolution for paddingRight on phone:
|
||||
// 1. Check node.breakpointParameters.phone.paddingRight → undefined
|
||||
// 2. Check node.parameters.paddingRight → undefined
|
||||
// 3. Check variant.breakpointParameters.phone.paddingRight → '12px' ✓ (use this)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Extend VariantModel
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts`
|
||||
|
||||
```typescript
|
||||
export class VariantModel extends Model {
|
||||
name: string;
|
||||
typename: string;
|
||||
parameters: Record<string, any>;
|
||||
stateParameters: Record<string, Record<string, any>>;
|
||||
stateTransitions: Record<string, any>;
|
||||
defaultStateTransitions: any;
|
||||
|
||||
// NEW
|
||||
breakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
constructor(args) {
|
||||
super();
|
||||
|
||||
this.name = args.name;
|
||||
this.typename = args.typename;
|
||||
this.parameters = {};
|
||||
this.stateParameters = {};
|
||||
this.stateTransitions = {};
|
||||
this.breakpointParameters = {}; // NEW
|
||||
}
|
||||
|
||||
// NEW methods
|
||||
hasBreakpointParameter(name: string, breakpoint: string): boolean {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
|
||||
}
|
||||
|
||||
getBreakpointParameter(name: string, breakpoint: string): any {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name];
|
||||
}
|
||||
|
||||
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any) {
|
||||
if (!this.breakpointParameters) this.breakpointParameters = {};
|
||||
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
|
||||
|
||||
const oldValue = this.breakpointParameters[breakpoint][name];
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.breakpointParameters[breakpoint][name];
|
||||
} else {
|
||||
this.breakpointParameters[breakpoint][name] = value;
|
||||
}
|
||||
|
||||
this.notifyListeners('variantParametersChanged', {
|
||||
name,
|
||||
value,
|
||||
breakpoint
|
||||
});
|
||||
|
||||
// Undo support
|
||||
if (args?.undo) {
|
||||
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
|
||||
|
||||
undo.push({
|
||||
label: args.label || 'change variant breakpoint parameter',
|
||||
do: () => this.setBreakpointParameter(name, value, breakpoint),
|
||||
undo: () => this.setBreakpointParameter(name, oldValue, breakpoint)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extend getParameter to support breakpoint context
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
let value;
|
||||
|
||||
// Check breakpoint-specific value
|
||||
if (args?.breakpoint && args.breakpoint !== 'desktop') {
|
||||
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// Check state-specific value (existing logic)
|
||||
if (args?.state && args.state !== 'neutral') {
|
||||
if (this.stateParameters?.[args.state]?.[name] !== undefined) {
|
||||
value = this.stateParameters[args.state][name];
|
||||
}
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// Check base parameters
|
||||
value = this.parameters[name];
|
||||
if (value !== undefined) return value;
|
||||
|
||||
// Get default from port
|
||||
const port = this.getPort(name, 'input');
|
||||
return port?.default;
|
||||
}
|
||||
|
||||
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
|
||||
// Check current breakpoint
|
||||
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[breakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade to larger breakpoints
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
|
||||
const currentIndex = cascadeOrder.indexOf(breakpoint);
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const bp = cascadeOrder[i];
|
||||
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extend updateFromNode to include breakpoint parameters
|
||||
updateFromNode(node) {
|
||||
_merge(this.parameters, node.parameters);
|
||||
|
||||
// Merge breakpoint parameters
|
||||
if (node.breakpointParameters) {
|
||||
if (!this.breakpointParameters) this.breakpointParameters = {};
|
||||
for (const breakpoint in node.breakpointParameters) {
|
||||
if (!this.breakpointParameters[breakpoint]) {
|
||||
this.breakpointParameters[breakpoint] = {};
|
||||
}
|
||||
_merge(this.breakpointParameters[breakpoint], node.breakpointParameters[breakpoint]);
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing state parameter merging
|
||||
|
||||
this.notifyListeners('variantParametersChanged');
|
||||
}
|
||||
|
||||
// Extend toJSON
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
typename: this.typename,
|
||||
parameters: this.parameters,
|
||||
stateParameters: this.stateParameters,
|
||||
stateTransitions: this.stateTransitions,
|
||||
defaultStateTransitions: this.defaultStateTransitions,
|
||||
breakpointParameters: this.breakpointParameters // NEW
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Extend Runtime Variant Handling
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
|
||||
|
||||
```javascript
|
||||
// Add method to update variant breakpoint parameters
|
||||
GraphModel.prototype.updateVariantBreakpointParameter = function(
|
||||
variantName,
|
||||
variantTypeName,
|
||||
parameterName,
|
||||
parameterValue,
|
||||
breakpoint
|
||||
) {
|
||||
const variant = this.getVariant(variantTypeName, variantName);
|
||||
if (!variant) {
|
||||
console.log("updateVariantBreakpointParameter: can't find variant", variantName, variantTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variant.breakpointParameters) {
|
||||
variant.breakpointParameters = {};
|
||||
}
|
||||
|
||||
if (!variant.breakpointParameters[breakpoint]) {
|
||||
variant.breakpointParameters[breakpoint] = {};
|
||||
}
|
||||
|
||||
if (parameterValue === undefined) {
|
||||
delete variant.breakpointParameters[breakpoint][parameterName];
|
||||
} else {
|
||||
variant.breakpointParameters[breakpoint][parameterName] = parameterValue;
|
||||
}
|
||||
|
||||
this.emit('variantUpdated', variant);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Extend ModelProxy for Variant Editing
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
|
||||
|
||||
```typescript
|
||||
export class ModelProxy {
|
||||
// ... existing properties
|
||||
|
||||
getParameter(name: string) {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const port = this.model.getPort(name, 'input');
|
||||
|
||||
// Breakpoint handling
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
// Check for breakpoint-specific value in source
|
||||
const breakpointValue = source.getBreakpointParameter?.(name, this.breakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
|
||||
// Cascade logic...
|
||||
}
|
||||
|
||||
// ... existing visual state and base parameter logic
|
||||
|
||||
return source.getParameter(name, {
|
||||
state: this.visualState,
|
||||
breakpoint: this.breakpoint
|
||||
});
|
||||
}
|
||||
|
||||
setParameter(name: string, value: any, args: any = {}) {
|
||||
const port = this.model.getPort(name, 'input');
|
||||
const target = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
|
||||
// If setting a breakpoint-specific value
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
target.setBreakpointParameter(name, value, this.breakpoint, {
|
||||
...args,
|
||||
undo: args.undo
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ... existing parameter setting logic
|
||||
}
|
||||
|
||||
isBreakpointValueInherited(name: string): boolean {
|
||||
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
|
||||
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
return !source.hasBreakpointParameter?.(name, this.breakpoint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Variant Editor UI
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx`
|
||||
|
||||
```tsx
|
||||
// Add breakpoint selector to variant editing mode
|
||||
|
||||
export class VariantsEditor extends React.Component<VariantsEditorProps, State> {
|
||||
// ... existing implementation
|
||||
|
||||
renderEditMode() {
|
||||
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<div className="variants-edit-mode-header">Edit variant</div>
|
||||
|
||||
{/* Show breakpoint selector in variant edit mode */}
|
||||
{hasBreakpointPorts && (
|
||||
<BreakpointSelector
|
||||
breakpoints={this.getBreakpoints()}
|
||||
selectedBreakpoint={this.state.breakpoint || 'desktop'}
|
||||
overrideCounts={this.calculateVariantOverrideCounts()}
|
||||
onBreakpointChange={this.onBreakpointChanged.bind(this)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="variants-section">
|
||||
<label>{this.state.variant.name}</label>
|
||||
<button
|
||||
className="variants-button teal"
|
||||
style={{ marginLeft: 'auto', width: '78px' }}
|
||||
onClick={this.onDoneEditingVariant.bind(this)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onBreakpointChanged(breakpoint: string) {
|
||||
this.setState({ breakpoint });
|
||||
this.props.onBreakpointChanged?.(breakpoint);
|
||||
}
|
||||
|
||||
calculateVariantOverrideCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
const variant = this.state.variant;
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
|
||||
for (const bp of settings.breakpoints) {
|
||||
if (bp.id === settings.defaultBreakpoint) continue;
|
||||
|
||||
const overrides = variant.breakpointParameters?.[bp.id];
|
||||
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
hasBreakpointAwarePorts(): boolean {
|
||||
const type = NodeLibrary.instance.getNodeTypeWithName(this.state.variant?.typename);
|
||||
if (!type?.ports) return false;
|
||||
|
||||
return type.ports.some(p => p.allowBreakpoints);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update NodeGraphNode Value Resolution
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
let value;
|
||||
|
||||
// 1. Check instance breakpoint parameters
|
||||
if (args?.breakpoint && args.breakpoint !== 'desktop') {
|
||||
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// 2. Check instance base parameters
|
||||
value = this.parameters[name];
|
||||
if (value !== undefined) return value;
|
||||
|
||||
// 3. Check variant (if has one)
|
||||
if (this.variant) {
|
||||
value = this.variant.getParameter(name, args);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// 4. Get port default
|
||||
const port = this.getPort(name, 'input');
|
||||
return port?.default;
|
||||
}
|
||||
|
||||
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
|
||||
// Check current breakpoint
|
||||
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[breakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade to larger breakpoints (instance level)
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
|
||||
const currentIndex = cascadeOrder.indexOf(breakpoint);
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const bp = cascadeOrder[i];
|
||||
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
// Check variant breakpoint parameters
|
||||
if (this.variant) {
|
||||
return this.variant.getBreakpointParameterWithCascade(name, breakpoint);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Sync Variant Changes to Runtime
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
|
||||
|
||||
```typescript
|
||||
// When variant breakpoint parameters change, sync to runtime
|
||||
|
||||
onVariantParametersChanged(variant: VariantModel, changeInfo: any) {
|
||||
// ... existing sync logic
|
||||
|
||||
// If breakpoint parameter changed, notify runtime
|
||||
if (changeInfo.breakpoint) {
|
||||
this.graphModel.updateVariantBreakpointParameter(
|
||||
variant.name,
|
||||
variant.typename,
|
||||
changeInfo.name,
|
||||
changeInfo.value,
|
||||
changeInfo.breakpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/VariantModel.ts` | Add breakpointParameters field and methods |
|
||||
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Update value resolution to check variant breakpoints |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle variant breakpoint context |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx` | Add breakpoint selector to variant edit mode |
|
||||
| `packages/noodl-runtime/src/models/graphmodel.js` | Add variant breakpoint parameter update method |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Can create variant with breakpoint-specific values
|
||||
- [ ] Variant breakpoint values are saved to project JSON
|
||||
- [ ] Variant breakpoint values are loaded from project JSON
|
||||
- [ ] Node instance inherits variant breakpoint values correctly
|
||||
- [ ] Node instance can override specific variant breakpoint values
|
||||
- [ ] Cascade works: variant tablet inherits from variant desktop
|
||||
- [ ] Editing variant in "Edit variant" mode shows breakpoint selector
|
||||
- [ ] Changes to variant breakpoint values propagate to all instances
|
||||
- [ ] Undo/redo works for variant breakpoint changes
|
||||
- [ ] Runtime applies variant breakpoint values correctly
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Variants can have different values per breakpoint
|
||||
2. ✅ Node instances inherit variant breakpoint values
|
||||
3. ✅ Node instances can selectively override variant values
|
||||
4. ✅ UI allows editing variant breakpoint values
|
||||
5. ✅ Runtime correctly resolves variant + breakpoint hierarchy
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Resolution Order**: The hierarchy is complex. Make sure tests cover all combinations:
|
||||
- Instance breakpoint override > Instance base > Variant breakpoint > Variant base > Type default
|
||||
|
||||
2. **Variant Edit Mode**: When editing a variant, the breakpoint selector edits the VARIANT's breakpoint values, not the instance's.
|
||||
|
||||
3. **Variant Update Propagation**: When a variant's breakpoint values change, ALL nodes using that variant need to update. This could be performance-sensitive.
|
||||
|
||||
4. **State + Breakpoint + Variant**: The full combination is: variant/instance × state × breakpoint. For simplicity, we might NOT support visual state variations within variant breakpoint values (e.g., no "variant hover on tablet"). Confirm this is acceptable.
|
||||
|
||||
5. **Migration**: Existing variants won't have breakpointParameters. Handle gracefully (undefined → empty object).
|
||||
|
||||
## Complexity Note
|
||||
|
||||
This phase adds a third dimension to the value resolution:
|
||||
- **Visual States**: hover, pressed, disabled
|
||||
- **Breakpoints**: desktop, tablet, phone
|
||||
- **Variants**: named style variations
|
||||
|
||||
The full matrix can get complex. For this phase, we're keeping visual states and breakpoints as independent axes (they don't interact with each other within variants). A future phase could add combined state+breakpoint support if needed.
|
||||
@@ -0,0 +1,575 @@
|
||||
# Phase 5: Visual States + Breakpoints Combo
|
||||
|
||||
## Overview
|
||||
|
||||
Enable granular control where users can define values for specific combinations of visual state AND breakpoint. For example: "button hover state on tablet" can have different padding than "button hover state on desktop".
|
||||
|
||||
**Estimate:** 2 days
|
||||
|
||||
**Dependencies:** Phases 1-4
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `stateBreakpointParameters` storage for combined state+breakpoint values
|
||||
2. Implement resolution hierarchy with combo values at highest priority
|
||||
3. Update property panel UI to show combo editing option
|
||||
4. Ensure runtime correctly resolves combo values
|
||||
|
||||
## When This Is Useful
|
||||
|
||||
Without combo support:
|
||||
- Button hover padding is `20px` (all breakpoints)
|
||||
- Button tablet padding is `16px` (all states)
|
||||
- When hovering on tablet → ambiguous! Which wins?
|
||||
|
||||
With combo support:
|
||||
- Can explicitly set: "button hover ON tablet = `18px`"
|
||||
- Clear, deterministic resolution
|
||||
|
||||
## Data Model
|
||||
|
||||
```javascript
|
||||
{
|
||||
parameters: {
|
||||
paddingLeft: '24px' // base
|
||||
},
|
||||
stateParameters: {
|
||||
hover: { paddingLeft: '28px' } // hover state (all breakpoints)
|
||||
},
|
||||
breakpointParameters: {
|
||||
tablet: { paddingLeft: '16px' } // tablet (all states)
|
||||
},
|
||||
// NEW: Combined state + breakpoint
|
||||
stateBreakpointParameters: {
|
||||
'hover:tablet': { paddingLeft: '20px' }, // hover ON tablet
|
||||
'hover:phone': { paddingLeft: '14px' }, // hover ON phone
|
||||
'pressed:tablet': { paddingLeft: '18px' } // pressed ON tablet
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Resolution Hierarchy
|
||||
|
||||
From highest to lowest priority:
|
||||
|
||||
```
|
||||
1. stateBreakpointParameters['hover:tablet'] // Most specific
|
||||
↓ (if undefined)
|
||||
2. stateParameters['hover'] // State-specific
|
||||
↓ (if undefined)
|
||||
3. breakpointParameters['tablet'] // Breakpoint-specific
|
||||
↓ (if undefined, cascade to larger breakpoints)
|
||||
4. parameters // Base value
|
||||
↓ (if undefined)
|
||||
5. variant values (same hierarchy)
|
||||
↓ (if undefined)
|
||||
6. type default
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Extend NodeGraphNode Model
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
export class NodeGraphNode {
|
||||
// ... existing properties
|
||||
|
||||
// NEW
|
||||
stateBreakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
constructor(args) {
|
||||
// ... existing initialization
|
||||
this.stateBreakpointParameters = args.stateBreakpointParameters || {};
|
||||
}
|
||||
|
||||
// NEW methods
|
||||
getStateBreakpointKey(state: string, breakpoint: string): string {
|
||||
return `${state}:${breakpoint}`;
|
||||
}
|
||||
|
||||
hasStateBreakpointParameter(name: string, state: string, breakpoint: string): boolean {
|
||||
const key = this.getStateBreakpointKey(state, breakpoint);
|
||||
return this.stateBreakpointParameters?.[key]?.[name] !== undefined;
|
||||
}
|
||||
|
||||
getStateBreakpointParameter(name: string, state: string, breakpoint: string): any {
|
||||
const key = this.getStateBreakpointKey(state, breakpoint);
|
||||
return this.stateBreakpointParameters?.[key]?.[name];
|
||||
}
|
||||
|
||||
setStateBreakpointParameter(
|
||||
name: string,
|
||||
value: any,
|
||||
state: string,
|
||||
breakpoint: string,
|
||||
args?: any
|
||||
): void {
|
||||
const key = this.getStateBreakpointKey(state, breakpoint);
|
||||
|
||||
if (!this.stateBreakpointParameters) {
|
||||
this.stateBreakpointParameters = {};
|
||||
}
|
||||
if (!this.stateBreakpointParameters[key]) {
|
||||
this.stateBreakpointParameters[key] = {};
|
||||
}
|
||||
|
||||
const oldValue = this.stateBreakpointParameters[key][name];
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.stateBreakpointParameters[key][name];
|
||||
// Clean up empty objects
|
||||
if (Object.keys(this.stateBreakpointParameters[key]).length === 0) {
|
||||
delete this.stateBreakpointParameters[key];
|
||||
}
|
||||
} else {
|
||||
this.stateBreakpointParameters[key][name] = value;
|
||||
}
|
||||
|
||||
this.notifyListeners('parametersChanged', {
|
||||
name,
|
||||
value,
|
||||
state,
|
||||
breakpoint,
|
||||
combo: true
|
||||
});
|
||||
|
||||
// Undo support
|
||||
if (args?.undo) {
|
||||
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
|
||||
|
||||
undo.push({
|
||||
label: args.label || `change ${name} for ${state} on ${breakpoint}`,
|
||||
do: () => this.setStateBreakpointParameter(name, value, state, breakpoint),
|
||||
undo: () => this.setStateBreakpointParameter(name, oldValue, state, breakpoint)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Updated getParameter with full resolution
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
const state = args?.state;
|
||||
const breakpoint = args?.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
// 1. Check state + breakpoint combo (most specific)
|
||||
if (state && state !== 'neutral' && breakpoint && breakpoint !== defaultBreakpoint) {
|
||||
const comboValue = this.getStateBreakpointParameter(name, state, breakpoint);
|
||||
if (comboValue !== undefined) return comboValue;
|
||||
}
|
||||
|
||||
// 2. Check state-specific value
|
||||
if (state && state !== 'neutral') {
|
||||
if (this.stateParameters?.[state]?.[name] !== undefined) {
|
||||
return this.stateParameters[state][name];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check breakpoint-specific value (with cascade)
|
||||
if (breakpoint && breakpoint !== defaultBreakpoint) {
|
||||
const breakpointValue = this.getBreakpointParameterWithCascade(name, breakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
}
|
||||
|
||||
// 4. Check base parameters
|
||||
if (this.parameters[name] !== undefined) {
|
||||
return this.parameters[name];
|
||||
}
|
||||
|
||||
// 5. Check variant (with same hierarchy)
|
||||
if (this.variant) {
|
||||
return this.variant.getParameter(name, args);
|
||||
}
|
||||
|
||||
// 6. Type default
|
||||
const port = this.getPort(name, 'input');
|
||||
return port?.default;
|
||||
}
|
||||
|
||||
// Extend toJSON
|
||||
toJSON(): object {
|
||||
return {
|
||||
...existingFields,
|
||||
stateBreakpointParameters: this.stateBreakpointParameters
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Extend ModelProxy
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
|
||||
|
||||
```typescript
|
||||
export class ModelProxy {
|
||||
// ... existing properties
|
||||
|
||||
getParameter(name: string) {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const port = this.model.getPort(name, 'input');
|
||||
const state = this.visualState;
|
||||
const breakpoint = this.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
// Check if both state and breakpoint are set (combo scenario)
|
||||
const hasState = state && state !== 'neutral';
|
||||
const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint;
|
||||
|
||||
// For combo: only check if BOTH the property allows states AND breakpoints
|
||||
if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) {
|
||||
const comboValue = source.getStateBreakpointParameter?.(name, state, breakpoint);
|
||||
if (comboValue !== undefined) return comboValue;
|
||||
}
|
||||
|
||||
// ... existing resolution logic
|
||||
return source.getParameter(name, { state, breakpoint });
|
||||
}
|
||||
|
||||
setParameter(name: string, value: any, args: any = {}) {
|
||||
const port = this.model.getPort(name, 'input');
|
||||
const target = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const state = this.visualState;
|
||||
const breakpoint = this.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
const hasState = state && state !== 'neutral';
|
||||
const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint;
|
||||
|
||||
// If BOTH state and breakpoint are active, and property supports both
|
||||
if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) {
|
||||
target.setStateBreakpointParameter(name, value, state, breakpoint, {
|
||||
...args,
|
||||
undo: args.undo
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If only breakpoint (and property supports it)
|
||||
if (hasBreakpoint && port?.allowBreakpoints) {
|
||||
target.setBreakpointParameter(name, value, breakpoint, {
|
||||
...args,
|
||||
undo: args.undo
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ... existing parameter setting logic (state or base)
|
||||
args.state = state;
|
||||
target.setParameter(name, value, args);
|
||||
}
|
||||
|
||||
// NEW: Check if current value is from combo
|
||||
isComboValue(name: string): boolean {
|
||||
if (!this.visualState || this.visualState === 'neutral') return false;
|
||||
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
|
||||
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
return source.hasStateBreakpointParameter?.(name, this.visualState, this.breakpoint) || false;
|
||||
}
|
||||
|
||||
// NEW: Get info about where current value comes from
|
||||
getValueSource(name: string): 'combo' | 'state' | 'breakpoint' | 'base' {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const state = this.visualState;
|
||||
const breakpoint = this.breakpoint;
|
||||
|
||||
if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') {
|
||||
if (source.hasStateBreakpointParameter?.(name, state, breakpoint)) {
|
||||
return 'combo';
|
||||
}
|
||||
}
|
||||
|
||||
if (state && state !== 'neutral') {
|
||||
if (source.stateParameters?.[state]?.[name] !== undefined) {
|
||||
return 'state';
|
||||
}
|
||||
}
|
||||
|
||||
if (breakpoint && breakpoint !== 'desktop') {
|
||||
if (source.hasBreakpointParameter?.(name, breakpoint)) {
|
||||
return 'breakpoint';
|
||||
}
|
||||
}
|
||||
|
||||
return 'base';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update Property Panel UI
|
||||
|
||||
**File:** Update property row to show combo indicators
|
||||
|
||||
```tsx
|
||||
// In PropertyPanelRow or equivalent
|
||||
|
||||
export function PropertyPanelRow({
|
||||
label,
|
||||
children,
|
||||
isBreakpointAware,
|
||||
allowsVisualStates,
|
||||
valueSource, // 'combo' | 'state' | 'breakpoint' | 'base'
|
||||
currentState,
|
||||
currentBreakpoint,
|
||||
onReset
|
||||
}: PropertyPanelRowProps) {
|
||||
|
||||
function getIndicator() {
|
||||
switch (valueSource) {
|
||||
case 'combo':
|
||||
return (
|
||||
<Tooltip content={`Set for ${currentState} on ${currentBreakpoint}`}>
|
||||
<span className={css.ComboIndicator}>
|
||||
● {currentState} + {currentBreakpoint}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'state':
|
||||
return (
|
||||
<Tooltip content={`Set for ${currentState} state`}>
|
||||
<span className={css.StateIndicator}>● {currentState}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'breakpoint':
|
||||
return (
|
||||
<Tooltip content={`Set for ${currentBreakpoint}`}>
|
||||
<span className={css.BreakpointIndicator}>● {currentBreakpoint}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'base':
|
||||
default:
|
||||
if (currentState !== 'neutral' || currentBreakpoint !== 'desktop') {
|
||||
return <span className={css.Inherited}>(inherited)</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<label className={css.Label}>{label}</label>
|
||||
<div className={css.InputContainer}>
|
||||
{children}
|
||||
{getIndicator()}
|
||||
{valueSource !== 'base' && onReset && (
|
||||
<button className={css.ResetButton} onClick={onReset}>
|
||||
<Icon icon={IconName.Undo} size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Runtime Resolution
|
||||
|
||||
**File:** `packages/noodl-runtime/src/nodes/nodebase.js`
|
||||
|
||||
```javascript
|
||||
{
|
||||
getResolvedParameterValue(name) {
|
||||
const port = this.getPort ? this.getPort(name, 'input') : null;
|
||||
const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
|
||||
const currentState = this._internal?.currentVisualState || 'default';
|
||||
const defaultBreakpoint = breakpointManager.settings?.defaultBreakpoint || 'desktop';
|
||||
|
||||
// 1. Check combo value (state + breakpoint)
|
||||
if (port?.allowVisualStates && port?.allowBreakpoints) {
|
||||
if (currentState !== 'default' && currentBreakpoint !== defaultBreakpoint) {
|
||||
const comboKey = `${currentState}:${currentBreakpoint}`;
|
||||
const comboValue = this._model.stateBreakpointParameters?.[comboKey]?.[name];
|
||||
if (comboValue !== undefined) return comboValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check state-specific value
|
||||
if (port?.allowVisualStates && currentState !== 'default') {
|
||||
const stateValue = this._model.stateParameters?.[currentState]?.[name];
|
||||
if (stateValue !== undefined) return stateValue;
|
||||
}
|
||||
|
||||
// 3. Check breakpoint-specific value (with cascade)
|
||||
if (port?.allowBreakpoints && currentBreakpoint !== defaultBreakpoint) {
|
||||
const breakpointValue = this.getBreakpointValueWithCascade(name, currentBreakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
}
|
||||
|
||||
// 4. Base parameters
|
||||
return this.getParameterValue(name);
|
||||
},
|
||||
|
||||
getBreakpointValueWithCascade(name, breakpoint) {
|
||||
// Check current breakpoint
|
||||
if (this._model.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[breakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade
|
||||
const inheritanceChain = breakpointManager.getInheritanceChain(breakpoint);
|
||||
for (const bp of inheritanceChain.reverse()) {
|
||||
if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Extend VariantModel (Optional)
|
||||
|
||||
If we want variants to also support combo values:
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts`
|
||||
|
||||
```typescript
|
||||
export class VariantModel extends Model {
|
||||
// ... existing properties
|
||||
|
||||
stateBreakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
// Add similar methods as NodeGraphNode:
|
||||
// - hasStateBreakpointParameter
|
||||
// - getStateBreakpointParameter
|
||||
// - setStateBreakpointParameter
|
||||
|
||||
// Update getParameter to include combo resolution
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
const state = args?.state;
|
||||
const breakpoint = args?.breakpoint;
|
||||
|
||||
// 1. Check combo
|
||||
if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') {
|
||||
const comboKey = `${state}:${breakpoint}`;
|
||||
if (this.stateBreakpointParameters?.[comboKey]?.[name] !== undefined) {
|
||||
return this.stateBreakpointParameters[comboKey][name];
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of resolution hierarchy
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update Serialization
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
// In toJSON()
|
||||
toJSON(): object {
|
||||
const json: any = {
|
||||
id: this.id,
|
||||
type: this.type.name,
|
||||
parameters: this.parameters,
|
||||
// ... other fields
|
||||
};
|
||||
|
||||
// Only include if not empty
|
||||
if (this.stateParameters && Object.keys(this.stateParameters).length > 0) {
|
||||
json.stateParameters = this.stateParameters;
|
||||
}
|
||||
|
||||
if (this.breakpointParameters && Object.keys(this.breakpointParameters).length > 0) {
|
||||
json.breakpointParameters = this.breakpointParameters;
|
||||
}
|
||||
|
||||
if (this.stateBreakpointParameters && Object.keys(this.stateBreakpointParameters).length > 0) {
|
||||
json.stateBreakpointParameters = this.stateBreakpointParameters;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
// In fromJSON / constructor
|
||||
static fromJSON(json) {
|
||||
return new NodeGraphNode({
|
||||
...json,
|
||||
stateBreakpointParameters: json.stateBreakpointParameters || {}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add stateBreakpointParameters field and methods |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle combo context in get/setParameter |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass combo info to property rows |
|
||||
| `packages/noodl-runtime/src/nodes/nodebase.js` | Add combo resolution to getResolvedParameterValue |
|
||||
| `packages/noodl-runtime/src/models/nodemodel.js` | Add stateBreakpointParameters storage |
|
||||
|
||||
## Files to Create
|
||||
|
||||
None - this phase extends existing files.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Can set combo value (e.g., hover + tablet)
|
||||
- [ ] Combo value takes priority over individual state/breakpoint values
|
||||
- [ ] When state OR breakpoint changes, combo value is no longer used (falls through to next priority)
|
||||
- [ ] Combo values are saved to project JSON
|
||||
- [ ] Combo values are loaded from project JSON
|
||||
- [ ] UI shows correct indicator for combo values
|
||||
- [ ] Reset button clears combo value correctly
|
||||
- [ ] Runtime applies combo values correctly when both conditions match
|
||||
- [ ] Undo/redo works for combo value changes
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Can define "hover on tablet" as distinct from "hover" and "tablet"
|
||||
2. ✅ Clear UI indication of what level value is set at
|
||||
3. ✅ Values fall through correctly when combo doesn't match
|
||||
4. ✅ Runtime correctly identifies when combo conditions are met
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Complexity**: This adds significant complexity. Consider if this is really needed, or if the simpler "state values apply across all breakpoints" is sufficient.
|
||||
|
||||
2. **UI Clarity**: The UI needs to clearly communicate which level a value is set at. Consider using different colors:
|
||||
- Purple dot: combo value (state + breakpoint)
|
||||
- Blue dot: state value only
|
||||
- Green dot: breakpoint value only
|
||||
- Gray/no dot: base value
|
||||
|
||||
3. **Property Support**: Only properties that have BOTH `allowVisualStates: true` AND `allowBreakpoints: true` can have combo values. In practice, this might be a small subset (mostly spacing properties for interactive elements).
|
||||
|
||||
4. **Variant Complexity**: If variants also support combos, the full hierarchy becomes:
|
||||
- Instance combo → Instance state → Instance breakpoint → Instance base
|
||||
- → Variant combo → Variant state → Variant breakpoint → Variant base
|
||||
- → Type default
|
||||
|
||||
This is 9 levels! Consider if variant combo support is worth it.
|
||||
|
||||
5. **Performance**: With 4 breakpoints × 4 states × N properties, the parameter space grows quickly. Make sure resolution is efficient.
|
||||
|
||||
## Alternative: Simpler Approach
|
||||
|
||||
If combo complexity is too high, consider this simpler alternative:
|
||||
|
||||
**States inherit from breakpoint, not base:**
|
||||
|
||||
```
|
||||
Current: state value = same across all breakpoints
|
||||
Alternative: state value = applied ON TOP OF current breakpoint value
|
||||
```
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
// Base: paddingLeft = 24px
|
||||
// Tablet: paddingLeft = 16px
|
||||
// Hover state: paddingLeft = +4px (relative)
|
||||
|
||||
// Result:
|
||||
// Desktop hover = 24 + 4 = 28px
|
||||
// Tablet hover = 16 + 4 = 20px
|
||||
```
|
||||
|
||||
This avoids needing explicit combo values but requires supporting relative/delta values for states, which has its own complexity.
|
||||
@@ -0,0 +1,489 @@
|
||||
# TASK: Video Player Node
|
||||
|
||||
**Task ID:** NODES-001
|
||||
**Priority:** Medium-High
|
||||
**Estimated Effort:** 16-24 hours
|
||||
**Prerequisites:** React 18.3+ runtime (completed)
|
||||
**Status:** Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Create a comprehensive Video Player node that handles video playback from URLs or blobs with rich inputs and outputs for complete video management. This addresses a gap in Noodl's visual node offerings - currently users must resort to Function nodes for anything beyond basic video display.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- **Table stakes feature** - Users expect video playback in any modern low-code tool
|
||||
- **App builder unlock** - Enables video-centric apps (portfolios, e-learning, social, editors)
|
||||
- **Blob support differentiator** - Play local files without server upload (rare in competitors)
|
||||
- **Community requested** - Direct request from OpenNoodl community
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Video plays from URL (mp4, webm)
|
||||
- [ ] Video plays from blob/File object (from File Picker node)
|
||||
- [ ] All playback controls work via signal inputs
|
||||
- [ ] Time tracking outputs update in real-time
|
||||
- [ ] Events fire correctly for all lifecycle moments
|
||||
- [ ] Fullscreen and Picture-in-Picture work cross-browser
|
||||
- [ ] Frame capture produces valid base64 image
|
||||
- [ ] Captions/subtitles display from VTT file
|
||||
- [ ] Works in both editor preview and deployed apps
|
||||
- [ ] Performance: time updates don't cause UI jank
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Node Registration
|
||||
|
||||
```
|
||||
Location: packages/noodl-viewer-react/src/nodes/visual/videoplayer.js (new file)
|
||||
Type: Visual/Frontend node using createNodeFromReactComponent
|
||||
Category: "Visual" or "UI Elements" > "Media"
|
||||
Name: net.noodl.visual.videoplayer
|
||||
Display Name: Video Player
|
||||
```
|
||||
|
||||
### Core Implementation Pattern
|
||||
|
||||
```javascript
|
||||
import { createNodeFromReactComponent } from '@noodl/react-component-node';
|
||||
|
||||
const VideoPlayer = createNodeFromReactComponent({
|
||||
name: 'net.noodl.visual.videoplayer',
|
||||
displayName: 'Video Player',
|
||||
category: 'Visual',
|
||||
docs: 'https://docs.noodl.net/nodes/visual/video-player',
|
||||
|
||||
// Standard visual node frame options
|
||||
frame: {
|
||||
dimensions: true,
|
||||
position: true,
|
||||
margins: true,
|
||||
align: true
|
||||
},
|
||||
|
||||
allowChildren: false,
|
||||
|
||||
getReactComponent() {
|
||||
return VideoPlayerComponent; // Defined below
|
||||
},
|
||||
|
||||
// ... inputs/outputs defined below
|
||||
});
|
||||
```
|
||||
|
||||
### React Component Structure
|
||||
|
||||
```javascript
|
||||
function VideoPlayerComponent(props) {
|
||||
const videoRef = useRef(null);
|
||||
const [state, setState] = useState({
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
isEnded: false,
|
||||
isBuffering: false,
|
||||
isSeeking: false,
|
||||
isFullscreen: false,
|
||||
isPiP: false,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
bufferedPercent: 0,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0
|
||||
});
|
||||
|
||||
// Use deferred value for time to prevent jank
|
||||
const deferredTime = useDeferredValue(state.currentTime);
|
||||
|
||||
// ... event handlers, effects, signal handlers
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={props.style}
|
||||
src={props.url || undefined}
|
||||
poster={props.posterImage}
|
||||
controls={props.controlsVisible}
|
||||
loop={props.loop}
|
||||
muted={props.muted}
|
||||
autoPlay={props.autoplay}
|
||||
playsInline={props.playsInline}
|
||||
preload={props.preload}
|
||||
crossOrigin={props.crossOrigin}
|
||||
// ... all event handlers
|
||||
>
|
||||
{props.captionsUrl && (
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={props.captionsUrl}
|
||||
srcLang={props.captionsLanguage || 'en'}
|
||||
default={props.captionsEnabled}
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input/Output Specification
|
||||
|
||||
### Inputs - Source
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| URL | string | - | Video URL (mp4, webm, ogg, hls) |
|
||||
| Blob | any | - | File/Blob object from File Picker |
|
||||
| Poster Image | string | - | Thumbnail URL shown before play |
|
||||
| Source Type | enum | auto | auto/mp4/webm/ogg/hls |
|
||||
|
||||
### Inputs - Playback Control (Signals)
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Play | signal | Start playback |
|
||||
| Pause | signal | Pause playback |
|
||||
| Toggle Play/Pause | signal | Toggle current state |
|
||||
| Stop | signal | Pause and seek to 0 |
|
||||
| Seek To | signal | Seek to "Seek Time" value |
|
||||
| Skip Forward | signal | Skip forward by "Skip Amount" |
|
||||
| Skip Backward | signal | Skip backward by "Skip Amount" |
|
||||
|
||||
### Inputs - Playback Settings
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| Seek Time | number | 0 | Target time for Seek To (seconds) |
|
||||
| Skip Amount | number | 10 | Seconds to skip forward/backward |
|
||||
| Volume | number | 1 | Volume level 0-1 |
|
||||
| Muted | boolean | false | Mute audio |
|
||||
| Playback Rate | number | 1 | Speed: 0.25-4 |
|
||||
| Loop | boolean | false | Loop playback |
|
||||
| Autoplay | boolean | false | Auto-start on load |
|
||||
| Preload | enum | auto | none/metadata/auto |
|
||||
| Controls Visible | boolean | true | Show native controls |
|
||||
|
||||
### Inputs - Advanced
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| Start Time | number | 0 | Auto-seek on load |
|
||||
| End Time | number | - | Auto-pause/loop point |
|
||||
| Plays Inline | boolean | true | iOS inline playback |
|
||||
| Cross Origin | enum | anonymous | anonymous/use-credentials |
|
||||
| PiP Enabled | boolean | true | Allow Picture-in-Picture |
|
||||
|
||||
### Inputs - Captions
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| Captions URL | string | - | VTT subtitle file URL |
|
||||
| Captions Enabled | boolean | false | Show captions |
|
||||
| Captions Language | string | en | Language code |
|
||||
|
||||
### Inputs - Actions (Signals)
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Enter Fullscreen | signal | Request fullscreen mode |
|
||||
| Exit Fullscreen | signal | Exit fullscreen mode |
|
||||
| Toggle Fullscreen | signal | Toggle fullscreen state |
|
||||
| Enter PiP | signal | Enter Picture-in-Picture |
|
||||
| Exit PiP | signal | Exit Picture-in-Picture |
|
||||
| Capture Frame | signal | Capture current frame to output |
|
||||
| Reload | signal | Reload video source |
|
||||
|
||||
### Outputs - State
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Is Playing | boolean | Currently playing |
|
||||
| Is Paused | boolean | Currently paused |
|
||||
| Is Ended | boolean | Playback ended |
|
||||
| Is Buffering | boolean | Waiting for data |
|
||||
| Is Seeking | boolean | Currently seeking |
|
||||
| Is Fullscreen | boolean | In fullscreen mode |
|
||||
| Is Picture-in-Picture | boolean | In PiP mode |
|
||||
| Has Error | boolean | Error occurred |
|
||||
| Error Message | string | Error description |
|
||||
|
||||
### Outputs - Time
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Current Time | number | Current position (seconds) |
|
||||
| Duration | number | Total duration (seconds) |
|
||||
| Progress | number | Position 0-1 |
|
||||
| Remaining Time | number | Time remaining (seconds) |
|
||||
| Formatted Current | string | "1:23" or "1:23:45" |
|
||||
| Formatted Duration | string | Total as formatted string |
|
||||
| Formatted Remaining | string | Remaining as formatted string |
|
||||
|
||||
### Outputs - Media Info
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Video Width | number | Native video width |
|
||||
| Video Height | number | Native video height |
|
||||
| Aspect Ratio | number | Width/height ratio |
|
||||
| Buffered Percent | number | Download progress 0-1 |
|
||||
| Ready State | number | HTML5 readyState 0-4 |
|
||||
|
||||
### Outputs - Events (Signals)
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Loaded Metadata | signal | Duration/dimensions available |
|
||||
| Can Play | signal | Ready to start playback |
|
||||
| Can Play Through | signal | Can play to end without buffering |
|
||||
| Play Started | signal | Playback started |
|
||||
| Paused | signal | Playback paused |
|
||||
| Ended | signal | Playback ended |
|
||||
| Seeking | signal | Seek operation started |
|
||||
| Seeked | signal | Seek operation completed |
|
||||
| Time Updated | signal | Time changed (frequent) |
|
||||
| Volume Changed | signal | Volume or mute changed |
|
||||
| Rate Changed | signal | Playback rate changed |
|
||||
| Entered Fullscreen | signal | Entered fullscreen |
|
||||
| Exited Fullscreen | signal | Exited fullscreen |
|
||||
| Entered PiP | signal | Entered Picture-in-Picture |
|
||||
| Exited PiP | signal | Exited Picture-in-Picture |
|
||||
| Error Occurred | signal | Error happened |
|
||||
| Buffering Started | signal | Started buffering |
|
||||
| Buffering Ended | signal | Finished buffering |
|
||||
|
||||
### Outputs - Special
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Captured Frame | string | Base64 data URL of captured frame |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Playback (4-6 hours)
|
||||
- [ ] Create node file structure
|
||||
- [ ] Basic video element with URL support
|
||||
- [ ] Play/Pause/Stop signal inputs
|
||||
- [ ] Basic state outputs (isPlaying, isPaused, etc.)
|
||||
- [ ] Time outputs (currentTime, duration, progress)
|
||||
- [ ] Register node in node library
|
||||
|
||||
### Phase 2: Extended Controls (4-6 hours)
|
||||
- [ ] Seek functionality (seekTo, skipForward, skipBackward)
|
||||
- [ ] Volume and mute controls
|
||||
- [ ] Playback rate control
|
||||
- [ ] Loop and autoplay
|
||||
- [ ] All time-related event signals
|
||||
- [ ] Formatted time outputs
|
||||
|
||||
### Phase 3: Advanced Features (4-6 hours)
|
||||
- [ ] Blob/File support (from File Picker)
|
||||
- [ ] Fullscreen API integration
|
||||
- [ ] Picture-in-Picture API integration
|
||||
- [ ] Frame capture functionality
|
||||
- [ ] Start/End time range support
|
||||
- [ ] Buffering state and events
|
||||
|
||||
### Phase 4: Polish & Testing (4-6 hours)
|
||||
- [ ] Captions/subtitles support
|
||||
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
|
||||
- [ ] Mobile testing (iOS Safari, Android Chrome)
|
||||
- [ ] Performance optimization (useDeferredValue for time)
|
||||
- [ ] Error handling and edge cases
|
||||
- [ ] Documentation
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
### New Files
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/visual/videoplayer.js # Main node
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/index.js # Register node
|
||||
packages/noodl-runtime/src/nodelibraryexport.js # Add to UI Elements category
|
||||
```
|
||||
|
||||
### Reference Files (existing patterns)
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/visual/image.js # Similar visual node
|
||||
packages/noodl-viewer-react/src/nodes/visual/video.js # Existing basic video (if exists)
|
||||
packages/noodl-viewer-react/src/nodes/controls/button.js # Signal input patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
**Basic Playback**
|
||||
- [ ] MP4 URL loads and plays
|
||||
- [ ] WebM URL loads and plays
|
||||
- [ ] Poster image shows before play
|
||||
- [ ] Native controls appear when enabled
|
||||
- [ ] Native controls hidden when disabled
|
||||
|
||||
**Signal Controls**
|
||||
- [ ] Play signal starts playback
|
||||
- [ ] Pause signal pauses playback
|
||||
- [ ] Toggle Play/Pause works correctly
|
||||
- [ ] Stop pauses and seeks to 0
|
||||
- [ ] Seek To jumps to correct time
|
||||
- [ ] Skip Forward/Backward work with Skip Amount
|
||||
|
||||
**State Outputs**
|
||||
- [ ] Is Playing true when playing, false otherwise
|
||||
- [ ] Is Paused true when paused
|
||||
- [ ] Is Ended true when video ends
|
||||
- [ ] Is Buffering true during buffering
|
||||
- [ ] Current Time updates during playback
|
||||
- [ ] Duration correct after load
|
||||
- [ ] Progress 0-1 range correct
|
||||
|
||||
**Events**
|
||||
- [ ] Loaded Metadata fires when ready
|
||||
- [ ] Play Started fires on play
|
||||
- [ ] Paused fires on pause
|
||||
- [ ] Ended fires when complete
|
||||
- [ ] Time Updated fires during playback
|
||||
|
||||
**Advanced Features**
|
||||
- [ ] Blob from File Picker plays correctly
|
||||
- [ ] Fullscreen enter/exit works
|
||||
- [ ] PiP enter/exit works (where supported)
|
||||
- [ ] Frame Capture produces valid image
|
||||
- [ ] Captions display from VTT file
|
||||
- [ ] Start Time auto-seeks on load
|
||||
- [ ] End Time auto-pauses/loops
|
||||
|
||||
**Cross-Browser**
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Edge (latest)
|
||||
- [ ] iOS Safari
|
||||
- [ ] Android Chrome
|
||||
|
||||
**Edge Cases**
|
||||
- [ ] Invalid URL shows error state
|
||||
- [ ] Network error during playback
|
||||
- [ ] Rapid play/pause doesn't break
|
||||
- [ ] Seeking while buffering
|
||||
- [ ] Source change during playback
|
||||
- [ ] Multiple Video Player nodes on same page
|
||||
|
||||
---
|
||||
|
||||
## Code Examples for Users
|
||||
|
||||
### Basic Video Playback
|
||||
```
|
||||
[Video URL] → [Video Player]
|
||||
↓
|
||||
[Is Playing] → [If node for UI state]
|
||||
```
|
||||
|
||||
### Custom Controls
|
||||
```
|
||||
[Button "Play"] → Play signal → [Video Player]
|
||||
[Button "Pause"] → Pause signal ↗
|
||||
[Slider] → Seek Time + Seek To signal ↗
|
||||
↓
|
||||
[Current Time] → [Text display]
|
||||
[Duration] → [Text display]
|
||||
```
|
||||
|
||||
### Video Upload Preview
|
||||
```
|
||||
[File Picker] → Blob → [Video Player]
|
||||
↓
|
||||
[Capture Frame] → [Image node for thumbnail]
|
||||
```
|
||||
|
||||
### E-Learning Progress Tracking
|
||||
```
|
||||
[Video Player]
|
||||
↓
|
||||
[Progress] → [Progress Bar]
|
||||
[Ended] → [Mark Lesson Complete logic]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Time Update Throttling**: The `timeupdate` event fires frequently (4-66Hz). Use `useDeferredValue` to prevent connected nodes from causing frame drops.
|
||||
|
||||
2. **Blob Memory**: When using blob sources, ensure proper cleanup on source change to prevent memory leaks.
|
||||
|
||||
3. **Frame Capture**: Canvas operations are synchronous. For large videos, this may cause brief UI freeze. Document this limitation.
|
||||
|
||||
4. **Multiple Instances**: Test with 3-5 Video Player nodes on same page to ensure no conflicts.
|
||||
|
||||
---
|
||||
|
||||
## React 19 Benefits
|
||||
|
||||
While this node works on React 18.3, React 19 offers:
|
||||
|
||||
1. **`ref` as prop** - Cleaner implementation without `forwardRef` wrapper
|
||||
2. **`useDeferredValue` improvements** - Better time update performance
|
||||
3. **`useTransition` for seeking** - Non-blocking seek operations
|
||||
|
||||
```javascript
|
||||
// React 19 pattern for smooth seeking
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSeek(time) {
|
||||
startTransition(() => {
|
||||
videoRef.current.currentTime = time;
|
||||
});
|
||||
}
|
||||
// isPending can drive "Is Seeking" output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
After implementation, create:
|
||||
- [ ] Node reference page for docs site
|
||||
- [ ] Example project: "Video Gallery"
|
||||
- [ ] Example project: "Custom Video Controls"
|
||||
- [ ] Migration guide from Function-based video handling
|
||||
|
||||
---
|
||||
|
||||
## Notes & Gotchas
|
||||
|
||||
1. **iOS Autoplay**: iOS requires `playsInline` and `muted` for autoplay to work
|
||||
2. **CORS**: External videos may need proper CORS headers for frame capture
|
||||
3. **HLS/DASH**: May require additional libraries (hls.js, dash.js) - consider Phase 2 enhancement
|
||||
4. **Safari PiP**: Has different API than Chrome/Firefox
|
||||
5. **Fullscreen**: Different browsers have different fullscreen APIs - use unified helper
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- HLS/DASH streaming support via hls.js
|
||||
- Video filters/effects
|
||||
- Multiple audio tracks
|
||||
- Chapter markers
|
||||
- Thumbnail preview on seek (sprite sheet)
|
||||
- Analytics integration
|
||||
- DRM support
|
||||
@@ -55,6 +55,7 @@
|
||||
background-color: var(--background);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
pointer-events: none; // Allow clicks to pass through to content
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ export type DialogLayerOptions = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type ShowDialogOptions = DialogLayerOptions & {
|
||||
/** Called when the dialog is closed */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerModelEvents> {
|
||||
public static instance = new DialogLayerModel();
|
||||
|
||||
@@ -84,4 +89,40 @@ export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerMo
|
||||
};
|
||||
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a custom dialog component.
|
||||
* Returns a close function that can be called to programmatically close the dialog.
|
||||
*
|
||||
* @param render - Function that receives a close callback and returns JSX
|
||||
* @param options - Dialog options including optional id
|
||||
* @returns A function to close the dialog
|
||||
*/
|
||||
public showDialog(
|
||||
render: (close: () => void) => JSX.Element,
|
||||
options: ShowDialogOptions = {}
|
||||
): () => void {
|
||||
const id = options.id ?? guid();
|
||||
const { onClose } = options;
|
||||
|
||||
const close = () => {
|
||||
this.closeById(id);
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
// Remove existing dialog with same id if present
|
||||
if (this._dialogs[id]) {
|
||||
this._order = this._order.filter((x) => x !== id);
|
||||
delete this._dialogs[id];
|
||||
}
|
||||
|
||||
this._order.push(id);
|
||||
this._dialogs[id] = {
|
||||
id,
|
||||
slot: () => render(close)
|
||||
};
|
||||
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
|
||||
|
||||
return close;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import { filesystem } from '@noodl/platform';
|
||||
|
||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
|
||||
import {
|
||||
@@ -409,27 +411,72 @@ export class MigrationSessionManager extends EventDispatcher {
|
||||
}
|
||||
|
||||
private async executeCopyPhase(): Promise<void> {
|
||||
if (!this.session) return;
|
||||
|
||||
const sourcePath = this.session.source.path;
|
||||
const targetPath = this.session.target.path;
|
||||
|
||||
this.updateProgress({ phase: 'copying', current: 0 });
|
||||
this.addLogEntry({
|
||||
level: 'info',
|
||||
message: 'Creating project copy...'
|
||||
message: `Copying project from ${sourcePath} to ${targetPath}...`
|
||||
});
|
||||
|
||||
// TODO: Implement actual file copying using filesystem
|
||||
// For now, this is a placeholder
|
||||
|
||||
await this.simulateDelay(500);
|
||||
|
||||
if (this.session) {
|
||||
this.session.target.copied = true;
|
||||
try {
|
||||
// Check if target already exists
|
||||
const targetExists = await filesystem.exists(targetPath);
|
||||
if (targetExists) {
|
||||
throw new Error(`Target directory already exists: ${targetPath}`);
|
||||
}
|
||||
|
||||
// Create target directory
|
||||
await filesystem.makeDirectory(targetPath);
|
||||
|
||||
// Copy all files recursively
|
||||
await this.copyDirectoryRecursive(sourcePath, targetPath);
|
||||
|
||||
this.session.target.copied = true;
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
message: 'Project copied successfully'
|
||||
});
|
||||
|
||||
this.updateProgress({ current: 1 });
|
||||
} catch (error) {
|
||||
this.addLogEntry({
|
||||
level: 'error',
|
||||
message: `Failed to copy project: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copies a directory and its contents
|
||||
*/
|
||||
private async copyDirectoryRecursive(sourcePath: string, targetPath: string): Promise<void> {
|
||||
const entries = await filesystem.listDirectory(sourcePath);
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourceItemPath = entry.fullPath;
|
||||
const targetItemPath = `${targetPath}/${entry.name}`;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
// Skip node_modules and .git folders
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create directory and recurse
|
||||
await filesystem.makeDirectory(targetItemPath);
|
||||
await this.copyDirectoryRecursive(sourceItemPath, targetItemPath);
|
||||
} else {
|
||||
// Copy file
|
||||
const content = await filesystem.readFile(sourceItemPath);
|
||||
await filesystem.writeFile(targetItemPath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAutomaticPhase(): Promise<void> {
|
||||
@@ -493,14 +540,47 @@ export class MigrationSessionManager extends EventDispatcher {
|
||||
}
|
||||
|
||||
private async executeFinalizePhase(): Promise<void> {
|
||||
if (!this.session) return;
|
||||
|
||||
this.updateProgress({ phase: 'finalizing' });
|
||||
this.addLogEntry({
|
||||
level: 'info',
|
||||
message: 'Finalizing migration...'
|
||||
});
|
||||
|
||||
// TODO: Update project.json with migration metadata
|
||||
await this.simulateDelay(200);
|
||||
try {
|
||||
// Update project.json with migration metadata
|
||||
const targetProjectJsonPath = `${this.session.target.path}/project.json`;
|
||||
|
||||
// Read existing project.json
|
||||
const projectJson = await filesystem.readJson(targetProjectJsonPath) as Record<string, unknown>;
|
||||
|
||||
// Add React 19 markers
|
||||
projectJson.runtimeVersion = 'react19';
|
||||
projectJson.migratedFrom = {
|
||||
version: 'react17',
|
||||
date: new Date().toISOString(),
|
||||
originalPath: this.session.source.path,
|
||||
aiAssisted: this.session.ai?.enabled ?? false
|
||||
};
|
||||
|
||||
// Write updated project.json back
|
||||
await filesystem.writeFile(
|
||||
targetProjectJsonPath,
|
||||
JSON.stringify(projectJson, null, 2)
|
||||
);
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
message: 'Project marked as React 19'
|
||||
});
|
||||
} catch (error) {
|
||||
this.addLogEntry({
|
||||
level: 'warning',
|
||||
message: `Could not update project.json metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
});
|
||||
// Don't throw - this is not a critical failure
|
||||
}
|
||||
|
||||
this.addLogEntry({
|
||||
level: 'success',
|
||||
|
||||
@@ -315,12 +315,14 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Default: Unknown - could be either version
|
||||
// Default: Assume React 17 for older projects without explicit markers
|
||||
// Any project without runtimeVersion, migratedFrom, or a recent editorVersion
|
||||
// is most likely a legacy project from before OpenNoodl
|
||||
// ==========================================================================
|
||||
return {
|
||||
version: 'unknown',
|
||||
version: 'react17',
|
||||
confidence: 'low',
|
||||
indicators: ['No version indicators found - manual verification recommended']
|
||||
indicators: ['No React 19 markers found - assuming legacy React 17 project']
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -950,3 +950,128 @@
|
||||
overflow: hidden overlay;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Legacy Project Styles (React 17 Migration)
|
||||
------------------------------------------------------------------- */
|
||||
|
||||
/* Legacy project card modifier */
|
||||
.projects-item--legacy {
|
||||
border: 2px solid #d49517;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projects-item--legacy:hover {
|
||||
border-color: #fdb314;
|
||||
}
|
||||
|
||||
/* Legacy badge in top-right corner */
|
||||
.projects-item-legacy-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: rgba(212, 149, 23, 0.9);
|
||||
color: #000;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.projects-item-legacy-badge svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hidden class for conditional display */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Legacy project hover actions overlay */
|
||||
.projects-item-legacy-actions {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background: rgba(19, 19, 19, 0.95);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projects-item--legacy:hover .projects-item-legacy-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Migrate Project button */
|
||||
.projects-item-migrate-btn {
|
||||
background-color: #d49517;
|
||||
border: none;
|
||||
color: #000;
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.projects-item-migrate-btn:hover {
|
||||
background-color: #fdb314;
|
||||
}
|
||||
|
||||
/* Open Read-Only button */
|
||||
.projects-item-readonly-btn {
|
||||
background-color: transparent;
|
||||
border: 1px solid #666;
|
||||
color: #aaa;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.projects-item-readonly-btn:hover {
|
||||
background-color: #333;
|
||||
border-color: #888;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Runtime detection pending indicator */
|
||||
.projects-item-detecting {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.projects-item-detecting::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #666;
|
||||
border-top-color: #d49517;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="projects-main">
|
||||
<!-- project item template -->
|
||||
<div data-template="projects-item" class="projects-item projects-item-plate" data-click="onProjectItemClicked">
|
||||
<div data-template="projects-item" class="projects-item projects-item-plate" data-class="isLegacy:projects-item--legacy" data-click="onProjectItemClicked">
|
||||
|
||||
<div class="projects-item-thumb" style="position:absolute; left:0px; top:0px; width:100%; bottom:70px;">
|
||||
<div class="projects-item-cloud-download" style="width:100%; height:100%;">
|
||||
@@ -10,6 +10,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy project badge -->
|
||||
<div class="projects-item-legacy-badge" data-class="!isLegacy:hidden">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
|
||||
</svg>
|
||||
<span>Legacy</span>
|
||||
</div>
|
||||
|
||||
<div class="projects-item-label" style="position:absolute; bottom:36px; left:10px; right:10px;"
|
||||
data-click="onRenameProjectClicked">
|
||||
<span data-text="label" data-test="project-card-label"></span>
|
||||
@@ -28,6 +36,16 @@
|
||||
<img class="projects-remove-icon" src="../assets/images/sharp-clear-24px.svg">
|
||||
</div>
|
||||
|
||||
<!-- Legacy project hover actions -->
|
||||
<div class="projects-item-legacy-actions" data-class="!isLegacy:hidden">
|
||||
<button class="projects-item-migrate-btn" data-click="onMigrateProjectClicked">
|
||||
Migrate Project
|
||||
</button>
|
||||
<button class="projects-item-readonly-btn" data-click="onOpenReadOnlyClicked">
|
||||
Open Read-Only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- tutorial item template, guides etc (not lessons) -->
|
||||
|
||||
@@ -13,6 +13,8 @@ import { templateRegistry } from '@noodl-utils/forge';
|
||||
|
||||
import Model from '../../../shared/model';
|
||||
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
||||
import { RuntimeVersionInfo } from '../models/migration/types';
|
||||
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
||||
import FileSystem from './filesystem';
|
||||
import { tracker } from './tracker';
|
||||
import { guid } from './utils';
|
||||
@@ -25,6 +27,14 @@ export interface ProjectItem {
|
||||
thumbURI: string;
|
||||
retainedProjectDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended project item with runtime version info (not persisted)
|
||||
*/
|
||||
export interface ProjectItemWithRuntime extends ProjectItem {
|
||||
runtimeInfo?: RuntimeVersionInfo;
|
||||
runtimeDetectionPending?: boolean;
|
||||
}
|
||||
export class LocalProjectsModel extends Model {
|
||||
public static instance = new LocalProjectsModel();
|
||||
|
||||
@@ -34,6 +44,17 @@ export class LocalProjectsModel extends Model {
|
||||
name: 'recently_opened_project'
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache for runtime version info - keyed by project directory path
|
||||
* Not persisted, re-detected on each app session
|
||||
*/
|
||||
private runtimeInfoCache: Map<string, RuntimeVersionInfo> = new Map();
|
||||
|
||||
/**
|
||||
* Set of project directories currently being detected
|
||||
*/
|
||||
private detectingProjects: Set<string> = new Set();
|
||||
|
||||
async fetch() {
|
||||
// Fetch projects from local storage and verify project folders
|
||||
const folders = (this.recentProjectsStore.get('recentProjects') || []) as ProjectItem[];
|
||||
@@ -299,4 +320,128 @@ export class LocalProjectsModel extends Model {
|
||||
|
||||
setRequestGitAccount(func);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Runtime Version Detection Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get cached runtime info for a project, or null if not yet detected
|
||||
* @param projectPath - The project directory path
|
||||
*/
|
||||
getRuntimeInfo(projectPath: string): RuntimeVersionInfo | null {
|
||||
return this.runtimeInfoCache.get(projectPath) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime detection is currently in progress for a project
|
||||
* @param projectPath - The project directory path
|
||||
*/
|
||||
isDetectingRuntime(projectPath: string): boolean {
|
||||
return this.detectingProjects.has(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get projects with their runtime info (extended interface)
|
||||
* Returns projects enriched with cached runtime detection status
|
||||
*/
|
||||
getProjectsWithRuntime(): ProjectItemWithRuntime[] {
|
||||
return this.projectEntries.map((project) => ({
|
||||
...project,
|
||||
runtimeInfo: this.getRuntimeInfo(project.retainedProjectDirectory),
|
||||
runtimeDetectionPending: this.isDetectingRuntime(project.retainedProjectDirectory)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect runtime version for a single project.
|
||||
* Results are cached and listeners are notified.
|
||||
* @param projectPath - Path to the project directory
|
||||
* @returns The detected runtime version info
|
||||
*/
|
||||
async detectProjectRuntime(projectPath: string): Promise<RuntimeVersionInfo> {
|
||||
// Return cached result if available
|
||||
const cached = this.runtimeInfoCache.get(projectPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Skip if already detecting
|
||||
if (this.detectingProjects.has(projectPath)) {
|
||||
// Wait for existing detection to complete by polling
|
||||
return new Promise((resolve) => {
|
||||
const checkCached = () => {
|
||||
const result = this.runtimeInfoCache.get(projectPath);
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else if (this.detectingProjects.has(projectPath)) {
|
||||
setTimeout(checkCached, 100);
|
||||
} else {
|
||||
// Detection finished but no result - return unknown
|
||||
resolve({ version: 'unknown', confidence: 'low', indicators: ['Detection failed'] });
|
||||
}
|
||||
};
|
||||
checkCached();
|
||||
});
|
||||
}
|
||||
|
||||
// Mark as detecting
|
||||
this.detectingProjects.add(projectPath);
|
||||
this.notifyListeners('runtimeDetectionStarted', projectPath);
|
||||
|
||||
try {
|
||||
const runtimeInfo = await detectRuntimeVersion(projectPath);
|
||||
this.runtimeInfoCache.set(projectPath, runtimeInfo);
|
||||
this.notifyListeners('runtimeDetectionComplete', projectPath, runtimeInfo);
|
||||
return runtimeInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to detect runtime for ${projectPath}:`, error);
|
||||
const fallback: RuntimeVersionInfo = {
|
||||
version: 'unknown',
|
||||
confidence: 'low',
|
||||
indicators: ['Detection error: ' + (error instanceof Error ? error.message : 'Unknown error')]
|
||||
};
|
||||
this.runtimeInfoCache.set(projectPath, fallback);
|
||||
this.notifyListeners('runtimeDetectionComplete', projectPath, fallback);
|
||||
return fallback;
|
||||
} finally {
|
||||
this.detectingProjects.delete(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect runtime version for all projects in the list (background)
|
||||
* Useful for pre-populating the cache when the projects view loads
|
||||
*/
|
||||
async detectAllProjectRuntimes(): Promise<void> {
|
||||
const projects = this.getProjects();
|
||||
|
||||
// Detect in parallel but don't wait for all to complete
|
||||
// Instead, trigger detection and let events update the UI
|
||||
for (const project of projects) {
|
||||
// Don't await - let them run in background
|
||||
this.detectProjectRuntime(project.retainedProjectDirectory).catch(() => {
|
||||
// Errors are handled in detectProjectRuntime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a project is a legacy project (React 17)
|
||||
* @param projectPath - Path to the project directory
|
||||
* @returns True if project is detected as React 17
|
||||
*/
|
||||
isLegacyProject(projectPath: string): boolean {
|
||||
const info = this.getRuntimeInfo(projectPath);
|
||||
return info?.version === 'react17';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear runtime cache for a specific project (e.g., after migration)
|
||||
* @param projectPath - Path to the project directory
|
||||
*/
|
||||
clearRuntimeCache(projectPath: string): void {
|
||||
this.runtimeInfoCache.delete(projectPath);
|
||||
this.notifyListeners('runtimeCacheCleared', projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* MigrationWizard Styles
|
||||
*
|
||||
* Main container for the migration wizard using CoreBaseDialog.
|
||||
*/
|
||||
|
||||
.WizardContainer {
|
||||
position: relative;
|
||||
width: 700px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.WizardHeader {
|
||||
padding: 24px 24px 16px;
|
||||
padding-right: 48px; // Space for close button
|
||||
}
|
||||
|
||||
.WizardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 24px 24px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.StepContainer {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* MigrationWizard
|
||||
*
|
||||
* Main container component for the React 19 migration wizard.
|
||||
* Manages step navigation and integrates with MigrationSessionManager.
|
||||
*
|
||||
* @module noodl-editor/views/migration
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
|
||||
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
import { MigrationSession, MigrationScan, MigrationResult } from '../../models/migration/types';
|
||||
import { migrationSessionManager, getStepLabel, getStepNumber, getTotalSteps } from '../../models/migration/MigrationSession';
|
||||
|
||||
import { WizardProgress } from './components/WizardProgress';
|
||||
import { ConfirmStep } from './steps/ConfirmStep';
|
||||
import { ScanningStep } from './steps/ScanningStep';
|
||||
import { ReportStep } from './steps/ReportStep';
|
||||
import { CompleteStep } from './steps/CompleteStep';
|
||||
import { FailedStep } from './steps/FailedStep';
|
||||
|
||||
import css from './MigrationWizard.module.scss';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface MigrationWizardProps {
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Name of the project */
|
||||
projectName: string;
|
||||
/** Called when migration completes successfully */
|
||||
onComplete: (targetPath: string) => void;
|
||||
/** Called when wizard is cancelled */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type WizardAction =
|
||||
| { type: 'SET_SESSION'; session: MigrationSession }
|
||||
| { type: 'SET_TARGET_PATH'; path: string }
|
||||
| { type: 'START_SCAN' }
|
||||
| { type: 'SCAN_COMPLETE'; scan: MigrationScan }
|
||||
| { type: 'ERROR'; error: Error }
|
||||
| { type: 'START_MIGRATE'; useAi: boolean }
|
||||
| { type: 'MIGRATION_PROGRESS'; progress: number; currentComponent?: string }
|
||||
| { type: 'COMPLETE'; result: MigrationResult }
|
||||
| { type: 'RETRY' };
|
||||
|
||||
interface WizardState {
|
||||
session: MigrationSession | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reducer
|
||||
// =============================================================================
|
||||
|
||||
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
||||
switch (action.type) {
|
||||
case 'SET_SESSION':
|
||||
return {
|
||||
...state,
|
||||
session: action.session
|
||||
};
|
||||
|
||||
case 'SET_TARGET_PATH':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
target: { ...state.session.target, path: action.path }
|
||||
}
|
||||
};
|
||||
|
||||
case 'START_SCAN':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: { ...state.session, step: 'scanning' },
|
||||
loading: true
|
||||
};
|
||||
|
||||
case 'SCAN_COMPLETE':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'report',
|
||||
scan: action.scan
|
||||
},
|
||||
loading: false
|
||||
};
|
||||
|
||||
case 'ERROR':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: { ...state.session, step: 'failed' },
|
||||
loading: false,
|
||||
error: action.error
|
||||
};
|
||||
|
||||
case 'START_MIGRATE':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'migrating',
|
||||
ai: action.useAi ? state.session.ai : undefined
|
||||
},
|
||||
loading: true
|
||||
};
|
||||
|
||||
case 'MIGRATION_PROGRESS':
|
||||
if (!state.session?.progress) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
progress: {
|
||||
...state.session.progress,
|
||||
current: action.progress,
|
||||
currentComponent: action.currentComponent
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
case 'COMPLETE':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'complete',
|
||||
result: action.result
|
||||
},
|
||||
loading: false
|
||||
};
|
||||
|
||||
case 'RETRY':
|
||||
if (!state.session) return state;
|
||||
return {
|
||||
...state,
|
||||
session: {
|
||||
...state.session,
|
||||
step: 'confirm',
|
||||
scan: undefined,
|
||||
progress: undefined,
|
||||
result: undefined
|
||||
},
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function MigrationWizard({
|
||||
sourcePath,
|
||||
projectName,
|
||||
onComplete,
|
||||
onCancel
|
||||
}: MigrationWizardProps) {
|
||||
// Initialize session on mount
|
||||
const [state, dispatch] = useReducer(wizardReducer, {
|
||||
session: null,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Create session on mount
|
||||
useEffect(() => {
|
||||
async function initSession() {
|
||||
try {
|
||||
// Create session in manager (stores it internally)
|
||||
await migrationSessionManager.createSession(sourcePath, projectName);
|
||||
// Set default target path
|
||||
const defaultTargetPath = `${sourcePath}-react19`;
|
||||
migrationSessionManager.setTargetPath(defaultTargetPath);
|
||||
|
||||
// Update session with new target path
|
||||
const updatedSession = migrationSessionManager.getSession();
|
||||
if (updatedSession) {
|
||||
// Initialize reducer state with the session
|
||||
dispatch({ type: 'SET_SESSION', session: updatedSession });
|
||||
}
|
||||
setIsInitialized(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to create migration session:', error);
|
||||
dispatch({ type: 'ERROR', error: error as Error });
|
||||
}
|
||||
}
|
||||
initSession();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
migrationSessionManager.cancelSession();
|
||||
};
|
||||
}, [sourcePath, projectName]);
|
||||
|
||||
// Sync local state with session manager
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const currentSession = migrationSessionManager.getSession();
|
||||
if (currentSession) {
|
||||
// Initialize local state from session manager
|
||||
dispatch({ type: 'SET_TARGET_PATH', path: currentSession.target.path });
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
// ==========================================================================
|
||||
// Handlers
|
||||
// ==========================================================================
|
||||
|
||||
const handleUpdateTargetPath = useCallback((path: string) => {
|
||||
migrationSessionManager.setTargetPath(path);
|
||||
dispatch({ type: 'SET_TARGET_PATH', path });
|
||||
}, []);
|
||||
|
||||
const handleStartScan = useCallback(async () => {
|
||||
dispatch({ type: 'START_SCAN' });
|
||||
try {
|
||||
const scan = await migrationSessionManager.startScanning();
|
||||
dispatch({ type: 'SCAN_COMPLETE', scan });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'ERROR', error: error as Error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStartMigration = useCallback(async (useAi: boolean) => {
|
||||
dispatch({ type: 'START_MIGRATE', useAi });
|
||||
try {
|
||||
const result = await migrationSessionManager.startMigration();
|
||||
dispatch({ type: 'COMPLETE', result });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'ERROR', error: error as Error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
try {
|
||||
await migrationSessionManager.resetForRetry();
|
||||
dispatch({ type: 'RETRY' });
|
||||
} catch (error) {
|
||||
console.error('Failed to reset session:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenProject = useCallback(() => {
|
||||
const currentSession = migrationSessionManager.getSession();
|
||||
if (currentSession?.target.path) {
|
||||
onComplete(currentSession.target.path);
|
||||
}
|
||||
}, [onComplete]);
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
|
||||
// Get current session from manager (source of truth)
|
||||
const session = migrationSessionManager.getSession();
|
||||
|
||||
if (!session) {
|
||||
return null; // Session not initialized yet
|
||||
}
|
||||
|
||||
const currentStep = session.step;
|
||||
const stepIndex = getStepNumber(currentStep);
|
||||
const totalSteps = getTotalSteps(false); // No AI for now
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 'confirm':
|
||||
return (
|
||||
<ConfirmStep
|
||||
sourcePath={sourcePath}
|
||||
projectName={projectName}
|
||||
targetPath={session.target.path}
|
||||
onUpdateTargetPath={handleUpdateTargetPath}
|
||||
onNext={handleStartScan}
|
||||
onCancel={onCancel}
|
||||
loading={state.loading}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'scanning':
|
||||
return (
|
||||
<ScanningStep
|
||||
sourcePath={sourcePath}
|
||||
targetPath={session.target.path}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'report':
|
||||
return (
|
||||
<ReportStep
|
||||
scan={session.scan!}
|
||||
onMigrateWithoutAi={() => handleStartMigration(false)}
|
||||
onMigrateWithAi={() => handleStartMigration(true)}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'migrating':
|
||||
return (
|
||||
<ScanningStep
|
||||
sourcePath={sourcePath}
|
||||
targetPath={session.target.path}
|
||||
isMigrating
|
||||
progress={session.progress}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<CompleteStep
|
||||
result={session.result!}
|
||||
sourcePath={sourcePath}
|
||||
targetPath={session.target.path}
|
||||
onOpenProject={handleOpenProject}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'failed':
|
||||
return (
|
||||
<FailedStep
|
||||
error={state.error}
|
||||
onRetry={handleRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
|
||||
<div className={css['WizardContainer']}>
|
||||
{/* Close Button */}
|
||||
<div className={css['CloseButton']}>
|
||||
<IconButton
|
||||
icon={IconName.Close}
|
||||
onClick={onCancel}
|
||||
variant={IconButtonVariant.Transparent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className={css['WizardHeader']}>
|
||||
<Title size={TitleSize.Large} variant={TitleVariant.Highlighted}>
|
||||
Migrate Project to React 19
|
||||
</Title>
|
||||
<Text textType={TextType.Secondary}>{getStepLabel(currentStep)}</Text>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={css['WizardContent']}>
|
||||
<WizardProgress
|
||||
currentStep={stepIndex}
|
||||
totalSteps={totalSteps}
|
||||
stepLabels={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
|
||||
/>
|
||||
<div className={css['StepContainer']}>
|
||||
{renderStep()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CoreBaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default MigrationWizard;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* WizardProgress Styles
|
||||
*
|
||||
* Step progress indicator for migration wizard.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.Step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.StepCircle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.Step.is-completed .StepCircle {
|
||||
background-color: var(--theme-color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.Step.is-active .StepCircle {
|
||||
background-color: var(--theme-color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.StepLabel {
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
text-align: center;
|
||||
max-width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Step.is-completed .StepLabel,
|
||||
.Step.is-active .StepLabel {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.Connector {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
margin-bottom: 20px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.Connector.is-completed {
|
||||
background-color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.CheckIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* WizardProgress
|
||||
*
|
||||
* A visual progress indicator showing the current step in the migration wizard.
|
||||
*
|
||||
* @module noodl-editor/views/migration/components
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import css from './WizardProgress.module.scss';
|
||||
|
||||
export interface WizardProgressProps {
|
||||
/** Current step index (1-indexed) */
|
||||
currentStep: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Labels for each step */
|
||||
stepLabels: string[];
|
||||
}
|
||||
|
||||
export function WizardProgress({ currentStep, totalSteps, stepLabels }: WizardProgressProps) {
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<div className={css['Steps']}>
|
||||
{stepLabels.map((label, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isActive = stepNumber === currentStep;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={classNames(
|
||||
css['Step'],
|
||||
isActive && css['is-active'],
|
||||
isCompleted && css['is-completed']
|
||||
)}
|
||||
>
|
||||
<div className={css['StepIndicator']}>
|
||||
{isCompleted ? (
|
||||
<svg viewBox="0 0 16 16" className={css['CheckIcon']}>
|
||||
<path
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span>{stepNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={css['StepLabel']}>{label}</span>
|
||||
{index < stepLabels.length - 1 && (
|
||||
<div
|
||||
className={classNames(
|
||||
css['StepConnector'],
|
||||
isCompleted && css['is-completed']
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={css['ProgressBar']}>
|
||||
<div
|
||||
className={css['ProgressFill']}
|
||||
style={{ width: `${((currentStep - 1) / (totalSteps - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WizardProgress;
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* CompleteStep Styles
|
||||
*
|
||||
* Final step showing migration summary.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.Stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.StatCardIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.StatCard.is-success .StatCardIcon {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.StatCard.is-warning .StatCardIcon {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.StatCard.is-error .StatCardIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.StatCardValue {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.StatCardLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.MetaInfo {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.MetaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.Paths {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.PathItem {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
span:last-child {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.NextSteps {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.StepsList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* CompleteStep
|
||||
*
|
||||
* Step 5 of the migration wizard: Shows final summary.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { MigrationResult } from '../../../models/migration/types';
|
||||
|
||||
import css from './CompleteStep.module.scss';
|
||||
|
||||
export interface CompleteStepProps {
|
||||
/** Migration result */
|
||||
result: MigrationResult;
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Path to the migrated project */
|
||||
targetPath: string;
|
||||
/** Called when user wants to open the migrated project */
|
||||
onOpenProject: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
export function CompleteStep({
|
||||
result,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
onOpenProject
|
||||
}: CompleteStepProps) {
|
||||
const hasIssues = result.needsReview > 0;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
{hasIssues ? <CheckWarningIcon /> : <CheckCircleIcon />}
|
||||
<Title size={TitleSize.Medium}>
|
||||
{hasIssues ? 'Migration Complete (With Notes)' : 'Migration Complete!'}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Text textType={TextType.Secondary}>
|
||||
Your project has been migrated to React 19. The original project remains untouched.
|
||||
</Text>
|
||||
|
||||
{/* Stats */}
|
||||
<div className={css['Stats']}>
|
||||
<StatCard
|
||||
icon={<CheckIcon />}
|
||||
value={result.migrated}
|
||||
label="Migrated"
|
||||
variant="success"
|
||||
/>
|
||||
{result.needsReview > 0 && (
|
||||
<StatCard
|
||||
icon={<WarningIcon />}
|
||||
value={result.needsReview}
|
||||
label="Needs Review"
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<StatCard
|
||||
icon={<ErrorIcon />}
|
||||
value={result.failed}
|
||||
label="Failed"
|
||||
variant="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration and Cost */}
|
||||
<div className={css['MetaInfo']}>
|
||||
<div className={css['MetaItem']}>
|
||||
<ClockIcon />
|
||||
<Text size={TextSize.Small}>Time: {formatDuration(result.duration)}</Text>
|
||||
</div>
|
||||
{result.totalCost > 0 && (
|
||||
<div className={css['MetaItem']}>
|
||||
<RobotIcon />
|
||||
<Text size={TextSize.Small}>AI cost: ${result.totalCost.toFixed(2)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Paths */}
|
||||
<div className={css['Paths']}>
|
||||
<Title size={TitleSize.Small}>Project Locations</Title>
|
||||
|
||||
<div className={css['PathItem']}>
|
||||
<LockIcon />
|
||||
<div className={css['PathContent']}>
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>Original (untouched)</Text>
|
||||
<Text size={TextSize.Small}>{sourcePath}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css['PathItem']}>
|
||||
<FolderIcon />
|
||||
<div className={css['PathContent']}>
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>Migrated copy</Text>
|
||||
<Text size={TextSize.Small}>{targetPath}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What's Next */}
|
||||
<div className={css['NextSteps']}>
|
||||
<Title size={TitleSize.Small}>What's Next?</Title>
|
||||
<ol className={css['StepsList']}>
|
||||
{result.needsReview > 0 && (
|
||||
<li>
|
||||
<WarningIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
Components marked with ⚠️ have notes in the component panel -
|
||||
click to see migration details
|
||||
</Text>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<CheckIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
Test your app thoroughly before deploying
|
||||
</Text>
|
||||
</li>
|
||||
<li>
|
||||
<TrashIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
Once confirmed working, you can archive or delete the original folder
|
||||
</Text>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Open Migrated Project"
|
||||
onClick={onOpenProject}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-Components
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
value: number;
|
||||
label: string;
|
||||
variant: 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
function StatCard({ icon, value, label, variant }: StatCardProps) {
|
||||
return (
|
||||
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
|
||||
<div className={css['StatCardIcon']}>{icon}</div>
|
||||
<div className={css['StatCardValue']}>{value}</div>
|
||||
<div className={css['StatCardLabel']}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
function CheckCircleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={32} height={32}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckWarningIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={32} height={32}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ClockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zM8 3.5a.75.75 0 01.75.75V8h2.5a.75.75 0 110 1.5H8a.75.75 0 01-.75-.75V4.25A.75.75 0 018 3.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompleteStep;
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* ConfirmStep Styles
|
||||
*
|
||||
* First step of migration wizard - confirm source and target paths.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.PathSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.PathHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.LockIcon,
|
||||
.FolderIcon {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.PathFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.PathField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.PathLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathDisplay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.PathText {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.PathValue {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.PathInput {
|
||||
input {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.PathError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.Arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.InfoBox {
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.StepsList {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--theme-color-fg-default);
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.WarningBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.WarningContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.WarningTitle {
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* ConfirmStep
|
||||
*
|
||||
* Step 1 of the migration wizard: Confirm source and target paths.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, ChangeEvent } from 'react';
|
||||
|
||||
import { FeedbackType } from '@noodl-constants/FeedbackType';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { filesystem } from '@noodl/platform';
|
||||
|
||||
import css from './ConfirmStep.module.scss';
|
||||
|
||||
export interface ConfirmStepProps {
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Name of the project */
|
||||
projectName: string;
|
||||
/** Current target path */
|
||||
targetPath: string;
|
||||
/** Called when target path changes */
|
||||
onUpdateTargetPath: (path: string) => void;
|
||||
/** Called when user proceeds to next step */
|
||||
onNext: () => void;
|
||||
/** Called when user cancels the wizard */
|
||||
onCancel: () => void;
|
||||
/** Whether the wizard is loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmStep({
|
||||
sourcePath,
|
||||
projectName,
|
||||
targetPath,
|
||||
onUpdateTargetPath,
|
||||
onNext,
|
||||
onCancel,
|
||||
loading = false
|
||||
}: ConfirmStepProps) {
|
||||
const [targetExists, setTargetExists] = useState(false);
|
||||
const [checkingPath, setCheckingPath] = useState(false);
|
||||
|
||||
// Check if target path exists
|
||||
const checkTargetPath = useCallback(async (path: string) => {
|
||||
if (!path) {
|
||||
setTargetExists(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingPath(true);
|
||||
try {
|
||||
const exists = await filesystem.exists(path);
|
||||
setTargetExists(exists);
|
||||
} catch {
|
||||
setTargetExists(false);
|
||||
} finally {
|
||||
setCheckingPath(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkTargetPath(targetPath);
|
||||
}, [targetPath, checkTargetPath]);
|
||||
|
||||
const handleTargetChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onUpdateTargetPath(e.target.value);
|
||||
},
|
||||
[onUpdateTargetPath]
|
||||
);
|
||||
|
||||
const handleUseUniqueName = useCallback(() => {
|
||||
const timestamp = Date.now();
|
||||
const uniquePath = `${sourcePath}-react19-${timestamp}`;
|
||||
onUpdateTargetPath(uniquePath);
|
||||
}, [sourcePath, onUpdateTargetPath]);
|
||||
|
||||
const canProceed = targetPath && !targetExists && !loading && !checkingPath;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
<Box hasBottomSpacing>
|
||||
<Text>
|
||||
We'll create a safe copy of your project before making any changes.
|
||||
Your original project will remain untouched.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Source Project (Read-only) */}
|
||||
<div className={css['PathSection']}>
|
||||
<div className={css['PathHeader']}>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={css['LockIcon']}
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
<path
|
||||
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Title size={TitleSize.Small}>Original Project (will not be modified)</Title>
|
||||
</div>
|
||||
<div className={css['PathDisplay']}>
|
||||
<Text className={css['PathText']}>{sourcePath}</Text>
|
||||
<Text className={css['ProjectName']}>{projectName}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className={css['Arrow']}>
|
||||
<svg viewBox="0 0 16 16" width={20} height={20}>
|
||||
<path
|
||||
d="M8 2a.75.75 0 01.75.75v8.69l2.22-2.22a.75.75 0 111.06 1.06l-3.5 3.5a.75.75 0 01-1.06 0l-3.5-3.5a.75.75 0 111.06-1.06l2.22 2.22V2.75A.75.75 0 018 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Text size={TextSize.Small}>Creates copy</Text>
|
||||
</div>
|
||||
|
||||
{/* Target Path (Editable) */}
|
||||
<div className={css['PathSection']}>
|
||||
<div className={css['PathHeader']}>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={css['FolderIcon']}
|
||||
width={16}
|
||||
height={16}
|
||||
>
|
||||
<path
|
||||
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Title size={TitleSize.Small}>Migrated Copy Location</Title>
|
||||
</div>
|
||||
<TextInput
|
||||
value={targetPath}
|
||||
onChange={handleTargetChange}
|
||||
UNSAFE_className={css['PathInput']}
|
||||
/>
|
||||
{targetExists && (
|
||||
<div className={css['PathError']}>
|
||||
<Text textType={FeedbackType.Danger}>
|
||||
A folder already exists at this location.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
label="Use Different Name"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={handleUseUniqueName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* What happens next */}
|
||||
<Box hasTopSpacing>
|
||||
<div className={css['InfoBox']}>
|
||||
<Title size={TitleSize.Small}>What happens next:</Title>
|
||||
<ol className={css['StepsList']}>
|
||||
<li>Your project will be copied to the new location</li>
|
||||
<li>We'll scan for compatibility issues</li>
|
||||
<li>You'll see a report of what needs to change</li>
|
||||
<li>Automatic fixes will be applied</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onCancel}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={loading ? 'Starting...' : 'Start Migration'}
|
||||
onClick={onNext}
|
||||
isDisabled={!canProceed}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmStep;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* FailedStep Styles
|
||||
*
|
||||
* Error state when migration fails.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ErrorCircleIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.DescriptionText {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.ErrorBox {
|
||||
margin-top: 16px;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ErrorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorText {
|
||||
color: var(--theme-color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ErrorMessage {
|
||||
padding: 12px 16px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.Suggestions {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.SuggestionList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Link {
|
||||
color: var(--theme-color-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.SafetyNotice {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.SafetyText {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* FailedStep
|
||||
*
|
||||
* Step shown when migration fails. Allows user to retry or cancel.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import css from './FailedStep.module.scss';
|
||||
|
||||
export interface FailedStepProps {
|
||||
/** The error that caused the failure */
|
||||
error: Error | null;
|
||||
/** Called when user wants to retry */
|
||||
onRetry: () => void;
|
||||
/** Called when user wants to cancel */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function FailedStep({
|
||||
error,
|
||||
onRetry,
|
||||
onCancel
|
||||
}: FailedStepProps) {
|
||||
const errorMessage = error?.message || 'An unknown error occurred during migration.';
|
||||
const isNetworkError = errorMessage.toLowerCase().includes('network') ||
|
||||
errorMessage.toLowerCase().includes('timeout');
|
||||
const isPermissionError = errorMessage.toLowerCase().includes('permission') ||
|
||||
errorMessage.toLowerCase().includes('access');
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
<ErrorCircleIcon />
|
||||
<Title size={TitleSize.Medium}>
|
||||
Migration Failed
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Text className={css['DescriptionText']}>
|
||||
Something went wrong during the migration process. Your original project is safe and unchanged.
|
||||
</Text>
|
||||
|
||||
{/* Error Details */}
|
||||
<div className={css['ErrorBox']}>
|
||||
<div className={css['ErrorHeader']}>
|
||||
<ErrorIcon />
|
||||
<Text className={css['ErrorText']}>Error Details</Text>
|
||||
</div>
|
||||
<div className={css['ErrorMessage']}>
|
||||
<Text size={TextSize.Small}>{errorMessage}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
<div className={css['Suggestions']}>
|
||||
<Title size={TitleSize.Small}>What you can try:</Title>
|
||||
<ul className={css['SuggestionList']}>
|
||||
{isNetworkError && (
|
||||
<li>
|
||||
<WifiIcon />
|
||||
<Text size={TextSize.Small}>Check your internet connection and try again</Text>
|
||||
</li>
|
||||
)}
|
||||
{isPermissionError && (
|
||||
<li>
|
||||
<LockIcon />
|
||||
<Text size={TextSize.Small}>Make sure you have write access to the target directory</Text>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<RefreshIcon />
|
||||
<Text size={TextSize.Small}>Click "Try Again" to restart the migration</Text>
|
||||
</li>
|
||||
<li>
|
||||
<FolderIcon />
|
||||
<Text size={TextSize.Small}>Try choosing a different target directory</Text>
|
||||
</li>
|
||||
<li>
|
||||
<HelpIcon />
|
||||
<Text size={TextSize.Small}>
|
||||
If the problem persists, check the{' '}
|
||||
<a
|
||||
href="https://github.com/The-Low-Code-Foundation/OpenNoodl/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={css['Link']}
|
||||
>
|
||||
GitHub Issues
|
||||
</a>
|
||||
</Text>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Safety Notice */}
|
||||
<div className={css['SafetyNotice']}>
|
||||
<ShieldIcon />
|
||||
<Text size={TextSize.Small} className={css['SafetyText']}>
|
||||
Your original project remains untouched. Any partial migration files have been cleaned up.
|
||||
</Text>
|
||||
</div>
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Try Again"
|
||||
onClick={onRetry}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
function ErrorCircleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={32} height={32} className={css['ErrorCircleIcon']}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm0-11.5a.75.75 0 01.75.75v3.5a.75.75 0 11-1.5 0v-3.5A.75.75 0 018 4.5zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WifiIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 12a1.5 1.5 0 100 3 1.5 1.5 0 000-3zM1.332 5.084a.75.75 0 10.97 1.142 9.5 9.5 0 0113.396 0 .75.75 0 00.97-1.142 11 11 0 00-15.336 0zm2.91 2.908a.75.75 0 10.97 1.142 5.5 5.5 0 017.576 0 .75.75 0 00.97-1.142 7 7 0 00-9.516 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 3a5 5 0 00-4.546 2.914.5.5 0 01-.908-.414A6 6 0 0113.944 5H12.5a.5.5 0 010-1h3a.5.5 0 01.5.5v3a.5.5 0 11-1 0V6.057A5.956 5.956 0 018 3zM0 8a.5.5 0 01.5-.5h1a.5.5 0 010 1H.5A.5.5 0 010 8zm1.5 2.5a.5.5 0 01.5.5v1.443A5.956 5.956 0 008 13a5 5 0 004.546-2.914.5.5 0 01.908.414A6 6 0 012.056 11H3.5a.5.5 0 010 1h-3a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zm0-10.25a2.25 2.25 0 00-2.25 2.25.75.75 0 001.5 0 .75.75 0 111.5 0c0 .52-.3.866-.658 1.075-.368.216-.842.425-.842 1.175a.75.75 0 001.5 0c0-.15.099-.282.282-.394.187-.114.486-.291.727-.524A2.25 2.25 0 008 4.25zM8 13a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M7.467.133a1.75 1.75 0 011.066 0l5.25 1.68A1.75 1.75 0 0115 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.7 1.7 0 01-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 011.217-1.667l5.25-1.68zm.61 1.429a.25.25 0 00-.153 0l-5.25 1.68a.25.25 0 00-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.2.2 0 00.154 0c2.245-.956 3.582-2.103 4.366-3.298C13.225 9.666 13.5 8.358 13.5 7V3.48a.25.25 0 00-.174-.238l-5.25-1.68zM11.28 6.28a.75.75 0 00-1.06-1.06L7.25 8.19 5.78 6.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l3.5-3.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FailedStep;
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* ReportStep Styles
|
||||
*
|
||||
* Scan results report with categories and AI options.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.StatsRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.StatCard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.StatCardIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.StatCard.is-automatic .StatCardIcon {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.StatCard.is-simpleFixes .StatCardIcon {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.StatCard.is-needsReview .StatCardIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.StatCardValue {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
.StatCardLabel {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.Categories {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.CategorySection {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.CategoryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.CategoryIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.CategorySection.is-automatic .CategoryIcon {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
.CategorySection.is-simpleFixes .CategoryIcon {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
.CategorySection.is-needsReview .CategoryIcon {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.CategoryTitle {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.CategoryCount {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.ExpandIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.CategorySection.is-expanded .ExpandIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ComponentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ComponentItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
}
|
||||
|
||||
.ComponentName {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ComponentIssueCount {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.AiPromptSection {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.AiPromptHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #8b5cf6; // AI purple
|
||||
}
|
||||
}
|
||||
|
||||
.AiPromptTitle {
|
||||
font-weight: 500;
|
||||
color: #8b5cf6; // AI purple
|
||||
}
|
||||
|
||||
.AiPromptSection.is-disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.Actions {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* ReportStep
|
||||
*
|
||||
* Step 3 of the migration wizard: Shows scan results by category.
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { MigrationScan, ComponentMigrationInfo } from '../../../models/migration/types';
|
||||
|
||||
import css from './ReportStep.module.scss';
|
||||
|
||||
export interface ReportStepProps {
|
||||
/** Scan results */
|
||||
scan: MigrationScan;
|
||||
/** Called when user chooses to migrate without AI */
|
||||
onMigrateWithoutAi: () => void;
|
||||
/** Called when user chooses to migrate with AI */
|
||||
onMigrateWithAi: () => void;
|
||||
/** Called when user cancels */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ReportStep({
|
||||
scan,
|
||||
onMigrateWithoutAi,
|
||||
onMigrateWithAi,
|
||||
onCancel
|
||||
}: ReportStepProps) {
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||
|
||||
const { automatic, simpleFixes, needsReview } = scan.categories;
|
||||
|
||||
const totalIssues = simpleFixes.length + needsReview.length;
|
||||
const allAutomatic = totalIssues === 0;
|
||||
|
||||
// Calculate estimated cost (placeholder - AI not yet implemented)
|
||||
const estimatedCost = needsReview.length * 0.05 + simpleFixes.length * 0.02;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
<Text>
|
||||
Analyzed {scan.totalComponents} components and {scan.totalNodes} nodes.
|
||||
{scan.customJsFiles > 0 && ` Found ${scan.customJsFiles} custom JavaScript files.`}
|
||||
</Text>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className={css['SummaryStats']}>
|
||||
<StatCard
|
||||
icon={<CheckCircleIcon />}
|
||||
value={automatic.length}
|
||||
label="Automatic"
|
||||
variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ZapIcon />}
|
||||
value={simpleFixes.length}
|
||||
label="Simple Fixes"
|
||||
variant="info"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ToolIcon />}
|
||||
value={needsReview.length}
|
||||
label="Needs Review"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Sections */}
|
||||
<div className={css['Categories']}>
|
||||
{/* Automatic */}
|
||||
<CategorySection
|
||||
title="Automatic"
|
||||
description="These components will migrate without any changes"
|
||||
icon={<CheckCircleIcon />}
|
||||
items={automatic}
|
||||
variant="success"
|
||||
expanded={expandedCategory === 'automatic'}
|
||||
onToggle={() =>
|
||||
setExpandedCategory(expandedCategory === 'automatic' ? null : 'automatic')
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Simple Fixes */}
|
||||
{simpleFixes.length > 0 && (
|
||||
<CategorySection
|
||||
title="Simple Fixes"
|
||||
description="Minor syntax updates needed"
|
||||
icon={<ZapIcon />}
|
||||
items={simpleFixes}
|
||||
variant="info"
|
||||
expanded={expandedCategory === 'simpleFixes'}
|
||||
onToggle={() =>
|
||||
setExpandedCategory(expandedCategory === 'simpleFixes' ? null : 'simpleFixes')
|
||||
}
|
||||
showIssueDetails
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Needs Review */}
|
||||
{needsReview.length > 0 && (
|
||||
<CategorySection
|
||||
title="Needs Review"
|
||||
description="May require manual adjustment after migration"
|
||||
icon={<ToolIcon />}
|
||||
items={needsReview}
|
||||
variant="warning"
|
||||
expanded={expandedCategory === 'needsReview'}
|
||||
onToggle={() =>
|
||||
setExpandedCategory(expandedCategory === 'needsReview' ? null : 'needsReview')
|
||||
}
|
||||
showIssueDetails
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Prompt (if there are issues) */}
|
||||
{!allAutomatic && (
|
||||
<div className={css['AiPrompt']}>
|
||||
<div className={css['AiPromptIcon']}>
|
||||
<RobotIcon />
|
||||
</div>
|
||||
<div className={css['AiPromptContent']}>
|
||||
<Title size={TitleSize.Small}>AI-Assisted Migration (Coming Soon)</Title>
|
||||
<Text textType={TextType.Secondary} size={TextSize.Small}>
|
||||
Claude can help automatically fix the {totalIssues} components that need
|
||||
code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css['Actions']}>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={allAutomatic ? 'Migrate Project' : 'Migrate (Auto Only)'}
|
||||
onClick={onMigrateWithoutAi}
|
||||
/>
|
||||
{!allAutomatic && (
|
||||
<PrimaryButton
|
||||
label="Migrate with AI"
|
||||
onClick={onMigrateWithAi}
|
||||
isDisabled // AI not yet implemented
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-Components
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode;
|
||||
value: number;
|
||||
label: string;
|
||||
variant: 'success' | 'info' | 'warning';
|
||||
}
|
||||
|
||||
function StatCard({ icon, value, label, variant }: StatCardProps) {
|
||||
return (
|
||||
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
|
||||
<div className={css['StatCardIcon']}>{icon}</div>
|
||||
<div className={css['StatCardValue']}>{value}</div>
|
||||
<div className={css['StatCardLabel']}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
items: ComponentMigrationInfo[];
|
||||
variant: 'success' | 'info' | 'warning';
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
showIssueDetails?: boolean;
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
items,
|
||||
variant,
|
||||
expanded,
|
||||
onToggle,
|
||||
showIssueDetails = false
|
||||
}: CategorySectionProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`${css['CategorySection']} ${css[`is-${variant}`]}`}>
|
||||
<button className={css['CategoryHeader']} onClick={onToggle}>
|
||||
<div className={css['CategoryHeaderLeft']}>
|
||||
{icon}
|
||||
<div>
|
||||
<Text textType={TextType.Proud}>
|
||||
{title} ({items.length})
|
||||
</Text>
|
||||
<Text textType={TextType.Secondary} size={TextSize.Small}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronIcon direction={expanded ? 'up' : 'down'} />
|
||||
</button>
|
||||
|
||||
<Collapsible isCollapsed={!expanded}>
|
||||
<div className={css['CategoryItems']}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={css['CategoryItem']}>
|
||||
<ComponentIcon />
|
||||
<div className={css['CategoryItemInfo']}>
|
||||
<Text size={TextSize.Small}>{item.name}</Text>
|
||||
{showIssueDetails && item.issues.length > 0 && (
|
||||
<ul className={css['IssuesList']}>
|
||||
{item.issues.map((issue) => (
|
||||
<li key={issue.id}>
|
||||
<code>{issue.type}</code>
|
||||
<Text size={TextSize.Small} isSpan textType={TextType.Secondary}>
|
||||
{' '}{issue.description}
|
||||
</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{item.estimatedCost !== undefined && (
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>
|
||||
~${item.estimatedCost.toFixed(2)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
function CheckCircleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={16} height={16}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ZapIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={16} height={16}>
|
||||
<path
|
||||
d="M9.504.43a.75.75 0 01.397.696L9.223 5h4.027a.75.75 0 01.577 1.22l-5.25 6.25a.75.75 0 01-1.327-.55l.678-4.42H3.902a.75.75 0 01-.577-1.22l5.25-6.25a.75.75 0 01.93-.18z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={16} height={16}>
|
||||
<path
|
||||
d="M5.433 2.304A4.492 4.492 0 003.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64l-.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 003.5 6a5.99 5.99 0 012.433-4.864.75.75 0 011.134.64v3.046l.5.865.5-.865V1.776a.75.75 0 011.134-.64A5.99 5.99 0 0111.5 6a4.495 4.495 0 01-.922 3.802c-.518.328-.929.923-.902 1.64l.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 007.5 6c0-.54-.185-1.061-.433-1.548"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={24} height={24}>
|
||||
<path
|
||||
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8.186 1.113a.5.5 0 00-.372 0L1.814 3.5l-.372.149v8.702l.372.149 6 2.387a.5.5 0 00.372 0l6-2.387.372-.149V3.649l-.372-.149-6-2.387zM8 2.123l4.586 1.828L8 5.778 3.414 3.95 8 2.123zm-5.5 2.89l5 1.992v6.372l-5-1.992V5.013zm6.5 8.364V7.005l5-1.992v6.372l-5 1.992z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ direction }: { direction: 'up' | 'down' }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ transform: direction === 'up' ? 'rotate(180deg)' : undefined }}
|
||||
>
|
||||
<path
|
||||
d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportStep;
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* ScanningStep Styles
|
||||
*
|
||||
* Scanning/migrating progress display.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
svg {
|
||||
color: var(--theme-color-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ProgressSection {
|
||||
padding: 16px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ProgressHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ProgressBar {
|
||||
height: 8px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ProgressFill {
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.ActivityLog {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ActivityHeader {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--theme-color-bg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ActivityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.ActivityItem {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
|
||||
&.is-info {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: var(--theme-color-success);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: var(--theme-color-warning);
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ActivityTime {
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ActivityMessage {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.EmptyActivity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
color: var(--theme-color-secondary-as-fg);
|
||||
}
|
||||
|
||||
.InfoBox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* ScanningStep
|
||||
*
|
||||
* Step 2 of the migration wizard: Shows progress while copying and scanning.
|
||||
* Also used during the migration phase (step 4).
|
||||
*
|
||||
* @module noodl-editor/views/migration/steps
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
import { MigrationProgress } from '../../../models/migration/types';
|
||||
|
||||
import css from './ScanningStep.module.scss';
|
||||
|
||||
export interface ScanningStepProps {
|
||||
/** Path to the source project */
|
||||
sourcePath: string;
|
||||
/** Path to the target project */
|
||||
targetPath: string;
|
||||
/** Whether we're in migration phase (vs scanning phase) */
|
||||
isMigrating?: boolean;
|
||||
/** Progress information (for migration phase) */
|
||||
progress?: MigrationProgress;
|
||||
}
|
||||
|
||||
export function ScanningStep({
|
||||
sourcePath: _sourcePath,
|
||||
targetPath: _targetPath,
|
||||
isMigrating = false,
|
||||
progress
|
||||
}: ScanningStepProps) {
|
||||
// sourcePath and targetPath are available for future use (e.g., displaying paths)
|
||||
void _sourcePath;
|
||||
void _targetPath;
|
||||
const title = isMigrating ? 'Migrating Project...' : 'Analyzing Project...';
|
||||
const subtitle = isMigrating
|
||||
? `Phase: ${getPhaseLabel(progress?.phase)}`
|
||||
: 'Creating a safe copy before making any changes';
|
||||
|
||||
const progressPercent = progress
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<VStack hasSpacing>
|
||||
<div className={css['Header']}>
|
||||
<ActivityIndicator />
|
||||
<Title size={TitleSize.Medium}>{title}</Title>
|
||||
</div>
|
||||
|
||||
<Text textType={TextType.Secondary}>{subtitle}</Text>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className={css['ProgressSection']}>
|
||||
<div className={css['ProgressBar']}>
|
||||
<div
|
||||
className={css['ProgressFill']}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress && (
|
||||
<Text size={TextSize.Small} textType={TextType.Shy}>
|
||||
{progress.current} / {progress.total} components
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Item */}
|
||||
{progress?.currentComponent && (
|
||||
<div className={css['CurrentItem']}>
|
||||
<svg viewBox="0 0 16 16" width={14} height={14}>
|
||||
<path
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Text size={TextSize.Small}>{progress.currentComponent}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Entries */}
|
||||
{progress?.log && progress.log.length > 0 && (
|
||||
<div className={css['LogSection']}>
|
||||
<Title size={TitleSize.Small}>Activity Log</Title>
|
||||
<div className={css['LogEntries']}>
|
||||
{progress.log.slice(-5).map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${css['LogEntry']} ${css[`is-${entry.level}`]}`}
|
||||
>
|
||||
<LogIcon level={entry.level} />
|
||||
<div className={css['LogContent']}>
|
||||
{entry.component && (
|
||||
<Text
|
||||
size={TextSize.Small}
|
||||
textType={TextType.Proud}
|
||||
isSpan
|
||||
>
|
||||
{entry.component}:{' '}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={TextSize.Small} isSpan>
|
||||
{entry.message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Box hasTopSpacing>
|
||||
<div className={css['InfoBox']}>
|
||||
<Text textType={TextType.Shy} size={TextSize.Small}>
|
||||
{isMigrating
|
||||
? 'Please wait while we migrate your project. This may take a few minutes for larger projects.'
|
||||
: 'Scanning components for React 17 patterns that need updating...'}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper Components
|
||||
function LogIcon({ level }: { level: string }) {
|
||||
const icons: Record<string, JSX.Element> = {
|
||||
info: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M8 16A8 8 0 108 0a8 8 0 000 16zm.93-9.412l-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287h.001zm-.043-3.33a.86.86 0 110 1.72.86.86 0 010-1.72z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
success: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg viewBox="0 0 16 16" width={12} height={12}>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
return icons[level] || icons.info;
|
||||
}
|
||||
|
||||
function getPhaseLabel(phase?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
copying: 'Copying files',
|
||||
automatic: 'Applying automatic fixes',
|
||||
'ai-assisted': 'AI-assisted migration',
|
||||
finalizing: 'Finalizing'
|
||||
};
|
||||
return labels[phase || ''] || 'Starting';
|
||||
}
|
||||
|
||||
export default ScanningStep;
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { filesystem, platform } from '@noodl/platform';
|
||||
|
||||
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
|
||||
import { LessonsProjectsModel } from '@noodl-models/LessonsProjectModel';
|
||||
import { CloudServiceMetadata } from '@noodl-models/projectmodel';
|
||||
import { setCloudServices } from '@noodl-models/projectmodel.editor';
|
||||
import { LocalProjectsModel, ProjectItem } from '@noodl-utils/LocalProjectsModel';
|
||||
import { LocalProjectsModel, ProjectItem, ProjectItemWithRuntime } from '@noodl-utils/LocalProjectsModel';
|
||||
|
||||
import { MigrationWizard } from './migration/MigrationWizard';
|
||||
|
||||
import View from '../../../shared/view';
|
||||
import LessonTemplatesModel from '../models/lessontemplatesmodel';
|
||||
@@ -29,6 +33,10 @@ type ProjectItemScope = {
|
||||
project: ProjectItem;
|
||||
label: string;
|
||||
latestAccessedTimeAgo: string;
|
||||
/** Whether the project uses legacy React 17 runtime */
|
||||
isLegacy: boolean;
|
||||
/** Whether runtime detection is in progress */
|
||||
isDetecting: boolean;
|
||||
};
|
||||
|
||||
export class ProjectsView extends View {
|
||||
@@ -112,6 +120,9 @@ export class ProjectsView extends View {
|
||||
|
||||
this.projectsModel.on('myProjectsChanged', () => this.renderProjectItemsPane(), this);
|
||||
|
||||
// Re-render when runtime detection completes to update legacy indicators
|
||||
this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);
|
||||
|
||||
this.switchPane('projects');
|
||||
|
||||
// this.$('#top-bar').css({ height: this.topBarHeight + 'px' });
|
||||
@@ -274,27 +285,34 @@ export class ProjectsView extends View {
|
||||
}) {
|
||||
options = options || {};
|
||||
|
||||
const items = options.items;
|
||||
const projectItems = options.items || [];
|
||||
const projectItemsSelector = options.appendProjectItemsTo || '.projects-items';
|
||||
const template = options.template || 'projects-item';
|
||||
this.$(projectItemsSelector).html('');
|
||||
|
||||
for (const i in items) {
|
||||
const label = items[i].name;
|
||||
for (const item of projectItems) {
|
||||
const label = item.name;
|
||||
if (options.filter && label.toLowerCase().indexOf(options.filter) === -1) continue;
|
||||
|
||||
const latestAccessed = items[i].latestAccessed || Date.now();
|
||||
const latestAccessed = item.latestAccessed || Date.now();
|
||||
|
||||
// Check if this is a legacy React 17 project
|
||||
const projectPath = item.retainedProjectDirectory;
|
||||
const isLegacy = projectPath ? this.projectsModel.isLegacyProject(projectPath) : false;
|
||||
const isDetecting = projectPath ? this.projectsModel.isDetectingRuntime(projectPath) : false;
|
||||
|
||||
const scope: ProjectItemScope = {
|
||||
project: items[i],
|
||||
project: item,
|
||||
label: label,
|
||||
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago'
|
||||
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago',
|
||||
isLegacy,
|
||||
isDetecting
|
||||
};
|
||||
|
||||
const el = this.bindView(this.cloneTemplate(template), scope);
|
||||
if (items[i].thumbURI) {
|
||||
if (item.thumbURI) {
|
||||
// Set the thumbnail image if there is one
|
||||
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + items[i].thumbURI + ')');
|
||||
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + item.thumbURI + ')');
|
||||
} else {
|
||||
// No thumbnail, show cloud download icon
|
||||
View.$(el, '.projects-item-cloud-download').show();
|
||||
@@ -302,6 +320,12 @@ export class ProjectsView extends View {
|
||||
|
||||
this.$(projectItemsSelector).append(el);
|
||||
}
|
||||
|
||||
// Trigger background runtime detection for all projects
|
||||
this.projectsModel.detectAllProjectRuntimes().then(() => {
|
||||
// Re-render after detection completes (if any legacy projects found)
|
||||
// The on('runtimeDetected') listener handles this
|
||||
});
|
||||
}
|
||||
|
||||
renderTutorialItems() {
|
||||
@@ -633,6 +657,107 @@ export class ProjectsView extends View {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user clicks "Migrate Project" on a legacy project card.
|
||||
* Opens the migration wizard dialog.
|
||||
*/
|
||||
onMigrateProjectClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
|
||||
evt.stopPropagation();
|
||||
|
||||
const projectPath = scope.project.retainedProjectDirectory;
|
||||
if (!projectPath) {
|
||||
ToastLayer.showError('Cannot migrate project: path not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the migration wizard as a dialog
|
||||
DialogLayerModel.instance.showDialog(
|
||||
(close) =>
|
||||
React.createElement(MigrationWizard, {
|
||||
sourcePath: projectPath,
|
||||
projectName: scope.project.name,
|
||||
onComplete: async (targetPath: string) => {
|
||||
close();
|
||||
// Clear runtime cache for the source project
|
||||
this.projectsModel.clearRuntimeCache(projectPath);
|
||||
|
||||
// Show activity indicator
|
||||
const activityId = 'opening-migrated-project';
|
||||
ToastLayer.showActivity('Opening migrated project', activityId);
|
||||
|
||||
try {
|
||||
// Open the migrated project from the target path
|
||||
const project = await this.projectsModel.openProjectFromFolder(targetPath);
|
||||
|
||||
if (!project.name) {
|
||||
project.name = scope.project.name + ' (React 19)';
|
||||
}
|
||||
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showSuccess('Project migrated successfully!');
|
||||
|
||||
// Open the migrated project
|
||||
this.notifyListeners('projectLoaded', project);
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Project migrated but could not open automatically. Check your projects list.');
|
||||
console.error('Failed to open migrated project:', error);
|
||||
// Refresh project list anyway
|
||||
this.projectsModel.fetch();
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
close();
|
||||
}
|
||||
}),
|
||||
{
|
||||
onClose: () => {
|
||||
// Refresh project list when dialog closes
|
||||
this.projectsModel.fetch();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
tracker.track('Migration Wizard Opened', {
|
||||
projectName: scope.project.name
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user clicks "Open Read-Only" on a legacy project card.
|
||||
* Opens the project in read-only mode without migration.
|
||||
* Note: The project will open normally; legacy banner display
|
||||
* will be handled by the EditorBanner component based on runtime detection.
|
||||
*/
|
||||
async onOpenReadOnlyClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
|
||||
evt.stopPropagation();
|
||||
|
||||
const activityId = 'opening-project-readonly';
|
||||
ToastLayer.showActivity('Opening project in read-only mode', activityId);
|
||||
|
||||
try {
|
||||
const project = await this.projectsModel.loadProject(scope.project);
|
||||
ToastLayer.hideActivity(activityId);
|
||||
|
||||
if (!project) {
|
||||
ToastLayer.showError("Couldn't load project.");
|
||||
return;
|
||||
}
|
||||
|
||||
tracker.track('Legacy Project Opened Read-Only', {
|
||||
projectName: scope.project.name
|
||||
});
|
||||
|
||||
// Open the project - the EditorBanner will detect legacy runtime
|
||||
// and display a warning banner automatically
|
||||
this.notifyListeners('projectLoaded', project);
|
||||
} catch (error) {
|
||||
ToastLayer.hideActivity(activityId);
|
||||
ToastLayer.showError('Could not open project');
|
||||
console.error('Failed to open legacy project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Import a project from a URL
|
||||
importFromUrl(uri) {
|
||||
// Extract and remove query from url
|
||||
|
||||
@@ -6,6 +6,15 @@ const URL = require('url');
|
||||
|
||||
var port = process.env.NOODL_CLOUD_FUNCTIONS_PORT || 8577;
|
||||
|
||||
// Safe console.log wrapper to prevent EPIPE errors when stdout is broken
|
||||
function safeLog(...args) {
|
||||
try {
|
||||
console.log(...args);
|
||||
} catch (e) {
|
||||
// Ignore EPIPE errors - stdout pipe may be broken
|
||||
}
|
||||
}
|
||||
|
||||
function guid() {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
@@ -30,7 +39,7 @@ function openCloudRuntimeDevTools() {
|
||||
mode: 'detach'
|
||||
});
|
||||
} else {
|
||||
console.log('No cloud sandbox active');
|
||||
safeLog('No cloud sandbox active');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +71,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
show: false
|
||||
});
|
||||
|
||||
console.log('starting cloud runtime');
|
||||
safeLog('starting cloud runtime');
|
||||
|
||||
hasLoadedProject = false;
|
||||
|
||||
@@ -103,9 +112,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
headers: args.headers
|
||||
};
|
||||
|
||||
console.log('noodl-cf-fetch');
|
||||
console.log(_options);
|
||||
console.log(args.body);
|
||||
safeLog('noodl-cf-fetch');
|
||||
safeLog(_options);
|
||||
safeLog(args.body);
|
||||
|
||||
const httpx = url.protocol === 'https:' ? https : http;
|
||||
const req = httpx.request(_options, (res) => {
|
||||
@@ -120,13 +129,13 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
body: _data,
|
||||
status: res.statusCode
|
||||
};
|
||||
console.log('response', _response);
|
||||
safeLog('response', _response);
|
||||
sandbox.webContents.send('noodl-cf-fetch-response', _response);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log('error', error);
|
||||
safeLog('error', error);
|
||||
sandbox.webContents.send('noodl-cf-fetch-response', {
|
||||
token,
|
||||
error: error
|
||||
@@ -161,9 +170,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
if (path.startsWith('/functions/')) {
|
||||
const functionName = decodeURIComponent(path.split('/')[2]);
|
||||
|
||||
console.log('Calling cloud function: ' + functionName);
|
||||
safeLog('Calling cloud function: ' + functionName);
|
||||
if (!sandbox) {
|
||||
console.log('Error: No cloud runtime active...');
|
||||
safeLog('Error: No cloud runtime active...');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -184,7 +193,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('with body ', body);
|
||||
safeLog('with body ', body);
|
||||
|
||||
const token = guid();
|
||||
_responseHandlers[token] = (args) => {
|
||||
@@ -205,7 +214,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
safeLog(e);
|
||||
|
||||
response.writeHead(400, headers);
|
||||
response.end(JSON.stringify({ error: 'Failed to run function.' }));
|
||||
@@ -218,7 +227,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
|
||||
var server;
|
||||
if (process.env.ssl) {
|
||||
console.log('Using SSL');
|
||||
safeLog('Using SSL');
|
||||
|
||||
const options = {
|
||||
key: fs.readFileSync(process.env.sslKey),
|
||||
@@ -244,8 +253,8 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
});
|
||||
});
|
||||
|
||||
server.on('listening', (e) => {
|
||||
console.log('noodl cloud functions server running on port', port);
|
||||
server.on('listening', () => {
|
||||
safeLog('noodl cloud functions server running on port', port);
|
||||
process.env.NOODL_CLOUD_FUNCTIONS_PORT = port;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# The Noodl Starter Template
|
||||
|
||||
## Overview
|
||||
|
||||
The Noodl Starter Template is a community project aimed at helping Noodl builders start new apps faster. The template contains a variety of different visual elements and logic flows. You can cherry pick the parts you need for your own app, or use the template as a boiler plate.
|
||||
|
||||
## Features
|
||||
|
||||
* Log in / Sign up workflows
|
||||
* Reset password workflow (Sendgrid API)
|
||||
* Header top bar
|
||||
* Tabs
|
||||
* Collapsable menu
|
||||
* File uploader
|
||||
* Profile button with floating menu
|
||||
|
||||
## Installation
|
||||
|
||||
* Download the repository contents to a project folder on your local machine.
|
||||
* Open your Noodl editor
|
||||
* Click 'Open Folder'
|
||||
* Select the folder where you placed the repository contents
|
||||
* Connect a Noodl Cloud Services back end (to use the native Noodl cloud data nodes included in the template)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"name":"Noodl Starter Template","components":[{"name":"/#__cloud__/SendGrid/Send Email","id":"55e43c55-c5ec-c1bb-10ea-fdd520e6dc28","graph":{"connections":[{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Do","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"run"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Text","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-Text"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Html","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-Html"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"To","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-To"},{"fromId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","fromProperty":"out-Success","toId":"10a94c4f-0c3e-5250-70f2-5bd02a335402","toProperty":"Success"},{"fromId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","fromProperty":"out-Failure","toId":"10a94c4f-0c3e-5250-70f2-5bd02a335402","toProperty":"Failure"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"From","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-From"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Subject","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-Subject"},{"fromId":"3efa1bbb-61fa-71ac-931a-cb900841f03c","fromProperty":"API Key","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-APIKey"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"CC","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-CC"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"BCC","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-BCC"}],"roots":[{"id":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","type":"Component Inputs","x":-312,"y":-62,"parameters":{},"ports":[{"name":"Do","plug":"output","type":"*","index":0},{"name":"Text","plug":"output","type":{"name":"*"},"index":1},{"name":"Html","plug":"output","type":{"name":"*"},"index":2}
|
||||
Reference in New Issue
Block a user