mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +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
|
## Template for Future Entries
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
|
|||||||
@@ -2,6 +2,388 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Session 1: Foundation + Detection
|
||||||
|
|
||||||
#### 2024-12-13
|
#### 2024-12-13
|
||||||
|
|||||||
@@ -10,20 +10,29 @@
|
|||||||
- [x] Create index.ts module exports
|
- [x] Create index.ts module exports
|
||||||
|
|
||||||
## Session 2: Wizard UI (Basic Flow)
|
## Session 2: Wizard UI (Basic Flow)
|
||||||
- [ ] MigrationWizard.tsx container
|
- [x] MigrationWizard.tsx container
|
||||||
- [ ] ConfirmStep.tsx component
|
- [x] WizardProgress.tsx component
|
||||||
- [ ] ScanningStep.tsx component
|
- [x] ConfirmStep.tsx component
|
||||||
- [ ] ReportStep.tsx component
|
- [x] ScanningStep.tsx component
|
||||||
- [ ] CompleteStep.tsx component
|
- [x] ReportStep.tsx component
|
||||||
- [ ] MigrationExecutor.ts (project copy + basic fixes)
|
- [x] CompleteStep.tsx component
|
||||||
- [ ] DialogLayerModel integration for showing wizard
|
- [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
|
## Session 3: Projects View Integration
|
||||||
- [ ] Update projectsview.ts to detect and show legacy badges
|
- [x] DialogLayerModel.showDialog() generic method
|
||||||
- [ ] Add "Migrate Project" button to project cards
|
- [x] LocalProjectsModel runtime detection with cache
|
||||||
- [ ] Add "Open Read-Only" button to project cards
|
- [x] Update projectsview.html template with legacy badges
|
||||||
- [ ] Create EditorBanner.tsx for read-only mode warning
|
- [x] Add CSS styles for legacy project indicators
|
||||||
- [ ] Wire open project flow to detect legacy projects
|
- [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
|
## Session 4: AI Migration + Polish
|
||||||
- [ ] claudeClient.ts (Anthropic API integration)
|
- [ ] 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);
|
background-color: var(--background);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
pointer-events: none; // Allow clicks to pass through to content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export type DialogLayerOptions = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShowDialogOptions = DialogLayerOptions & {
|
||||||
|
/** Called when the dialog is closed */
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerModelEvents> {
|
export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerModelEvents> {
|
||||||
public static instance = new DialogLayerModel();
|
public static instance = new DialogLayerModel();
|
||||||
|
|
||||||
@@ -84,4 +89,40 @@ export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerMo
|
|||||||
};
|
};
|
||||||
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
|
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
|
* @since 1.2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { filesystem } from '@noodl/platform';
|
||||||
|
|
||||||
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
|
||||||
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
|
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
|
||||||
import {
|
import {
|
||||||
@@ -409,27 +411,72 @@ export class MigrationSessionManager extends EventDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async executeCopyPhase(): Promise<void> {
|
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.updateProgress({ phase: 'copying', current: 0 });
|
||||||
this.addLogEntry({
|
this.addLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
message: 'Creating project copy...'
|
message: `Copying project from ${sourcePath} to ${targetPath}...`
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Implement actual file copying using filesystem
|
try {
|
||||||
// For now, this is a placeholder
|
// Check if target already exists
|
||||||
|
const targetExists = await filesystem.exists(targetPath);
|
||||||
await this.simulateDelay(500);
|
if (targetExists) {
|
||||||
|
throw new Error(`Target directory already exists: ${targetPath}`);
|
||||||
if (this.session) {
|
|
||||||
this.session.target.copied = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create target directory
|
||||||
|
await filesystem.makeDirectory(targetPath);
|
||||||
|
|
||||||
|
// Copy all files recursively
|
||||||
|
await this.copyDirectoryRecursive(sourcePath, targetPath);
|
||||||
|
|
||||||
|
this.session.target.copied = true;
|
||||||
|
|
||||||
this.addLogEntry({
|
this.addLogEntry({
|
||||||
level: 'success',
|
level: 'success',
|
||||||
message: 'Project copied successfully'
|
message: 'Project copied successfully'
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateProgress({ current: 1 });
|
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> {
|
private async executeAutomaticPhase(): Promise<void> {
|
||||||
@@ -493,14 +540,47 @@ export class MigrationSessionManager extends EventDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async executeFinalizePhase(): Promise<void> {
|
private async executeFinalizePhase(): Promise<void> {
|
||||||
|
if (!this.session) return;
|
||||||
|
|
||||||
this.updateProgress({ phase: 'finalizing' });
|
this.updateProgress({ phase: 'finalizing' });
|
||||||
this.addLogEntry({
|
this.addLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
message: 'Finalizing migration...'
|
message: 'Finalizing migration...'
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Update project.json with migration metadata
|
try {
|
||||||
await this.simulateDelay(200);
|
// 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({
|
this.addLogEntry({
|
||||||
level: 'success',
|
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 {
|
return {
|
||||||
version: 'unknown',
|
version: 'react17',
|
||||||
confidence: 'low',
|
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;
|
overflow: hidden overlay;
|
||||||
max-height: calc(100vh - 180px);
|
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">
|
<div class="projects-main">
|
||||||
<!-- project item template -->
|
<!-- 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-thumb" style="position:absolute; left:0px; top:0px; width:100%; bottom:70px;">
|
||||||
<div class="projects-item-cloud-download" style="width:100%; height:100%;">
|
<div class="projects-item-cloud-download" style="width:100%; height:100%;">
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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;"
|
<div class="projects-item-label" style="position:absolute; bottom:36px; left:10px; right:10px;"
|
||||||
data-click="onRenameProjectClicked">
|
data-click="onRenameProjectClicked">
|
||||||
<span data-text="label" data-test="project-card-label"></span>
|
<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">
|
<img class="projects-remove-icon" src="../assets/images/sharp-clear-24px.svg">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- tutorial item template, guides etc (not lessons) -->
|
<!-- tutorial item template, guides etc (not lessons) -->
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { templateRegistry } from '@noodl-utils/forge';
|
|||||||
|
|
||||||
import Model from '../../../shared/model';
|
import Model from '../../../shared/model';
|
||||||
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
||||||
|
import { RuntimeVersionInfo } from '../models/migration/types';
|
||||||
|
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
||||||
import FileSystem from './filesystem';
|
import FileSystem from './filesystem';
|
||||||
import { tracker } from './tracker';
|
import { tracker } from './tracker';
|
||||||
import { guid } from './utils';
|
import { guid } from './utils';
|
||||||
@@ -25,6 +27,14 @@ export interface ProjectItem {
|
|||||||
thumbURI: string;
|
thumbURI: string;
|
||||||
retainedProjectDirectory: 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 {
|
export class LocalProjectsModel extends Model {
|
||||||
public static instance = new LocalProjectsModel();
|
public static instance = new LocalProjectsModel();
|
||||||
|
|
||||||
@@ -34,6 +44,17 @@ export class LocalProjectsModel extends Model {
|
|||||||
name: 'recently_opened_project'
|
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() {
|
async fetch() {
|
||||||
// Fetch projects from local storage and verify project folders
|
// Fetch projects from local storage and verify project folders
|
||||||
const folders = (this.recentProjectsStore.get('recentProjects') || []) as ProjectItem[];
|
const folders = (this.recentProjectsStore.get('recentProjects') || []) as ProjectItem[];
|
||||||
@@ -299,4 +320,128 @@ export class LocalProjectsModel extends Model {
|
|||||||
|
|
||||||
setRequestGitAccount(func);
|
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 { filesystem, platform } from '@noodl/platform';
|
||||||
|
|
||||||
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
|
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
|
||||||
import { LessonsProjectsModel } from '@noodl-models/LessonsProjectModel';
|
import { LessonsProjectsModel } from '@noodl-models/LessonsProjectModel';
|
||||||
import { CloudServiceMetadata } from '@noodl-models/projectmodel';
|
import { CloudServiceMetadata } from '@noodl-models/projectmodel';
|
||||||
import { setCloudServices } from '@noodl-models/projectmodel.editor';
|
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 View from '../../../shared/view';
|
||||||
import LessonTemplatesModel from '../models/lessontemplatesmodel';
|
import LessonTemplatesModel from '../models/lessontemplatesmodel';
|
||||||
@@ -29,6 +33,10 @@ type ProjectItemScope = {
|
|||||||
project: ProjectItem;
|
project: ProjectItem;
|
||||||
label: string;
|
label: string;
|
||||||
latestAccessedTimeAgo: 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 {
|
export class ProjectsView extends View {
|
||||||
@@ -112,6 +120,9 @@ export class ProjectsView extends View {
|
|||||||
|
|
||||||
this.projectsModel.on('myProjectsChanged', () => this.renderProjectItemsPane(), this);
|
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.switchPane('projects');
|
||||||
|
|
||||||
// this.$('#top-bar').css({ height: this.topBarHeight + 'px' });
|
// this.$('#top-bar').css({ height: this.topBarHeight + 'px' });
|
||||||
@@ -274,27 +285,34 @@ export class ProjectsView extends View {
|
|||||||
}) {
|
}) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
|
||||||
const items = options.items;
|
const projectItems = options.items || [];
|
||||||
const projectItemsSelector = options.appendProjectItemsTo || '.projects-items';
|
const projectItemsSelector = options.appendProjectItemsTo || '.projects-items';
|
||||||
const template = options.template || 'projects-item';
|
const template = options.template || 'projects-item';
|
||||||
this.$(projectItemsSelector).html('');
|
this.$(projectItemsSelector).html('');
|
||||||
|
|
||||||
for (const i in items) {
|
for (const item of projectItems) {
|
||||||
const label = items[i].name;
|
const label = item.name;
|
||||||
if (options.filter && label.toLowerCase().indexOf(options.filter) === -1) continue;
|
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 = {
|
const scope: ProjectItemScope = {
|
||||||
project: items[i],
|
project: item,
|
||||||
label: label,
|
label: label,
|
||||||
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago'
|
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago',
|
||||||
|
isLegacy,
|
||||||
|
isDetecting
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = this.bindView(this.cloneTemplate(template), scope);
|
const el = this.bindView(this.cloneTemplate(template), scope);
|
||||||
if (items[i].thumbURI) {
|
if (item.thumbURI) {
|
||||||
// Set the thumbnail image if there is one
|
// 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 {
|
} else {
|
||||||
// No thumbnail, show cloud download icon
|
// No thumbnail, show cloud download icon
|
||||||
View.$(el, '.projects-item-cloud-download').show();
|
View.$(el, '.projects-item-cloud-download').show();
|
||||||
@@ -302,6 +320,12 @@ export class ProjectsView extends View {
|
|||||||
|
|
||||||
this.$(projectItemsSelector).append(el);
|
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() {
|
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
|
// Import a project from a URL
|
||||||
importFromUrl(uri) {
|
importFromUrl(uri) {
|
||||||
// Extract and remove query from url
|
// Extract and remove query from url
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ const URL = require('url');
|
|||||||
|
|
||||||
var port = process.env.NOODL_CLOUD_FUNCTIONS_PORT || 8577;
|
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 guid() {
|
||||||
function s4() {
|
function s4() {
|
||||||
return Math.floor((1 + Math.random()) * 0x10000)
|
return Math.floor((1 + Math.random()) * 0x10000)
|
||||||
@@ -30,7 +39,7 @@ function openCloudRuntimeDevTools() {
|
|||||||
mode: 'detach'
|
mode: 'detach'
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('No cloud sandbox active');
|
safeLog('No cloud sandbox active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +71,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
show: false
|
show: false
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('starting cloud runtime');
|
safeLog('starting cloud runtime');
|
||||||
|
|
||||||
hasLoadedProject = false;
|
hasLoadedProject = false;
|
||||||
|
|
||||||
@@ -103,9 +112,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
headers: args.headers
|
headers: args.headers
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('noodl-cf-fetch');
|
safeLog('noodl-cf-fetch');
|
||||||
console.log(_options);
|
safeLog(_options);
|
||||||
console.log(args.body);
|
safeLog(args.body);
|
||||||
|
|
||||||
const httpx = url.protocol === 'https:' ? https : http;
|
const httpx = url.protocol === 'https:' ? https : http;
|
||||||
const req = httpx.request(_options, (res) => {
|
const req = httpx.request(_options, (res) => {
|
||||||
@@ -120,13 +129,13 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
body: _data,
|
body: _data,
|
||||||
status: res.statusCode
|
status: res.statusCode
|
||||||
};
|
};
|
||||||
console.log('response', _response);
|
safeLog('response', _response);
|
||||||
sandbox.webContents.send('noodl-cf-fetch-response', _response);
|
sandbox.webContents.send('noodl-cf-fetch-response', _response);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on('error', (error) => {
|
req.on('error', (error) => {
|
||||||
console.log('error', error);
|
safeLog('error', error);
|
||||||
sandbox.webContents.send('noodl-cf-fetch-response', {
|
sandbox.webContents.send('noodl-cf-fetch-response', {
|
||||||
token,
|
token,
|
||||||
error: error
|
error: error
|
||||||
@@ -161,9 +170,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
if (path.startsWith('/functions/')) {
|
if (path.startsWith('/functions/')) {
|
||||||
const functionName = decodeURIComponent(path.split('/')[2]);
|
const functionName = decodeURIComponent(path.split('/')[2]);
|
||||||
|
|
||||||
console.log('Calling cloud function: ' + functionName);
|
safeLog('Calling cloud function: ' + functionName);
|
||||||
if (!sandbox) {
|
if (!sandbox) {
|
||||||
console.log('Error: No cloud runtime active...');
|
safeLog('Error: No cloud runtime active...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +193,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('with body ', body);
|
safeLog('with body ', body);
|
||||||
|
|
||||||
const token = guid();
|
const token = guid();
|
||||||
_responseHandlers[token] = (args) => {
|
_responseHandlers[token] = (args) => {
|
||||||
@@ -205,7 +214,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
|
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
safeLog(e);
|
||||||
|
|
||||||
response.writeHead(400, headers);
|
response.writeHead(400, headers);
|
||||||
response.end(JSON.stringify({ error: 'Failed to run function.' }));
|
response.end(JSON.stringify({ error: 'Failed to run function.' }));
|
||||||
@@ -218,7 +227,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
|
|
||||||
var server;
|
var server;
|
||||||
if (process.env.ssl) {
|
if (process.env.ssl) {
|
||||||
console.log('Using SSL');
|
safeLog('Using SSL');
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
key: fs.readFileSync(process.env.sslKey),
|
key: fs.readFileSync(process.env.sslKey),
|
||||||
@@ -244,8 +253,8 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('listening', (e) => {
|
server.on('listening', () => {
|
||||||
console.log('noodl cloud functions server running on port', port);
|
safeLog('noodl cloud functions server running on port', port);
|
||||||
process.env.NOODL_CLOUD_FUNCTIONS_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