This is an automated email from the ASF dual-hosted git repository.
baoyuan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new dd641c9b0 fix: preserve plugins with empty config using targeted
approach (#3277)
dd641c9b0 is described below
commit dd641c9b09defc3088cf171fa438d50e44e1f6d3
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Mon Apr 13 19:23:35 2026 +0530
fix: preserve plugins with empty config using targeted approach (#3277)
---
e2e/pom/stream_routes.ts | 2 +-
e2e/server/apisix_conf.yml | 5 +
e2e/tests/plugin_metadata.crud-all-fields.spec.ts | 3 +-
e2e/tests/protos.crud-all-fields.spec.ts | 5 +-
e2e/tests/protos.crud-required-fields.spec.ts | 4 +-
e2e/tests/routes.crud-all-fields.spec.ts | 11 +-
e2e/tests/routes.empty-plugin-config.spec.ts | 257 +++++++++++++++++++++
e2e/tests/services.crud-required-fields.spec.ts | 3 +
e2e/tests/services.stream_routes.crud.spec.ts | 1 -
e2e/tests/stream_routes.crud-all-fields.spec.ts | 3 +
.../stream_routes.crud-required-fields.spec.ts | 3 +
e2e/tests/upstreams.empty-discovery-args.spec.ts | 117 ++++++++++
e2e/utils/ui/index.ts | 29 ++-
e2e/utils/ui/upstreams.ts | 10 +-
.../FormItemPlugins/PluginEditorDrawer.tsx | 1 +
src/components/form-slice/FormPartRoute/util.ts | 2 +
.../{FormPartRoute => FormPartStreamRoute}/util.ts | 37 +--
.../FormPartUpstream/FormSectionDiscovery.tsx | 1 +
src/components/form-slice/FormPartUpstream/util.ts | 50 ++++
src/components/form/Editor.tsx | 17 +-
src/components/form/JsonInput.tsx | 1 +
src/config/req.ts | 5 +-
src/routes/routes/detail.$id.tsx | 22 +-
src/routes/services/add.tsx | 5 +-
src/routes/services/detail.$id/index.tsx | 3 +-
src/routes/stream_routes/add.tsx | 4 +-
src/routes/upstreams/add.tsx | 9 +-
src/routes/upstreams/detail.$id.tsx | 36 ++-
src/utils/producer.ts | 37 ++-
29 files changed, 614 insertions(+), 69 deletions(-)
diff --git a/e2e/pom/stream_routes.ts b/e2e/pom/stream_routes.ts
index ce78e11e7..fa2077f61 100644
--- a/e2e/pom/stream_routes.ts
+++ b/e2e/pom/stream_routes.ts
@@ -47,7 +47,7 @@ const assert = {
const title = page.getByRole('heading', {
name: 'Stream Route Detail',
});
- await expect(title).toBeVisible({ timeout: 20000 });
+ await expect(title).toBeVisible({ timeout: 30000 });
},
};
diff --git a/e2e/server/apisix_conf.yml b/e2e/server/apisix_conf.yml
index 94532cf57..7d75f9436 100644
--- a/e2e/server/apisix_conf.yml
+++ b/e2e/server/apisix_conf.yml
@@ -40,3 +40,8 @@ deployment:
- "http://etcd:2379" # multiple etcd address
prefix: "/apisix" # apisix configurations prefix
timeout: 30 # 30 seconds
+
+discovery:
+ nacos:
+ host:
+ - "http://nacos:8848"
diff --git a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
index 907c530c5..4774f6720 100644
--- a/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
+++ b/e2e/tests/plugin_metadata.crud-all-fields.spec.ts
@@ -56,7 +56,7 @@ const getMonacoEditorValue = async (editPluginDialog:
Locator) => {
if (!editorValue || editorValue.trim() === '{') {
const allText = await editPluginDialog.textContent();
- console.log('DEBUG: editorValue fallback failed, dialog text:', allText);
+ void allText; // fallback failed, editor value unavailable
}
return editorValue;
};
@@ -64,7 +64,6 @@ const getMonacoEditorValue = async (editPluginDialog:
Locator) => {
// Helper function to close edit dialog
const closeEditDialog = async (editPluginDialog: Locator) => {
const buttons = await editPluginDialog.locator('button').allTextContents();
- console.log('DEBUG: Edit Plugin dialog buttons:', buttons);
let closed = false;
for (const [i, name] of buttons.entries()) {
if (name.trim().toLowerCase() === 'cancel') {
diff --git a/e2e/tests/protos.crud-all-fields.spec.ts
b/e2e/tests/protos.crud-all-fields.spec.ts
index 3e14854dd..1c7805fee 100644
--- a/e2e/tests/protos.crud-all-fields.spec.ts
+++ b/e2e/tests/protos.crud-all-fields.spec.ts
@@ -74,8 +74,9 @@ test.describe('CRUD proto with all fields', () => {
const createdProto = protos.list.find((p) =>
p.value.content?.includes('package test;')
);
- expect(createdProto).toBeDefined();
expect(createdProto!.value.id).toBeDefined();
-
+ expect(createdProto).toBeDefined();
+ expect(createdProto?.value.id).toBeDefined();
+
createdProtoId = createdProto!.value.id;
// Verify content matches
diff --git a/e2e/tests/protos.crud-required-fields.spec.ts
b/e2e/tests/protos.crud-required-fields.spec.ts
index f8c8a0773..0310355b3 100644
--- a/e2e/tests/protos.crud-required-fields.spec.ts
+++ b/e2e/tests/protos.crud-required-fields.spec.ts
@@ -74,8 +74,8 @@ test.describe('CRUD proto with required fields only', () => {
p.value.content?.includes('package test_required')
);
expect(createdProto).toBeDefined();
- expect(createdProto!.value.id).toBeDefined();
-
+ expect(createdProto?.value.id).toBeDefined();
+
createdProtoId = createdProto!.value.id;
// Verify content matches
diff --git a/e2e/tests/routes.crud-all-fields.spec.ts
b/e2e/tests/routes.crud-all-fields.spec.ts
index f4e8dc3e2..4f1604a4b 100644
--- a/e2e/tests/routes.crud-all-fields.spec.ts
+++ b/e2e/tests/routes.crud-all-fields.spec.ts
@@ -49,7 +49,7 @@ test.beforeAll(async () => {
test('should CRUD route with all fields', async ({ page }) => {
test.slow();
- const varsSection = page.getByText('Vars').locator('..');
+ const varsSection = page.getByRole('group', { name: 'Match Rules'
}).getByText('Vars').locator('..');
// Navigate to the route list page
await routesPom.toIndex(page);
@@ -172,7 +172,8 @@ test('should CRUD route with all fields', async ({ page })
=> {
).toBeVisible();
// clear the editor, will show JSON format is not valid
- await uiClearMonacoEditor(page);
+ const realIpEditor = addPluginDialog.locator('.monaco-editor').first();
+ await uiClearMonacoEditor(page, realIpEditor);
await expect(
addPluginDialog.getByText('JSON format is not valid')
).toBeVisible();
@@ -262,7 +263,8 @@ test('should CRUD route with all fields', async ({ page })
=> {
await expect(status).toHaveValue('Disabled');
// Verify Vars field
- await expect(varsSection.getByText('arg_name')).toBeVisible();
+ await uiGetMonacoEditor(page, varsSection, false);
+ await expect(varsSection.getByText('arg_name')).toBeVisible({ timeout:
15000 });
await expect(varsSection.getByText('json')).toBeVisible();
// Verify Plugins
@@ -333,7 +335,8 @@ test('should CRUD route with all fields', async ({ page })
=> {
).toHaveValue('200');
// Verify updated Vars field
- await expect(varsSection.getByText('arg_name')).toBeVisible();
+ await uiGetMonacoEditor(page, varsSection, false);
+ await expect(varsSection.getByText('arg_name')).toBeVisible({ timeout:
15000 });
await expect(varsSection.getByText('updated')).toBeVisible();
// Return to list page and verify the route exists
diff --git a/e2e/tests/routes.empty-plugin-config.spec.ts
b/e2e/tests/routes.empty-plugin-config.spec.ts
new file mode 100644
index 000000000..bff6a2a05
--- /dev/null
+++ b/e2e/tests/routes.empty-plugin-config.spec.ts
@@ -0,0 +1,257 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import {
+ uiFillMonacoEditor,
+ uiGetMonacoEditor,
+ uiHasToastMsg,
+} from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+const routeNameWithEmptyPlugin = randomId('test-route-empty-plugin');
+const routeUri = '/test-empty-plugin';
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test('should preserve plugin with empty configuration (key-auth) after edit',
async ({
+ page,
+}) => {
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+
+ await test.step('create route with key-auth plugin having configuration',
async () => {
+ await routesPom.getAddRouteBtn(page).click();
+ await routesPom.isAddPage(page);
+
+ // Fill in required fields
+ await page.getByLabel('Name', { exact: true
}).first().fill(routeNameWithEmptyPlugin);
+ await page.getByLabel('URI', { exact: true }).fill(routeUri);
+
+ // Select HTTP method
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+
+ // Add upstream nodes
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(upstreamSection, {
+ nodes: [
+ { host: 'httpbin.org', port: 80, weight: 1 },
+ { host: 'httpbin.org', port: 80, weight: 1 },
+ ],
+ name: 'test-upstream-empty-plugin',
+ });
+
+ // Add key-auth plugin with some configuration first
+ const selectPluginsBtn = page.getByRole('button', {
+ name: 'Select Plugins',
+ });
+ await selectPluginsBtn.click();
+
+ const selectPluginsDialog = page.getByRole('dialog', {
+ name: 'Select Plugins',
+ });
+ const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+ await searchInput.fill('key-auth');
+
+ await selectPluginsDialog
+ .getByTestId('plugin-key-auth')
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ // Add plugin with initial configuration
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog);
+ await uiFillMonacoEditor(page, pluginEditor, '{"hide_credentials": true}');
+ await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+ await expect(addPluginDialog).toBeHidden();
+
+ // Verify the plugin was added
+ const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+ await expect(pluginsSection.getByTestId('plugin-key-auth')).toBeVisible();
+
+ // Submit the form
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, {
+ hasText: 'Add Route Successfully',
+ });
+ });
+
+ await test.step('verify key-auth plugin is visible in detail page', async ()
=> {
+ await routesPom.isDetailPage(page);
+
+ // Verify the plugin is visible
+ await expect(page.getByTestId('plugin-key-auth')).toBeVisible();
+
+ // Verify the route name
+ const name = page.getByLabel('Name', { exact: true }).first();
+ await expect(name).toHaveValue(routeNameWithEmptyPlugin);
+ });
+
+ await test.step('edit plugin to have empty configuration and verify it
persists', async () => {
+ // Click the Edit button in the detail page
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify we're in edit mode
+ const nameField = page.getByLabel('Name', { exact: true }).first();
+ await expect(nameField).toBeEnabled();
+
+ // Verify the key-auth plugin is visible in edit mode
+ const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+ const keyAuthPlugin = pluginsSection.getByTestId('plugin-key-auth');
+ await expect(keyAuthPlugin).toBeVisible();
+
+ // Click edit on the plugin
+ await keyAuthPlugin.getByRole('button', { name: 'Edit' }).click();
+
+ // Edit plugin to have empty configuration
+ const editPluginDialog = page.getByRole('dialog', { name: 'Edit Plugin' });
+ const pluginEditor = await uiGetMonacoEditor(page, editPluginDialog);
+
+ // Clear the editor and set empty object
+ await uiFillMonacoEditor(page, pluginEditor, '{}');
+
+ // Save the plugin
+ await editPluginDialog.getByRole('button', { name: 'Save' }).click();
+ await expect(editPluginDialog).toBeHidden();
+
+ // Verify the plugin is still visible after making config empty
+ await expect(keyAuthPlugin).toBeVisible();
+
+ // Update the description field to trigger a change
+ const descriptionField = page.getByLabel('Description').first();
+ await descriptionField.fill('Updated description after emptying key-auth
config');
+
+ // Click the Save button to save changes
+ const saveBtn = page.getByRole('button', { name: 'Save' });
+ await saveBtn.click();
+
+ // Verify the update was successful
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Verify we're back in detail view mode
+ await routesPom.isDetailPage(page);
+
+ // Verify the key-auth plugin is STILL visible after save (critical check
for the fix)
+ await expect(page.getByTestId('plugin-key-auth')).toBeVisible();
+ });
+});
+
+test('should preserve plugin with empty configuration (key-auth) during
creation', async ({
+ page,
+}) => {
+ const routeNameWithInitialEmptyPlugin =
randomId('test-route-create-empty-plugin');
+ const routeUriCreate = '/test-create-empty-plugin';
+
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+
+ await test.step('create route with key-auth plugin having empty
configuration', async () => {
+ await routesPom.getAddRouteBtn(page).click();
+ await routesPom.isAddPage(page);
+
+ // Fill in required fields
+ await page.getByLabel('Name', { exact: true
}).first().fill(routeNameWithInitialEmptyPlugin);
+ await page.getByLabel('URI', { exact: true }).fill(routeUriCreate);
+
+ // Select HTTP method
+ await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+ await page.getByRole('option', { name: 'GET' }).click();
+
+ // Add upstream nodes
+ const upstreamSection = page.getByRole('group', {
+ name: 'Upstream',
+ exact: true,
+ });
+ await uiFillUpstreamRequiredFields(upstreamSection, {
+ nodes: [
+ { host: 'httpbin.org', port: 80, weight: 1 },
+ { host: 'httpbin.org', port: 80, weight: 1 },
+ ],
+ name: 'test-upstream-create-empty-plugin',
+ });
+
+ // Add key-auth plugin with empty configuration
+ const selectPluginsBtn = page.getByRole('button', {
+ name: 'Select Plugins',
+ });
+ await selectPluginsBtn.click();
+
+ const selectPluginsDialog = page.getByRole('dialog', {
+ name: 'Select Plugins',
+ });
+ const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+ await searchInput.fill('key-auth');
+
+ await selectPluginsDialog
+ .getByTestId('plugin-key-auth')
+ .getByRole('button', { name: 'Add' })
+ .click();
+
+ // Add plugin with empty configuration
+ const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+ const pluginEditor = await uiGetMonacoEditor(page, addPluginDialog);
+ await uiFillMonacoEditor(page, pluginEditor, '{}');
+ await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+ await expect(addPluginDialog).toBeHidden();
+
+ // Verify the plugin was added in the form
+ const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+ await expect(pluginsSection.getByTestId('plugin-key-auth')).toBeVisible();
+
+ // Submit the form
+ await routesPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, {
+ hasText: 'Add Route Successfully',
+ });
+ });
+
+ await test.step('verify key-auth plugin is visible in detail page after
creation', async () => {
+ await routesPom.isDetailPage(page);
+
+ // Verify the plugin is visible
+ await expect(page.getByTestId('plugin-key-auth')).toBeVisible();
+
+ // Verify the route name
+ const name = page.getByLabel('Name', { exact: true }).first();
+ await expect(name).toHaveValue(routeNameWithInitialEmptyPlugin);
+
+ // Open the plugin in view mode and verify its config is the empty object
{}
+ const pluginCard = page.getByTestId('plugin-key-auth');
+ await pluginCard.getByRole('button', { name: 'View' }).click();
+ const viewPluginDialog = page.getByRole('dialog', { name: 'View Plugin' });
+ await uiGetMonacoEditor(page, viewPluginDialog, false);
+ // Read Monaco editor content from the visible lines (not the textarea)
+ const viewContent = await viewPluginDialog
+ .locator('.view-lines')
+ .first()
+ .innerText();
+ expect(viewContent.replace(/\s/g, '')).toBe('{}');
+ });
+});
diff --git a/e2e/tests/services.crud-required-fields.spec.ts
b/e2e/tests/services.crud-required-fields.spec.ts
index ede2ac2c6..cc94d91c9 100644
--- a/e2e/tests/services.crud-required-fields.spec.ts
+++ b/e2e/tests/services.crud-required-fields.spec.ts
@@ -57,6 +57,9 @@ test('should CRUD service with required fields', async ({
page }) => {
await rows.first().locator('input').first().fill('127.0.0.1');
await rows.first().locator('input').nth(1).fill('80');
await rows.first().locator('input').nth(2).fill('1');
+ await rows.first().locator('input').nth(2).blur();
+ // eslint-disable-next-line playwright/no-wait-for-timeout
+ await page.waitForTimeout(500);
// Ensure the name field is properly filled before submitting
const nameField = page.getByRole('textbox', { name: 'Name' }).first();
diff --git a/e2e/tests/services.stream_routes.crud.spec.ts
b/e2e/tests/services.stream_routes.crud.spec.ts
index cb169e333..e559decb0 100644
--- a/e2e/tests/services.stream_routes.crud.spec.ts
+++ b/e2e/tests/services.stream_routes.crud.spec.ts
@@ -129,7 +129,6 @@ test('should CRUD stream route under service', async ({
page }) => {
const saveBtn = page.getByRole('button', { name: 'Save' });
await saveBtn.click();
- // Verify the update was successful
await uiHasToastMsg(page, {
hasText: 'success',
});
diff --git a/e2e/tests/stream_routes.crud-all-fields.spec.ts
b/e2e/tests/stream_routes.crud-all-fields.spec.ts
index 4da6fef3b..1d7a94bbe 100644
--- a/e2e/tests/stream_routes.crud-all-fields.spec.ts
+++ b/e2e/tests/stream_routes.crud-all-fields.spec.ts
@@ -66,14 +66,17 @@ test('CRUD stream route with all fields', async ({ page })
=> {
const hostInput = firstRow.locator('input').nth(0);
await hostInput.click();
await hostInput.fill('127.0.0.11');
+ await hostInput.press('Tab');
const portInput = firstRow.locator('input').nth(1);
await portInput.click();
await portInput.fill('8081');
+ await portInput.press('Tab');
const weightInput = firstRow.locator('input').nth(2);
await weightInput.click();
await weightInput.fill('100');
+ await weightInput.press('Tab');
// Submit and land on detail page
await page.getByRole('button', { name: 'Add', exact: true }).click();
diff --git a/e2e/tests/stream_routes.crud-required-fields.spec.ts
b/e2e/tests/stream_routes.crud-required-fields.spec.ts
index 94593c1c9..2bb7a2d9a 100644
--- a/e2e/tests/stream_routes.crud-required-fields.spec.ts
+++ b/e2e/tests/stream_routes.crud-required-fields.spec.ts
@@ -59,14 +59,17 @@ test('CRUD stream route with required fields', async ({
page }) => {
const hostInput = firstRow.locator('input').nth(0);
await hostInput.click();
await hostInput.fill('127.0.0.2');
+ await hostInput.press('Tab');
const portInput = firstRow.locator('input').nth(1);
await portInput.click();
await portInput.fill('8080');
+ await portInput.press('Tab');
const weightInput = firstRow.locator('input').nth(2);
await weightInput.click();
await weightInput.fill('1');
+ await weightInput.press('Tab');
// Submit and land on detail page
await page.getByRole('button', { name: 'Add', exact: true }).click();
diff --git a/e2e/tests/upstreams.empty-discovery-args.spec.ts
b/e2e/tests/upstreams.empty-discovery-args.spec.ts
new file mode 100644
index 000000000..3f08fbff9
--- /dev/null
+++ b/e2e/tests/upstreams.empty-discovery-args.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import { API_UPSTREAMS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+/**
+ * Test that editing an upstream with service discovery and empty
discovery_args
+ * does not cause the discovery_args field to be dropped.
+ * Regression test for: https://github.com/apache/apisix-dashboard/issues/3270
+ */
+const upstream: APISIXType['Upstream'] = {
+ id: 'upstream_discovery_test',
+ name: 'upstream_discovery_test',
+ desc: 'Upstream with service discovery',
+ discovery_type: 'nacos',
+ service_name: 'test-service',
+ discovery_args: {},
+};
+
+test.beforeAll(async () => {
+ await deleteAllUpstreams(e2eReq);
+ await putUpstreamReq(e2eReq, upstream);
+});
+
+test.afterAll(async () => {
+ await e2eReq.delete(`${API_UPSTREAMS}/${upstream.id}`);
+});
+
+test('should preserve empty discovery_args after editing upstream', async ({
+ page,
+}) => {
+ await test.step('verify upstream exists and has discovery fields', async ()
=> {
+ // Verify upstream was created with discovery_args via API
+ const resp = await e2eReq.get(`${API_UPSTREAMS}/${upstream.id}`);
+ const data = resp.data;
+ expect(data.value.discovery_type).toBe('nacos');
+ expect(data.value.service_name).toBe('test-service');
+ expect(data.value.discovery_args).toEqual({});
+ });
+
+ await test.step('navigate to upstream detail page', async () => {
+ await upstreamsPom.toIndex(page);
+ await upstreamsPom.isIndexPage(page);
+
+ // Click on the upstream to view details
+ const row = page.locator('tr').filter({ hasText: upstream.name });
+ await expect(row).toBeVisible();
+ await row.getByRole('button', { name: 'View' }).click();
+ await upstreamsPom.isDetailPage(page);
+ });
+
+ await test.step('edit upstream description and save', async () => {
+ // Click Edit button
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ // Verify we're in edit mode
+ const nameField = page.getByLabel('Name', { exact: true }).first();
+ await expect(nameField).toBeEnabled();
+
+ // Verify discovery fields are visible
+ const discoverySection = page.getByRole('group', {
+ name: 'Service Discovery',
+ });
+ await expect(discoverySection).toBeVisible();
+
+ // Update the description to trigger a change
+ const descriptionField = page.getByLabel('Description').first();
+ await descriptionField.fill(
+ 'Updated description to verify discovery_args persists'
+ );
+
+ // Save the changes
+ const saveBtn = page.getByRole('button', { name: 'Save' });
+ await saveBtn.click();
+
+ // Verify the update was successful
+ await uiHasToastMsg(page, {
+ hasText: 'success',
+ });
+
+ // Verify we're back in detail view mode
+ await upstreamsPom.isDetailPage(page);
+ });
+
+ await test.step('verify discovery_args is preserved after edit', async () =>
{
+ // Verify via API that discovery_args was not dropped
+ const resp = await e2eReq.get(`${API_UPSTREAMS}/${upstream.id}`);
+ const data = resp.data;
+ expect(data.value.discovery_type).toBe('nacos');
+ expect(data.value.service_name).toBe('test-service');
+ expect(data.value.discovery_args).toEqual({});
+ expect(data.value.desc).toBe(
+ 'Updated description to verify discovery_args persists'
+ );
+ });
+});
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index cfb491690..764a78542 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -41,7 +41,15 @@ export const uiHasToastMsg = async (
) => {
const alertMsg = page.getByRole('alert').filter(...filterOpts);
// Increased timeout for CI environment (30s instead of default 5s)
- await expect(alertMsg).toBeVisible({ timeout: 30000 });
+ try {
+ await expect(alertMsg).toBeVisible({ timeout: 25000 });
+ } catch (e) {
+ const alerts = await page.getByRole('alert').all();
+ const texts = await Promise.all(alerts.map((a) => a.textContent()));
+ console.error(`Failed to find toast with filter options:
${JSON.stringify(filterOpts)}`);
+ console.error(`Currently visible alerts: ${JSON.stringify(texts)}`);
+ throw e;
+ }
await alertMsg.getByRole('button').click();
await expect(alertMsg).not.toBeVisible();
};
@@ -64,11 +72,18 @@ export async function uiFillHTTPStatuses(
}
}
-export const uiClearMonacoEditor = async (page: Page) => {
- await page.evaluate(() => {
- const editor = window.__monacoEditor__;
- editor.getModel()?.setValue('');
- });
+export const uiClearMonacoEditor = async (page: Page, editor?: Locator) => {
+ if (editor) {
+ // Use the specific editor element to find its Monaco instance
+ await editor.click();
+ await page.keyboard.press('Control+A');
+ await page.keyboard.press('Delete');
+ } else {
+ await page.evaluate(() => {
+ const ed = window.__monacoEditor__;
+ ed.getModel()?.setValue('');
+ });
+ }
};
export const uiGetMonacoEditor = async (
@@ -83,7 +98,7 @@ export const uiGetMonacoEditor = async (
await expect(editor).toBeVisible({ timeout: 10000 });
if (clear) {
- await uiClearMonacoEditor(page);
+ await uiClearMonacoEditor(page, editor);
}
return editor;
diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts
index 9def978d9..21a26c44f 100644
--- a/e2e/utils/ui/upstreams.ts
+++ b/e2e/utils/ui/upstreams.ts
@@ -63,7 +63,15 @@ export async function uiFillUpstreamRequiredFields(
// Add a third node and then remove it to test deletion functionality
await secondRowHost.blur();
- if (page) await page.waitForTimeout(500);
+ let p: Page;
+ if (
+ typeof (ctx as Locator).page === 'function'
+ ) {
+ p = (ctx as Locator).page();
+ } else {
+ p = ctx as Page;
+ }
+ await p.waitForTimeout(500);
await addNodeBtn.click();
await expect(rows).toHaveCount(3, { timeout: 10000 });
await rows.nth(2).getByRole('button', { name: 'Delete' }).click();
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index 8a72b8122..1547af8c7 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -80,6 +80,7 @@ export const PluginEditorDrawer = (props:
PluginEditorDrawerProps) => {
customSchema={schema}
isLoading={!schema}
required
+ disabled={mode === 'view'}
/>
</form>
diff --git a/src/components/form-slice/FormPartRoute/util.ts
b/src/components/form-slice/FormPartRoute/util.ts
index 7b406f7e7..951880333 100644
--- a/src/components/form-slice/FormPartRoute/util.ts
+++ b/src/components/form-slice/FormPartRoute/util.ts
@@ -16,6 +16,7 @@
*/
import { produce } from 'immer';
+import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
import { pipeProduce } from '@/utils/producer';
@@ -35,5 +36,6 @@ export const produceVarsToAPI = produce((draft:
RoutePostType) => {
export const produceRoute = pipeProduce(
produceRmUpstreamWhenHas('service_id', 'upstream_id'),
+ produceRmEmptyUpstreamFields,
produceVarsToAPI
);
diff --git a/src/components/form-slice/FormPartRoute/util.ts
b/src/components/form-slice/FormPartStreamRoute/util.ts
similarity index 53%
copy from src/components/form-slice/FormPartRoute/util.ts
copy to src/components/form-slice/FormPartStreamRoute/util.ts
index 7b406f7e7..1b26d84dd 100644
--- a/src/components/form-slice/FormPartRoute/util.ts
+++ b/src/components/form-slice/FormPartStreamRoute/util.ts
@@ -15,25 +15,28 @@
* limitations under the License.
*/
import { produce } from 'immer';
+import { pipe } from 'rambdax';
+import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
-import { pipeProduce } from '@/utils/producer';
-import type { RoutePostType, RoutePutType } from './schema';
+import type { StreamRoutePostType } from './schema';
-export const produceVarsToForm = produce((draft: RoutePostType) => {
- if (draft.vars && Array.isArray(draft.vars)) {
- draft.vars = JSON.stringify(draft.vars);
- }
-}) as (draft: RoutePostType) => RoutePutType;
+export const produceStreamRoute = (val: StreamRoutePostType) =>
+ pipe(
+ produceRmEmptyUpstreamFields,
+ (produceRmUpstreamWhenHas('service_id', 'upstream_id') as unknown as (
+ d: StreamRoutePostType
+ ) => StreamRoutePostType),
+ produce((draft: StreamRoutePostType) => {
+ // Stream Routes do not support name and status
+ const d = draft as StreamRoutePostType & { name?: string; status?:
number };
+ delete d.name;
+ delete d.status;
-export const produceVarsToAPI = produce((draft: RoutePostType) => {
- if (draft.vars && typeof draft.vars === 'string') {
- draft.vars = JSON.parse(draft.vars);
- }
-});
-
-export const produceRoute = pipeProduce(
- produceRmUpstreamWhenHas('service_id', 'upstream_id'),
- produceVarsToAPI
-);
+ // Cleanup protocol if name is missing
+ if (draft.protocol && !draft.protocol.name) {
+ delete draft.protocol;
+ }
+ })
+ )(val);
diff --git
a/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
b/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
index 7eb04aa3f..b40cab00c 100644
--- a/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormSectionDiscovery.tsx
@@ -45,6 +45,7 @@ export const FormSectionDiscovery = () => {
label={t('form.upstreams.discoveryArgs.title')}
control={control}
toObject
+ shouldUnregister={false}
/>
</FormSection>
);
diff --git a/src/components/form-slice/FormPartUpstream/util.ts
b/src/components/form-slice/FormPartUpstream/util.ts
index f397a7ce7..f4bf2b0f9 100644
--- a/src/components/form-slice/FormPartUpstream/util.ts
+++ b/src/components/form-slice/FormPartUpstream/util.ts
@@ -31,3 +31,53 @@ export const produceToUpstreamForm = (
d.__checksPassiveEnabled =
!!upstream.checks?.passive && isNotEmpty(upstream.checks.passive);
});
+
+const isAllUndefined = (obj: Record<string, unknown>) =>
+ Object.values(obj).every(
+ (v) => v === undefined || v === null || v === '' || Number.isNaN(v)
+ );
+
+export const produceRmEmptyUpstreamFields = produce(
+ (
+ draft: {
+ timeout?: Record<string, unknown>;
+ keepalive_pool?: Record<string, unknown>;
+ tls?: Record<string, unknown>;
+ upstream?: {
+ timeout?: Record<string, unknown>;
+ keepalive_pool?: Record<string, unknown>;
+ tls?: Record<string, unknown>;
+ };
+ } & Record<string, unknown>
+ ) => {
+ if (draft.timeout && isAllUndefined(draft.timeout)) {
+ delete draft.timeout;
+ }
+ if (draft.keepalive_pool && isAllUndefined(draft.keepalive_pool)) {
+ delete draft.keepalive_pool;
+ }
+ if (draft.tls && isAllUndefined(draft.tls)) {
+ delete draft.tls;
+ }
+
+ if (draft.upstream) {
+ const u = draft.upstream as Record<string, unknown>;
+ delete u.name;
+ delete u.desc;
+ delete u.labels;
+
+ if (draft.upstream.timeout && isAllUndefined(draft.upstream.timeout)) {
+ delete draft.upstream.timeout;
+ }
+ if (
+ draft.upstream.keepalive_pool &&
+ isAllUndefined(draft.upstream.keepalive_pool)
+ ) {
+ delete draft.upstream.keepalive_pool;
+ }
+ if (draft.upstream.tls && isAllUndefined(draft.upstream.tls)) {
+ delete draft.upstream.tls;
+ }
+ }
+ }
+);
diff --git a/src/components/form/Editor.tsx b/src/components/form/Editor.tsx
index 1139ca414..f621ca313 100644
--- a/src/components/form/Editor.tsx
+++ b/src/components/form/Editor.tsx
@@ -58,7 +58,7 @@ export const FormItemEditor = <T extends FieldValues>(
const { controllerProps, restProps } = genControllerProps(props, '');
const { customSchema, language, isLoading, required, ...wrapperProps } =
restProps;
- const { trigger } = useFormContext();
+ const { trigger, watch } = useFormContext();
const monacoErrorRef = useRef<string | null>(null);
const enhancedControllerProps = useMemo(() => {
return {
@@ -86,10 +86,23 @@ export const FormItemEditor = <T extends FieldValues>(
}, [controllerProps, required, t, monacoErrorRef]);
const {
- field: { value, onChange: fOnChange, ...restField },
+ field: { value: controlledValue, onChange: fOnChange, ...restField },
fieldState,
} = useController<T>(enhancedControllerProps);
+ // useController returns undefined for disabled fields in react-hook-form.
+ // useFormContext().watch() reads from both _formValues and _defaultValues,
+ // making it reliable even when the form is disabled with
shouldUnregister:true.
+ const watchedValue = watch(controllerProps.name as string);
+ // watchedValue may be a non-string (e.g. raw array) if the form store has
+ // the pre-transform value. Normalize to a JSON string for Monaco.
+ const normalizedWatchedValue = typeof watchedValue === 'string'
+ ? watchedValue
+ : watchedValue != null
+ ? JSON.stringify(watchedValue, null, 2)
+ : undefined;
+ const value = restField.disabled ? normalizedWatchedValue ?? '' :
controlledValue;
+
const [internalLoading, setLoading] = useState(false);
useEffect(() => {
diff --git a/src/components/form/JsonInput.tsx
b/src/components/form/JsonInput.tsx
index 206289eb8..c848051a8 100644
--- a/src/components/form/JsonInput.tsx
+++ b/src/components/form/JsonInput.tsx
@@ -70,6 +70,7 @@ export const FormItemJsonInput = <T extends FieldValues>(
formatOnBlur
autosize
resize="vertical"
+ validationError={null}
{...restField}
{...omit(['objValue'], restProps)}
/>
diff --git a/src/config/req.ts b/src/config/req.ts
index 2f136c9c1..078bd513f 100644
--- a/src/config/req.ts
+++ b/src/config/req.ts
@@ -79,9 +79,10 @@ req.interceptors.response.use(
if (matchSkipInterceptor(err)) return Promise.reject(err);
const res = err.response as AxiosResponse<APISIXRespErr>;
const d = res.data;
+ const message = d?.error_msg || d?.message || `Error status:
${res.status}`;
notifications.show({
- id: d?.error_msg || d?.message,
- message: d?.error_msg || d?.message,
+ id: message,
+ message,
color: 'red',
});
// Requires to enter admin key at 401
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index ca49bf2de..48f124783 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -23,7 +23,7 @@ import {
useNavigate,
useParams,
} from '@tanstack/react-router';
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBoolean } from 'react-use';
@@ -62,6 +62,16 @@ const RouteDetailForm = (props: Props) => {
const routeQuery = useQuery(getRouteQueryOptions(id));
const { data: routeData, isLoading, refetch } = routeQuery;
+ const formDefaults = useMemo(() => {
+ if (routeData?.value) {
+ const upstreamProduced = produceToUpstreamForm(
+ routeData.value.upstream || {},
+ routeData.value
+ );
+ return produceVarsToForm(upstreamProduced);
+ }
+ }, [routeData]);
+
const form = useForm({
resolver: zodResolver(RoutePutSchema),
shouldUnregister: true,
@@ -71,14 +81,10 @@ const RouteDetailForm = (props: Props) => {
});
useEffect(() => {
- if (routeData?.value && !isLoading) {
- const upstreamProduced = produceToUpstreamForm(
- routeData.value.upstream || {},
- routeData.value
- );
- form.reset(produceVarsToForm(upstreamProduced));
+ if (formDefaults && !isLoading) {
+ form.reset(formDefaults);
}
- }, [routeData, form, isLoading]);
+ }, [formDefaults, form, isLoading]);
const putRoute = useMutation({
mutationFn: (d: RoutePutType) =>
diff --git a/src/routes/services/add.tsx b/src/routes/services/add.tsx
index e6d7ec3ca..28498edad 100644
--- a/src/routes/services/add.tsx
+++ b/src/routes/services/add.tsx
@@ -25,6 +25,7 @@ import { postServiceReq, type ServicePostType } from
'@/apis/services';
import { FormSubmitBtn } from '@/components/form/Btn';
import { FormPartService } from '@/components/form-slice/FormPartService';
import { ServicePostSchema } from
'@/components/form-slice/FormPartService/schema';
+import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
@@ -39,7 +40,7 @@ const ServiceAddForm = () => {
mutationFn: (d: ServicePostType) =>
postServiceReq(
req,
- pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(d)
+ pipeProduce(produceRmUpstreamWhenHas('upstream_id'),
produceRmEmptyUpstreamFields)(d) as ServicePostType
),
async onSuccess(res) {
notifications.show({
@@ -62,7 +63,7 @@ const ServiceAddForm = () => {
return (
<FormProvider {...form}>
- <form onSubmit={form.handleSubmit((d) => postService.mutateAsync(d))}>
+ <form onSubmit={form.handleSubmit((d) => postService.mutateAsync(d as
ServicePostType))}>
<FormPartService />
<FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
</form>
diff --git a/src/routes/services/detail.$id/index.tsx
b/src/routes/services/detail.$id/index.tsx
index e64f9188d..4e4a518de 100644
--- a/src/routes/services/detail.$id/index.tsx
+++ b/src/routes/services/detail.$id/index.tsx
@@ -32,6 +32,7 @@ import { getServiceQueryOptions } from '@/apis/hooks';
import { putServiceReq } from '@/apis/services';
import { FormSubmitBtn } from '@/components/form/Btn';
import { FormPartService } from '@/components/form-slice/FormPartService';
+import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
@@ -73,7 +74,7 @@ const ServiceDetailForm = (props: Props) => {
mutationFn: (d: APISIXType['Service']) =>
putServiceReq(
req,
- pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(d)
+ pipeProduce(produceRmUpstreamWhenHas('upstream_id'),
produceRmEmptyUpstreamFields)(d)
),
async onSuccess() {
notifications.show({
diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx
index 39f02ebd7..6364ad03a 100644
--- a/src/routes/stream_routes/add.tsx
+++ b/src/routes/stream_routes/add.tsx
@@ -23,12 +23,12 @@ import { useTranslation } from 'react-i18next';
import { postStreamRouteReq } from '@/apis/stream_routes';
import { FormSubmitBtn } from '@/components/form/Btn';
-import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
import { FormPartStreamRoute } from
'@/components/form-slice/FormPartStreamRoute';
import {
StreamRoutePostSchema,
type StreamRoutePostType,
} from '@/components/form-slice/FormPartStreamRoute/schema';
+import { produceStreamRoute } from
'@/components/form-slice/FormPartStreamRoute/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { StreamRoutesErrorComponent } from
'@/components/page-slice/stream_routes/ErrorComponent';
@@ -46,7 +46,7 @@ export const StreamRouteAddForm = (props: Props) => {
const postStreamRoute = useMutation({
mutationFn: (d: StreamRoutePostType) =>
- postStreamRouteReq(req, produceRoute(d)),
+ postStreamRouteReq(req, produceStreamRoute(d)),
async onSuccess(res) {
notifications.show({
message: t('info.add.success', { name: t('streamRoutes.singular') }),
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index a080b815e..28d2150e0 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -26,6 +26,7 @@ import { postUpstreamReq } from '@/apis/upstreams';
import { FormSubmitBtn } from '@/components/form/Btn';
import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
import { FormPartUpstreamSchema } from
'@/components/form-slice/FormPartUpstream/schema';
+import { produceRmEmptyUpstreamFields } from
'@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
import { req } from '@/config/req';
@@ -41,7 +42,7 @@ const UpstreamAddForm = () => {
const { t } = useTranslation();
const router = useRouter();
const postUpstream = useMutation({
- mutationFn: (d: PostUpstreamType) => postUpstreamReq(req, d),
+ mutationFn: (d: PostUpstreamType) => postUpstreamReq(req,
pipeProduce(produceRmEmptyUpstreamFields)(d) as PostUpstreamType),
async onSuccess(data) {
notifications.show({
message: t('info.add.success', { name: t('upstreams.singular') }),
@@ -61,11 +62,7 @@ const UpstreamAddForm = () => {
return (
<FormProvider {...form}>
- <form
- onSubmit={form.handleSubmit((d) =>
- postUpstream.mutateAsync(pipeProduce()(d))
- )}
- >
+ <form onSubmit={form.handleSubmit((d) => postUpstream.mutateAsync(d as
PostUpstreamType))}>
<FormPartUpstream />
<FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn>
</form>
diff --git a/src/routes/upstreams/detail.$id.tsx
b/src/routes/upstreams/detail.$id.tsx
index ca5f8e9c4..9ed24dda9 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { zodResolver } from '@hookform/resolvers/zod';
-import { Button, Group,Skeleton } from '@mantine/core';
+import { Button, Group, Skeleton } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
queryOptions,
@@ -27,7 +27,7 @@ import {
useNavigate,
useParams,
} from '@tanstack/react-router';
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBoolean } from 'react-use';
@@ -36,7 +36,10 @@ import { getUpstreamReq, putUpstreamReq } from
'@/apis/upstreams';
import { FormSubmitBtn } from '@/components/form/Btn';
import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
import { FormPartUpstreamSchema } from
'@/components/form-slice/FormPartUpstream/schema';
-import { produceToUpstreamForm } from
'@/components/form-slice/FormPartUpstream/util';
+import {
+ produceRmEmptyUpstreamFields,
+ produceToUpstreamForm,
+} from '@/components/form-slice/FormPartUpstream/util';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import { FormSectionGeneral } from
'@/components/form-slice/FormSectionGeneral';
import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
@@ -68,6 +71,11 @@ const UpstreamDetailForm = (
refetch,
} = useSuspenseQuery(getUpstreamQueryOptions(id));
+ const formDefaults = useMemo(
+ () => produceToUpstreamForm(upstreamData),
+ [upstreamData]
+ );
+
const form = useForm({
resolver: zodResolver(FormPartUpstreamSchema),
shouldUnregister: true,
@@ -76,7 +84,21 @@ const UpstreamDetailForm = (
});
const putUpstream = useMutation({
- mutationFn: (d: APISIXType['Upstream']) => putUpstreamReq(req, d),
+ mutationFn: (d: APISIXType['Upstream']) => {
+ // Merge original discovery_args into form data before processing,
+ // so pipeProduce's produceRestoreEmptyPlugins can restore
discovery_args: {}
+ // even if the field was not touched (and thus absent from d).
+ const merged = {
+ ...d,
+ ...(upstreamData.discovery_args !== undefined && !('discovery_args' in
d)
+ ? { discovery_args: upstreamData.discovery_args }
+ : {}),
+ };
+ return putUpstreamReq(
+ req,
+ pipeProduce(produceRmEmptyUpstreamFields)(merged) as
APISIXType['Upstream']
+ );
+ },
async onSuccess() {
notifications.show({
message: t('info.edit.success', { name: t('upstreams.singular') }),
@@ -89,9 +111,9 @@ const UpstreamDetailForm = (
useEffect(() => {
if (upstreamData && !isLoading) {
- form.reset(produceToUpstreamForm(upstreamData));
+ form.reset(formDefaults);
}
- }, [upstreamData, form, isLoading]);
+ }, [formDefaults, form, isLoading, upstreamData]);
if (isLoading) {
return <Skeleton height={400} />;
@@ -102,7 +124,7 @@ const UpstreamDetailForm = (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit((d) => {
- putUpstream.mutateAsync(pipeProduce()(d));
+ putUpstream.mutateAsync(d as APISIXType['Upstream']);
})}
>
<FormSectionGeneral readOnly />
diff --git a/src/utils/producer.ts b/src/utils/producer.ts
index 588b5edb4..2d4e82e15 100644
--- a/src/utils/producer.ts
+++ b/src/utils/producer.ts
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { clean,type ICleanerOptions } from 'fast-clean';
+import { clean, type ICleanerOptions } from 'fast-clean';
import { produce } from 'immer';
import { pipe } from 'rambdax';
@@ -35,6 +35,38 @@ export const produceDeepCleanEmptyKeys = (opts:
ICleanerOptions = {}) =>
deepCleanEmptyKeys(draft, opts);
});
+/**
+ * Preserves plugin entries with empty config ({}) after deep cleaning.
+ * APISIX plugins like key-auth have no required fields and are valid with {}.
+ * deepCleanEmptyKeys would strip them, so we restore them from the original.
+ */
+export const produceRestoreEmptyPlugins = (original: object) =>
+ produce((draft: Record<string, unknown>) => {
+ const orig = original as Record<string, unknown>;
+ if (orig.plugins && typeof orig.plugins === 'object') {
+ const origPlugins = orig.plugins as Record<string, unknown>;
+ const draftPlugins = (draft.plugins ?? {}) as Record<string, unknown>;
+ Object.keys(origPlugins).forEach((name) => {
+ if (!(name in draftPlugins)) {
+ draftPlugins[name] = origPlugins[name];
+ }
+ });
+ if (Object.keys(draftPlugins).length > 0) {
+ draft.plugins = draftPlugins;
+ }
+ }
+ // Restore discovery_args: {} if it was present in the original.
+ // APISIX accepts empty discovery_args and deepCleanEmptyKeys would strip
it.
+ if (
+ 'discovery_args' in orig &&
+ orig.discovery_args !== null &&
+ typeof orig.discovery_args === 'object' &&
+ Object.keys(orig.discovery_args as object).length === 0
+ ) {
+ (draft as Record<string, unknown>).discovery_args = {};
+ }
+ });
+
export const rmDoubleUnderscoreKeys = (obj: object) => {
Object.keys(obj).forEach((key) => {
const k = key as keyof typeof obj;
@@ -64,7 +96,8 @@ export const pipeProduce = (...funcs: ((a: any) =>
unknown)[]) => {
...fs,
produceRmDoubleUnderscoreKeys,
produceTime,
- produceDeepCleanEmptyKeys()
+ produceDeepCleanEmptyKeys(),
+ produceRestoreEmptyPlugins(val as object)
)(draft) as never;
}) as T;
};