Repository navigation
feat(plugin-list,app-shell): a console list's toolbar grouping lives in the URL (objectui#11860) - #11991
Conversation
…in the URL (objectui#11860) ListView gains an optional onGroupingChange prop, fired with the spec GroupingConfig when the user changes the grouping in either grouping editor (the toolbar Group panel and the compact View settings popover), and not when the list re-reads a changed schema.grouping. The console object page writes it to uf__group (replace), reads it back through GroupingConfigSchema with the same drop-on-invalid read as the other uf_ keys, and seeds the list through schema.grouping: a URL grouping wins over the view's declared one, otherwise the declared one stands. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude <noreply@anthropic.com>
…bjectui#11860) The object page wrote `sort` twice into the list schema: the rung that puts a URL-carried sort first, and a later spread of the view's own sort, left over from before the URL rung existed. On a view that declares a sort the spread overrode the link's sort, so it reached the address bar and never the query. The spread is removed; the rung is the one sort write. The grouping pins also mount the grid now, so the group header query is where the grouping is read. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude <noreply@anthropic.com>
…jectui#11860) Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
The grouping section says which edits fire it, with which value, and that a schema re-sync does not; the "With Callbacks" example lists it beside onSortChange and onFilterChange. Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Inputs read: card #11860 (body and every comment, 6051934402 through 6064349633 — the triage direction 6055441287 and the lane amendment 6060546214 included), PR #11991's body and file list, the net diff of the head against ① Derived judgments
Not touched, as the claim's not-on-it list requires: ② Semver level
③ Boundary flagsDev flags (report 6064349633):
Escalations to the owning seat (out-of-scope findings; none blocks this PR):
Check-runs on the head: 40 success, 3 skipped by design, no failure — the gate verdicts stand. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #11860
Clause-②: yes —
@object-ui/plugin-list's publishedListViewPropsgains one optional member,onGroupingChange; nothing else on any published surface moves, and a host that does not pass it behaves as before.Grouping is the last of the card's four pieces. The other three (the Filter panel's conditions, the search term and the sort) landed in objectui#11915; this PR follows the same pattern. With it, all four pieces of the maintainer's contract are delivered: 「视图、筛选、排序、分组都写进 URL;临时界面(面板、弹窗)不写进 URL。」
What changes
ListView(@object-ui/plugin-list) reports a user's grouping change.onGroupingChange(grouping), besideonSortChangeandonFilterChange. The value is the spec'sGroupingConfig(@objectstack/spec/ui), orundefinedwhen the grouping is cleared.changeGrouping:schema.groupingis not echoed back. That is the same ruleonSortChangefollows for a view's declared sort.The console object page (
@object-ui/app-shell) writes it touf__groupand opens a list grouped by it.userFilterUrlState.tsgainsLIST_GROUP_PARAM(uf__group). The value is theGroupingConfigserialized as JSON. The key is reserved, soparseUserFilterParamsnever hands it toUserFiltersas a quick-filter field. This also applies toInterfaceListPageandObjectDataPage, which call that parser.GroupingConfigSchema.shape.fields.element, then the envelope throughGroupingConfigSchemaitself;perms.checkFieldonce permissions have loaded;ObjectView.tsxuses the existing seed machinery (listSeedRef/ownListParamsRef/writeListUrlState) without forking it:grouping;grouping: listSeed?.grouping ?? listSchema.groupingintoschema.grouping, whichListViewalready reads;onGroupingChangewritesuf__groupwith replace;Measured against the dispatch's mechanism assumptions (on
989b1908)groupingConfigis set in four places: the toolbar editor'sonChange, its Clear button, the setter handed toViewSettingsPopover(whose editor and Section Clear both call it), and the schema re-sync effect. The three user call sites now fire the callback, and the re-sync does not. Pinned byListView.onGroupingChange-11860.test.tsx: the toolbar editor (add, flip order, remove), toolbar Clear, popover add and popover Clear all fire. A host-sideschema.groupingchange is re-read, the editor showsDescending, andonGroupingChangeis not called.GroupingConfigSchema.parsereturns the editor's ownGroupingConfigValueunchanged. This is measured in the unit test, and in a pre-edit probe of the spec on its own. One bound was found, see Acceptance notes: the schema refuses{ fields: [] }(too_small), so a cleared grouping has no URL form.uf__groupwins over the view's declared grouping.REPLACEonly, and Back leaves the list in one step.onGroupingChangeisObjectView.tsx. The 124-file app-shell union, which includes everyInterfaceListPageandObjectDataPageimporter, passes unchanged.Also in this PR: a sort-precedence fix in the same file (bounded in-place fix)
Measured with a throwaway probe, now deleted. On a view that declares
sort: [name asc], a link carryinguf__sort=[due_date desc]produced a query with$orderby=name asc, while the address bar still showeddue_date desc. The control, the same link on a view with no declared sort, querieddue_date desc.Cause.
renderListViewwrotesorttwice. The first write is the rung objectui#11915 taught to put the URL's sort first. The second is a later...(viewDef.sort?.length ? { sort: viewDef.sort } : {})spread, left over from before that rung existed, which overrode the rung on every view that declares a sort. The spread is removed. It was redundant with the rung in every other case: for an emptyviewDef.sortthe rung already yields it.Why it is fixed here. All four in-place conditions hold:
ObjectView.tsxis already in this claim's file surface, and no other open PR touches it;Pin and changeset. The fix is pinned by a new
describeinObjectView.listUrlState-11860.test.tsxthat covers both directions, and it is named in the changeset.Tests
Every run below was taken at head
7973e15c3with a clean tree, through the shared verify lock.New and changed pins:
ListView.onGroupingChange-11860.test.tsx(new, 4 tests).userFilterUrlState.listState-11860.test.ts: 5 grouping tests added, and the reserved-key test extended touf__group.ObjectView.listUrlState-11860.test.tsx: 8 grouping tests and 2 sort-precedence tests.@object-ui/plugin-gridis now imported, andfindreturns one row in the grouping block, so a grouped grid mounts and asks for its groups. The grouping is read off thequeryGroupHeadersrequest, at the data source.ObjectView.relayRungCensus-7559.test.ts: thegroupingabsence reason now says the relay writes the key only to lay a URL grouping over it. It reads noviewDef, so the census still classes it asrelayed-upstream.Unions and type checks:
pnpm exec vitest run --maxWorkers=2 packages/plugin-list/→Test Files 117 passed (117)·Tests 1247 passed (1247).ObjectView,userFilterUrlState,listFilterStorage,InterfaceListPageorObjectDataPage, plus every test that readsObjectView.tsx,ListView.tsxoruserFilterUrlStateoff disk outsidepackages/plugin-list/→Test Files 124 passed (124)·Tests 2666 passed (2666).pnpm exec vitest run --maxWorkers=2 scripts/__tests__/→Test Files 179 passed | 2 skipped (181)·Tests 5452 passed | 2 skipped (5454).pnpm --filter @object-ui/plugin-list type-checkandpnpm --filter @object-ui/app-shell type-checkboth exit 0, and each echoes its script name (tsc --noEmit && tsc -p tsconfig.test.json). Both test configs includesrc/**/*.test.ts(x). The dependency closure was built first (turbo run build --filter=@object-ui/app-shell^..., 28/28 tasks).Ablations. Each fix was committed first, mutated through
ablation-replace.mjs(anchor must hit, blob must move), run, and restored with blob equal to HEAD and an emptygit diff HEAD. Every prediction was written before its run. The subjects resolve tosrcthrough the root vitest alias, so nodistleg was needed.onGroupingChange?.(next);fromchangeGroupinggrouping: listSchema.grouping(no URL seed)LIST_GROUP_PARAMfrom the reserved keysGates (all exit 0 unless noted):
node scripts/check-changeset-presence.mjs: "7 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)".check-changeset-no-major,pnpm check:changeset-claims("No pending changeset names a file this change touches"),pnpm check:pending-changeset-literals,check-changeset-fixed.pnpm check:new-line-citations: "0 new citation(s)".pnpm check:control-bytes, plus a control-byte self-scan of the 9 changed files (no match).pnpm check:i18n-keys,check-vi-mock-override-shape,check-test-path-roots.pnpm check:doc-types: no doc namesListViewProps.pnpm check:skill-examples, after its scoped build: "Every marked skill example holds up against the built types".ListView.tsx188 → 187).node scripts/check-changeset-overwrite.mjsreports, as designed, the one-sentence edit to.changeset/11860-list-url-state.md(see below).Not measured:
pnpm check:readme-exports: exit 1, because 6 packages this PR does not touch are unbuilt (cli,plugin-ai,plugin-gantt,plugin-map,plugin-markdown,plugin-timeline). The judged part (507 real, 0 fabricated) includes both packages this diff touches. The diff adds or removes no export and no README line.pnpm lintand the fullpnpm testare CI's runs.Acceptance notes
GroupingConfigSchemarequires at least one level, so clearing deletesuf__group. On a view that declares a grouping, a link therefore cannot carry "no grouping": it opens on the declared one. This follows the ruling (read throughGroupingConfigSchema, drop on invalid). It is not worked around with a sentinel of this module's own. The report raises it as an open question.import type { GroupingConfig }line at the top ofListView.tsx;ObjectView.tsx;.changeset/11860-list-url-state.md: "Toolbar grouping is not included yet." is deleted, because this PR makes it false and both entries would publish in the same release.packages/plugin-list/README.mdand its "With Callbacks" example are outside the claim's file surface. Offered to the seat: one line after "End users can also add or remove grouping fields at runtime via the Group toolbar button", saying that a host hears each change throughonGroupingChange.Out of scope, measured as the dispatch asked (not fixed)
The quick-filter carry-over across a view switch reproduces. It was measured with a throwaway probe, now deleted, in the same harness: this page,
plugin-view'sObjectViewandListViewrender for real, andwindow.locationwas set to the router's entry, becauseinitialUfSelectionsreads it.view/qa?uf_priority=urgent, then navigate the same mounted page toview/qb(no query string).qb: the query still carried["priority","=","urgent"], the chip read "Priority1", and the address bar's search was empty.view/qbopened fresh queried with no filter.initialUfSelectionsis captured once per mount. That path is not edited here. The finding is reported for the seat to file.Generated by Claude Code