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
4 changes: 4 additions & 0 deletions .changeset/11396-master-detail-details-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

No release. `@object-ui/plugin-form`'s exported `MasterDetailDetailConfig` is now derived from `@objectstack/spec`'s `details` entry by reference (`ComponentPropsMap['object-master-detail-form'].details[]`, a closed shape since 17.6.0) instead of restating it by hand, minus the one member the renderer does not read (`sortField`: retired here by objectui#11070, and on objectstack `main` by objectstack-ai/objectstack#21589, unreleased after 17.6.0; the subtraction holds on both spec shapes). Member for member the type is the same as before — the same keys, the same member types, `columns` the same inline grid column — so what a caller may write does not move. The `object-master-detail-form` registration declares `of: 'object'` and a description for `details`; tests and the console's parity prose follow (objectui#11396).
Original file line number Diff line number Diff line change
Expand Up @@ -3034,7 +3034,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'object-master-detail-form.details': {
file: 'packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx',
pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. The spec row is `z.array(z.unknown())`, whose description names five of the eleven members the renderer reads, and the registration declares no `of`, so the read site is the whole member contract. New file (objectui#8071 slice 18).',
pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. Since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215) the spec row is an array of CLOSED entries — `childObject` required, `columns` the spec\'s inline grid column, an undeclared member key refused by name — and `@object-ui/types` takes that row by reference, so both published faces give the spec\'s verdict on an entry (`packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts`). `MasterDetailDetailConfig` is DERIVED from that entry (objectui#11396) and held `Equal` to it at compile time in the pinned file, with ONE stated fork: 17.6.0 declares `sortField`, which this block does not read (row 2c); objectstack `main` has since retired it (objectstack-ai/objectstack#21589, unreleased after 17.6.0), and the fork closes at the bump to the first release carrying that. The registration declares `of: \'object\'`, derived from the now single-kind member contract. A declaration fixes an entry\'s SHAPE, not what this block DOES with a member, so the read site is still the whole member contract. New file (objectui#8071 slice 18).',
},
'object-master-detail-form.fields': {
file: 'packages/plugin-form/src/__tests__/topLevelFieldsWarnCoverage-8847.test.tsx',
Expand Down
93 changes: 58 additions & 35 deletions packages/plugin-form/src/MasterDetailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BatchTransactionOperation, DataSource, GridFieldMetadata, I18nLabel } from '@object-ui/types';
import { runBatchTransaction } from '@object-ui/core';
import { LineItemsField, type GridColumn } from '@object-ui/fields';
import { LineItemsField } from '@object-ui/fields';
import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components';
import {
formatDisplayNumber,
Expand All @@ -59,40 +59,63 @@ import {
type ChildSchema,
} from './masterDetailTx';
import { isSameStoredValue } from './sanitize';
import { deriveDetail, type InlineMode } from './deriveMasterDetail';

export interface MasterDetailDetailConfig {
/** Child object name, e.g. 'expense_line'. */
childObject: string;
/** FK field on the child pointing back to the parent, e.g. 'expense_claim'.
* Optional — auto-detected from the child's master_detail/lookup field that
* references the parent object when omitted. */
relationshipField?: string;
/** Editable columns for the child grid. Optional — derived from the child
* object's fields (via DataSource.getObjectSchema) when omitted. */
columns?: GridColumn[];
/** Field names for the per-row expand form. Optional — derived from the child
* object's fields (broader than `columns`: includes rich types) when omitted. */
formFields?: string[];
/** Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list +
* per-row full form. Optional — resolved from the relationship's `inlineEdit`
* (incl. the smart default) when omitted. */
inlineMode?: InlineMode;
/** Numeric child column to sum, e.g. 'amount'. */
amountField?: string;
// ⛔ No `sortField` member. The child field the grid stamps with each line's
// position is DERIVED from the child object (`deriveDetail`: its `position` /
// `sort_order` / … field) and carried on the resolved entry, never authored:
// objectui#11070 round 9 retired the authored override, which nothing wrote
// in either repository and no spec key carries.
/** Parent field to receive the rolled-up sum, e.g. 'total_amount'. */
totalField?: string;
/** Section title. */
title?: string;
minRows?: number;
maxRows?: number;
addLabel?: string;
}
import { deriveDetail } from './deriveMasterDetail';
// 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';

/**
* One detail collection: the `details` entry of
* `ComponentPropsMap['object-master-detail-form']` in `@objectstack/spec`, on
* its authoring face (`z.input`, what a document writes), taken by reference
* rather than restated here (objectui#11396). Since 17.6.0 the spec judges that
* entry as a closed shape (objectstack-ai/objectstack#21215): `childObject`
* required, every other member optional, `columns` the spec's inline grid
* column (the type the line grid's `GridColumn` is), an undeclared key refused
* by name. `@object-ui/types` takes the whole props row by reference, so
* `objectui validate` gives the spec's verdict on an entry; this is the
* TypeScript face of that entry, and
* `__tests__/masterDetailDetailsMembers-8071.test.tsx` holds the two equal at
* compile time.
*
* ⛔ ONE fork, stated rather than absorbed: at 17.6.0 the spec entry declares
* `sortField`, and this form reads no such member. objectui#11070 round 9
* retired the authored override: the child field the grid stamps with each
* line's position is DERIVED from the child object (`deriveDetail` picks its
* `position` / `sort_order` / … field) and carried on the resolved entry, and
* row 2c of that test pins that a written `sortField` is read by nothing.
* objectstack has since retired the member on its `main` — a `retiredKey()`
* tombstone on the entry (objectstack-ai/objectstack#21589, landed by PR
* objectstack-ai/objectstack#21632 as `6ec54f00`), unreleased after 17.6.0 —
* so the fork closes at objectui's bump to the first `@objectstack/spec`
* release that carries it; the omission below then subtracts a key the entry
* no longer offers, and holds on both shapes. Until that bump the member is
* left OFF this face, so the authoring face declares no key the renderer does
* not honour. That omission is the whole of what is hand-written about the
* shape.
*
* Where each member lands — the renderer's reading, which the spec's
* description does not carry:
* - `childObject` names the child object; `relationshipField` is the FK on the
* child back to the parent, auto-detected from the child's `master_detail` /
* `lookup` field that references the parent when omitted.
* - `columns` are the line grid's editable columns, derived from the child's
* fields (via `DataSource.getObjectSchema`) when omitted; `formFields` are
* the per-row expand form's field names (broader than `columns`: rich types
* included), derived likewise.
* - `inlineMode`: `grid` = editable cells, `form` = read-only list + per-row
* full form; resolved from the relationship's `inlineEdit` (incl. the smart
* default) when omitted.
* - `amountField` is the CHILD column summed, and reaches the grid as its
* `totalField`; `totalField` is the PARENT field the sum is saved to, and is
* not forwarded to the grid.
* - `title` heads the collection's section (`Line Items` when unset);
* `minRows`, `maxRows` and `addLabel` reach the grid under the same names.
*/
export type MasterDetailDetailConfig = Omit<
NonNullable<ObjectMasterDetailFormProps['details']>[number],
'sortField'
>;

export interface MasterDetailFormSchema {
type?: 'object-master-detail-form';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,24 @@
* `MasterDetailForm` is its only reader. A member is one detail collection,
* typed `MasterDetailDetailConfig`.
*
* Nothing declared says what a member is. The registration is a bare
* `{ name: 'details', type: 'array', required: true }` with no `of` and no
* description. The spec row is `z.array(z.unknown())`, so every member parses,
* and its description names five members (`title`, `childObject`, `addLabel`,
* `columns`, `relationshipField`). The renderer reads every member
* `MasterDetailDetailConfig` declares, eleven since objectui#11070 round 9
* retired `sortField`; the compile-time block at the end of this file holds
* that member list, so the count is re-derived by the compiler rather than
* by this sentence. So the read site is the whole member contract, and every
* row below is measured there.
* What a member IS is declared on both sides, and both declarations are the
* spec's since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215,
* objectui#11396): the spec row is an array of CLOSED entries — `childObject`
* required, an undeclared key refused by name, `columns` the spec's inline grid
* column — and `MasterDetailDetailConfig` is derived from that entry by
* reference, with ONE stated fork: `@objectstack/spec` 17.6.0 declares
* `sortField`, which this block does not read (objectui#11070 round 9 retired
* the authored override; row 2c below pins that a written one reaches
* nothing); objectstack `main` has since retired it too
* (objectstack-ai/objectstack#21589, unreleased after 17.6.0), and the fork
* closes at objectui's bump to the first release carrying that. The registration
* declares `of: 'object'`, derived from that single-kind member contract. The
* compile-time block at the end of this file holds the config equal to the
* spec's entry minus that fork and spells the member list out, so the member
* count is re-derived by the compiler rather than by this sentence. A
* declaration fixes an entry's SHAPE, not what this block DOES with a member,
* so the read site is still the whole member contract, and every row below is
* measured there.
*
* ## Where each member lands
*
Expand Down Expand Up @@ -99,7 +107,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import React from 'react';
import { registerAllFields } from '@object-ui/fields';
import { registerAllFields, type GridColumn } from '@object-ui/fields';
import type { ObjectMasterDetailFormProps } from '@objectstack/spec/ui';
import { MasterDetailForm, type MasterDetailDetailConfig } from '../MasterDetailForm';

/**
Expand Down Expand Up @@ -472,9 +481,33 @@ describe('`object-master-detail-form` — the member shape of `details`', () =>
/** Exact type equality: `true` only when A and B are the same type. */
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The spec's own `details` entry, on its authoring face (objectui#11396). */
type SpecDetailEntry = NonNullable<ObjectMasterDetailFormProps['details']>[number];

/**
* The config IS the spec's entry minus the one stated fork, by reference
* (objectui#11396): a member the spec adds, drops or retypes lands on the
* config with no hand edit, and a hand-written restatement that drifts is red
* here before it is red anywhere else. It holds on both spec shapes this
* repository is compiled against: 17.6.0, where the entry declares `sortField`
* as a string, and objectstack `main`, where the entry carries a `retiredKey()`
* tombstone for it (objectstack-ai/objectstack#21589, unreleased after 17.6.0)
* — the subtraction removes the key either way. ⛔ No row here pins which of
* the two shapes the spec has; the `Spec Main Shape Gate` compiles this file
* against `main`, so such a row is red there the day the two differ. The
* forward tripwire for the retirement is the runtime pin in `@object-ui/types`
* (`object-master-detail-form-details-entry-11396.test.ts`), which records
* `sortField` as ACCEPTED on 17.6.0 and flips at the bump.
*/
const derivedFromSpec: Equal<MasterDetailDetailConfig, Omit<SpecDetailEntry, 'sortField'>> = true;

/** A `columns` entry is the spec's inline grid column, the type the line grid reads. */
const columnsAreTheGridColumn: Equal<NonNullable<MasterDetailDetailConfig['columns']>[number], GridColumn> = true;

/**
* Every member a detail can be authored with: the list the header's count is
* read from. `sortField` is not on it (objectui#11070 round 9).
* Every member a detail can be authored with, spelled out: the spec entry's
* keys minus `sortField` (objectui#11070 round 9). A key the spec adds is a
* red row here, never a silent widening of what this block is said to read.
*/
const memberList: Equal<
keyof MasterDetailDetailConfig,
Expand All @@ -493,6 +526,6 @@ const memberList: Equal<

/** The lit control for the directive below: the same literal without the retired member compiles. */
const authoredDetail: MasterDetailDetailConfig = { childObject: 'po_step', amountField: 'qty' };
// @ts-expect-error objectui#11070 round 9: `sortField` is retired as a detail member; the sort field is derived from the child object.
// @ts-expect-error objectui#11070 round 9: `sortField` is retired as a detail member; the sort field is derived from the child object. This face leaves it off on purpose (objectui#11396), on 17.6.0 where the spec declares it and on objectstack main where the spec retired it too.
const retiredSortField: MasterDetailDetailConfig = { childObject: 'po_step', sortField: 'line_no' };
void [memberList, authoredDetail, retiredSortField];
void [derivedFromSpec, columnsAreTheGridColumn, memberList, authoredDetail, retiredSortField];
16 changes: 15 additions & 1 deletion packages/plugin-form/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,21 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer
},
{ name: 'mode', type: 'enum', enum: ['create', 'edit'] },
{ name: 'sections', type: 'array' },
{ name: 'details', type: 'array', required: true },
// `of: 'object'` is DERIVED from the contract, as `ComponentInput.of`
// prescribes (objectui#11396): since `@objectstack/spec` 17.6.0 the row's
// `details` member is a closed object entry (objectstack-ai/objectstack#21215),
// the one coarse kind the spec accepts there, and the console parity gate's
// member direction witnesses the declaration against the spec row. The
// entry's TypeScript face is `MasterDetailDetailConfig`, derived from the
// same spec entry by reference (MasterDetailForm.tsx).
{
name: 'details',
type: 'array',
of: 'object',
required: true,
description:
'The child collections, one OBJECT per entry, judged by the spec\'s closed `details` entry: `childObject` (required) names the child object; `relationshipField` is the FK on the child back to the parent, derived from the child\'s metadata when omitted; `columns` are the line grid\'s columns as the spec\'s inline grid column objects (`{ name, … }`, never a bare field name), derived from the child\'s fields when omitted; `formFields` names the per-row form\'s fields; `inlineMode` is `grid` (editable cells) or `form` (list + per-row form); `amountField` is the CHILD column summed and `totalField` the PARENT field that sum is saved to; `title`, `minRows`, `maxRows` and `addLabel` dress the section and its grid. An undeclared member key is refused by name. The entry\'s `sortField` is read by nothing here — the line-position field is derived from the child object (objectui#11070) — and objectstack retires the key on its `main` (objectstack-ai/objectstack#21589, after 17.6.0).',
},
{ name: 'recordId', type: 'string', description: 'The parent record to load in `edit` mode. Leave unset for `create`.' },
// TWO values, not the six `object-form` declares (objectui#5939). A bare
// `string` here let an out-of-vocabulary value match NO renderer branch and
Expand Down
Loading
Loading