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
65 changes: 64 additions & 1 deletion e2e/tests/routes.edit-payload-preservation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { randomId } from '@e2e/utils/common';
import { e2eReq } from '@e2e/utils/req';
import { test } from '@e2e/utils/test';
import { uiGoto } from '@e2e/utils/ui';
import { uiFillMonacoEditor, uiGoto } from '@e2e/utils/ui';
import { expect, type Request } from '@playwright/test';

import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
Expand All @@ -26,6 +26,11 @@ import { API_ROUTES } from '@/config/constant';
const routeId = randomId('route-payload-preserve');
const routeUri = '/route-payload-preserve';
const updatedDesc = 'updated through form while preserving raw payload';
const rawDraftDesc = 'raw json draft that will fail once';
const rawLatestDesc = 'latest server value after failed raw save';

const readMonacoValue = async (page: Parameters<typeof uiGoto>[0]) =>
page.evaluate(() => window.__monacoEditor__?.getValue() ?? '');

test.beforeAll(async () => {
await deleteAllRoutes(e2eReq);
Expand Down Expand Up @@ -107,3 +112,61 @@ test('route form save preserves raw payload and strips readonly fields', async (
},
});
});

test('raw JSON save failure offers reset and reload recovery actions', async ({
page,
}) => {
Comment on lines +116 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark shared route tests as serial

Because playwright.config.ts enables fullyParallel: true, adding this second test lets the two tests in this file run in separate workers while each worker's file-level setup/teardown still calls deleteAllRoutes. When those workers overlap, this test's setup or teardown can delete the route the other test is currently editing (or vice versa), so the suite becomes flaky outside the single-worker command used in the commit; mark this file/describe as serial or avoid global route cleanup for these shared-resource tests.

Useful? React with 👍 / 👎.

await uiGoto(page, '/routes/detail/$id', { id: routeId });
await page.getByRole('tab', { name: 'Raw JSON' }).click();

const rawJsonPanel = page.getByRole('tabpanel', { name: 'Raw JSON' });
const editor = rawJsonPanel.locator('.monaco-editor').first();
await expect(editor).toBeVisible();
await uiFillMonacoEditor(
page,
editor,
JSON.stringify({
uri: routeUri,
desc: rawDraftDesc,
})
);

let patchAttempts = 0;
await page.route(`**${API_ROUTES}/${routeId}`, async (route) => {
if (route.request().method() === 'PATCH') {
patchAttempts += 1;
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error_msg: 'temporary raw save failure' }),
});
return;
}
await route.continue();
});

await page.getByRole('button', { name: 'Save Changes' }).click();

await expect(
page.getByRole('alert').filter({ hasText: 'Save failed' }).first()
).toBeVisible();
await expect(page.getByRole('button', { name: 'Reset draft' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Reload latest' })).toBeVisible();
expect(patchAttempts).toBe(1);

await e2eReq.put(`${API_ROUTES}/${routeId}`, {
uri: routeUri,
desc: rawLatestDesc,
methods: ['GET'],
plugins: {
'response-rewrite': {
body: 'latest response body',
},
},
});

await page.getByRole('button', { name: 'Reload latest' }).click();
await expect
.poll(() => readMonacoValue(page))
.toContain(`"desc": "${rawLatestDesc}"`);
});
48 changes: 44 additions & 4 deletions src/components/page/AdminApiJsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,37 @@ export const AdminApiJsonEditor = ({
userEditedRef.current = false;
}, [api]);

const handleResetDraft = useCallback(() => {
userEditedRef.current = false;
setValue(original);
setError(null);
setSaveFeedback(null);
}, [original]);

const handleReloadLatest = useCallback(async () => {
if (!api || saving) return;
setLoading(true);
setError(null);
setSaveFeedback(null);
try {
const res = await req.get(api);
const data = res.data?.value as Record<string, unknown> | undefined;
if (!isRecord(data)) {
throw new Error('Admin API returned no resource value');
}
loadData(data);
setSaveFeedback({
type: 'success',
message: 'Reloaded latest APISIX resource state.',
at: new Date().toLocaleTimeString(),
});
Comment on lines +206 to +210
} catch (e) {
setError('Reload failed: ' + getAdminApiErrorMessage(e));
} finally {
setLoading(false);
}
}, [api, loadData, saving]);

useEffect(() => {
if (!active || !api) return;

Expand Down Expand Up @@ -413,6 +444,18 @@ export const AdminApiJsonEditor = ({
type="error"
showIcon
message={<div style={{ whiteSpace: 'pre-wrap', fontFamily: 'var(--app-font-monospace)', fontSize: 'var(--app-font-size-sm)' }}>{error}</div>}
action={
!disabled && (
<Space>
<Button size="small" onClick={handleResetDraft} disabled={!isDirty || saving}>
Reset draft
</Button>
<Button size="small" onClick={handleReloadLatest} loading={loading}>
Reload latest
</Button>
</Space>
)
}
style={{ marginBottom: 12 }}
closable
onClose={() => setError(null)}
Expand Down Expand Up @@ -490,10 +533,7 @@ export const AdminApiJsonEditor = ({
</Tooltip>
<Button
size="small"
onClick={() => {
userEditedRef.current = false;
setValue(original);
}}
onClick={handleResetDraft}
disabled={!isDirty}
>
Comment on lines 534 to 538
Reset
Expand Down