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/fields-depends-on-declared-read-6153.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/fields': patch
---

The option widgets and the lookup read `dependsOn` through the declared type.

`SelectField`, `MultiSelectField`, `RadioField` and `CheckboxesField` now read the
cascade key as `field.dependsOn` — `BaseFieldMetadata.dependsOn` — instead of
through an `as any`; `LookupField` reads both of its spellings (`depends_on`, then
`dependsOn`) through `LookupFieldMetadata`. Behaviour is unchanged: a select whose
metadata carries `dependsOn` still gates and prunes its options, a lookup still
scopes its candidate queries, and the metadata key still wins over the `dependsOn`
widget prop. What changed is that a wrong spelling or shape at the read site is now
a compile error rather than a silent no-op. objectui#6153.
20 changes: 20 additions & 0 deletions .changeset/types-field-depends-on-declared-6153.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/types': minor
---

`dependsOn` is now a declared member of the field-metadata face.

`BaseFieldMetadata` gains `dependsOn?: FieldDependsOn`, the spec's field-level
cascade key in the spec's own shape — derived from `@objectstack/spec/data`'s
`Field` by reference: an array of controlling field names, or `{ field, param }`
entries. Every field type inherits it, so an annotated `SelectFieldMetadata` or
`LookupFieldMetadata` literal can now carry the key the running widgets have
honoured all along; before, the excess-property check refused it and the widgets
reached it through an `as any`. A bare parent name is refused at the type, as the
spec refuses it at publish (`invalid_type`) — that shape belongs to the form-level
`FormField.dependsOn` and to the `dependsOn` widget prop. `FieldDependsOn` is
exported.

The snake_case `depends_on` stays declared for now: it is objectui's legacy twin,
never a spec key, and retires on its own card (objectui#7357). Maintainer ruling A
on objectui#6153.
27 changes: 27 additions & 0 deletions content/docs/fields/lookup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,33 @@ and must never reach authored object metadata
`dataSource` a host injects and the `onCreateNew` callback it passes are widget props,
not metadata.

A **dependent lookup** scopes its candidates by a sibling field's value, declared
with `dependsOn` — the same `BaseFieldMetadata` member the select widgets gate on
([objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153)), in the
spec's field-level shape: an array of controlling field names, or `{ field, param }`
entries when the remote filter parameter differs from the local field name. While
any controlling value is empty the trigger is gated ("Select account first"); once
set, every candidate query — the typeahead popover, the Record Picker and the
people picker — carries the chain as a hard `$filter` no user input can override.

```ts
import type { LookupFieldMetadata } from '@object-ui/types';

const contact: LookupFieldMetadata = {
type: 'lookup',
name: 'contact',
label: 'Contact',
reference_to: 'contacts',
// Filter `contacts` by `account_id` equal to the form's current `account`.
dependsOn: [{ field: 'account', param: 'account_id' }],
};
```

The snake_case `depends_on` is objectui's legacy twin of the same key: still read
by this widget, never a spec key, and retiring under
[objectui#7357](https://github.com/objectstack-ai/objectui/issues/7357) — author
`dependsOn`.

The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).

Expand Down
39 changes: 33 additions & 6 deletions content/docs/fields/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ cascade-clear propagate down the chain.
`visibleWhen` options are for **small, static dictionaries** (category →
subcategory, a handful of provinces). When the data is large, changes over time,
or is shared across forms (real country/province/city tables, org units, product
catalogs), model each level as a **`lookup`** with `depends_on` instead — the
catalogs), model each level as a **`lookup`** with `dependsOn` instead — the
candidate query is filtered server-side and paginated. See
[Lookup Field](/docs/fields/lookup).

Expand Down Expand Up @@ -116,11 +116,38 @@ const status: SelectFieldMetadata = {
};
```

Cascading option lists are driven by a sibling field's value. The widget reads a
camelCase `dependsOn` off the metadata, but no exported metadata type declares it —
`BaseFieldMetadata` declares the snake_case `depends_on` instead — so the two
spellings disagree and the gap is tracked as
[objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153).
Cascading option lists are driven by a sibling field's value, declared with
`dependsOn` — a `BaseFieldMetadata` member every field type inherits
([objectui#6153](https://github.com/objectstack-ai/objectui/issues/6153)), in the
shape `@objectstack/spec` declares at field level: an **array** of controlling
field names, or `{ field, param }` entries when the remote parameter name differs
from the local field name. While any controlling value is empty the widget is
gated; once it is set, each option's `visibleWhen` decides whether it is offered,
and a selection the parent no longer offers is cleared.

```ts
import type { SelectFieldMetadata } from '@object-ui/types';

const province: SelectFieldMetadata = {
type: 'select',
name: 'province',
label: 'Province',
dependsOn: ['country'],
options: [
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
],
};
```

A bare parent name (`dependsOn: "country"`, as in the form schema above) is the
**form-level** shape, `FormField.dependsOn`; on field metadata the spec accepts
only the array, and `SelectFieldMetadata` refuses the string for the same reason.
The metadata key wins over the `dependsOn` widget prop a host may pass. The
snake_case `depends_on` is objectui's legacy twin of the same key — read only by
the lookup widget and retiring under
[objectui#7357](https://github.com/objectstack-ai/objectui/issues/7357); author
`dependsOn`.

The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).
Expand Down
3 changes: 2 additions & 1 deletion packages/fields/src/widgets/CheckboxesField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function CheckboxesField({
const groupId = useId();
const fieldName = props.name || config?.name || props.id || '';

const dependsOn = config?.dependsOn ?? dependsOnProp;
// Read through the declared type, not the untyped carrier (objectui#6153) — see SelectField.
const dependsOn = field?.dependsOn ?? dependsOnProp;
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
rawOptions,
dependsOn,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A lookup whose METADATA carries `dependsOn` scopes its candidates — read
* through the DECLARED type (objectui#6153, instance 1; maintainer ruling A,
* 2026-09-02).
*
* `LookupField.dependsOn.test.tsx` (#2215) proves the gate and the hard
* `$filter` through the legacy `depends_on` spelling on an `as any` literal.
* This file proves the same two facts through the spec's field-level spelling
* on ANNOTATED `LookupFieldMetadata` literals with NO cast — the
* excess-property check refused `dependsOn` on this exact document before the
* declaration landed (compile half), and the widget still gates and scopes on
* it (runtime half). Both spellings stay readable until objectui#7357 retires
* the snake_case twin; that card, not this one, drops the arm.
*/

import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { LookupFieldMetadata } from '@object-ui/types';
import { LookupField } from './LookupField';

const contacts = [
{ id: 'c1', name: 'Nora Field', account: 'a1' },
{ id: 'c2', name: 'Oscar Grant', account: 'a2' },
];

function makeDataSource() {
const find = vi.fn(async (_obj: string, params: { $filter?: Record<string, unknown> } | undefined) => {
const account = params?.$filter?.account ?? params?.$filter?.account_id;
const data = account ? contacts.filter((c) => c.account === account) : contacts;
return { data, total: data.length };
});
return { find } as never;
}

beforeEach(() => {
// jsdom has no matchMedia; the people-picker branch's useIsMobile needs it.
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as never;
});

// The literal `content/docs/fields/lookup.mdx` teaches — annotated, uncast.
const contactField: LookupFieldMetadata = {
type: 'lookup',
name: 'contact',
label: 'Contact',
reference_to: 'contacts',
reference_field: 'name',
dependsOn: [{ field: 'account', param: 'account_id' }],
};

const shorthandField: LookupFieldMetadata = {
...contactField,
dependsOn: ['account'],
};

// `dependentValues` is a HOST prop (the form renderer's channel), not metadata.
const host = (dependentValues: Record<string, unknown>) => ({ dependentValues }) as Record<string, unknown>;

describe('LookupField — `dependsOn` off the DECLARED metadata type (objectui#6153)', () => {
it('gates the trigger while the controlling field is empty', () => {
render(
<LookupField
field={contactField}
value={undefined}
onChange={vi.fn()}
readonly={false}
dataSource={makeDataSource()}
{...host({ account: null })}
/>,
);
const trigger = screen.getByTestId('lookup-trigger-gated');
expect(trigger).toBeDisabled();
expect(trigger).toHaveTextContent(/select account first/i);
});

it('scopes the candidate query by the `{ field, param }` entry once the parent is set', async () => {
const ds = makeDataSource();
render(
<LookupField
field={contactField}
value={undefined}
onChange={vi.fn()}
readonly={false}
dataSource={ds}
{...host({ account: 'a1' })}
/>,
);

const trigger = screen.getByRole('button', { name: /select/i });
expect(trigger).not.toBeDisabled();
await act(async () => {
fireEvent.click(trigger);
});

await waitFor(() => {
expect((ds as { find: ReturnType<typeof vi.fn> }).find).toHaveBeenCalledWith(
'contacts',
expect.objectContaining({ $filter: expect.objectContaining({ account_id: 'a1' }) }),
);
});
await waitFor(() => {
expect(screen.getByText('Nora Field')).toBeInTheDocument();
expect(screen.queryByText('Oscar Grant')).not.toBeInTheDocument();
});
});

it('the shorthand `[name]` entry filters by the sibling name itself', async () => {
const ds = makeDataSource();
render(
<LookupField
field={shorthandField}
value={undefined}
onChange={vi.fn()}
readonly={false}
dataSource={ds}
{...host({ account: 'a1' })}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /select/i }));
});
await waitFor(() => {
expect((ds as { find: ReturnType<typeof vi.fn> }).find).toHaveBeenCalledWith(
'contacts',
expect.objectContaining({ $filter: expect.objectContaining({ account: 'a1' }) }),
);
});
});
});
33 changes: 21 additions & 12 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { cn,
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
import { FieldWidgetComponentProps } from './types.js';
import { toDomProps } from './toDomProps.js';
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
import type { DataSource, QueryParams, LookupColumnDef, LookupFieldMetadata } from '@object-ui/types';
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog.js';
import type { RecordPickerFilterColumn } from './RecordPickerDialog.js';
import { PeoplePicker } from './PeoplePicker.js';
Expand Down Expand Up @@ -288,24 +288,33 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
* Dependent lookups — restrict candidates based on values of *other* fields
* in the same form. Two shapes are accepted:
*
* 1. `depends_on: ['country']` → shorthand. The dependent field value is sent
* 1. `dependsOn: ['country']` → shorthand. The dependent field value is sent
* as both the filter field and the source field (i.e. `country = ${country}`).
* 2. `depends_on: [{ field: 'country', param: 'country_id' }]` → explicit.
* 2. `dependsOn: [{ field: 'country', param: 'country_id' }]` → explicit.
* The remote field name (`param`) can differ from the local field name.
*
* The key is read THROUGH THE DECLARED TYPE (objectui#6153): `dependsOn` is
* `BaseFieldMetadata.dependsOn`, the spec's field-level spelling; `depends_on`
* is objectui's legacy twin, still declared and still honoured until
* objectui#7357 retires it — that card drops the snake_case arm below. Only
* this cascade read goes through `LookupFieldMetadata`; the rest of
* `fieldMeta` stays untyped because its camelCase-fallback family
* (`displayField`, `descriptionField`, …) is objectui#4631's population.
*
* When any dependency is empty, the lookup is gated and the user sees a
* helpful "Select {field} first" hint instead of unfiltered records.
*/
const cascadeMeta: LookupFieldMetadata | undefined = fieldMeta;
const dependsOn = useMemo<Array<{ field: string; param: string }>>(() => {
const raw = fieldMeta?.depends_on ?? fieldMeta?.dependsOn;
if (!raw) return [];
if (Array.isArray(raw)) {
return raw.map((d: any) =>
typeof d === 'string' ? { field: d, param: d } : { field: d.field, param: d.param ?? d.field },
);
}
return [];
}, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
const raw = cascadeMeta?.depends_on ?? cascadeMeta?.dependsOn;
// A bare parent name is the FORM-level shape (`FormField.dependsOn`), not the
// field-level one the spec declares (array only) — an untyped host handing
// one through still gets no cascade here, exactly as before.
if (!raw || !Array.isArray(raw)) return [];
return raw.map((d) =>
typeof d === 'string' ? { field: d, param: d } : { field: d.field, param: d.param ?? d.field },
);
}, [cascadeMeta?.depends_on, cascadeMeta?.dependsOn]);

/**
* The gate sentence's `{{fields}}` — the controlling fields named the way the
Expand Down
3 changes: 2 additions & 1 deletion packages/fields/src/widgets/MultiSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export function MultiSelectField({
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
const fieldName = props.name || config?.name || props.id || '';

const dependsOn = config?.dependsOn ?? dependsOnProp;
// Read through the declared type, not the untyped carrier (objectui#6153) — see SelectField.
const dependsOn = field?.dependsOn ?? dependsOnProp;
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
rawOptions,
dependsOn,
Expand Down
3 changes: 2 additions & 1 deletion packages/fields/src/widgets/RadioField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function RadioField({
const groupId = useId();
const fieldName = props.name || config?.name || props.id || '';

const dependsOn = config?.dependsOn ?? dependsOnProp;
// Read through the declared type, not the untyped carrier (objectui#6153) — see SelectField.
const dependsOn = field?.dependsOn ?? dependsOnProp;
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
rawOptions,
dependsOn,
Expand Down
Loading
Loading