From a4d5a3ce07ccf024dfc043a56732dd31499cc55b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:43:57 +0800 Subject: [PATCH] feat(master-detail): relationship inlineEdit mode (grid | form) + smart default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the inline child collection as an editable grid OR a read-only list whose Add / per-row edit opens the child's full form — chosen per relationship, with a smart default by child shape. The hybrid mainstream pattern: thin children (invoice/order lines) → grid; fat children (rich types, many fields) → per-row form. - deriveMasterDetail: `resolveInlineMode(childSchema, inlineEdit)` → 'grid'|'form' (explicit wins; else smart default — rich/form-only types or >8 business fields → form). `deriveDetail` now returns `mode`. Exported from plugin-form. - MetadataProvider.attachInlineSubforms: resolves + passes `inlineMode` per inline relationship. - GridField: `displayMode: 'grid' | 'list'` + `onAdd`. List mode renders rows read-only (select → label, currency formatted, lookup → name) with per-row edit (expand) + delete; Add calls `onAdd`. - MasterDetailForm: form-mode details render the list; "Add" appends a row and opens it in the full inline editor; cancelling a freshly-added row discards it. Verified: live e2e 10/10 (showcase pins tasks to grid); form mode + smart default browser-verified (fat task child auto-renders the form-mode list) and unit-tested. Unit: resolveInlineMode + deriveFormFields (plugin-form 93), GridField incl. list mode (fields, +list tests), app-shell 336. Pairs with framework (spec widen + showcase + docs). Co-Authored-By: Claude Opus 4.8 --- .../providers/MetadataProvider.merge.test.ts | 11 +++-- .../src/providers/MetadataProvider.tsx | 4 ++ .../fields/src/widgets/GridField.test.tsx | 38 +++++++++++++++ packages/fields/src/widgets/GridField.tsx | 46 +++++++++++++++++-- packages/plugin-form/src/MasterDetailForm.tsx | 41 +++++++++++++++-- .../src/deriveMasterDetail.test.ts | 32 ++++++++++++- .../plugin-form/src/deriveMasterDetail.ts | 44 +++++++++++++++++- packages/plugin-form/src/index.tsx | 2 + 8 files changed, 204 insertions(+), 14 deletions(-) diff --git a/packages/app-shell/src/providers/MetadataProvider.merge.test.ts b/packages/app-shell/src/providers/MetadataProvider.merge.test.ts index 6e0d3de07..1da856c00 100644 --- a/packages/app-shell/src/providers/MetadataProvider.merge.test.ts +++ b/packages/app-shell/src/providers/MetadataProvider.merge.test.ts @@ -109,9 +109,14 @@ describe('attachInlineSubforms — relationship-level inlineEdit', () => { it('merges inlineEdit children into the parent form as subforms', () => { const out = attachInlineSubforms(objects); const invoice = out.find((o) => o.name === 'invoice')!; - expect(invoice.form?.subforms).toEqual([ - { childObject: 'invoice_line', relationshipField: 'invoice', title: 'Lines' }, - ]); + expect(invoice.form?.subforms).toHaveLength(1); + expect(invoice.form?.subforms?.[0]).toMatchObject({ + childObject: 'invoice_line', + relationshipField: 'invoice', + title: 'Lines', + }); + // The resolved inline-edit mode is attached too. + expect(['grid', 'form']).toContain(invoice.form?.subforms?.[0]?.inlineMode); }); it('does not inline master_detail children without inlineEdit', () => { diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx index f398ecbba..6b0eb82ac 100644 --- a/packages/app-shell/src/providers/MetadataProvider.tsx +++ b/packages/app-shell/src/providers/MetadataProvider.tsx @@ -7,6 +7,7 @@ import { type ReactNode, } from 'react'; import type { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { resolveInlineMode } from '@object-ui/plugin-form'; import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataState } from '@object-ui/react'; export type { MetadataState, MetadataContextValue }; @@ -251,6 +252,9 @@ export function attachInlineSubforms(objects: any[]): any[] { (inlineByParent[parent] ||= []).push({ childObject: child.name, relationshipField: fname, + // Resolve the inline-edit form factor (grid vs per-row form) from the + // declared value, falling back to the smart default by child shape. + inlineMode: resolveInlineMode(child, d.inlineEdit, { relationshipField: fname }), ...(d.inlineTitle ? { title: d.inlineTitle } : {}), ...(Array.isArray(d.inlineColumns) ? { columns: d.inlineColumns } : {}), ...(typeof d.inlineAmountField === 'string' ? { amountField: d.inlineAmountField } : {}), diff --git a/packages/fields/src/widgets/GridField.test.tsx b/packages/fields/src/widgets/GridField.test.tsx index 6071fd2a2..07f51ce15 100644 --- a/packages/fields/src/widgets/GridField.test.tsx +++ b/packages/fields/src/widgets/GridField.test.tsx @@ -59,6 +59,44 @@ describe('GridField / LineItemsField — editable line items', () => { }); }); + describe('list mode (displayMode="list" — form-factor for fat children)', () => { + const listField = { + columns: [ + { field: 'title', label: 'Title', type: 'text' as const, required: true }, + { field: 'status', label: 'Status', type: 'select' as const, options: [{ label: 'To Do', value: 'todo' }] }, + ], + } as any; + + it('renders rows read-only (no cell inputs) and an Add button', () => { + const onAdd = vi.fn(); + render( + {}} + field={listField} + displayMode="list" + onRowExpand={() => {}} + onAdd={onAdd} + />, + ); + // Read-only display: the status renders its option label, not a combobox. + expect(screen.getByText('To Do')).toBeTruthy(); + expect(screen.queryByLabelText('Title')).toBeNull(); // no editable input + expect(screen.getByRole('button', { name: /Open row/i })).toBeTruthy(); // per-row edit + }); + + it('Add calls onAdd (host opens the full form) instead of inserting a blank row', () => { + const onAdd = vi.fn(); + const onChange = vi.fn(); + render( + {}} onAdd={onAdd} />, + ); + fireEvent.click(screen.getByTestId('line-items-add')); + expect(onAdd).toHaveBeenCalledTimes(1); + expect(onChange).not.toHaveBeenCalled(); // did NOT insert a blank inline row + }); + }); + it('editing a text cell emits the raw string', () => { const onChange = vi.fn(); render(); diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index 76b1dbbc6..a25ade1db 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -75,6 +75,22 @@ const MIN_WIDTH_BY_TYPE: Record = { }; const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132; +/** Read-only display text for a cell in list mode (select → option label, + * currency/number → formatted, empty → em dash). Lookups render separately. */ +function displayText(c: GridColumn, value: any): string { + if (value === null || value === undefined || value === '') return '—'; + if (c.type === 'select' && Array.isArray(c.options)) { + const opt = c.options.find((o) => String(o.value) === String(value)); + return opt ? opt.label : String(value); + } + if (isNumeric(c.type)) { + const n = Number(value); + if (Number.isFinite(n)) return c.type === 'currency' ? `${c.prefix || '¥'}${n.toLocaleString()}` : n.toLocaleString(); + } + if (Array.isArray(value)) return value.join(', '); + return String(value); +} + function coerce(type: string | undefined, raw: string): any { if (isNumeric(type)) { if (raw === '' || raw == null) return null; @@ -101,6 +117,8 @@ export function GridField({ disabled, className, onRowExpand, + displayMode, + onAdd, ...props }: FieldWidgetProps & { /** When provided, each row shows an "expand" button that opens the row in a @@ -108,10 +126,19 @@ export function GridField({ * writes the edited values back). Lets a "fat" child be edited in a real form * while the grid stays a quick at-a-glance editor. */ onRowExpand?: (rowIndex: number) => void; + /** 'grid' (default) = editable cells; 'list' = read-only rows whose primary + * action is per-row edit (via `onRowExpand`) and whose Add opens a new row + * in the full form (via `onAdd`). The form-factor for "fat" children. */ + displayMode?: 'grid' | 'list'; + /** In 'list' mode, "Add" calls this (host opens the full form for a new row) + * instead of inserting a blank inline row. */ + onAdd?: () => void; }) { const cfg = (field || (props as any).schema || {}) as any; const allColumns: GridColumn[] = cfg.columns || []; const rows: Row[] = Array.isArray(value) ? value : []; + // List mode: rows are read-only at-a-glance; editing happens in the full form. + const isList = displayMode === 'list' && !readonly; // Column visibility — a curated default-visible set with the rest revealable // via the column chooser (mainstream "personalize columns" pattern). Required @@ -364,8 +391,21 @@ export function GridField({ )} {columns.map((c) => ( - - {c.type === 'lookup' ? ( + + {isList ? ( + c.type === 'lookup' && row[c.field] != null && row[c.field] !== '' ? ( + {}} + readonly + field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any} + /> + ) : ( + + {displayText(c, row[c.field])} + + ) + ) : c.type === 'lookup' ? ( setCellValue(rowIdx, c.field, v)} @@ -485,7 +525,7 @@ export function GridField({ type="button" variant="outline" size="sm" - onClick={addRow} + onClick={isList && onAdd ? onAdd : addRow} disabled={maxRows != null && rows.length >= maxRows} data-testid="line-items-add" > diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 5a7eff9ee..9bec0b146 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -30,7 +30,7 @@ import { LineItemsField, type GridColumn } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; import { ObjectForm } from './ObjectForm'; import { applyDetail, idOf, buildMasterDetailBatch, buildMasterDetailEditBatch, sumRows } from './masterDetailTx'; -import { deriveDetail } from './deriveMasterDetail'; +import { deriveDetail, type InlineMode } from './deriveMasterDetail'; export interface MasterDetailDetailConfig { /** Child object name, e.g. 'expense_line'. */ @@ -45,6 +45,10 @@ export interface MasterDetailDetailConfig { /** 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; /** Parent field to receive the rolled-up sum, e.g. 'total_amount'. */ @@ -126,6 +130,7 @@ export const MasterDetailForm: React.FC = ({ relationshipField: derived.relationshipField, columns: derived.columns, formFields: d.formFields ?? derived.formFields, + inlineMode: d.inlineMode ?? derived.mode, amountField: d.amountField ?? derived.amountField, }; } catch { @@ -197,7 +202,9 @@ export const MasterDetailForm: React.FC = ({ // fields, incl. rich types the grid omits) in a drawer, pre-filled with the // row. Saving writes back into the in-memory row — the atomic batch still // persists everything on the parent Save (no separate backend write here). - const [expanded, setExpanded] = useState<{ detailIdx: number; rowIdx: number } | null>(null); + // `isNew` marks a row created by "Add" in list/form mode — cancelling the + // editor without applying discards that empty row. + const [expanded, setExpanded] = useState<{ detailIdx: number; rowIdx: number; isNew?: boolean } | null>(null); const expandedRow = expanded ? state[expanded.detailIdx]?.rows?.[expanded.rowIdx] : undefined; const expandedDetail = expanded ? details[expanded.detailIdx] : undefined; @@ -215,6 +222,28 @@ export const MasterDetailForm: React.FC = ({ [], ); + /** List/form mode "Add": append a blank row and open it in the full form. */ + const addRowViaForm = useCallback((detailIdx: number) => { + setState((prev) => { + const next = prev.map((s, i) => (i === detailIdx ? { ...s, rows: [...s.rows, {}] } : s)); + const rowIdx = next[detailIdx].rows.length - 1; + setExpanded({ detailIdx, rowIdx, isNew: true }); + return next; + }); + }, []); + + /** Editor cancelled: drop the row if it was a freshly-added (empty) one. */ + const cancelRowEdit = useCallback(() => { + setExpanded((cur) => { + if (cur?.isNew) { + setState((prev) => + prev.map((s, i) => (i === cur.detailIdx ? { ...s, rows: s.rows.filter((_, j) => j !== cur.rowIdx) } : s)), + ); + } + return null; + }); + }, []); + /** * Built-in feedback so a save is NEVER silent (a silent success looks broken * and invites duplicate submits). Shows a toast, and on CREATE clears the @@ -416,6 +445,8 @@ export const MasterDetailForm: React.FC = ({ value={state[i]?.rows ?? []} onChange={(rows) => setRows(i, rows)} onRowExpand={(rowIdx) => setExpanded({ detailIdx: i, rowIdx })} + displayMode={d.inlineMode === 'form' ? 'list' : 'grid'} + {...(d.inlineMode === 'form' ? { onAdd: () => addRowViaForm(i) } : {})} field={ { columns: d.columns, @@ -424,7 +455,7 @@ export const MasterDetailForm: React.FC = ({ total_field: d.amountField || (d.totalField ? 'amount' : undefined), min_rows: d.minRows, max_rows: d.maxRows, - add_label: d.addLabel, + add_label: d.inlineMode === 'form' ? (d.addLabel || 'Add') : d.addLabel, } as any } /> @@ -452,7 +483,7 @@ export const MasterDetailForm: React.FC = ({ variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" - onClick={() => setExpanded(null)} + onClick={cancelRowEdit} > Close @@ -475,7 +506,7 @@ export const MasterDetailForm: React.FC = ({ applyRowEdit(expanded.detailIdx, expanded.rowIdx, values); setExpanded(null); }, - onCancel: () => setExpanded(null), + onCancel: cancelRowEdit, } as any} dataSource={dataSource} /> diff --git a/packages/plugin-form/src/deriveMasterDetail.test.ts b/packages/plugin-form/src/deriveMasterDetail.test.ts index db34f07a9..b6ad70e5f 100644 --- a/packages/plugin-form/src/deriveMasterDetail.test.ts +++ b/packages/plugin-form/src/deriveMasterDetail.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { findRelationshipField, deriveColumns, deriveDetail, deriveFormFields, fieldTypeToColumnType } from './deriveMasterDetail'; +import { findRelationshipField, deriveColumns, deriveDetail, deriveFormFields, resolveInlineMode, fieldTypeToColumnType } from './deriveMasterDetail'; const taskSchema = { name: 'showcase_task', @@ -179,6 +179,36 @@ describe('deriveFormFields (per-row expand form)', () => { }); }); +describe('resolveInlineMode (grid vs form)', () => { + const thin = { fields: { name: { type: 'text' }, amount: { type: 'currency' }, parent: { type: 'master_detail', reference: 'p' } } }; + const rich = { fields: { name: { type: 'text' }, notes: { type: 'textarea' }, parent: { type: 'master_detail', reference: 'p' } } }; + const wide = { + fields: Object.fromEntries( + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'].map((n) => [n, { type: 'text' }]) + .concat([['parent', { type: 'master_detail', reference: 'p' }]]), + ), + }; + + it('honors explicit grid/form', () => { + expect(resolveInlineMode(thin, 'grid', { relationshipField: 'parent' })).toBe('grid'); + expect(resolveInlineMode(rich, 'grid', { relationshipField: 'parent' })).toBe('grid'); // explicit wins over heuristic + expect(resolveInlineMode(thin, 'form', { relationshipField: 'parent' })).toBe('form'); + }); + + it('smart default: thin child → grid', () => { + expect(resolveInlineMode(thin, true, { relationshipField: 'parent' })).toBe('grid'); + expect(resolveInlineMode(thin, undefined, { relationshipField: 'parent' })).toBe('grid'); + }); + + it('smart default: child with a rich/form-only type → form', () => { + expect(resolveInlineMode(rich, true, { relationshipField: 'parent' })).toBe('form'); + }); + + it('smart default: many business fields → form', () => { + expect(resolveInlineMode(wide, true, { relationshipField: 'parent' })).toBe('form'); // 9 fields > 8 + }); +}); + describe('deriveDetail', () => { it('resolves relationshipField + columns + amountField from the child schema', () => { const d = deriveDetail('showcase_task', taskSchema, 'showcase_project'); diff --git a/packages/plugin-form/src/deriveMasterDetail.ts b/packages/plugin-form/src/deriveMasterDetail.ts index d44c2e6ac..3c2e2e057 100644 --- a/packages/plugin-form/src/deriveMasterDetail.ts +++ b/packages/plugin-form/src/deriveMasterDetail.ts @@ -218,12 +218,48 @@ export function deriveFormFields( return out; } +/** Inline-edit form factor. */ +export type InlineMode = 'grid' | 'form'; + +/** Rich / form-only field types that read poorly in a narrow grid cell — their + * presence on a child tips the smart default toward the per-row `form`. */ +const FORM_ONLY_TYPES = new Set([ + 'textarea', 'richtext', 'html', 'markdown', 'rich-text', + 'file', 'image', 'avatar', 'attachment', 'json', 'location', 'address', +]); + +/** Above this many editable business fields, the grid gets cramped → `form`. */ +export const SMART_FORM_FIELD_THRESHOLD = 8; + +/** + * Resolve the inline-edit form factor for a child collection. + * - explicit `'grid'` / `'form'` win; + * - otherwise (`true` / undefined) pick by the child's shape: a `form` when it + * has rich/form-only fields or more than {@link SMART_FORM_FIELD_THRESHOLD} + * editable business fields, else a `grid`. + */ +export function resolveInlineMode( + childSchema: ObjectSchemaLike | undefined, + inlineEdit: boolean | InlineMode | undefined, + opts: { relationshipField?: string } = {}, +): InlineMode { + if (inlineEdit === 'grid' || inlineEdit === 'form') return inlineEdit; + const fields = (childSchema?.fields ?? {}) as Record; + const names = deriveFormFields(childSchema, { relationshipField: opts.relationshipField }); + const hasRich = names.some((n) => FORM_ONLY_TYPES.has(fields[n]?.type)); + if (hasRich) return 'form'; + if (names.length > SMART_FORM_FIELD_THRESHOLD) return 'form'; + return 'grid'; +} + export interface DerivedDetail { childObject: string; relationshipField: string; columns: GridColumn[]; /** Field names for the per-row expand form (broader than `columns`). */ formFields: string[]; + /** Inline-edit form factor (grid = editable cells; form = list + per-row form). */ + mode: InlineMode; /** First numeric column, used as the running-total source when none is set. */ amountField?: string; } @@ -238,7 +274,7 @@ export function deriveDetail( childObject: string, childSchema: ObjectSchemaLike | undefined, parentObjectName: string, - override: { relationshipField?: string; columns?: GridColumn[]; amountField?: string } = {}, + override: { relationshipField?: string; columns?: GridColumn[]; amountField?: string; inlineEdit?: boolean | InlineMode } = {}, ): DerivedDetail { const relationshipField = override.relationshipField || findRelationshipField(childSchema, parentObjectName); if (!relationshipField) { @@ -250,5 +286,9 @@ export function deriveDetail( const columns = override.columns?.length ? override.columns : deriveColumns(childSchema, { relationshipField }); const amountField = override.amountField || columns.find((c) => c.type === 'number' || c.type === 'currency')?.field; const formFields = deriveFormFields(childSchema, { relationshipField }); - return { childObject, relationshipField, columns, formFields, amountField }; + // Resolve mode from the explicit override, else the relationship field's + // `inlineEdit` value, else the smart default from the child's shape. + const inlineEdit = override.inlineEdit ?? (childSchema?.fields as any)?.[relationshipField]?.inlineEdit; + const mode = resolveInlineMode(childSchema, inlineEdit, { relationshipField }); + return { childObject, relationshipField, columns, formFields, mode, amountField }; } diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 8a9f10c78..1d8a56ddc 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -48,6 +48,8 @@ export type { } from './MasterDetailForm'; export { LineItemsPanel } from './LineItemsPanel'; export type { LineItemsPanelSchema } from './LineItemsPanel'; +export { deriveDetail, deriveColumns, deriveFormFields, findRelationshipField, resolveInlineMode } from './deriveMasterDetail'; +export type { DerivedDetail, InlineMode } from './deriveMasterDetail'; // Register object-form component const ObjectFormRenderer: React.FC<{ schema: any }> = ({ schema }) => {