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: 11 additions & 0 deletions .changeset/b2-grid-rules-and-submit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@object-ui/core": patch
"@object-ui/fields": patch
"@object-ui/plugin-form": patch
"@object-ui/components": patch
---

B2 follow-ups (A): field conditional rules in inline grids + submit-time enforcement.

- **Grids**: a line-item column's `readonlyWhen` / `requiredWhen` CEL rule is now honored per row — `deriveMasterDetail` carries the props onto the `GridColumn` and `GridField` evaluates them against each row via `resolveFieldRuleState` (a `readonlyWhen`-TRUE cell locks; a `requiredWhen`-TRUE empty cell flags inline-invalid). Rules are row-scoped (`record.*`); the core helpers gained an optional `scope` (and `GridField` a `contextRecord` prop) so a future header-driven lock can bind `parent.*` — that wiring is deferred (it needs the master-detail header's re-renders isolated).
- **Submit enforcement**: `requiredWhen` already drove react-hook-form's `required` rule, so submit is blocked with a field error when the predicate is TRUE and the value is empty. Added a reactive cleanup so a stale *required* error clears when the predicate flips FALSE (and all errors clear when a field is hidden by `visibleWhen`).
32 changes: 32 additions & 0 deletions docs/adr/0036-field-conditional-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ paid_on: Field.date({
Covered by the `field-conditional-rules` live e2e (drives Status →
paid/sent/draft and asserts each dependent field re-gates).

## Inline grids (line items)

The same rules apply to **inline line-item grid cells**. `deriveMasterDetail`
carries a column's `readonlyWhen` / `requiredWhen` through to its `GridColumn`,
and `GridField` evaluates them **per row** via `resolveFieldRuleState`:

- A `readonlyWhen`-TRUE cell renders locked (its control is disabled).
- A `requiredWhen`-TRUE empty cell flags inline-invalid on that row
(`data-testid="line-items-invalid-<row>-<field>"`), the same affordance a
statically-required empty cell uses.

Scope: today the grid evaluates against the **row** (`record.*`) — e.g.
`description.requiredWhen = "record.quantity >= 100"` (a bulk line needs a
note). The core helpers also accept an extra `scope` (so a predicate could
reference the header as `parent.*`, e.g. lock a paid invoice's lines), and
`GridField` accepts a `contextRecord` prop for it — but wiring the live header
record into the grid requires isolating the grid's re-renders from the
reset-sensitive master-detail header form (a parent re-render mid-submit can
fire the header's `form.reset`). That header-driven lock is therefore a
**deferred** follow-up; row-scoped rules ship now.

## Submit-time enforcement

`requiredWhen` is enforced not just visually but at **submit**: the form
renderer registers react-hook-form's `required` rule from the *resolved*
(CEL) required state, so saving while the predicate is TRUE and the value is
empty blocks submission and attaches the error to the field. When the predicate
later flips FALSE (e.g. the status that imposed it changes), a reactive effect
clears the now-stale *required* error (react-hook-form keeps an error until the
erroring field itself revalidates) — and a field hidden by `visibleWhen` clears
all of its errors.

## Consequences

- Authors express conditional UX once, on the field, in the same CEL they
Expand Down
37 changes: 37 additions & 0 deletions e2e/live/grid-conditional-rules.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from '@playwright/test';

/**
* B2 in grids (A1): an inline line-item cell honors its column's `requiredWhen`
* CEL rule, evaluated PER ROW against that row's record. The showcase Invoice
* line declares `description.requiredWhen = "record.quantity >= 100"` — a bulk
* line must carry a description — so a row whose quantity crosses the threshold
* flags its (empty) Description cell required inline, and clears once filled.
*
* (This is the row-scoped generalization of B2 to grid cells. A header-driven
* lock — "paid invoice → lock lines", referencing `parent` — is a separate
* deferred capability; see ADR-0036.)
*/
test('a line cell flags required per row from a row-scoped requiredWhen', async ({ page }) => {
await page.goto('/apps/showcase_app/showcase_invoice');
await page.getByRole('button', { name: /^(New|新建)$/i }).first().click();

const dialog = page.getByRole('dialog');
await expect(dialog.getByTestId('md-form-submit')).toBeVisible();

const grid = dialog.getByTestId('line-items');
const qty = grid.locator('input[aria-label="Qty"]').first();
await expect(qty).toBeVisible();

// Below threshold: a small qty leaves Description optional (no invalid flag).
await qty.fill('2');
await expect(grid.getByTestId('line-items-invalid-0-description')).toHaveCount(0);

// Cross the threshold: quantity >= 100 ⇒ Description (still empty) flags
// required inline on that row.
await qty.fill('100');
await expect(grid.getByTestId('line-items-invalid-0-description')).toBeVisible();

// Filling Description clears the flag.
await grid.locator('input[aria-label="Description"]').first().fill('Bulk order');
await expect(grid.getByTestId('line-items-invalid-0-description')).toHaveCount(0);
});
40 changes: 40 additions & 0 deletions e2e/live/required-when-submit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test, expect } from '@playwright/test';
import { selectOption, fillLookup } from './helpers';

/**
* B2 (A2): a `requiredWhen` field is enforced at SUBMIT — the form renderer
* registers react-hook-form's `required` rule from the resolved (CEL) required
* state, so attempting to save while the predicate is TRUE and the value is
* empty blocks submission and attaches the error to that field.
*
* Showcase Invoice: issued_on.requiredWhen = "record.status in ['sent','paid']".
* Status=sent + empty Issued On ⇒ submit blocked, "Issued On is required".
* Status=draft (predicate FALSE) ⇒ no such error.
*/
test('requiredWhen blocks submit with a field error, and relaxes when FALSE', async ({ page }) => {
const batches: any[] = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/v1/batch')) batches.push(r.url());
});

await page.goto('/apps/showcase_app/showcase_invoice');
await page.getByRole('button', { name: /^(New|新建)$/i }).first().click();

const dialog = page.getByRole('dialog');
await expect(dialog.getByTestId('md-form-submit')).toBeVisible();

// Fill the statically-required header fields, leave Issued On empty.
await dialog.locator('input[name="name"]').fill(`INV-${Date.now()}`);
await fillLookup(page, 'account', 'North');

// Status=sent makes issued_on required (CEL). Submit should be blocked with
// the error attached to Issued On.
await selectOption(dialog, 'status', 'sent');
await dialog.getByTestId('md-form-submit').click();
await expect(dialog.getByText(/Issued On is required/i)).toBeVisible();
expect(batches.length).toBe(0); // submission blocked

// Flip to Draft → predicate FALSE → the conditional requirement clears.
await selectOption(dialog, 'status', 'draft');
await expect(dialog.getByText(/Issued On is required/i)).toHaveCount(0);
});
29 changes: 29 additions & 0 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,35 @@ ComponentRegistry.register('form',
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields, JSON.stringify(watched)]);

// When a field's CEL rule relaxes — it becomes hidden (visibleWhen FALSE) or
// no longer required (requiredWhen FALSE) — clear any stale validation error
// left from a prior submit attempt. react-hook-form keeps an error until the
// erroring field itself revalidates; without this a "required" message would
// linger after the condition that imposed it (e.g. status) changed.
React.useEffect(() => {
const errs = form.formState.errors as Record<string, unknown>;
if (!errs || Object.keys(errs).length === 0) return;
for (const f of fields as FormFieldConfig[]) {
const name = f?.name;
if (!name || !errs[name]) continue;
const st = resolveFieldRuleState(
{
visibleWhen: (f as any).visibleWhen,
readonlyWhen: (f as any).readonlyWhen,
requiredWhen: (f as any).requiredWhen,
conditionalRequired: (f as any).conditionalRequired,
},
ruleRecord,
{ required: !!f.required, readonly: (f as any).readonly === true },
);
// A hidden field shows no errors at all; an un-required field clears
// only its *required* error (keep legitimate format/min/etc. errors).
const errType = (errs[name] as { type?: string } | undefined)?.type;
if (!st.visible || (!st.required && errType === 'required')) form.clearErrors(name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ruleRecord]);

// Read DataSource from SchemaRendererContext and propagate it to field
// widgets as a prop so they can dynamically load related records.
const schemaCtx = React.useContext(SchemaRendererContext);
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/evaluator/__tests__/fieldRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ describe('evalFieldPredicate', () => {
expect(evalFieldPredicate("record.status == 'paid'", { status: null }, true)).toBe(false);
});

it('binds an extra scope (parent.*) for inline line-item cells', () => {
// A grid cell can reference both its own row and the header via `parent`.
expect(
evalFieldPredicate("parent.status == 'paid'", { quantity: 2 }, false, undefined, {
parent: { status: 'paid' },
}),
).toBe(true);
expect(
evalFieldPredicate("parent.status == 'paid' || record.quantity == 0", { quantity: 0 }, false, undefined, {
parent: { status: 'draft' },
}),
).toBe(true);
// Without the scope, `parent` is unbound → fault → fallback.
expect(evalFieldPredicate("parent.status == 'paid'", { quantity: 2 }, false)).toBe(false);
});

it('exposes previous.* for transition predicates', () => {
expect(
evalFieldPredicate("record.status == 'paid' && previous.status != 'paid'", { status: 'paid' }, false, {
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/evaluator/fieldRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,25 @@ function toExpression(pred: FieldRulePredicate): Expression {
* `false` for readonly/required (don't lock/block on error),
* `true` for visibility (don't hide on error).
* @param previous The prior persisted record, if any (for `previous.*` refs).
* @param scope Extra top-level scope variables bound alongside `record` —
* e.g. `{ parent }` so an inline line-item cell can reference
* its header (`parent.status == 'paid'`) as well as its own
* row (`record.quantity`). Bound via the engine's `extra`.
*/
export function evalFieldPredicate(
pred: FieldRulePredicate | undefined | null,
record: Record<string, unknown>,
fallback: boolean,
previous?: Record<string, unknown>,
scope?: Record<string, unknown>,
): boolean {
if (pred == null || (typeof pred === 'string' && !pred.trim())) return fallback;
try {
const res = ExpressionEngine.evaluate<boolean>(toExpression(pred), { record, previous });
const res = ExpressionEngine.evaluate<boolean>(toExpression(pred), {
record,
previous,
...(scope ? { extra: scope } : {}),
});
if (!res.ok) return fallback;
return res.value === true;
} catch {
Expand All @@ -83,22 +92,23 @@ export function resolveFieldRuleState(
record: Record<string, unknown>,
statics: { required?: boolean; readonly?: boolean },
previous?: Record<string, unknown>,
scope?: Record<string, unknown>,
): { visible: boolean; readonly: boolean; required: boolean } {
const visible =
rules.visibleWhen != null
? evalFieldPredicate(rules.visibleWhen, record, true, previous)
? evalFieldPredicate(rules.visibleWhen, record, true, previous, scope)
: true;

const readonly =
statics.readonly === true ||
(rules.readonlyWhen != null
? evalFieldPredicate(rules.readonlyWhen, record, false, previous)
? evalFieldPredicate(rules.readonlyWhen, record, false, previous, scope)
: false);

const requiredPred = rules.requiredWhen ?? rules.conditionalRequired;
const required =
statics.required === true ||
(requiredPred != null ? evalFieldPredicate(requiredPred, record, false, previous) : false);
(requiredPred != null ? evalFieldPredicate(requiredPred, record, false, previous, scope) : false);

return { visible, readonly, required };
}
55 changes: 50 additions & 5 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Label,
} from '@object-ui/components';
import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
import { resolveFieldRuleState } from '@object-ui/core';
import { LookupField } from './LookupField';

/**
Expand Down Expand Up @@ -75,6 +76,20 @@ export interface GridColumn {
* sibling columns of the same name (e.g. a product's unit_price/description).
* On by default for lookup columns; set `false` to disable the auto-fill. */
autofill?: boolean;
/**
* CEL predicate: when TRUE for this row, the cell is **read-only** (B2 field
* rules, generalized to grid cells). Evaluated per row against the row as
* `record` plus the header as `parent` (so a line locks when
* `parent.status == 'paid'` *or* on an intra-row condition like
* `record.kind == 'auto'`). Client-side UX; fails open (stays editable).
*/
readonlyWhen?: string | { dialect?: string; source: string };
/**
* CEL predicate: when TRUE for this row, the cell is **required** (flagged
* inline-invalid while empty). Same `record` + `parent` scope as
* {@link readonlyWhen}.
*/
requiredWhen?: string | { dialect?: string; source: string };
}

type Row = Record<string, any>;
Expand Down Expand Up @@ -291,10 +306,35 @@ export function GridField({
/** In 'list' mode, "Add" calls this (host opens the full form for a new row)
* instead of inserting a blank inline row. */
onAdd?: () => void;
/** The header/parent record, bound as `parent` when evaluating a column's
* `readonlyWhen` / `requiredWhen` CEL predicate — so a line cell can react to
* the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */
contextRecord?: Record<string, unknown>;
}) {
const cfg = (field || (props as any).schema || {}) as any;
const allColumns: GridColumn[] = cfg.columns || [];
const rows: Row[] = Array.isArray(value) ? value : [];
const contextRecord = (props as any).contextRecord as Record<string, unknown> | undefined;

// Per-cell CEL rule state (B2 in grids). A column with no readonlyWhen/
// requiredWhen resolves to its static flags (cheap fast-path — no engine
// call). Otherwise evaluate against the row (`record`) + header (`parent`).
const cellRules = useCallback(
(c: GridColumn, row: Row): { readonly: boolean; required: boolean } => {
if (!c.readonlyWhen && !c.requiredWhen) {
return { readonly: false, required: !!c.required };
}
const s = resolveFieldRuleState(
{ readonlyWhen: c.readonlyWhen, requiredWhen: c.requiredWhen },
(row || {}) as Record<string, unknown>,
{ required: !!c.required, readonly: false },
undefined,
contextRecord ? { parent: contextRecord } : undefined,
);
return { readonly: s.readonly, required: s.required };
},
[contextRecord],
);
// List mode: rows are read-only at-a-glance; editing happens in the full form.
const isList = displayMode === 'list' && !readonly;

Expand Down Expand Up @@ -620,6 +660,8 @@ export function GridField({
* editable borderless control (spreadsheet feel). */
const renderCellInput = (c: GridColumn, colIdx: number, rowIdx: number, row: Row) => {
const val = row?.[c.field];
// A readonlyWhen-TRUE cell is locked: treat like the form-wide `disabled`.
const locked = disabled || cellRules(c, row).readonly;
// List (form-factor) mode → read-only at-a-glance display.
if (isList) {
if (c.type === 'lookup' && val != null && val !== '') {
Expand Down Expand Up @@ -654,13 +696,13 @@ export function GridField({
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}
disabled={locked}
/>
);
}
if (c.type === 'select') {
return (
<Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={disabled}>
<Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={locked}>
<SelectTrigger className="h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus:ring-1 focus:ring-ring/60" aria-label={c.label || c.field}>
<SelectValue placeholder="—" />
</SelectTrigger>
Expand Down Expand Up @@ -690,7 +732,7 @@ export function GridField({
aria-label={c.label || c.field}
value={val != null ? String(val) : ''}
onChange={(e) => setCell(rowIdx, c, e.target.value)}
disabled={disabled}
disabled={locked}
/>
</div>
);
Expand Down Expand Up @@ -772,8 +814,11 @@ export function GridField({
)}
{columns.map((c, colIdx) => {
// Inline validation: a required, non-computed cell that's
// empty on a real (non-ghost) row flags red in place.
const invalid = !isGhost && !isList && !!c.required && !c.computed && (row[c.field] == null || row[c.field] === '');
// empty on a real (non-ghost) row flags red in place. The
// "required" verdict honors a column's `requiredWhen` CEL
// rule (B2), evaluated against the row + parent header.
const required = cellRules(c, row).required;
const invalid = !isGhost && !isList && required && !c.computed && (row[c.field] == null || row[c.field] === '');
return (
<td
key={c.field}
Expand Down
17 changes: 17 additions & 0 deletions packages/plugin-form/src/deriveMasterDetail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ describe('deriveColumns', () => {
expect(byName.budget.type).toBe('currency');
expect(byName.assignee).toMatchObject({ type: 'lookup', reference: 'user', displayField: 'name' });
});

it('carries field-level CEL conditional rules (readonlyWhen / requiredWhen) onto columns', () => {
const schema = {
name: 'line',
fields: {
parent: { type: 'master_detail', reference: 'order' },
qty: { type: 'number', label: 'Qty', readonlyWhen: "parent.status == 'paid'" },
note: { type: 'text', label: 'Note', requiredWhen: 'record.qty >= 100' },
memo: { type: 'text', label: 'Memo', conditionalRequired: 'record.qty >= 1' },
},
};
const byName = Object.fromEntries(deriveColumns(schema, { relationshipField: 'parent' }).map((c) => [c.field, c]));
expect(byName.qty.readonlyWhen).toBe("parent.status == 'paid'");
expect(byName.note.requiredWhen).toBe('record.qty >= 100');
// conditionalRequired is carried as the requiredWhen alias.
expect(byName.memo.requiredWhen).toBe('record.qty >= 1');
});
});

describe('deriveColumns curation (column budget)', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin-form/src/deriveMasterDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export function deriveColumns(
col.reference = d?.reference;
col.displayField = d?.display_field || d?.reference_field;
}
// Field-level CEL conditional rules (B2 in grids). Carried through verbatim
// so the grid cell evaluates them per row (against the row + `parent`
// header). requiredWhen falls back to the conditionalRequired alias.
if (d?.readonlyWhen) col.readonlyWhen = d.readonlyWhen;
if (d?.requiredWhen ?? d?.conditionalRequired) col.requiredWhen = d.requiredWhen ?? d.conditionalRequired;
// A field carrying an arithmetic `expression` (e.g. amount = quantity *
// unit_price) becomes a live read-only computed column. The expression may
// be a bare string or the normalized CEL envelope `{ dialect, source }`.
Expand Down
Loading