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
Original file line number Diff line number Diff line change
@@ -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 ? (
<button type="button" data-testid="submit-params" onClick={() => state.resolve?.({ reason: 'because' })}>
submit-params
</button>
) : null,
}));
// Stub the result dialog: surface its open state + data for assertions.
vi.mock('../ActionResultDialog', () => ({
ActionResultDialog: ({ state }: any) =>
state.open ? <div data-testid="result-dialog">{JSON.stringify(state.data)}</div> : null,
}));

import { MetadataTypeActions } from './MetadataTypeActions';

beforeEach(() => mockFetch.mockClear());

describe('MetadataTypeActions', () => {
it('runs an api action without params directly (no dialog)', async () => {
render(
<MetadataTypeActions
location="record_header"
recordId="ds1"
entry={{ actions: [{ name: 'test_connection', label: 'Test', type: 'api', target: '/api/v1/datasources/${ctx.recordId}/test', locations: ['record_header'] }] }}
/>,
);
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(
<MetadataTypeActions
location="record_header"
recordId="ds1"
entry={{ actions: [{ name: 'sync', label: 'Sync', type: 'api', target: '/api/v1/datasources/${ctx.recordId}/sync', locations: ['record_header'], params: [{ name: 'reason', label: 'Reason', type: 'text' }] as unknown[] }] }}
/>,
);
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(
<MetadataTypeActions
location="record_header"
recordId="ds1"
entry={{ actions: [{ name: 'probe', label: 'Probe', type: 'api', target: '/api/v1/x', locations: ['record_header'], resultDialog: { fields: [{ path: 'message' }] } }] }}
/>,
);
fireEvent.click(screen.getByTitle('Probe'));
await waitFor(() => expect(screen.getByTestId('result-dialog')).toBeTruthy());
expect(screen.getByTestId('result-dialog').textContent).toContain('done');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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. */
Expand Down Expand Up @@ -81,6 +89,8 @@ export interface MetadataTypeActionsProps {
*/
export function MetadataTypeActions({ entry, location, recordId, onAfter }: MetadataTypeActionsProps): React.ReactElement | null {
const [busy, setBusy] = React.useState<string | null>(null);
const [paramState, setParamState] = React.useState<ParamDialogState>({ open: false, params: [] });
const [resultState, setResultState] = React.useState<ResultDialogState>({ open: false });
const authFetch = React.useMemo(() => createAuthenticatedFetch(), []);

const actions = React.useMemo(
Expand All @@ -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<Record<string, unknown> | null>((resolve) => {
setParamState({ open: true, params, title, resolve });
});

const run = async (action: MetadataTypeAction) => {
const title = action.label ?? action.name;

Expand All @@ -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<string, unknown>;
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<string, unknown> | undefined) ?? {};
}

const ctx = { recordId, origin: window.location.origin };
const resolved = interpolateTarget(action.target ?? '', ctx, params);
if (!resolved) {
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -176,6 +207,20 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta
</Button>
);
})}

<ActionParamDialog
state={paramState}
onOpenChange={(open) => {
if (!open) {
paramState.resolve?.(null);
setParamState({ open: false, params: [] });
}
}}
/>
<ActionResultDialog
state={resultState}
onAcknowledge={() => setResultState({ open: false })}
/>
</>
);
}
14 changes: 12 additions & 2 deletions packages/app-shell/src/views/metadata-admin/useMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
/**
* 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<string, unknown> | 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<string, unknown>;
}

export interface RichMetadataTypeEntry {
Expand Down
Loading