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 {