Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/28179-required-form-row-holds-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(console): a metadata form row the served form marks required holds Save when it is left empty

Clause-②: no
Metadata-protocol: none

A served metadata form row's `required: true` is a layout override, and the type's schema may leave the field optional: the permission set's Label is the case today. The Console drew such a row with a required star only, so the create page saved it empty, because its checks read the schema and the server accepts the body.

Now the metadata create and edit page judges those rows itself, for every metadata type. A row the form offers (in a visible section, not hidden, editable) whose served row says `required: true` and whose value is empty shows "This field is required." at that field, and Save sends no request until it is filled. A row in a collapsed section or another tab still counts; a hidden or read-only row never does, so Save is never held on a field the author cannot fill. A field the schema itself requires keeps the verdict it had before, and a field the schema gives a default is never empty. The server's accept sets do not change.

`SchemaForm` gains an optional `onRequiredRowFieldsChange(fields)` prop that reports those rows.
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#28179 — a served form row marked `required: true` is not only a
* star. The permission set's Identity row `label` is required in the served
* form while `PermissionSetSchema.label` stays optional, so neither the live
* Zod pass nor the server refuses an empty one: the page does.
*
* (a) New Permission Set, Label empty, Name typed, Save: no `PUT`, and the
* refusal is the inline error on Label; typing a Label then saves;
* (b) CONTROL — the same page over a served form whose Label row is not
* required: an empty Label saves, the field stays optional;
* (c) a field the served SCHEMA requires keeps its own verdict, the one this
* page gave before (the create seed's placeholder refusal), and the new
* message is not added beside it.
*
* The REAL page over a REAL `MetadataClient` whose transport is an in-memory
* server recording every `PUT`, with the real served form from
* `METADATA_FORM_REGISTRY`. Which rows count as offered is pinned in
* `SchemaForm.requiredRows-28179.test.tsx`.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { z } from 'zod';
import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel';
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
import { MetadataClient } from '@object-ui/data-objectstack';
import { t } from './i18n';

const REQUIRED = t('engine.validation.fieldRequired', 'en-US');

const server = {
puts: [] as Array<{ name: string; body: Record<string, unknown> }>,
};

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = new URL(String(input), 'http://console.test');
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
if (!meta) return json(200, []);
const seg = meta[1].split('/').map(decodeURIComponent);
if (seg[0] === '_drafts') return json(200, { drafts: [] });
if (seg.length === 1) return json(200, { items: [] });
if (method === 'PUT') {
server.puts.push({ name: seg[1]!, body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown> });
return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'draft', message: `Saved ${seg[0]}/${seg[1]}` });
}
return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
}

const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, {
io: 'input',
unrepresentable: 'any',
}) as Record<string, unknown>;

const SERVED_FORM = METADATA_FORM_REGISTRY.permission as unknown as {
sections: Array<{ fields: Array<string | { field: string; required?: boolean }> }>;
};

/** The served form with the Label row's `required` taken off — (b) only. */
function formWithOptionalLabel() {
return {
...SERVED_FORM,
sections: SERVED_FORM.sections.map((s) => ({
...s,
fields: s.fields.map((f) => {
if (typeof f === 'string' || f.field !== 'label') return f;
const { required: _required, ...rest } = f;
return rest;
}),
})),
};
}

const served = vi.hoisted(() => ({
form: undefined as unknown,
schema: undefined as unknown,
}));

const client = vi.hoisted(() => ({ current: null as unknown }));

vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
return {
...mod,
useMetadataClient: () => client.current,
useMetadataTypes: () => ({
loading: false,
entries: [
{
type: 'permission',
label: 'Permission Set',
domain: 'security',
allowEnvironmentOverlay: true,
allowRuntimeCreate: true,
schema: served.schema,
form: served.form,
createSeed: getMetadataCreateSeed('permission') as Record<string, unknown>,
},
],
}),
};
});

import { MetadataResourceEditPage } from './ResourceEditPage';
import { registerDefaultMetadataSchemas } from './default-schemas';
import { registerBuiltinAnchors } from './anchors';

client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });

registerDefaultMetadataSchemas();
// The live registration: Label → Name derive, `createFields: ['label', 'name']`.
registerBuiltinAnchors();

beforeEach(() => {
server.puts = [];
served.form = SERVED_FORM;
served.schema = PERMISSION_JSON_SCHEMA;
});
afterEach(() => {
cleanup();
});

const saveButton = () => screen.getByTestId('resource-save');

async function openNewPermissionSet() {
render(
<MemoryRouter initialEntries={['/metadata/permission/new']}>
<Routes>
<Route path="/metadata/permission/new" element={<MetadataResourceEditPage type="permission" name="" createMode />} />
<Route path="/metadata/permission/:name" element={<div data-testid="landed" />} />
</Routes>
</MemoryRouter>,
);
const nameBox = await screen.findByRole('textbox', { name: /^Name/ }, { timeout: 8000 });
const labelBox = screen.getByRole('textbox', { name: /^Label/ });
return { nameBox, labelBox };
}

/** The row hosting `control` — the field's own block in the form grid. */
function rowOf(control: HTMLElement): HTMLElement {
return control.closest('[style*="grid-column"]') as HTMLElement;
}

describe('a served required row holds Save (objectstack#28179)', () => {
it('(a) empty Label + typed Name is refused at Label with no PUT; a typed Label then saves', async () => {
const { nameBox, labelBox } = await openNewPermissionSet();
expect(PERMISSION_JSON_SCHEMA.required ?? []).not.toContain('label');
fireEvent.change(nameBox, { target: { value: 'sales_ops' } });
fireEvent.click(saveButton());
const issue = await within(rowOf(labelBox)).findByTestId('schema-form-issue', {}, { timeout: 8000 });
expect(issue).toHaveTextContent(REQUIRED);
expect(within(rowOf(nameBox)).queryByTestId('schema-form-issue')).toBeNull();
await new Promise((r) => setTimeout(r, 300));
expect(server.puts).toEqual([]);
expect(screen.queryByTestId('landed')).toBeNull();

fireEvent.change(labelBox, { target: { value: 'Sales Ops' } });
await waitFor(() => expect(within(rowOf(labelBox)).queryByTestId('schema-form-issue')).toBeNull());
fireEvent.click(saveButton());
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
expect(server.puts[0]!.name).toBe('sales_ops');
expect(server.puts[0]!.body.label).toBe('Sales Ops');
await screen.findByTestId('landed', {}, { timeout: 8000 });
});

it('(b) CONTROL — a served Label row that is not required stays optional: an empty Label saves', async () => {
served.form = formWithOptionalLabel();
const { nameBox } = await openNewPermissionSet();
fireEvent.change(nameBox, { target: { value: 'sales_ops' } });
fireEvent.click(saveButton());
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
expect(server.puts[0]!.body.label).toBeUndefined();
expect(screen.queryByText(REQUIRED)).toBeNull();
await screen.findByTestId('landed', {}, { timeout: 8000 });
});

it('(c) a field the served schema requires keeps its own verdict, unchanged — no second message', async () => {
// The served schema requires `label`, so the schema's own door decides an
// empty one: the create seed's placeholder is never saved
// (`engine.validation.seedPlaceholderUnset`, objectui#12810). That is the
// verdict this page gave before; the required-row rule adds nothing to it.
served.schema = { ...PERMISSION_JSON_SCHEMA, required: [...((PERMISSION_JSON_SCHEMA.required as string[]) ?? []), 'label'] };
const { nameBox } = await openNewPermissionSet();
fireEvent.change(nameBox, { target: { value: 'sales_ops' } });
fireEvent.click(saveButton());
await screen.findByText(/before saving: a new item is never saved with the placeholder/, {}, { timeout: 8000 });
await new Promise((r) => setTimeout(r, 300));
expect(server.puts).toEqual([]);
expect(screen.queryByText(REQUIRED)).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,11 @@ function MetadataResourceEditPageImpl({
React.useEffect(() => {
inlineIssueFieldsRef.current = inlineIssueFields;
});
// objectstack#28179 — the rows the form on screen offers whose served layout
// says `required: true` (`onRequiredRowFieldsChange`). The flag is a layout
// override the Zod need not share, so neither the live pass nor the server
// judges it: this page does, below (`requiredRowIssues`).
const [requiredRowFields, setRequiredRowFields] = React.useState<ReadonlySet<string>>(() => new Set());

// Wrap setDraft so that editing a field clears any *server-side*
// diagnostic issues whose path begins with that field. The user
Expand Down Expand Up @@ -838,11 +843,32 @@ function MetadataResourceEditPageImpl({
window.clearTimeout(handle);
};
}, [type, draft, entry?.schema, draftMode, createMode]);
// objectstack#28179 — an offered row whose served layout says `required:
// true`, left unset, is a located "required" issue on that field, for every
// type. Only where the override is STRICTER than the schema: a field the
// schema requires already has its own verdict (the live pass's message, the
// server's refusal) and keeps it, and a value the schema defaults is never
// unset. Computed from the draft, never from the debounced live pass, so it
// holds the same Save it is shown beside (`doSave`).
const requiredRowIssues = React.useMemo<SchemaFormIssue[]>(() => {
const props = (schema?.properties ?? {}) as Record<string, { default?: unknown } | undefined>;
const schemaRequired = Array.isArray(schema?.required) ? (schema.required as unknown[]).map(String) : [];
const unset = (v: unknown) =>
v === undefined || v === null || (typeof v === 'string' && v.trim() === '') || (Array.isArray(v) && v.length === 0);
const out: SchemaFormIssue[] = [];
for (const field of requiredRowFields) {
if (schemaRequired.includes(field) || props[field]?.default !== undefined) continue;
if (unset((draft as Record<string, unknown>)[field])) {
out.push({ path: field, message: t('engine.validation.fieldRequired', locale) });
}
}
return out;
}, [requiredRowFields, draft, schema, locale]);
// Issues to DISPLAY (banner + inline). Suppressed on a pristine create form
// so a blank new item doesn't open covered in required-field errors.
const displayIssues = React.useMemo(
() => (createMode && !createDirty ? [] : issues),
[createMode, createDirty, issues],
() => (createMode && !createDirty ? [] : requiredRowIssues.length > 0 ? [...issues, ...requiredRowIssues] : issues),
[createMode, createDirty, issues, requiredRowIssues],
);
// Per-item draft pending publish (mode=draft saves land here).
// When non-null, the editor is "viewing the draft" and we surface
Expand Down Expand Up @@ -1894,6 +1920,17 @@ function MetadataResourceEditPageImpl({
return;
}
}
// objectstack#28179 — an unset row the served form marks `required:
// true` holds the save, create or edit: no request, and the refusal is
// the inline error `displayIssues` already draws on that field. The
// server accepts the body (the override is stricter than its Zod), so
// nothing else would refuse it. Bounded like the Name hold above: only
// rows the form on screen offers and the author can edit report, so the
// author can always fill what holds the save.
if (requiredRowIssues.length > 0) {
setSaving(false);
return;
}
// objectui#12810 — a create never saves the seed's placeholder identity
// (`buildCreateModeBody` leaves it out). One the author left unset that
// the type requires — a dataset's base object — is refused HERE, named,
Expand Down Expand Up @@ -3460,6 +3497,7 @@ function MetadataResourceEditPageImpl({
widgetContext={widgetContext}
revealIssues={revealIssues}
onInlineIssueFieldsChange={setInlineIssueFields}
onRequiredRowFieldsChange={setRequiredRowFields}
/>
)}
</div>
Expand All @@ -3486,6 +3524,7 @@ function MetadataResourceEditPageImpl({
widgetContext={widgetContext}
revealIssues={revealIssues}
onInlineIssueFieldsChange={setInlineIssueFields}
onRequiredRowFieldsChange={setRequiredRowFields}
/>
)}
{/* objectui#7611 — under the designer, too: `position` renders
Expand Down
Loading
Loading