diff --git a/.changeset/sdui-related-list-add.md b/.changeset/sdui-related-list-add.md new file mode 100644 index 000000000..1fa9de1c3 --- /dev/null +++ b/.changeset/sdui-related-list-add.md @@ -0,0 +1,7 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-detail': minor +'@object-ui/app-shell': minor +--- + +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). diff --git a/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx new file mode 100644 index 000000000..94085873d --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -0,0 +1,82 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * AssignedUsersSection — "Manage Assignments" for a permission set. + * + * Renders the GENERIC `` (with its add-by-picker affordance) over + * the `sys_user_permission_set` junction, so an admin can grant/revoke a + * permission set to users WITHOUT raw data editing. This is the Salesforce + * "Assigned Users" pattern, expressed through the reusable related-list + * primitive rather than a bespoke editor: pick a `sys_user` → a junction row + * `{permission_set_id, user_id}` is created; remove deletes the junction row. + * Server-side insert rules still apply and surface inline — e.g. the AI-seat + * cap rejects the N+1 assignment for the `ai_seat` permission set. + * + * It is deliberately permission-set-agnostic: every role/permission set gets + * the same management UI, and `ai_seat` (the AI-seat) is just one of them. + */ + +import * as React from 'react'; +import { useAdapter } from '@object-ui/react'; +import { RelatedList } from '@object-ui/plugin-detail'; + +export interface AssignedUsersSectionProps { + /** The permission set's machine name (e.g. `ai_seat`, `admin_full_access`). */ + permissionSetName: string; +} + +export function AssignedUsersSection({ permissionSetName }: AssignedUsersSectionProps) { + const adapter = useAdapter(); + const [setId, setSetId] = React.useState(null); + + // Resolve the permission set's id from its name (the junction links by id). + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const res: any = await (adapter as any).find('sys_permission_set', { + $filter: { name: permissionSetName }, + limit: 1, + }); + const rows: any[] = Array.isArray(res) ? res : res?.data ?? res?.records ?? []; + const id = rows?.[0]?.id; + if (!cancelled) setSetId(id != null ? String(id) : null); + } catch { + if (!cancelled) setSetId(null); + } + })(); + return () => { + cancelled = true; + }; + }, [adapter, permissionSetName]); + + if (!setId) return null; + + return ( + { + const id = row?.id ?? row?._id; + if (id != null) await (adapter as any).delete?.('sys_user_permission_set', String(id)); + }} + /> + ); +} + +export default AssignedUsersSection; diff --git a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 7fb0b73df..97b55fa5e 100644 --- a/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -62,6 +62,7 @@ import { PageShell } from './PageShell'; import { useMetadataClient, useMetadataTypes, type RichMetadataTypeEntry } from './useMetadata'; import { resolveResourceConfig } from './registry'; import { t as translate, detectLocale } from './i18n'; +import { AssignedUsersSection } from './AssignedUsersSection'; /* ────────────────────────────────────────────────────────────────── */ /* Domain shapes */ @@ -451,6 +452,11 @@ export function PermissionMatrixEditPage({ type, name }: PermissionMatrixEditPag onBulkSet={bulkSetObject} /> + {/* Manage Assignments — generic "Assigned Users" via the related-list + primitive (works for every permission set; `ai_seat` is one of them). */} +
+ +
{/* Destructive-change dialog */} diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 633f795d1..e198ead57 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -39,7 +39,7 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; -import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; +import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@object-ui/fields'; import { useSafeFieldLabel } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { useDetailTranslation } from './useDetailTranslation'; @@ -63,6 +63,20 @@ export interface RelatedListProps { onRowEdit?: (row: any) => void; /** Callback when a row Delete action is clicked */ onRowDelete?: (row: any) => void; + /** + * Add-existing-via-picker config (generic m2m/junction assignment). When set, + * the toolbar shows an "Add" button that opens a record picker on + * `picker.object`; selecting records creates link rows in this list's `api` + * object as `{[referenceField]: parentId, [linkField]: }` (junction + * case), or — when `linkField` is omitted — re-parents the picked child by + * setting its `referenceField` to `parentId` (1:m case). Server-side rules on + * insert (e.g. the AI-seat cap) surface as an inline error. + */ + add?: { + picker: { object: string; valueField?: string; labelField?: string; filter?: any }; + linkField?: string; + label?: string; + }; /** Callback when a row is clicked (opens record detail) */ onRowClick?: (row: any) => void; /** Maximum number of columns to auto-generate. Default 6. */ @@ -125,6 +139,7 @@ export const RelatedList: React.FC = ({ onRowEdit, onRowDelete, onRowClick, + add, maxColumns = 6, pageSize, sortable = false, @@ -150,6 +165,12 @@ export const RelatedList: React.FC = ({ const [filterText, setFilterText] = React.useState(''); const [objectSchema, setObjectSchema] = React.useState(null); const [collapsed, setCollapsed] = React.useState(defaultCollapsed); + // Add-by-picker (generic m2m/junction assignment). `refreshNonce` re-runs the + // auto-fetch after an add/remove so the list reflects the new link rows. + const [pickerOpen, setPickerOpen] = React.useState(false); + const [addBusy, setAddBusy] = React.useState(false); + const [addError, setAddError] = React.useState(null); + const [refreshNonce, setRefreshNonce] = React.useState(0); // Per-lookup-field cache of resolved labels: fieldName -> Map const [lookupLabels, setLookupLabels] = React.useState>>({}); const { t } = useDetailTranslation(); @@ -227,7 +248,7 @@ export const RelatedList: React.FC = ({ .finally(() => setLoading(false)); } } - }, [api, dataProvided, dataSource, referenceField, parentId]); + }, [api, dataProvided, dataSource, referenceField, parentId, refreshNonce]); // Resolve lookup-field display labels by batch-fetching referenced records. // For each lookup/master_detail column whose data is a primitive ID, gather @@ -276,6 +297,7 @@ export const RelatedList: React.FC = ({ r?.title || r?.label || r?.code || + r?.email || String(id); } return { fieldName, map }; @@ -357,11 +379,45 @@ export const RelatedList: React.FC = ({ setDeleteTarget(row); }, []); - const handleConfirmDelete = React.useCallback(() => { - if (deleteTarget) onRowDelete?.(deleteTarget); + const handleConfirmDelete = React.useCallback(async () => { + if (deleteTarget) { + try { + await onRowDelete?.(deleteTarget); + setRefreshNonce((n) => n + 1); // reflect the removal + } catch (err) { + console.error('[RelatedList] remove failed', err); + } + } setDeleteTarget(null); }, [deleteTarget, onRowDelete]); + // Add existing records via picker → create link rows (junction) or re-parent + // (1:m). Server-side insert rules (e.g. the AI-seat cap) surface inline. + const handleAddRecords = React.useCallback(async (records: any[]) => { + if (!add || !dataSource || !api || referenceField == null || parentId === undefined || parentId === null) return; + const vf = add.picker.valueField || 'id'; + setAddBusy(true); + setAddError(null); + try { + for (const rec of records || []) { + const pickedId = rec?.[vf] ?? rec?.id; + if (pickedId == null) continue; + if (add.linkField) { + await (dataSource as any).create?.(api, { [referenceField]: parentId, [add.linkField]: pickedId }); + } else { + await (dataSource as any).update?.(add.picker.object, String(pickedId), { [referenceField]: parentId }); + } + } + setRefreshNonce((n) => n + 1); + } catch (err: any) { + const raw = err?.body?.error ?? err?.error ?? err?.message ?? String(err); + setAddError(typeof raw === 'string' ? raw : 'Failed to add'); + } finally { + setAddBusy(false); + setPickerOpen(false); + } + }, [add, dataSource, api, referenceField, parentId]); + // Generate effective columns from explicit prop or object schema fields. // Behavior: // - Hide the parent FK column (already implicit context). @@ -639,6 +695,18 @@ export const RelatedList: React.FC = ({ )}
+ {add && ( + + )} {onNew && (
); diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index cfb8b4f79..799aaa7bb 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -102,6 +102,18 @@ export interface RecordRelatedListComponentProps { showViewAll?: boolean; /** Available actions for the related list */ actions?: string[]; + /** + * Add-existing-via-picker config (generic m2m/junction assignment). Pick + * records from `add.picker.object` and create link rows in `objectName` + * (`{[relationshipField]: parentId, [add.linkField]: pickedId}`), or omit + * `linkField` to re-parent the picked 1:m child. Mirrors the spec + * RecordRelatedListProps.add. + */ + add?: { + picker: { object: string; valueField?: string; labelField?: string; filter?: unknown }; + linkField?: string; + label?: string; + }; /** ARIA accessibility attributes */ aria?: RecordComponentAriaProps; }