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
14 changes: 14 additions & 0 deletions .changeset/gated-options-keep-stored-value-4247.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
---
'@object-ui/fields': patch
'@object-ui/components': patch
---

A dependency-gated option list no longer deletes the field's stored value on mount

The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is *gated* — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.

Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.

Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.

Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* A GATED option list never deletes the form's stored value (objectui#4247).
*
* The form renderer runs its OWN cascade clear (#2284) alongside the widgets':
* for every option field it resolves the offered set and `form.setValue(name,
* undefined)` when the current value is no longer offered. `resolveCascadingOptions`
* returns an EMPTY set whenever the list is gated — a declared `dependsOn`
* parent is still empty — so a record that simply ARRIVES with the parent empty
* (a later-cleared parent, an import, a partially-migrated row) had its
* dependent picklist wiped on mount, before any interaction, while the field
* rendered "select Country first" beside it.
*
* Ruling (#4247): **gated means UNKNOWN, not invalid.** Missing information is
* not a reason to destroy stored data. Convergence stays where ADR-0058 put it:
* once the parent IS chosen and the resolved set genuinely excludes the value,
* the prune applies — pinned as the control below.
*
* This is the FORM host of the same defect pinned per-widget in
* `@object-ui/fields` (`optionWidgets.gatedOptions.test.tsx`). The two clears
* are independent code paths reading the same resolver, so both are pinned:
* these components tests never load the fields package, so nothing but
* `form.tsx`'s own effect can move the value here.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
// Module-scope import (not `beforeAll`) — objectui#3010.
import '../../../renderers';

function renderForm(schema: Record<string, unknown>) {
const Form = ComponentRegistry.get('form')!;
return render(<Form schema={{ type: 'form', submitLabel: 'Save', ...schema }} />);
}

const PROVINCE_OPTIONS = [
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
];

const FIELDS = [
{ name: 'country', label: 'Country', type: 'input' },
{
name: 'province',
label: 'Province',
type: 'select',
dependsOn: 'country',
options: PROVINCE_OPTIONS,
},
{
name: 'provinces',
label: 'Provinces',
type: 'multiselect',
dependsOn: 'country',
options: PROVINCE_OPTIONS,
},
];

async function submitAndRead(onSubmit: ReturnType<typeof vi.fn>) {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
return onSubmit.mock.calls[0][0] as Record<string, unknown>;
}

describe('form renderer — a GATED option list keeps the stored value (#4247)', () => {
it('submits the record it was given when the controlling field arrives empty', async () => {
const onSubmit = vi.fn();
renderForm({
fields: FIELDS,
defaultValues: { country: '', province: 'zj', provinces: ['zj'] },
onSubmit,
});

// The gate is up: the form is telling the user it cannot offer anything.
expect(screen.getAllByText(/select country first/i).length).toBeGreaterThan(0);

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBe('zj');
expect(payload.provinces).toEqual(['zj']);
});
});

describe('control — a RESOLVED list still prunes what it does not offer (ADR-0058)', () => {
it('clears the values the chosen parent excludes', async () => {
const onSubmit = vi.fn();
renderForm({
fields: FIELDS,
// Parent IS chosen, and `ca` is a US province — genuinely excluded.
defaultValues: { country: 'cn', province: 'ca', provinces: ['ca'] },
onSubmit,
});

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.provinces).toEqual([]);
});

it('keeps the values the chosen parent still offers', async () => {
const onSubmit = vi.fn();
renderForm({
fields: FIELDS,
defaultValues: { country: 'cn', province: 'zj', provinces: ['zj'] },
onSubmit,
});

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBe('zj');
expect(payload.provinces).toEqual(['zj']);
});
});

describe('transition — the gate lifting is what converges the value (#4247)', () => {
it('prunes only once the user picks a parent that excludes the stored value', async () => {
const onSubmit = vi.fn();
renderForm({
fields: FIELDS,
defaultValues: { country: '', province: 'zj', provinces: ['zj'] },
onSubmit,
});

// Gated on mount — nothing staged yet.
expect(screen.getAllByText(/select country first/i).length).toBeGreaterThan(0);

// The user picks a country whose list does NOT contain the stored value.
fireEvent.change(screen.getByLabelText(/country/i), { target: { value: 'us' } });
await waitFor(() =>
expect(screen.queryAllByText(/select country first/i)).toHaveLength(0),
);

const payload = await submitAndRead(onSubmit);
expect(payload.province).toBeUndefined();
expect(payload.provinces).toEqual([]);
});
});
20 changes: 16 additions & 4 deletions packages/components/src/renderers/form/form.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -920,10 +920,22 @@ ComponentRegistry.register('form',
if (!hasOptionPredicate && !dependsOn) continue;
const current = form.getValues(name);
if (current === undefined || current === null || current === '') continue;
// While gated (a dependency is empty) the whole list is withheld — clear
// any prior value so it can't linger past a parent reset. Same shared
// resolver the widgets use, so gating/filtering stays in lockstep.
const { options: visible } = resolveCascadingOptions(opts, ruleRecord, dependsOn, predicateScope);
// Same shared resolver the widgets use, so gating/filtering stays in
// lockstep — including the guard below.
const { options: visible, gated } = resolveCascadingOptions(opts, ruleRecord, dependsOn, predicateScope);
// Gated ≠ invalid (objectui#4247). While a dependency is empty the whole
// list is WITHHELD, so `visible` is empty for a reason that says nothing
// about the stored value — and this effect runs on mount, so a record
// that merely ARRIVES with its controlling field empty (a later-cleared
// parent, an import, a partially-migrated row) had the dependent
// picklist wiped before the user touched anything, while the field
// rendered "select the parent first" beside it. Gating is missing
// information, not a verdict; the cascade converges when the parent IS
// chosen and the resolved set genuinely excludes the value, which is the
// clear below, unchanged. The four option WIDGETS carry the matching
// guard — this effect is an independent second clear on the form host,
// and the widgets' fix does not reach it.
if (gated) continue;
if (!isValueStillOffered(current, visible)) {
form.setValue(name, Array.isArray(current) ? [] : undefined, {
shouldValidate: false,
Expand Down
5 changes: 5 additions & 0 deletions packages/fields/src/widgets/CheckboxesField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,11 @@ export function CheckboxesField({
// Never configured → nothing to prune against; see `MultiSelectField`'s
// copy of this guard for the measured failure (objectui#4220).
if (rawOptions.length === 0) return;
// Gated → the authored list is withheld until the `dependsOn` parent is
// chosen, so the empty offered set is missing information, not a verdict on
// the stored value; clearing here fired on mount (objectui#4247). Same
// reasoning, at length, in `MultiSelectField`.
if (gated) return;
if (selected.length === 0) return;
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
if (stillOffered.length !== selected.length) onChange(stillOffered);
Expand Down
35 changes: 26 additions & 9 deletions packages/fields/src/widgets/MultiSelectField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,16 +53,33 @@ export function MultiSelectField({
// the scalar case we prune per-element rather than clearing the whole field.
useEffect(() => {
if (readonly) return;
// Nothing was ever CONFIGURED to prune against (objectui#4220). An empty
// offered set has two very different causes: a list that cascaded down to
// zero (a real decision — clear), and a field authored with no `options` at
// all (no decision — the widget renders its "unfillable" state below). In
// the second case pruning is not a cascade, it is deleting the stored value
// of a field the user was only ever shown a hint for — measured on the
// detail page's inline editor, which stages that empty array into the
// record draft the moment the row enters edit mode, and on the grid's
// inline cell editor, which has always taken this path.
// An empty offered set has THREE very different causes, and only one of them
// is a decision to prune against. This is the canonical copy of the guards;
// the other three option widgets carry the same pair.
//
// 1. Nothing was ever CONFIGURED (objectui#4220) — a field authored with no
// `options` at all. No decision was made anywhere, and the widget renders
// its "unfillable" state below. Pruning here is not a cascade, it is
// deleting the stored value of a field the user was only ever shown a
// hint for — measured on the detail page's inline editor, which stages
// that empty array into the record draft the moment the row enters edit
// mode, and on the grid's inline cell editor, which has always taken this
// path.
if (rawOptions.length === 0) return;
// 2. The list is GATED (objectui#4247) — an authored list withheld because a
// declared `dependsOn` parent is still empty, so `resolveCascadingOptions`
// returns nothing at all. Gated means UNKNOWN, not invalid: the widget has
// no information about which stored values are valid, which is exactly
// what the `OptionsEmptyState` below tells the user ("select Country
// first"). Clearing here fired on MOUNT with no interaction — a record
// that merely ARRIVES with the parent empty (a later-cleared parent, an
// import, a partially-migrated row) had its picklist silently staged for
// deletion. Read from the resolver's own `gated` flag rather than
// re-derived from `options.length === 0`, which would collide with case 1.
if (gated) return;
// 3. The list RESOLVED and genuinely excludes the value — a real cascade
// (the parent changed, a predicate flipped). That is the ADR-0058
// contract and it still prunes, below.
if (selected.length === 0) return;
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
if (stillOffered.length !== selected.length) onChange(stillOffered);
Expand Down
5 changes: 5 additions & 0 deletions packages/fields/src/widgets/RadioField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,11 @@ export function RadioField({
// Never configured → nothing to prune against; see `MultiSelectField`'s
// copy of this guard for the measured failure (objectui#4220).
if (rawOptions.length === 0) return;
// Gated → the authored list is withheld until the `dependsOn` parent is
// chosen, so the empty offered set is missing information, not a verdict on
// the stored value; clearing here fired on mount (objectui#4247). Same
// reasoning, at length, in `MultiSelectField`.
if (gated) return;
if (value === undefined || value === null || (value as unknown) === '') return;
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
5 changes: 5 additions & 0 deletions packages/fields/src/widgets/SelectField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,11 @@ function SingleSelectField({
// cascade prunes, and all four render the same `OptionsEmptyState` when
// there is none.
if (rawOptions.length === 0) return;
// Gated → the authored list is withheld until the `dependsOn` parent is
// chosen, so the empty offered set is missing information, not a verdict on
// the stored value; clearing here fired on mount (objectui#4247). Same
// reasoning, at length, in `MultiSelectField`.
if (gated) return;
if (value === undefined || value === null || (value as unknown) === '') return;
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Loading
Loading