mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
feat(phase-10): STRUCT-002 export engine core
Task: STRUCT-002 Branch: cline-dev-dishant Cross-branch notes: none no shared dependencies with Richard's phase 9/6 work - ProjectExporter class (pure, filesystem-agnostic) - Converts legacy project.json to v2 multi-file format - Outputs: nodegx.project.json, nodegx.routes.json, nodegx.styles.json, components/_registry.json, per-component component.json/nodes.json/connections.json - Helper exports: legacyNameToPath, inferComponentType, flattenNodes, countNodes - Full legacy type definitions (LegacyProject, LegacyComponent, LegacyNode, etc.) - 50 unit tests in tests/io/ProjectExporter.test.ts - Registered in tests/index.ts
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Phase 10A — AI-Powered Development: Dishant's Progress
|
||||
# Phase 10A AI-Powered Development: Dishant's Progress
|
||||
|
||||
**Developer:** Dishant
|
||||
**Branch:** `cline-dev-dishant`
|
||||
**Last Updated:** 2026-02-18
|
||||
**Last Updated:** 2026-02-19
|
||||
|
||||
---
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
| Task ID | Title | Status | Notes |
|
||||
|------------|------------------------------|-------------|--------------------------------------------|
|
||||
| STRUCT-001 | JSON Schema Definition | ✅ Complete | 8 schemas + validator + tests (33/33 pass) |
|
||||
| STRUCT-002 | Export Engine Core | 🔜 Next | Depends on STRUCT-001 |
|
||||
| STRUCT-001 | JSON Schema Definition | Complete | 8 schemas + validator + tests (33/33 pass) |
|
||||
| STRUCT-002 | Export Engine Core | Complete | ProjectExporter + 50 unit tests |
|
||||
|
||||
---
|
||||
|
||||
## STRUCT-001 — JSON Schema Definition ✅
|
||||
## STRUCT-001 JSON Schema Definition
|
||||
|
||||
**Completed:** 2026-02-18
|
||||
**Scope:** Define JSON schemas for the v2 multi-file project format
|
||||
@@ -39,14 +39,14 @@ All files live under `packages/noodl-editor/src/editor/src/schemas/`:
|
||||
|
||||
#### Validator (`validator.ts`)
|
||||
|
||||
- `SchemaValidator` singleton class — compiles all 8 schemas once, reuses validators
|
||||
- `SCHEMA_IDS` const — typed schema ID map
|
||||
- `SchemaValidator` singleton class compiles all 8 schemas once, reuses validators
|
||||
- `SCHEMA_IDS` const typed schema ID map
|
||||
- `validateSchema()` convenience function
|
||||
- `validateOrThrow()` — throws with context on failure
|
||||
- `validateOrThrow()` throws with context on failure
|
||||
- Per-schema convenience methods: `validateProject()`, `validateComponent()`, etc.
|
||||
- `formatValidationErrors()` — human-readable error formatting
|
||||
- `formatValidationErrors()` human-readable error formatting
|
||||
- Ajv v8 with `ajv-formats` for `date-time` format validation
|
||||
- `allErrors: true` — collects all errors, not just first
|
||||
- `allErrors: true` collects all errors, not just first
|
||||
|
||||
#### Index (`index.ts`)
|
||||
|
||||
@@ -59,7 +59,7 @@ All files live under `packages/noodl-editor/src/editor/src/schemas/`:
|
||||
- Valid minimal fixtures, full fixtures with all optional fields
|
||||
- Invalid cases: missing required fields, wrong enum values, invalid formats
|
||||
- Edge cases: legacy component refs (`/#Header`), complex port type objects, deeply nested metadata
|
||||
- Registered in `tests/index.ts` → `tests/schemas/index.ts`
|
||||
- Registered in `tests/index.ts` `tests/schemas/index.ts`
|
||||
|
||||
### Dependencies added
|
||||
|
||||
@@ -72,10 +72,10 @@ Added to `packages/noodl-editor/package.json` dependencies.
|
||||
|
||||
### Key design decisions
|
||||
|
||||
1. **`additionalProperties: true` on nodes/connections** — node parameters and connection metadata are open-ended by design; the schema validates structure, not content
|
||||
2. **Port type is `oneOf [string, object]`** — Noodl uses both `"string"` and `{ name: "stringlist", ... }` type formats
|
||||
3. **`strict: false` on Ajv** — schemas use `description` in `definitions` which Ajv strict mode rejects
|
||||
4. **`require()` for `ajv-formats`** — avoids TS type conflict between root-level `ajv-formats` (which bundles its own Ajv) and the package-local Ajv v8
|
||||
1. **`additionalProperties: true` on nodes/connections** node parameters and connection metadata are open-ended by design; the schema validates structure, not content
|
||||
2. **Port type is `oneOf [string, object]`** Noodl uses both `"string"` and `{ name: "stringlist", ... }` type formats
|
||||
3. **`strict: false` on Ajv** schemas use `description` in `definitions` which Ajv strict mode rejects
|
||||
4. **`require()` for `ajv-formats`** avoids TS type conflict between root-level `ajv-formats` (which bundles its own Ajv) and the package-local Ajv v8
|
||||
|
||||
### Verification
|
||||
|
||||
@@ -86,7 +86,95 @@ Added to `packages/noodl-editor/package.json` dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Next: STRUCT-002 — Export Engine Core
|
||||
## STRUCT-002 Export Engine Core
|
||||
|
||||
**Unblocked by:** STRUCT-001 ✅
|
||||
**Goal:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure, using the schemas defined in STRUCT-001 for validation.
|
||||
**Completed:** 2026-02-19
|
||||
**Scope:** Build the engine that converts the legacy `project.json` format into the v2 multi-file directory structure
|
||||
|
||||
### What was built
|
||||
|
||||
All files:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/io/ProjectExporter.ts` | Core exporter class + legacy type definitions + helper functions |
|
||||
| `packages/noodl-editor/tests/io/ProjectExporter.test.ts` | 50 unit tests |
|
||||
| `packages/noodl-editor/tests/io/index.ts` | Test barrel export |
|
||||
|
||||
### Architecture
|
||||
|
||||
`ProjectExporter` is a **pure class** it does not touch the filesystem. It takes a `LegacyProject` object (the parsed `project.json`) and returns an `ExportResult` containing all files to write.
|
||||
|
||||
```
|
||||
ProjectExporter.export(legacyProject)
|
||||
ExportResult {
|
||||
files: ExportFile[], // { relativePath, content }[]
|
||||
stats: { totalComponents, totalNodes, totalConnections }
|
||||
}
|
||||
```
|
||||
|
||||
Output file layout:
|
||||
```
|
||||
nodegx.project.json project metadata
|
||||
nodegx.routes.json route definitions (if any in metadata)
|
||||
nodegx.styles.json global styles + variants (if any)
|
||||
components/
|
||||
_registry.json component index
|
||||
Header/
|
||||
component.json component metadata
|
||||
nodes.json flat node list (tree flattened)
|
||||
connections.json connection list
|
||||
Pages/Home/
|
||||
component.json
|
||||
nodes.json
|
||||
connections.json
|
||||
...
|
||||
```
|
||||
|
||||
### Key functions exported
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `legacyNameToPath(name)` | Converts `/#Header` `Header`, `/Pages/Home` `Pages/Home` |
|
||||
| `inferComponentType(name)` | Heuristic: root/page/cloud/visual from legacy name |
|
||||
| `flattenNodes(roots)` | Flattens recursive legacy node tree flat `NodeV2[]` with parent/children IDs |
|
||||
| `countNodes(roots)` | Counts all nodes including nested children |
|
||||
|
||||
### Key design decisions
|
||||
|
||||
1. **Pure / filesystem-agnostic** caller writes files; exporter just produces the data. Easy to test, easy to use in different contexts (Electron, Node, tests).
|
||||
2. **Node tree flattening** legacy format stores nodes as a recursive tree (children embedded). v2 format stores a flat array with `parent` and `children` (as ID arrays). `flattenNodes()` handles this.
|
||||
3. **Styles file is conditional** only produced when `metadata.styles` has content OR variants exist. Same for routes.
|
||||
4. **Legacy `stateParamaters` typo normalised** VariantModel.toJSON() uses `stateParamaters` (typo). The exporter maps this to `stateParameters` in the v2 format.
|
||||
5. **Metadata split** `styles` and `routes` are extracted from `project.metadata` into their own files; remaining metadata keys stay in `nodegx.project.json`.
|
||||
6. **Original legacy path preserved** `component.json` stores `path: "/#Header"` for round-trip fidelity (STRUCT-003 import engine will need this).
|
||||
7. **Empty fields omitted** nodes with empty `parameters: {}` don't get a `parameters` key in the output, keeping files lean.
|
||||
|
||||
### Tests
|
||||
|
||||
50 unit tests in `tests/io/ProjectExporter.test.ts` covering:
|
||||
|
||||
- `legacyNameToPath` 6 cases (slash stripping, hash stripping, cloud paths, nested paths)
|
||||
- `inferComponentType` 6 cases (root, cloud, page, case-insensitive, visual)
|
||||
- `countNodes` 4 cases (empty, single, recursive, multiple roots)
|
||||
- `flattenNodes` 11 cases (empty, single, nested, parent/child IDs, field omission, metadata)
|
||||
- `ProjectExporter.export()` 23 cases across:
|
||||
- Basic structure (always-present files, conditional files, stats)
|
||||
- Project file (name, version, runtimeVersion, settings, $schema, metadata stripping)
|
||||
- Routes file (conditional, content, non-array guard)
|
||||
- Styles file (conditional, colors, variant typo normalisation)
|
||||
- Component files (3 files per component, paths, IDs, type inference, node flattening, connections)
|
||||
- Registry (all components listed, path/type/nodeCount/connectionCount, stats, version)
|
||||
- ExportResult stats
|
||||
- Edge cases (no graph, empty graph, multiple components, cloud type, variant preservation)
|
||||
|
||||
### Registered in
|
||||
|
||||
- `tests/io/index.ts` `tests/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Next: STRUCT-003 Import Engine
|
||||
|
||||
**Unblocked by:** STRUCT-002
|
||||
**Goal:** Build the engine that converts the v2 multi-file format back to the legacy `project.json` format, with round-trip validation.
|
||||
|
||||
560
packages/noodl-editor/src/editor/src/io/ProjectExporter.ts
Normal file
560
packages/noodl-editor/src/editor/src/io/ProjectExporter.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* STRUCT-002 — Export Engine Core
|
||||
*
|
||||
* Converts a legacy monolithic project.json into the v2 multi-file directory
|
||||
* structure defined by the JSON schemas in STRUCT-001.
|
||||
*
|
||||
* Output layout:
|
||||
* <outputDir>/
|
||||
* nodegx.project.json ← project metadata
|
||||
* nodegx.routes.json ← route definitions (if any)
|
||||
* nodegx.styles.json ← global styles + variants
|
||||
* components/
|
||||
* _registry.json ← component index
|
||||
* <ComponentName>/
|
||||
* component.json ← component metadata + ports
|
||||
* nodes.json ← node graph
|
||||
* connections.json ← wiring
|
||||
*
|
||||
* @module noodl-editor/io/ProjectExporter
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProjectV2File,
|
||||
ComponentV2File,
|
||||
NodesV2File,
|
||||
ConnectionsV2File,
|
||||
RegistryV2File,
|
||||
RegistryComponentEntry,
|
||||
RoutesV2File,
|
||||
StylesV2File,
|
||||
NodeV2,
|
||||
ConnectionV2,
|
||||
PortDefinition
|
||||
} from '../schemas';
|
||||
|
||||
// ─── Legacy format types (what project.toJSON() produces) ─────────────────────
|
||||
|
||||
/** A single connection in the legacy graph format */
|
||||
export interface LegacyConnection {
|
||||
fromId: string;
|
||||
fromProperty: string;
|
||||
toId: string;
|
||||
toProperty: string;
|
||||
annotation?: 'Deleted' | 'Changed' | 'Created';
|
||||
}
|
||||
|
||||
/** A single node in the legacy graph format (recursive via children) */
|
||||
export interface LegacyNode {
|
||||
id: string;
|
||||
type: string;
|
||||
variant?: string;
|
||||
version?: number;
|
||||
label?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
stateParameters?: Record<string, Record<string, unknown>>;
|
||||
stateTransitions?: Record<string, Record<string, unknown>>;
|
||||
defaultStateTransitions?: Record<string, unknown>;
|
||||
ports?: unknown[];
|
||||
dynamicports?: unknown[];
|
||||
conflicts?: unknown[];
|
||||
children?: LegacyNode[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** The graph object inside a legacy component */
|
||||
export interface LegacyGraph {
|
||||
roots: LegacyNode[];
|
||||
connections: LegacyConnection[];
|
||||
visualRoots?: string[];
|
||||
comments?: unknown[];
|
||||
}
|
||||
|
||||
/** A single component in the legacy project.json */
|
||||
export interface LegacyComponent {
|
||||
name: string;
|
||||
id: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
graph: LegacyGraph;
|
||||
}
|
||||
|
||||
/** A variant entry in the legacy project.json */
|
||||
export interface LegacyVariant {
|
||||
name: string;
|
||||
typename: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
stateParamaters?: Record<string, Record<string, unknown>>; // note: legacy typo
|
||||
stateTransitions?: Record<string, Record<string, unknown>>;
|
||||
defaultStateTransitions?: Record<string, unknown>;
|
||||
conflicts?: unknown[];
|
||||
}
|
||||
|
||||
/** The full legacy project.json structure */
|
||||
export interface LegacyProject {
|
||||
name: string;
|
||||
version?: string;
|
||||
runtimeVersion?: 'react17' | 'react19';
|
||||
settings?: Record<string, unknown>;
|
||||
rootNodeId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
lesson?: unknown;
|
||||
variants?: LegacyVariant[];
|
||||
components: LegacyComponent[];
|
||||
}
|
||||
|
||||
// ─── Export result types ───────────────────────────────────────────────────────
|
||||
|
||||
/** A single file to be written as part of the export */
|
||||
export interface ExportFile {
|
||||
/** Relative path from the output directory root */
|
||||
relativePath: string;
|
||||
/** Parsed JSON content (caller serialises with JSON.stringify) */
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
/** The complete result of a ProjectExporter.export() call */
|
||||
export interface ExportResult {
|
||||
/** All files that should be written to disk */
|
||||
files: ExportFile[];
|
||||
/** Summary statistics */
|
||||
stats: {
|
||||
totalComponents: number;
|
||||
totalNodes: number;
|
||||
totalConnections: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a legacy component name (e.g. "/#Header", "/Pages/Home") to a
|
||||
* filesystem-safe directory name relative to the components/ folder.
|
||||
*
|
||||
* Rules:
|
||||
* - Strip leading "/"
|
||||
* - Strip leading "#" (root component marker)
|
||||
* - Replace remaining "/" with OS path separator (we use "/" for portability)
|
||||
* - Strip "__cloud__/" prefix used by cloud function components
|
||||
*
|
||||
* Examples:
|
||||
* "/#Header" → "Header"
|
||||
* "/%rootcomponent" → "%rootcomponent"
|
||||
* "/Pages/Home" → "Pages/Home"
|
||||
* "/#__cloud__/SendGrid/Send" → "__cloud__/SendGrid/Send"
|
||||
*/
|
||||
export function legacyNameToPath(legacyName: string): string {
|
||||
// Remove leading slash
|
||||
let path = legacyName.startsWith('/') ? legacyName.slice(1) : legacyName;
|
||||
// Remove leading # (root component marker in legacy format)
|
||||
if (path.startsWith('#')) {
|
||||
path = path.slice(1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers the component type from its legacy name and graph structure.
|
||||
*
|
||||
* Heuristics (in priority order):
|
||||
* 1. "/%rootcomponent" → 'root'
|
||||
* 2. Name contains "/Pages/" or starts with "Pages/" → 'page'
|
||||
* 3. Name contains "/__cloud__/" → 'cloud'
|
||||
* 4. Default → 'visual'
|
||||
*/
|
||||
export function inferComponentType(
|
||||
legacyName: string
|
||||
): 'root' | 'page' | 'visual' | 'logic' | 'cloud' {
|
||||
const lower = legacyName.toLowerCase();
|
||||
if (lower.includes('%rootcomponent')) return 'root';
|
||||
if (lower.includes('__cloud__')) return 'cloud';
|
||||
if (lower.includes('/pages/') || lower.startsWith('/pages/')) return 'page';
|
||||
return 'visual';
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a tree of legacy nodes (which embed children recursively) into a
|
||||
* flat array of NodeV2 objects, preserving parent/child relationships via IDs.
|
||||
*/
|
||||
export function flattenNodes(roots: LegacyNode[]): NodeV2[] {
|
||||
const result: NodeV2[] = [];
|
||||
|
||||
function visit(node: LegacyNode, parentId?: string): void {
|
||||
const childIds = (node.children ?? []).map((c) => c.id);
|
||||
|
||||
const v2Node: NodeV2 = {
|
||||
id: node.id,
|
||||
type: node.type
|
||||
};
|
||||
|
||||
// Only include optional fields when they have meaningful values
|
||||
if (node.label !== undefined) v2Node.label = node.label;
|
||||
if (node.x !== undefined) v2Node.x = node.x;
|
||||
if (node.y !== undefined) v2Node.y = node.y;
|
||||
if (node.variant !== undefined) v2Node.variant = node.variant;
|
||||
if (node.version !== undefined) v2Node.version = node.version;
|
||||
if (node.parameters && Object.keys(node.parameters).length > 0) {
|
||||
v2Node.parameters = node.parameters;
|
||||
}
|
||||
if (node.stateParameters && Object.keys(node.stateParameters).length > 0) {
|
||||
v2Node.stateParameters = node.stateParameters;
|
||||
}
|
||||
if (node.stateTransitions && Object.keys(node.stateTransitions).length > 0) {
|
||||
v2Node.stateTransitions = node.stateTransitions;
|
||||
}
|
||||
if (node.defaultStateTransitions && Object.keys(node.defaultStateTransitions).length > 0) {
|
||||
v2Node.defaultStateTransitions = node.defaultStateTransitions;
|
||||
}
|
||||
if (node.ports && (node.ports as unknown[]).length > 0) {
|
||||
v2Node.ports = node.ports as NodeV2['ports'];
|
||||
}
|
||||
if (node.dynamicports && (node.dynamicports as unknown[]).length > 0) {
|
||||
v2Node.dynamicports = node.dynamicports as NodeV2['dynamicports'];
|
||||
}
|
||||
if (node.conflicts && (node.conflicts as unknown[]).length > 0) {
|
||||
v2Node.conflicts = node.conflicts as NodeV2['conflicts'];
|
||||
}
|
||||
if (node.metadata && Object.keys(node.metadata).length > 0) {
|
||||
v2Node.metadata = node.metadata;
|
||||
}
|
||||
if (childIds.length > 0) v2Node.children = childIds;
|
||||
if (parentId !== undefined) v2Node.parent = parentId;
|
||||
|
||||
result.push(v2Node);
|
||||
|
||||
// Recurse into children
|
||||
for (const child of node.children ?? []) {
|
||||
visit(child, node.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
visit(root);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts all nodes in a legacy component graph (including nested children).
|
||||
*/
|
||||
export function countNodes(roots: LegacyNode[]): number {
|
||||
let count = 0;
|
||||
function visit(node: LegacyNode): void {
|
||||
count++;
|
||||
for (const child of node.children ?? []) visit(child);
|
||||
}
|
||||
for (const root of roots) visit(root);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts component ports from the legacy component metadata.
|
||||
* Legacy components don't store ports directly — they're derived at runtime
|
||||
* from nodes with `haveComponentPorts`. We preserve whatever is in metadata.
|
||||
*/
|
||||
function extractPorts(component: LegacyComponent): ComponentV2File['ports'] | undefined {
|
||||
// Legacy format doesn't serialise computed ports into component JSON.
|
||||
// If the component metadata has port info, preserve it.
|
||||
const meta = component.metadata;
|
||||
if (!meta) return undefined;
|
||||
|
||||
// Some components store port info in metadata under 'ports' key
|
||||
if (meta.ports && typeof meta.ports === 'object') {
|
||||
return meta.ports as ComponentV2File['ports'];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ─── ProjectExporter ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a legacy project.json object into the v2 multi-file format.
|
||||
*
|
||||
* This class is pure — it does not touch the filesystem. The caller receives
|
||||
* an `ExportResult` containing all files to write, and is responsible for
|
||||
* actually writing them (e.g. via `fs.writeFile` or the platform abstraction).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const exporter = new ProjectExporter();
|
||||
* const result = exporter.export(legacyProjectJson);
|
||||
*
|
||||
* for (const file of result.files) {
|
||||
* await fs.writeFile(
|
||||
* path.join(outputDir, file.relativePath),
|
||||
* JSON.stringify(file.content, null, 2),
|
||||
* 'utf-8'
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class ProjectExporter {
|
||||
/**
|
||||
* Exports a legacy project JSON object to the v2 multi-file format.
|
||||
*
|
||||
* @param project - The parsed legacy project.json object
|
||||
* @returns ExportResult containing all files to write and summary stats
|
||||
*/
|
||||
export(project: LegacyProject): ExportResult {
|
||||
const files: ExportFile[] = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// ── 1. Project metadata file ─────────────────────────────────────────────
|
||||
files.push({
|
||||
relativePath: 'nodegx.project.json',
|
||||
content: this.buildProjectFile(project, now)
|
||||
});
|
||||
|
||||
// ── 2. Routes file (extracted from metadata if present) ──────────────────
|
||||
const routesFile = this.buildRoutesFile(project);
|
||||
if (routesFile !== null) {
|
||||
files.push({
|
||||
relativePath: 'nodegx.routes.json',
|
||||
content: routesFile
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Styles file (colors, textStyles, variants) ────────────────────────
|
||||
const stylesFile = this.buildStylesFile(project);
|
||||
if (stylesFile !== null) {
|
||||
files.push({
|
||||
relativePath: 'nodegx.styles.json',
|
||||
content: stylesFile
|
||||
});
|
||||
}
|
||||
|
||||
// ── 4. Components ────────────────────────────────────────────────────────
|
||||
const registry: RegistryV2File = {
|
||||
$schema: 'https://opennoodl.dev/schemas/registry-v2.json',
|
||||
version: 1,
|
||||
lastUpdated: now,
|
||||
components: {},
|
||||
stats: {
|
||||
totalComponents: 0,
|
||||
totalNodes: 0,
|
||||
totalConnections: 0
|
||||
}
|
||||
};
|
||||
|
||||
let totalNodes = 0;
|
||||
let totalConnections = 0;
|
||||
|
||||
for (const component of project.components) {
|
||||
const componentPath = legacyNameToPath(component.name);
|
||||
const nodeCount = countNodes(component.graph?.roots ?? []);
|
||||
const connectionCount = (component.graph?.connections ?? []).length;
|
||||
|
||||
totalNodes += nodeCount;
|
||||
totalConnections += connectionCount;
|
||||
|
||||
// component.json
|
||||
files.push({
|
||||
relativePath: `components/${componentPath}/component.json`,
|
||||
content: this.buildComponentFile(component, now)
|
||||
});
|
||||
|
||||
// nodes.json
|
||||
files.push({
|
||||
relativePath: `components/${componentPath}/nodes.json`,
|
||||
content: this.buildNodesFile(component)
|
||||
});
|
||||
|
||||
// connections.json
|
||||
files.push({
|
||||
relativePath: `components/${componentPath}/connections.json`,
|
||||
content: this.buildConnectionsFile(component)
|
||||
});
|
||||
|
||||
// Registry entry
|
||||
const registryEntry: RegistryComponentEntry = {
|
||||
path: componentPath,
|
||||
type: inferComponentType(component.name),
|
||||
nodeCount,
|
||||
connectionCount,
|
||||
modified: now
|
||||
};
|
||||
|
||||
// Preserve created timestamp from metadata if available
|
||||
if (component.metadata?.created && typeof component.metadata.created === 'string') {
|
||||
registryEntry.created = component.metadata.created;
|
||||
}
|
||||
|
||||
registry.components[componentPath] = registryEntry;
|
||||
}
|
||||
|
||||
// Update registry stats
|
||||
registry.stats = {
|
||||
totalComponents: project.components.length,
|
||||
totalNodes,
|
||||
totalConnections
|
||||
};
|
||||
|
||||
// _registry.json
|
||||
files.push({
|
||||
relativePath: 'components/_registry.json',
|
||||
content: registry
|
||||
});
|
||||
|
||||
return {
|
||||
files,
|
||||
stats: {
|
||||
totalComponents: project.components.length,
|
||||
totalNodes,
|
||||
totalConnections
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private builders ──────────────────────────────────────────────────────
|
||||
|
||||
private buildProjectFile(project: LegacyProject, now: string): ProjectV2File {
|
||||
const file: ProjectV2File = {
|
||||
$schema: 'https://opennoodl.dev/schemas/project-v2.json',
|
||||
name: project.name,
|
||||
version: project.version ?? '4',
|
||||
nodegxVersion: '1.1.0',
|
||||
modified: now,
|
||||
structure: {
|
||||
componentsDir: 'components',
|
||||
assetsDir: 'assets'
|
||||
}
|
||||
};
|
||||
|
||||
if (project.runtimeVersion) {
|
||||
file.runtimeVersion = project.runtimeVersion;
|
||||
}
|
||||
|
||||
if (project.settings && Object.keys(project.settings).length > 0) {
|
||||
file.settings = project.settings as ProjectV2File['settings'];
|
||||
}
|
||||
|
||||
// Preserve non-styles, non-routes metadata
|
||||
if (project.metadata) {
|
||||
const { styles, routes, ...rest } = project.metadata as Record<string, unknown>;
|
||||
if (Object.keys(rest).length > 0) {
|
||||
file.metadata = rest;
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private buildRoutesFile(project: LegacyProject): RoutesV2File | null {
|
||||
const routes = (project.metadata as Record<string, unknown> | undefined)?.routes;
|
||||
if (!routes) return null;
|
||||
|
||||
// If routes is already an array of route objects, use it directly
|
||||
if (Array.isArray(routes)) {
|
||||
return {
|
||||
$schema: 'https://opennoodl.dev/schemas/routes-v2.json',
|
||||
version: 1,
|
||||
routes: routes as RoutesV2File['routes']
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildStylesFile(project: LegacyProject): StylesV2File | null {
|
||||
const metaStyles = (project.metadata as Record<string, unknown> | undefined)?.styles as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hasVariants = project.variants && project.variants.length > 0;
|
||||
const hasStyles = metaStyles && Object.keys(metaStyles).length > 0;
|
||||
|
||||
if (!hasStyles && !hasVariants) return null;
|
||||
|
||||
const file: StylesV2File = {
|
||||
$schema: 'https://opennoodl.dev/schemas/styles-v2.json',
|
||||
version: 1
|
||||
};
|
||||
|
||||
if (metaStyles) {
|
||||
if (metaStyles.colors && typeof metaStyles.colors === 'object') {
|
||||
file.colors = metaStyles.colors as Record<string, string>;
|
||||
}
|
||||
if (metaStyles.textStyles && typeof metaStyles.textStyles === 'object') {
|
||||
file.textStyles = metaStyles.textStyles as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
if (project.variants && project.variants.length > 0) {
|
||||
file.variants = project.variants.map((v) => ({
|
||||
name: v.name,
|
||||
typename: v.typename,
|
||||
parameters: v.parameters,
|
||||
// Note: legacy uses "stateParamaters" (typo) — we normalise here
|
||||
stateParameters: v.stateParamaters,
|
||||
stateTransitions: v.stateTransitions,
|
||||
defaultStateTransitions: v.defaultStateTransitions
|
||||
}));
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private buildComponentFile(component: LegacyComponent, now: string): ComponentV2File {
|
||||
const componentPath = legacyNameToPath(component.name);
|
||||
const localName = componentPath.split('/').pop() ?? componentPath;
|
||||
|
||||
const file: ComponentV2File = {
|
||||
$schema: 'https://opennoodl.dev/schemas/component-v2.json',
|
||||
id: component.id,
|
||||
name: localName,
|
||||
path: component.name, // preserve original legacy path for round-trip
|
||||
type: inferComponentType(component.name),
|
||||
modified: now
|
||||
};
|
||||
|
||||
// Preserve metadata fields as component settings
|
||||
if (component.metadata && Object.keys(component.metadata).length > 0) {
|
||||
file.metadata = component.metadata;
|
||||
}
|
||||
|
||||
// Extract ports if available in metadata
|
||||
const ports = extractPorts(component);
|
||||
if (ports) {
|
||||
file.ports = ports;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private buildNodesFile(component: LegacyComponent): NodesV2File {
|
||||
const nodes = flattenNodes(component.graph?.roots ?? []);
|
||||
|
||||
return {
|
||||
$schema: 'https://opennoodl.dev/schemas/nodes-v2.json',
|
||||
componentId: component.id,
|
||||
version: 1,
|
||||
nodes
|
||||
};
|
||||
}
|
||||
|
||||
private buildConnectionsFile(component: LegacyComponent): ConnectionsV2File {
|
||||
const legacyConnections = component.graph?.connections ?? [];
|
||||
|
||||
const connections: ConnectionV2[] = legacyConnections.map((c) => {
|
||||
const conn: ConnectionV2 = {
|
||||
fromId: c.fromId,
|
||||
fromProperty: c.fromProperty,
|
||||
toId: c.toId,
|
||||
toProperty: c.toProperty
|
||||
};
|
||||
if (c.annotation) conn.annotation = c.annotation;
|
||||
return conn;
|
||||
});
|
||||
|
||||
return {
|
||||
$schema: 'https://opennoodl.dev/schemas/connections-v2.json',
|
||||
componentId: component.id,
|
||||
version: 1,
|
||||
connections
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from './projectmerger';
|
||||
export * from './projectpatcher';
|
||||
export * from './utils';
|
||||
export * from './schemas';
|
||||
export * from './io';
|
||||
|
||||
448
packages/noodl-editor/tests/io/ProjectExporter.test.ts
Normal file
448
packages/noodl-editor/tests/io/ProjectExporter.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* ProjectExporter Tests -- STRUCT-002
|
||||
*
|
||||
* Tests for the export engine that converts legacy project.json
|
||||
* to the v2 multi-file directory structure.
|
||||
*/
|
||||
|
||||
import {
|
||||
ProjectExporter,
|
||||
legacyNameToPath,
|
||||
inferComponentType,
|
||||
flattenNodes,
|
||||
countNodes,
|
||||
LegacyProject,
|
||||
LegacyComponent,
|
||||
LegacyNode
|
||||
} from '../../src/editor/src/io/ProjectExporter';
|
||||
|
||||
// ---- Fixtures ----------------------------------------------------------------
|
||||
|
||||
const makeNode = (id: string, type = 'Group', overrides: Partial<LegacyNode> = {}): LegacyNode => ({
|
||||
id,
|
||||
type,
|
||||
x: 0,
|
||||
y: 0,
|
||||
parameters: {},
|
||||
children: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
const makeComponent = (name: string, overrides: Partial<LegacyComponent> = {}): LegacyComponent => ({
|
||||
name,
|
||||
id: 'comp_' + name.replace(/[^a-z0-9]/gi, '_'),
|
||||
graph: { roots: [], connections: [] },
|
||||
...overrides
|
||||
});
|
||||
|
||||
const makeProject = (overrides: Partial<LegacyProject> = {}): LegacyProject => ({
|
||||
name: 'Test Project',
|
||||
version: '4',
|
||||
components: [],
|
||||
variants: [],
|
||||
...overrides
|
||||
});
|
||||
|
||||
// ---- legacyNameToPath --------------------------------------------------------
|
||||
|
||||
describe('legacyNameToPath', () => {
|
||||
it('strips leading slash and hash', () => {
|
||||
expect(legacyNameToPath('/#Header')).toBe('Header');
|
||||
});
|
||||
it('strips leading slash only', () => {
|
||||
expect(legacyNameToPath('/Pages/Home')).toBe('Pages/Home');
|
||||
});
|
||||
it('handles root component marker', () => {
|
||||
expect(legacyNameToPath('/%rootcomponent')).toBe('%rootcomponent');
|
||||
});
|
||||
it('handles cloud component path', () => {
|
||||
expect(legacyNameToPath('/#__cloud__/SendGrid/Send')).toBe('__cloud__/SendGrid/Send');
|
||||
});
|
||||
it('handles name without leading slash', () => {
|
||||
expect(legacyNameToPath('Header')).toBe('Header');
|
||||
});
|
||||
it('handles nested path', () => {
|
||||
expect(legacyNameToPath('/Shared/Button')).toBe('Shared/Button');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- inferComponentType ------------------------------------------------------
|
||||
|
||||
describe('inferComponentType', () => {
|
||||
it('returns root for %rootcomponent', () => {
|
||||
expect(inferComponentType('/%rootcomponent')).toBe('root');
|
||||
});
|
||||
it('returns cloud for __cloud__ paths', () => {
|
||||
expect(inferComponentType('/#__cloud__/SendGrid/Send')).toBe('cloud');
|
||||
});
|
||||
it('returns page for /Pages/ paths', () => {
|
||||
expect(inferComponentType('/Pages/Home')).toBe('page');
|
||||
});
|
||||
it('returns page case-insensitively', () => {
|
||||
expect(inferComponentType('/pages/profile')).toBe('page');
|
||||
});
|
||||
it('returns visual for regular components', () => {
|
||||
expect(inferComponentType('/#Header')).toBe('visual');
|
||||
});
|
||||
it('returns visual for shared components', () => {
|
||||
expect(inferComponentType('/Shared/Button')).toBe('visual');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- countNodes --------------------------------------------------------------
|
||||
|
||||
describe('countNodes', () => {
|
||||
it('returns 0 for empty roots', () => {
|
||||
expect(countNodes([])).toBe(0);
|
||||
});
|
||||
it('counts a single root node', () => {
|
||||
expect(countNodes([makeNode('n1')])).toBe(1);
|
||||
});
|
||||
it('counts root + children recursively', () => {
|
||||
const root = makeNode('n1', 'Group', {
|
||||
children: [
|
||||
makeNode('n2', 'Text', { children: [makeNode('n3')] }),
|
||||
makeNode('n4')
|
||||
]
|
||||
});
|
||||
expect(countNodes([root])).toBe(4);
|
||||
});
|
||||
it('counts multiple roots', () => {
|
||||
expect(countNodes([makeNode('n1'), makeNode('n2'), makeNode('n3')])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- flattenNodes ------------------------------------------------------------
|
||||
|
||||
describe('flattenNodes', () => {
|
||||
it('returns empty array for no roots', () => {
|
||||
expect(flattenNodes([])).toEqual([]);
|
||||
});
|
||||
it('flattens a single root node', () => {
|
||||
const nodes = flattenNodes([makeNode('n1', 'Group')]);
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe('n1');
|
||||
expect(nodes[0].type).toBe('Group');
|
||||
});
|
||||
it('flattens nested children into flat array', () => {
|
||||
const root = makeNode('root', 'Group', {
|
||||
children: [makeNode('child1', 'Text'), makeNode('child2', 'Image')]
|
||||
});
|
||||
const nodes = flattenNodes([root]);
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes.map((n) => n.id)).toEqual(['root', 'child1', 'child2']);
|
||||
});
|
||||
it('sets parent id on child nodes', () => {
|
||||
const root = makeNode('root', 'Group', { children: [makeNode('child1')] });
|
||||
const nodes = flattenNodes([root]);
|
||||
const child = nodes.find((n) => n.id === 'child1');
|
||||
expect(child?.parent).toBe('root');
|
||||
});
|
||||
it('root nodes have no parent field', () => {
|
||||
const nodes = flattenNodes([makeNode('root')]);
|
||||
expect(nodes[0].parent).toBeUndefined();
|
||||
});
|
||||
it('sets children array of ids on parent node', () => {
|
||||
const root = makeNode('root', 'Group', { children: [makeNode('c1'), makeNode('c2')] });
|
||||
const nodes = flattenNodes([root]);
|
||||
const rootNode = nodes.find((n) => n.id === 'root');
|
||||
expect(rootNode?.children).toEqual(['c1', 'c2']);
|
||||
});
|
||||
it('omits empty parameters object', () => {
|
||||
const nodes = flattenNodes([makeNode('n1', 'Group', { parameters: {} })]);
|
||||
expect(nodes[0].parameters).toBeUndefined();
|
||||
});
|
||||
it('preserves non-empty parameters', () => {
|
||||
const nodes = flattenNodes([makeNode('n1', 'Group', { parameters: { layout: 'row' } })]);
|
||||
expect(nodes[0].parameters).toEqual({ layout: 'row' });
|
||||
});
|
||||
it('preserves stateParameters', () => {
|
||||
const nodes = flattenNodes([makeNode('n1', 'Group', { stateParameters: { hover: { opacity: 0.5 } } })]);
|
||||
expect(nodes[0].stateParameters).toEqual({ hover: { opacity: 0.5 } });
|
||||
});
|
||||
it('preserves metadata', () => {
|
||||
const nodes = flattenNodes([makeNode('n1', 'Group', { metadata: { comment: 'hello' } })]);
|
||||
expect(nodes[0].metadata).toEqual({ comment: 'hello' });
|
||||
});
|
||||
it('handles deeply nested children', () => {
|
||||
const deep = makeNode('l3');
|
||||
const mid = makeNode('l2', 'Group', { children: [deep] });
|
||||
const root = makeNode('l1', 'Group', { children: [mid] });
|
||||
const nodes = flattenNodes([root]);
|
||||
expect(nodes).toHaveLength(3);
|
||||
const l3 = nodes.find((n) => n.id === 'l3');
|
||||
expect(l3?.parent).toBe('l2');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- ProjectExporter ---------------------------------------------------------
|
||||
|
||||
describe('ProjectExporter', () => {
|
||||
let exporter: ProjectExporter;
|
||||
|
||||
beforeEach(() => {
|
||||
exporter = new ProjectExporter();
|
||||
});
|
||||
|
||||
describe('export() basic structure', () => {
|
||||
it('always produces nodegx.project.json', () => {
|
||||
const result = exporter.export(makeProject());
|
||||
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.project.json');
|
||||
});
|
||||
it('always produces components/_registry.json', () => {
|
||||
const result = exporter.export(makeProject());
|
||||
expect(result.files.map((f) => f.relativePath)).toContain('components/_registry.json');
|
||||
});
|
||||
it('does not produce routes file when no routes in metadata', () => {
|
||||
const result = exporter.export(makeProject());
|
||||
expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.routes.json');
|
||||
});
|
||||
it('does not produce styles file when no styles or variants', () => {
|
||||
const result = exporter.export(makeProject());
|
||||
expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.styles.json');
|
||||
});
|
||||
it('returns correct stats for empty project', () => {
|
||||
const result = exporter.export(makeProject());
|
||||
expect(result.stats).toEqual({ totalComponents: 0, totalNodes: 0, totalConnections: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodegx.project.json', () => {
|
||||
const getProjectFile = (project: LegacyProject) => {
|
||||
const result = exporter.export(project);
|
||||
return result.files.find((f) => f.relativePath === 'nodegx.project.json')?.content as any;
|
||||
};
|
||||
|
||||
it('includes project name', () => {
|
||||
expect(getProjectFile(makeProject({ name: 'My App' })).name).toBe('My App');
|
||||
});
|
||||
it('includes version', () => {
|
||||
expect(getProjectFile(makeProject({ version: '4' })).version).toBe('4');
|
||||
});
|
||||
it('includes runtimeVersion when set', () => {
|
||||
expect(getProjectFile(makeProject({ runtimeVersion: 'react19' })).runtimeVersion).toBe('react19');
|
||||
});
|
||||
it('omits runtimeVersion when not set', () => {
|
||||
expect(getProjectFile(makeProject()).runtimeVersion).toBeUndefined();
|
||||
});
|
||||
it('includes settings when present', () => {
|
||||
expect(getProjectFile(makeProject({ settings: { bodyScroll: true } })).settings).toEqual({ bodyScroll: true });
|
||||
});
|
||||
it('includes $schema field', () => {
|
||||
expect(getProjectFile(makeProject())['$schema']).toContain('project-v2');
|
||||
});
|
||||
it('strips styles and routes from metadata but keeps other keys', () => {
|
||||
const meta = getProjectFile(makeProject({
|
||||
metadata: { styles: { colors: {} }, routes: [], cloudservices: { id: 'abc' } }
|
||||
})).metadata;
|
||||
expect(meta?.styles).toBeUndefined();
|
||||
expect(meta?.routes).toBeUndefined();
|
||||
expect(meta?.cloudservices).toEqual({ id: 'abc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodegx.routes.json', () => {
|
||||
it('produces routes file when metadata.routes is an array', () => {
|
||||
const result = exporter.export(makeProject({ metadata: { routes: [{ path: '/home', component: 'pages/Home' }] } }));
|
||||
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.routes.json');
|
||||
});
|
||||
it('routes file contains the routes array', () => {
|
||||
const routes = [{ path: '/home', component: 'pages/Home' }];
|
||||
const result = exporter.export(makeProject({ metadata: { routes } }));
|
||||
const file = result.files.find((f) => f.relativePath === 'nodegx.routes.json')?.content as any;
|
||||
expect(file.routes).toEqual(routes);
|
||||
});
|
||||
it('does not produce routes file when metadata.routes is not an array', () => {
|
||||
const result = exporter.export(makeProject({ metadata: { routes: {} } }));
|
||||
expect(result.files.map((f) => f.relativePath)).not.toContain('nodegx.routes.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodegx.styles.json', () => {
|
||||
it('produces styles file when metadata.styles has colors', () => {
|
||||
const result = exporter.export(makeProject({ metadata: { styles: { colors: { primary: '#3B82F6' } } } }));
|
||||
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.styles.json');
|
||||
});
|
||||
it('produces styles file when variants exist', () => {
|
||||
const result = exporter.export(makeProject({ variants: [{ name: 'primary', typename: 'Button' }] }));
|
||||
expect(result.files.map((f) => f.relativePath)).toContain('nodegx.styles.json');
|
||||
});
|
||||
it('includes colors from metadata.styles', () => {
|
||||
const result = exporter.export(makeProject({ metadata: { styles: { colors: { primary: '#3B82F6' } } } }));
|
||||
const file = result.files.find((f) => f.relativePath === 'nodegx.styles.json')?.content as any;
|
||||
expect(file.colors).toEqual({ primary: '#3B82F6' });
|
||||
});
|
||||
it('normalises legacy stateParamaters typo to stateParameters', () => {
|
||||
const result = exporter.export(makeProject({
|
||||
variants: [{ name: 'primary', typename: 'Button', stateParamaters: { hover: { opacity: 0.8 } } }]
|
||||
}));
|
||||
const file = result.files.find((f) => f.relativePath === 'nodegx.styles.json')?.content as any;
|
||||
expect(file.variants[0].stateParameters).toEqual({ hover: { opacity: 0.8 } });
|
||||
expect(file.variants[0].stateParamaters).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('component files', () => {
|
||||
it('produces 3 files per component', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] }));
|
||||
const paths = result.files.map((f) => f.relativePath);
|
||||
expect(paths).toContain('components/Header/component.json');
|
||||
expect(paths).toContain('components/Header/nodes.json');
|
||||
expect(paths).toContain('components/Header/connections.json');
|
||||
});
|
||||
it('uses correct path for nested component', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] }));
|
||||
expect(result.files.map((f) => f.relativePath)).toContain('components/Pages/Home/component.json');
|
||||
});
|
||||
it('component.json has correct id', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.id = 'comp_header_123';
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Header/component.json')?.content as any;
|
||||
expect(file.id).toBe('comp_header_123');
|
||||
});
|
||||
it('component.json preserves original legacy path', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Header/component.json')?.content as any;
|
||||
expect(file.path).toBe('/#Header');
|
||||
});
|
||||
it('component.json infers correct type for page', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Pages/Home/component.json')?.content as any;
|
||||
expect(file.type).toBe('page');
|
||||
});
|
||||
it('nodes.json has componentId', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.id = 'comp_header_123';
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
||||
expect(file.componentId).toBe('comp_header_123');
|
||||
});
|
||||
it('nodes.json flattens nested nodes', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.roots = [makeNode('root', 'Group', { children: [makeNode('child', 'Text')] })];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
||||
expect(file.nodes).toHaveLength(2);
|
||||
});
|
||||
it('connections.json maps legacy connections correctly', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' }];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Header/connections.json')?.content as any;
|
||||
expect(file.connections).toHaveLength(1);
|
||||
expect(file.connections[0]).toEqual({ fromId: 'n1', fromProperty: 'onClick', toId: 'n2', toProperty: 'trigger' });
|
||||
});
|
||||
it('connections.json preserves annotation field', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in', annotation: 'Created' }];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/Header/connections.json')?.content as any;
|
||||
expect(file.connections[0].annotation).toBe('Created');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_registry.json', () => {
|
||||
it('lists all components', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/#Header'), makeComponent('/Pages/Home')] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(Object.keys(file.components)).toHaveLength(2);
|
||||
expect(file.components['Header']).toBeDefined();
|
||||
expect(file.components['Pages/Home']).toBeDefined();
|
||||
});
|
||||
it('registry entry has correct path', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/#Header')] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(file.components['Header'].path).toBe('Header');
|
||||
});
|
||||
it('registry entry has correct type', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/Pages/Home')] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(file.components['Pages/Home'].type).toBe('page');
|
||||
});
|
||||
it('registry entry has nodeCount', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.roots = [makeNode('n1'), makeNode('n2')];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(file.components['Header'].nodeCount).toBe(2);
|
||||
});
|
||||
it('registry entry has connectionCount', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.connections = [
|
||||
{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' },
|
||||
{ fromId: 'n2', fromProperty: 'out', toId: 'n3', toProperty: 'in' }
|
||||
];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(file.components['Header'].connectionCount).toBe(2);
|
||||
});
|
||||
it('registry stats are correct', () => {
|
||||
const comp1 = makeComponent('/#Header');
|
||||
comp1.graph.roots = [makeNode('n1')];
|
||||
comp1.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }];
|
||||
const comp2 = makeComponent('/Pages/Home');
|
||||
comp2.graph.roots = [makeNode('n2'), makeNode('n3')];
|
||||
const result = exporter.export(makeProject({ components: [comp1, comp2] }));
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(file.stats.totalComponents).toBe(2);
|
||||
expect(file.stats.totalNodes).toBe(3);
|
||||
expect(file.stats.totalConnections).toBe(1);
|
||||
});
|
||||
it('registry has version 1', () => {
|
||||
const result = exporter.export(makeProject());
|
||||
const file = result.files.find((f) => f.relativePath === 'components/_registry.json')?.content as any;
|
||||
expect(file.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExportResult stats', () => {
|
||||
it('stats match actual component/node/connection counts', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.roots = [makeNode('n1', 'Group', { children: [makeNode('n2')] })];
|
||||
comp.graph.connections = [{ fromId: 'n1', fromProperty: 'out', toId: 'n2', toProperty: 'in' }];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
expect(result.stats.totalComponents).toBe(1);
|
||||
expect(result.stats.totalNodes).toBe(2);
|
||||
expect(result.stats.totalConnections).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles component with no graph gracefully', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
(comp as any).graph = undefined;
|
||||
expect(() => exporter.export(makeProject({ components: [comp] }))).not.toThrow();
|
||||
});
|
||||
it('handles component with empty graph', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph = { roots: [], connections: [] };
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const nodesFile = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
||||
expect(nodesFile.nodes).toHaveLength(0);
|
||||
});
|
||||
it('handles multiple components correctly', () => {
|
||||
const project = makeProject({
|
||||
components: [makeComponent('/#Header'), makeComponent('/Pages/Home'), makeComponent('/Shared/Button')]
|
||||
});
|
||||
const result = exporter.export(project);
|
||||
expect(result.stats.totalComponents).toBe(3);
|
||||
const componentFiles = result.files.filter(
|
||||
(f) => f.relativePath.startsWith('components/') && !f.relativePath.endsWith('_registry.json')
|
||||
);
|
||||
expect(componentFiles).toHaveLength(9);
|
||||
});
|
||||
it('cloud component gets correct type', () => {
|
||||
const result = exporter.export(makeProject({ components: [makeComponent('/#__cloud__/SendGrid/Send')] }));
|
||||
const file = result.files.find((f) => f.relativePath.includes('component.json'))?.content as any;
|
||||
expect(file.type).toBe('cloud');
|
||||
});
|
||||
it('node with variant is preserved', () => {
|
||||
const comp = makeComponent('/#Header');
|
||||
comp.graph.roots = [makeNode('n1', 'Button', { variant: 'primary' })];
|
||||
const result = exporter.export(makeProject({ components: [comp] }));
|
||||
const nodesFile = result.files.find((f) => f.relativePath === 'components/Header/nodes.json')?.content as any;
|
||||
expect(nodesFile.nodes[0].variant).toBe('primary');
|
||||
});
|
||||
});
|
||||
});
|
||||
1
packages/noodl-editor/tests/io/index.ts
Normal file
1
packages/noodl-editor/tests/io/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ProjectExporter.test';
|
||||
Reference in New Issue
Block a user