Skip to content

Commit 61de599

Browse files
fix(plugin-list): offer the In a queue toggle only when the organization has a queue (#28889)
Fixes #28428 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:列表工具栏的「在队列中」开关现在只在两个条件都成立时出现:对象带平台注入的 `queue` 字段,且当前会话的 `/auth/me/permissions` 回答 `hasAnyQueue: true`(组织至少有一个队列)。`@object-ui/permissions` 的上下文新增一个可选成员 `hasAnyQueue`,把服务端这个布尔值原样传下去。 - 为什么改:平台给每个归属用户的对象都注入 `queue` 字段,所以没有任何队列的组织(如 app-crm、HotCRM)每个列表都显示这个开关,点了永远是空列表。按 #28428 的裁定 A,服务端已在 #28508 提供该布尔值。 - 风险与回滚:会话没有这个答案时(没有权限提供者、基于角色的提供者、匿名会话)开关隐藏;控制台的提供者在答案到达前不渲染列表,所以控制台里不会出现"加载中"的状态。服务端的队列行为(认领、释放、读取规则)不变。回滚即还原本 PR,无数据或协议变更。 - 先看:`ui/packages/plugin-list/src/ListView.tsx`(`queueFilterOffered`)、`ui/packages/permissions/src/MePermissionsProvider.tsx`(`hasAnyQueue`)、`ui/packages/permissions/src/PermissionContext.ts`。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx packages/permissions/src/__tests__/hasAnyQueue-28428.test.tsx`,预期 18 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **`@object-ui/permissions`:** `PermissionContextValue` gains one optional member, `hasAnyQueue?: boolean`. `MePermissionsProvider` sets it from the authenticated `/auth/me/permissions` answer (#28508). It is `undefined` with no answer: the anonymous answer, the role-based `PermissionProvider`, and no provider at all (`usePermissions`' no-provider literal). `ServerReadScope` and `RecordFieldAccessScope` spread their parent, so they forward it unchanged. - **`@object-ui/plugin-list`:** `queueFilterOffered` is now `hasAnyQueue === true && objectHasOwnershipQueue(objectDef)`. The flag rides the permissions answer the console already holds, so opening a list reads no queue list and sends no new request. - **Unknown flag = hidden** (claim amendment 6567840348). Under the console, `MePermissionsProvider` renders its loading fallback until the payload arrives, so a list never sees a pending flag there. Elsewhere (no provider, the role-based provider, an anonymous session) nothing can show a queue exists, so the toggle stays hidden. - Turning the toggle on filters exactly as before. Nothing on the server changes. ## Why Route A ruling 6553134732 on #28428: offer the toggle when the object carries the `queue` field AND the session's has-queues boolean is true. In an organization with no queue, the toggle could only return an empty list. ## Tests - `ListView.queueSittingFilter-25769.test.tsx`: the existing cases now mount under a session double (`MePermissionsProvider` with `hasAnyQueue: true`) and still pass. New cases: no queue gives no toggle even on an object with the injected column; one queue gives the toggle; an unknown answer (no provider, anonymous) gives no toggle; a list mount makes the same data-source calls with and without a queue, every `find` targets the listed object, and `fetch` is never called. - New `hasAnyQueue-28428.test.tsx` in `@object-ui/permissions`: the truth table (served `true` and `false`, anonymous, forwarded through `ServerReadScope`, role-based provider, no provider). - Ablations, each run through `scripts/ablation-replace.mjs` with the blob restored to HEAD afterwards: - Gate removed (`(hasAnyQueue === true || true) && …`): 2 red, "no queue" and "unknown answer". - Provider member removed (`hasAnyQueue: undefined`): 3 red, served `true`, served `false`, scope forwarding. ## Acceptance notes Measured on a production console build (`turbo run build`, `pnpm console:build`), `pnpm dev:crm -- --fresh`, admin, at head `c0b35cd6`: | State | `/auth/me/permissions` `hasAnyQueue` | `crm_account` list | `crm_contact` list | |---|---|---|---| | app-crm as shipped (no queue) | `false` | no toggle | no toggle | | one `sys_queue` row inserted (201) | `true` | toggle shown | toggle shown | | row deleted again | `false` | no toggle | no toggle | - `crm_account` serves the injected `queue` column (`system: true`, hidden), so the old rule would have shown the toggle there. - Pressing the toggle with one queue sends `GET /api/v1/data/crm_account?…&filter=["and",["owner_id","is_empty",true],["queue","is_not_empty",true]]`, the same filter as before. - Network: a list load made 31 or 32 API requests in both states, over two runs each. The distinct endpoint sets were identical. The one-request difference is a `sys_user_preference` PATCH that comes and goes with timing in either state. No request in any run named a queue. - Acceptance 1 says HotCRM too. HotCRM declares no queue (card reading), so it gets `hasAnyQueue: false` and the same result. It was not run here: it lives in another repository. - Screenshots are in the project files under `issue-28428/`. ## Verification <details><summary>Local runs at head c0b35cd</summary> - `pnpm exec vitest run packages/plugin-list/` (from `ui/`): Test Files 179 passed, Tests 1727 passed. - `pnpm exec vitest run packages/permissions/`: Test Files 16 passed, Tests 172 passed. - `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped; Tests 4900 passed, 2 skipped. - `turbo run type-check --filter=@object-ui/plugin-list --filter=@object-ui/permissions`: 51 of 51 tasks successful. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 commands, all exit 0 (33 in one loop, plus the ui `scripts/` suite above), including `check-sdui-manifest.mjs`, `check-changeset-no-major.mjs --base origin/main`, `check-adr-0087-registration.mjs`, `check:published-files`, `check:nul-bytes`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not applicable. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section is the Chinese summary the repo asks for). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUddMTB1CyxjzoKGxi --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUddMTB1CyxjzoKGxi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e1b9b0 commit 61de599

7 files changed

Lines changed: 235 additions & 11 deletions

File tree

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+
'@object-ui/permissions': patch
4+
---
5+
6+
fix(plugin-list): the list toolbar offers the "In a queue" toggle only when the organization has at least one queue
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
The "In a queue" toggle showed on every list of an owned object, because the platform adds the `queue` column to every such object. In an organization with no queue it could only ever return an empty list. It now appears when both of these hold:
12+
13+
- **The object carries the platform's `queue` column**, as before.
14+
- **The session says the organization has a queue.** `GET /auth/me/permissions` answers `hasAnyQueue` (true when the organization has a declared queue or a `sys_queue` row). The console already reads that answer once per session, so opening a list sends no new request and reads no queue list.
15+
16+
When the answer is unknown (no permission provider is mounted, the role-based `PermissionProvider` is used, or the session is anonymous), the toggle stays hidden. Turning the toggle on filters exactly as before, and the server's queue behaviour (claim, release, the read rule) does not change.
17+
18+
`@object-ui/permissions`: `PermissionContextValue` gains one optional member, `hasAnyQueue?: boolean`. `MePermissionsProvider` sets it from the authenticated answer. It is `undefined` with no answer.

‎ui/packages/permissions/src/MePermissionsProvider.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,9 @@ export function MePermissionsProvider({
439439
// `current_user.can(object, verb)` is answered from. Keyed on `dataKey`
440440
// like every member here, so it is the SAME object for the same payload.
441441
effectiveObjects: authed?.objects,
442+
// [objectstack#28508] The server's one has-queues boolean, verbatim;
443+
// `undefined` with no authenticated answer.
444+
hasAnyQueue: authed?.hasAnyQueue,
442445
isLoaded,
443446
}));
444447

‎ui/packages/permissions/src/PermissionContext.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ export interface PermissionContextValue {
102102
* answer from it (see app-shell's `useExpressionPermissions`).
103103
*/
104104
effectiveObjects?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
105+
/**
106+
* [objectstack#28508] Whether the caller's organization has at least one
107+
* ownership queue, declared or a `sys_queue` row: the server resolves it and
108+
* `/auth/me/permissions` serves it as `hasAnyQueue`, handed on here verbatim.
109+
* It names no queue.
110+
*
111+
* `undefined` means no answer: `MePermissionsProvider` before its first
112+
* answer or for an anonymous session, the role-based `PermissionProvider`,
113+
* or no provider at all. A consumer reads only `=== true` as "there is a
114+
* queue": an unknown answer cannot show one exists (the list's "In a queue"
115+
* toggle stays hidden on it, objectstack#28428).
116+
*/
117+
hasAnyQueue?: boolean;
105118
/** Whether permissions are loaded */
106119
isLoaded: boolean;
107120
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#28428 — `hasAnyQueue`: the `/auth/me/permissions` boolean
6+
* (objectstack#28508) saying the caller's organization has at least one
7+
* ownership queue, handed on verbatim for the list's "In a queue" toggle.
8+
*
9+
* The truth table, one row per `PermissionContextValue` source:
10+
* - `MePermissionsProvider` with an authenticated answer → the answer's own
11+
* boolean, `true` and `false` alike;
12+
* - `MePermissionsProvider` with the anonymous answer → `undefined`;
13+
* - a scope below it (`ServerReadScope`) → the parent's value, forwarded;
14+
* - `PermissionProvider` (roles from config, no server payload) → `undefined`;
15+
* - no provider at all → `undefined`.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { render } from '@testing-library/react';
20+
import React from 'react';
21+
import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '../MePermissionsProvider';
22+
import { PermissionProvider } from '../PermissionProvider';
23+
import { ServerReadScope } from '../ServerReadScope';
24+
import { usePermissions } from '../usePermissions';
25+
26+
const seen: Array<ReturnType<typeof usePermissions>> = [];
27+
28+
function Probe() {
29+
seen.push(usePermissions());
30+
return null;
31+
}
32+
33+
function answer(hasAnyQueue: boolean): MePermissionsAuthenticatedResponse {
34+
return {
35+
authenticated: true,
36+
userId: 'u1',
37+
tenantId: 'org_1',
38+
hasAnyQueue,
39+
positions: [],
40+
permissionSets: ['member_default'],
41+
objects: {},
42+
fields: {},
43+
};
44+
}
45+
46+
describe('objectstack#28428 — hasAnyQueue on the permission context', () => {
47+
it.each([true, false])('MePermissionsProvider hands on the served %s', (value) => {
48+
seen.length = 0;
49+
render(
50+
<MePermissionsProvider initialPermissions={answer(value)}>
51+
<Probe />
52+
</MePermissionsProvider>,
53+
);
54+
expect(seen.at(-1)?.isLoaded).toBe(true);
55+
expect(seen.at(-1)?.hasAnyQueue).toBe(value);
56+
});
57+
58+
it('the anonymous answer carries no has-queues answer', () => {
59+
seen.length = 0;
60+
render(
61+
<MePermissionsProvider initialPermissions={{ authenticated: false }}>
62+
<Probe />
63+
</MePermissionsProvider>,
64+
);
65+
expect(seen.at(-1)?.isLoaded).toBe(true);
66+
expect(seen.at(-1)?.hasAnyQueue).toBeUndefined();
67+
});
68+
69+
it('a scope below the provider forwards the parent\'s answer', () => {
70+
seen.length = 0;
71+
render(
72+
<MePermissionsProvider initialPermissions={answer(true)}>
73+
<ServerReadScope objects={['sys_user']}>
74+
<Probe />
75+
</ServerReadScope>
76+
</MePermissionsProvider>,
77+
);
78+
expect(seen.at(-1)?.hasAnyQueue).toBe(true);
79+
});
80+
81+
it('PermissionProvider has no server payload: undefined', () => {
82+
seen.length = 0;
83+
render(
84+
<PermissionProvider roles={[]} permissions={[]} userRoles={[]}>
85+
<Probe />
86+
</PermissionProvider>,
87+
);
88+
expect(seen.at(-1)?.isLoaded).toBe(true);
89+
expect(seen.at(-1)?.hasAnyQueue).toBeUndefined();
90+
});
91+
92+
it('no provider at all: undefined', () => {
93+
seen.length = 0;
94+
render(<Probe />);
95+
expect(seen.at(-1)?.isLoaded).toBe(false);
96+
expect(seen.at(-1)?.hasAnyQueue).toBeUndefined();
97+
});
98+
});

‎ui/packages/permissions/src/usePermissions.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ const NO_PROVIDER_PERMISSIONS: PermissionsWithHelpers = Object.freeze({
4343
// for an unreported backend and keeps `hasCapabilities` fail-open.
4444
systemPermissions: undefined,
4545
hasCapabilities: () => true,
46+
// [objectstack#28508] No provider → no has-queues answer.
47+
hasAnyQueue: undefined,
4648
isLoaded: false,
4749
can: () => true,
4850
cannot: () => false,

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2159,11 +2159,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
21592159
: userFilterConditions;
21602160

21612161
// The "In a queue" toggle (objectstack ADR-0142 D3). Offered only on an
2162-
// object the server serves with its injected `queue` column; withheld with
2163-
// the user filters on a dataset-bound chart. Ephemeral like the toolbar's
2164-
// other unpersisted toggles: it narrows what is on screen, nothing else.
2162+
// object the server serves with its injected `queue` column, AND only when
2163+
// the session says the organization has at least one queue (`hasAnyQueue`
2164+
// on `/auth/me/permissions`, objectstack#28428): with no queue the toggle
2165+
// could only ever return an empty list. The flag rides the permissions
2166+
// answer the console already holds, so a list mount reads no queue list.
2167+
// An unknown answer (no provider, the role-based one, anonymous) hides it.
2168+
// Withheld with the user filters on a dataset-bound chart. Ephemeral like
2169+
// the toolbar's other unpersisted toggles: it narrows what is on screen.
21652170
const [sittingInQueueOnly, setSittingInQueueOnly] = React.useState(false);
2166-
const queueFilterOffered = objectHasOwnershipQueue(objectDef);
2171+
const { hasAnyQueue } = usePermissions();
2172+
const queueFilterOffered = hasAnyQueue === true && objectHasOwnershipQueue(objectDef);
21672173
const appliedQueueSittingConditions =
21682174
queueFilterOffered && sittingInQueueOnly && !datasetChartOnScreen
21692175
? QUEUE_SITTING_CONDITIONS

‎ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx‎

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
*
1313
* Pinned:
1414
* - the render condition: the toggle appears only when the SERVED object
15-
* definition carries the platform-injected `queue` column (`system: true`);
16-
* an object without it, or with an author's own non-system `queue` field,
17-
* gets no toggle;
15+
* definition carries the platform-injected `queue` column (`system: true`)
16+
* AND the session's `/auth/me/permissions` answer says the organization has
17+
* at least one queue (`hasAnyQueue`, objectstack#28428); an object without
18+
* the column, or with an author's own non-system `queue` field, gets no
19+
* toggle, and neither does any object while `hasAnyQueue` is false or
20+
* unknown (no permission provider, or an anonymous answer);
21+
* - the cost: the flag rides the permissions answer the session already
22+
* holds, so a list mount makes the same data-source calls and no other
23+
* request whether or not a queue exists;
1824
* - the request: pressing it AND-s exactly `owner_id is_empty` and `queue
1925
* is_not_empty` into the fetch filter, and releasing it drops them. Empty is
2026
* the server's reading (null OR ''), so the null-only operators are not
@@ -27,6 +33,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
2733
import { render, waitFor, cleanup, fireEvent, screen } from '@testing-library/react';
2834
import { ListView, buildEffectiveFilter, objectHasOwnershipQueue, QUEUE_SITTING_CONDITIONS } from '../ListView';
2935
import { SchemaRendererProvider } from '@object-ui/react';
36+
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
3037
import type { ListViewSchema } from '@object-ui/types';
3138

3239
afterEach(cleanup);
@@ -49,14 +56,38 @@ function dataSourceServing(fields: Record<string, unknown>) {
4956

5057
const schema = { type: 'list-view', objectName: OBJECT, fields: ['subject'] } as unknown as ListViewSchema;
5158

52-
function mount(ds: ReturnType<typeof dataSourceServing>) {
59+
/** The session double: the `/auth/me/permissions` answer, serving `hasAnyQueue`. */
60+
function session(hasAnyQueue: boolean): MePermissionsResponse {
61+
return {
62+
authenticated: true,
63+
userId: 'u1',
64+
tenantId: 'org_1',
65+
hasAnyQueue,
66+
positions: [],
67+
permissionSets: ['member_default'],
68+
objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
69+
fields: {},
70+
};
71+
}
72+
73+
/** The organization has a queue unless a test says otherwise. */
74+
const WITH_QUEUE = session(true);
75+
76+
function mount(ds: ReturnType<typeof dataSourceServing>, perms: MePermissionsResponse | null = WITH_QUEUE) {
77+
const list = <ListView schema={schema} dataSource={ds as any} />;
5378
return render(
5479
<SchemaRendererProvider dataSource={ds as any}>
55-
<ListView schema={schema} dataSource={ds as any} />
80+
{perms ? <MePermissionsProvider initialPermissions={perms}>{list}</MePermissionsProvider> : list}
5681
</SchemaRendererProvider>,
5782
);
5883
}
5984

85+
/** Settles a mount that renders no toggle: the definition is read and the rows fetched. */
86+
async function settled(ds: ReturnType<typeof dataSourceServing>) {
87+
await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
88+
await waitFor(() => expect(ds.find).toHaveBeenCalled());
89+
}
90+
6091
const lastFilter = (ds: ReturnType<typeof dataSourceServing>) => {
6192
const calls = ds.find.mock.calls;
6293
return (calls[calls.length - 1]?.[1] as { $filter?: unknown } | undefined)?.$filter;
@@ -128,8 +159,61 @@ describe('ListView "In a queue" toggle (ADR-0142 D3)', () => {
128159
it('does not render on an object without the injected column', async () => {
129160
const ds = dataSourceServing({ queue: { name: 'queue', type: 'text' } });
130161
mount(ds);
131-
await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
132-
await waitFor(() => expect(ds.find).toHaveBeenCalled());
162+
await settled(ds);
133163
expect(screen.queryByTestId('queue-sitting-filter')).toBeNull();
134164
});
135165
});
166+
167+
describe('ListView "In a queue" toggle — offered only when the organization has a queue (objectstack#28428)', () => {
168+
it('an organization with no queue: no toggle, even on an object with the injected column', async () => {
169+
const ds = dataSourceServing({ queue: INJECTED_QUEUE });
170+
mount(ds, session(false));
171+
await settled(ds);
172+
expect(screen.queryByTestId('queue-sitting-filter')).toBeNull();
173+
expect(JSON.stringify(lastFilter(ds) ?? null)).not.toContain('owner_id');
174+
});
175+
176+
it('an organization with one queue: the toggle on the injected column\'s object', async () => {
177+
const ds = dataSourceServing({ queue: INJECTED_QUEUE });
178+
mount(ds, session(true));
179+
expect(await screen.findByTestId('queue-sitting-filter')).toBeTruthy();
180+
});
181+
182+
it('an unknown answer hides it: no permission provider, or an anonymous answer', async () => {
183+
for (const perms of [null, { authenticated: false } as MePermissionsResponse]) {
184+
const ds = dataSourceServing({ queue: INJECTED_QUEUE });
185+
const { unmount } = mount(ds, perms);
186+
await settled(ds);
187+
expect(screen.queryByTestId('queue-sitting-filter')).toBeNull();
188+
unmount();
189+
}
190+
});
191+
192+
it('a list mount costs the same calls with and without a queue, and reads no queue list', async () => {
193+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
194+
try {
195+
const calls = async (hasAnyQueue: boolean) => {
196+
const ds = dataSourceServing({ queue: INJECTED_QUEUE });
197+
const { unmount } = mount(ds, session(hasAnyQueue));
198+
await settled(ds);
199+
if (hasAnyQueue) await screen.findByTestId('queue-sitting-filter');
200+
const counts = {
201+
getObjectSchema: ds.getObjectSchema.mock.calls.length,
202+
find: ds.find.mock.calls.length,
203+
findOne: ds.findOne.mock.calls.length,
204+
// Every `find` targets the listed object: no `sys_queue` read.
205+
resources: [...new Set(ds.find.mock.calls.map((c) => c[0]))],
206+
};
207+
unmount();
208+
return counts;
209+
};
210+
const without = await calls(false);
211+
const withQueue = await calls(true);
212+
expect(withQueue).toEqual(without);
213+
expect(without.resources).toEqual([OBJECT]);
214+
expect(fetchSpy).not.toHaveBeenCalled();
215+
} finally {
216+
fetchSpy.mockRestore();
217+
}
218+
});
219+
});

0 commit comments

Comments
 (0)