Skip to content

Commit d23db5c

Browse files
os-zhuangclaude
andauthored
feat(detail): related-list add-by-picker + generic 'Assigned Users' for permission sets (#1999)
* feat(detail): related-list add-by-picker + generic 'Assigned Users' for permission sets Implements the SDUI side of spec RecordRelatedListProps.add: the related-list primitive now renders an 'Add' button that opens a record picker over the far object and creates link rows (junction) or re-parents (1:m) — declaratively, via metadata, instead of a hand-coded editor. Errors from server-side insert rules (e.g. the AI-seat cap) surface inline; the list refreshes after add/remove. - types: RecordRelatedListComponentProps.add - plugin-detail RelatedList: Add button, RecordPickerDialog, create/refresh/error - plugin-detail record-related-list renderer: pass `add`, wire generic remove - app-shell AssignedUsersSection: reuses the generic RelatedList over sys_user_permission_set; embedded in the permission-set editor → every role (incl. `ai_seat`) gets a 'Manage Assignments' UI with zero bespoke CRUD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): related-list add-by-picker + Assigned Users --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e98dfbb commit d23db5c

6 files changed

Lines changed: 212 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/plugin-detail': minor
4+
'@object-ui/app-shell': minor
5+
---
6+
7+
feat(detail): related-list add-by-picker (generic m2m/junction) + a generic "Assigned Users" management UI on permission sets (assign ai_seat and any role with zero bespoke CRUD; server-side cap errors surface inline).
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* AssignedUsersSection — "Manage Assignments" for a permission set.
9+
*
10+
* Renders the GENERIC `<RelatedList>` (with its add-by-picker affordance) over
11+
* the `sys_user_permission_set` junction, so an admin can grant/revoke a
12+
* permission set to users WITHOUT raw data editing. This is the Salesforce
13+
* "Assigned Users" pattern, expressed through the reusable related-list
14+
* primitive rather than a bespoke editor: pick a `sys_user` → a junction row
15+
* `{permission_set_id, user_id}` is created; remove deletes the junction row.
16+
* Server-side insert rules still apply and surface inline — e.g. the AI-seat
17+
* cap rejects the N+1 assignment for the `ai_seat` permission set.
18+
*
19+
* It is deliberately permission-set-agnostic: every role/permission set gets
20+
* the same management UI, and `ai_seat` (the AI-seat) is just one of them.
21+
*/
22+
23+
import * as React from 'react';
24+
import { useAdapter } from '@object-ui/react';
25+
import { RelatedList } from '@object-ui/plugin-detail';
26+
27+
export interface AssignedUsersSectionProps {
28+
/** The permission set's machine name (e.g. `ai_seat`, `admin_full_access`). */
29+
permissionSetName: string;
30+
}
31+
32+
export function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps) {
33+
const adapter = useAdapter();
34+
const [setId, setSetId] = React.useState<string | null>(null);
35+
36+
// Resolve the permission set's id from its name (the junction links by id).
37+
React.useEffect(() => {
38+
let cancelled = false;
39+
(async () => {
40+
try {
41+
const res: any = await (adapter as any).find('sys_permission_set', {
42+
$filter: { name: permissionSetName },
43+
limit: 1,
44+
});
45+
const rows: any[] = Array.isArray(res) ? res : res?.data ?? res?.records ?? [];
46+
const id = rows?.[0]?.id;
47+
if (!cancelled) setSetId(id != null ? String(id) : null);
48+
} catch {
49+
if (!cancelled) setSetId(null);
50+
}
51+
})();
52+
return () => {
53+
cancelled = true;
54+
};
55+
}, [adapter, permissionSetName]);
56+
57+
if (!setId) return null;
58+
59+
return (
60+
<RelatedList
61+
title="Assigned Users"
62+
type="table"
63+
api="sys_user_permission_set"
64+
objectName="sys_user_permission_set"
65+
referenceField="permission_set_id"
66+
parentId={setId}
67+
dataSource={adapter as any}
68+
columns={['user_id', 'granted_by']}
69+
add={{
70+
picker: { object: 'sys_user', valueField: 'id', labelField: 'email' },
71+
linkField: 'user_id',
72+
label: 'Add user',
73+
}}
74+
onRowDelete={async (row: any) => {
75+
const id = row?.id ?? row?._id;
76+
if (id != null) await (adapter as any).delete?.('sys_user_permission_set', String(id));
77+
}}
78+
/>
79+
);
80+
}
81+
82+
export default AssignedUsersSection;

packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { PageShell } from './PageShell';
6262
import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata';
6363
import { resolveResourceConfig } from './registry';
6464
import { t as translate, detectLocale } from './i18n';
65+
import { AssignedUsersSection } from './AssignedUsersSection';
6566

6667
/* ────────────────────────────────────────────────────────────────── */
6768
/* Domain shapes */
@@ -451,6 +452,11 @@ export function PermissionMatrixEditPage({ type, name }: PermissionMatrixEditPag
451452
onBulkSet={bulkSetObject}
452453
/>
453454
</div>
455+
{/* Manage Assignments — generic "Assigned Users" via the related-list
456+
primitive (works for every permission set; `ai_seat` is one of them). */}
457+
<div className="shrink-0 border-t max-h-80 overflow-auto bg-background">
458+
<AssignedUsersSection permissionSetName={name} />
459+
</div>
454460
</div>
455461

456462
{/* Destructive-change dialog */}

packages/plugin-detail/src/RelatedList.tsx

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
} from 'lucide-react';
4040
import type { LucideIcon } from 'lucide-react';
4141
import type { DataSource, FieldMetadata } from '@object-ui/types';
42-
import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
42+
import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@object-ui/fields';
4343
import { useSafeFieldLabel } from '@object-ui/react';
4444
import { usePermissions } from '@object-ui/permissions';
4545
import { useDetailTranslation } from './useDetailTranslation';
@@ -63,6 +63,20 @@ export interface RelatedListProps {
6363
onRowEdit?: (row: any) => void;
6464
/** Callback when a row Delete action is clicked */
6565
onRowDelete?: (row: any) => void;
66+
/**
67+
* Add-existing-via-picker config (generic m2m/junction assignment). When set,
68+
* the toolbar shows an "Add" button that opens a record picker on
69+
* `picker.object`; selecting records creates link rows in this list's `api`
70+
* object as `{[referenceField]: parentId, [linkField]: <pickedId>}` (junction
71+
* case), or — when `linkField` is omitted — re-parents the picked child by
72+
* setting its `referenceField` to `parentId` (1:m case). Server-side rules on
73+
* insert (e.g. the AI-seat cap) surface as an inline error.
74+
*/
75+
add?: {
76+
picker: { object: string; valueField?: string; labelField?: string; filter?: any };
77+
linkField?: string;
78+
label?: string;
79+
};
6680
/** Callback when a row is clicked (opens record detail) */
6781
onRowClick?: (row: any) => void;
6882
/** Maximum number of columns to auto-generate. Default 6. */
@@ -125,6 +139,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
125139
onRowEdit,
126140
onRowDelete,
127141
onRowClick,
142+
add,
128143
maxColumns = 6,
129144
pageSize,
130145
sortable = false,
@@ -150,6 +165,12 @@ export const RelatedList: React.FC<RelatedListProps> = ({
150165
const [filterText, setFilterText] = React.useState('');
151166
const [objectSchema, setObjectSchema] = React.useState<any>(null);
152167
const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
168+
// Add-by-picker (generic m2m/junction assignment). `refreshNonce` re-runs the
169+
// auto-fetch after an add/remove so the list reflects the new link rows.
170+
const [pickerOpen, setPickerOpen] = React.useState(false);
171+
const [addBusy, setAddBusy] = React.useState(false);
172+
const [addError, setAddError] = React.useState<string | null>(null);
173+
const [refreshNonce, setRefreshNonce] = React.useState(0);
153174
// Per-lookup-field cache of resolved labels: fieldName -> Map<id, label>
154175
const [lookupLabels, setLookupLabels] = React.useState<Record<string, Record<string, string>>>({});
155176
const { t } = useDetailTranslation();
@@ -227,7 +248,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
227248
.finally(() => setLoading(false));
228249
}
229250
}
230-
}, [api, dataProvided, dataSource, referenceField, parentId]);
251+
}, [api, dataProvided, dataSource, referenceField, parentId, refreshNonce]);
231252

232253
// Resolve lookup-field display labels by batch-fetching referenced records.
233254
// For each lookup/master_detail column whose data is a primitive ID, gather
@@ -276,6 +297,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
276297
r?.title ||
277298
r?.label ||
278299
r?.code ||
300+
r?.email ||
279301
String(id);
280302
}
281303
return { fieldName, map };
@@ -357,11 +379,45 @@ export const RelatedList: React.FC<RelatedListProps> = ({
357379
setDeleteTarget(row);
358380
}, []);
359381

360-
const handleConfirmDelete = React.useCallback(() => {
361-
if (deleteTarget) onRowDelete?.(deleteTarget);
382+
const handleConfirmDelete = React.useCallback(async () => {
383+
if (deleteTarget) {
384+
try {
385+
await onRowDelete?.(deleteTarget);
386+
setRefreshNonce((n) => n + 1); // reflect the removal
387+
} catch (err) {
388+
console.error('[RelatedList] remove failed', err);
389+
}
390+
}
362391
setDeleteTarget(null);
363392
}, [deleteTarget, onRowDelete]);
364393

394+
// Add existing records via picker → create link rows (junction) or re-parent
395+
// (1:m). Server-side insert rules (e.g. the AI-seat cap) surface inline.
396+
const handleAddRecords = React.useCallback(async (records: any[]) => {
397+
if (!add || !dataSource || !api || referenceField == null || parentId === undefined || parentId === null) return;
398+
const vf = add.picker.valueField || 'id';
399+
setAddBusy(true);
400+
setAddError(null);
401+
try {
402+
for (const rec of records || []) {
403+
const pickedId = rec?.[vf] ?? rec?.id;
404+
if (pickedId == null) continue;
405+
if (add.linkField) {
406+
await (dataSource as any).create?.(api, { [referenceField]: parentId, [add.linkField]: pickedId });
407+
} else {
408+
await (dataSource as any).update?.(add.picker.object, String(pickedId), { [referenceField]: parentId });
409+
}
410+
}
411+
setRefreshNonce((n) => n + 1);
412+
} catch (err: any) {
413+
const raw = err?.body?.error ?? err?.error ?? err?.message ?? String(err);
414+
setAddError(typeof raw === 'string' ? raw : 'Failed to add');
415+
} finally {
416+
setAddBusy(false);
417+
setPickerOpen(false);
418+
}
419+
}, [add, dataSource, api, referenceField, parentId]);
420+
365421
// Generate effective columns from explicit prop or object schema fields.
366422
// Behavior:
367423
// - Hide the parent FK column (already implicit context).
@@ -639,6 +695,18 @@ export const RelatedList: React.FC<RelatedListProps> = ({
639695
)}
640696
</div>
641697
<div className="flex items-center gap-1 shrink-0">
698+
{add && (
699+
<Button
700+
variant={isEmpty ? 'ghost' : 'outline'}
701+
size="sm"
702+
disabled={addBusy}
703+
onClick={(e) => { e.stopPropagation(); setAddError(null); setPickerOpen(true); }}
704+
className="gap-1 h-9 sm:h-7 text-xs shadow-none"
705+
>
706+
<Plus className="h-3.5 w-3.5" />
707+
{add.label || t('detail.add', { defaultValue: 'Add' })}
708+
</Button>
709+
)}
642710
{onNew && (
643711
<Button
644712
variant={isEmpty ? 'ghost' : 'outline'}
@@ -768,6 +836,26 @@ export const RelatedList: React.FC<RelatedListProps> = ({
768836
</AlertDialogFooter>
769837
</AlertDialogContent>
770838
</AlertDialog>
839+
{addError && (
840+
<div
841+
className="mx-4 mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
842+
role="alert"
843+
>
844+
{addError}
845+
</div>
846+
)}
847+
{add && dataSource && (
848+
<RecordPickerDialog
849+
open={pickerOpen}
850+
onOpenChange={(o) => setPickerOpen(o)}
851+
multiple
852+
dataSource={dataSource as any}
853+
objectName={add.picker.object}
854+
title={add.label || t('detail.add', { defaultValue: 'Add' })}
855+
onSelect={() => {}}
856+
onSelectRecords={(records: any[]) => { void handleAddRecords(records); }}
857+
/>
858+
)}
771859
</Card>
772860
);
773861
};

packages/plugin-detail/src/renderers/record-related-list.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ export const RecordRelatedListRenderer: React.FC<RecordRelatedListRendererProps>
117117
columns={filteredColumns as any}
118118
pageSize={schema.limit}
119119
dataSource={ctx?.dataSource as any}
120+
add={(schema as any).add}
121+
onRowDelete={
122+
// Generic remove for link/junction rows: delete the related row itself.
123+
// RelatedList refreshes after this resolves. Only wired when an `add`
124+
// config is present (i.e. this is a managed assignment list), so plain
125+
// read-only related lists keep their existing behavior.
126+
(schema as any).add && ctx?.dataSource
127+
? async (row: any) => {
128+
const id = row?.id ?? row?._id;
129+
if (id != null) await (ctx!.dataSource as any).delete?.(objectName, String(id));
130+
}
131+
: undefined
132+
}
120133
/>
121134
</div>
122135
);

packages/types/src/record-components.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ export interface RecordRelatedListComponentProps {
102102
showViewAll?: boolean;
103103
/** Available actions for the related list */
104104
actions?: string[];
105+
/**
106+
* Add-existing-via-picker config (generic m2m/junction assignment). Pick
107+
* records from `add.picker.object` and create link rows in `objectName`
108+
* (`{[relationshipField]: parentId, [add.linkField]: pickedId}`), or omit
109+
* `linkField` to re-parent the picked 1:m child. Mirrors the spec
110+
* RecordRelatedListProps.add.
111+
*/
112+
add?: {
113+
picker: { object: string; valueField?: string; labelField?: string; filter?: unknown };
114+
linkField?: string;
115+
label?: string;
116+
};
105117
/** ARIA accessibility attributes */
106118
aria?: RecordComponentAriaProps;
107119
}

0 commit comments

Comments
 (0)