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>
This commit is contained in:
cliffhall 2026-08-26 14:50:52 -04:00
commit 85b030cbde
2 changed files with 120 additions and 5 deletions

View file

@ -2391,6 +2391,43 @@ describe("SchemaForm multiline strings (#2042)", () => {
expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy();
});
it("corrects a stale const when the schema changes in place", async () => {
// The user cannot edit a read-only field, so a `const` that moves under
// an unchanged `resetKey` would display the new value while the old one
// sat in `values`, waiting to be submitted.
const onChange = vi.fn();
const pinned = (value: string): InspectorFormSchema => ({
type: "object",
properties: {
kind: { type: "string", const: value },
note: { type: "string", title: "Note" },
},
});
const { rerender } = renderWithMantine(
<SchemaForm
schema={pinned("email")}
values={{ kind: "email", note: "kept" }}
onChange={onChange}
resetKey="same-tool"
/>,
);
await Promise.resolve();
onChange.mockClear();
rerender(
<SchemaForm
schema={pinned("sms")}
values={{ kind: "email", note: "kept" }}
onChange={onChange}
resetKey="same-tool"
/>,
);
// The constant is corrected; what the user typed is left alone.
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith({ kind: "sms", note: "kept" }),
);
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",

View file

@ -36,7 +36,10 @@ import {
resolveRootUnion,
selectBranchIndex,
} from "@inspector/core/json/rootUnion.js";
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
import {
applySchemaConstants,
collectSchemaDefaults,
} from "../../../utils/jsonUtils";
const FieldLabel = Text.withProps({
fw: 500,
@ -751,9 +754,20 @@ export function SchemaForm({
const missing = Object.entries(collectSchemaDefaults(current)).filter(
([name]) => !Object.hasOwn(held, name),
);
if (missing.length > 0) {
report({ ...held, ...Object.fromEntries(missing) });
}
// Constants are re-applied as well as seeded: an in-place schema change can
// move a `const` the user cannot edit — the read-only field then displays
// the new value while `values` still holds the old one, which is what would
// be submitted. Ordinary defaults are only ever *added*, so an edited field
// keeps what the user put there.
const next = applySchemaConstants(
current,
missing.length > 0 ? { ...held, ...Object.fromEntries(missing) } : held,
);
const changed = Object.keys(next).some(
(name) =>
!Object.hasOwn(held, name) || !Object.is(next[name], held[name]),
);
if (changed) report(next);
// Keyed by the entity and branch being edited, and by the fixed values
// themselves: a tool refreshed in place keeps its `resetKey` and its branch
// while its schema changes underneath, and a newly pinned field would