Skip to content

Commit b5c8d2d

Browse files
committed
Merge origin/main into #4464 branch — the floor and the resolved total compose
PR #4510 (#4501's abstain floor) landed on the same two `BulkActionBar` prop sites this branch rewrites, so both hunks conflicted. Composed rather than picked: the offer requires #4510's FLOOR (`canOfferSelectAllMatching` — no escalation without a query to replay) and carries #4503's RESOLVED total (`resolvedTotalMatching` — the host's `rowCount` on the external path), i.e. totalMatching={canOfferSelectAllMatching ? resolvedTotalMatching : undefined} at both sites. The floor subsumes the `singleSelection ? undefined : …` suppression the incoming side spelled there — `!singleSelection` is its first conjunct — so no gating is lost; a host with a real total but no `findParams` still gets no offer, which is the safety semantics winning the tie. Follow-through, ruled in both PRs: the composition test gains the full-path fan-out assertion #4510 made possible. The real ListView issues a real filtered query, the real grid offers the escalation off the host's `rowCount`, and the dispatched bulk action's fan-out is asserted to replay ListView's own params verbatim — measured against what went on the wire, not a hand-written literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
2 parents 6099e17 + aca27fa commit b5c8d2d

85 files changed

Lines changed: 7892 additions & 699 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@object-ui/plugin-grid': minor
3+
---
4+
5+
fix(plugin-grid): cross-page "select all N matching" replays the host's real query — or abstains — instead of fanning out unfiltered
6+
7+
`resolveBulkRows` re-issues the view's query in 500-record pages so a bulk action
8+
receives the whole match set rather than the visible window. The query it
9+
replayed came from `lastFindParamsRef`, whose only writer is ObjectGrid's own
10+
data loader. Under a host that fetches the rows itself — ListView passing `data`
11+
plus `manualPagination` and `rowCount`, which is what the console does — that
12+
loader never runs, so the ref was not the query behind the rows on screen:
13+
absent, or stale from an earlier own-fetch. Either way the `?? {}` default let
14+
the fan-out ask the server for the WHOLE OBJECT — no `$filter`, no `$orderby`,
15+
no `$search` — and hand up to 5000 unmatched records to a destructive executor
16+
(`onBulkDelete`) while the bar read "All N matching records are selected".
17+
18+
The host now hands its query down as the new optional `findParams` prop on
19+
`ObjectGridExternalPaginationProps` (the same shape the internal loader stores),
20+
and the fan-out reads whichever side owns the fetch. There is deliberately no
21+
grid-side default: when no query is available for the current data path the
22+
escalation is **not offered at all** — a host that forgets `findParams` loses
23+
the affordance rather than silently collecting the whole object, which is what
24+
makes the unfiltered fan-out structurally unreachable rather than merely
25+
currently-wired-right. A changed `findParams` also resets the escalation,
26+
mirroring the `setSelectAllMatching(false)` the internal loader runs next to its
27+
own params write, so "All N matching" cannot survive the host's filter, search,
28+
sort or page changing; the comparison is by content, so a host re-render that
29+
rebuilds an equal object does not drop the user's escalation.
30+
31+
The internal-loader path is unchanged: with the ref populated the fan-out issues
32+
the same params it always did, and the `selection.type: 'single'` suppression is
33+
untouched.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/plugin-charts': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
A null-keyed group renders as an explicit bucket instead of silently vanishing from a chart (objectui#4466)
8+
9+
`buildChartSeries`' single-dimension branch passed rows through verbatim, so a row whose category VALUE is `null` reached recharts with a null category and drew no mark. The visible outcome was not an empty chart but a quietly wrong one: rows `[{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}]` drew exactly ONE bar — the dominant group, 51 of 53 events, dropped while the y-axis scale still accommodated it, so the chart understated its own data and the axis proved the data had been there. With every group null it drew axes, gridlines and an axis title with zero marks and no empty state, which is the shipped first-boot state of the built-in System Overview board's "Events by User" (every seeded `sys_audit_log` row is written with `user_id = NULL`).
10+
11+
The mapping lives in the shared series layer, so dashboard widgets and standalone `ObjectChart` get one answer rather than a per-chart patch in the recharts wrapper. It resolves the two-answers disagreement the card names as well: an empty result set keeps the designed empty state, a non-empty result always draws bars — the null bucket included.
12+
13+
`@object-ui/core` gains `NULL_CATEGORY_LABEL` and `ChartSeriesOptions`; `buildChartSeries` and `findChartSeriesRow` each take an optional trailing `options`. Both additive — every existing call site compiles and behaves identically, and a result with no null category is still returned by array identity. The two helpers are a pair on purpose: the caller matches a clicked segment against rows that still carry the raw `null`, so `findChartSeriesRow` reads the bucket label back to that row and the newly-visible bar keeps its drill-through instead of resolving to `-1`.
14+
15+
The label goes through the i18n channel (`chart.nullCategory`, en `(None)` / zh `(未指定)`, all ten packs), passed down by the renderer: `@object-ui/core` is React-free and cannot read the locale bundle, so it takes the resolved string the same way `dimensionOptionTranslator` takes a resolver. Its English constant is the floor for a provider-less host, not the mechanism.
16+
17+
`hasNoCategoryKey` (framework#4033) is untouched and now documented against this: a row that does not carry the category key AT ALL is a different defect — a dimension grouped by but never projected — and keeps its explanatory placeholder. The bucket deliberately never ADDS the key to such a row, which is what keeps that guard's signal alive. Key absent → the placeholder; key present with a null value → the bucket.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-charts': patch
4+
---
5+
6+
The multi-dimension pivot branch buckets a null first-dimension value instead of dropping its bar (objectui#4497)
7+
8+
`buildChartSeries`' pivot branch (2+ dimensions, single measure) bucketed rows by `String(xRaw ?? '')` but wrote the RAW value into the emitted row, so a null first-dimension value produced `{status: null, Low: 3}` and reached recharts with a null category — which draws no mark. Measured at the DOM: a two-group pivot drew ONE bar, and an all-null pivot drew axes and gridlines with zero bar rectangles and no empty state. That is the same mechanism objectui#4466 fixed one branch below, on the branch that card deliberately left pinned as-is until the pivot's own bucketing had been measured.
9+
10+
The pivot now maps a null/undefined first-dimension VALUE to the same bucket label the single-dimension branch uses — `ChartSeriesOptions.nullCategoryLabel`, defaulting to `NULL_CATEGORY_LABEL`. One doctrine, one predicate, two call sites; no new export, and every existing call site compiles and behaves identically.
11+
12+
The bucket KEY is untouched, which is what keeps this a display fix: `String(xRaw ?? '')` still decides which rows share a bar, so every existing grouping is byte-identical and only the label the bucket carries changes. Rows that lack the category key entirely are still not bucketed — that shape is a dimension grouped by but never projected (framework#4033), a different defect with a different answer.
13+
14+
Drill-through needed no change, which was measured rather than assumed: the pivot's emitted rows are AGGREGATED, so they are not index-aligned with `drillRawRows` and the one production caller (`DatasetWidget.handleChartDrill`) already drills by SEARCHING the raw rows through `findChartSeriesRow`. Those raw rows still carry their null, and objectui#4466's label-matching covers the multi-dimension arm as well as the single-dimension one, so the newly-visible bar resolves to the right record. Pinned at both levels so a regression in either half surfaces as the dead click it would be.

.changeset/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@
5050
"updateInternalDependencies": "patch",
5151
"ignore": [
5252
"@object-ui/example-*",
53-
"@object-ui/site"
53+
"@object-ui/site",
54+
"@object-ui/test-support"
5455
],
5556
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
5657
"onlyUpdatePeerDependentsWhenOutOfRange": true
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
A dashboard chart's null-value bucket now reads the app's language instead of the English `(None)`
6+
7+
`buildChartSeries` groups rows whose category value is `null` under a labelled bucket, so the group draws as a bar instead of vanishing off the axis (objectui#4466). The label comes from the caller: `@object-ui/core` is React-free, cannot read the locale bundle, and falls back to the English constant `(None)`. `ObjectChart` passes its resolved label and localizes; `DatasetWidget` called the same helper with no options, so a dashboard widget in a zh app labelled the bucket `(None)` while the standalone chart one panel over labelled it `(未指定)`. It now passes `chart.nullCategory` from the i18n channel, which every locale pack already carries.
8+
9+
The same label goes to `findChartSeriesRow`, and that half is what keeps the bar clickable. That helper is the inverse map behind segment-click drill-through: it compares the clicked category against its own copy of the bucket label, defaulting to the same English floor. Passing the localized label to only the forward call would draw a bar reading `(未指定)` while the drill matched `(None)` — the click resolves to no row and the drawer never opens, which is a worse outcome than the untranslated word this fixes. Both calls now read one binding, so they cannot drift apart.
10+
11+
Nothing else moves: non-null categories chart and drill exactly as before, an `en` app still reads `(None)` (now via its locale pack rather than the hardcoded floor), and a widget over data with no null group is untouched.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
The console shows a standing impersonation banner, with an exit that fails loudly (#4467).
7+
8+
While `session.impersonatedBy` is present, `ConsoleShell` renders a banner naming BOTH
9+
parties — the impersonated user, whose name every write is recorded under, and the
10+
administrator who started it — plus a stop affordance. It derives from the session rather
11+
than from client memory of the click, so it survives a full SPA reboot, a new tab and a
12+
browser restart, and it cannot disagree with who the server thinks is acting. An ordinary
13+
session renders `null` and its chrome is unchanged.
14+
15+
The exit calls `POST /auth/admin/stop-impersonating` over the same data lane and then
16+
awaits a session refresh. The server restores the administrator from the `admin_session`
17+
COOKIE, so a deployment that blocks cookies cannot exit this way — the banner says so and
18+
stays up instead of appearing to succeed, which would leave the operator doing ordinary
19+
work under someone else's identity.
20+
21+
Ten locale packs carry the banner's copy.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@object-ui/auth': minor
3+
---
4+
5+
The data lane now honors `set-auth-token`, so impersonation takes effect at all (#4467).
6+
7+
The console injects the same localStorage bearer from two lanes: the AUTH lane
8+
(`createBearerFetch` inside `createAuthClient`) and the DATA lane
9+
(`createAuthenticatedFetch` — the adapter, `provider: 'api'` data sources, and every
10+
metadata `type: 'api'` action). better-auth's server-side bearer plugin hands a ROTATED
11+
session token back in the `set-auth-token` response header on whichever lane the call
12+
arrived over, and only the auth lane read it. A rotation issued to a data-lane call was
13+
discarded and the browser kept sending the old token.
14+
15+
`POST /auth/admin/impersonate-user` is exactly such a call — an ordinary metadata action.
16+
The impersonated session token was dropped on the floor while the server's bearer plugin
17+
kept overwriting the impersonation cookie with the admin bearer the console kept sending,
18+
so impersonation was a complete no-op in the console rather than merely an invisible one.
19+
Support staff believed they were seeing a user's view while acting entirely as themselves.
20+
21+
Published behaviour that moves: a data-lane response carrying `set-auth-token` now
22+
replaces the stored session token, on any API call this lane authenticated (untrusted
23+
targets remain the `sameOriginOnly` option's job — it short-circuits before any header
24+
work). The accepted cost, recorded on the card: while impersonating, the administrator's
25+
own token is replaced in localStorage for the duration, and a client that misses the stop
26+
rotation is stranded until re-login.
27+
28+
Also in this release, all additive:
29+
30+
- `AuthContextValue.refreshSession()` re-resolves `user`/`session` from the server in
31+
place, without raising `isLoading` — the transitions that change WHO the session is
32+
without going through `signIn`/`signOut`.
33+
- `TokenStorage.subscribeRotation()` notifies when a token already in hand is replaced by
34+
a different one. First store, `clear()`, and re-storing the same value stay silent:
35+
those transitions have an owner that updates identity itself.
36+
- `AuthClientSession.impersonatedBy?: string` — optional, set by better-auth's admin
37+
plugin for the life of an impersonated session.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
---
4+
5+
fix(plugin-list): ListView hands the child grid the query behind the window it passes down
6+
7+
ListView owns the fetch on the external-pagination path — it holds the filter,
8+
the search term and the sort, and it is the side that calls `dataSource.find`.
9+
The grid it hands the window to has a cross-page "select all N matching"
10+
escalation that RE-ISSUES that query to collect the whole match set, and with
11+
nothing handed down it replayed its own never-written params ref and so asked the
12+
server for the entire object, feeding unmatched records to bulk delete.
13+
14+
The params object is now hoisted out of the `find` call — one object, one query,
15+
no reconstruction that could drift from what was actually asked — recorded past
16+
the stale-request guard so it is always the query that produced the rows on
17+
screen, and forwarded as `findParams` in the same handoff block as `rowCount`,
18+
`page` and `onPageChange`. No public API of `ListView` changes.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
Members & invitations tabs gate their affordances by org role instead of letting the server's 403 be the UI (#4475)
7+
8+
A user whose organization role is `member` opened the workspace members page and
9+
was shown an enabled **Invite member** button plus a per-row **Member actions**
10+
menu carrying **Remove member** — on every row, the workspace Owner's included.
11+
Nothing was hidden or disabled; the action only failed after the user had
12+
committed to it. The Settings tab of the same page already gated correctly; the
13+
members and invitations tabs never got the same treatment.
14+
15+
The affordances are now narrowed to the roles that can actually use them, keyed
16+
on the active member's role — the same source the role-change menu on this page
17+
already reads. Which roles those are is **measured against the routes that
18+
enforce them**, not assumed to be "owner":
19+
20+
| affordance | route | permission | roles |
21+
|---------------------|-----------------------------------|-------------------------|-------------------------------|
22+
| Invite member | `/organization/invite-member` | `invitation:["create"]` | owner, admin, delegated_admin |
23+
| Remove member | `/organization/remove-member` | `member:["delete"]` | owner, admin |
24+
| Cancel invitation | `/organization/cancel-invitation` | `invitation:["cancel"]` | owner, admin |
25+
26+
Three different gates, because `delegated_admin` holds `invitation:["create"]`
27+
without `member:["delete"]` and deliberately without `cancel` — so it keeps the
28+
invite button and the copy-link action while losing remove and cancel. A single
29+
owner check could not express that.
30+
31+
An actor left with no row action at all gets no menu rather than a trigger that
32+
opens onto nothing, and the members page explains the absence where the Invite
33+
button used to sit, in the Settings tab's own voice. An unresolved role is
34+
treated as the least privileged, so nothing privileged is offered to a viewer
35+
whose membership could not be read.
36+
37+
Reading the pages is unaffected: the member list and the invitation ledger still
38+
render in full. Whether `org_member` should be able to read the invitation
39+
ledger at all is a separate, server-side question.

.changeset/olive-donkeys-shave.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/plugin-dashboard': patch
3+
---
4+
5+
Dashboard global filters sourced from `optionsFrom` now commit the RAW value instead of the display label.
6+
7+
The option source is a server GROUP BY whose response carries both forms of every grouped value: `rows` holds the resolved display labels (`{status: 'In Review'}`) and the index-aligned `drillRawRows` holds the raw stored values (`{status: 'in_review'}`). `DashboardFilterBar` read the value off `rows`, so picking an option broadcast a label no record carries into every bound widget's `runtimeFilter` and each widget repainted to "No rows". Options are now paired index-wise — value from `drillRawRows`, label from the displayed row — mirroring how the drill path has always read the same response. The trigger still displays the label, and statically declared `options` are unaffected. When the raw rows are absent, disagree in length with `rows`, or carry no such field, the previous read is kept rather than guessing at a pairing.

0 commit comments

Comments
 (0)