Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/13477-assigned-users-position-label.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<typeof import('@object-ui/fields')>()),
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<string, unknown[]> = {
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(
<AdapterCtx.Provider value={adapter as never}>
<MetadataProvider adapter={adapter}>
<AssignedUsersSection permissionSetName={SET} />
</MetadataProvider>
</AdapterCtx.Provider>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
}

/**
Expand Down Expand Up @@ -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<string, string> {
const out = new Map<string, string>();
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`.
Expand Down Expand Up @@ -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<string, string>();
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
Expand Down Expand Up @@ -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()];
Expand Down Expand Up @@ -535,14 +561,20 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm truncate">{r.name}</span>
{r.via.map((v, i) => (
<span
key={i}
className="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground"
>
{v.kind === 'direct' ? c.direct : c.viaPosition(v.position)}
</span>
))}
{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 (
<span
key={i}
className="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground"
title={v.kind === 'position' && shown !== v.position ? v.position : undefined}
>
{v.kind === 'direct' ? c.direct : c.viaPosition(shown)}
</span>
);
})}
</div>
{r.email && r.email !== r.name && (
<div className="text-xs text-muted-foreground truncate">{r.email}</div>
Expand Down
Loading