Skip to content

Commit 14cb729

Browse files
os-zhuangclaude
andauthored
feat(fields): MultiSelectField per-option visibleWhen cascading + dependsOn gating (#2715) (#2717)
Closes the parity gap #2709 opened: when a `select` + `multiple` (and the `multiselect` type) began delegating to the multi-value chip picker, that picker did not implement the ADR-0058 per-option `visibleWhen` cascading / `dependsOn` gating the single `SelectField` has, so a multi-select with dependent options showed the unfiltered set. - Extract `useCascadingOptions` — the shared hook resolving per-option `visibleWhen` filtering, `dependsOn` gating, and the live `dependentValues` + predicate-scope wiring — and route both `SingleSelectField` and `MultiSelectField` through it (no duplicated resolver). - `MultiSelectField` narrows its offered chips against the live record + `current_user`, gates behind a "select the parent first" hint while a `dependsOn` field is empty, and surfaces a legible empty state. - Cascade-clear prunes only the now-invalid selections (array analogue of the single select's clear), keeping still-offered ones. - Tests mirror `SelectField.cascade.test.tsx`; README + changeset updated. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 16bcffc commit 14cb729

6 files changed

Lines changed: 323 additions & 49 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@object-ui/fields": minor
3+
---
4+
5+
feat(fields): MultiSelectField per-option `visibleWhen` cascading + `dependsOn` gating (parity with single select, #2715)
6+
7+
The multi-value chip picker now implements the same ADR-0058 option
8+
resolution as the single `SelectField`, closing the gap #2709 opened when a
9+
`select` + `multiple` (and the `multiselect` type) started delegating to it.
10+
11+
- Extracted `useCascadingOptions` — the shared hook that resolves per-option
12+
`visibleWhen` filtering, `dependsOn` gating, and the live `dependentValues` +
13+
predicate-scope wiring — and routed both `SingleSelectField` and
14+
`MultiSelectField` through it (no duplicated resolver).
15+
- `MultiSelectField` narrows its offered chips against the live record +
16+
`current_user`, gates behind a "select the parent first" hint while a
17+
`dependsOn` field is empty, and surfaces a legible empty state instead of a
18+
bare chip row.
19+
- Cascade-clear: when the offered set changes (parent changed / predicate
20+
flipped) the widget prunes only the now-invalid selections, keeping the
21+
still-offered ones — the array analogue of the single select's clear.
22+
- Tests: `MultiSelectField.cascade.test.tsx` mirrors `SelectField.cascade.test.tsx`
23+
(gating, per-element cascade clear, role/context gating).

packages/fields/README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,22 @@ picker (the same widget the `multiselect` type uses) and stores a `string[]`.
9191
Delegating inside `SelectField` — rather than at a type-resolution layer — means
9292
every surface that renders the `select` widget (the object form, the inline grid
9393
editor, and the app-shell `ActionParamDialog`) gets multi-select identically,
94-
with no per-surface drift. Single-value selects keep the cascading dropdown
95-
below. (A multi-value select forgoes per-option `visibleWhen` cascading, which
96-
the chip picker does not implement.)
94+
with no per-surface drift. Both single- and multi-value selects resolve
95+
per-option `visibleWhen` cascading and `dependsOn` gating through the same
96+
[`useCascadingOptions`](./src/widgets/useCascadingOptions.ts) hook, so the
97+
offered chips narrow (and now-invalid selections are pruned) exactly as the
98+
single dropdown does.
9799

98100
### Cascading & role-gated select options
99101

100-
`select` options support a per-option `visibleWhen` CEL predicate (offered only
101-
when TRUE, evaluated against the live record + `current_user`) and a field-level
102-
`dependsOn`. Together they drive dependent selects (country → province → city)
103-
and role-gated options with no bespoke matrix — the same primitives dependent
104-
lookups use. While a `dependsOn` parent is empty the control is gated; a parent
105-
change re-filters the list and clears a now-invalid value. Client-side hiding is
102+
`select` and `multiselect` options support a per-option `visibleWhen` CEL
103+
predicate (offered only when TRUE, evaluated against the live record +
104+
`current_user`) and a field-level `dependsOn`. Together they drive dependent
105+
selects (country → province → city) and role-gated options with no bespoke
106+
matrix — the same primitives dependent lookups use. While a `dependsOn` parent
107+
is empty the control is gated; a parent change re-filters the list and clears a
108+
now-invalid value (single select drops the value; multi-select prunes just the
109+
offered-out entries). Client-side hiding is
106110
UX only — gate authorization-sensitive values on the server too. See
107111
[`content/docs/fields/select.mdx`](../../content/docs/fields/select.mdx).
108112

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Cascading / role-gated `multiselect` options (#2715) — parity with the single
11+
* `SelectField` (ADR-0058 / #2284).
12+
*
13+
* Exercises the observable widget behaviour: the dependency gate (a "select the
14+
* parent first" empty-state), and the cascade clear — but here per-element: a
15+
* selection dropped from the array when the offered set no longer includes it,
16+
* while still-valid selections are kept. The per-option filtering itself is
17+
* unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
18+
* chip picker wires `dependentValues` + predicate scope into that resolver via
19+
* the shared `useCascadingOptions` hook.
20+
*/
21+
import { describe, it, expect, vi, beforeEach } from 'vitest';
22+
import React from 'react';
23+
import { render, screen } from '@testing-library/react';
24+
import '@testing-library/jest-dom';
25+
import { PredicateScopeProvider } from '@object-ui/react';
26+
import { MultiSelectField } from './MultiSelectField';
27+
28+
const provinceField = {
29+
name: 'province',
30+
type: 'multiselect',
31+
dependsOn: 'country',
32+
options: [
33+
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
34+
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
35+
],
36+
} as any;
37+
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
});
41+
42+
describe('MultiSelectField — dependency gating (#2715)', () => {
43+
it('gates with a "select parent first" hint while the controlling field is empty', () => {
44+
render(
45+
<MultiSelectField
46+
value={[]}
47+
onChange={vi.fn()}
48+
field={provinceField}
49+
{...({ name: 'province', dependentValues: {} } as any)}
50+
/>,
51+
);
52+
const gate = screen.getByTestId('multiselect-empty-province');
53+
expect(gate).toHaveTextContent(/select country first/i);
54+
// Gated → no chips offered.
55+
expect(screen.queryAllByRole('button')).toHaveLength(0);
56+
});
57+
58+
it('unlocks the chips once the controlling field is set', () => {
59+
render(
60+
<MultiSelectField
61+
value={[]}
62+
onChange={vi.fn()}
63+
field={provinceField}
64+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
65+
/>,
66+
);
67+
expect(screen.queryByTestId('multiselect-empty-province')).not.toBeInTheDocument();
68+
// Only the `cn` option is offered.
69+
expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
70+
expect(screen.queryByTestId('multiselect-option-ca')).not.toBeInTheDocument();
71+
});
72+
});
73+
74+
describe('MultiSelectField — cascade clear (#2715)', () => {
75+
it('drops only the selections the parent change no longer offers', () => {
76+
const onChange = vi.fn();
77+
render(
78+
<MultiSelectField
79+
value={['zj', 'ca']}
80+
onChange={onChange}
81+
field={provinceField}
82+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
83+
/>,
84+
);
85+
// 'ca' is a US province — under country=cn it is not offered, so it is
86+
// pruned; the still-valid 'zj' is kept.
87+
expect(onChange).toHaveBeenCalledWith(['zj']);
88+
});
89+
90+
it('keeps selections that are all still offered', () => {
91+
const onChange = vi.fn();
92+
render(
93+
<MultiSelectField
94+
value={['zj']}
95+
onChange={onChange}
96+
field={provinceField}
97+
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
98+
/>,
99+
);
100+
expect(onChange).not.toHaveBeenCalled();
101+
});
102+
});
103+
104+
describe('MultiSelectField — role / context gating (#2715)', () => {
105+
const tierField = {
106+
name: 'tiers',
107+
type: 'multiselect',
108+
options: [
109+
{ label: 'Standard', value: 'standard' },
110+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
111+
],
112+
} as any;
113+
114+
it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
115+
const onChange = vi.fn();
116+
render(
117+
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
118+
<MultiSelectField
119+
value={['standard', 'admin_only']}
120+
onChange={onChange}
121+
field={tierField}
122+
{...({ name: 'tiers', dependentValues: {} } as any)}
123+
/>
124+
</PredicateScopeProvider>,
125+
);
126+
expect(onChange).toHaveBeenCalledWith(['standard']);
127+
});
128+
129+
it('keeps an admin-only value for an admin', () => {
130+
const onChange = vi.fn();
131+
render(
132+
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
133+
<MultiSelectField
134+
value={['standard', 'admin_only']}
135+
onChange={onChange}
136+
field={tierField}
137+
{...({ name: 'tiers', dependentValues: {} } as any)}
138+
/>
139+
</PredicateScopeProvider>,
140+
);
141+
expect(onChange).not.toHaveBeenCalled();
142+
});
143+
});

packages/fields/src/widgets/MultiSelectField.tsx

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,102 @@
1-
import React from 'react';
1+
import React, { useEffect } from 'react';
22
import { Badge, EmptyValue, cn } from '@object-ui/components';
3+
import type { OptionLike } from '@object-ui/core';
34
import { FieldWidgetProps } from './types';
5+
import { useCascadingOptions } from './useCascadingOptions';
46

5-
interface Option { label: string; value: string; color?: string }
7+
interface Option extends OptionLike { color?: string }
68

79
/**
810
* MultiSelectField - select zero or more values from a fixed option set.
911
*
1012
* Renders the configured options as toggleable chips. The stored value is a
11-
* string[] (the selected option values). Used for the `multiselect` field type.
13+
* string[] (the selected option values). Used for the `multiselect` field type
14+
* and for a `select` field declared `multiple: true`.
15+
*
16+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
17+
* gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
18+
* shared {@link useCascadingOptions} hook: the offered chips narrow against the
19+
* live form record + `current_user`, the control is gated behind a "select the
20+
* parent first" hint while a dependency is empty, and selections no longer
21+
* offered (parent changed / predicate flipped) are dropped from the array.
1222
*/
13-
export function MultiSelectField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
14-
const config = (field || (props as any).schema) as any;
15-
const options: Option[] = config?.options || [];
23+
export function MultiSelectField({
24+
value,
25+
onChange,
26+
field,
27+
readonly,
28+
className,
29+
schema,
30+
dependentValues,
31+
dependsOn: dependsOnProp,
32+
emptyHint: _emptyHint,
33+
dataSource: _dataSource,
34+
...props
35+
}: FieldWidgetProps<string[]>) {
36+
const config = (field || schema) as any;
37+
const rawOptions: Option[] = config?.options || [];
1638
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39+
const fieldName = (props as any).name || config?.name || (props as any).id || '';
40+
41+
const dependsOn = config?.dependsOn ?? dependsOnProp;
42+
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
43+
rawOptions,
44+
dependsOn,
45+
dependentValues,
46+
);
47+
48+
// Cascade clear: drop selected values the offered set no longer includes
49+
// (parent changed / predicate flipped), keeping the ones still valid — unlike
50+
// the scalar case we prune per-element rather than clearing the whole field.
51+
useEffect(() => {
52+
if (readonly) return;
53+
if (selected.length === 0) return;
54+
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
55+
if (stillOffered.length !== selected.length) onChange(stillOffered);
56+
// eslint-disable-next-line react-hooks/exhaustive-deps
57+
}, [options, gated]);
1758

1859
if (readonly) {
1960
if (selected.length === 0) return <EmptyValue />;
2061
return (
2162
<div className="flex flex-wrap gap-1">
2263
{selected.map((v) => {
23-
const opt = options.find((o) => o.value === v);
64+
// Label from the raw set so a stored value hidden by `visibleWhen`
65+
// still renders its label rather than a bare id.
66+
const opt = rawOptions.find((o) => o.value === v);
2467
return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
2568
})}
2669
</div>
2770
);
2871
}
2972

73+
// No offered options is unfillable — surface a legible state instead of an
74+
// empty chip row: a dependency-gated list prompts for its controlling field;
75+
// an unconfigured / fully-filtered list says so. Mirrors the single select.
76+
if (options.length === 0) {
77+
const hint = gated
78+
? `Select ${dependsOnFields.join(' / ')} first`
79+
: 'No options available';
80+
return (
81+
<div
82+
data-testid={fieldName ? `multiselect-empty-${fieldName}` : undefined}
83+
className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
84+
>
85+
{hint}
86+
</div>
87+
);
88+
}
89+
3090
const toggle = (v: string) => {
3191
const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
3292
onChange(next);
3393
};
3494

3595
return (
36-
<div className={cn('flex flex-wrap gap-1.5', className)}>
96+
<div
97+
className={cn('flex flex-wrap gap-1.5', className)}
98+
data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99+
>
37100
{options.map((opt) => {
38101
const active = selected.includes(opt.value);
39102
return (
@@ -43,6 +106,7 @@ export function MultiSelectField({ value, onChange, field, readonly, className,
43106
onClick={() => toggle(opt.value)}
44107
disabled={(props as any).disabled}
45108
aria-pressed={active}
109+
data-testid={`multiselect-option-${opt.value}`}
46110
className={cn(
47111
'rounded-full border px-3 py-1 text-sm transition-colors',
48112
active

packages/fields/src/widgets/SelectField.tsx

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext, useEffect, useMemo } from 'react';
1+
import React, { useEffect } from 'react';
22
import {
33
Select,
44
SelectContent,
@@ -7,17 +7,12 @@ import {
77
SelectValue,
88
EmptyValue,
99
} from '@object-ui/components';
10-
import {
11-
resolveVisibleOptions,
12-
isOptionGroupGated,
13-
resolveDependsOnFields,
14-
isValueStillOffered,
15-
} from '@object-ui/core';
16-
import { SchemaRendererContext, usePredicateScope } from '@object-ui/react';
10+
import { isValueStillOffered } from '@object-ui/core';
1711
import { SelectFieldMetadata } from '@object-ui/types';
1812
import { useFieldTranslation } from './useFieldTranslation';
1913
import { FieldWidgetProps } from './types';
2014
import { MultiSelectField } from './MultiSelectField';
15+
import { useCascadingOptions } from './useCascadingOptions';
2116

2217
/**
2318
* SelectField - dropdown selection widget.
@@ -30,9 +25,9 @@ import { MultiSelectField } from './MultiSelectField';
3025
* `ActionParamDialog` — inherits multi-select identically, with no drift
3126
* between them. Single-value selects keep the cascading dropdown below.
3227
*
33-
* (A `multiple` select forgoes per-option `visibleWhen` cascading, which the
34-
* chip picker does not implement; single selects retain it. Multi-select +
35-
* cascading is not a combination in use today.)
28+
* Both branches resolve per-option `visibleWhen` cascading / role-gating through
29+
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
30+
* in lockstep.
3631
*/
3732
export function SelectField(props: FieldWidgetProps<any>) {
3833
const config = (props.field || (props as any).schema) as SelectFieldMetadata | undefined;
@@ -74,28 +69,11 @@ function SingleSelectField({
7469
// react-hook-form field name spread in by the form renderer (FormField).
7570
const fieldName = (props as any).name || (config as any)?.name || props.id || '';
7671

77-
// Live form values for cascading options — injected by the form renderer as
78-
// `dependentValues` (same channel dependent lookups use), falling back to the
79-
// record on SchemaRendererContext. `current_user` etc. come from the global
80-
// predicate scope so role/context predicates resolve too.
81-
const ctx = useContext(SchemaRendererContext) as any;
82-
const record = useMemo<Record<string, unknown>>(() => {
83-
return (dependentValues ?? ctx?.formValues ?? ctx?.data ?? {}) as Record<string, unknown>;
84-
}, [dependentValues, ctx?.formValues, ctx?.data]);
85-
const predicateScope = usePredicateScope();
86-
8772
const dependsOn = (config as any)?.dependsOn ?? dependsOnProp;
88-
const dependsOnFields = useMemo(() => resolveDependsOnFields(dependsOn), [dependsOn]);
89-
const gated = useMemo(
90-
() => dependsOnFields.length > 0 && isOptionGroupGated(dependsOn, record),
91-
[dependsOnFields, dependsOn, record],
92-
);
93-
94-
// Effective (offered) options after per-option `visibleWhen` filtering. Empty
95-
// while gated so we never present an unfiltered set before the parent is set.
96-
const options = useMemo(
97-
() => (gated ? [] : resolveVisibleOptions(rawOptions, record, predicateScope)),
98-
[gated, rawOptions, record, predicateScope],
73+
const { options, gated, dependsOnFields } = useCascadingOptions(
74+
rawOptions,
75+
dependsOn,
76+
dependentValues,
9977
);
10078

10179
// Cascade clear: once the offered set no longer includes the current value

0 commit comments

Comments
 (0)