@miadi/stateloom-web 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +2 -2
- package/server/.next/prerender-manifest.json +3 -3
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__13c3d2a8._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__2046b812._.js +2 -2
- package/server/.next/server/chunks/[root-of-the-server]__87bf4b91._.js +1 -1
- package/server/.next/server/chunks/[root-of-the-server]__95b05635._.js +2 -2
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__2b35b711._.js → [root-of-the-server]__df915e74._.js} +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/server/server-reference-manifest.js +1 -1
- package/server/.next/server/server-reference-manifest.json +1 -1
- package/server/.next/static/chunks/3dc2436b75e43174.css +2 -0
- package/server/.next/static/chunks/e7adb06692216348.js +10 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/page.tsx +31 -1
- package/server/src/components/erd/ErdWorkspace.tsx +502 -0
- package/server/src/lib/projectFile.ts +5 -7
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/static/chunks/1cbe108679f9af04.css +0 -2
- package/server/.next/static/chunks/8ff30b68acab2d20.js +0 -10
- /package/server/.next/static/{MpwR1y8jNIjcY-QAij3pD → 6GrzDGxArqw_jZoajUQwk}/_buildManifest.js +0 -0
- /package/server/.next/static/{MpwR1y8jNIjcY-QAij3pD → 6GrzDGxArqw_jZoajUQwk}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{MpwR1y8jNIjcY-QAij3pD → 6GrzDGxArqw_jZoajUQwk}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The designer, when the document is an ERD (`.erdf.json`, Spec 80).
|
|
5
|
+
*
|
|
6
|
+
* It is a separate workspace rather than a mode of the state designer because
|
|
7
|
+
* nothing in `useDesignerStore` applies: no state tree, no events, no PatchOps.
|
|
8
|
+
* What it shares is everything underneath — the same `?doc=` parameter and file
|
|
9
|
+
* API, the same hub room keyed by the resolved path, the same canvas package.
|
|
10
|
+
*
|
|
11
|
+
* Every edit is written to disk and then pushed to the room whole. Disk first,
|
|
12
|
+
* because the agent's MCP tools read the file before each of their own edits:
|
|
13
|
+
* a change that lived only in the browser would be overwritten by the next
|
|
14
|
+
* `add_entity`. The edits are the protocol's pure functions, the same ones the
|
|
15
|
+
* MCP tools call, so a hand and an agent make identical changes.
|
|
16
|
+
*/
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { EntityRelationshipCanvas } from "@miadi/stateloom-canvas";
|
|
19
|
+
import { createBridgeSession, type BridgeSession } from "@miadi/stateloom-react";
|
|
20
|
+
import {
|
|
21
|
+
IDENTITY_VIEWPORT,
|
|
22
|
+
ERD_CARDINALITIES,
|
|
23
|
+
addAttribute,
|
|
24
|
+
addEntity,
|
|
25
|
+
addRelationship,
|
|
26
|
+
checkLinks,
|
|
27
|
+
checkStateOf,
|
|
28
|
+
emptyErd,
|
|
29
|
+
erdAutoLayout,
|
|
30
|
+
isErdDefinition,
|
|
31
|
+
removeAttribute,
|
|
32
|
+
removeEntity,
|
|
33
|
+
removeRelationship,
|
|
34
|
+
renderMermaidEr,
|
|
35
|
+
validateErd,
|
|
36
|
+
type EntityRelationshipDefinition,
|
|
37
|
+
type ErdCardinality,
|
|
38
|
+
type ErdValidationError,
|
|
39
|
+
type LayoutBox,
|
|
40
|
+
type StateMachineDefinition,
|
|
41
|
+
type Viewport,
|
|
42
|
+
} from "@miadi/stateloom-protocol";
|
|
43
|
+
import DocSwitcher from "@/components/DocSwitcher";
|
|
44
|
+
import { docQuery, navigateToDoc, useRequestedDoc } from "@/lib/docParam";
|
|
45
|
+
import { loadRuntimeConfig } from "@/lib/runtimeConfig";
|
|
46
|
+
|
|
47
|
+
type Moved = Record<string, { x: number; y: number }>;
|
|
48
|
+
type Edit = (def: EntityRelationshipDefinition) => EntityRelationshipDefinition;
|
|
49
|
+
|
|
50
|
+
const layoutKey = (docPath: string): string => `stateloom:erd-layout:${docPath}`;
|
|
51
|
+
|
|
52
|
+
function readMoved(docPath: string): Moved {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(localStorage.getItem(layoutKey(docPath)) ?? "{}") as Moved;
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Machine {
|
|
61
|
+
path: string;
|
|
62
|
+
def: StateMachineDefinition;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The state machines in the same directory as `docPath` — the ERD's siblings. */
|
|
66
|
+
async function siblingMachines(docPath: string): Promise<Machine[]> {
|
|
67
|
+
const dir = docPath.slice(0, docPath.lastIndexOf("/"));
|
|
68
|
+
const listing = (await (await fetch("/api/docs", { cache: "no-store" })).json()) as { docs?: { path: string }[] };
|
|
69
|
+
const paths = (listing.docs ?? [])
|
|
70
|
+
.map((d) => d.path)
|
|
71
|
+
.filter((p) => p.endsWith(".smdf.json") && p.slice(0, p.lastIndexOf("/")) === dir);
|
|
72
|
+
const machines: Machine[] = [];
|
|
73
|
+
for (const path of paths) {
|
|
74
|
+
try {
|
|
75
|
+
const body = await (await fetch(`/api/file${docQuery(path)}`, { cache: "no-store" })).json();
|
|
76
|
+
const parsed = JSON.parse(body.content ?? "null");
|
|
77
|
+
const def = parsed?.stateMachine ?? parsed?.StateMachine ?? parsed;
|
|
78
|
+
if (def?.state) machines.push({ path, def });
|
|
79
|
+
} catch {
|
|
80
|
+
// An unreadable sibling is skipped; it is not this document's problem.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return machines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const input =
|
|
87
|
+
"w-full rounded border border-gray-700 bg-gray-950 px-2 py-1 text-xs text-gray-200 placeholder:text-gray-600";
|
|
88
|
+
const button = "rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 hover:bg-gray-700";
|
|
89
|
+
const heading = "mb-1 mt-3 text-[11px] font-semibold uppercase tracking-wide text-gray-500";
|
|
90
|
+
|
|
91
|
+
export default function ErdWorkspace() {
|
|
92
|
+
const requested = useRequestedDoc();
|
|
93
|
+
const [docPath, setDocPath] = useState("");
|
|
94
|
+
const [def, setDef] = useState<EntityRelationshipDefinition | null>(null);
|
|
95
|
+
const [moved, setMoved] = useState<Moved>({});
|
|
96
|
+
const [viewport, setViewport] = useState<Viewport>({ ...IDENTITY_VIEWPORT });
|
|
97
|
+
const [fitKey, setFitKey] = useState(0);
|
|
98
|
+
const [selection, setSelection] = useState<string | null>(null);
|
|
99
|
+
const [status, setStatus] = useState("loading…");
|
|
100
|
+
const [peers, setPeers] = useState(0);
|
|
101
|
+
const [linkReport, setLinkReport] = useState<ErdValidationError[] | null>(null);
|
|
102
|
+
const sessionRef = useRef<BridgeSession | null>(null);
|
|
103
|
+
|
|
104
|
+
// Load the document, then join its room.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
let cancelled = false;
|
|
107
|
+
let session: BridgeSession | null = null;
|
|
108
|
+
(async () => {
|
|
109
|
+
const body = await (await fetch(`/api/file${docQuery(requested)}`, { cache: "no-store" })).json();
|
|
110
|
+
if (cancelled) return;
|
|
111
|
+
if (body.error) return setStatus(body.error);
|
|
112
|
+
const path: string = body.path ?? "";
|
|
113
|
+
let loaded: EntityRelationshipDefinition | null = null;
|
|
114
|
+
try {
|
|
115
|
+
const parsed = body.content ? JSON.parse(body.content) : null;
|
|
116
|
+
if (isErdDefinition(parsed)) loaded = { ...parsed, relationships: parsed.relationships ?? [] };
|
|
117
|
+
} catch {
|
|
118
|
+
// Falls through to the unreadable-document message below.
|
|
119
|
+
}
|
|
120
|
+
if (body.exists && !loaded) return setStatus(`${path} is not a readable ERD definition`);
|
|
121
|
+
const name = (path.split("/").pop() ?? "Untitled").replace(/\.erdf\.json$/i, "");
|
|
122
|
+
setDocPath(path);
|
|
123
|
+
setDef(loaded ?? emptyErd("Default", name));
|
|
124
|
+
setMoved(readMoved(path));
|
|
125
|
+
setSelection(null);
|
|
126
|
+
setLinkReport(null);
|
|
127
|
+
setFitKey((k) => k + 1);
|
|
128
|
+
setStatus(body.exists ? "loaded" : "new document — the first edit writes it");
|
|
129
|
+
|
|
130
|
+
const { bridgeUrl } = await loadRuntimeConfig();
|
|
131
|
+
if (cancelled || !bridgeUrl) return;
|
|
132
|
+
session = createBridgeSession({
|
|
133
|
+
url: bridgeUrl,
|
|
134
|
+
role: "web",
|
|
135
|
+
docId: path,
|
|
136
|
+
name: "web-designer",
|
|
137
|
+
onFull: (e) => {
|
|
138
|
+
const incoming: unknown = e.def;
|
|
139
|
+
if (!isErdDefinition(incoming)) return;
|
|
140
|
+
setDef({ ...incoming, relationships: incoming.relationships ?? [] });
|
|
141
|
+
setStatus("updated live");
|
|
142
|
+
},
|
|
143
|
+
// An ERD has no PatchOp vocabulary; anything arriving as a patch is not for this board.
|
|
144
|
+
onPatch: () => {},
|
|
145
|
+
onPresence: (list) => setPeers(list.length),
|
|
146
|
+
});
|
|
147
|
+
sessionRef.current = session;
|
|
148
|
+
session.connect();
|
|
149
|
+
})().catch((e) => !cancelled && setStatus(String(e)));
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true;
|
|
152
|
+
sessionRef.current = null;
|
|
153
|
+
session?.disconnect();
|
|
154
|
+
};
|
|
155
|
+
}, [requested]);
|
|
156
|
+
|
|
157
|
+
const positions = useMemo<Record<string, LayoutBox>>(() => {
|
|
158
|
+
if (!def) return {};
|
|
159
|
+
const derived = erdAutoLayout(def);
|
|
160
|
+
for (const [name, at] of Object.entries(moved)) {
|
|
161
|
+
if (derived[name]) derived[name] = { ...derived[name], ...at };
|
|
162
|
+
}
|
|
163
|
+
return derived;
|
|
164
|
+
}, [def, moved]);
|
|
165
|
+
|
|
166
|
+
const problems = useMemo(() => (def ? validateErd(def) : []), [def]);
|
|
167
|
+
const errorEntities = useMemo(
|
|
168
|
+
() => problems.map((p) => (p.element ?? "").split(/[.[]/)[0]).filter(Boolean),
|
|
169
|
+
[problems],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const apply = useCallback(
|
|
173
|
+
async (edit: Edit) => {
|
|
174
|
+
if (!def || !docPath) return;
|
|
175
|
+
let next: EntityRelationshipDefinition;
|
|
176
|
+
try {
|
|
177
|
+
next = edit(def);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return setStatus(e instanceof Error ? e.message : String(e));
|
|
180
|
+
}
|
|
181
|
+
setDef(next);
|
|
182
|
+
setLinkReport(null);
|
|
183
|
+
try {
|
|
184
|
+
const r = await fetch(`/api/file${docQuery(docPath)}`, {
|
|
185
|
+
method: "PUT",
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ content: JSON.stringify(next, null, 2) + "\n" }),
|
|
188
|
+
});
|
|
189
|
+
if (!r.ok) throw new Error((await r.json())?.error ?? `HTTP ${r.status}`);
|
|
190
|
+
sessionRef.current?.emitFull(next as unknown as StateMachineDefinition);
|
|
191
|
+
setStatus("saved");
|
|
192
|
+
} catch (e) {
|
|
193
|
+
setStatus(`not saved: ${e instanceof Error ? e.message : String(e)}`);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
[def, docPath],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const moveEntity = useCallback(
|
|
200
|
+
(name: string, box: LayoutBox) => {
|
|
201
|
+
setMoved((prev) => {
|
|
202
|
+
const next = { ...prev, [name]: { x: Math.round(box.x), y: Math.round(box.y) } };
|
|
203
|
+
try {
|
|
204
|
+
localStorage.setItem(layoutKey(docPath), JSON.stringify(next));
|
|
205
|
+
} catch {
|
|
206
|
+
// A full or disabled store costs the arrangement, not the document.
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
[docPath],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const arrange = (): void => {
|
|
215
|
+
setMoved({});
|
|
216
|
+
try {
|
|
217
|
+
localStorage.removeItem(layoutKey(docPath));
|
|
218
|
+
} catch {
|
|
219
|
+
/* as above */
|
|
220
|
+
}
|
|
221
|
+
setFitKey((k) => k + 1);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const runLinkCheck = async (): Promise<void> => {
|
|
225
|
+
if (!def) return;
|
|
226
|
+
setStatus("checking links…");
|
|
227
|
+
const machines = await siblingMachines(docPath);
|
|
228
|
+
if (machines.length === 0) {
|
|
229
|
+
setLinkReport([]);
|
|
230
|
+
return setStatus("no .smdf.json beside this document to check against");
|
|
231
|
+
}
|
|
232
|
+
const found = machines.flatMap((m) =>
|
|
233
|
+
checkLinks(m.def, def).map((e) => ({ ...e, message: `${m.def.settings?.name ?? m.path}: ${e.message}` })),
|
|
234
|
+
);
|
|
235
|
+
found.push(...checkStateOf(def, machines.map((m) => m.def.settings?.name ?? "")));
|
|
236
|
+
setLinkReport(found);
|
|
237
|
+
setStatus(`checked ${machines.length} machine(s): ${found.length} problem(s)`);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const openMachine = async (machine: string): Promise<void> => {
|
|
241
|
+
const hit = (await siblingMachines(docPath)).find((m) => m.def.settings?.name === machine);
|
|
242
|
+
if (hit) navigateToDoc(hit.path);
|
|
243
|
+
else setStatus(`no .smdf.json beside this document defines '${machine}'`);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const selected = def?.entities.find((e) => e.name === selection) ?? null;
|
|
247
|
+
const fileName = docPath.split("/").pop() || "…";
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div className="app-shell flex flex-col overflow-hidden bg-gray-950 text-gray-200">
|
|
251
|
+
<header className="flex flex-wrap items-center gap-2 border-b border-gray-800 bg-gray-900 px-3 py-2 text-xs">
|
|
252
|
+
<span className="font-semibold text-blue-300">ERD</span>
|
|
253
|
+
<DocSwitcher />
|
|
254
|
+
<span className="text-gray-400" title={docPath}>
|
|
255
|
+
{def?.settings.name ?? ""} · {fileName}
|
|
256
|
+
</span>
|
|
257
|
+
<span className="ml-auto text-gray-500">
|
|
258
|
+
{status}
|
|
259
|
+
{peers > 1 ? ` · ${peers} in the room` : ""}
|
|
260
|
+
</span>
|
|
261
|
+
<button className={button} onClick={arrange} title="Forget the dragged positions and lay the board out again">
|
|
262
|
+
⤢ Arrange
|
|
263
|
+
</button>
|
|
264
|
+
<button className={button} onClick={() => setFitKey((k) => k + 1)}>
|
|
265
|
+
Fit
|
|
266
|
+
</button>
|
|
267
|
+
<button className={button} onClick={runLinkCheck} title="Check the state machines beside this document against it">
|
|
268
|
+
Check links
|
|
269
|
+
</button>
|
|
270
|
+
<button
|
|
271
|
+
className={button}
|
|
272
|
+
onClick={() => def && navigator.clipboard?.writeText(renderMermaidEr(def)).then(() => setStatus("mermaid copied"))}
|
|
273
|
+
>
|
|
274
|
+
Copy mermaid
|
|
275
|
+
</button>
|
|
276
|
+
</header>
|
|
277
|
+
|
|
278
|
+
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
|
279
|
+
<div className="min-h-[45dvh] flex-1 overflow-hidden">
|
|
280
|
+
{def && (
|
|
281
|
+
<EntityRelationshipCanvas
|
|
282
|
+
definition={def}
|
|
283
|
+
positions={positions}
|
|
284
|
+
viewport={viewport}
|
|
285
|
+
onViewportChange={setViewport}
|
|
286
|
+
selection={selection}
|
|
287
|
+
errorElements={errorEntities}
|
|
288
|
+
fitKey={fitKey}
|
|
289
|
+
onSelect={setSelection}
|
|
290
|
+
onClearSelection={() => setSelection(null)}
|
|
291
|
+
onEntityMove={moveEntity}
|
|
292
|
+
onOpenMachine={openMachine}
|
|
293
|
+
emptyHint="No entities yet — add one in the panel, or let an agent call add_entity."
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{def && (
|
|
299
|
+
<aside className="touch-targets w-full overflow-y-auto border-t border-gray-800 bg-gray-900 p-3 md:w-80 md:border-l md:border-t-0">
|
|
300
|
+
<div className={heading} style={{ marginTop: 0 }}>
|
|
301
|
+
Entities
|
|
302
|
+
</div>
|
|
303
|
+
<div className="flex flex-wrap gap-1">
|
|
304
|
+
{def.entities.map((e) => (
|
|
305
|
+
<button
|
|
306
|
+
key={e.name}
|
|
307
|
+
className={`${button} ${selection === e.name ? "border-blue-500 text-blue-300" : ""}`}
|
|
308
|
+
onClick={() => setSelection(e.name)}
|
|
309
|
+
>
|
|
310
|
+
{e.name}
|
|
311
|
+
</button>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
<InlineForm
|
|
315
|
+
fields={[{ name: "name", placeholder: "new entity" }]}
|
|
316
|
+
submit="+ Entity"
|
|
317
|
+
onSubmit={(v) => {
|
|
318
|
+
apply((d) => addEntity(d, { name: v.name.trim() }));
|
|
319
|
+
setSelection(v.name.trim());
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
{selected && (
|
|
324
|
+
<>
|
|
325
|
+
<div className={heading}>{selected.name}</div>
|
|
326
|
+
{(selected.attributes ?? []).map((a) => (
|
|
327
|
+
<div key={a.name} className="flex items-center justify-between gap-2 py-0.5 font-mono text-[11px]">
|
|
328
|
+
<span className={a.stateOf ? "text-blue-300" : "text-gray-300"}>
|
|
329
|
+
{a.name} : {a.type}
|
|
330
|
+
<span className="text-gray-500">
|
|
331
|
+
{a.key ? ` ${a.key.toUpperCase()}` : ""}
|
|
332
|
+
{a.references ? ` → ${a.references}` : ""}
|
|
333
|
+
{a.stateOf ? ` ◉ ${[a.stateOf].flat().join(", ")}` : ""}
|
|
334
|
+
</span>
|
|
335
|
+
</span>
|
|
336
|
+
<button
|
|
337
|
+
className="text-gray-500 hover:text-red-400"
|
|
338
|
+
aria-label={`Remove ${a.name}`}
|
|
339
|
+
onClick={() => apply((d) => removeAttribute(d, selected.name, a.name))}
|
|
340
|
+
>
|
|
341
|
+
✕
|
|
342
|
+
</button>
|
|
343
|
+
</div>
|
|
344
|
+
))}
|
|
345
|
+
<InlineForm
|
|
346
|
+
fields={[
|
|
347
|
+
{ name: "name", placeholder: "attribute" },
|
|
348
|
+
{ name: "type", placeholder: "type", initial: "string" },
|
|
349
|
+
{ name: "key", options: ["", "pk", "uk"] },
|
|
350
|
+
{ name: "references", placeholder: "references Entity.attr" },
|
|
351
|
+
{ name: "stateOf", placeholder: "stateOf Machine" },
|
|
352
|
+
]}
|
|
353
|
+
submit="+ Attribute"
|
|
354
|
+
onSubmit={(v) =>
|
|
355
|
+
apply((d) =>
|
|
356
|
+
addAttribute(d, selected.name, {
|
|
357
|
+
name: v.name.trim(),
|
|
358
|
+
type: v.type.trim() || "string",
|
|
359
|
+
key: v.key === "pk" || v.key === "uk" ? v.key : undefined,
|
|
360
|
+
references: v.references.trim() || undefined,
|
|
361
|
+
stateOf: v.stateOf.trim() || undefined,
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
/>
|
|
366
|
+
<button
|
|
367
|
+
className={`${button} mt-2 text-red-300`}
|
|
368
|
+
onClick={() => {
|
|
369
|
+
apply((d) => removeEntity(d, selected.name));
|
|
370
|
+
setSelection(null);
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
🗑 Delete {selected.name}
|
|
374
|
+
</button>
|
|
375
|
+
</>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
<div className={heading}>Relationships</div>
|
|
379
|
+
{def.relationships.map((r, i) => (
|
|
380
|
+
<div key={i} className="flex items-center justify-between gap-2 py-0.5 text-[11px] text-gray-300">
|
|
381
|
+
<span>
|
|
382
|
+
{r.from} <span className="text-gray-500">{r.cardinality}</span> {r.to}
|
|
383
|
+
{r.label ? <span className="text-gray-500"> : {r.label}</span> : null}
|
|
384
|
+
</span>
|
|
385
|
+
<button
|
|
386
|
+
className="text-gray-500 hover:text-red-400"
|
|
387
|
+
aria-label="Remove relationship"
|
|
388
|
+
onClick={() => apply((d) => removeRelationship(d, r.from, r.to, r.label))}
|
|
389
|
+
>
|
|
390
|
+
✕
|
|
391
|
+
</button>
|
|
392
|
+
</div>
|
|
393
|
+
))}
|
|
394
|
+
{def.entities.length > 0 && (
|
|
395
|
+
<InlineForm
|
|
396
|
+
fields={[
|
|
397
|
+
{ name: "from", options: def.entities.map((e) => e.name) },
|
|
398
|
+
{ name: "cardinality", options: [...ERD_CARDINALITIES], initial: "1:N" },
|
|
399
|
+
{ name: "to", options: def.entities.map((e) => e.name) },
|
|
400
|
+
{ name: "label", placeholder: "label (a verb)" },
|
|
401
|
+
]}
|
|
402
|
+
submit="+ Relationship"
|
|
403
|
+
onSubmit={(v) =>
|
|
404
|
+
apply((d) =>
|
|
405
|
+
addRelationship(d, {
|
|
406
|
+
from: v.from,
|
|
407
|
+
to: v.to,
|
|
408
|
+
cardinality: v.cardinality as ErdCardinality,
|
|
409
|
+
label: v.label.trim() || undefined,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
/>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{(problems.length > 0 || linkReport) && <div className={heading}>Problems</div>}
|
|
417
|
+
{problems.map((p, i) => (
|
|
418
|
+
<div key={`p${i}`} className="py-0.5 text-[11px] text-red-300">
|
|
419
|
+
[{p.ruleId}] {p.message}
|
|
420
|
+
</div>
|
|
421
|
+
))}
|
|
422
|
+
{linkReport?.length === 0 && problems.length === 0 && (
|
|
423
|
+
<div className="py-0.5 text-[11px] text-green-400">Every name the machines use resolves.</div>
|
|
424
|
+
)}
|
|
425
|
+
{linkReport?.map((p, i) => (
|
|
426
|
+
<div key={`l${i}`} className="py-0.5 text-[11px] text-amber-300">
|
|
427
|
+
[{p.ruleId}] {p.message}
|
|
428
|
+
</div>
|
|
429
|
+
))}
|
|
430
|
+
</aside>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
interface Field {
|
|
438
|
+
name: string;
|
|
439
|
+
placeholder?: string;
|
|
440
|
+
options?: string[];
|
|
441
|
+
initial?: string;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** A row of inputs and one submit. Clears its text fields after a submit; keeps the selects. */
|
|
445
|
+
function InlineForm({
|
|
446
|
+
fields,
|
|
447
|
+
submit,
|
|
448
|
+
onSubmit,
|
|
449
|
+
}: {
|
|
450
|
+
fields: Field[];
|
|
451
|
+
submit: string;
|
|
452
|
+
onSubmit: (values: Record<string, string>) => void;
|
|
453
|
+
}) {
|
|
454
|
+
const initial = (): Record<string, string> =>
|
|
455
|
+
Object.fromEntries(fields.map((f) => [f.name, f.initial ?? f.options?.[0] ?? ""]));
|
|
456
|
+
const [values, setValues] = useState(initial);
|
|
457
|
+
const first = fields[0];
|
|
458
|
+
return (
|
|
459
|
+
<form
|
|
460
|
+
className="mt-1 grid grid-cols-2 gap-1"
|
|
461
|
+
onSubmit={(e) => {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
const merged = { ...initial(), ...values };
|
|
464
|
+
if (!merged[first.name]?.trim()) return;
|
|
465
|
+
onSubmit(merged);
|
|
466
|
+
setValues((prev) =>
|
|
467
|
+
Object.fromEntries(fields.map((f) => [f.name, f.options ? (prev[f.name] ?? "") : (f.initial ?? "")])),
|
|
468
|
+
);
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
{fields.map((f) =>
|
|
472
|
+
f.options ? (
|
|
473
|
+
<select
|
|
474
|
+
key={f.name}
|
|
475
|
+
className={input}
|
|
476
|
+
aria-label={f.name}
|
|
477
|
+
value={values[f.name] ?? ""}
|
|
478
|
+
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
|
479
|
+
>
|
|
480
|
+
{f.options.map((o) => (
|
|
481
|
+
<option key={o} value={o}>
|
|
482
|
+
{o || `(${f.name})`}
|
|
483
|
+
</option>
|
|
484
|
+
))}
|
|
485
|
+
</select>
|
|
486
|
+
) : (
|
|
487
|
+
<input
|
|
488
|
+
key={f.name}
|
|
489
|
+
className={input}
|
|
490
|
+
aria-label={f.name}
|
|
491
|
+
placeholder={f.placeholder}
|
|
492
|
+
value={values[f.name] ?? ""}
|
|
493
|
+
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
|
494
|
+
/>
|
|
495
|
+
),
|
|
496
|
+
)}
|
|
497
|
+
<button type="submit" className={`${button} col-span-2`}>
|
|
498
|
+
{submit}
|
|
499
|
+
</button>
|
|
500
|
+
</form>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
@@ -162,13 +162,11 @@ export function listDocuments(limit = 300, maxDepth = 4): { docs: DocEntry[]; tr
|
|
|
162
162
|
|
|
163
163
|
for (const root of allowedDocRoots()) walk(root, 0);
|
|
164
164
|
|
|
165
|
-
// `.smdf.json`
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return aSmdf - bSmdf || b.mtime - a.mtime || a.path.localeCompare(b.path);
|
|
171
|
-
});
|
|
165
|
+
// The loom's own documents first (`.smdf.json` machines, `.erdf.json` ERDs) —
|
|
166
|
+
// the rest are `.json` files the allowlist tolerates but nobody came here
|
|
167
|
+
// looking for — then most recently touched.
|
|
168
|
+
const rank = (name: string): number => (/\.(smdf|erdf)\.json$/i.test(name) ? 0 : 1);
|
|
169
|
+
docs.sort((a, b) => rank(a.name) - rank(b.name) || b.mtime - a.mtime || a.path.localeCompare(b.path));
|
|
172
170
|
|
|
173
171
|
return { docs, truncated };
|
|
174
172
|
}
|