Skip to content

Commit b74793c

Browse files
fix(setup): position holder picker shows email beside the name (#27427)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8MmawCuxSaq1JmBYvdfaADn)_ Fixes #27410 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup › 岗位 › 添加持有人 的用户选择框,在姓名列旁边多画一列邮箱。 - 为什么改:原来只列姓名,两个同名的人分不清,授予岗位前也无法确认是哪个账号;同页的持有人列表本来就在姓名下显示邮箱。 - 风险与回滚:只给选择框传了列和对象 schema;看不到 `sys_user.email` 的人仍只看到姓名列(选择框自身的字段读权限规则);回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx`(`RecordPickerDialog` 的 `columns` 与 `objectSchema`)、同目录新测试 `PositionHoldersSection.pickerColumns-27410.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PositionHoldersSection`,预期 4 个文件 17 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `PositionHoldersSection` now opens its `RecordPickerDialog` on `sys_user` with: - `columns`: a Name column (`name`, the field `sys_user` declares as its `nameField`) and an Email column (`email`, its login identity), with headers from the section's own en/zh copy. - `objectSchema`: `sys_user` as the registry serves it, read from the `useMetadata()` objects the section already holds (no new fetch). The name column resolves each person's title through it, as a lookup's dropdown does. Nothing else moves. Per the card's ruling, the picker's own read rule (`readableColumns`) decides what it draws: the section adds no permission check of its own and reads the email no other way. ## Why The picker drew only its default display column, so two people of one name could not be told apart, while the holder rows on the same section already show each holder's email under the name. Row 2 of #27356. ## Tests New file `PositionHoldersSection.pickerColumns-27410.test.tsx`, against the real `RecordPickerDialog` (the existing suites stub it): - the picker draws a name column and an email column (cells read by `data-lookup-cell`); - CONTROL: choosing a person still creates the `sys_user_position` row by name; - CONTROL: under a real `PermissionProvider` policy that denies `sys_user.email` to the viewer, the picker draws the name column only and no email text. Ablation (one-shot, `node scripts/ablation-replace.mjs --delete` on the `columns` prop, under the lock): the email-column test goes red (1 failed, 2 passed), and the restore leg reports blob `7283c48e2e90` equal to HEAD with `git diff HEAD` empty. ## Acceptance notes - `AssignedUsersSection` (a permission set's holders) opens the same `RecordPickerDialog` on `sys_user` with no `columns`, so its picker lists names only too. Read from the source, not filed here; reported to the seat. ## Verification All readings on head `d2e47a1a` (origin/main merged). <details><summary>Local runs</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/PositionHoldersSection` (lock): Test Files 4 passed (4), Tests 17 passed (17). - `pnpm --filter @object-ui/app-shell type-check` (lock): VERDICT command-exit 0. - `pnpm --dir ui run type-check:scripts`: exit 0. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `node scripts/check-sdui-manifest.mjs`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived families, each run, all exit 0; `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. </details> Changeset: `.changeset/27410-position-holder-picker-email.md`, `@object-ui/app-shell` and `@object-ui/console` patch (app-shell is private and versioned; console ships it). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI runs it) - [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: its generated artifacts are regenerated and committed. (not touched) - [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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZN5JqZpSuejtNPz6p)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbb49c9 commit b74793c

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(setup): Position › Add holder's user picker shows each person's email beside their name
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** On a Setup position page, Add holder opened a user picker that drew one column, the user's name. An administrator could not tell two people of the same name apart, nor confirm the account before granting the position, although the holder list on the same page already shows each holder's email under the name.
12+
- **Now.** The picker draws a Name column and an Email column (`sys_user`'s `name` and `email`), and titles each person by `sys_user` as the registry serves it.
13+
- **Unchanged.** The picker still draws only the columns the caller may read: for a caller whose policy denies `sys_user.email`, the Email column is not drawn and the Name column stands alone. Choosing people still assigns the position by name, offers the active organization's members only, and skips anyone who already holds it.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27410 — Add holder's user picker shows each person's email beside
5+
* their name, as the holder rows do, so two people of one name can be told
6+
* apart before either is granted the position.
7+
*
8+
* Pinned against the real `RecordPickerDialog` (not a stub), so what is read
9+
* is the table the admin sees:
10+
*
11+
* - the picker draws a name column and an email column;
12+
* - CONTROL: choosing a person still assigns the position BY NAME;
13+
* - CONTROL: under a loaded policy that denies `sys_user.email`, the picker's
14+
* own read rule leaves the name column alone — the section adds no check of
15+
* its own and reads the email no other way.
16+
*/
17+
import * as React from 'react';
18+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
19+
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
20+
import { PermissionProvider } from '@object-ui/permissions';
21+
import type { ObjectPermissionConfig } from '@object-ui/types';
22+
23+
const users = [
24+
{ id: 'u_1', name: 'Ada Auditor', email: 'ada@example.com' },
25+
{ id: 'u_2', name: 'Sam Lee', email: 'sam.lee@example.com' },
26+
{ id: 'u_3', name: 'Sam Lee', email: 'slee@example.com' },
27+
];
28+
const assignments = [{ id: 'up_1', user_id: 'u_1', position: 'auditor' }];
29+
30+
const adapter = {
31+
find: vi.fn(async (object: string, query: any) => {
32+
if (object === 'sys_user_position') {
33+
return { data: assignments.filter((a) => a.position === query?.$filter?.position) };
34+
}
35+
if (object === 'sys_user') {
36+
const ids: string[] | undefined = query?.$filter?.id?.$in;
37+
const data = ids ? users.filter((u) => ids.includes(u.id)) : users;
38+
return { data, total: data.length };
39+
}
40+
return { data: [] };
41+
}),
42+
create: vi.fn(async () => ({})),
43+
delete: vi.fn(async () => ({})),
44+
};
45+
vi.mock('@object-ui/react', async (importOriginal) => ({
46+
...(await importOriginal<Record<string, unknown>>()),
47+
useAdapter: () => adapter,
48+
}));
49+
50+
import { PositionHoldersSection } from './PositionHoldersSection';
51+
52+
/** A policy under which `viewer` may not read `sys_user.email`. */
53+
const EMAIL_DENIED: ObjectPermissionConfig[] = [
54+
{
55+
object: 'sys_user',
56+
roles: { viewer: { actions: ['read'], fieldPermissions: [{ field: 'email', read: false }] } },
57+
} as ObjectPermissionConfig,
58+
];
59+
60+
function cells(field: string): string[] {
61+
return Array.from(document.querySelectorAll(`[data-lookup-cell="${field}"]`)).map((el) => el.textContent ?? '');
62+
}
63+
64+
async function openPicker(): Promise<void> {
65+
await waitFor(() => expect(screen.getByText('Ada Auditor')).toBeTruthy());
66+
fireEvent.click(screen.getByRole('button', { name: /Add holder/ }));
67+
await waitFor(() => expect(screen.getByTestId('record-row-u_3')).toBeTruthy());
68+
}
69+
70+
beforeEach(() => {
71+
adapter.find.mockClear();
72+
adapter.create.mockClear();
73+
});
74+
75+
afterEach(() => {
76+
cleanup();
77+
});
78+
79+
describe('PositionHoldersSection — the picker shows email beside the name (objectstack#27410)', () => {
80+
it('draws a name column and an email column', async () => {
81+
render(<PositionHoldersSection name="auditor" />);
82+
await openPicker();
83+
84+
const dialog = screen.getByTestId('record-picker-dialog');
85+
expect(dialog).toHaveTextContent('Email');
86+
expect(cells('name')).toEqual(['Ada Auditor', 'Sam Lee', 'Sam Lee']);
87+
expect(cells('email')).toEqual(['ada@example.com', 'sam.lee@example.com', 'slee@example.com']);
88+
});
89+
90+
it('CONTROL: choosing a person still assigns the position by name', async () => {
91+
render(<PositionHoldersSection name="auditor" />);
92+
await openPicker();
93+
94+
fireEvent.click(screen.getByTestId('record-row-u_3'));
95+
fireEvent.click(screen.getByTestId('record-picker-confirm'));
96+
await waitFor(() => expect(adapter.create).toHaveBeenCalledTimes(1));
97+
expect(adapter.create).toHaveBeenCalledWith('sys_user_position', { user_id: 'u_3', position: 'auditor' });
98+
});
99+
100+
it('CONTROL: a viewer who may not read email gets the name column alone', async () => {
101+
render(
102+
<PermissionProvider roles={[]} userRoles={['viewer']} permissions={EMAIL_DENIED}>
103+
<PositionHoldersSection name="auditor" />
104+
</PermissionProvider>,
105+
);
106+
await openPicker();
107+
108+
expect(cells('name')).toEqual(['Ada Auditor', 'Sam Lee', 'Sam Lee']);
109+
expect(cells('email')).toEqual([]);
110+
expect(screen.getByTestId('record-picker-dialog')).not.toHaveTextContent('slee@example.com');
111+
});
112+
});

‎ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ const asArray = (res: any): any[] => (Array.isArray(res) ? res : res?.data ?? []
8686
const personLabel = (u: any): string =>
8787
u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? '');
8888

89+
/**
90+
* objectstack#27410 — the picker shows what a holder row shows: the person's
91+
* name and, beside it, their email, so two people of one name can be told
92+
* apart before either is granted the position. `name` is the field `sys_user`
93+
* declares as its `nameField`, and `email` is its login identity. The picker
94+
* draws only the columns the caller may read (`RecordPickerDialog`'s own read
95+
* rule), so for a caller who may not read `email` the name column stands alone.
96+
*/
97+
const PICKER_NAME_FIELD = 'name';
98+
const PICKER_EMAIL_FIELD = 'email';
99+
89100
/**
90101
* The server's own sentence out of a refused call, or `''` when it gave none.
91102
* Read through `@object-ui/react`'s shared reader, the one place the adapters'
@@ -107,6 +118,8 @@ function useCopy() {
107118
empty: '还没有人持有此岗位。',
108119
loading: '加载中…',
109120
pickTitle: '选择要分配此岗位的组织成员',
121+
nameColumn: '姓名',
122+
emailColumn: '邮箱',
110123
noMembers: '本组织还没有可分配的成员。',
111124
membersFailed: '无法读取本组织的成员。',
112125
removeTitle: (who: string) => `将 ${who} 从此岗位移除?`,
@@ -124,6 +137,8 @@ function useCopy() {
124137
empty: 'Nobody holds this position yet.',
125138
loading: 'Loading…',
126139
pickTitle: 'Select organization members to assign this position to',
140+
nameColumn: 'Name',
141+
emailColumn: 'Email',
127142
noMembers: 'This organization has no members to assign yet.',
128143
membersFailed: 'Could not read the members of this organization.',
129144
removeTitle: (who: string) => `Remove ${who} from this position?`,
@@ -197,6 +212,12 @@ export function PositionHoldersSection({ name }: PositionHoldersSectionProps): R
197212
const canAdd = resolveAffordance('relatedNew', holdingSource).allowed;
198213
const canRemove = resolveAffordance('relatedRowDelete', holdingSource).allowed;
199214
const memberOrganization = membershipOrganization(activeOrganizationId, objects);
215+
/**
216+
* `sys_user` as the registry serves it, read from the metadata this section
217+
* already holds: the picker's name column resolves each person's title by it.
218+
*/
219+
const userSchema = ((objects ?? []).find((o) => (o as { name?: unknown } | null)?.name === 'sys_user') ??
220+
null) as Record<string, unknown> | null;
200221

201222
const load = React.useCallback(async () => {
202223
if (!adapter) return;
@@ -392,6 +413,11 @@ export function PositionHoldersSection({ name }: PositionHoldersSectionProps): R
392413
multiple
393414
dataSource={adapter}
394415
objectName="sys_user"
416+
objectSchema={userSchema}
417+
columns={[
418+
{ field: PICKER_NAME_FIELD, label: c.nameColumn },
419+
{ field: PICKER_EMAIL_FIELD, label: c.emailColumn },
420+
]}
395421
title={c.pickTitle}
396422
baseFilter={memberFilter}
397423
onSelect={() => {}}

0 commit comments

Comments
 (0)