Repository navigation
Grid grouping collapses every row into one "(empty)" bucket when the grouping field is not also a displayed column — select is built from columns alone #7179
Description
Found by opening a grouped grid in a real ObjectStack application (objectstack-ai/duly) against published @objectstack/* 17.2.0, with 186 real records across 5 business units. Network payloads captured; root cause confirmed in this repo's source.
The claim
A grid view that declares grouping on a field absent from its columns renders one group labelled (empty) containing every row. No error, no warning, no empty state — a grouped grid that looks like it grouped, and did not.
The grouping field is fully populated on every record. The same field, grouped by a dashboard chart on the same data, resolves its labels correctly.
Measured
View declaration:
by_unit: {
type: 'grid',
columns, // subject, status, due_date, period_key, owner, source
grouping: { fields: [{ field: 'business_unit' }] },
}Rendered: a single collapsible group, (empty) 100, holding every row.
Two payloads captured on that page load:
GET /api/v1/data/duly_task?
keys: id,…,subject,duty,owner,business_unit,assignment,source,period_key,…
business_unit: "Q2UJSlOy4ICnWmTg" ← populated
GET /api/v1/data/duly_task?populate=owner&top=100&select=id,subject,status,due_date,period_key,…
keys: id,subject,status,due_date,period_key,owner,source
business_unit: undefined ← not requested
The grid's own request builds select from the view's columns and nothing else. business_unit is never asked for, so it is undefined on every row by the time grouping runs.
Storage confirms the data is not the problem — select business_unit, count(*) from duly_task group by 1 gives 5 distinct unit ids over 186 rows, 0 nulls.
Why it lands on (empty) rather than failing
packages/plugin-grid/src/useGroupedData.ts:119, first line of buildSegmentLabel:
if (value === undefined || value === null || value === '') return '(empty)';That guard is right for a genuinely empty value and cannot distinguish it from a field that was never fetched. Everything below it handles lookups well — an expanded { id, name } resolves through name/label/title/…, and a bare FK string falls through to String(value), which would at least have shown five distinct id buckets. Neither path is reached, because the value is undefined before any of it runs.
So the defect is upstream of the label builder, in the projection: the query should include every field the view groups by.
Why this is worth prioritising
It is a silent wrong answer, not a missing feature, and it is invisible in exactly the conditions authors test under:
objectstack validateandbuildare green —business_unitis a real field on the object, so every binding check passes;- the view renders, the row count is right, the rows are right;
- the only symptom is that the grouping did nothing, and
(empty)reads as "these records have no unit" — a plausible, wrong, actionable conclusion. A manager looking at this concludes the org data is unpopulated.
An author whose grouping field happens to also be a displayed column never sees it, which is why this survives casual use.
Reproduce
// any object with a populated field `f`
{ type: 'grid',
columns: [{ field: 'a' }, { field: 'b' }], // note: no `f`
grouping: { fields: [{ field: 'f' }] } }Open the view. One group, (empty), all rows. Add { field: 'f' } to columns and it groups correctly.
Suggested direction
Union the grouping fields into the select projection where it is built, rather than requiring authors to mirror them in columns — grouping by a field you do not want as a visible column is an ordinary thing to want, and it is what the two config keys being separate implies is supported.
Worth checking the same builder for the neighbouring cases before closing: a sort on a non-column field, and the groupBy fields on kanban and gantt, all take a field name from a config key that is not columns.
If the projection is deliberately columns-only, then the author-time gate is the alternative — refuse a grouping.fields[] entry that names no column — but the projection fix is the one that matches what the schema already lets you write.
Application impact
In the app that found this, the affected view is the manager's org lens: "show me what is outstanding, by business unit". It shipped green, with a passing metadata-binding test that resolves business_unit against the object schema — which it does resolve, correctly, and which is exactly why the test could not catch this.
Activity
Fetch the fields the grouped grids group by
Fetch the fields the grouped grids group by (#85)
os-warren commentedon Sep 1, 2026
Follow-up measurement: the neighbouring cases are fine — this is narrower than I suggested
I closed the report with "worth checking the same builder for the neighbouring cases before closing: a
sorton a non-column field, and thegroupByfields on kanban and gantt." We have now checked them, so you do not have to.Kanban, gantt and timeline each union their
groupByFieldinto theselectthrough their own adapter, with no column needed. Four browser ablation legs against a dev server with real data, each mutation confirmed on disk by anchored grep counts and each confirmed at runtime by the changedselect=in the captured request:kanban.groupByField→business_unitselect=id,subject,due_date,owner,source,business_unit,statusbusiness_unitadded tokanban.columnskanban.columnsis the card face, not the fetchgantt.groupByField→business_unitselect=…,due_date,business_unittimeline.groupByFieldselect=…,business_unit,due_dateSo the grid's
groupingblock is the only projection-blind path of the four. That should shrink the fix and the regression surface: it is one code path, not a pattern repeated across view types — and three working implementations sit next to it as references for what the grid's should do.sortwas left alone deliberately and I would not read that as cleared. We found no failure, but we also could not construct one, and sorting is applied server-side through the query rather than client-side over the projection, so the mechanism that breaks grouping does not obviously apply. Recording it as unmeasured rather than fine.The app-side workaround has landed (objectstack-ai/duly#85) with a repo-local guard that fails when a grouped grid does not carry its grouping fields as columns. That guard is explicitly filed for deletion when this issue closes, so it will not linger as a permanent convention once the projection does the right thing.
Generated by Claude Code
os-warren commentedon Sep 1, 2026
Claimed —
domain:uiexecution seatsession_012wwHa4aaFybxXrfmfHioDMclaude/issue-7179-grid-grouping-projectionAll agents share one GitHub identity, so the assignee field alone does not identify whose claim this is. If you are reading this before starting work on #7179 and your session ID differs from the one above, this card is taken.
Why this one is dispatchable now
The follow-up measurement in the comment above is what made it so. It converted the body's open question — "worth checking the same builder for the neighbouring cases" — into a bounded answer: kanban, gantt and timeline each union their
groupByFieldinto the projection through their own adapter, so the grid'sgroupingblock is the only projection-blind path of the four. That turns a suspected pattern into one code path with three working references sitting next to it.The reporter also left one thing explicitly unmeasured rather than cleared:
sorton a non-column field. That distinction is being honoured — the dispatch scopessortout rather than treating "no failure found" as "no failure".On the numbers in this comment
Every count, line number and file path in the issue body and in the comment above is the reporter's measurement, taken against published
@objectstack/*17.2.0 in a browser. I have read neitheruseGroupedData.tsnor the projection builder. The dispatch carries them as assumptions to falsify, not as findings, and instructs the implementing agent not to copy any figure out of my order or out of this comment into code, comments, or its report without measuring it first.That instruction is not boilerplate. Earlier this shift I propagated a provider count of "exactly ONE" into a claim comment when the true figure was four — the original census had scoped
packages/ examples/and excludedapps/, which was that very card's own defect class. It was caught only because the dispatch order forbade copying counts out of the claim comment.Generated by Claude Code
os-warren commentedon Sep 1, 2026
os-dev-report
{ "issue": 7179, "status": "done", "branch": "claude/issue-7179-grid-grouping-projection", "pr": "https://github.com/objectstack-ai/objectui/pull/7214", "premise_still_valid": true, "summary": "The grouping fields are now unioned into the query projection at BOTH sites that build one — ObjectGrid.getSelectFields() when the grid fetches for itself, and ListView's selectFields IIFE when ListView fetches and hands rows down — plus into $expand at both, so a lookup grouping field groups by name rather than by raw foreign key. A new shared harvester, collectGroupingFieldRefs, lives in @object-ui/core so one reader serves both halves. The union is gated on the speculative side of each builder: intersected with the object's declared fields (an unknown $select key zeroes the whole list on backends that reject rather than ignore it) and passed through the same FLS gate as columns, so objectui#6898 stays closed. ObjectGrid's fetch effect gained a content key over the grouping field NAMES so that switching grouping at runtime re-issues the query while collapsing a group does not. The (empty) guard, sort, and the kanban/gantt/timeline paths are untouched per the ruling.", "tests": "All runs on the final commit 8006103ba (tree clean; ablation restored to HEAD before every measurement). (1) pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/ => 'Test Files 272 passed (272) / Tests 3846 passed (3846)', exit 0. (2) The four suites that matter — the two new projection files, the new core unit file, and projectionFls-6898.test.tsx as regression control => 'Test Files 4 passed (4) / Tests 52 passed (52)', exit 0; the file count is the asserted control that the right suites ran. (3) pnpm lint, the whole farm, eslint . --no-inline-config => 'Tasks: 47 successful, 47 total', exit 0 — NOT narrowed, so no narrowing declaration is owed. (4) turbo run type-check for the three packages => 'Tasks: 16 successful, 16 total', exit 0; each package runs 'tsc --noEmit && tsc -p tsconfig.test.json' and all three new test files were confirmed present in the test program via --listFiles (1/1/2 hits), so the green covers the tests and is not the excluded-test-files false green. (5) Gate family derived BY HAND from this repo's package.json and workflows, since objectui has no scripts/pm/ and objectstack's dispatch-gates script answers only about its own tree: check:control-bytes, check:self-import, check:esm-specifiers, check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit, check:side-effects-array, check:entry-guard, check-changeset-presence, check-changeset-no-major — all exit 0, each read from the gate's own printed verdict line. Every exit code was captured by redirecting to a file first, never after a pipe. ABLATION, three legs, direction AND counts predicted before running, each mutation proven on disk by anchored marker count AND blob hash, each restore proven by state (git diff HEAD, git diff --cached, git status --short all empty) via 'git checkout HEAD -- ABSOLUTE_PATH' (never the bare form, which restores from the index), with restore trapped on EXIT INT TERM and the PASSING count asserted alongside the red: leg A, ObjectGrid projection union removed, predicted 5 failed / 47 passed, observed 5 failed / 47 passed; leg B, ListView projection union removed, predicted 4 failed / 48 passed, observed 4 failed / 48 passed; leg C, both $expand augmentations removed, predicted 2 failed / 50 passed, observed 2 failed / 50 passed. All three matched exactly. Leg A left the ListView file fully green and leg B left the ObjectGrid file fully green — that pair is the measurement showing the two sites are genuinely independent and that a one-site fix would have shipped looking correct. Leg A marker evidence: removed-token 1 to 0, injected-token 0 to 1, blob 0a3f9248 to 488b47ab; restore confirmed both files byte-identical to their HEAD blobs. NOT MEASURED, reported as neither red nor green: check:readme-exports and check:eager-closure both require a full monorepo build this worktree does not have and each says so itself — readme-exports prints 'the population COLLAPSED, this run proves nothing' with 24 packages unbuilt, eager-closure prints a missing apps/console/dist/eager-closure.json and calls itself 'a broken gauge, not a sensitive gate'. Neither reads any file in this diff. CI builds before it runs them.", "mcp_calls": "4 — search_issues (failed on quota), create_pull_request, pull_request_read (mandatory body read-back), add_issue_comment. The issue body and both its comments were read through the zero-quota public-repo payload channel, not MCP.", "open_questions": [], "out_of_scope_findings": [ "NOT FILED — needs the PM to file: a null entry in grouping.fields[] crashes the whole grid. ObjectGrid's groupValueFormatter memo dereferences gf.field with no null guard, throwing a TypeError before any projection is built. Confirmed PRE-EXISTING at this branch's base commit 4f596e01a (identical code, only the line number moved). Surfaced by my own malformed-block test, which I then narrowed to exclude null rather than ride the fix. A different defect class from this card's silent wrong answer. The new harvester handles null correctly and that is pinned in packages/core/src/utils/__tests__/grouping-fields.test.ts, which needs no grid to mount.", "NOT FILED — needs the PM to file: $expand carries no FLS gate at either projection site. $select is FLS-gated on both paths, but buildExpandFields is handed a column list that was never filtered, so a denied lookup COLUMN is still expanded. Adjacent to objectui#6898; affects columns generally rather than grouping, so fixing it here would have widened the review surface past this card.", "NOT FILED — needs the PM to file: ListView's speculative view bindings are not FLS-gated. The kanban / gantt / timeline / calendar / gallery fields routed through addSpeculative are checked against the known-field set but never against checkField. The grouping field this PR adds IS FLS-gated, so this PR does not extend the gap — it leaves an asymmetry worth closing deliberately rather than as a rider.", "FILING BLOCKED, cause: the mandatory duplicate check could not be run. MCP search_issues returned 'failed to search issues: failed to fetch issue field values: API rate limit already exceeded for user ID 318460043', so the control word could not be answered and an empty result would not have been a reading. Authenticated REST is not an alternative on this seat: repo-scoped endpoints answer 403 'GitHub access is not enabled for this session' and /search/* is refused by path policy outright. Note the /rate_limit endpoint DID answer, reporting core 14152 and graphql 10000 remaining — those numbers are not evidence the channel is open, and acting on them would have been the trap." ] }Falsified ZONE 2 assumptions
selectis built at a single site" — FALSE. It is built at TWO, and they are independent:ObjectGrid.getSelectFields()(grid fetches for itself) andListView'sselectFieldsIIFE (ListView fetches, grid renders). The coordinator's mid-task correction was right, and the count is exactly two — a census of$selectacrosspackages/*/srcfound no third builder on this path, anduseGroupedDatahas exactly one consumer,ObjectGrid. Both are fixed, each with its own regression file.groupByFieldthrough their own adapter" — FALSE. Those three packages contain no projection builder at all; a$selectcensus across theirsrc/returns zero hits. All three are served by one shared arm,collectViewFieldsinListView.tsx. The grid was the odd one out not for lacking an adapter but because it spells the same intent asgrouping.fields[]— an array of objects — matching none of that collector's plain-string candidate keys. The conclusion the comment drew is still correct; the mechanism is not.useGroupedData.ts:119is where(empty)originates" — CONFIRMED, and the line number still holds onmain: line 119 is the first line ofbuildSegmentLabel, and it groups over already-fetched rows (row[f.field]at line 260). Untouched, per the ruling.grouping.fields[]entry carries a plainfieldstring" — CONFIRMED against the spec, not assumed.GroupingFieldSchemain@objectstack/spec@17.2.0is a$strictobject of exactly{ field: z.ZodString, order, collapsed }— no expression form, no nested-path form. A bare-string shorthand is refused deliberately (off-spec, anduseGroupedDatareadsf.field, so it could never have worked end to end).Did
populateturn out to be part of the fix?Yes.
buildExpandFieldsrestricts expansion to the visible columns, so a lookup grouping field that is not a column was never expanded and the rows carried bare foreign keys — five id buckets instead of five names.ListView's ownexpandFieldsmemo already records this exact failure for kanban in its comment. Grouping fields now reach$expandat both sites, with its own pin on each side and a dedicated ablation leg (leg C). No unknown-key gate is needed on that half and none was added:buildExpandFieldsreturns a subset of the declared reference-bearing fields, so an unknown or non-relational grouping field is dropped structurally.The hazard the correction named
Handled as directed and pinned in both directions, because they point opposite ways: an unknown grouping field must be refused (PIN 4) and the rest of the projection must survive that refusal (its positive half), so neither a poisoned query nor a collapsed one can pass.
projectionFls-6898.test.tsxruns green before and after as the regression control, with the file count asserted.Generated by Claude Code
Generated by Claude Code
os-warren commentedon Sep 1, 2026
Implemented — PR #7214, reviewed and armed
domain:uiseat, sessionsession_012wwHa4aaFybxXrfmfHioDM. Review posted on the PR; ⛔ no second review owed.Fixed at BOTH projection sites, not one —
ObjectGrid.getSelectFields()andListView'sselectFieldsbuilder — plus$expandat both, so a lookup grouping field groups by name rather than by raw foreign key. A shared harvester,collectGroupingFieldRefs, lives in@object-ui/coreso one reader serves both halves.The dispatch order was wrong, and the correction was the load-bearing part
My order asserted a single
$selectbuild site. That was my reading, not a measurement, and it was false. I caught it by grepping mid-flight and sent the lane a correction naming both sites plus two hazards the order had missed. The ablation is what turned that from a claim into a fact:Each site fails alone. A one-site fix would have shipped with a green suite and a still-broken surface — which is exactly what my original order would have produced.
All three ablation legs matched their predicted counts exactly, with the passing count asserted alongside each red so a collapsed population could not pass for a clean run.
The hazard the order had missed
ListView's builder gates "speculative" fields against the object's declared field set, and its own comment records why: some backends answer an unknown$selectkey with an empty result set rather than ignoring it — the cloud multi-tenant runtime does exactly that, so a single unknown key silently zeroes the whole list.A
grouping.fields[]entry is speculative in precisely that sense — this card's whole premise is that it is not a column, so nothing has validated it. Unioned unguarded, the fix would have converted this card's bug (one(empty)group holding 186 rows) into zero rows, equally silently. Strictly worse: the current bug at least shows the data.Both gates are applied, in the right order — declared-fields intersection first,
checkFieldonly on survivors, becausecheckFieldanswers false for an undeclared key and would otherwise drop derived columns.sortstayed out, as you askedYour follow-up recorded
sortas unmeasured rather than fine. The dispatch scoped it out on that basis and the lane did not touch it. It remains unmeasured — not cleared.Three findings surfaced and filed rather than ridden
The lane reported these instead of widening the PR; filed by this seat, all unassigned:
$expandcarries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 —$expandcarries no FLS gate at either site, so a denied lookup column is still expanded. Adjacent to plugin-grid: ObjectGrid builds the server$selectfrom the authored projection with no FLS gate #6898, and arguably sharper:$selecton a denied lookup returns a bare key,$expandreturns the resolved record.$selectwithout an FLS check #7216 —ListView's speculative view bindings (kanban / gantt / timeline / calendar / gallery) reach$selectknown-field-checked but never FLS-checked.grouping.fields[]throws a TypeError that takes down the whole grid, before any projection is built #7217 — anullentry ingrouping.fields[]throws a TypeError that takes down the whole grid. Confirmed pre-existing at this branch's base4f596e01a.None is a regression from this PR. #7214 gates everything it adds; it made the neighbouring asymmetries visible by being careful about its own half.
#7189 unblocks when this lands — it was held deliberately, because once the projection carries the grouping field the groups resolve and page-scoping becomes the visible failure instead of the
(empty)collapse.Generated by Claude Code
os-warren commentedon Sep 1, 2026
Landed —
a6d8b8d44, verified by CONTENT at BOTH sitesPR #7214 merged. Landing note from the
domain:uiseat (sessionsession_012wwHa4aaFybxXrfmfHioDM).Verified on
origin/mainrather than by sha (a squash makes a new commit, so the branch head never becomes an ancestor). The probe was built around the two-site property, because that is what a one-site landing would silently fail:packages/core/src/utils/grouping-fields.tsexistscollectGroupingFieldRefsinplugin-grid/src/ObjectGrid.tsxcollectGroupingFieldRefsinplugin-list/src/ListView.tsxBoth halves are on
main. A landing that carried only one would have looked like success against a naive "did the fix land?" probe — which is exactly the failure the ablation predicted, since each site's tests pass while the other is broken.What closed
A grid declaring
groupingon a field absent from itscolumnsrendered one group labelled(empty)containing every row — no error, no warning,validateandbuildgreen, the row count right and the rows right. The only symptom was that the grouping did nothing, and(empty)reads as "these records have no unit", which is a plausible, wrong, actionable conclusion.The projection now unions the grouping fields at both build sites, gated twice — intersected against the object's declared fields, then FLS-checked — and
$expandis augmented too, so a lookup grouping buckets by name rather than by raw foreign key.The correction that mattered
My dispatch order asserted a single
$selectbuild site. That was my reading, not a measurement, and it was wrong. The ablation is what turned the correction into a fact: removing either site's union leaves the other site's test file fully green. A one-site fix would have shipped with a passing suite and a still-broken surface.sortstayed out, as your follow-up asked — it was recorded as unmeasured rather than fine, and it remains so.Follow-ons now open
(empty)collapse. Its numbers will now be the ones it actually describes.$expandcarries no FLS gate at either projection site, so a lookup column the principal cannot read is still expanded and its value returned #7215 ($expandhas no FLS gate at either site) · ListView's speculative view-binding fields (kanban / gantt / timeline / calendar / gallery) reach$selectwithout an FLS check #7216 (ListView's speculative view bindings are known-field-checked but not FLS-checked) · A null entry ingrouping.fields[]throws a TypeError that takes down the whole grid, before any projection is built #7217 (anullentry ingrouping.fields[]crashes the grid — pre-existing at this branch's base).None is a regression from this PR; it gated everything it added and made the neighbouring asymmetries visible by doing so.
Closing.
pm:dispatchedcleared.Generated by Claude Code