From edbd623fbc2e4b3f0154662e3fda73d2d89488e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:23:00 +0000 Subject: [PATCH 1/2] fix(app-shell): show a position's label in a permission set's Assigned users (objectui#13477) The "via position" chip drew the holder's sys_user_position.position, the position's machine name, although the section already lists the registry's position definitions (ensureType('position')) to find the positions that distribute the set, and /meta/position serves their labels in the caller's language. The chip now reads the label from that list and keeps the name on its title, as CitedItemChip in AccessExplainPanel does; a definition with no label reads by its name. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XWGTsmqrtrt1Zqh1ioWqsT --- .../13477-assigned-users-position-label.md | 10 ++ ...dUsersSection.positionLabel-13477.test.tsx | 115 ++++++++++++++++++ .../AssignedUsersSection.test.tsx | 3 +- .../metadata-admin/AssignedUsersSection.tsx | 54 ++++++-- 4 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 .changeset/13477-assigned-users-position-label.md create mode 100644 packages/app-shell/src/views/metadata-admin/AssignedUsersSection.positionLabel-13477.test.tsx 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..17c1ef942e 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. + */ +export 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}
From 05f61208ac38347635eb937c68949fd9b4779a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:32:52 +0000 Subject: [PATCH 2/2] refactor(app-shell): keep the position-label reader module-private (objectui#13477) Nothing outside the section reads it, and a module-level export beside the component costs a react-refresh warning. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XWGTsmqrtrt1Zqh1ioWqsT --- .../app-shell/src/views/metadata-admin/AssignedUsersSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx index 17c1ef942e..0110fa30dd 100644 --- a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -245,7 +245,7 @@ export function positionsDistributing(positions: readonly unknown[], setName: st * caller's language (objectui#13477). A definition with no label has no entry, * so its chip shows the name, as `CitedItemChip` in `AccessExplainPanel` does. */ -export function positionLabels(positions: readonly unknown[]): Map { +function positionLabels(positions: readonly unknown[]): Map { const out = new Map(); for (const raw of positions) { const p = raw as { name?: unknown; label?: unknown } | null;