From a62959a75232b044068ce15d2063c8f6a991f203 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:39:17 +0000 Subject: [PATCH 1/3] chore(plugin-form): raise the @objectstack/spec floor to ^17.7.0 (objectui#11428) The published entry is about to import `deriveInlineRowFormFields` and `isInlineRowFormOffered` from `@objectstack/spec/data`, which the spec first exports in 17.7.0. The old floor admitted 17.6.x, which carries neither name. The resolved version stays 17.7.0; the lockfile moves only this importer's specifier line. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- packages/plugin-form/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index e6457f44ac..f0993a9d4e 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -28,7 +28,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.6.0", + "@objectstack/spec": "^17.7.0", "lucide-react": "^1.43.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 100a923a20..ab3e8fc47e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1998,7 +1998,7 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.6.0 + specifier: ^17.7.0 version: 17.7.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 From 7ae2f6b461e0e103035d686d9844e21beed3e4e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:39:33 +0000 Subject: [PATCH 2/3] feat(plugin-form): derive the per-row form's fields and its offer through the spec rule `deriveInlineRowFormFields` / `isInlineRowFormOffered` (objectui#11428) `deriveFormFields` now returns `@objectstack/spec/data`'s `deriveInlineRowFormFields` answer, and `MasterDetailForm` offers a row the expand form when `isInlineRowFormOffered` says so, asked with the collection's resolved `inlineMode`, `formFields` and `columns`. The local `SYSTEM_FIELDS` and `NON_INPUT_TYPES` sets, which only the row form read, are removed; `SORT_FIELD_NAMES` stays for the sort-field pick. Pins: a 15-entry corpus holds `deriveFormFields` equal to the spec's answer and to the replaced filter's answers (checked against that filter before the swap); a file-scoped spy pins that both sites read the spec, with stubbed answers the local rules could never give. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11428-inline-row-form-spec-rule.md | 9 + packages/plugin-form/src/MasterDetailForm.tsx | 8 +- ...erDetail.inlineRowFormFields-11428.test.ts | 195 +++++++++++++++ .../plugin-form/src/deriveMasterDetail.ts | 64 +++-- .../inlineRowForm.specSource-11428.test.tsx | 222 ++++++++++++++++++ 5 files changed, 463 insertions(+), 35 deletions(-) create mode 100644 .changeset/11428-inline-row-form-spec-rule.md create mode 100644 packages/plugin-form/src/deriveMasterDetail.inlineRowFormFields-11428.test.ts create mode 100644 packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx diff --git a/.changeset/11428-inline-row-form-spec-rule.md b/.changeset/11428-inline-row-form-spec-rule.md new file mode 100644 index 0000000000..3742ba9313 --- /dev/null +++ b/.changeset/11428-inline-row-form-spec-rule.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-form': patch +--- + +The per-row expand form of an inline master-detail collection now takes both of its rules from `@objectstack/spec` (objectui#11428), as the collection's grid columns already do. `deriveFormFields`, the form's fields when its author listed none, returns the spec's `deriveInlineRowFormFields` answer, and `MasterDetailForm` offers a row the form when the spec's `isInlineRowFormOffered` says so. The rules are the spec's now, so objectstack's `field-no-consumers` lint credits exactly the fields this form draws. + +The output does not change. `deriveFormFields` keeps its signature and returns the same names in the same order, and a row is offered the form exactly when it was before: always in `form` mode, and in `grid` mode only when the form has more fields than the grid has columns. How each named field renders is still decided here. + +`@object-ui/plugin-form` raises its `@objectstack/spec` floor from `^17.6.0` to `^17.7.0`, because its published entry now imports `deriveInlineRowFormFields` and `isInlineRowFormOffered`, which the spec first exports in 17.7.0. diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 037d45a411..b0dc78caea 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -60,6 +60,8 @@ import { } from './masterDetailTx'; import { isSameStoredValue } from './sanitize'; import { deriveDetail } from './deriveMasterDetail'; +// objectui#11428 — the per-row form's offer test is the spec's rule. +import { isInlineRowFormOffered } from '@objectstack/spec/data'; // objectui#11396 — one `details` entry, by reference: the spec's own type for // `ComponentPropsMap['object-master-detail-form'].details[]`. Type-only. import type { ObjectMasterDetailFormProps } from '@objectstack/spec/ui'; @@ -877,8 +879,10 @@ const MasterDetailLines: React.FC = ({ // always in form mode (it IS the editor), and in grid mode only // when the full form has fields the grid omits. A thin grid whose // columns already cover every field (e.g. invoice lines) shows no - // redundant expand button. - {...((d.inlineMode === 'form' || (d.formFields?.length ?? 0) > (d.columns?.length ?? 0)) + // redundant expand button. The test is `@objectstack/spec`'s + // `isInlineRowFormOffered` (objectui#11428), asked with the + // collection's RESOLVED form factor and lists, authored or derived. + {...(isInlineRowFormOffered({ inlineMode: d.inlineMode, formFields: d.formFields, columns: d.columns }) ? { onRowExpand: (rowIdx: number) => onRowExpand(entry.id, rowIdx) } : {})} displayMode={d.inlineMode === 'form' ? 'list' : 'grid'} diff --git a/packages/plugin-form/src/deriveMasterDetail.inlineRowFormFields-11428.test.ts b/packages/plugin-form/src/deriveMasterDetail.inlineRowFormFields-11428.test.ts new file mode 100644 index 0000000000..44969b5bab --- /dev/null +++ b/packages/plugin-form/src/deriveMasterDetail.inlineRowFormFields-11428.test.ts @@ -0,0 +1,195 @@ +/** + * objectui#11428 — `deriveFormFields`, the field names of an inline + * master-detail collection's per-row expand form, takes them from + * `@objectstack/spec`'s `deriveInlineRowFormFields`. It is the row-form twin of + * `deriveMasterDetail.inlineGridColumns-11345.test.ts` (the grid columns). + * + * Two pins over one corpus, and they guard different things: + * + * 1. The names and their order ARE the spec's answer. This is what lets + * objectstack's `field-no-consumers` lint credit exactly the fields this + * form draws: a local filter creeping back in would let the two disagree, + * and this pin goes red the moment one changes an answer. + * 2. The output is what `deriveFormFields` returned BEFORE the swap. The + * expected arrays below are the answers of the local filter this card + * replaced (its own system-name, sort-name and non-input-type sets), and + * `deriveFormFields` is a public export of `@object-ui/plugin-form`: the + * swap must not move a single name. The corpus covers the relationship + * field and `exclude` alone, together and absent; every system and + * sort-position name; the `system` and `hidden` flags; `readonly`, which the + * form KEEPS where the grid drops it; every non-input type; the rich types + * the grid omits; falsy and primitive field definitions; prototype-named + * keys; and the shapes with no field map. + * + * That `deriveFormFields` reads the spec's export at all, rather than agreeing + * with it by coincidence, is pinned where a module mock is file-scoped: + * `inlineRowForm.specSource-11428.test.tsx`. + * + * `toStrictEqual` on purpose, as in the twin. + */ +import { describe, it, expect } from 'vitest'; +import { deriveInlineRowFormFields } from '@objectstack/spec/data'; +import { deriveFormFields, type ChildObjectSchemaLike } from './deriveMasterDetail'; + +type Opts = { relationshipField?: string; exclude?: string[] }; + +const line = { + name: { type: 'text', label: 'Name' }, + qty: { type: 'number', label: 'Qty' }, + unit_price: { type: 'currency', label: 'Unit price' }, + status: { type: 'select', options: ['open', 'closed'] }, + memo: { type: 'textarea' }, + order: { type: 'master_detail', reference: 'order', required: true }, +}; + +const CORPUS: Array<{ title: string; schema: ChildObjectSchemaLike | undefined; opts?: Opts; before: string[] }> = [ + { + title: 'the relationship field is dropped', + schema: { name: 'line', fields: line }, + opts: { relationshipField: 'order' }, + before: ['name', 'qty', 'unit_price', 'status', 'memo'], + }, + { + title: '`exclude` alone: the unexcluded relationship field stays', + schema: { name: 'line', fields: line }, + opts: { exclude: ['memo'] }, + before: ['name', 'qty', 'unit_price', 'status', 'order'], + }, + { + title: '`exclude` and the relationship field together, with a name the map does not hold', + schema: { name: 'line', fields: line }, + opts: { relationshipField: 'order', exclude: ['qty', 'qty', 'nope'] }, + before: ['name', 'unit_price', 'status', 'memo'], + }, + { + title: 'no options: every business field, the relationship field included', + schema: { name: 'line', fields: line }, + before: ['name', 'qty', 'unit_price', 'status', 'memo', 'order'], + }, + { + title: 'an empty relationship field excludes nothing', + schema: { name: 'line', fields: line }, + opts: { relationshipField: '' }, + before: ['name', 'qty', 'unit_price', 'status', 'memo', 'order'], + }, + { + title: 'every system / audit name and every sort-position name is dropped', + schema: { + name: 'line', + fields: { + id: { type: 'text' }, + _id: {}, + recordId: {}, + created_at: { type: 'datetime' }, + updated_at: { type: 'datetime' }, + created_by: { type: 'lookup', reference: 'user' }, + updated_by: { type: 'lookup', reference: 'user' }, + createdAt: {}, + updatedAt: {}, + createdBy: {}, + updatedBy: {}, + organization_id: {}, + tenant_id: {}, + space: {}, + owner: { type: 'lookup', reference: 'user' }, + position: { type: 'number' }, + sort_order: { type: 'number' }, + sequence: { type: 'number' }, + line_no: { type: 'number' }, + line_number: { type: 'number' }, + sort: { type: 'number' }, + title: { type: 'text', required: true }, + }, + }, + before: ['title'], + }, + { + title: 'flags: `system` and `hidden` drop a field, `readonly` and `required` do not', + schema: { + name: 'line', + fields: { + secret: { type: 'text', hidden: true }, + internal: { type: 'text', system: true }, + locked: { type: 'text', readonly: true }, + must: { type: 'text', required: true }, + shown: { type: 'text', hidden: false, system: false }, + truthy_hidden: { type: 'text', hidden: 1 }, + }, + }, + before: ['locked', 'must', 'shown'], + }, + { + title: 'types: the non-input types are dropped, the rich types the grid omits are kept', + schema: { + name: 'invoice_line', + fields: { + total: { type: 'formula', expression: 'qty * unit_price' }, + sum: { type: 'summary' }, + roll: { type: 'rollup' }, + seq: { type: 'autonumber' }, + seq2: { type: 'auto_number' }, + notes: { type: 'textarea' }, + body: { type: 'richtext' }, + blob: { type: 'json' }, + place: { type: 'location' }, + html: { type: 'html' }, + md: { type: 'markdown' }, + cover: { type: 'image' }, + attachment: { type: 'file', multiple: true }, + face: { type: 'avatar' }, + product: { type: 'lookup', reference: 'product' }, + amount: { type: 'currency', expression: 'qty * unit_price' }, + typed_oddly: { type: 7 }, + untyped: {}, + }, + }, + before: ['notes', 'body', 'blob', 'place', 'html', 'md', 'cover', 'attachment', 'face', 'product', 'amount', 'typed_oddly', 'untyped'], + }, + { + title: 'falsy and primitive field definitions are kept, in field order', + schema: { + name: 'line', + fields: { + a_null: null, + b_undefined: undefined, + c_zero: 0, + d_empty: '', + e_false: false, + f_string: 'text', + g_number: 1, + h_true: true, + name: { type: 'text', label: 'Name' }, + }, + }, + before: ['a_null', 'b_undefined', 'c_zero', 'd_empty', 'e_false', 'f_string', 'g_number', 'h_true', 'name'], + }, + { + title: 'prototype-named keys are ordinary field names', + schema: { + name: 'line', + fields: { + constructor: { type: 'text' }, + toString: { type: 'text' }, + hasOwnProperty: { type: 'text', hidden: true }, + valueOf: { type: 'formula' }, + }, + }, + opts: { exclude: ['toString'] }, + before: ['constructor'], + }, + { title: 'no schema', schema: undefined, before: [] }, + { title: 'no field map', schema: { name: 'line' }, before: [] }, + { title: 'an empty field map', schema: { name: 'line', fields: {} }, before: [] }, + { title: 'a null field map', schema: { name: 'line', fields: null as unknown as Record }, before: [] }, + { title: 'a field map that is not an object', schema: { name: 'line', fields: 'nope' as unknown as Record }, before: [] }, +]; + +describe('deriveFormFields derives its names through the spec rule (objectui#11428)', () => { + it.each(CORPUS)("$title: the names and their order are the spec's deriveInlineRowFormFields", ({ schema, opts }) => { + expect(deriveFormFields(schema, opts)).toStrictEqual(deriveInlineRowFormFields(schema, opts)); + }); + + it.each(CORPUS)('$title: the output is unchanged from the local filter it replaced', ({ schema, opts, before }) => { + expect(deriveFormFields(schema, opts)).toStrictEqual(before); + }); +}); diff --git a/packages/plugin-form/src/deriveMasterDetail.ts b/packages/plugin-form/src/deriveMasterDetail.ts index d0e8590de7..3fd4e2aeb4 100644 --- a/packages/plugin-form/src/deriveMasterDetail.ts +++ b/packages/plugin-form/src/deriveMasterDetail.ts @@ -15,7 +15,11 @@ * `reportCurrencyColumnScale` (objectui#10783). */ -import { DEFAULT_MAX_INLINE_GRID_COLUMNS, deriveInlineGridColumns } from '@objectstack/spec/data'; +import { + DEFAULT_MAX_INLINE_GRID_COLUMNS, + deriveInlineGridColumns, + deriveInlineRowFormFields, +} from '@objectstack/spec/data'; import type { GridColumn } from '@object-ui/fields'; /** @@ -43,17 +47,10 @@ export interface ChildObjectSchemaLike { fields?: Record; } -/** System / audit fields never offered in a line's row form. */ -const SYSTEM_FIELDS = new Set([ - 'id', '_id', 'recordId', - 'created_at', 'updated_at', 'created_by', 'updated_by', - 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', - 'organization_id', 'tenant_id', 'space', 'owner', -]); - -/** Field names that hold a line's sort position — excluded from the row form - * (the grid stamps them on drag-reorder instead), and the names `deriveDetail` - * picks the line's sort field from. */ +/** Field names that hold a line's sort position — the names `deriveDetail` + * picks the line's sort field from (the grid stamps it on drag-reorder). The + * grid columns and the row form leave these names out by the spec's own rule + * (`deriveInlineGridColumns`, `deriveInlineRowFormFields`). */ const SORT_FIELD_NAMES = new Set(['position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort']); /** Map an ObjectQL field type to a LineItems grid column type. */ @@ -347,33 +344,34 @@ export function hydrateColumns( }); } -/** Computed / non-input field types — excluded from the row form (read-only, - * server-derived). Unlike grid columns we DO keep rich inputs (textarea, - * richtext, file, image, json…) since the row form has room for them. */ -const NON_INPUT_TYPES = new Set(['formula', 'summary', 'rollup', 'autonumber', 'auto_number']); - /** - * Field names for a child's full "row form" (the per-row expand editor) — every - * editable business field, skipping system/audit fields, the back-reference FK, - * and computed types. Broader than {@link deriveColumns} (which only returns - * grid-friendly types): the form has room for textarea/richtext/file/etc. + * Field names for a child's full "row form" (the per-row expand editor) — the + * form an inline master-detail collection opens when its author listed no + * `formFields`. + * + * WHICH fields, in what order, is `@objectstack/spec`'s rule, + * `deriveInlineRowFormFields` (objectui#11428), the row-form twin of the + * `deriveInlineGridColumns` rule {@link deriveColumns} reads: it skips system / + * audit fields, sort-position fields, fields flagged `system` or `hidden`, the + * computed types, the back-reference FK to the parent and any `exclude`d name, + * and keeps every other field in the child's field order. So it is broader than + * the grid: `readonly` fields and the rich types a grid cell omits (textarea, + * richtext, json, file, …) stay, since the form has room for them. The rule + * lives in the spec so that an author-time tool (objectstack's + * `field-no-consumers` lint) credits exactly the fields this form draws. + * + * WHETHER a row offers the form at all is the spec's `isInlineRowFormOffered`, + * which `MasterDetailForm` asks. How each named field renders is the form's own + * per-field builder, over these names. */ export function deriveFormFields( childSchema: ChildObjectSchemaLike | undefined, opts: { relationshipField?: string; exclude?: string[] } = {}, ): string[] { - const fields = childSchema?.fields; - if (!fields || typeof fields !== 'object') return []; - const exclude = new Set([...(opts.exclude ?? []), ...(opts.relationshipField ? [opts.relationshipField] : [])]); - const out: string[] = []; - for (const [name, def] of Object.entries(fields)) { - const d = def as any; - if (SYSTEM_FIELDS.has(name) || exclude.has(name) || SORT_FIELD_NAMES.has(name)) continue; - if (d?.system || d?.hidden) continue; - if (NON_INPUT_TYPES.has(d?.type)) continue; - out.push(name); - } - return out; + return deriveInlineRowFormFields(childSchema, { + relationshipField: opts.relationshipField, + exclude: opts.exclude, + }); } /** Inline-edit form factor. */ diff --git a/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx b/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx new file mode 100644 index 0000000000..c0eef7081e --- /dev/null +++ b/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx @@ -0,0 +1,222 @@ +/** + * objectui#11428 — both halves of an inline master-detail collection's per-row + * expand form read `@objectstack/spec`'s rule, not a local copy of it: + * + * - WHICH fields the form draws: `deriveFormFields` returns + * `deriveInlineRowFormFields`' answer; + * - WHETHER a row offers the form at all: `MasterDetailForm` asks + * `isInlineRowFormOffered`, with the collection's RESOLVED `inlineMode`, + * `formFields` and `columns` (authored or derived). + * + * The local rules this card replaced agree with the spec on every input, so an + * equality pin cannot tell "reads the spec" from "agrees with it by + * coincidence" (the agreement itself is pinned by the corpus in + * `deriveMasterDetail.inlineRowFormFields-11428.test.ts`). These pins can: the + * two exports are wrapped in pass-through spies, and the stubbed rows hand back + * an answer the local rule would never give. A site that stopped reading the + * spec keeps its old answer and goes red here. + * + * The pass-through rows are the existing behaviour. With the real answer, the + * three authored offer arms (`masterDetailDetailsMembers-8071` row 3: a list, + * a form wider than its cells, a form no wider than its cells) answer as they + * did, and a fully derived collection is offered the form its derived + * `formFields` widen. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import { deriveInlineRowFormFields, isInlineRowFormOffered } from '@objectstack/spec/data'; +import { MasterDetailForm } from './MasterDetailForm'; +import { deriveDetail, deriveFormFields } from './deriveMasterDetail'; + +type SpecData = typeof import('@objectstack/spec/data'); + +const { gridProps, probe } = vi.hoisted(() => ({ + gridProps: [] as Array>, + probe: { + factoryRan: false, + realRowFormFields: undefined as unknown as SpecData['deriveInlineRowFormFields'], + realOffer: undefined as unknown as SpecData['isInlineRowFormOffered'], + }, +})); + +/** The spec's two exports behind pass-through spies; every other export is the real one. */ +vi.mock('@objectstack/spec/data', async (importOriginal) => { + const actual = await importOriginal(); + probe.factoryRan = true; + probe.realRowFormFields = actual.deriveInlineRowFormFields; + probe.realOffer = actual.isInlineRowFormOffered; + return { + ...actual, + deriveInlineRowFormFields: vi.fn(actual.deriveInlineRowFormFields), + isInlineRowFormOffered: vi.fn(actual.isInlineRowFormOffered), + }; +}); + +/** Every prop object `MasterDetailForm` hands the line grid; the REAL grid still renders. */ +vi.mock('@object-ui/fields', async (importOriginal) => { + const actual = await importOriginal(); + const { createElement } = await import('react'); + const RealLineItemsField = actual.LineItemsField; + return { + ...actual, + LineItemsField: (props: Record) => { + gridProps.push(props); + return createElement(RealLineItemsField as any, props); + }, + }; +}); + +registerAllFields(); + +const OBJECTS: Record }> = { + po: { name: 'po', fields: { ref: { type: 'text', label: 'Ref' } } }, + po_line: { + name: 'po_line', + fields: { + po: { type: 'master_detail', reference: 'po', label: 'Purchase order' }, + qty: { type: 'number', label: 'Qty' }, + price: { type: 'number', label: 'Price' }, + }, + }, + po_note: { + name: 'po_note', + fields: { + po: { type: 'lookup', reference: 'po', label: 'Purchase order' }, + note: { type: 'text', label: 'Note' }, + }, + }, + // Fully derived: a `vector` field is no grid cell but a form field, so the + // derived form (qty, embedding) is wider than the derived grid (qty), and + // the smart default stays `grid`. + po_vec: { + name: 'po_vec', + fields: { + po: { type: 'master_detail', reference: 'po', label: 'Purchase order' }, + qty: { type: 'number', label: 'Qty' }, + embedding: { type: 'vector', label: 'Embedding' }, + }, + }, +}; + +function makeDataSource() { + return { + getObjectSchema: vi.fn(async (name: string) => OBJECTS[name] ?? { name, fields: {} }), + find: vi.fn().mockResolvedValue({ data: [] }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + bulk: vi.fn(), + batchTransaction: vi.fn().mockResolvedValue({ results: [{ id: 'po1' }] }), + } as any; +} + +const QTY = { name: 'qty', label: 'Qty', type: 'number' as const }; +const NOTE = { name: 'note', label: 'Note', type: 'text' as const }; + +const DETAILS = [ + { childObject: 'po_line', relationshipField: 'po', title: 'As a list', columns: [QTY], inlineMode: 'form' }, + { childObject: 'po_line', relationshipField: 'po', title: 'Wider form', columns: [QTY], formFields: ['qty', 'price'] }, + { childObject: 'po_note', relationshipField: 'po', title: 'Cells only', columns: [NOTE], formFields: ['note'] }, + { childObject: 'po_vec', title: 'Derived' }, +]; +const HEADINGS = DETAILS.map((d) => d.title); + +/** Mount the block and wait until every section's grid has its columns. */ +async function mount() { + const view = render( + , + ); + await waitFor(() => { + if (!view.container.querySelector('input[name="ref"]')) throw new Error('parent form not ready'); + for (const h of HEADINGS) if (!gridOf(h)?.field?.columns?.length) throw new Error(`${h}: columns not resolved yet`); + }); + return view; +} + +/** The props the grid of the section headed `heading` received LAST. */ +function gridOf(heading: string): Record { + const section = screen.getByRole('heading', { name: heading }).closest('section'); + if (!section) throw new Error(`no section headed ${heading}`); + const sections = Array.from(document.querySelectorAll('section')); + return gridProps.slice(-sections.length)[sections.indexOf(section)]; +} + +const offered = (heading: string) => typeof gridOf(heading).onRowExpand === 'function'; + +beforeEach(() => { + gridProps.length = 0; + vi.mocked(deriveInlineRowFormFields).mockReset().mockImplementation(probe.realRowFormFields); + vi.mocked(isInlineRowFormOffered).mockReset().mockImplementation(probe.realOffer); +}); + +describe('the per-row form reads the spec rule, not a copy of it (objectui#11428)', () => { + it('the spies stand in front of the real exports', () => { + expect(probe.factoryRan).toBe(true); + expect(vi.isMockFunction(deriveInlineRowFormFields)).toBe(true); + expect(vi.isMockFunction(isInlineRowFormOffered)).toBe(true); + expect(typeof probe.realOffer).toBe('function'); + }); + + it("deriveFormFields hands the spec the child schema, relationshipField and exclude, and returns the spec's answer as is", () => { + const schema = OBJECTS.po_line; + // A system name, the relationship field and a name the map lacks: three + // answers the replaced local filter could never give. + const stub = ['id', 'po', 'not_a_field']; + vi.mocked(deriveInlineRowFormFields).mockReturnValueOnce(stub); + expect(deriveFormFields(schema, { relationshipField: 'po', exclude: ['price'] })).toStrictEqual(stub); + expect(deriveInlineRowFormFields).toHaveBeenCalledTimes(1); + expect(deriveInlineRowFormFields).toHaveBeenCalledWith(schema, { relationshipField: 'po', exclude: ['price'] }); + }); + + it("deriveDetail's formFields and its smart default read the same answer, each arm the other's control", () => { + const schema = { + name: 'po_memo', + fields: { + po: { type: 'master_detail', reference: 'po' }, + qty: { type: 'number' }, + memo: { type: 'textarea' }, + }, + }; + const real = deriveDetail('po_memo', schema, 'po'); + expect(real.formFields).toStrictEqual(['qty', 'memo']); + expect(real.mode, 'a textarea in the form tips the smart default to `form`').toBe('form'); + + vi.mocked(deriveInlineRowFormFields).mockReturnValue(['qty']); + const stubbed = deriveDetail('po_memo', schema, 'po'); + expect(stubbed.formFields).toStrictEqual(['qty']); + expect(stubbed.mode, 'the spec dropped the textarea, so nothing tips it').toBe('grid'); + }); + + it('pass-through: each collection asks isInlineRowFormOffered with its resolved inlineMode, formFields and columns, and is offered the form as before', async () => { + await mount(); + const asked = vi.mocked(isInlineRowFormOffered); + expect(asked).toHaveBeenCalledWith({ inlineMode: 'form', formFields: undefined, columns: [QTY] }); + expect(asked).toHaveBeenCalledWith({ inlineMode: undefined, formFields: ['qty', 'price'], columns: [QTY] }); + expect(asked).toHaveBeenCalledWith({ inlineMode: undefined, formFields: ['note'], columns: [NOTE] }); + expect(asked).toHaveBeenCalledWith( + expect.objectContaining({ inlineMode: 'grid', formFields: ['qty', 'embedding'], columns: [expect.objectContaining({ name: 'qty' })] }), + ); + + expect(offered('As a list'), 'list mode edits through the full form').toBe(true); + expect(offered('Wider form'), '`formFields` wider than `columns` offers the row form').toBe(true); + expect(offered('Cells only'), 'a form no wider than the cells adds nothing to open').toBe(false); + expect(offered('Derived'), 'the derived form is wider than the derived grid').toBe(true); + }); + + it("the spec's answer is the only source: an inverted answer inverts every offer and moves nothing else", async () => { + vi.mocked(isInlineRowFormOffered).mockImplementation((opts) => !probe.realOffer(opts)); + await mount(); + expect(offered('As a list')).toBe(false); + expect(offered('Wider form')).toBe(false); + expect(offered('Cells only')).toBe(true); + expect(offered('Derived')).toBe(false); + + const list = gridOf('As a list'); + expect(list.displayMode, 'the list stays a list').toBe('list'); + expect(typeof list.onAdd, 'and still adds through the full form').toBe('function'); + expect(gridOf('Cells only').displayMode).toBe('grid'); + }); +}); From 328a381211a196103938b7f3612a6581a20e5d27 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:47:02 +0000 Subject: [PATCH 3/3] test(plugin-form): type the objectui#11428 spec-source pin without `any` The captured grid props, the child object fixtures, the data source and the block schema are typed against `GridProps`, `DataSource` and `MasterDetailFormSchema` instead of `any`. No assertion changes. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../inlineRowForm.specSource-11428.test.tsx | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx b/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx index c0eef7081e..a91a4e8626 100644 --- a/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx +++ b/packages/plugin-form/src/inlineRowForm.specSource-11428.test.tsx @@ -24,16 +24,20 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; +import React, { type ComponentProps } from 'react'; +import type { DataSource } from '@object-ui/types'; import { registerAllFields } from '@object-ui/fields'; import { deriveInlineRowFormFields, isInlineRowFormOffered } from '@objectstack/spec/data'; -import { MasterDetailForm } from './MasterDetailForm'; +import { MasterDetailForm, type MasterDetailDetailConfig, type MasterDetailFormSchema } from './MasterDetailForm'; import { deriveDetail, deriveFormFields } from './deriveMasterDetail'; type SpecData = typeof import('@objectstack/spec/data'); +type Fields = typeof import('@object-ui/fields'); +/** The members of a grid's props these pins read. */ +type GridProps = { onRowExpand?: unknown; onAdd?: unknown; displayMode?: string; field?: { columns?: unknown[] } }; const { gridProps, probe } = vi.hoisted(() => ({ - gridProps: [] as Array>, + gridProps: [] as GridProps[], probe: { factoryRan: false, realRowFormFields: undefined as unknown as SpecData['deriveInlineRowFormFields'], @@ -56,21 +60,21 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => { /** Every prop object `MasterDetailForm` hands the line grid; the REAL grid still renders. */ vi.mock('@object-ui/fields', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); const { createElement } = await import('react'); const RealLineItemsField = actual.LineItemsField; return { ...actual, - LineItemsField: (props: Record) => { - gridProps.push(props); - return createElement(RealLineItemsField as any, props); + LineItemsField: (props: ComponentProps) => { + gridProps.push(props as GridProps); + return createElement(RealLineItemsField, props); }, }; }); registerAllFields(); -const OBJECTS: Record }> = { +const OBJECTS: Record> }> = { po: { name: 'po', fields: { ref: { type: 'text', label: 'Ref' } } }, po_line: { name: 'po_line', @@ -100,7 +104,7 @@ const OBJECTS: Record }> = { }, }; -function makeDataSource() { +function makeDataSource(): DataSource { return { getObjectSchema: vi.fn(async (name: string) => OBJECTS[name] ?? { name, fields: {} }), find: vi.fn().mockResolvedValue({ data: [] }), @@ -110,24 +114,24 @@ function makeDataSource() { delete: vi.fn(), bulk: vi.fn(), batchTransaction: vi.fn().mockResolvedValue({ results: [{ id: 'po1' }] }), - } as any; + } as unknown as DataSource; } const QTY = { name: 'qty', label: 'Qty', type: 'number' as const }; const NOTE = { name: 'note', label: 'Note', type: 'text' as const }; -const DETAILS = [ +const DETAILS: MasterDetailDetailConfig[] = [ { childObject: 'po_line', relationshipField: 'po', title: 'As a list', columns: [QTY], inlineMode: 'form' }, { childObject: 'po_line', relationshipField: 'po', title: 'Wider form', columns: [QTY], formFields: ['qty', 'price'] }, { childObject: 'po_note', relationshipField: 'po', title: 'Cells only', columns: [NOTE], formFields: ['note'] }, { childObject: 'po_vec', title: 'Derived' }, ]; -const HEADINGS = DETAILS.map((d) => d.title); +const HEADINGS = ['As a list', 'Wider form', 'Cells only', 'Derived']; /** Mount the block and wait until every section's grid has its columns. */ async function mount() { const view = render( - , + , ); await waitFor(() => { if (!view.container.querySelector('input[name="ref"]')) throw new Error('parent form not ready'); @@ -137,7 +141,7 @@ async function mount() { } /** The props the grid of the section headed `heading` received LAST. */ -function gridOf(heading: string): Record { +function gridOf(heading: string): GridProps { const section = screen.getByRole('heading', { name: heading }).closest('section'); if (!section) throw new Error(`no section headed ${heading}`); const sections = Array.from(document.querySelectorAll('section'));