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
46 changes: 46 additions & 0 deletions .changeset/8672-lookup-action-param-depends-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/app-shell': patch
'@object-ui/core': patch
---

A `dependsOn` declared on a field-backed **lookup action param** now gates, **ungates**
and filters the picker (objectui#8672, maintainer ruling — director decision batch #115,
2026-09-11, arm A "wire it").

**What was broken.** `ActionParamDialog` threaded its live record (`dependentValues`) to
the option widgets only — `select` / `multiselect` / `radio` / `checkboxes`. A `lookup`
param is in none of those, so `LookupField` fell through to the `SchemaRendererContext`
tail that is unconditionally `{}` (nothing can populate a member the type does not
declare), `dependenciesMissing` could never clear, and the trigger rendered **disabled
forever** — prompting for the very field the user had just filled. There was no error at
author time, at type-check or at runtime: the failure looked like a broken picker rather
than a key that did nothing here.

Independently, the one route the repo's own `RESOLVED_ONLY_PARAM_KEYS.dependsOn` message
points authors to ("make the param field-backed to pick it up") read the **snake**
spelling `field.depends_on`, which `@objectstack/spec`'s `FieldSchema` refuses by name,
while the camel `dependsOn` it declares was never read. The two spellings were disjoint,
so no spec-valid document could reach the feature at all.

**What changed.**

- `ActionParamDialog` now supplies its live `values` to the reference-bearing pickers as
well as to the option widgets. That is the dialog's whole record: unlike the grid
(`ctx.pendingRow ?? ctx.row`) it is not scoped to a row — its params *are* the record,
which is the same record its option widgets have resolved against since objectui#3765.
- `resolveActionParams` reads the declared `field.dependsOn` and no longer reads
`field.depends_on`. Unlike its five sibling lookup keys the snake leg is removed rather
than demoted, because there is no producer to protect: no document that parses can
carry a spelling `FieldSchema` rejects by name.

Nothing about the cascade itself is new. `LookupField` has always turned `dependsOn` into
a hard `$filter` shared by the quick-select popover, the Level-2 table picker and
PeoplePicker; this supplies the one input no host could otherwise deliver.

**Unchanged on purpose.** `ActionParamSchema` still refuses `dependsOn` written *inline*
on a param — the honoured route is the field-backed one. `CASCADE_OPTION_WIDGET_TYPES`
gains no member: it is shared verbatim with the object form's cascade-clear loop and with
`plugin-grid`'s `BulkActionDialog`, and it means "this widget's offered *option set* is
re-resolved", which a lookup has none of. The dialog ORs a second family beside it
instead — the shape the object form has shipped all along. The bulk action dialog is
untouched and still carries the original gap.
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,22 @@ const PICKER_KEYS = [
'dependsOn',
] as const;

/** One object field per relevant type, each carrying full picker config. */
/**
* One object field per relevant type, each carrying full picker config.
*
* ⚠️ The spellings here are MIXED, and deliberately so — this fixture is about
* WHICH members inherit picker config, never about how a key is spelled. Most
* rows stay snake because `resolveActionParams` really does read that spelling
* (some as the only spelling it reads, some behind the declared one).
*
* ⭐ `dependsOn` is the exception and must stay camel (objectui#8672, ruling A).
* It was `depends_on: ['region']` and passed only because the resolver read the
* snake spelling — a spelling `@objectstack/spec`'s `FieldSchema` refuses BY
* NAME. Ruling A moved that read onto the declared `dependsOn` and removed the
* snake leg, so the old fixture row silently produced nothing and the
* `PICKER_KEYS` loop below caught it. ⛔ Do not "fix" a future failure here by
* restoring a dual read: the key is camelCase-only on both sides of the seam.
*/
const field = (type: string) => ({
type,
label: type,
Expand All @@ -112,7 +127,7 @@ const field = (type: string) => ({
lookup_columns: ['name'],
lookup_filters: [['active', '=', true]],
lookup_page_size: 25,
depends_on: ['region'],
dependsOn: ['region'],
});

const ctx = (): ResolveActionParamsContext => ({
Expand Down
23 changes: 11 additions & 12 deletions packages/app-shell/src/utils/paramToField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,17 @@ describe('paramToField', () => {
// in-repo framework producer of that spelling, so the emit had to move with
// the reader.
//
// ⚠️ CORRECTED (objectui#8672). This sentence used to end "…or the dialog's
// dependent lookups would have gone dead silently", which describes a WORKING
// cascade in this dialog. Measured, there has never been one: `dependsOn` is
// emitted onto the field bag below, but `ActionParamDialog` supplies
// `dependentValues` only to `CASCADE_OPTION_WIDGET_TYPES` (`select` /
// `multiselect` / `radio` / `checkboxes` — `lookup` is not a member), so
// `LookupField` resolves `{}` and the gate NEVER lifts. What moving the emit
// preserved is therefore the GATE, not a cascade: leaving it behind would have
// flipped a permanently gated picker into an ungated, UNFILTERED one — the
// silent behaviour change `paramToField.ts`'s own comment records. Pinned in
// `views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx`; which
// disposition that gap gets is open on objectui#8672.
// ⚠️ CORRECTED ONCE, then OVERTAKEN (objectui#8672). The sentence above used
// to end "…or the dialog's dependent lookups would have gone dead silently",
// which described a WORKING cascade in this dialog; at the time there had
// never been one, so the correction said what moving the emit preserved was
// the GATE, not a cascade. ⭐ Ruling A then wired the missing half: the dialog
// feeds the reference-bearing pickers their live record too
// (`paramNeedsDependentValues()` in `views/ActionParamDialog.tsx`), so this
// emit now reaches a cascade that really does ungate and really does filter.
// Both readings are kept because the order matters — the emit had to move
// BEFORE the supply existed, and it is what made the wiring a one-line
// change. Pinned in `views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx`.
// The remaining snake members below (`reference_to`, `title_format`,
// `lookup_columns`, `lookup_page_size`) were outside both rulings and are
// unchanged — this mixed shape is deliberate, and asserting it keeps the two
Expand Down
26 changes: 14 additions & 12 deletions packages/app-shell/src/utils/paramToField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,18 +189,20 @@ export function paramToField(param: ActionParamDef): Record<string, any> {
// leaving it would have handed `LookupField` a key it no longer reads.
//
// ⚠️ What that would have changed, stated as MEASURED and not larger:
// this dialog supplies `dependentValues` only to
// `CASCADE_OPTION_WIDGET_TYPES` (`select` / `multiselect` / `radio` /
// `checkboxes`, the supply site being `ActionParamDialog.tsx`'s
// `cascadeProps`), and `LookupField`'s context fallback resolves to `{}`
// here, so a lookup param's cascade gate in this dialog is PERMANENT:
// it never lifts, whatever the user types into the parent. Dropping the
// read arm without moving this emit would therefore have flipped that
// permanent gate into an UNGATED, UNFILTERED picker — a silent behaviour
// change, and the reason the emit moves with the reader; ⛔ not a
// working cascade that would have died. That the gate never lifts here
// is a PRE-EXISTING residue of its own, not something this card
// introduced or fixes.
// dropping the read arm without moving this emit would have flipped the
// gate this key raises into an UNGATED, UNFILTERED picker — a silent
// behaviour change, and the reason the emit moves with the reader.
//
// ⭐ objectui#8672, ruling A — the residue that note recorded is gone.
// When it was written, this dialog supplied `dependentValues` only to
// `CASCADE_OPTION_WIDGET_TYPES` (no `lookup` member), so `LookupField`
// resolved `{}` and the gate this emit raises NEVER lifted, whatever the
// user typed into the parent. `ActionParamDialog` now feeds the
// reference-bearing family its live `values` as well — see
// `paramNeedsDependentValues()` there — so the key this line emits does
// what its author meant: the trigger ungates once the named parent
// carries a value, and the picker is narrowed by it on every surface.
// ⛔ Do not restate the supply rule here; it is asked of that predicate.
//
// The remaining snake members above are a different question
// (objectui#7155's ruling covered four keys and this was not one of them).
Expand Down
58 changes: 36 additions & 22 deletions packages/app-shell/src/utils/resolveActionParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,16 @@ interface RuntimeField {
// camel spelling here would fossilise a spelling no contract declares —
// the exact defect this family exists to stop. Routed to objectui#7650.
//
// ⛔ `depends_on` gains no camel twin either, and its reason is the opposite
// shape: `FieldSchema` DOES declare `dependsOn`, so the omission is not
// contractual but MEASURED. See the read site below.
// ⭐ objectui#8672, ruling A — `dependsOn` is now declared in the SPEC
// spelling and ONLY in it. Unlike the five keys above it gains no snake twin
// to rank behind, because there was never a contractual reason for one:
// `FieldSchema` DECLARES `dependsOn` and refuses `depends_on` by name. The
// snake member this interface used to carry was the only spelling the read
// site below looked at, so a spec-valid field def declaring the cascade
// resolved to `undefined` while the one spelling that DID arrive was one no
// author could legally write. That is not a producer leg worth keeping behind
// the declared one — it is the inverse of one, and keeping it would be the
// consumer-side tolerance AGENTS.md #0.1 bans.
reference_to?: string;
reference?: string;
displayField?: string;
Expand All @@ -343,7 +350,7 @@ interface RuntimeField {
lookup_filters?: unknown[];
lookupPageSize?: number;
lookup_page_size?: number;
depends_on?: unknown[];
dependsOn?: unknown[];
}

interface RuntimeObject {
Expand Down Expand Up @@ -599,23 +606,30 @@ export function resolveActionParam(
// rank first, so there is nothing to add that would not fossilise an
// undeclared key.
//
// ⛔ `dependsOn` is the third declared key of that slice and it keeps
// its snake-only read for a MEASURED reason, not a contractual one.
// Adding `field.dependsOn ?? field.depends_on` was built and rendered
// before being refused: a field-backed lookup param whose def declares
// the spec spelling then renders `lookup-trigger-gated` and DISABLED,
// where the same def renders an enabled trigger today (control: an
// otherwise identical lookup without the key, enabled in both runs).
// The gate never lifts — this dialog supplies `dependentValues` only to
// `CASCADE_OPTION_WIDGET_TYPES`, which excludes `lookup`, so
// `LookupField`'s `dependenciesMissing` can never clear. That is pinned,
// with a keystroke witness, by leg A of
// `views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx`, and leg
// C of the same file pins the `undefined` this read produces today.
// ⇒ ranking the declared spelling first here would trade a config-loss
// bug for an unusable picker on every spec-valid def that declares the
// cascade. Which of objectui#8672's three dispositions to take — wire
// it, refuse it, declare the limit — is that card's ruling to make.
// ⭐ objectui#8672, ruling A — `dependsOn` is the third declared key of
// that slice and it lands here now, in the DECLARED spelling and with
// no snake leg behind it. It was held back deliberately: honouring the
// spec spelling while `ActionParamDialog` fed `dependentValues` to
// option widgets only would have turned every spec-valid def that
// declares a cascade into a `lookup-trigger-gated`, permanently
// DISABLED picker — a measurement objectui#9130 built, rendered and
// then refused to ship, routing the disposition to objectui#8672.
//
// The dialog now supplies that record (see `paramNeedsDependentValues`
// in `views/ActionParamDialog.tsx`), so the gate lifts as soon as the
// named parent carries a value and `LookupField` narrows the picker by
// it. The two halves ship together on purpose: either alone is a
// regression, and this comment is the reason they may not be split
// again.
//
// ⛔ The snake read is GONE rather than demoted, which is the one place
// this key departs from its five siblings above. Their snake legs are
// kept because a pre-tightening document or an out-of-repo host adapter
// could still emit them. `depends_on` has no such producer to protect:
// `FieldSchema` refuses it BY NAME (suggesting `dependsOn`), so no
// document that parses can carry it, and objectui#7357 already retired
// the renderer-side twin in `LookupField`. Keeping it would leave this
// resolver the last reader of a spelling the protocol rejects.
referenceTo: param.reference ?? field.reference,
displayField:
field.displayField ?? field.display_field ?? field.reference_field,
Expand All @@ -625,7 +639,7 @@ export function resolveActionParam(
lookupColumns: field.lookupColumns ?? field.lookup_columns,
lookupFilters: field.lookupFilters ?? field.lookup_filters,
lookupPageSize: field.lookupPageSize ?? field.lookup_page_size,
dependsOn: field.depends_on,
dependsOn: field.dependsOn,
}
: {};

Expand Down
Loading
Loading