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
7 changes: 7 additions & 0 deletions .changeset/sdui-related-list-add.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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 `<RelatedList>` (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<string | null>(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 (
<RelatedList
title="Assigned Users"
type="table"
api="sys_user_permission_set"
objectName="sys_user_permission_set"
referenceField="permission_set_id"
parentId={setId}
dataSource={adapter as any}
columns={['user_id', 'granted_by']}
add={{
picker: { object: 'sys_user', valueField: 'id', labelField: 'email' },
linkField: 'user_id',
label: 'Add user',
}}
onRowDelete={async (row: any) => {
const id = row?.id ?? row?._id;
if (id != null) await (adapter as any).delete?.('sys_user_permission_set', String(id));
}}
/>
);
}

export default AssignedUsersSection;
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -451,6 +452,11 @@ export function PermissionMatrixEditPage({ type, name }: PermissionMatrixEditPag
onBulkSet={bulkSetObject}
/>
</div>
{/* Manage Assignments — generic "Assigned Users" via the related-list
primitive (works for every permission set; `ai_seat` is one of them). */}
<div className="shrink-0 border-t max-h-80 overflow-auto bg-background">
<AssignedUsersSection permissionSetName={name} />
</div>
</div>

{/* Destructive-change dialog */}
Expand Down
96 changes: 92 additions & 4 deletions packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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]: <pickedId>}` (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. */
Expand Down Expand Up @@ -125,6 +139,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
onRowEdit,
onRowDelete,
onRowClick,
add,
maxColumns = 6,
pageSize,
sortable = false,
Expand All @@ -150,6 +165,12 @@ export const RelatedList: React.FC<RelatedListProps> = ({
const [filterText, setFilterText] = React.useState('');
const [objectSchema, setObjectSchema] = React.useState<any>(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<string | null>(null);
const [refreshNonce, setRefreshNonce] = React.useState(0);
// Per-lookup-field cache of resolved labels: fieldName -> Map<id, label>
const [lookupLabels, setLookupLabels] = React.useState<Record<string, Record<string, string>>>({});
const { t } = useDetailTranslation();
Expand Down Expand Up @@ -227,7 +248,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
.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
Expand Down Expand Up @@ -276,6 +297,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
r?.title ||
r?.label ||
r?.code ||
r?.email ||
String(id);
}
return { fieldName, map };
Expand Down Expand Up @@ -357,11 +379,45 @@ export const RelatedList: React.FC<RelatedListProps> = ({
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).
Expand Down Expand Up @@ -639,6 +695,18 @@ export const RelatedList: React.FC<RelatedListProps> = ({
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{add && (
<Button
variant={isEmpty ? 'ghost' : 'outline'}
size="sm"
disabled={addBusy}
onClick={(e) => { e.stopPropagation(); setAddError(null); setPickerOpen(true); }}
className="gap-1 h-9 sm:h-7 text-xs shadow-none"
>
<Plus className="h-3.5 w-3.5" />
{add.label || t('detail.add', { defaultValue: 'Add' })}
</Button>
)}
{onNew && (
<Button
variant={isEmpty ? 'ghost' : 'outline'}
Expand Down Expand Up @@ -768,6 +836,26 @@ export const RelatedList: React.FC<RelatedListProps> = ({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{addError && (
<div
className="mx-4 mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
role="alert"
>
{addError}
</div>
)}
{add && dataSource && (
<RecordPickerDialog
open={pickerOpen}
onOpenChange={(o) => setPickerOpen(o)}
multiple
dataSource={dataSource as any}
objectName={add.picker.object}
title={add.label || t('detail.add', { defaultValue: 'Add' })}
onSelect={() => {}}
onSelectRecords={(records: any[]) => { void handleAddRecords(records); }}
/>
)}
</Card>
);
};
13 changes: 13 additions & 0 deletions packages/plugin-detail/src/renderers/record-related-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ export const RecordRelatedListRenderer: React.FC<RecordRelatedListRendererProps>
columns={filteredColumns as any}
pageSize={schema.limit}
dataSource={ctx?.dataSource as any}
add={(schema as any).add}
onRowDelete={
// Generic remove for link/junction rows: delete the related row itself.
// RelatedList refreshes after this resolves. Only wired when an `add`
// config is present (i.e. this is a managed assignment list), so plain
// read-only related lists keep their existing behavior.
(schema as any).add && ctx?.dataSource
? async (row: any) => {
const id = row?.id ?? row?._id;
if (id != null) await (ctx!.dataSource as any).delete?.(objectName, String(id));
}
: undefined
}
/>
</div>
);
Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/record-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading