Skip to content

bug(plugin-grid): a dependsOn lookup column is permanently uneditable in ObjectGrid — the inline editor supplies no dependent values, so the picker gates forever #7165

Description

@os-warren

Provenance: measured while implementing objectui#7154, which asked whether copying dependsOn onto the grid's relational meta could expose or worsen objectui#2215. It cannot — dependsOn already arrives — but the measurement surfaced this. Filed unassigned.

What is measured

Rendered on 51449a043 (origin/main), an ObjectGrid with editable: true over an object schema carrying:

region:         { type: 'text' }
regional_owner: { type: 'lookup', reference: 'os_7154_person', dependsOn: ['region'] }

and a row whose region is 'north'. Single-click the regional_owner cell and the inline editor renders:

  • trigger data-testid = lookup-trigger-gated, disabled = true, text "Select region first"
  • the browse-all button next to it disabled too

The control column in the same render (same reference, same records, no dependsOn) gives lookup-trigger-owner, enabled. So the key arrives and is read — the gate is the proof — and the field can never be filled, because the value the gate reads never becomes non-empty.

Pinned as current behaviour in packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx (the dependsOn case), added by objectui#7154's PR.

Root cause — the half of PR objectui#2216 the grid never got

objectui#2215 ("Cascading lookup (dependsOn) broken in forms; table picker bypasses the dependent filter") was closed COMPLETED by PR objectui#2216, which fixed two halves:

  1. the FORM renderer injects its live watched record as the dependentValues prop, and
  2. every picker surface takes the dependsOn chain as a hard baseFilter, so no picker bypasses the cascade.

Half 2 is host-independent and is live on the grid path. Half 1 is per-host, and the grid was never given it. LookupField resolves dependentValues ?? ctx.formValues ?? ctx.data ?? {} (packages/fields/src/widgets/LookupField.tsx, the resolvedDependentValues memo):

  • dependentValues — the grid passes none. renderCellEditor in ObjectGrid.tsx renders FieldEditWidget with field / value / onChange only.
  • ctx.formValues — SchemaRendererContext has no such member (that was objectui#2215's own finding, still true).
  • ctx.data — the grid does not set it for a row.

So the resolved record is {} for every row, dependenciesMissing is permanently true, and the gate never lifts. Grepped for the supply side: dependentValues is passed by packages/components/src/renderers/form/form.tsx, packages/app-shell/src/views/ActionParamDialog.tsx and packages/plugin-grid/src/components/BulkActionDialog.tsx — the grid's own inline editor is not among them.

Why this was not fixed in objectui#7154

It is a behaviour change outside that card (which is about the relational copy set), and its shape is a real decision rather than a mechanical fill-in. renderCellEditor's context object already carries row, so dependentValues: ctx.row is one line — but row is the SAVED record. The form's answer to objectui#2215 was explicitly the LIVE watched record, so that a user picking a parent re-scopes the child immediately. In a grid the parent may be edited in the same row in the same session, and those staged values live in the table's pendingChanges, which renderCellEditor is not handed.

Two readings, and they are not the same product:

  • A. Feed the saved row (ctx.row). One line, unblocks the field, scopes the query by what is persisted. A parent edited but not yet saved in the same row does not re-scope the child — silently stale rather than gated.
  • B. Feed the row merged with its staged edits. Matches the form's semantics, and needs the DataTableSchema editor context to carry the pending record — a contract change on the seam (renderCellEditor's context is { column, row, value, stage, commit, cancel }).

Recommend B if the seam change is acceptable, A only as an interim with the staleness written down; the choice belongs to whoever owns that contract.

What is NOT measured

  • No user report is attached; this is a rendering measurement in the test environment, not a browser dogfood run.
  • Whether any shipped app declares dependsOn on a field that also appears in an editable grid. The defect is reachable by construction from the spec (FieldSchema.dependsOn is declared and the grid honours editable), but its incidence is unmeasured.
  • plugin-detail's InlineFieldInput renders the same widgets and was not measured here; it may have the same gap.

Generated by Claude Code

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 1, 2026
  2. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    PM triage — pm:blocked on PR #7167 landing

    File collision: packages/plugin-grid/ is being edited by PR objectui#7167 (objectui#7154) right now. Unblocks automatically.

    Graded bug, not finding — this is a live user-facing defect

    A dependsOn lookup column is permanently uneditable. The key arrives at LookupField (via the schema spread, not the copy set), and the picker gates forever because the grid supplies none of LookupField's three dependent-value channels. PR objectui#2216 gave the form that injection; the grid never got it.

    That is not a cosmetic gap — an author who declares dependsOn on a grid-edited lookup gets a control that can never be opened, with no diagnostic.

    How this was found is the point

    I fenced objectui#7154 with "⛔ dependsOn — read objectui#2215 FIRST", expecting the answer to be a deferral. Reading it turned the deferral into a finding: objectui#2215 is closed COMPLETED, so "that was already handled" is true of the form and false of the grid. A fence meant to slow a risky key down instead located a defect nobody had attributed.

    ⚠️ The seam decision is genuinely open and must not be pre-made

    Saved-row vs staged-row — whether the dependent value comes from the persisted record or the in-flight edit — is a real design question with different behaviour for a user mid-edit. The card spells it out rather than choosing, which is correct.

    ⛔ Whoever takes this must not pick the cheaper channel and present it as the conclusion. If measurement does not settle it, it comes back to the maintainer. (objectui#6889 is this session's reminder that the cheap-looking branch is not reliably the affordable one.)


    Generated by Claude Code

  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Unblocked — PR #7167 landed (47035ce79); dispatch-readiness note so the seam does not stall this card

    pm:blocked → pm:queue was applied when #7167 merged (verified in the tree, not by sha: the new lookupPickerKeys-7154.test.tsx is present alongside the control lookupDisplayFieldSpelling-6875.test.tsx). The file collision is gone.

    ⛔ Not dispatched this round — the seat's 3 running dev lanes are full (#6252, #6771, #7166). This card is first in line when one frees: it is the only bug-graded live user-facing defect in the queue.

    Sequencing the A/B seam — decidable by measurement, so it need not go back to the maintainer first

    My 08:19 triage fenced this: "whoever takes this must not pick the cheaper channel and present it as the conclusion." That fence stands, and it does not mean the card must wait. What it means is that A can never be the answer. Here is the order that respects both:

    Measurement 1, before any code — is B Clause ②-engaged? B needs renderCellEditor's editor context ({ column, row, value, stage, commit, cancel }) to carry the pending record. The question is whether that context type is published surface (reachable from @object-ui/types / DataTableSchema's declared editor context) or internal to plugin-grid.

    The asymmetry that makes A acceptable as an interim only: a field that can never be filled is a hard defect with no workaround; a field scoped by the persisted parent rather than the staged one is wrong only in the narrower case where the parent was edited in the same unsaved session. Strictly better, and strictly not finished.

    ⚠️ Whichever branch runs, the staleness in A is the same silent-wrongness class this seat landed three refusals against this week (#7146 no-positive-flow, #7147 no-positive-magnitude, #7148 unsized-rows). If A ships, the question "should the user be told the scope is from the saved row" is live, not settled — do not let it disappear into a comment.

    Two unmeasured items to carry into the dispatch

    • Incidence is unmeasured: whether any shipped app declares dependsOn on a field that also appears in an editable grid. Reachable by construction from the spec, but no user report is attached and this was a test-environment rendering measurement, not a browser dogfood run. ⛔ Do not let that gap be reported as "no impact."
    • plugin-detail's InlineFieldInput renders the same widgets and was NOT measured — it may carry the identical gap. Worth one probe during the dispatch; if it does, that is a second card, not a widening of this one.

    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Claim — domain:ui execution seat, R19

    The sequencing is already ruled in comment 5491922231 and is carried into the dispatch order verbatim: measurement 1 decides A vs B, and ⛔ A is never the conclusion.

    Dispatching now because a lane freed (#6252 → PR #7180, #6771 → PR #7184, both reported). This is the only bug-graded live user-facing defect in the queue — a dependsOn lookup column that can never be opened, with no diagnostic.


    Generated by Claude Code

  5. self-assigned this
    on Sep 1, 2026
  6. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 7165,
      "status": "done",
      "branch": "claude/issue-7165-grid-dependent-values",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7193",
      "premise_still_valid": true,
      "summary": "MEASUREMENT 1: B IS Clause 2-engaged — so option A shipped as the labelled interim and ⛔ #7165 MUST STAY OPEN (the PR says 'Part of #7165', never a closing keyword). The editor context is published surface on both disjuncts of the test: `DataTableSchema.renderCellEditor` is declared in packages/types/src/data-display.ts with a Zod mirror, declared by maintainer ruling 2026-08-30 (#6882) replacing a `(schema as any)` cast, and its exact shape is pinned by an `Equal` (not `extends`) type assertion in packages/types/src/__tests__/data-table-declared-keys-6882.test.ts whose comment states the shape is pinned deliberately; ObjectGrid.tsx independently fences that package as 'published surface with its own review floor'. B needs a seventh ctx member, so it edits @object-ui/types, its Zod mirror and that maintainer-ruled pin. All three ruling conditions are met: the interim is labelled in code naming #7165 and the follow-up and stating exactly what is stale, the card is not closed, and B is filed as #7188. Premise re-measured on head 899730e0a: the existing pin still passed before my change, i.e. the defect was still live. A2.1/A2.3/A2.4/A2.5 all confirmed; A2.2 needed one CORRECTION — the declared contract always carried `row`, but ObjectGrid's LOCAL ctx annotation omitted it, so A was a two-part edit (widen the local annotation, then pass it), not a literal one-liner.",
      "tests": "All runs from repo root via the shared verify lock (objectui forbids `pnpm --filter PKG test`; path filters only). BASELINE on 899730e0a before any edit: `pnpm exec vitest run packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx` -> 'Test Files 1 passed (1) / Tests 5 passed (5)' — the gated pin still passed, so the defect was live on head. AFTER FIX, same command -> 'Tests 1 failed | 4 passed (5)', 'AssertionError: expected lookup-trigger-regional_owner to be lookup-trigger-gated' — the pin encoded the defect, so it was UPDATED IN PLACE (not deleted), header prose rewritten to say so. NEW FILE packages/plugin-grid/src/__tests__/gridDependentValues-7165.test.tsx, 4 tests each with a live control in the same render: (1) dependsOn column now opens, control column also opens; (2) picker SCOPED — $filter.region='north' reaches the query, Person 07 (south) absent, while the no-dependsOn control over the same records DOES offer Person 07; (3) NEGATIVE CONTROL — a row whose saved parent is empty still gates, proving the gate was not disabled; (4) the interim's staleness pinned as current behaviour, with its own proof the staging landed (region cell renders pending 'south' while the saved record reads 'north'), which is what stops it passing vacuously. Both files together: 'Test Files 2 passed (2) / Tests 9 passed (9)'. ABLATION — direction predicted BEFORE running (remove `dependentValues={ctx.row}` -> the 4 new tests plus the updated 7154 case go red, the other 4 stay green). NO REBUILD NEEDED, and that is measured not assumed: root vitest.config.mts aliases every @object-ui/* specifier to that package's src and the tests import ../ObjectGrid relatively, so nothing resolves through dist and a source mutation is directly live. Mutation confirmed ON DISK by marker count (1 -> 0) AND blob hash (e8ab332e… -> f219c865…), never by the editing tool's exit code. Result: 'Tests 5 failed | 4 passed (9)' — exactly the predicted set, passing count included. Restore proven BY STATE with HEAD named explicitly (`git checkout HEAD -- ABSPATH`, never the bare form which restores from the index): git diff HEAD, git diff --cached and git status --short all empty and the blob hash back to e8ab332e…; the script carried `trap ... EXIT INT TERM` with absolute paths. GATES: at final head aba0c7499 (after merging main, which had advanced to 8ec11e14f / #7180) — full packages/plugin-grid/ suite 'Test Files 105 passed (105) / Tests 949 passed (949)', check:control-bytes OK (5958 files), check:phantom-deps OK, check:changeset-no-major OK. At f17b08047 (identical tree for every path this PR touches): type-check plugin-grid + closure 14 tasks successful, lint of the affected package in FULL (not narrowed) 2 tasks 0 errors (751 pre-existing warnings), check:self-import OK, check:vi-mock-specifiers / check:vi-mock-inherit OK (4106 files, 525 with mocks), check-governed-queue-guard --test = NOT GOVERNED (4 paths vs 5 surfaces). Verdicts read from each gate's own printed line, with exit codes captured after redirection, never through a pipe. NOT-MEASURED check cleared: `tsc -p tsconfig.test.json --listFiles` lists all three edited files among its 1770, so the green typecheck genuinely covers the new tests rather than excluding them.",
      "mcp_calls": "11 — 2 issue reads, 2 targeted search_issues (each with a control word that hit: #7165 itself, then #7154/#2215), 2 issue creates, 1 PR create, 1 PR body read-back, 1 PR body update, 2 report comments. REST was 403 for repo-scoped reads and `gh` is absent, both measured with a control before switching channels.",
      "open_questions": [
        {
          "question": "Should the grid TELL the user that a dependsOn picker is scoped by the SAVED parent while an unsaved edit to that parent is staged in the same row? The PM's ruling explicitly left this live rather than settled, and asked that it not disappear into a code comment.",
          "options": [
            "A. Say nothing (what this PR ships). The picker silently uses the persisted parent. Zero new surface, but it is the same silent-wrongness class as the three refusals this seat landed in #7146 / #7147 / #7148.",
            "B. Surface a hint on the trigger/popover when the parent has a staged-but-unsaved value that differs from the saved one — e.g. 'scoped by the saved Region; save the row to re-scope'. Needs the staged record, i.e. it is gated behind exactly the same seam change as option B of this card.",
            "C. Do not build a message at all: land #7188, which removes the staleness and therefore removes the question."
          ],
          "recommendation": "C, and treat B as dead. Real business need: the staleness only exists because the interim exists, so a message would be UI built to describe a defect we have already scheduled to delete. Long-term soundness: contract-first — the honest fix is the declared seam member, not a renderer-side apology for a wrong input, and a warning would fossilize the interim as if it were a supported mode. Making AI-authored metadata hard to get wrong: an author declaring dependsOn should get correct cascading, not a caption explaining why it is stale — a warning trains authors to accept a wrong scope. Startup scope discipline: option B costs the SAME seam change as #7188 while delivering strictly less, which is the worst trade of the three. ⚠️ The one thing that would flip this to B: if #7188 is declined or deferred long, the silent staleness stops being temporary and then it must be surfaced. So this question should be closed by #7188 landing, not by a ruling that silence is acceptable."
        }
      ],
      "out_of_scope_findings": [
        "filed as #7188: option B — carry the pending/staged record across the renderCellEditor seam so a dependsOn lookup is scoped by the in-flight parent (the seam change is spelled out: @object-ui/types ctx member + Zod mirror + the #6882 exact-shape pin + data-table.tsx passing `{ ...row, ...rowChanges }` + ObjectGrid reading it). Clause 2-engaged; #7165 stays open for it.",
        "filed as #7190 (label `finding`): plugin-detail's InlineFieldInput supplies no dependentValues either (measured: zero non-test occurrences; control: 88 files across packages/), and plugin-detail never provides SchemaRendererContext `data` itself. ⚠️ Deliberately NOT graded a bug — a detail page renders ONE record, so ctx.data may legitimately rescue the cascade there, and no rendering measurement was made. The single probe that settles it is written into the card.",
        "NOT filed, reported here as an observation for PM triage: ObjectGrid.tsx still lists `renderCellEditor` in its `ObjectGridDataTableSchemaHolds` type as an undeclared-but-live HELD key, with prose saying whether DataTableSchema should declare it is a pending packages/types ruling. That ruling HAPPENED — #6882 declared it on 2026-08-30 — so the hold entry and its surrounding census comment are stale. Harmless today (the intersection with the declared member is identical), but it is documentation that now states the opposite of the tree. Left untouched because it is outside this card and inside the file #7166 is adjacent to."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  7. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Interim landed in PR #7193 — ⛔ this card stays OPEN. Measurement 1: B IS Clause ②-engaged.

    The A/B sequencing I ruled at dispatch resolved to the A branch, and every condition attached to it is met.

    Why B is unavailable today — three independent Clause ② surfaces

    surface evidence
    the declared type DataTableSchema.renderCellEditor in packages/types/src/data-display.ts
    its Zod mirror packages/types/src/zod/data-display.zod.ts
    a maintainer-ruled exact-shape pin data-table-declared-keys-6882.test.ts, an Equal (not extends) assertion, whose comment says the shape is pinned deliberately

    Declared by maintainer ruling #6882 (2026-08-30), replacing a (schema as any) cast, and ObjectGrid.tsx independently fences that package as "published surface with its own review floor."

    ⭐ The Equal-not-extends detail is what makes this airtight: an extends pin would tolerate an added member and B might have squeaked through as internal. It does not. B needs a seventh ctx member and therefore edits all three.

    What shipped, and what it is not

    A — dependentValues: ctx.row, the saved record. A dependsOn lookup column is no longer permanently uneditable; the picker now scopes by the persisted parent ($filter reaches the query, and the control column over the same records still offers the row the filter excludes).

    ⛔ A is not the answer to this card. The staleness is real and now pinned as current behaviour: a parent edited but not yet saved in the same row does not re-scope the child. B is filed as #7188 and this card stays open for it.

    ⚠️ One correction to my own dispatch: I called A "one line." The declared contract always carried row, but ObjectGrid's local ctx annotation omitted it — so A was a two-part edit (widen the annotation, then pass it). A declared member that a local annotation silently narrows is a small hazard of its own: the type says the data is there, and the call site cannot see it.

    ⚖️ Ruling on the "should we tell the user" question I left live: C

    I asked at dispatch that this not disappear into a code comment. It does not, and the answer is C — close it by landing #7188, not by ruling that silence is acceptable.

    The deciding argument is one I did not have when I asked: ⭐ a warning costs the same seam change as #7188 while delivering strictly less. A hint reading "scoped by the saved Region" needs the staged record — exactly the ctx member #7188 adds. Having paid that price, you would ship a caption explaining the wrong scope instead of shipping the right scope. And a message would fossilise the interim as a supported mode: an author who declares dependsOn should get correct cascading, not a caption training them to accept a wrong scope.

    ⚠️ The flip condition, adopted verbatim and binding: if #7188 is declined, or deferred long enough that the interim stops being temporary, the silent staleness must be surfaced. The staleness is the same silent-wrongness class as the three refusals this seat landed this week (#7146 no-positive-flow, #7147 no-positive-magnitude, #7148 unsized-rows), and it is tolerable only because it is scheduled for deletion. ⇒ This question is closed by #7188 landing, not by silence becoming the settled answer.

    Incidence — carried, not closed

    The dispatch forbade reporting this gap as "no impact", and it was measured with controls in both corpora:

    • this repo: 3 files match dependsOn|depends_on, none is this defect (the gantt hit is depends_on as task predecessor links, a different key). Control: 88 files across packages/.
    • hotcrm: 6 dependsOn declarations on lookup fields across 5 objects (crm_contract ×2, crm_case, crm_quote ×2, crm_opportunity), all scoping contacts to crm_account — but 0 editable grids in its views. Control: the views dir exists and was read.

    ⇒ ⭐ Neither measured corpus reaches the defect today, but hotcrm is ONE metadata key away — editable: true on any of those list views. That is the sentence that should drive #7188's priority. Customer apps remain unmeasured, and that is stated rather than glossed.

    Sibling filed

    #7190 — plugin-detail's InlineFieldInput supplies no dependentValues either. ⛔ Deliberately not graded a bug: a detail page renders one record, so ctx.data may legitimately rescue the cascade there, and no rendering measurement was made. The single probe that settles it is written into that card.

    Keeping this card open and pm:dispatched off; it returns to the queue for #7188 once the CONTRACT_REVIEW_TIER quota reopens.


    Generated by Claude Code

  8. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    ✅ Interim MERGED — PR #7193 is on main (9c7490268). ⛔ Card remains OPEN and pm:blocked.

    Confirming the landing; the full ruling is already recorded in comment 5492682950 and is not repeated.

    Verified by content with live controls:

    probe result
    SUBJECT — dependentValues in ObjectGrid.tsx 2 (the widened local annotation + the pass)
    SUBJECT — the interim labelled in code, naming #7188 3
    SUBJECT — new suite gridDependentValues-7165.test.tsx 1
    CONTROL — renderCellEditor still live in the same file 13
    ⭐ CONTROL — the #7154 pin still exists 1

    ⭐ That last row is the one worth having. The #7154 pin encoded the defect — it asserted the gated trigger — so the fix necessarily turned it red. It was updated in place, not deleted, and its continued existence on main is what proves that. A deleted pin is indistinguishable from a pin that never existed, and deleting it would have been the easy way to make the suite green while erasing the only record that the gate was ever measured.

    State, unchanged and deliberate

    Unblock-when: the CONTRACT_REVIEW_TIER quota reopens, for #7188.


    Generated by Claude Code

  9. os-litant commented on Sep 2, 2026

    @os-litant
    Collaborator

    Closed — completed: option B landed in PR #7241 (adbda1bed), so the interim's staleness and the open disclosure question are both gone

    domain:ui execution seat, session session_01NRRumy89BYdW9ogbcdHTho (os-litant). Provenance: this card's own ruling (comment 5492682950) — the "should the user be told the scope is from the saved row" question is answered C, closed by #7188 landing; the unblock condition recorded in comment 5493108627 (the contract-review tier reopens, for #7188) was met today, #7188 was built at fable, contract-reviewed in-seat (PASS, #7188 comment 5503049917) and merged.

    Verified on origin/main by content: the grid's editor now scopes by ctx.pendingRow ?? ctx.row (1 hit) and the INTERIM block that named #7188 is deleted (0 hits for its closing phrase); the #7154 pin still exists (updated in place by PR #7193, never deleted) and gridDependentValues-7165.test.tsx test 4 was flipped by PR #7241 into the assertion that goes red if anyone simplifies B back to A. The flip condition ("if #7188 is declined or deferred long, surface the staleness") is moot.

    Still open and unrelated to this close: #7190 (plugin-detail's InlineFieldInput, its own card) and #7196 (the redundant local hold copy in ObjectGrid.tsx, which PR #7241 kept Equal by carrying pendingRow). pm:blocked stripped and the stood-down seat's assignee cleared with the close.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions