Skip to content

Commit e651c93

Browse files
authored
fix(app-shell): unwrap the declared response envelope on the datasource page and the api-action runner (objectstack#3843) (#2972)
framework#3843 moves four more route modules onto BaseResponseSchema, so /api/v1/datasources/*, /api/v1/packages/* and /api/settings/* now answer { success, data } / { success: false, error: { code, message } }. DatasourceResourcePage's shared api() helper returned the raw body, so all nine payload reads would have gone undefined and the page rendered empty; its error path stringified the new nested error object to "[object Object]". MetadataTypeActions — the generic `type: 'api'` action runner, so this affects every declared API action — had the same "[object Object]", plus a resultDialog would have bound its path fields against the envelope wrapper. Both now unwrap { success, data } centrally, and both shapes are accepted so a console build can talk to a backend on either side of the #3675 → #3689 → #3843 sequence. The packages readers needed no change (already `payload?.data ?? payload`). Three new MetadataTypeActions cases pin all three directions.
1 parent aecc934 commit e651c93

3 files changed

Lines changed: 133 additions & 7 deletions

File tree

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const mockFetch = vi.fn(async () => ({
1313
}));
1414
vi.mock('@object-ui/auth', () => ({ createAuthenticatedFetch: () => mockFetch }));
1515

16+
// Toasts are the only observable output of the error path.
17+
const toastError = vi.fn();
18+
const toastSuccess = vi.fn();
19+
vi.mock('sonner', () => ({ toast: { error: (...a: unknown[]) => toastError(...a), success: (...a: unknown[]) => toastSuccess(...a) } }));
20+
1621
// Stub the param dialog: when open, expose a button that resolves with values —
1722
// lets us drive the collect-params promise without the heavy field renderers.
1823
vi.mock('../ActionParamDialog', () => ({
@@ -31,7 +36,11 @@ vi.mock('../ActionResultDialog', () => ({
3136

3237
import { MetadataTypeActions } from './MetadataTypeActions';
3338

34-
beforeEach(() => mockFetch.mockClear());
39+
beforeEach(() => {
40+
mockFetch.mockClear();
41+
toastError.mockClear();
42+
toastSuccess.mockClear();
43+
});
3544

3645
describe('MetadataTypeActions', () => {
3746
it('runs an api action without params directly (no dialog)', async () => {
@@ -79,4 +88,72 @@ describe('MetadataTypeActions', () => {
7988
await waitFor(() => expect(screen.getByTestId('result-dialog')).toBeTruthy());
8089
expect(screen.getByTestId('result-dialog').textContent).toContain('done');
8190
});
91+
92+
// ── framework#3843: the declared `{ success, data }` envelope ──────────────
93+
// Service route modules (`/api/v1/datasources`, `/api/v1/packages`,
94+
// `/api/settings`, …) answer `BaseResponseSchema`, so an action's payload
95+
// arrives under `data`. Both shapes are accepted so this repo is not coupled
96+
// to the framework's module-by-module merge order.
97+
98+
it('unwraps the { success, data } envelope before binding the result dialog', async () => {
99+
mockFetch.mockResolvedValueOnce({
100+
ok: true,
101+
status: 200,
102+
statusText: 'OK',
103+
json: async () => ({ success: true, data: { message: 'connection ok', latencyMs: 7 } }),
104+
} as never);
105+
render(
106+
<MetadataTypeActions
107+
location="record_header"
108+
recordId="ds1"
109+
entry={{ actions: [{ name: 'probe', label: 'Probe', type: 'api', target: '/api/v1/x', locations: ['record_header'], resultDialog: { fields: [{ path: 'message' }] } }] }}
110+
/>,
111+
);
112+
fireEvent.click(screen.getByTitle('Probe'));
113+
await waitFor(() => expect(screen.getByTestId('result-dialog')).toBeTruthy());
114+
const shown = screen.getByTestId('result-dialog').textContent ?? '';
115+
expect(shown).toContain('connection ok');
116+
// The wrapper itself must not reach the dialog's `path` bindings.
117+
expect(JSON.parse(shown)).toEqual({ message: 'connection ok', latencyMs: 7 });
118+
});
119+
120+
it('reads the failure message out of the nested error object, not "[object Object]"', async () => {
121+
mockFetch.mockResolvedValueOnce({
122+
ok: false,
123+
status: 400,
124+
statusText: 'Bad Request',
125+
json: async () => ({ success: false, error: { code: 'datasource_admin_error', message: 'duplicate name' } }),
126+
} as never);
127+
render(
128+
<MetadataTypeActions
129+
location="record_header"
130+
recordId="ds1"
131+
entry={{ actions: [{ name: 'probe', label: 'Probe', type: 'api', target: '/api/v1/x', locations: ['record_header'] }] }}
132+
/>,
133+
);
134+
fireEvent.click(screen.getByTitle('Probe'));
135+
await waitFor(() => expect(toastError).toHaveBeenCalled());
136+
const msg = String(toastError.mock.calls[0][0]);
137+
expect(msg).toContain('duplicate name');
138+
expect(msg).not.toContain('[object Object]');
139+
});
140+
141+
it('still reads a pre-envelope bare-string error — merge order is not a coupling', async () => {
142+
mockFetch.mockResolvedValueOnce({
143+
ok: false,
144+
status: 503,
145+
statusText: 'Service Unavailable',
146+
json: async () => ({ error: 'datasource_admin_unavailable' }),
147+
} as never);
148+
render(
149+
<MetadataTypeActions
150+
location="record_header"
151+
recordId="ds1"
152+
entry={{ actions: [{ name: 'probe', label: 'Probe', type: 'api', target: '/api/v1/x', locations: ['record_header'] }] }}
153+
/>,
154+
);
155+
fireEvent.click(screen.getByTitle('Probe'));
156+
await waitFor(() => expect(toastError).toHaveBeenCalled());
157+
expect(String(toastError.mock.calls[0][0])).toContain('datasource_admin_unavailable');
158+
});
82159
});

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,39 @@ export function MetadataTypeActions({ entry, location, recordId, onAfter }: Meta
155155
if (method !== 'GET' && method !== 'DELETE') init.body = JSON.stringify(params);
156156

157157
const res = await authFetch(url, init);
158-
let data: Record<string, unknown> | null = null;
158+
let body: Record<string, unknown> | null = null;
159159
try {
160-
data = (await res.json()) as Record<string, unknown>;
160+
body = (await res.json()) as Record<string, unknown>;
161161
} catch {
162162
/* non-JSON / empty body — fall back to status text */
163163
}
164164

165-
if (!res.ok || (data && data.success === false)) {
165+
// framework#3843: the service route modules now answer the declared
166+
// `{ success, data }` envelope (`BaseResponseSchema`), so the payload a
167+
// `resultDialog` binds to — and the `message` the success toast reads —
168+
// lives under `data` rather than at the top level. Unwrap it here, in the
169+
// one place every `type: 'api'` action passes through.
170+
//
171+
// Deliberately tolerant of an already-unwrapped body: endpoints an action
172+
// may target are converted module by module (framework#3675 → #3689 →
173+
// #3843), so this repo must not be coupled to the merge order of that
174+
// sequence. Same reason the two attachment openers read
175+
// `body?.url ?? body?.data?.url` for framework#3689.
176+
const data =
177+
body && typeof body.success === 'boolean' && 'data' in body
178+
? (body.data as Record<string, unknown> | null)
179+
: body;
180+
181+
if (!res.ok || (body && body.success === false)) {
182+
// `error` is `{ code, message }` in the envelope, and was a bare string
183+
// before it — read both so a partially-converted backend still explains
184+
// itself instead of toasting "[object Object]".
185+
const err = body?.error as { message?: string } | string | undefined;
166186
const detail =
167-
(data?.error as string) || (data?.message as string) || `HTTP ${res.status} ${res.statusText}`.trim();
187+
(typeof err === 'object' && err !== null ? err.message : undefined) ||
188+
(typeof err === 'string' ? err : undefined) ||
189+
(body?.message as string) ||
190+
`HTTP ${res.status} ${res.statusText}`.trim();
168191
toast.error(`${action.errorMessage ? `${action.errorMessage}: ` : ''}${title}: ${detail}`);
169192
return;
170193
}

packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,40 @@ export function DatasourceResourcePage(_props: { type?: string }): React.ReactEl
172172
const [loading, setLoading] = React.useState(true);
173173
const [busy, setBusy] = React.useState<string | null>(null);
174174

175+
/**
176+
* Fetch + unwrap the declared response envelope.
177+
*
178+
* framework#3843 moved `/api/v1/datasources/*` onto `BaseResponseSchema`, so
179+
* every body is now `{ success, data }` / `{ success: false, error: { code,
180+
* message } }`. Unwrapping here keeps all nine call sites below reading their
181+
* payload key unchanged (`data.datasources`, `data.drivers`, `data.tables`,
182+
* `{ draft }`, …) — the keys did not change, only their depth.
183+
*
184+
* Both shapes are accepted on purpose, error and success alike. This is the
185+
* same tolerance the console already carries for framework#3689 (the
186+
* attachment openers' `body?.url ?? body?.data?.url`), and it exists so the
187+
* two repos are not coupled by merge order: a console on either side of the
188+
* framework change talks to a backend on either side of it. It is a migration
189+
* device, not a permanent second contract — the producer is the authority
190+
* (framework Prime Directive #12).
191+
*/
175192
const api = React.useCallback(
176193
async (path: string, init?: RequestInit) => {
177194
const res = await authFetch(`${SERVER}${path}`, { credentials: 'include', headers: { 'Content-Type': 'application/json' }, ...init });
178195
const text = await res.text();
179196
let body: any = text;
180197
try { body = JSON.parse(text); } catch { /* keep text */ }
181-
if (!res.ok) throw new Error((body && (body.message || body.error)) || `HTTP ${res.status}`);
182-
return body;
198+
if (!res.ok) {
199+
// `error` is `{ code, message }` post-#3843 and was a bare string
200+
// before it; without the first read this threw "[object Object]".
201+
const detail =
202+
(body && typeof body.error === 'object' && body.error?.message) ||
203+
(body && typeof body.error === 'string' ? body.error : undefined) ||
204+
(body && body.message) ||
205+
`HTTP ${res.status}`;
206+
throw new Error(detail);
207+
}
208+
return body && typeof body.success === 'boolean' && 'data' in body ? body.data : body;
183209
},
184210
[authFetch],
185211
);

0 commit comments

Comments
 (0)