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
10 changes: 10 additions & 0 deletions .changeset/subform-ui-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@object-ui/fields": minor
"@object-ui/plugin-form": minor
---

Master-detail entry: lighter layout, compact lookup cells, persisted line order.

- **De-framed line-item section** — the subform no longer double-frames the grid in a `Card` (border + `p-6`); it renders as a light label + the grid's own bordered table, reclaiming the width the line table needs.
- **Compact lookup cells** — `LookupField` gains a `compact` mode (used by grid cells): the selected value shows inline in a borderless single-line trigger instead of a chip stacked above a separate "Select…" button.
- **Persisted drag-reorder** — `deriveMasterDetail` detects a sort field (`position`/`sort_order`/…), excludes it from the editable columns/row-form, and threads it as the grid's `sort_field` so reordering stamps `row[position] = index` and survives a reload.
1 change: 1 addition & 0 deletions e2e/live/form-view-subforms.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ test('New <object> modal renders relationship-derived subforms and submits an at
expect(child?.data?.description).toBe('Standard widget'); // auto-filled
expect(Number(child?.data?.unit_price)).toBe(29.99); // auto-filled
expect(Number(child?.data?.amount)).toBe(59.98); // computed (2 × 29.99)
expect(Number(child?.data?.position)).toBe(0); // sort position stamped from row order
// The empty ghost line must NOT have been persisted as a blank child.
expect(ops.filter((o: any) => o.object === 'showcase_invoice_line')).toHaveLength(1);
});
1 change: 1 addition & 0 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ export function GridField({
value={val}
onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
onSelectRecord={(rec: any) => applyLookupSelection(rowIdx, c, rec)}
compact
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField, multiple: c.multiple, options: c.options, placeholder: '—' } as any}
disabled={disabled}
/>
Expand Down
40 changes: 27 additions & 13 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
import { Button,
import { cn,
Button,
Input,
Badge,
Popover,
Expand Down Expand Up @@ -565,14 +566,20 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
);
}

// Compact mode (e.g. inside a line-item grid cell): show the selected value
// INSIDE a borderless trigger on a single line — no chip stacked above a
// separate "Select…" button (which double-stacks and wastes the row height).
const compact = !!(props as any).compact;
const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];

return (
<div className="space-y-2">
{/* Selected values display */}
{selectedOptions.length > 0 && (
<div className={compact ? '' : 'space-y-2'}>
{/* Selected values display (full mode only — compact shows it in-trigger) */}
{selectedOptions.length > 0 && !compact && (
<div className="flex flex-wrap gap-1">
{selectedOptions.map((opt, idx) => (
<Badge
key={idx}
<Badge
key={idx}
variant="outline"
className="gap-1"
>
Expand All @@ -594,23 +601,30 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
<div className="flex items-center gap-1.5">
<Popover open={isOpen} onOpenChange={(o) => !dependenciesMissing && setIsOpen(o)}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-0 flex-1 justify-start text-left font-normal"
<Button
variant="outline"
className={cn(
'min-w-0 flex-1 justify-start text-left font-normal',
compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
)}
type="button"
disabled={dependenciesMissing || (props as any).disabled}
data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
title={dependenciesMissing
? `Select ${dependsOn.map(d => d.field).join(', ')} first`
: undefined}
>
<Search className="mr-2 size-4" />
<Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
<span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
{dependenciesMissing
? `Select ${dependsOn.map(d => d.field).join(', ')} first`
: selectedOptions.length === 0
? lookupField?.placeholder || t('common.select')
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
: compact && !multiple && selectedOptions.length > 0
? singleSelectedLabel
: selectedOptions.length === 0
? lookupField?.placeholder || t('common.select')
: multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
Expand Down
20 changes: 12 additions & 8 deletions packages/plugin-form/src/MasterDetailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export interface MasterDetailDetailConfig {
inlineMode?: InlineMode;
/** Numeric child column to sum, e.g. 'amount'. */
amountField?: string;
/** Child field holding the line sort position — stamped on drag-reorder so
* order persists. Auto-derived from a `position`/`sort_order`/… field. */
sortField?: string;
/** Parent field to receive the rolled-up sum, e.g. 'total_amount'. */
totalField?: string;
/** Section title. */
Expand Down Expand Up @@ -136,6 +139,7 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
formFields: d.formFields ?? derived.formFields,
inlineMode: d.inlineMode ?? derived.mode,
amountField: d.amountField ?? derived.amountField,
sortField: d.sortField ?? derived.sortField,
};
} catch {
return d; // leave as-is; the grid card will show a config hint
Expand Down Expand Up @@ -475,13 +479,13 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
<ObjectForm key={formKey} schema={parentSchema as any} dataSource={dataSource} />
</div>

{/* 2) Line items below the header */}
{/* 2) Line items below the header. Rendered as a light section (label +
the grid's own bordered table) rather than a heavy Card — a Card here
would double-frame the grid and its p-6 padding wastes the width the
line table needs. */}
{details.map((d, i) => (
<Card key={`${d.childObject}-${i}`} className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{d.title || 'Line Items'}</CardTitle>
</CardHeader>
<CardContent>
<section key={`${d.childObject}-${i}`} className="space-y-2">
<h3 className="text-sm font-medium text-foreground">{d.title || 'Line Items'}</h3>
{!d.columns?.length ? (
<p className="py-4 text-sm text-muted-foreground">Loading columns…</p>
) : (
Expand All @@ -504,15 +508,15 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
// Show the per-grid running total whenever an amount column is
// set — unless the document totals stack below subsumes it.
total_field: showTaxStack ? undefined : (d.amountField || (d.totalField ? 'amount' : undefined)),
sort_field: d.sortField,
min_rows: d.minRows,
max_rows: d.maxRows,
add_label: d.inlineMode === 'form' ? (d.addLabel || 'Add') : d.addLabel,
} as any
}
/>
)}
</CardContent>
</Card>
</section>
))}

{/* Document totals stack (Subtotal / Tax / Total) — the right-aligned block
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin-form/src/deriveMasterDetail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,22 @@ describe('deriveDetail', () => {
expect(d.amountField).toBe('amount'); // running total prefers the computed line total
});

it('detects a sort/position field, excludes it from columns, and reports it as sortField', () => {
const lineSchema = {
fields: {
invoice: { type: 'master_detail', reference: 'inv' },
position: { type: 'number', label: 'Position' },
product: { type: 'text', label: 'Product', required: true },
quantity: { type: 'number', label: 'Qty', required: true },
},
};
const d = deriveDetail('inv_line', lineSchema, 'inv');
expect(d.sortField).toBe('position');
expect(d.columns.map((c) => c.field)).not.toContain('position'); // not user-edited
expect(d.formFields).not.toContain('position');
expect(d.columns.map((c) => c.field)).toEqual(['product', 'quantity']);
});

it('honors explicit overrides over derived values', () => {
const d = deriveDetail('showcase_task', taskSchema, 'showcase_project', {
relationshipField: 'project',
Expand Down
14 changes: 11 additions & 3 deletions packages/plugin-form/src/deriveMasterDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const SYSTEM_FIELDS = new Set([
'organization_id', 'tenant_id', 'space', 'owner',
]);

/** Field names that hold a line's sort position — excluded from the editable
* columns and the row form (the grid stamps them on drag-reorder instead). */
const SORT_FIELD_NAMES = new Set(['position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort']);

/** Field types that are not directly editable in a line-item grid. */
const NON_EDITABLE_TYPES = new Set([
'formula', 'summary', 'rollup', 'autonumber', 'auto_number',
Expand Down Expand Up @@ -168,7 +172,7 @@ export function deriveColumns(
const cols: GridColumn[] = [];
for (const [name, def] of Object.entries(fields)) {
const d = def as any;
if (SYSTEM_FIELDS.has(name) || exclude.has(name)) continue;
if (SYSTEM_FIELDS.has(name) || exclude.has(name) || SORT_FIELD_NAMES.has(name)) continue;
if (d?.system || d?.readonly || d?.hidden) continue;
if (NON_EDITABLE_TYPES.has(d?.type)) continue;
const col: GridColumn = {
Expand Down Expand Up @@ -220,7 +224,7 @@ export function deriveFormFields(
const out: string[] = [];
for (const [name, def] of Object.entries(fields)) {
const d = def as any;
if (SYSTEM_FIELDS.has(name) || exclude.has(name)) continue;
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);
Expand Down Expand Up @@ -294,6 +298,9 @@ export interface DerivedDetail {
mode: InlineMode;
/** First numeric column, used as the running-total source when none is set. */
amountField?: string;
/** Child field holding the line sort position, if any — the grid stamps it on
* drag-reorder so order persists (excluded from the editable columns). */
sortField?: string;
}

/**
Expand Down Expand Up @@ -322,5 +329,6 @@ export function deriveDetail(
// `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 };
const sortField = Object.keys(childSchema?.fields ?? {}).find((n) => SORT_FIELD_NAMES.has(n));
return { childObject, relationshipField, columns, formFields, mode, amountField, sortField };
}
5 changes: 4 additions & 1 deletion packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ describe('ListView', () => {
await vi.waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
expect(screen.getByText('No items found')).toBeInTheDocument();
// With no filters/search this is the FIRST-RUN empty state ("Nothing here
// yet"), which invites the user to create — "No items found" is reserved
// for the filtered/no-matches case (see the hasActiveQuery branch).
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
});

it('should show custom empty state when configured', async () => {
Expand Down
Loading