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
11 changes: 8 additions & 3 deletions packages/app-shell/src/providers/MetadataProvider.merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 } : {}),
Expand Down
38 changes: 38 additions & 0 deletions packages/fields/src/widgets/GridField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<GridField
value={[{ title: 'Ship it', status: 'todo' }]}
onChange={() => {}}
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(
<GridField value={[]} onChange={onChange} field={listField} displayMode="list" onRowExpand={() => {}} 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(<GridField value={[{ description: '', amount: null }]} onChange={onChange} field={field} />);
Expand Down
46 changes: 43 additions & 3 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ const MIN_WIDTH_BY_TYPE: Record<string, number> = {
};
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;
Expand All @@ -101,17 +117,28 @@ export function GridField({
disabled,
className,
onRowExpand,
displayMode,
onAdd,
...props
}: FieldWidgetProps<Row[]> & {
/** When provided, each row shows an "expand" button that opens the row in a
* full form (the host — e.g. MasterDetailForm — renders the drawer/modal and
* 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
Expand Down Expand Up @@ -364,8 +391,21 @@ export function GridField({
</td>
)}
{columns.map((c) => (
<td key={c.field} className="px-2 py-1.5 align-top">
{c.type === 'lookup' ? (
<td key={c.field} className={cn('px-2 py-1.5', isList ? 'align-middle' : 'align-top')}>
{isList ? (
c.type === 'lookup' && row[c.field] != null && row[c.field] !== '' ? (
<LookupField
value={row[c.field]}
onChange={() => {}}
readonly
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
/>
) : (
<span className={cn('text-sm text-foreground', isNumeric(c.type) && 'tabular-nums', (row[c.field] == null || row[c.field] === '') && 'text-muted-foreground')}>
{displayText(c, row[c.field])}
</span>
)
) : c.type === 'lookup' ? (
<LookupField
value={row[c.field]}
onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
Expand Down Expand Up @@ -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"
>
Expand Down
41 changes: 36 additions & 5 deletions packages/plugin-form/src/MasterDetailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'. */
Expand All @@ -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'. */
Expand Down Expand Up @@ -126,6 +130,7 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
relationshipField: derived.relationshipField,
columns: derived.columns,
formFields: d.formFields ?? derived.formFields,
inlineMode: d.inlineMode ?? derived.mode,
amountField: d.amountField ?? derived.amountField,
};
} catch {
Expand Down Expand Up @@ -197,7 +202,9 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
// 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;
Expand All @@ -215,6 +222,28 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
[],
);

/** 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
Expand Down Expand Up @@ -416,6 +445,8 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
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,
Expand All @@ -424,7 +455,7 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
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
}
/>
Expand Down Expand Up @@ -452,7 +483,7 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => setExpanded(null)}
onClick={cancelRowEdit}
>
Close
</Button>
Expand All @@ -475,7 +506,7 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
applyRowEdit(expanded.detailIdx, expanded.rowIdx, values);
setExpanded(null);
},
onCancel: () => setExpanded(null),
onCancel: cancelRowEdit,
} as any}
dataSource={dataSource}
/>
Expand Down
32 changes: 31 additions & 1 deletion packages/plugin-form/src/deriveMasterDetail.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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');
Expand Down
44 changes: 42 additions & 2 deletions packages/plugin-form/src/deriveMasterDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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;
}
Expand All @@ -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) {
Expand All @@ -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 };
}
2 changes: 2 additions & 0 deletions packages/plugin-form/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading