Skip to content

Commit 85b030c

Browse files
committed
fix: address Copilot review round 37 — stale constants, inherited answers, prefixed labels
- The seeding effect re-applies constants as well as filling absences: an in-place schema change can move a `const` the user cannot edit, and the read-only field then displayed the new value while `values` still held the old one — which is what would be submitted. Ordinary defaults are still only ever added, so an edited field keeps what the user put there. - hasMissingIn checks own-property presence: a required argument legally named `constructor` resolved to the inherited one and read as supplied, enabling a submit the schema rejects. - The TUI keeps the declared name as a branch field's fallback title. The prefix is an internal field NAME, and buildFields labels from its map key — so the showcase displayed `__b0__address` where the schema says `address`. Signed-off-by: cliffhall <cliff@futurescale.com>
1 parent f78b541 commit 85b030c

6 files changed

Lines changed: 120 additions & 5 deletions

File tree

‎clients/tui/__tests__/schemaToForm.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,35 @@ describe("schemaToForm", () => {
465465
]);
466466
});
467467

468+
it("labels a branch field by the name the schema declared", () => {
469+
// The prefix is an internal field name; `buildFields` falls back to its
470+
// map key for a label, so the user would otherwise see `__b0__address`.
471+
const form = schemaToForm(UNION, "union_tool");
472+
expect(form.sections[1]!.fields[1]).toMatchObject({
473+
name: "__b0__address",
474+
label: "address",
475+
});
476+
});
477+
478+
it("keeps a declared title ahead of the fallback", () => {
479+
const form = schemaToForm(
480+
{
481+
type: "object",
482+
anyOf: [
483+
{
484+
type: "object",
485+
properties: { a: { type: "string", title: "Street address" } },
486+
},
487+
{ type: "object", properties: { b: { type: "string" } } },
488+
],
489+
},
490+
"titled",
491+
);
492+
expect(form.sections[1]!.fields[0]).toMatchObject({
493+
label: "Street address",
494+
});
495+
});
496+
468497
it("offers a variant select listing the alternatives", () => {
469498
const form = schemaToForm(UNION, "union_tool");
470499
expect(form.sections[0]!.fields[0]).toMatchObject({

‎clients/tui/src/utils/schemaToForm.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ function branchFields(
143143
return [...new Set([...own, ...sharedFieldNames(base, branches)])];
144144
}
145145

146+
/**
147+
* A property declaration carrying the name it was declared under as its
148+
* fallback `title`, so a renamed field still displays the schema's own name.
149+
*/
150+
function labelled(property: unknown, name: string): unknown {
151+
if (typeof property !== "object" || property === null) {
152+
// JSON Schema's `true` constrains nothing, so a title-only object says the
153+
// same thing and carries the label.
154+
return { title: name };
155+
}
156+
return { title: name, ...property };
157+
}
158+
146159
/** The `const` a property schema pins its value to, if any. */
147160
function constOf(schema: unknown): unknown {
148161
if (typeof schema !== "object" || schema === null) return undefined;
@@ -217,7 +230,11 @@ export function schemaToForm(
217230
const properties = Object.fromEntries(
218231
branchFields(base, branches, index).map((name) => [
219232
branchFieldName(prefix, index, name),
220-
branch.schema.properties?.[name],
233+
// The prefix is an internal field NAME, never a label: `buildFields`
234+
// falls back to its map key when a declaration carries no `title`, so
235+
// without this the form would show `__b0__address` where the schema
236+
// says `address`.
237+
labelled(branch.schema.properties?.[name], name),
221238
]),
222239
);
223240
sections.push({

‎clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2391,6 +2391,43 @@ describe("SchemaForm multiline strings (#2042)", () => {
23912391
expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy();
23922392
});
23932393

2394+
it("corrects a stale const when the schema changes in place", async () => {
2395+
// The user cannot edit a read-only field, so a `const` that moves under
2396+
// an unchanged `resetKey` would display the new value while the old one
2397+
// sat in `values`, waiting to be submitted.
2398+
const onChange = vi.fn();
2399+
const pinned = (value: string): InspectorFormSchema => ({
2400+
type: "object",
2401+
properties: {
2402+
kind: { type: "string", const: value },
2403+
note: { type: "string", title: "Note" },
2404+
},
2405+
});
2406+
const { rerender } = renderWithMantine(
2407+
<SchemaForm
2408+
schema={pinned("email")}
2409+
values={{ kind: "email", note: "kept" }}
2410+
onChange={onChange}
2411+
resetKey="same-tool"
2412+
/>,
2413+
);
2414+
await Promise.resolve();
2415+
onChange.mockClear();
2416+
2417+
rerender(
2418+
<SchemaForm
2419+
schema={pinned("sms")}
2420+
values={{ kind: "email", note: "kept" }}
2421+
onChange={onChange}
2422+
resetKey="same-tool"
2423+
/>,
2424+
);
2425+
// The constant is corrected; what the user typed is left alone.
2426+
await waitFor(() =>
2427+
expect(onChange).toHaveBeenCalledWith({ kind: "sms", note: "kept" }),
2428+
);
2429+
});
2430+
23942431
it("renders no picker for a single-branch union but still shows its fields", () => {
23952432
const schema: InspectorFormSchema = {
23962433
type: "object",

‎clients/web/src/components/groups/SchemaForm/SchemaForm.tsx‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import {
3636
resolveRootUnion,
3737
selectBranchIndex,
3838
} from "@inspector/core/json/rootUnion.js";
39-
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
39+
import {
40+
applySchemaConstants,
41+
collectSchemaDefaults,
42+
} from "../../../utils/jsonUtils";
4043

4144
const FieldLabel = Text.withProps({
4245
fw: 500,
@@ -751,9 +754,20 @@ export function SchemaForm({
751754
const missing = Object.entries(collectSchemaDefaults(current)).filter(
752755
([name]) => !Object.hasOwn(held, name),
753756
);
754-
if (missing.length > 0) {
755-
report({ ...held, ...Object.fromEntries(missing) });
756-
}
757+
// Constants are re-applied as well as seeded: an in-place schema change can
758+
// move a `const` the user cannot edit — the read-only field then displays
759+
// the new value while `values` still holds the old one, which is what would
760+
// be submitted. Ordinary defaults are only ever *added*, so an edited field
761+
// keeps what the user put there.
762+
const next = applySchemaConstants(
763+
current,
764+
missing.length > 0 ? { ...held, ...Object.fromEntries(missing) } : held,
765+
);
766+
const changed = Object.keys(next).some(
767+
(name) =>
768+
!Object.hasOwn(held, name) || !Object.is(next[name], held[name]),
769+
);
770+
if (changed) report(next);
757771
// Keyed by the entity and branch being edited, and by the fixed values
758772
// themselves: a tool refreshed in place keeps its `resetKey` and its branch
759773
// while its schema changes underneath, and a newly pinned field would

‎clients/web/src/utils/jsonUtils.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,20 @@ describe("root composition (#2123)", () => {
601601
).toBe(true);
602602
});
603603

604+
it("does not read an inherited property as a supplied answer", () => {
605+
// `constructor` is a legal argument name; reading it off the prototype
606+
// would enable a submit the schema rejects.
607+
const schema: InspectorFormSchema = {
608+
type: "object",
609+
// Built through `fromEntries`: in an object literal, `constructor` is
610+
// TypeScript's own inherited member rather than a plain key.
611+
properties: Object.fromEntries([["constructor", { type: "string" }]]),
612+
required: ["constructor"],
613+
};
614+
expect(hasMissingRequiredFields(schema, {})).toBe(true);
615+
expect(hasMissingRequiredFields(schema, { constructor: "x" })).toBe(false);
616+
});
617+
604618
it("blocks submission while no branch is satisfied", () => {
605619
expect(hasMissingRequiredFields(UNION, {})).toBe(true);
606620
expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true);

‎clients/web/src/utils/jsonUtils.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ function hasMissingIn(
352352
const required = Array.isArray(schema.required) ? schema.required : [];
353353
const properties = schema.properties ?? {};
354354
return required.some((field) => {
355+
// `hasOwn` first: an argument legally named `constructor` would otherwise
356+
// resolve to the inherited one and read as supplied, enabling a submit the
357+
// schema rejects.
358+
if (!Object.hasOwn(values, field)) return true;
355359
const value = values[field];
356360
if (value === null) {
357361
const fieldSchema = properties[field];

0 commit comments

Comments
 (0)