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
2 changes: 2 additions & 0 deletions docs/design/json-editor-standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ These contexts have different actions, but they share one editing standard.
## Contextual Actions

- Payload JSON submits the complete create payload through the form workflow.
- Same-draft editors such as Payload JSON and Plugin JSON provide an explicit
apply action for reviewing valid JSON edits in the paired visual editor.
- Admin API JSON editors show identity fields separately as values managed by the
Admin API path. The editable JSON excludes read-only fields, sends changed
editable fields with PATCH, and verifies the saved resource with a follow-up
Expand Down
26 changes: 25 additions & 1 deletion e2e/tests/resource-required-templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
* limitations under the License.
*/
import { test } from '@e2e/utils/test';
import { uiGetMonacoEditor, uiSelectByLabel } from '@e2e/utils/ui';
import {
uiGetMonacoEditor,
uiSelectByLabel,
} from '@e2e/utils/ui';
import { expect, type Locator, type Page } from '@playwright/test';

const requiredFields = (page: Page) =>
Expand Down Expand Up @@ -55,6 +58,21 @@ test('create forms show conditional required fields and minimal JSON templates',
await page.locator('input[name="uri"]').fill('/orders');
await expect.poll(() => requiredFields(page)).not.toContain('uris');

await page.getByRole('tab', { name: 'Payload JSON' }).click();
await expect.poll(async () => {
const payload = JSON.parse(await readMonacoValue(page, routeJsonEditor)) as {
uri?: string;
};
return payload.uri;
}).toBe('/orders');

await page.getByRole('button', { name: 'Apply to Visual Editor' }).click();
await expect(page.getByRole('tab', { name: 'Visual Editor' })).toHaveAttribute(
'aria-selected',
'true'
);
await expect(page.locator('input[name="uri"]')).toHaveValue('/orders');

await page.goto('/ui/services/add');
await page.getByRole('tab', { name: 'Payload JSON' }).click();
await expect
Expand Down Expand Up @@ -177,4 +195,10 @@ test('plugin add JSON prefills required fields from APISIX schema', async ({
count: 1,
time_window: 1,
});

await addPluginDialog.getByRole('button', { name: 'Apply to Fields' }).click();
await expect(addPluginDialog.getByRole('tab', { name: 'Fields' })).toHaveAttribute(
'aria-selected',
'true'
);
});
37 changes: 29 additions & 8 deletions src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,23 +117,39 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => {
setActiveTab(canUseForm ? 'form' : 'json');
}, [canUseForm, name]);

const applyJsonToFields = useCallback(() => {
try {
const parsed = JSON.parse(methods.getValues('config') || '{}') as unknown;
if (!isRecord(parsed)) {
setSaveError('Plugin config must be a JSON object.');
return false;
}
setFormValue(parsed);
setSaveError(null);
return true;
} catch {
setSaveError('Fix the Plugin JSON syntax error before switching to Fields.');
return false;
}
}, [methods]);

const handleApplyJsonToFields = useCallback(() => {
if (applyJsonToFields()) {
setActiveTab('form');
}
}, [applyJsonToFields]);

const handleTabChange = useCallback((key: string) => {
if (key === 'json' && activeTab === 'form') {
// Serialize form values to JSON editor
methods.setValue('config', toConfigStr(formValue as object));
} else if (key === 'form' && activeTab === 'json') {
// Parse JSON editor to form values. If the JSON is currently invalid, stay
// on the JSON tab and surface the error rather than silently discarding edits.
try {
const parsed = JSON.parse(methods.getValues('config') || '{}') as Record<string, unknown>;
setFormValue(parsed);
} catch {
setSaveError('Fix the Plugin JSON syntax error before switching to Fields.');
if (!applyJsonToFields()) {
return;
}
}
setActiveTab(key);
}, [activeTab, formValue, methods]);
}, [activeTab, applyJsonToFields, formValue, methods]);

const handleFormChange = useCallback((val: Record<string, unknown>) => {
setFormValue(val);
Expand Down Expand Up @@ -412,6 +428,11 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => {
))}
{activeTab === 'json' && mode !== 'view' && (
<Space style={{ width: '100%', justifyContent: 'flex-end', marginBottom: 8 }}>
{canUseForm && (
<Button size="small" onClick={handleApplyJsonToFields}>
Apply to Fields
</Button>
)}
<Tooltip title="Format Plugin JSON">
<Button
size="small"
Expand Down
41 changes: 31 additions & 10 deletions src/components/form/FormJsonTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,27 @@ export const FormJsonTabs = (props: FormJsonTabsProps) => {
}
}, [doSubmit]);

const applyJsonToForm = useCallback(() => {
try {
const parsed = JSON.parse(jsonStr || '{}') as Record<string, unknown>;
const sanitizedParsed = rawData ? stripSystemReadonlyFields(parsed) : parsed;
form.reset(sanitizedParsed, { keepDefaultValues: true });

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 Preserve applied JSON when reopening Payload JSON

In create flows that pass a createJsonTemplate (routes, services, upstreams), this reset() clears formState.touchedFields. After a user edits Payload JSON, clicks the new Apply button, and then opens Payload JSON again without touching a visual field, handleTabChange still treats the form as untouched and rewrites jsonStr from the minimal template, discarding the JSON payload they just applied. Mark the form as no longer template-initial or preserve touched state after applying JSON so the next JSON tab render serializes form.getValues() instead of the template.

Useful? React with 👍 / 👎.

setJsonTabDirty(false);
setJsonError(null);
void form.trigger();
return true;
} catch (e) {
setJsonError('Invalid JSON: ' + String(e));
return false;
}
Comment on lines +415 to +427
}, [form, jsonStr, rawData]);

const handleApplyJsonToForm = useCallback(() => {
if (applyJsonToForm()) {
setActiveTab('form');
}
}, [applyJsonToForm]);

const handleTabChange = useCallback(
(key: string) => {
if (saveInProgress) return;
Expand All @@ -431,25 +452,17 @@ export const FormJsonTabs = (props: FormJsonTabsProps) => {
setJsonTabDirty(false);
setJsonError(null);
} else if (key === 'form' && activeTab === 'json') {
// Parse JSON editor back into form
try {
const parsed = JSON.parse(jsonStr || '{}') as Record<string, unknown>;
const sanitizedParsed = rawData ? stripSystemReadonlyFields(parsed) : parsed;
form.reset(sanitizedParsed, { keepDefaultValues: true });
setJsonTabDirty(false);
setJsonError(null);
} catch (e) {
setJsonError('Invalid JSON: ' + String(e));
if (!applyJsonToForm()) {
return;
}
}
setActiveTab(key);
},
[
activeTab,
applyJsonToForm,
createJsonTemplate,
form,
jsonStr,
rawData,
saveInProgress,
]
Expand Down Expand Up @@ -550,6 +563,7 @@ export const FormJsonTabs = (props: FormJsonTabsProps) => {
type="info"
showIcon
message="Create this resource by editing the same Payload JSON that the visual editor will validate and submit."
description="Apply JSON edits to review them in the Visual Editor before submitting."
style={{ marginBottom: 12, padding: '8px 12px', fontSize: 'var(--app-font-size-sm)' }}
/>
{schema && <JsonSchemaGuide schema={schema} value={jsonStr} compact />}
Expand Down Expand Up @@ -584,6 +598,13 @@ export const FormJsonTabs = (props: FormJsonTabsProps) => {
onFocusFirstError={focusFirstFormError}
onRevert={handleRevert}
>
<Button
size="middle"
disabled={isSubmitting || isSaving}
onClick={handleApplyJsonToForm}
>
Apply to Visual Editor
</Button>
<Button
type="primary"
size="middle"
Expand Down