feat(dashboard): recursive schema rendering with nested object, array, oneOf
- Nested object: expand properties with └─ indentation - object[]: show type as 'object[]', expand items.properties - string[]/number[]: show type directly, no expansion - oneOf/discriminatedUnion: variant headers with ├/└ connectors - Auto-detect discriminator field (const values) - Skip discriminator in variant field list - Recursive to arbitrary depth Phase 1+2 of #258
This commit is contained in:
@@ -17,21 +17,134 @@ function versionCount(detail: WorkflowDetail): number {
|
|||||||
return detail.history.length + 1;
|
return detail.history.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaPropertiesTable(schema: Record<string, unknown>): Array<{
|
type SchemaRow = {
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
}> {
|
depth: number;
|
||||||
|
prefix: string;
|
||||||
|
isVariantHeader: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveType(prop: Record<string, unknown>): string {
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
|
if (items !== undefined) {
|
||||||
|
const itemType = String(items.type ?? "unknown");
|
||||||
|
return `${itemType}[]`;
|
||||||
|
}
|
||||||
|
return "array";
|
||||||
|
}
|
||||||
|
return String(prop.type ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSchema(
|
||||||
|
schema: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
parentRequired: Set<string>,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const rows: SchemaRow[] = [];
|
||||||
|
|
||||||
|
// Handle oneOf / discriminatedUnion
|
||||||
|
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||||
|
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||||
|
const variant = oneOf[vi];
|
||||||
|
// Try to find a distinguishing literal (e.g. status: "approved")
|
||||||
|
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
|
let variantLabel = `Variant ${vi + 1}`;
|
||||||
|
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||||
|
if (pDef.const !== undefined) {
|
||||||
|
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isLast = vi === oneOf.length - 1;
|
||||||
|
const connector = isLast ? "└" : "├";
|
||||||
|
rows.push({
|
||||||
|
key: `${keyPrefix}variant-${vi}`,
|
||||||
|
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||||
|
type: "",
|
||||||
|
description: "",
|
||||||
|
depth,
|
||||||
|
prefix: parentPrefix,
|
||||||
|
isVariantHeader: true,
|
||||||
|
});
|
||||||
|
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||||
|
const variantRequired = new Set<string>(
|
||||||
|
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||||
|
);
|
||||||
|
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||||
|
if (pDef.const !== undefined) continue; // skip discriminator field
|
||||||
|
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
|
||||||
|
rows.push(...subRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular object with properties
|
||||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
const required = new Set<string>(
|
const required = new Set<string>(
|
||||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||||
);
|
);
|
||||||
return Object.entries(props).map(([name, prop]) => {
|
for (const [name, prop] of Object.entries(props)) {
|
||||||
let type = String(prop.type ?? "unknown");
|
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||||
if (!required.has(name)) type += "?";
|
rows.push(...subRows);
|
||||||
const description = String(prop.description ?? "");
|
}
|
||||||
return { name, type, description };
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenProperty(
|
||||||
|
name: string,
|
||||||
|
prop: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
required: Set<string>,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const rows: SchemaRow[] = [];
|
||||||
|
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||||
|
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||||
|
if (!required.has(name)) type += "?";
|
||||||
|
const description = String(prop.description ?? "");
|
||||||
|
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key: `${keyPrefix}${name}`,
|
||||||
|
name: displayName,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
depth,
|
||||||
|
prefix: parentPrefix,
|
||||||
|
isVariantHeader: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recurse into nested object
|
||||||
|
if (prop.type === "object" && prop.properties !== undefined) {
|
||||||
|
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into array of objects
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
|
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||||
|
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`, new Set()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into oneOf
|
||||||
|
if (hasOneOf) {
|
||||||
|
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoleCard({
|
function RoleCard({
|
||||||
@@ -41,7 +154,7 @@ function RoleCard({
|
|||||||
roleName: string;
|
roleName: string;
|
||||||
role: WorkflowRoleDescriptor;
|
role: WorkflowRoleDescriptor;
|
||||||
}) {
|
}) {
|
||||||
const fields = schemaPropertiesTable(role.schema);
|
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`role-${roleName}`}
|
id={`role-${roleName}`}
|
||||||
@@ -59,7 +172,7 @@ function RoleCard({
|
|||||||
{role.description}
|
{role.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{fields.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||||
@@ -76,18 +189,31 @@ function RoleCard({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{fields.map((f) => (
|
{rows.map((r) => (
|
||||||
<tr key={f.name} style={{ borderBottom: "1px solid var(--color-border)" }}>
|
<tr
|
||||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-accent)" }}>{f.name}</td>
|
key={r.key}
|
||||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{f.type}</td>
|
style={{
|
||||||
<td className="py-1" style={{ color: "var(--color-text)" }}>{f.description || "—"}</td>
|
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="py-1 pr-3 font-mono whitespace-pre"
|
||||||
|
style={{
|
||||||
|
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||||
|
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{r.type}</td>
|
||||||
|
<td className="py-1" style={{ color: "var(--color-text)" }}>{r.description || (r.isVariantHeader ? "" : "—")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fields.length === 0 && Object.keys(role.schema).length > 0 && (
|
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||||
<pre
|
<pre
|
||||||
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||||
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user