Skip to content

Commit 3084fec

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): dialogs for metadata-type actions (params + result) (#1838)
Metadata type-level actions (MetadataTypeActions) previously only fired an api fetch + toast — no input dialog, unlike business-object actions. Add dialog support by reusing the shared runtime dialog components: - params declared as an array of ActionParam descriptors are now collected from the user via ActionParamDialog before the request (the collected values are the request body + ${param.X} token source); a static params object still forwards as-is (backward compatible). - actions declaring resultDialog render the API response in ActionResultDialog; otherwise a success toast (now honoring successMessage/errorMessage). - confirmText + auth-aware fetch + refreshAfter unchanged. This lets type-level actions (e.g. a future datasource 'sync objects' action) collect inputs and show rich results, matching business-object action UX. Tests: no-params direct run, array-params dialog → body, resultDialog reveal. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent af53b74 commit 3084fec

3 files changed

Lines changed: 143 additions & 6 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import * as React from 'react';
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
6+
7+
// Capture the authenticated fetch so we can assert request bodies.
8+
const mockFetch = vi.fn(async () => ({
9+
ok: true,
10+
status: 200,
11+
statusText: 'OK',
12+
json: async () => ({ success: true, message: 'done' }),
13+
}));
14+
vi.mock('@object-ui/auth', () => ({ createAuthenticatedFetch: () => mockFetch }));
15+
16+
// Stub the param dialog: when open, expose a button that resolves with values —
17+
// lets us drive the collect-params promise without the heavy field renderers.
18+
vi.mock('../ActionParamDialog', () => ({
19+
ActionParamDialog: ({ state }: any) =>
20+
state.open ? (
21+
<button type="button" data-testid="submit-params" onClick={() => state.resolve?.({ reason: 'because' })}>
22+
submit-params
23+
</button>
24+
) : null,
25+
}));
26+
// Stub the result dialog: surface its open state + data for assertions.
27+
vi.mock('../ActionResultDialog', () => ({
28+
ActionResultDialog: ({ state }: any) =>
29+
state.open ? <div data-testid="result-dialog">{JSON.stringify(state.data)}</div> : null,
30+
}));
31+
32+
import { MetadataTypeActions } from './MetadataTypeActions';
33+
34+
beforeEach(() => mockFetch.mockClear());
35+
36+
describe('MetadataTypeActions', () => {
37+
it('runs an api action without params directly (no dialog)', async () => {
38+
render(
39+
<MetadataTypeActions
40+
location="record_header"
41+
recordId="ds1"
42+
entry={{ actions: [{ name: 'test_connection', label: 'Test', type: 'api', target: '/api/v1/datasources/${ctx.recordId}/test', locations: ['record_header'] }] }}
43+
/>,
44+
);
45+
fireEvent.click(screen.getByTitle('Test'));
46+
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
47+
const [url] = mockFetch.mock.calls[0];
48+
expect(url).toContain('/api/v1/datasources/ds1/test');
49+
expect(screen.queryByTestId('submit-params')).toBeNull();
50+
});
51+
52+
it('collects array params via the dialog and sends them as the body', async () => {
53+
render(
54+
<MetadataTypeActions
55+
location="record_header"
56+
recordId="ds1"
57+
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[] }] }}
58+
/>,
59+
);
60+
fireEvent.click(screen.getByTitle('Sync'));
61+
// dialog opens (params present) — no fetch yet
62+
const submit = await screen.findByTestId('submit-params');
63+
expect(mockFetch).not.toHaveBeenCalled();
64+
fireEvent.click(submit);
65+
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
66+
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
67+
expect(JSON.parse(String(init.body))).toEqual({ reason: 'because' });
68+
});
69+
70+
it('shows the result dialog when the action declares resultDialog', async () => {
71+
render(
72+
<MetadataTypeActions
73+
location="record_header"
74+
recordId="ds1"
75+
entry={{ actions: [{ name: 'probe', label: 'Probe', type: 'api', target: '/api/v1/x', locations: ['record_header'], resultDialog: { fields: [{ path: 'message' }] } }] }}
76+
/>,
77+
);
78+
fireEvent.click(screen.getByTitle('Probe'));
79+
await waitFor(() => expect(screen.getByTestId('result-dialog')).toBeTruthy());
80+
expect(screen.getByTestId('result-dialog').textContent).toContain('done');
81+
});
82+
});

packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
* on :5180 and the backend on :3000). `${ctx.recordId}` / `${param.X}` tokens
2323
* in `target` are resolved here, exactly as the spec mandates renderers do.
2424
*
25+
* Dialogs: actions that declare an array of `params` collect them from the
26+
* user in the shared {@link ActionParamDialog} before running (same UX as
27+
* business-object actions); actions that declare a `resultDialog` render the
28+
* API response in {@link ActionResultDialog}. `confirmText` still gates the run.
29+
*
2530
* Only `type:'api'` is wired today; other types surface a toast so a
2631
* misconfigured action fails loud instead of silent.
2732
*/
@@ -31,7 +36,10 @@ import { toast } from 'sonner';
3136
import { Loader2 } from 'lucide-react';
3237
import { Button } from '@object-ui/components';
3338
import { createAuthenticatedFetch } from '@object-ui/auth';
39+
import type { ActionParamDef } from '@object-ui/core';
3440
import { getIcon } from '../../utils/getIcon';
41+
import { ActionParamDialog, type ParamDialogState } from '../ActionParamDialog';
42+
import { ActionResultDialog, type ResultDialogState } from '../ActionResultDialog';
3543
import type { MetadataTypeAction, RichMetadataTypeEntry } from './useMetadata';
3644

3745
/** Map the spec's action variants onto the Shadcn Button variants. */
@@ -81,6 +89,8 @@ export interface MetadataTypeActionsProps {
8189
*/
8290
export function MetadataTypeActions({ entry, location, recordId, onAfter }: MetadataTypeActionsProps): React.ReactElement | null {
8391
const [busy, setBusy] = React.useState<string | null>(null);
92+
const [paramState, setParamState] = React.useState<ParamDialogState>({ open: false, params: [] });
93+
const [resultState, setResultState] = React.useState<ResultDialogState>({ open: false });
8494
const authFetch = React.useMemo(() => createAuthenticatedFetch(), []);
8595

8696
const actions = React.useMemo(
@@ -93,6 +103,12 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta
93103

94104
if (actions.length === 0) return null;
95105

106+
/** Open the param dialog and resolve with the collected values (or null on cancel). */
107+
const collectParams = (params: ActionParamDef[], title?: string) =>
108+
new Promise<Record<string, unknown> | null>((resolve) => {
109+
setParamState({ open: true, params, title, resolve });
110+
});
111+
96112
const run = async (action: MetadataTypeAction) => {
97113
const title = action.label ?? action.name;
98114

@@ -105,7 +121,17 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta
105121

106122
if (action.confirmText && !window.confirm(action.confirmText)) return;
107123

108-
const params = action.params ?? {};
124+
// Inputs: an array of param descriptors → collect in a dialog; a static
125+
// object → forward as-is.
126+
let params: Record<string, unknown>;
127+
if (Array.isArray(action.params) && action.params.length > 0) {
128+
const collected = await collectParams(action.params as ActionParamDef[], title);
129+
if (collected == null) return; // user cancelled
130+
params = collected;
131+
} else {
132+
params = (action.params as Record<string, unknown> | undefined) ?? {};
133+
}
134+
109135
const ctx = { recordId, origin: window.location.origin };
110136
const resolved = interpolateTarget(action.target ?? '', ctx, params);
111137
if (!resolved) {
@@ -139,12 +165,17 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta
139165
if (!res.ok || (data && data.success === false)) {
140166
const detail =
141167
(data?.error as string) || (data?.message as string) || `HTTP ${res.status} ${res.statusText}`.trim();
142-
toast.error(`${title}: ${detail}`);
168+
toast.error(`${action.errorMessage ? `${action.errorMessage}: ` : ''}${title}: ${detail}`);
143169
return;
144170
}
145171

146-
const msg = typeof data?.message === 'string' ? (data.message as string) : `${title} ✓`;
147-
toast.success(msg);
172+
// Rich result reveal when declared, else a success toast.
173+
if (action.resultDialog) {
174+
setResultState({ open: true, spec: action.resultDialog as ResultDialogState['spec'], data: data ?? {} });
175+
} else {
176+
const msg = action.successMessage || (typeof data?.message === 'string' ? (data.message as string) : `${title} ✓`);
177+
toast.success(msg);
178+
}
148179
if (action.refreshAfter) onAfter?.();
149180
} catch (err) {
150181
toast.error(`${title}: ${(err as Error)?.message ?? String(err)}`);
@@ -176,6 +207,20 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta
176207
</Button>
177208
);
178209
})}
210+
211+
<ActionParamDialog
212+
state={paramState}
213+
onOpenChange={(open) => {
214+
if (!open) {
215+
paramState.resolve?.(null);
216+
setParamState({ open: false, params: [] });
217+
}
218+
}}
219+
/>
220+
<ActionResultDialog
221+
state={resultState}
222+
onAcknowledge={() => setResultState({ open: false })}
223+
/>
179224
</>
180225
);
181226
}

packages/app-shell/src/views/metadata-admin/useMetadata.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,18 @@ export interface MetadataTypeAction {
5050
confirmText?: string;
5151
/** Reload the view after a successful run. */
5252
refreshAfter?: boolean;
53-
/** Static request body / param bag forwarded to the endpoint. */
54-
params?: Record<string, unknown>;
53+
/**
54+
* Inputs. Either an array of ActionParam descriptors (collected from the user
55+
* in a dialog before running — same as business-object actions), or a static
56+
* key→value bag forwarded as the request body.
57+
*/
58+
params?: Record<string, unknown> | unknown[];
59+
/** Success toast message (when no resultDialog). */
60+
successMessage?: string;
61+
/** Error toast prefix shown when the action fails. */
62+
errorMessage?: string;
63+
/** Render the API response in a result dialog (spec Action.resultDialog). */
64+
resultDialog?: Record<string, unknown>;
5565
}
5666

5767
export interface RichMetadataTypeEntry {

0 commit comments

Comments
 (0)