diff --git a/.changeset/13477-assigned-users-position-label.md b/.changeset/13477-assigned-users-position-label.md new file mode 100644 index 0000000000..632e4169f4 --- /dev/null +++ b/.changeset/13477-assigned-users-position-label.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +Clause-②: no +Metadata-protocol: none + +A permission set's Assigned users list now names the position a person holds the set through by the position's label, in the viewer's language, instead of its machine name (objectui#13477). + +The "via position" chip drew the `position` column of the holder's `sys_user_position` row, the machine name, although the section had already listed the registry's position definitions to find the positions that distribute the set, and that list carries each definition's label as the server localizes it. The chip now reads the label from that list and keeps the name on its tooltip, as the access explainer's position chips do. A position whose definition has no label still reads by its name. diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.positionLabel-13477.test.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.positionLabel-13477.test.tsx new file mode 100644 index 0000000000..38051c5af5 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.positionLabel-13477.test.tsx @@ -0,0 +1,115 @@ +/** + * ObjectUI + * Copyright (c) 2026 ObjectStack. Licensed under the MIT license. + * + * A holder's "via position" chip names the position by its label + * (objectui#13477). + * + * The chip used to draw `sys_user_position.position` — the position's machine + * name — while the section had already listed the registry's position + * definitions, labels included, to find the positions that distribute the set. + * `/meta/position` serves those labels in the caller's language + * (objectstack#25701). This file holds that the chip reads the label from that + * list with the name on its `title`, as `CitedItemChip` in `AccessExplainPanel` + * does, and that a definition with no label still reads by its name. + * + * Everything between the server and the chip is real: a `MetadataProvider` + * over an `ObjectStackAdapter` whose `fetch` is the only stub, so the label + * reaches the section through the store's own `/meta/position` read. + */ + +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { AdapterCtx } from '@object-ui/react'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { AssignedUsersSection } from './AssignedUsersSection'; + +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + RecordPickerDialog: () => null, +})); + +const SET = 'crm_sales_user'; + +/** The registry's position definitions as `/meta/position` serves them. */ +const POSITIONS = [ + { name: 'sales_manager', label: 'Sales Manager', permissionSets: [SET] }, + // A definition with no label: its chip reads by its name. + { name: 'field_rep', permissionSets: [SET] }, + { name: 'auditor', label: 'Auditor', permissionSets: ['crm_auditor'] }, +]; + +const ROWS: Record = { + sys_user_permission_set: [], + sys_user_position: [ + { id: 'up_1', user_id: 'u_manager', position: 'sales_manager' }, + { id: 'up_2', user_id: 'u_rep', position: 'field_rep' }, + ], + sys_user: [ + { id: 'u_manager', name: 'Manager Mia', email: 'mia@example.com' }, + { id: 'u_rep', name: 'Rep Ray', email: 'ray@example.com' }, + ], +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +let seq = 0; + +function makeAdapter() { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/api/v1/discovery')) { + return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } }); + } + if (url.pathname === '/api/v1/meta/position') { + return json(200, { success: true, data: { type: 'position', items: structuredClone(POSITIONS) } }); + } + const list = /^\/api\/v1\/meta\/([a-z_]+)$/.exec(url.pathname); + if (list) return json(200, { success: true, data: { type: list[1], items: [] } }); + const data = /^\/api\/v1\/data\/([^/?]+)$/.exec(url.pathname); + if (data) { + const records = ROWS[data[1]] ?? []; + return json(200, { object: data[1], records, total: records.length }); + } + return json(404, { success: false, error: { message: `no route ${url.pathname}` } }); + }); + // A fresh origin per adapter: the adapter shares discovery per base URL. + return new ObjectStackAdapter({ baseUrl: `http://position-label-${(seq += 1)}.test`, autoReconnect: false, fetch: fetchImpl }); +} + +function renderSection() { + const adapter = makeAdapter(); + render( + + + + + , + ); +} + +afterEach(() => { + cleanup(); +}); + +describe("AssignedUsersSection — a position chip reads the position's label (objectui#13477)", () => { + it('shows the label from /meta/position, with the machine name on title', async () => { + renderSection(); + + const chip = await screen.findByText('via position Sales Manager'); + expect(chip).toHaveAttribute('title', 'sales_manager'); + expect(screen.queryByText('via position sales_manager')).toBeNull(); + }); + + it('a definition with no label reads by its name, with no title', async () => { + renderSection(); + + const chip = await screen.findByText('via position field_rep'); + expect(chip).not.toHaveAttribute('title'); + // The labelled holder beside it still reads by its label. + expect(screen.getByText('via position Sales Manager')).toBeTruthy(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx index 7e208daadd..fc8d8ba50a 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.test.tsx @@ -85,7 +85,8 @@ describe('AssignedUsersSection — effective holders (objectui#2382)', () => { expect(screen.getByText('2')).toBeTruthy(); // Attribution badges. expect(screen.getAllByText('direct').length).toBeGreaterThan(0); - expect(screen.getAllByText('via position contributor').length).toBeGreaterThan(0); + // The chip reads the position's label from the registry (objectui#13477). + expect(screen.getAllByText('via position Contributor').length).toBeGreaterThan(0); }); it('surfaces the everyone-anchor binding as a note instead of enumerating members', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx index d4fb5e1d5c..0110fa30dd 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -112,8 +112,12 @@ interface AssignedRow { userId: string; name: string; email: string; - /** How the user holds the set: a direct grant, or via one or more positions. */ - via: Array<{ kind: 'direct' } | { kind: 'position'; position: string }>; + /** + * How the user holds the set: a direct grant, or via one or more positions. + * A position carries its machine name and, where its registry definition + * has one, its label (objectui#13477). + */ + via: Array<{ kind: 'direct' } | { kind: 'position'; position: string; label?: string }>; } /** @@ -235,6 +239,22 @@ export function positionsDistributing(positions: readonly unknown[], setName: st return out; } +/** + * The labels of the registry's `position` definitions, by name — the list + * {@link positionsDistributing} reads, which `/meta/position` serves in the + * caller's language (objectui#13477). A definition with no label has no entry, + * so its chip shows the name, as `CitedItemChip` in `AccessExplainPanel` does. + */ +function positionLabels(positions: readonly unknown[]): Map { + const out = new Map(); + for (const raw of positions) { + const p = raw as { name?: unknown; label?: unknown } | null; + if (typeof p?.name !== 'string' || typeof p.label !== 'string' || !p.label.trim()) continue; + out.set(p.name, p.label); + } + return out; +} + export function AssignedUsersSection({ permissionSetName, onHoldersRead }: AssignedUsersSectionProps) { const adapter = useAdapter() as any; // Read through a ref so a host's inline callback does not re-key `load`. @@ -315,9 +335,11 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig // are THE distribution channel — a direct-grants-only list told the // admin "0 users" for any normally-administered set. let positionNames: string[] = []; + let labelByPosition = new Map(); let boundEveryone = false; try { const positions = await metadataStoreRef.current.ensureType('position'); + labelByPosition = positionLabels(positions); const names = positionsDistributing(positions, permissionSetName); // The audience anchors are implicit memberships — `everyone` is every // signed-in member; enumerating them as rows would be noise. Surface @@ -345,7 +367,11 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig for (const a of assignments) { if (!a?.user_id || !a?.position) continue; const uid = String(a.user_id); - viaByUser.set(uid, [...(viaByUser.get(uid) ?? []), { kind: 'position' as const, position: String(a.position) }]); + const position = String(a.position); + viaByUser.set(uid, [ + ...(viaByUser.get(uid) ?? []), + { kind: 'position' as const, position, label: labelByPosition.get(position) }, + ]); } const userIds = [...viaByUser.keys()]; @@ -535,14 +561,20 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig
{r.name} - {r.via.map((v, i) => ( - - {v.kind === 'direct' ? c.direct : c.viaPosition(v.position)} - - ))} + {r.via.map((v, i) => { + // A position reads by its label, with its name on `title`; + // with no label the name is the text (objectui#13477). + const shown = v.kind === 'position' ? v.label ?? v.position : ''; + return ( + + {v.kind === 'direct' ? c.direct : c.viaPosition(shown)} + + ); + })}
{r.email && r.email !== r.name && (
{r.email}