An open-source editor for 1EdTech CASE frameworks. You can create and edit frameworks (CFDocument), items (CFItem), and associations (CFAssociation) through a visual canvas, with a draft → publish workflow. It's deliberately focused on core authoring — not trying to replace commercial CASE tools.
Under the hood, the CASE model is treated as a graph (items + associations). The canvas you see in the UI is just one way to visualise that graph — the domain model is what matters.
- React 19 + TypeScript (Vite)
- Tailwind CSS v4
- shadcn/ui components
- React Flow (
@xyflow/react) - Vitest (unit testing)
- oidc-client-ts (authentication)
These are the principles we don't bend on:
-
Domain model is the source of truth — The domain representation of a framework (items + associations) is authoritative.
-
React Flow is a projection of the domain graph — React Flow nodes/edges are derived from the domain model. They're not the canonical data.
-
All user interactions become application commands — UI events (edit node, add child, connect, delete) must translate to commands handled by the application layer.
-
Clean architecture dependency direction — Dependencies always point inward:
domainhas no dependencies on React, React Flow, APIs, or storageapplicationdepends ondomaininfrastructureimplements interfaces defined byapplicationuidepends onapplication(and usesinfrastructurethrough composition)
Pure logic (reducer, layout algorithms, geometry) is isolated from React so it can be tested and reasoned about independently.
src
├── app
│ ├── App.tsx # App shell — topology detection + pre-render layout
│ ├── main.tsx
│ └── providers/
├── domain
│ └── framework/
│ └── model/ # Framework, Item, Association entities
├── application
│ └── framework/
│ ├── mappers/ # Domain ↔ CASE JSON DTO mapping
│ └── ports/ # Repository / ID generator interfaces
├── infrastructure
│ └── caseApi/
│ ├── CaseApiClient.ts # HTTP client for CASE API
│ └── http.ts # Fetch-based HTTP client
├── ui
│ ├── editor
│ │ ├── components/ # Canvas, header, dialogs
│ │ ├── reactflow/
│ │ │ ├── nodeTypes/ # CaseItemNode, CaseFrameworkNode
│ │ │ ├── edgeTypes/ # LabeledEdge
│ │ │ ├── mapping/
│ │ │ │ ├── toReactFlow.ts # Domain → React Flow nodes/edges
│ │ │ │ └── fromEditorGraph.ts # React Flow → Domain + layout
│ │ │ └── types.ts # CaseEditorNodeType, CaseEditorEdge, CaseEdgeData
│ │ ├── state/
│ │ │ ├── EditorContext.tsx # React provider (orchestration only)
│ │ │ ├── editorReducer.ts # Pure state reducer (no React dependency)
│ │ │ ├── editorFactories.ts # Node/edge factory helpers
│ │ │ └── helpers/
│ │ │ └── nodeGeometry.ts # Pure geometry, type guards, adjacency
│ │ ├── layout/
│ │ │ ├── detectTopology.ts # Classify graph as hierarchy/star/tree
│ │ │ ├── applyInitialLayout.ts # Pre-render layout orchestrator
│ │ │ ├── hierarchyLayout.ts # Flat column layout (smoothstep)
│ │ │ ├── starLayout.ts # Elliptical radial layout (bezier)
│ │ │ └── treeLayout.ts # Recursive tree-spread layout (bezier)
│ │ └── terminology/
│ │ ├── lens.ts
│ │ └── strings.ts
│ ├── home/ # Home screen, framework cards, import dialog
│ └── shared/
│ ├── components/ui/ # shadcn/ui primitives
│ └── hooks/
├── __tests__
│ └── fixtures.ts # Reusable mock factories for tests
├── styles/
│ └── index.css
└── lib/
└── utils.ts
We treat CASE as a graph:
Item= node (CFItem)Association= edge (CFAssociation)Framework= aggregate root owning items + associations (CFDocument+ supporting structures)
The domain model has no knowledge of React Flow, the UI, or any external system.
This is the most important boundary in the codebase. The mapping code is intentionally centralised in two files:
src/ui/editor/reactflow/mapping/toReactFlow.ts— Domain → React Flowsrc/ui/editor/reactflow/mapping/fromEditorGraph.ts— React Flow → Domain
How it works:
- Domain → React Flow is derived — React Flow nodes/edges are built from the domain
Framework(Item → Node, Association → Edge). - UI changes become commands — When a user drags, edits, or connects something, the UI translates that into an application command. The command updates the domain model, and the UI re-renders by re-projecting the domain.
- Layout is separate — Positions (x, y) are a UI concern. They're stored in UI state, not in domain entities.
Editor state uses a pure reducer pattern, cleanly separated from React:
state/editorReducer.ts— A single pure function that handles all state transitions: selection, CRUD, layout, connections, graph loading, etc.state/helpers/nodeGeometry.ts— Geometry utilities (sizing, handles, overlap detection), type guards, and graph adjacency builders.state/editorFactories.ts— Factory functions for items, documents, edges, and graph structures.
layout/detectTopology.ts— Looks at a graph's shape and classifies it ashierarchy,star, ortree.layout/hierarchyLayout.ts— Vertical column: framework at top, items stacked below. Smoothstep edges.layout/starLayout.ts— Radial: framework at centre, branches fan out in angular sectors. Bezier edges.layout/treeLayout.ts— Recursive horizontal tree-spread. Bezier edges.layout/applyInitialLayout.ts— Pre-render orchestrator. Detects topology and applies layout before the first React Flow render, so there's no visible jump.
state/EditorContext.tsx— A thin React provider. It imports the reducer and layout functions, wires them up with hooks, and exposes theEditorContextAPI. Business logic stays out.
Commands define what a user can do:
AddItem,UpdateItem,RemoveItemAddAssociation,RemoveAssociationPublishFramework
Command handlers validate inputs, enforce domain invariants, update the model, and call repository ports as needed.
Implements the interfaces defined in application/ports:
FrameworkRepository— load/save/publishIdGenerator— GUID/URI generation- HTTP client for the CASE API
- Serialisation between domain objects and CASE JSON DTOs
- Renders the canvas editor (React Flow)
- Provides panels and dialogs for editing properties
- Translates UI events → commands
- Shows draft status and the explicit publishing flow
- Applies a terminology lens (K-12 / HE / Workforce) — UI labels adapt to context without touching the domain
The version field on a framework's metadata (CFDocument.version, e.g. "1.0", "2024.1") is author-controlled free text — the editor never generates or mutates it.
Before: frameworkToCfPackage auto-incremented a major.minor.build version on every save (incrementVersion: true), and defaulted unset versions to 1.0.0. The incrementVersion flag threaded through frameworkToCfDocument, frameworkToCfPackage, and FrameworkExportParams, and the version field in NodePropertiesPanel was a read-only display (hidden entirely when unset).
Now: version is a straight passthrough of framework.metadata.version — unset stays unset, and nothing increments it across saves. NodePropertiesPanel exposes it as a free-text input, same pattern as title/creator/publisher.
Why: auto-incrementing build numbers on every save was arbitrary versioning the author never asked for and couldn't override — it didn't track anything meaningful (e.g. semantic changes, publish milestones), and a hidden default (1.0.0) masked the fact that no version had actually been assigned. Making it a plain authored field puts version semantics under the framework author's control, consistent with other metadata fields like title and publisher.
Note this is distinct from the storage versioning the backend does on every save ({docId}_v0001.json, _v0002.json, ...) — see apps/opencase/docs/DEVELOPER.md. That's an internal, immutable revision history; CFDocument.version is the framework's own public-facing version label.
See apps/editor/src/application/framework/mappers/case/toCasePackage.ts and its test file for the passthrough behavior.
- All edits happen in a draft state
- Switching frameworks discards the draft (with a warning)
- Publishing is explicit: review changes, optionally add notes, then publish
All pure modules have unit tests (Vitest, jsdom). Tests live alongside the code they cover (*.test.ts), with shared fixtures in src/__tests__/fixtures.ts.
npm run test # Single run
npm run test:watch # Watch modeIf you're adding a new reducer action or layout algorithm, please add tests to go with it.
Where to put things:
- Business meaning →
domain/ - Use-case or command →
application/ - Presentation →
ui/ - External integrations →
infrastructure/
If you're working on nodes/edges:
- Change components in
ui/editor/reactflow/* - Keep mapping logic in
mapping/*
If you're adding state logic:
- Add new actions to the
Actionunion ineditorReducer.ts - Handle them in the reducer (keep it pure)
- Keep
EditorContext.tsxas a thin orchestrator - Write tests
CASE frameworks are inherently items + associations — a graph. Even though the current UI mostly shows a tree, future features (crosswalks, alignment links, references) will need edges that aren't purely hierarchical. Getting the mapping right now avoids a painful re-architecture later.