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
9 changes: 9 additions & 0 deletions .changeset/11428-inline-row-form-spec-rule.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/plugin-form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 6 additions & 2 deletions packages/plugin-form/src/MasterDetailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -877,8 +879,10 @@ const MasterDetailLines: React.FC<MasterDetailLinesProps> = ({
// 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'}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }, before: [] },
{ title: 'a field map that is not an object', schema: { name: 'line', fields: 'nope' as unknown as Record<string, unknown> }, 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);
});
});
64 changes: 31 additions & 33 deletions packages/plugin-form/src/deriveMasterDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -43,17 +47,10 @@ export interface ChildObjectSchemaLike {
fields?: Record<string, any>;
}

/** 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. */
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading