Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/13304-position-preview-permission-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

The Position preview lists the permission sets a position declares in `permissionSets` (ADR-0131 D4), by machine name in declared order. The "positions don't grant data access on their own — bind it to Permission Sets" note now shows only for a position that declares none (objectui#13304).
7 changes: 7 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,10 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.positionPreview.bind':
'Bind {name} to one or more {permissionSets} to control CRUD-VAMA, field access, and tab visibility. Record visibility comes from the business-unit tree and sharing rules, not from the position.',
'engine.positionPreview.permissionSets': 'Permission Sets',
// objectui#13304 — a position that declares `permissionSets` (ADR-0131 D4).
'engine.positionPreview.grantsVia': 'This position grants access through these {permissionSets}:',
'engine.positionPreview.visibility':
'Record visibility comes from the business-unit tree and sharing rules, not from the position.',
// objectui#10202 — the read-only picklist page (the kind is package-owned).
'engine.picklistPreview.empty': 'This picklist has no name.',
'engine.picklistPreview.readOnly':
Expand Down Expand Up @@ -5306,6 +5310,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.positionPreview.bind':
'将 {name} 绑定到一个或多个 {permissionSets},以控制 CRUD-VAMA、字段访问和标签页可见性。记录的可见性来自业务单元树和共享规则,而不是岗位。',
'engine.positionPreview.permissionSets': '权限集',
// objectui#13304 — a position that declares `permissionSets` (zh).
'engine.positionPreview.grantsVia': '该岗位通过以下{permissionSets}授予访问权限:',
'engine.positionPreview.visibility': '记录的可见性来自业务单元树和共享规则,而不是岗位。',
// objectui#10202 — the read-only picklist page (zh).
'engine.picklistPreview.empty': '该选项列表没有名称。',
'engine.picklistPreview.readOnly': '只读:选项列表归声明它的软件包所有,不能在这里新建、编辑或删除。',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PositionPreview lists the permission sets a position declares (objectui#13304).
*
* `PositionSchema.permissionSets` (ADR-0131 D4) names the sets a position
* distributes. The preview read only name / label / description and always
* rendered "Positions don't grant data access on their own … bind it to
* Permission Sets" — telling an admin to do what the draft had already done.
*
* The callout now lists the declared sets by machine name, in declared order,
* and keeps the "grants nothing on its own" note for a position that declares
* none: absent, empty, or not an array.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { PositionPreview } from './PositionPreview';
import { t } from '../i18n';

afterEach(cleanup);

const EN = 'en-US';
const NO_ACCESS = t('engine.positionPreview.noAccess', EN);

function mount(draft: Record<string, unknown>) {
return render(<PositionPreview type="position" name="service_manager" draft={draft} locale={EN} />);
}

/** The machine names the callout lists, in rendered order. */
function listedSets(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll('li code')).map((el) => el.textContent ?? '');
}

const BASE = { name: 'service_manager', label: 'Service Manager' };

describe('PositionPreview — declared permissionSets (objectui#13304)', () => {
it('lists every declared set by machine name, in declared order, and drops the "grants nothing" note', () => {
const { container } = mount({ ...BASE, permissionSets: ['service_agent', 'support_viewer'] });
expect(listedSets(container)).toEqual(['service_agent', 'support_viewer']);
expect(container.textContent).not.toContain(NO_ACCESS);
});

it('CONTROL: a draft with no permissionSets renders the existing note', () => {
const { container } = mount({ ...BASE });
expect(container.textContent).toContain(NO_ACCESS);
expect(listedSets(container)).toEqual([]);
});

it('CONTROL: an empty permissionSets array renders the existing note', () => {
const { container } = mount({ ...BASE, permissionSets: [] });
expect(container.textContent).toContain(NO_ACCESS);
expect(listedSets(container)).toEqual([]);
});

it('a permissionSets value that is not an array (a string) declares none', () => {
const { container } = mount({ ...BASE, permissionSets: 'service_agent' });
expect(container.textContent).toContain(NO_ACCESS);
expect(listedSets(container)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
/**
* PositionPreview — read-only summary of a Position draft (ADR-0090 D3).
*
* The Position schema is intentionally minimal — name / label /
* description — and deliberately FLAT: no parent, no hierarchy. The
* visibility hierarchy lives on the business-unit tree, not here, so the
* preview's job is to put those fields into the mental model an operator
* The Position schema is deliberately FLAT: no parent, no hierarchy. The
* visibility hierarchy lives on the business-unit tree, not here. What a
* position does carry, besides name / label / description, is
* `permissionSets` (ADR-0131 D4): the permission sets it distributes, by
* name. The preview puts those fields into the mental model an operator
* carries:
*
* • Header card with label and machine name.
* • Description block.
* • A "permissions are managed separately" note pointing the user to
* Permission Sets that grant CRUD-VAMA — this is the most common
* confusion when first authoring a position.
* • A permissions callout. When the draft declares `permissionSets`, it
* lists them by machine name in declared order — that is where the
* position's access comes from. When it declares none, it says the
* position grants nothing on its own and points the user to Permission
* Sets — the most common confusion when first authoring a position.
*
* The preview reads the draft only: set names are shown as written, with no
* fetch for their labels.
*/

import * as React from 'react';
Expand All @@ -28,6 +34,11 @@ export function PositionPreview({ name, draft, locale }: MetadataPreviewProps) {
const positionName = String(d.name ?? name ?? '');
const label = String(d.label ?? positionName);
const description = (d.description as string | undefined) ?? '';
// ADR-0131 D4 — the sets this position distributes, by name. Anything that
// is not an array declares none.
const permissionSets = Array.isArray(d.permissionSets)
? d.permissionSets.filter((s): s is string => typeof s === 'string')
: [];

if (!positionName) {
return (
Expand Down Expand Up @@ -61,15 +72,33 @@ export function PositionPreview({ name, draft, locale }: MetadataPreviewProps) {
<div className="rounded border border-blue-200 bg-blue-50 p-2.5 text-[11px] text-blue-900">
<div className="flex items-start gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<div>
<div className="font-medium">{tr('engine.positionPreview.noAccess', locale)}</div>
<div className="opacity-90 mt-0.5">
{withNodes(tr('engine.positionPreview.bind', locale), {
name: <code className="font-mono">{positionName}</code>,
permissionSets: <strong>{tr('engine.positionPreview.permissionSets', locale)}</strong>,
})}
{permissionSets.length > 0 ? (
<div>
<div className="font-medium">
{withNodes(tr('engine.positionPreview.grantsVia', locale), {
permissionSets: <strong>{tr('engine.positionPreview.permissionSets', locale)}</strong>,
})}
</div>
<ul className="mt-0.5 space-y-0.5">
{permissionSets.map((set, i) => (
<li key={`${i}:${set}`}>
<code className="font-mono">{set}</code>
</li>
))}
</ul>
<div className="opacity-90 mt-0.5">{tr('engine.positionPreview.visibility', locale)}</div>
</div>
</div>
) : (
<div>
<div className="font-medium">{tr('engine.positionPreview.noAccess', locale)}</div>
<div className="opacity-90 mt-0.5">
{withNodes(tr('engine.positionPreview.bind', locale), {
name: <code className="font-mono">{positionName}</code>,
permissionSets: <strong>{tr('engine.positionPreview.permissionSets', locale)}</strong>,
})}
</div>
</div>
)}
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,21 @@ describe('PositionPreview reads the designer locale (objectui#10862)', () => {
]);
expectAsWritten(['Sales Rep', 'sales_rep', 'Works the pipeline.']);
});

// objectui#13304 — a position that declares its sets lists them instead.
it(`${lang}: the declared permission sets`, () => {
const draft = { name: 'sales_rep', label: 'Sales Rep', permissionSets: ['sales_user', 'report_viewer'] };
inLang(lang, <PositionPreview type="position" name="sales_rep" draft={draft} locale={LOCALE[lang]} />);
expectSites(lang, [
{ key: 'engine.positionPreview.permissionSets' },
{
key: 'engine.positionPreview.grantsVia',
vars: { permissionSets: row(lang, 'engine.positionPreview.permissionSets') },
},
{ key: 'engine.positionPreview.visibility' },
]);
expectAsWritten(['Sales Rep', 'sales_rep', 'sales_user', 'report_viewer']);
});
}
});

Expand Down
Loading