diff --git a/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx b/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx new file mode 100644 index 000000000..a18578e3a --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.test.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +// Capture the authenticated fetch so we can assert request bodies. +const mockFetch = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true, message: 'done' }), +})); +vi.mock('@object-ui/auth', () => ({ createAuthenticatedFetch: () => mockFetch })); + +// Stub the param dialog: when open, expose a button that resolves with values — +// lets us drive the collect-params promise without the heavy field renderers. +vi.mock('../ActionParamDialog', () => ({ + ActionParamDialog: ({ state }: any) => + state.open ? ( + + ) : null, +})); +// Stub the result dialog: surface its open state + data for assertions. +vi.mock('../ActionResultDialog', () => ({ + ActionResultDialog: ({ state }: any) => + state.open ?
{JSON.stringify(state.data)}
: null, +})); + +import { MetadataTypeActions } from './MetadataTypeActions'; + +beforeEach(() => mockFetch.mockClear()); + +describe('MetadataTypeActions', () => { + it('runs an api action without params directly (no dialog)', async () => { + render( + , + ); + fireEvent.click(screen.getByTitle('Test')); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('/api/v1/datasources/ds1/test'); + expect(screen.queryByTestId('submit-params')).toBeNull(); + }); + + it('collects array params via the dialog and sends them as the body', async () => { + render( + , + ); + fireEvent.click(screen.getByTitle('Sync')); + // dialog opens (params present) — no fetch yet + const submit = await screen.findByTestId('submit-params'); + expect(mockFetch).not.toHaveBeenCalled(); + fireEvent.click(submit); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ reason: 'because' }); + }); + + it('shows the result dialog when the action declares resultDialog', async () => { + render( + , + ); + fireEvent.click(screen.getByTitle('Probe')); + await waitFor(() => expect(screen.getByTestId('result-dialog')).toBeTruthy()); + expect(screen.getByTestId('result-dialog').textContent).toContain('done'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx b/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx index 8db916228..b85b5be40 100644 --- a/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx +++ b/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx @@ -22,6 +22,11 @@ * on :5180 and the backend on :3000). `${ctx.recordId}` / `${param.X}` tokens * in `target` are resolved here, exactly as the spec mandates renderers do. * + * Dialogs: actions that declare an array of `params` collect them from the + * user in the shared {@link ActionParamDialog} before running (same UX as + * business-object actions); actions that declare a `resultDialog` render the + * API response in {@link ActionResultDialog}. `confirmText` still gates the run. + * * Only `type:'api'` is wired today; other types surface a toast so a * misconfigured action fails loud instead of silent. */ @@ -31,7 +36,10 @@ import { toast } from 'sonner'; import { Loader2 } from 'lucide-react'; import { Button } from '@object-ui/components'; import { createAuthenticatedFetch } from '@object-ui/auth'; +import type { ActionParamDef } from '@object-ui/core'; import { getIcon } from '../../utils/getIcon'; +import { ActionParamDialog, type ParamDialogState } from '../ActionParamDialog'; +import { ActionResultDialog, type ResultDialogState } from '../ActionResultDialog'; import type { MetadataTypeAction, RichMetadataTypeEntry } from './useMetadata'; /** Map the spec's action variants onto the Shadcn Button variants. */ @@ -81,6 +89,8 @@ export interface MetadataTypeActionsProps { */ export function MetadataTypeActions({ entry, location, recordId, onAfter }: MetadataTypeActionsProps): React.ReactElement | null { const [busy, setBusy] = React.useState(null); + const [paramState, setParamState] = React.useState({ open: false, params: [] }); + const [resultState, setResultState] = React.useState({ open: false }); const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); const actions = React.useMemo( @@ -93,6 +103,12 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta if (actions.length === 0) return null; + /** Open the param dialog and resolve with the collected values (or null on cancel). */ + const collectParams = (params: ActionParamDef[], title?: string) => + new Promise | null>((resolve) => { + setParamState({ open: true, params, title, resolve }); + }); + const run = async (action: MetadataTypeAction) => { const title = action.label ?? action.name; @@ -105,7 +121,17 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta if (action.confirmText && !window.confirm(action.confirmText)) return; - const params = action.params ?? {}; + // Inputs: an array of param descriptors → collect in a dialog; a static + // object → forward as-is. + let params: Record; + if (Array.isArray(action.params) && action.params.length > 0) { + const collected = await collectParams(action.params as ActionParamDef[], title); + if (collected == null) return; // user cancelled + params = collected; + } else { + params = (action.params as Record | undefined) ?? {}; + } + const ctx = { recordId, origin: window.location.origin }; const resolved = interpolateTarget(action.target ?? '', ctx, params); if (!resolved) { @@ -139,12 +165,17 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta if (!res.ok || (data && data.success === false)) { const detail = (data?.error as string) || (data?.message as string) || `HTTP ${res.status} ${res.statusText}`.trim(); - toast.error(`${title}: ${detail}`); + toast.error(`${action.errorMessage ? `${action.errorMessage}: ` : ''}${title}: ${detail}`); return; } - const msg = typeof data?.message === 'string' ? (data.message as string) : `${title} ✓`; - toast.success(msg); + // Rich result reveal when declared, else a success toast. + if (action.resultDialog) { + setResultState({ open: true, spec: action.resultDialog as ResultDialogState['spec'], data: data ?? {} }); + } else { + const msg = action.successMessage || (typeof data?.message === 'string' ? (data.message as string) : `${title} ✓`); + toast.success(msg); + } if (action.refreshAfter) onAfter?.(); } catch (err) { toast.error(`${title}: ${(err as Error)?.message ?? String(err)}`); @@ -176,6 +207,20 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta ); })} + + { + if (!open) { + paramState.resolve?.(null); + setParamState({ open: false, params: [] }); + } + }} + /> + setResultState({ open: false })} + /> ); } diff --git a/packages/app-shell/src/views/metadata-admin/useMetadata.ts b/packages/app-shell/src/views/metadata-admin/useMetadata.ts index ddfb499a0..a6c311dfa 100644 --- a/packages/app-shell/src/views/metadata-admin/useMetadata.ts +++ b/packages/app-shell/src/views/metadata-admin/useMetadata.ts @@ -50,8 +50,18 @@ export interface MetadataTypeAction { confirmText?: string; /** Reload the view after a successful run. */ refreshAfter?: boolean; - /** Static request body / param bag forwarded to the endpoint. */ - params?: Record; + /** + * Inputs. Either an array of ActionParam descriptors (collected from the user + * in a dialog before running — same as business-object actions), or a static + * key→value bag forwarded as the request body. + */ + params?: Record | unknown[]; + /** Success toast message (when no resultDialog). */ + successMessage?: string; + /** Error toast prefix shown when the action fails. */ + errorMessage?: string; + /** Render the API response in a result dialog (spec Action.resultDialog). */ + resultDialog?: Record; } export interface RichMetadataTypeEntry {