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
56 changes: 56 additions & 0 deletions .changeset/8069-visiblewhen-fault-refuses-submit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@object-ui/types': minor
'@object-ui/core': minor
'@object-ui/i18n': minor
'@object-ui/components': patch
'@object-ui/plugin-form': patch
'@object-ui/console': patch
---

fix(types,components,plugin-form,console,core): a faulted `visibleWhen` refuses the submit, naming the field and the rule; a blank field rule is refused; blank gates are diagnosed (objectui#8069)

⚠️ **User-visible, and a narrowing.** A form whose field `visibleWhen` cannot be
evaluated — a typo in a column name, a syntax error, an unbound root — used to
render the field (fail-open) and submit as if the rule had said "show". It now
still renders the field, and **refuses the submit** with a message that names
the field and the rule (`form.visibleWhenFaulted`). This is ADR-0137 D2 as
ruled for objectui#8069 (Q1 = B, one judge per rule): no server evaluates a
field's `visibleWhen`, so its fail-open render direction (D3) was a silent grant
— a field the working rule would have hidden, drawn, edited and written. The
refusal applies on the record form renderer (`form.tsx`, every `ObjectForm`
layout), the console's `/forms/:name` and `/f/:slug` page, and the wizard's
cross-step gate at final submit.

- `requiredWhen` / `readonlyWhen` are **unchanged on the client**: the server
evaluates both and refuses a faulted one itself (ADR-0137 D2), and the form
renderer shows that field-attributed refusal beside the input.
- **Accepted residuals:** a `visibleWhen` reading `previous` cannot be
evaluated on a CREATE form, so such a form is refused on every submit; and
the wizard's cross-step gate binds no `previous` in either mode, so the same
rule is refused at the final submit of an EDIT wizard too.
- A **blank** field rule (`''`, whitespace, an envelope whose `source` is
blank) is a fault, not "no rule" (ADR-0137 D2). A STORED blank `visibleWhen`
is refused at submit on the same three paths; a stored blank `requiredWhen` /
`readonlyWhen` is the server's to refuse.

⚠️ **Narrowing (`@object-ui/types`): `FormFieldSchema` refuses a blank field
rule at parse.** `visibleWhen`, `readonlyWhen` and `requiredWhen` on a form
field now refuse a predicate that is blank after trimming, with the spec's own
sentence (`EVALUATED_EXPRESSION_SOURCE_REQUIRED`) — ADR-0137 D1, the same
refusal `@objectstack/spec` makes on `FieldSchema`. The accepted SHAPE is
unchanged (a string or `{ dialect?, source }`, `ExpressionWireSchema`'s own
arms): only the blank value is taken out. A blank GATE — `BaseSchema`'s
`visible` / `hidden` / `disabled`, a form field's view-level `visibleOn`, an
option's `visibleWhen` — still parses and is still read as "no gate".

**Added (`@object-ui/core`):** `resolveFieldRuleState` returns `faults` beside
its three verdicts — the per-rule fault report the submit paths read, filled
from the same evaluation — and its type, `FieldRuleFaults`, is exported.
**Added (`@object-ui/i18n`):** the `form.visibleWhenFaulted` key in all ten
packs.

**Diagnosed, no verdict changed (ADR-0137 D4):** a blank CEL gate reaching
`ExpressionEvaluator.evaluateCondition`, and a blank gate folded to "no gate" by
`hasDeclaredPredicate`, now each report once through the same `[blank]` channel
field-rule faults use. Both verdicts (objectui#3850 / #3960) are unchanged,
`throwOnError` included.
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,8 @@ describe('objectui#5627 — the keys reach the rows, in their own slots', () =>
visible: true,
readonly: false,
required: false,
// No rule, no fault — the report the submit check reads (objectui#8069).
faults: {},
});
expect(resolveRowState(byName.viewHidden, values, null, isCreate).visible).toBe(false);
expect(resolveRowState(byName.objectHidden, values, null, isCreate).visible).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
*
* The shared widgets announce required on the state channel and never arm the
* native attribute the hand-rolled controls carried, so the page refuses a
* submit that leaves a required row empty itself (`findMissingRequired`). The
* submit that leaves a required row empty itself (`findSubmitRefusals`). The
* refusal is pinned with its control: the same form, filled, submits.
*/

Expand Down
74 changes: 62 additions & 12 deletions apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
isRuntimeDefault,
isServerOwnedValue,
resolveFieldRuleState,
type FieldRuleFaults,
type FieldRulePredicate,
} from '@object-ui/core';
import { omitServerResolvedDefaults, resolveSectionGroupReferences } from '@object-ui/plugin-form';
Expand Down Expand Up @@ -994,7 +995,7 @@ export function resolveRowState(
previous: Record<string, unknown> | null | undefined,
isCreateForm: boolean,
predicateScope?: Record<string, unknown>,
): { visible: boolean; readonly: boolean; required: boolean } {
): { visible: boolean; readonly: boolean; required: boolean; faults: FieldRuleFaults } {
const ruleState = resolveFieldRuleState(
field.rules ?? {},
values,
Expand All @@ -1018,6 +1019,10 @@ export function resolveRowState(
visible: ruleState.visible && viewVisible,
readonly: ruleState.readonly,
required: ruleState.required,
// The OBJECT-level rules' fault report, passed through untouched for the
// submit check (objectui#8069). The view-level predicate has none: it is
// a layout gate, evaluated by `isFieldVisible`, and not a field rule.
faults: ruleState.faults,
};
}

Expand Down Expand Up @@ -1783,7 +1788,7 @@ function needsDependentValues(widgetKey: string): boolean {
* attribute. The widget contract refuses a `required` boolean by name so the
* asterisk keeps one author, and arming the browser's constraint bubble
* would put a second validator beside this page's own submit check (see
* {@link findMissingRequired});
* {@link findSubmitRefusals});
* - readonly as `disabled`: the control stays on screen, labelled and inert,
* which is what this page's rows have always done. A widget's `readonly`
* branch renders a replacement display that drops the host id, and the
Expand Down Expand Up @@ -1846,8 +1851,31 @@ function FieldInput({ field, state, value, onChange, values, onUploadingChange }
}

/**
* The rows that would submit EMPTY while their effective verdict says required
* — the page's own required check (objectui#10179).
* What this page's own submit check refuses, read off ONE {@link resolveRowState}
* call per row — so the refusal is decided by the evaluation that drew the row,
* never by a second one.
*
* ## `faultedVisibleWhen` — the rule no server judges (objectui#8069)
*
* The rows whose OBJECT-level `visibleWhen` could not be evaluated. ADR-0137
* D2, as ruled for objectui#8069 (Q1 = B, one judge per rule): the client
* refuses the submit on this one rule, because no server evaluates it and its
* fail-open render direction (D3) would otherwise be a silent grant.
* `requiredWhen` / `readonlyWhen` faults are the server's to refuse; this page
* keeps their render direction and warning unchanged. A stored BLANK
* `visibleWhen` is a fault like any other (ADR-0137 D2, `FieldRuleFaults`); the
* view-level predicate is not a field rule at all but a layout gate, and is
* not judged.
*
* Judged over the rows the required check below walks — a hidden SECTION is
* skipped by both. On this page visibility decides what is DRAWN and nothing
* else (a hidden row's value still submits, unchanged), so a row a hidden
* section already keeps off screen cannot be shown by its own broken rule, and
* there is no grant to refuse.
*
* ## `missing` — the rows that would submit EMPTY while required
*
* The page's own required check (objectui#10179).
*
* The hand-rolled controls this page used to render carried the NATIVE
* `required` attribute, so the browser refused such a submit on the text-like
Expand All @@ -1859,24 +1887,26 @@ function FieldInput({ field, state, value, onChange, values, onUploadingChange }
* `0` are values), and skips exactly what a browser skips — a row that is not
* on screen, and a locked one.
*/
function findMissingRequired(
function findSubmitRefusals(
sections: RenderableSection[],
values: Record<string, unknown>,
previous: Record<string, unknown> | null | undefined,
isCreateForm: boolean,
predicateScope?: Record<string, unknown>,
): RenderableField[] {
): { faultedVisibleWhen: RenderableField[]; missing: RenderableField[] } {
const faultedVisibleWhen: RenderableField[] = [];
const missing: RenderableField[] = [];
for (const sec of sections) {
if (!isSectionVisible(sec, values, previous, predicateScope)) continue;
for (const f of sec.fields) {
const state = resolveRowState(f, values, previous, isCreateForm, predicateScope);
if (state.faults.visibleWhen !== undefined) faultedVisibleWhen.push(f);
if (state.visible && state.required && !state.readonly && isMissingForRequired(values[f.name])) {
missing.push(f);
}
}
}
return missing;
return { faultedVisibleWhen, missing };
}

// ─── Main component ─────────────────────────────────────────────────
Expand Down Expand Up @@ -2117,12 +2147,32 @@ export function FormPage({ mode, recordPath }: FormPageProps) {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!loaded) return;
// The client-side refusals — see `findSubmitRefusals`. Both go out on the
// page's failure channel and the outcome toast id, so a retry that
// succeeds supersedes them exactly as it supersedes a refused write
// (objectui#7252).
const { faultedVisibleWhen, missing } = findSubmitRefusals(
sections,
values,
loaded.record,
isCreateForm,
predicateScope,
);
// A faulted `visibleWhen` first (objectui#8069): it names the field and
// the rule, and nothing typed into the form can clear it, so the required
// check behind it would only ask for work that is refused anyway.
if (faultedVisibleWhen.length > 0) {
const msg = t('form.visibleWhenFaulted', {
fields: faultedVisibleWhen
.map((f) => fieldLabel(loaded.object, f.name, f.label))
.join(t('validation.formInvalidJoiner')),
});
setError(msg);
toast.error(msg, { id: outcomeToastId });
return;
}
// The client-side required refusal the native attribute used to give the
// hand-rolled controls (objectui#10179) — see `findMissingRequired`. It
// goes out on the page's failure channel and the outcome toast id, so a
// retry that succeeds supersedes it exactly as it supersedes a refused
// write (objectui#7252).
const missing = findMissingRequired(sections, values, loaded.record, isCreateForm, predicateScope);
// hand-rolled controls (objectui#10179).
if (missing.length > 0) {
// One catalogue frame around the labels (objectui#11071), joined with
// the pack's own list separator: the colon, its spacing and the order of
Expand Down
Loading
Loading