diff --git a/.changeset/b2-grid-rules-and-submit.md b/.changeset/b2-grid-rules-and-submit.md new file mode 100644 index 000000000..dd55d8f22 --- /dev/null +++ b/.changeset/b2-grid-rules-and-submit.md @@ -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`). diff --git a/docs/adr/0036-field-conditional-rules.md b/docs/adr/0036-field-conditional-rules.md index 6e313c2ac..f47b8bb0c 100644 --- a/docs/adr/0036-field-conditional-rules.md +++ b/docs/adr/0036-field-conditional-rules.md @@ -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--"`), 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 diff --git a/e2e/live/grid-conditional-rules.spec.ts b/e2e/live/grid-conditional-rules.spec.ts new file mode 100644 index 000000000..677193119 --- /dev/null +++ b/e2e/live/grid-conditional-rules.spec.ts @@ -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); +}); diff --git a/e2e/live/required-when-submit.spec.ts b/e2e/live/required-when-submit.spec.ts new file mode 100644 index 000000000..7d004abee --- /dev/null +++ b/e2e/live/required-when-submit.spec.ts @@ -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); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index a67d8c335..89379770d 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -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; + 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); diff --git a/packages/core/src/evaluator/__tests__/fieldRules.test.ts b/packages/core/src/evaluator/__tests__/fieldRules.test.ts index 0f3e622ee..e31ccd312 100644 --- a/packages/core/src/evaluator/__tests__/fieldRules.test.ts +++ b/packages/core/src/evaluator/__tests__/fieldRules.test.ts @@ -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, { diff --git a/packages/core/src/evaluator/fieldRules.ts b/packages/core/src/evaluator/fieldRules.ts index b259d52a9..cb147eba1 100644 --- a/packages/core/src/evaluator/fieldRules.ts +++ b/packages/core/src/evaluator/fieldRules.ts @@ -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, fallback: boolean, previous?: Record, + scope?: Record, ): boolean { if (pred == null || (typeof pred === 'string' && !pred.trim())) return fallback; try { - const res = ExpressionEngine.evaluate(toExpression(pred), { record, previous }); + const res = ExpressionEngine.evaluate(toExpression(pred), { + record, + previous, + ...(scope ? { extra: scope } : {}), + }); if (!res.ok) return fallback; return res.value === true; } catch { @@ -83,22 +92,23 @@ export function resolveFieldRuleState( record: Record, statics: { required?: boolean; readonly?: boolean }, previous?: Record, + scope?: Record, ): { 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 }; } diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index dd5ede7f4..8f5a6f01d 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -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'; /** @@ -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; @@ -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; }) { 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 | 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, + { 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; @@ -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 !== '') { @@ -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 ( - setCell(rowIdx, c, v)} disabled={locked}> @@ -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} /> ); @@ -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 ( { 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)', () => { diff --git a/packages/plugin-form/src/deriveMasterDetail.ts b/packages/plugin-form/src/deriveMasterDetail.ts index 7a2cbacdf..9766a7cf7 100644 --- a/packages/plugin-form/src/deriveMasterDetail.ts +++ b/packages/plugin-form/src/deriveMasterDetail.ts @@ -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 }`.