From fb801862ace9b12f09f618011393f58e67116fe1 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 9 Jul 2026 08:42:28 +0800 Subject: [PATCH 1/2] feat(app-shell,auth,console): active-org nav context + related-list row-action correctness + first-run wizard fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanism wiring for cloud ADR-0081 (in-shell org/member management): - Nav template context now carries currentOrgId (sidebars, command palette, search results) so '{current_org_id}' recordId deep-links resolve — the spec + NavigationRenderer already supported the token; the shell just never supplied the value. - AuthProvider: refreshOrganizations returns the fetched list (callers were reading a stale state closure), and a session with no active org but exactly one membership auto-activates it (repairs pre-existing sessions without a re-login). - Related-list row actions (record pages) fixed end-to-end: - RelatedRecordActionsBridge spread the ActionParam[] DEFINITION array into the params object ({0:{...}}), which downstream sent to the data API as a fields map -> INVALID_FIELD: Unknown field '0'. Dispatch now mirrors ObjectGrid: defs as actionParams, params reserved for _rowRecord. - Param dialogs resolve field-backed params against the action's OWN (child) object and prefill defaultFromRow from the clicked row (RecordDetailView + useConsoleActionRuntime). - RecordDetailView.apiHandler gains the canonical absolute-HTTP branch (recordIdParam/bodyExtra/bodyShape + active-org injection). Previously every type:'api' action mapped to dataSource.update: remove_member silently no-opped as a fake success, and update_member_role bypassed better-auth with a raw write to a managed identity table. - First-run wizard (SetupPage): use the returned org list with a short retry (stale closure made it always fall through to createOrganization, which single-org FORBIDs and the error was swallowed); keep the existing slug when the org name slugifies to '' (CJK names); don't auto-redirect mid-submission (the navigation killed the in-flight rename). Live-verified against a single-org framework stack in the browser. Co-Authored-By: Claude Fable 5 --- apps/console/src/pages/auth/SetupPage.tsx | 42 ++++++--- .../app-shell/src/chrome/CommandPalette.tsx | 7 +- .../src/hooks/useConsoleActionRuntime.tsx | 13 ++- packages/app-shell/src/layout/AppSidebar.tsx | 8 +- .../app-shell/src/layout/UnifiedSidebar.tsx | 4 +- .../app-shell/src/views/RecordDetailView.tsx | 94 +++++++++++++++++-- .../src/views/RelatedRecordActionsBridge.tsx | 21 ++++- .../app-shell/src/views/SearchResultsPage.tsx | 4 +- packages/auth/src/AuthContext.ts | 8 +- packages/auth/src/AuthProvider.tsx | 27 ++++-- 10 files changed, 184 insertions(+), 44 deletions(-) diff --git a/apps/console/src/pages/auth/SetupPage.tsx b/apps/console/src/pages/auth/SetupPage.tsx index 142f50e15f..e64f41d033 100644 --- a/apps/console/src/pages/auth/SetupPage.tsx +++ b/apps/console/src/pages/auth/SetupPage.tsx @@ -40,7 +40,6 @@ export function SetupPage() { const { user, signUp, - organizations, refreshOrganizations, updateOrganization, createOrganization, @@ -83,10 +82,14 @@ export function SetupPage() { }, [bootstrapped, user, navigate]); useEffect(() => { - if (user) { + // Already-signed-in visitors bounce home — but NOT mid-submission: signUp + // flips `user` while handleSubmit is still renaming the bootstrap org, and + // navigating here killed that in-flight rename (the org silently kept the + // "Default Organization" name). handleSubmit owns the redirect on success. + if (user && !submitting) { window.location.assign('/'); } - }, [user]); + }, [user, submitting]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -94,26 +97,43 @@ export function SetupPage() { try { await signUp(name, email, password); - // The Security plugin auto-creates a personal "'s Workspace" - // on signup so multi-tenant RLS has something to hang on. Don't - // create a second org — rename the auto-created one to the user's - // chosen name (or create a fresh one if the plugin is disabled). + // The server bootstraps the owner's organization (plugin-auth's + // default-org bootstrap in single-org mode; org-scoping's in + // multi-org) — don't create a second org, RENAME the bootstrap one + // to the user's chosen name. Two subtleties: + // - the bootstrap runs off a permission-grant middleware and may + // land moments after signUp() resolves → poll briefly; + // - the `organizations` state from useAuth() is a STALE CLOSURE + // here (refresh just updated it, but this render can't see it) — + // use the list refreshOrganizations() returns. Reading the state + // was the bug that made this path silently fall through to + // createOrganization(), which single-org mode FORBIDs. const trimmedName = orgName.trim(); if (trimmedName) { try { - await refreshOrganizations(); - const personal = organizations[0]; + let personal: { id?: string } | undefined; + for (let attempt = 0; attempt < 4 && !personal?.id; attempt++) { + if (attempt > 0) await new Promise((r) => setTimeout(r, 500)); + const orgs = await refreshOrganizations(); + personal = orgs?.[0]; + } + // CJK/emoji-only names slugify to '' — an empty slug fails the org + // update server-side and the whole rename used to be silently + // swallowed. The rename is about the DISPLAY name: keep the + // existing slug when there's nothing latin to derive, and mint a + // stable fallback only when creating from scratch. + const slug = slugify(trimmedName); let activeOrgId: string | undefined; if (personal?.id) { await updateOrganization(personal.id, { name: trimmedName, - slug: slugify(trimmedName), + ...(slug ? { slug } : {}), }); activeOrgId = personal.id; } else { const created = await createOrganization({ name: trimmedName, - slug: slugify(trimmedName), + slug: slug || `org-${Date.now().toString(36)}`, }); activeOrgId = created?.id; } diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index afaef936e5..d5c7f3e071 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -70,8 +70,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour }, [open]); const baseUrl = `/apps/${appName || appRouteSegment(activeApp)}`; - const { user } = useAuth(); - const templateContext = useMemo(() => ({ currentUserId: user?.id ?? null }), [user?.id]); + const { user, activeOrganization } = useAuth(); + const templateContext = useMemo( + () => ({ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null }), + [user?.id, activeOrganization?.id], + ); const runCommand = useCallback((command: () => void) => { setOpen(false); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index a8e5bc8608..3b6344d759 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -165,8 +165,17 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons const row = action?.params && !Array.isArray(action.params) ? (action.params as Record)._rowRecord : undefined; + // Field-backed params resolve against the action's OWN object when the + // dispatch carries one (related-list row actions retarget a CHILD object + // — e.g. sys_member rows on an org record page); the page-level object + // is only the fallback. Without this, a child action's `field` lookup + // ran against the parent object, missed, and degraded to a bare text + // input (no select options, no field label). + const actionObject = typeof action?.objectName === 'string' && action.objectName + ? action.objectName + : undefined; const resolved = resolveActionParams(params as any, { - objectName: objectName || (objectDef as any)?.name || '', + objectName: actionObject || objectName || (objectDef as any)?.name || '', objects: objects || [], fieldLabel, fieldOptionLabel, @@ -174,7 +183,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons }); // Localize each param's label/placeholder/helpText via the // `_actions..params..` convention. - const objForI18n = objectName || (objectDef as any)?.name; + const objForI18n = actionObject || objectName || (objectDef as any)?.name; const localized = (resolved as any[]).map((p: any) => ({ ...p, label: actionParamText(objForI18n, action?.name, p.name, 'label', p.label) ?? p.label, diff --git a/packages/app-shell/src/layout/AppSidebar.tsx b/packages/app-shell/src/layout/AppSidebar.tsx index 9ee2578218..d1730c5417 100644 --- a/packages/app-shell/src/layout/AppSidebar.tsx +++ b/packages/app-shell/src/layout/AppSidebar.tsx @@ -139,7 +139,7 @@ const getIcon = resolveIcon; export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: string, onAppChange: (name: string) => void }) { const { isMobile, setOpenMobile } = useSidebar(); - const { user, signOut, isAuthEnabled } = useAuth(); + const { user, signOut, isAuthEnabled, activeOrganization } = useAuth(); const isWorkspaceAdmin = useIsWorkspaceAdmin(); const navigate = useNavigate(); const location = useLocation(); @@ -389,7 +389,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri location.pathname, location.search, basePath, - { currentUserId: user?.id ?? null, contextValues }, + { currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }, ); const sel = active ? `?sel=${encodeURIComponent(`nav:${active.id}`)}` : ''; navigate(`/apps/${seg}/metadata/app/${activeAppName}${sel}`); @@ -499,7 +499,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri resolveObjectLabel={(objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback })} resolveViewLabel={(objectName, viewName, fallback) => resolveNavViewLabel(objectName, viewName, fallback)} t={t} - templateContext={{ currentUserId: user?.id ?? null, contextValues }} + templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }} /> {/* Recent Items (elevated position for quick access) */} @@ -686,7 +686,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri return leaves.slice(0, 5).map((item: any) => { const NavIcon = getIcon(item.icon); const baseUrl = activeApp ? `/apps/${appRouteSegment(activeApp) ?? activeAppName}` : ''; - const { href } = resolveHref(item, baseUrl, { currentUserId: user?.id ?? null }); + const { href } = resolveHref(item, baseUrl, { currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null }); return ( diff --git a/packages/app-shell/src/layout/UnifiedSidebar.tsx b/packages/app-shell/src/layout/UnifiedSidebar.tsx index 9e9ef4e406..0e230cf40b 100644 --- a/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -150,7 +150,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { const { t } = useObjectTranslation(); const { objectLabel: resolveNavObjectLabel, dashboardLabel: resolveNavDashboardLabel, navGroupLabel: resolveNavGroupLabel, viewLabel: resolveNavViewLabel } = useObjectLabel(); const { context, currentAppName } = useNavigationContext(); - const { user } = useAuth(); + const { user, activeOrganization } = useAuth(); const isWorkspaceAdmin = useIsWorkspaceAdmin(); // Swipe-from-left-edge gesture to open sidebar on mobile @@ -454,7 +454,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) { ) : undefined} resolveViewLabel={(objectName, viewName, fallback) => resolveNavViewLabel(objectName, viewName, fallback)} t={t} - templateContext={{ currentUserId: user?.id ?? null, contextValues }} + templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }} /> {/* Recent Items */} diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index cac6090255..56359e7773 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -117,7 +117,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const objectName = objectNameOverride ?? params.objectName; const recordId = recordIdOverride ?? params.recordId; const { showDebug } = useMetadataInspector(); - const { user } = useAuth(); + const { user, activeOrganization } = useAuth(); const navigate = useNavigate(); // objectui#2257 — the active detail tab is URL-addressable (`?tab=`), so it // survives the page subtree remounting (refreshKey-style save refreshes; @@ -362,15 +362,27 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const paramCollectionHandler = useCallback((params: ActionParamDef[], action?: any) => { return new Promise | null>((resolve) => { + // Related-list row actions retarget a CHILD object (e.g. sys_member rows + // on an org record page) and stash the clicked row under + // `params._rowRecord` — resolve field-backed params against the + // action's own object and pre-fill `defaultFromRow` from the row, with + // the page object only as fallback (mirrors useConsoleActionRuntime). + const actionObject = typeof action?.objectName === 'string' && action.objectName + ? action.objectName + : undefined; + const row = action?.params && !Array.isArray(action.params) + ? (action.params as Record)._rowRecord + : undefined; const resolved = resolveActionParams(params as any, { - objectName: objectName || objectDef?.name || '', + objectName: actionObject || objectName || objectDef?.name || '', objects: objects || [], fieldLabel, fieldOptionLabel, + row, }); // Localize param label/placeholder/helpText (see ObjectView for the // convention); falls back to the metadata literal. - const objForI18n = objectName || objectDef?.name; + const objForI18n = actionObject || objectName || objectDef?.name; const localized = (resolved as any[]).map((p: any) => ({ ...p, label: actionParamText(objForI18n, action?.name, p.name, 'label', p.label) ?? p.label, @@ -422,13 +434,82 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } }, [navigate]); - // API action handler — maps logical action targets to dataSource operations + // Authenticated fetch for direct backend calls (absolute `type:'api'` + // targets below + the flow trigger). Declared before apiHandler. + const authFetch = useMemo(() => createAuthenticatedFetch(), []); + + // API action handler — absolute HTTP targets go to the backend verbatim + // (the canonical `type:'api'` semantics); logical targets map to + // dataSource operations. const apiHandler = useCallback(async (action: ActionDef) => { try { const target = action.target || action.name; + const rowRecord = action.params && !Array.isArray(action.params) + ? ((action.params as Record)._rowRecord as Record | undefined) + : undefined; const params: Record = { ...(action.params || {}) }; delete params._rowRecord; + // Absolute HTTP target — mirror of useConsoleActionRuntime.apiHandler's + // canonical branch (fetch + recordIdParam/bodyExtra/bodyShape + + // active-org injection for better-auth endpoints). The legacy + // dataSource.update mapping below MUST NOT see these: routing a + // better-auth row action (remove_member / update_member_role) through + // it either silently no-opped (no collected params) or bypassed + // better-auth with a raw table write on a managed identity table. + const targetStr = typeof target === 'string' ? target : ''; + if (targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr)) { + const baseUrl = import.meta.env.VITE_SERVER_URL || ''; + // Interpolate `{field}` tokens in the target URL from the row record. + let resolvedTarget = targetStr; + if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) { + resolvedTarget = resolvedTarget.replace(/\{([a-z_][a-z0-9_]*)\}/gi, (_, k) => { + const v = rowRecord[k]; + return v == null ? '' : encodeURIComponent(String(v)); + }); + } + const url = resolvedTarget.startsWith('http') ? resolvedTarget : `${baseUrl}${resolvedTarget}`; + + const wrap = action.bodyShape && typeof action.bodyShape === 'object' && (action.bodyShape as any).wrap + ? (action.bodyShape as any).wrap + : undefined; + const body: Record = wrap ? { [wrap]: params } : { ...params }; + + if (action.recordIdParam) { + const rowField = (action as any).recordIdField || 'id'; + const rowValue = rowRecord?.[rowField] ?? (action as any).recordId + ?? (!action.objectName || action.objectName === objectName ? (pageRecord as any)?.[rowField] : undefined); + if (rowValue != null) body[action.recordIdParam] = rowValue; + } + + // better-auth org endpoints resolve the session's active org; pass it + // explicitly so row actions stay correct mid-org-switch. + if (/\/api\/v1\/auth\//.test(resolvedTarget) && !body.organizationId && activeOrganization?.id) { + body.organizationId = activeOrganization.id; + } + if (action.bodyExtra && typeof action.bodyExtra === 'object') { + Object.assign(body, action.bodyExtra); + } + + const method = (action.method || 'POST').toUpperCase(); + const init: any = { + method, + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }; + if (method !== 'GET' && method !== 'DELETE') init.body = JSON.stringify(body); + const res = await authFetch(url, init); + if (!res.ok) { + let errBody: any = null; + try { errBody = await res.json(); } catch { /* response body not JSON */ } + const detail = errBody?.error?.message || errBody?.error || errBody?.message || `HTTP ${res.status}`; + return { success: false, error: typeof detail === 'string' ? detail : `HTTP ${res.status}` }; + } + const data = await res.json().catch(() => ({})); + if (action.refreshAfter === true) notifyRecordChanged(); + return { success: true, data, reload: action.refreshAfter === true }; + } + // Merge `bodyExtra` constant fields into the update payload. Per the // ActionSchema contract these are "applied last; overrides user params", // and the PageView/list executeAPI path already honors them. Without this @@ -496,15 +577,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } catch (error) { return { success: false, error: (error as Error).message }; } - }, [dataSource, objectName, pureRecordId, pageRecord]); + }, [dataSource, objectName, pureRecordId, pageRecord, authFetch, activeOrganization]); // Client-side modal transport: `type:'modal'` actions open here (Dialog / // Sheet / Drawer by `placement`) and render arbitrary SchemaNode content. const { modalHandler, modalElement } = useActionModal(dataSource); - // Authenticated fetch for direct backend calls (e.g. flow trigger). - const authFetch = useMemo(() => createAuthenticatedFetch(), []); - // Flow action handler — POST to /api/v1/automation/{name}/trigger. // Triggered when an Action with `type: 'flow'` is invoked from a record-level // location (record_header, record_more, …). The server-side automation diff --git a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx index 113a045b28..2a7e3295e1 100644 --- a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx +++ b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx @@ -142,13 +142,24 @@ export function RelatedRecordActionsBridge({ const runRowAction = useCallback( async (childObject: string, record: any, action: RelatedRowActionDef) => { const id = record?.id ?? record?._id; - const def = { - ...(action as unknown as ActionDef), + // Same dispatch shape as ObjectGrid.onActionDef: a metadata action's + // `params` is the ActionParam[] COLLECTION DEFINITION — surface it as + // `actionParams` (the runner's param-dialog input) and reserve `params` + // for the `_rowRecord` stash (apiHandler row-id injection + + // `defaultFromRow` prefill). Spreading the array into `params` used to + // produce `{0: {...}}`, which downstream consumers sent to the data API + // as a fields map → INVALID_FIELD: Unknown field '0'. + const { params: rawParams, ...rest } = action as unknown as ActionDef & { params?: unknown }; + const def: any = { + ...rest, objectName: childObject, ...(id != null ? { recordId: String(id) } : {}), - params: { ...(action.params as Record | undefined) }, - } as ActionDef; - const res = await execute(def); + params: { _rowRecord: record }, + }; + if (Array.isArray(rawParams) && rawParams.length > 0) { + def.actionParams = rawParams; + } + const res = await execute(def as ActionDef); // Refresh open related lists for this child object after a successful // mutating action (the row menu handler is otherwise fire-and-forget). if (res?.success) notifyRelatedChanged(childObject); diff --git a/packages/app-shell/src/views/SearchResultsPage.tsx b/packages/app-shell/src/views/SearchResultsPage.tsx index c75ba4e595..0629c89352 100644 --- a/packages/app-shell/src/views/SearchResultsPage.tsx +++ b/packages/app-shell/src/views/SearchResultsPage.tsx @@ -75,13 +75,13 @@ export function SearchResultsPage() { const apps = metadataApps || []; const activeApp = matchAppBySegment(apps, appName) || apps[0]; const baseUrl = `/apps/${appName}`; - const { user } = useAuth(); + const { user, activeOrganization } = useAuth(); // Build searchable items from navigation const allItems = useMemo((): SearchResult[] => { if (!activeApp) return []; const navItems = flattenNavigation(activeApp.navigation || []); - const templateContext = { currentUserId: user?.id ?? null }; + const templateContext = { currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null }; return navItems.map((item: any) => { const { href } = resolveHref(item, baseUrl, templateContext); diff --git a/packages/auth/src/AuthContext.ts b/packages/auth/src/AuthContext.ts index e048fe58fb..2d315cabbe 100644 --- a/packages/auth/src/AuthContext.ts +++ b/packages/auth/src/AuthContext.ts @@ -93,8 +93,12 @@ export interface AuthContextValue { switchOrganization: (orgId: string) => Promise; /** Create a new organization */ createOrganization: (data: { name: string; slug: string; logo?: string }) => Promise; - /** Refresh the organizations list */ - refreshOrganizations: () => Promise; + /** + * Refresh the organizations list. Returns the freshly fetched list so + * callers that need it right away (e.g. the first-run wizard's rename + * step) don't read a stale `organizations` closure. + */ + refreshOrganizations: () => Promise; /** Update organization details (owner/admin) */ updateOrganization: (orgId: string, data: Partial>) => Promise; /** Delete an organization (owner) */ diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 04c4638aa3..2c7a8a9c2d 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -351,20 +351,35 @@ export function AuthProvider({ setIsOrganizationsLoading(true); try { const orgs = await client.listOrganizations(); - if (isCancelled?.()) return; + if (isCancelled?.()) return orgs; setOrganizations(orgs); // If no active org is set but orgs exist, try to get active from server if (orgs.length > 0 && !activeOrganization) { + let active: AuthOrganization | null = null; try { - const active = await client.getActiveOrganization(); - if (active && !isCancelled?.()) { - setActiveOrganization(active); - ActiveOrganizationStorage.set(active.id); - } + active = await client.getActiveOrganization(); } catch { // No active org set — that's fine + active = null; + } + // Single-membership repair (ADR-0081): a session created before the + // server-side active-org stamp existed carries no active org even + // though the user belongs to exactly one — activate it so org-scoped + // UI ({current_org_id} nav links, org endpoints) works without a + // re-login. With multiple orgs the choice stays with the user. + if (!active && orgs.length === 1) { + try { + active = await client.setActiveOrganization(orgs[0].id); + } catch { + active = null; + } + } + if (active && !isCancelled?.()) { + setActiveOrganization(active); + ActiveOrganizationStorage.set(active.id); } } + return orgs; } catch (err) { // A route change / unmount racing the in-flight request is not a real // failure — only warn when this call is still the one that matters. From de3a25f7e4897f88c0d09eea764f11d6326a6111 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 9 Jul 2026 11:27:05 +0800 Subject: [PATCH 2/2] feat(plugin-detail,react,app-shell): bridge child list_toolbar actions onto related lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related lists surfaced a child object's row actions (list_item) but not its LIST actions — on an organization record page the Invitations tab had no way to send an invitation (sys_invitation's invite_user is a list_toolbar action). Extend the same host-bridge contract with toolbarActions / onToolbarAction: the bridge derives the child's list_toolbar actions (localized), RelatedList renders them as header buttons before Add/New, and execution reuses the row-action dispatch without a row record. Completes the in-shell Team surface (cloud ADR-0081 D3): invite / cancel / resend now work from the org record page's Invitations list; live-verified against the cloud control plane. Co-Authored-By: Claude Fable 5 --- .../src/views/RelatedRecordActionsBridge.tsx | 26 +++++++++++++---- packages/plugin-detail/src/RelatedList.tsx | 28 +++++++++++++++++++ .../src/renderers/record-related-list.tsx | 2 ++ .../context/RelatedRecordActionsContext.tsx | 9 ++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx index 2a7e3295e1..7efb92b0f5 100644 --- a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx +++ b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx @@ -91,13 +91,18 @@ export interface RelatedRecordActionsBridgeProps { } /** - * Derive the child object's row actions (metadata `actions` filtered to the - * `list_item` location), localized and shaped for the related-list row menu. + * Derive the child object's actions for a related-list location + * (`list_item` → row menu, `list_toolbar` → header buttons), localized and + * shaped for the related-list bridge. */ -function deriveRowActions(childDef: any, actionLabel: ActionLabelFn): RelatedRowActionDef[] { +function deriveActions( + childDef: any, + actionLabel: ActionLabelFn, + location: 'list_item' | 'list_toolbar', +): RelatedRowActionDef[] { const actions = Array.isArray(childDef?.actions) ? childDef.actions : []; return actions - .filter((a: any) => Array.isArray(a?.locations) && a.locations.includes('list_item')) + .filter((a: any) => Array.isArray(a?.locations) && a.locations.includes(location)) .map((a: any) => ({ ...a, label: actionLabel(childDef.name, a.name, a.label || a.name), @@ -221,13 +226,24 @@ export function RelatedRecordActionsBridge({ }; } - const rowActions = deriveRowActions(childDef, actionLabel); + const rowActions = deriveActions(childDef, actionLabel, 'list_item'); if (rowActions.length > 0) { handlers.rowActions = rowActions; handlers.onRowAction = (action, record) => runRowAction(objectName, record, action); } + // List-level actions (e.g. sys_invitation's `invite_user`) render as + // header buttons — the related-list equivalent of the object list's + // toolbar. Executed through the same dispatch as row actions, just + // without a row record. + const toolbarActions = deriveActions(childDef, actionLabel, 'list_toolbar'); + if (toolbarActions.length > 0) { + handlers.toolbarActions = toolbarActions; + handlers.onToolbarAction = (action) => + runRowAction(objectName, undefined, action); + } + return handlers; }, }), diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 71523b2d19..f3b6a544ce 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -86,6 +86,14 @@ export interface RelatedListProps { rowActions?: RelatedRowActionDef[]; /** Execute one of {@link rowActions} against the clicked row. */ onRowAction?: (action: RelatedRowActionDef, row: any) => void | Promise; + /** + * Child-object list actions (`locations: ['list_toolbar']`), already + * localized by the host. Rendered as header buttons next to Add/New — + * e.g. `invite_user` on an organization's Invitations list. + */ + toolbarActions?: RelatedRowActionDef[]; + /** Execute one of {@link toolbarActions} (no row context). */ + onToolbarAction?: (action: RelatedRowActionDef) => void | Promise; /** Maximum number of columns to auto-generate. Default 6. */ maxColumns?: number; /** Page size for pagination (enables pagination when set) */ @@ -148,6 +156,8 @@ export const RelatedList: React.FC = ({ onRowClick, rowActions, onRowAction, + toolbarActions, + onToolbarAction, add, maxColumns = 6, pageSize, @@ -742,6 +752,24 @@ export const RelatedList: React.FC = ({ )}
+ {/* Child-object list_toolbar actions (e.g. Invite User) — the + related-list equivalent of the object list's toolbar buttons. + Rendered before Add/New so the domain action leads. */} + {onToolbarAction && (toolbarActions ?? []).map((a) => { + const ActionIcon = a.icon ? resolveIconComponent(a.icon) : null; + return ( + + ); + })} {add && (