// ModelActionToolbar — renders page-level (toolbar) triggers for a model's // declarative actions and owns the modal dispatch for them. // // A model's actions carry a `placement` hint (see manifest/v3 Action.placement): // "row" (default) — rendered per-row inside 's action column. // "table" — a plain toolbar button (no record context). // "create" — a primary toolbar button that replaces the generic // "create" button, for addons shipping a custom create // experience (e.g. a journal entry with debit/credit lines). // // This component renders the buttons for the placements it's asked to surface // (default: table + create) and mounts the , which // resolves either a custom federated modal (registered via the action registry) // or the generic declarative form. For create-style actions there is no record // yet, so the dispatcher receives an empty record `{}`. // // It is the single generic primitive every host consumes — DynamicCRUDPage uses // it internally, and bespoke host pages (e.g. ops `/m/$model`) mount it directly // next to their own toolbar. Hosts never reimplement action-button plumbing. import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@asteby/metacore-ui/primitives' import { useApi } from './api-context' import { useMetadataCache } from './metadata-cache' import { DynamicIcon } from './dynamic-icon' import { ActionModalDispatcher } from './action-modal-dispatcher' import { useCan, modelCapability } from './permissions-context' import type { ActionDefinition, ActionMetadata, TableMetadata } from './types' export type ActionPlacement = 'row' | 'table' | 'create' export interface ModelActionToolbarProps { /** Model key as registered on the backend (e.g. "JournalEntry"). */ model: string /** Data endpoint passed to the dispatcher. Defaults to `/data//me`. */ endpoint?: string /** * Pre-fetched action definitions. When omitted the toolbar reads them from * the metadata cache, falling back to `/metadata/table/`. Pass this * when the host page already holds the metadata to avoid a second fetch. */ actions?: ActionDefinition[] /** Which placements to render. Defaults to `['table', 'create']`. */ placements?: ActionPlacement[] /** Fired after an action's modal reports success. */ onChange?: () => void /** Extra classes on the button row container. */ className?: string /** * Fired on hover/focus of an action button so the host can prefetch a * federated remote (e.g. collections) before the click — Module Federation * lazy load without blocking first paint of the list page. */ onActionIntent?: (action: ActionDefinition) => void } const DEFAULT_PLACEMENTS: ActionPlacement[] = ['table', 'create'] /** * Last-resort label when `t(key)` has no translation yet: never flash the full * dotted key in a primary CTA. Prefers the action/field segment over a trailing * `.label` / `.confirm` leaf. * "receivables.action.collect_payment_create" → "Collect payment create" * "integration_github.action.create_issue.label" → "Create issue" * Already-human labels pass through unchanged. */ export function humanizeActionLabel(label: string): string { if (!label) return label if (!label.includes('.') || /\s/.test(label)) return label const parts = label.split('.').filter(Boolean) let leaf = parts[parts.length - 1] || label if ( (leaf === 'label' || leaf === 'confirm' || leaf === 'title') && parts.length >= 2 ) { leaf = parts[parts.length - 2] } return leaf .split('_') .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } function toActionMetadata(a: ActionDefinition): ActionMetadata { return { key: a.key, label: a.label, icon: a.icon || 'Zap', color: a.color, confirm: a.confirm, confirmMessage: a.confirmMessage, fields: a.fields, requiresState: a.requiresState, executable: a.executable, placement: a.placement, } } /** * Returns the model's actions matching the requested placements. Reads from the * `actions` prop when provided, else the metadata cache, else fetches once. */ export function useModelActions( model: string, placements: ActionPlacement[] = DEFAULT_PLACEMENTS, provided?: ActionDefinition[], ): ActionDefinition[] { const api = useApi() const cached = useMetadataCache((s) => s.getMetadata(model)) const [fetched, setFetched] = useState(null) const haveSource = provided != null || cached != null useEffect(() => { if (haveSource) return let cancelled = false api .get(`/metadata/table/${model}`) .then((res) => { if (!cancelled) setFetched((res.data?.data ?? res.data) as TableMetadata) }) .catch(() => { if (!cancelled) setFetched(null) }) return () => { cancelled = true } }, [model, haveSource, api]) const all = provided ?? cached?.actions ?? fetched?.actions ?? [] return useMemo( () => all.filter((a) => placements.includes((a.placement ?? 'row') as ActionPlacement)), [all, placements], ) } export function ModelActionToolbar({ model, endpoint, actions, placements = DEFAULT_PLACEMENTS, onChange, className, onActionIntent, }: ModelActionToolbarProps) { const { t } = useTranslation() const all = useModelActions(model, placements, actions) // Capability gating — always-true without a . Custom // table/create actions map onto `lowercase(model).`. const can = useCan() const surfaced = useMemo( () => all.filter((a) => can(modelCapability(model, a.key))), [all, can, model], ) const [active, setActive] = useState(null) const dataEndpoint = endpoint ?? `/data/${model}/me` if (surfaced.length === 0) return null return ( <>
{surfaced.map((a) => { const isCreate = (a.placement ?? 'row') === 'create' return ( ) })}
{active && ( { if (!open) setActive(null) }} action={active} model={model} record={{}} endpoint={dataEndpoint} onSuccess={() => { setActive(null) onChange?.() }} /> )} ) }