Sprint 6: add React JSON power-tool editor workflow
Some checks are pending
CI / test (push) Waiting to run

This commit is contained in:
Rbanh 2026-02-19 23:16:54 -05:00
parent b8d894f243
commit 35211c69a4
3 changed files with 200 additions and 3 deletions

View File

@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { analyze, autoLayout, compile, tidyLayout } from "./api/client"; import { analyze, autoLayout, compile, tidyLayout } from "./api/client";
import { CanvasArea } from "./components/CanvasArea"; import { CanvasArea } from "./components/CanvasArea";
import { LeftPanel } from "./components/LeftPanel"; import { LeftPanel } from "./components/LeftPanel";
@ -40,6 +40,23 @@ function toCompileFromAnalyze(result: Awaited<ReturnType<typeof analyze>>): Reco
}; };
} }
function sortKeysDeep(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => sortKeysDeep(item));
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const sorted = Object.keys(record)
.sort((a, b) => a.localeCompare(b))
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = sortKeysDeep(record[key]);
return acc;
}, {});
return sorted;
}
return value;
}
export function App() { export function App() {
const store = useMemo(() => createSchemetaStore({ model: SAMPLE_PAYLOAD }), []); const store = useMemo(() => createSchemetaStore({ model: SAMPLE_PAYLOAD }), []);
const actionsApi = useSchemetaActions(store); const actionsApi = useSchemetaActions(store);
@ -48,6 +65,13 @@ export function App() {
const compileResult = useSchemetaSelector(store, (state) => state.compileResult); const compileResult = useSchemetaSelector(store, (state) => state.compileResult);
const lifecycle = useSchemetaSelector(store, (state) => state.lifecycle); const lifecycle = useSchemetaSelector(store, (state) => state.lifecycle);
const jsonError = useSchemetaSelector(store, (state) => state.uiFlags.jsonError); const jsonError = useSchemetaSelector(store, (state) => state.uiFlags.jsonError);
const [jsonEditorText, setJsonEditorText] = useState(() => JSON.stringify(SAMPLE_PAYLOAD, null, 2));
useEffect(() => {
if (model) {
setJsonEditorText(JSON.stringify(model, null, 2));
}
}, [model]);
const lastApiVersion = useSchemetaSelector(store, (state) => { const lastApiVersion = useSchemetaSelector(store, (state) => {
const apiVersion = state.compileResult && typeof state.compileResult.api_version === "string" ? state.compileResult.api_version : null; const apiVersion = state.compileResult && typeof state.compileResult.api_version === "string" ? state.compileResult.api_version : null;
return apiVersion ?? "-"; return apiVersion ?? "-";
@ -149,6 +173,71 @@ export function App() {
} }
} }
function applyJsonFromEditor() {
const parsed = actionsApi.applyJsonText(jsonEditorText);
if (!parsed.ok) {
actionsApi.failCompile("apply-json", parsed.error);
return;
}
actionsApi.completeCompile("apply-json");
}
async function validateJsonEditor() {
const parsed = actionsApi.applyJsonText(jsonEditorText);
if (!parsed.ok) {
actionsApi.failCompile("validate-json", parsed.error);
return;
}
const modelCandidate = store.getState().model;
if (!modelCandidate) {
actionsApi.failCompile("validate-json", "No model loaded");
return;
}
actionsApi.beginCompile("validate-json");
try {
const analyzed = await analyze(modelCandidate);
actionsApi.setCompileResult(toCompileFromAnalyze(analyzed));
actionsApi.completeCompile("validate-json");
} catch (error) {
actionsApi.failCompile("validate-json", error);
}
}
function formatJsonEditor() {
const parsed = actionsApi.applyJsonText(jsonEditorText);
if (!parsed.ok) {
actionsApi.failCompile("format-json", parsed.error);
return;
}
const next = store.getState().model;
if (next) {
setJsonEditorText(JSON.stringify(next, null, 2));
}
actionsApi.completeCompile("format-json");
}
function sortJsonEditorKeys() {
try {
const parsed = JSON.parse(jsonEditorText);
const sorted = sortKeysDeep(parsed);
setJsonEditorText(JSON.stringify(sorted, null, 2));
actionsApi.completeCompile("sort-json");
} catch (error) {
actionsApi.failCompile("sort-json", error);
}
}
async function copyReproJson() {
try {
await navigator.clipboard.writeText(jsonEditorText);
actionsApi.completeCompile("copy-repro");
} catch {
actionsApi.failCompile("copy-repro", "Clipboard write failed");
}
}
return ( return (
<div className="app-shell"> <div className="app-shell">
<TopBar <TopBar
@ -185,7 +274,23 @@ export function App() {
} }
/> />
<CanvasArea store={store} /> <CanvasArea store={store} />
<RightInspector model={model} selection={selection} compileResult={compileResult} /> <RightInspector
model={model}
selection={selection}
compileResult={compileResult}
jsonText={jsonEditorText}
jsonError={jsonError}
onJsonTextChange={setJsonEditorText}
onApplyJson={applyJsonFromEditor}
onValidateJson={() => {
void validateJsonEditor();
}}
onFormatJson={formatJsonEditor}
onSortJson={sortJsonEditorKeys}
onCopyRepro={() => {
void copyReproJson();
}}
/>
</div> </div>
</div> </div>
); );

View File

@ -4,6 +4,14 @@ type RightInspectorProps = {
model: SchemetaModel | null; model: SchemetaModel | null;
selection: SelectionSlice; selection: SelectionSlice;
compileResult: unknown | null; compileResult: unknown | null;
jsonText: string;
jsonError: string | null;
onJsonTextChange: (value: string) => void;
onApplyJson: () => void;
onValidateJson: () => void;
onFormatJson: () => void;
onSortJson: () => void;
onCopyRepro: () => void;
}; };
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
@ -33,7 +41,19 @@ function toSnippet(value: unknown, fallback: string): string {
} }
} }
export function RightInspector({ model, selection, compileResult }: RightInspectorProps) { export function RightInspector({
model,
selection,
compileResult,
jsonText,
jsonError,
onJsonTextChange,
onApplyJson,
onValidateJson,
onFormatJson,
onSortJson,
onCopyRepro
}: RightInspectorProps) {
const selectedRef = selection.selectedRefs[0] ?? null; const selectedRef = selection.selectedRefs[0] ?? null;
const selectedInstance = selectedRef ? (model?.instances ?? []).find((instance) => instance.ref === selectedRef) : null; const selectedInstance = selectedRef ? (model?.instances ?? []).find((instance) => instance.ref === selectedRef) : null;
@ -66,6 +86,35 @@ export function RightInspector({ model, selection, compileResult }: RightInspect
<h2 className="panel__title">Layout Metrics</h2> <h2 className="panel__title">Layout Metrics</h2>
<pre>{toSnippet(layoutMetrics, "No layout metrics available yet.")}</pre> <pre>{toSnippet(layoutMetrics, "No layout metrics available yet.")}</pre>
<h2 className="panel__title">JSON Editor</h2>
<div className="json-tools">
<button type="button" onClick={onValidateJson}>
Validate
</button>
<button type="button" onClick={onFormatJson}>
Format
</button>
<button type="button" onClick={onSortJson}>
Sort Keys
</button>
<button type="button" onClick={onCopyRepro}>
Copy Repro
</button>
<button type="button" onClick={onApplyJson}>
Apply JSON
</button>
</div>
<textarea
className="json-editor"
value={jsonText}
onChange={(event) => onJsonTextChange(event.target.value)}
spellCheck={false}
aria-label="Schematic JSON editor"
/>
<p className={`panel__body ${jsonError ? "json-status--error" : "json-status--ok"}`}>
{jsonError ? `JSON error: ${jsonError}` : "JSON ready"}
</p>
</aside> </aside>
); );
} }

View File

@ -190,6 +190,49 @@ body {
font-size: 0.76rem; font-size: 0.76rem;
} }
.json-tools {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.json-tools button {
padding: 0.28rem 0.48rem;
border: 1px solid #b9c9d6;
border-radius: 6px;
background: #f8fbff;
color: #173247;
font: inherit;
cursor: pointer;
}
.json-tools button:hover {
background: #eaf2f8;
}
.json-editor {
width: 100%;
min-height: 220px;
border: 1px solid #c1d0dd;
border-radius: 8px;
padding: 0.5rem;
background: #fbfdff;
color: #173247;
font-family: "IBM Plex Mono", "Fira Code", monospace;
font-size: 0.78rem;
line-height: 1.35;
resize: vertical;
}
.json-status--ok {
color: #12633c;
}
.json-status--error {
color: #a31515;
}
.canvas { .canvas {
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;