feat: project schema approval fields
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildSchemaApprovalModel } from "./schema-approval-model.js";
|
||||||
|
|
||||||
|
describe("buildSchemaApprovalModel", () => {
|
||||||
|
it("projects explicit object schema properties into approval fields", () => {
|
||||||
|
const model = buildSchemaApprovalModel({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["selected_issue_ids"],
|
||||||
|
properties: {
|
||||||
|
selected_issue_ids: {
|
||||||
|
type: "array",
|
||||||
|
description: "Issue ids to create",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
comment: { type: "string" },
|
||||||
|
approved: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
selected_issue_ids: ["risk-1"],
|
||||||
|
comment: "Create the selected issue.",
|
||||||
|
approved: true,
|
||||||
|
},
|
||||||
|
outcomes: ["submitted", "cancelled"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.hasExplicitFields).toBe(true);
|
||||||
|
expect(model.outcomes).toEqual(["submitted", "cancelled"]);
|
||||||
|
expect(model.fields).toEqual([
|
||||||
|
{
|
||||||
|
name: "selected_issue_ids",
|
||||||
|
label: "selected issue ids",
|
||||||
|
kind: "array",
|
||||||
|
required: true,
|
||||||
|
description: "Issue ids to create",
|
||||||
|
valuePreview: "[\"risk-1\"]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comment",
|
||||||
|
label: "comment",
|
||||||
|
kind: "string",
|
||||||
|
required: false,
|
||||||
|
description: null,
|
||||||
|
valuePreview: "Create the selected issue.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "approved",
|
||||||
|
label: "approved",
|
||||||
|
kind: "boolean",
|
||||||
|
required: false,
|
||||||
|
description: null,
|
||||||
|
valuePreview: "true",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses payload preview when schema is a loose object without properties", () => {
|
||||||
|
const model = buildSchemaApprovalModel({
|
||||||
|
schema: { type: "object" },
|
||||||
|
payload: {
|
||||||
|
selected_issue_ids: ["risk-1"],
|
||||||
|
comment: "Create the selected issue.",
|
||||||
|
},
|
||||||
|
outcomes: ["submitted", "cancelled"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.hasExplicitFields).toBe(false);
|
||||||
|
expect(model.fields).toEqual([]);
|
||||||
|
expect(model.payloadPreview).toEqual([
|
||||||
|
{ key: "selected_issue_ids", value: "[\"risk-1\"]" },
|
||||||
|
{ key: "comment", value: "Create the selected issue." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles non-object schema without throwing", () => {
|
||||||
|
const model = buildSchemaApprovalModel({
|
||||||
|
schema: true,
|
||||||
|
payload: null,
|
||||||
|
outcomes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.hasExplicitFields).toBe(false);
|
||||||
|
expect(model.fields).toEqual([]);
|
||||||
|
expect(model.payloadPreview).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export type SchemaApprovalFieldKind = "string" | "number" | "boolean" | "array" | "object" | "unknown";
|
||||||
|
|
||||||
|
export type SchemaApprovalField = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly kind: SchemaApprovalFieldKind;
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly description: string | null;
|
||||||
|
readonly valuePreview: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchemaApprovalModel = {
|
||||||
|
readonly hasExplicitFields: boolean;
|
||||||
|
readonly fields: ReadonlyArray<SchemaApprovalField>;
|
||||||
|
readonly payloadPreview: ReadonlyArray<{ readonly key: string; readonly value: string }>;
|
||||||
|
readonly outcomes: ReadonlyArray<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildSchemaApprovalModelInput = {
|
||||||
|
readonly schema: unknown;
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly outcomes: ReadonlyArray<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is JsonObject =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const stringArray = (value: unknown): ReadonlyArray<string> =>
|
||||||
|
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||||
|
|
||||||
|
const formatValue = (value: unknown): string | null => {
|
||||||
|
if (value === undefined) return null;
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldKind = (propertySchema: unknown): SchemaApprovalFieldKind => {
|
||||||
|
if (!isObject(propertySchema)) return "unknown";
|
||||||
|
const type = propertySchema.type;
|
||||||
|
if (type === "integer") return "number";
|
||||||
|
if (type === "string" || type === "number" || type === "boolean" || type === "array" || type === "object") {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelFor = (name: string): string => name.replaceAll("_", " ");
|
||||||
|
|
||||||
|
const payloadEntries = (payload: unknown): ReadonlyArray<{ readonly key: string; readonly value: string }> => {
|
||||||
|
if (!isObject(payload)) return [];
|
||||||
|
return Object.entries(payload).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: formatValue(value) ?? "undefined",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSchemaApprovalModel = ({
|
||||||
|
schema,
|
||||||
|
payload,
|
||||||
|
outcomes,
|
||||||
|
}: BuildSchemaApprovalModelInput): SchemaApprovalModel => {
|
||||||
|
const required = isObject(schema) ? new Set(stringArray(schema.required)) : new Set<string>();
|
||||||
|
const properties = isObject(schema) && isObject(schema.properties) ? schema.properties : null;
|
||||||
|
const payloadObject = isObject(payload) ? payload : {};
|
||||||
|
|
||||||
|
// This is projection, not validation. For loose schemas the product should
|
||||||
|
// still show the recorded resume payload instead of inventing absent fields.
|
||||||
|
const fields = properties
|
||||||
|
? Object.entries(properties).map(([name, propertySchema]): SchemaApprovalField => ({
|
||||||
|
name,
|
||||||
|
label: labelFor(name),
|
||||||
|
kind: fieldKind(propertySchema),
|
||||||
|
required: required.has(name),
|
||||||
|
description: isObject(propertySchema) && typeof propertySchema.description === "string"
|
||||||
|
? propertySchema.description
|
||||||
|
: null,
|
||||||
|
valuePreview: formatValue(payloadObject[name]),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasExplicitFields: fields.length > 0,
|
||||||
|
fields,
|
||||||
|
payloadPreview: payloadEntries(payload),
|
||||||
|
outcomes,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user