This is an automated email from the ASF dual-hosted git repository.
moonming 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 91d89d117 fix: pass host reset (#3295)
91d89d117 is described below
commit 91d89d117b69d601ce62375051cbf39d1a3bcce8
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Wed May 20 04:59:33 2026 +0530
fix: pass host reset (#3295)
* fix: pass_host resets to default when editing upstream nodes (#3294)
Also added E2E regression test e2e/tests/upstreams.pass-host-reset.spec.ts
* chore: update generated route tree
* chore: ensure route tree matches current environment
* fix: update test files to ensure proper functionality and add type safety
in upstream form
* fix: refactor route detail forms to use useSuspenseQuery and improve
cleanup logic
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
* fix: add loading skeleton to RouteDetailForm and improve service data
handling
* Replaced useQuery with useSuspenseQuery & Added Suspense with Skeleton
* Improved readibility
* fix: resolve the must-fix issue regarding the UX regression
* fix(upstream): apply Suspense boundary to UpstreamDetailForm to prevent
UX regression
* style(stream-routes): use consistent Skeleton Suspense fallback for
detail page
* test(e2e): remove global beforeAll upstream deletion to avoid race
conditions
* perf: useMemo for formDefaults in upstream and route detail pages
* fix: resolve E2E test failures by improving form synchronization and data
initialization
* Fixed lint error in util.ts: The fix was typing produceVarsToAPI's return
to reflect that vars becomes unknown[] | undefined after JSON.parse, which
satisfies the APISIXType['Route'] cast in add.tsx.
* Updated the function signature to accept APISIXType['Route'] to match
what's actually being passed.
* fix(upstream): stabilize node state sync and resolve E2E timeouts
- Fix infinite update loop in FormItemNodes by ensuring stable IDs in
genRecord
- Implement atomic sync between MobX and RHF by adding deep-equal checks
- Stabilize E2E tests by injecting safety pauses during node row transitions
- Updated test helpers to ensure valid node data (port/weight) for Zod
validation
* fix: resolve merge artifacts and syntax errors in detail pages
* Update routeTree.gen.ts
---------
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
e2e/tests/routes.clear-upstream-field.spec.ts | 47 +++-
e2e/tests/routes.crud-all-fields.spec.ts | 2 +
e2e/tests/upstreams.crud-required-fields.spec.ts | 10 +-
e2e/tests/upstreams.pass-host-reset.spec.ts | 305 +++++++++++++++++++++
e2e/utils/ui/index.ts | 10 +
e2e/utils/ui/upstreams.ts | 41 +--
src/apis/routes.ts | 3 +-
src/components/form-slice/FormPartRoute/util.ts | 4 +-
.../form-slice/FormPartUpstream/FormItemNodes.tsx | 29 +-
.../form-slice/FormPartUpstream/schema.ts | 4 +-
src/components/form-slice/FormPartUpstream/util.ts | 16 ++
src/components/form/Editor.tsx | 2 +-
src/routes/routes/add.tsx | 11 +-
src/routes/routes/detail.$id.tsx | 84 ++++--
src/routes/services/detail.$id/index.tsx | 39 ++-
src/routes/stream_routes/detail.$id.tsx | 45 +--
src/routes/upstreams/add.tsx | 6 +-
src/routes/upstreams/detail.$id.tsx | 42 +--
18 files changed, 558 insertions(+), 142 deletions(-)
diff --git a/e2e/tests/routes.clear-upstream-field.spec.ts
b/e2e/tests/routes.clear-upstream-field.spec.ts
index 7d625f043..18b558c93 100644
--- a/e2e/tests/routes.clear-upstream-field.spec.ts
+++ b/e2e/tests/routes.clear-upstream-field.spec.ts
@@ -23,11 +23,14 @@ import { uiDeleteRoute } from '@e2e/utils/ui/routes';
import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
import { expect, type Page } from '@playwright/test';
-import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
-import { deleteAllServices, postServiceReq } from '@/apis/services';
-import { deleteAllUpstreams, postUpstreamReq } from '@/apis/upstreams';
+import { getRouteReq } from '@/apis/routes';
+import { postServiceReq } from '@/apis/services';
+import { postUpstreamReq } from '@/apis/upstreams';
+import { API_ROUTES, API_SERVICES, API_UPSTREAMS } from '@/config/constant';
import type { APISIXType } from '@/types/schema/apisix';
+test.describe.configure({ mode: 'serial' });
+
const upstreamName = randomId('test-upstream');
const serviceName = randomId('test-service');
const routeNameForUpstreamId = randomId('test-route-upstream-id');
@@ -42,6 +45,7 @@ const upstreamNodes: APISIXType['UpstreamNode'][] = [
let testUpstreamId: string;
let testServiceId: string;
+const createdRouteIds = new Set<string>();
// Common helper functions
async function fillBasicRouteFields(
@@ -130,11 +134,6 @@ async function editRouteAndAddUpstream(
}
test.beforeAll(async () => {
- // Clean up existing resources
- await deleteAllRoutes(e2eReq);
- await deleteAllServices(e2eReq);
- await deleteAllUpstreams(e2eReq);
-
// Create a test upstream for testing upstream_id scenario
const upstreamResponse = await postUpstreamReq(e2eReq, {
name: upstreamName,
@@ -150,10 +149,24 @@ test.beforeAll(async () => {
testServiceId = serviceResponse.data.value.id;
});
+test.afterEach(async () => {
+ await Promise.all(
+ Array.from(createdRouteIds).map((routeId) =>
+ e2eReq.delete(`${API_ROUTES}/${routeId}`).catch(() => {
+ // Ignore cleanup errors so tests can proceed; route may already be
deleted.
+ })
+ )
+ );
+ createdRouteIds.clear();
+});
+
test.afterAll(async () => {
- await deleteAllRoutes(e2eReq);
- await deleteAllServices(e2eReq);
- await deleteAllUpstreams(e2eReq);
+ await e2eReq.delete(`${API_SERVICES}/${testServiceId}`).catch(() => {
+ // Ignore cleanup errors; resource may already be deleted.
+ });
+ await e2eReq.delete(`${API_UPSTREAMS}/${testUpstreamId}`).catch(() => {
+ // Ignore cleanup errors; resource may already be deleted.
+ });
});
test('should clear upstream field when upstream_id exists (create and edit)',
async ({
@@ -189,7 +202,8 @@ test('should clear upstream field when upstream_id exists
(create and edit)', as
});
await test.step('verify upstream field is cleared after creation', async ()
=> {
- await verifyRouteData(page, 'upstream_id', testUpstreamId);
+ const routeId = await verifyRouteData(page, 'upstream_id', testUpstreamId);
+ createdRouteIds.add(routeId);
});
await test.step('edit route and add upstream configuration again', async ()
=> {
@@ -201,7 +215,8 @@ test('should clear upstream field when upstream_id exists
(create and edit)', as
});
await test.step('verify upstream field is still cleared after editing',
async () => {
- await verifyRouteData(page, 'upstream_id', testUpstreamId);
+ const routeId = await verifyRouteData(page, 'upstream_id', testUpstreamId);
+ createdRouteIds.add(routeId);
await uiDeleteRoute(page);
});
});
@@ -240,7 +255,8 @@ test('should clear upstream field when service_id exists
(create and edit)', asy
});
await test.step('verify upstream field is cleared after creation', async ()
=> {
- await verifyRouteData(page, 'service_id', testServiceId);
+ const routeId = await verifyRouteData(page, 'service_id', testServiceId);
+ createdRouteIds.add(routeId);
});
await test.step('edit route and add upstream configuration again', async ()
=> {
@@ -252,7 +268,8 @@ test('should clear upstream field when service_id exists
(create and edit)', asy
});
await test.step('verify upstream field is still cleared after editing',
async () => {
- await verifyRouteData(page, 'service_id', testServiceId);
+ const routeId = await verifyRouteData(page, 'service_id', testServiceId);
+ createdRouteIds.add(routeId);
await uiDeleteRoute(page);
});
});
diff --git a/e2e/tests/routes.crud-all-fields.spec.ts
b/e2e/tests/routes.crud-all-fields.spec.ts
index 4f1604a4b..e8b76f677 100644
--- a/e2e/tests/routes.crud-all-fields.spec.ts
+++ b/e2e/tests/routes.crud-all-fields.spec.ts
@@ -20,6 +20,7 @@ import { e2eReq } from '@e2e/utils/req';
import { test } from '@e2e/utils/test';
import {
uiClearMonacoEditor,
+ uiEnsureSettingsClosed,
uiFillMonacoEditor,
uiGetMonacoEditor,
uiHasToastMsg,
@@ -47,6 +48,7 @@ test.beforeAll(async () => {
});
test('should CRUD route with all fields', async ({ page }) => {
+ await uiEnsureSettingsClosed(page);
test.slow();
const varsSection = page.getByRole('group', { name: 'Match Rules'
}).getByText('Vars').locator('..');
diff --git a/e2e/tests/upstreams.crud-required-fields.spec.ts
b/e2e/tests/upstreams.crud-required-fields.spec.ts
index 55261ae40..b25e0a15d 100644
--- a/e2e/tests/upstreams.crud-required-fields.spec.ts
+++ b/e2e/tests/upstreams.crud-required-fields.spec.ts
@@ -25,20 +25,23 @@ import {
} from '@e2e/utils/ui/upstreams';
import { expect } from '@playwright/test';
+import { deleteAllRoutes } from '@/apis/routes';
import { deleteAllUpstreams } from '@/apis/upstreams';
import type { APISIXType } from '@/types/schema/apisix';
const upstreamName = randomId('test-upstream');
const nodes: APISIXType['UpstreamNode'][] = [
- { host: 'test.com' },
- { host: 'test2.com', port: 80 },
+ { host: 'test.com', port: 80, weight: 100 },
+ { host: 'test2.com', port: 80, weight: 100 },
];
test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
await deleteAllUpstreams(e2eReq);
});
test('should CRUD upstream with required fields', async ({ page }) => {
+
await upstreamsPom.toIndex(page);
await upstreamsPom.isIndexPage(page);
@@ -156,7 +159,8 @@ test('should CRUD upstream with required fields', async ({
page }) => {
});
await test.step('delete upstream in detail page', async () => {
- await page.getByRole('button', { name: 'Delete' }).click();
+ // Delete the upstream
+ await page.getByRole('button', { name: 'Delete', exact: true
}).first().click();
await page
.getByRole('dialog', { name: 'Delete Upstream' })
diff --git a/e2e/tests/upstreams.pass-host-reset.spec.ts
b/e2e/tests/upstreams.pass-host-reset.spec.ts
new file mode 100644
index 000000000..66fa22407
--- /dev/null
+++ b/e2e/tests/upstreams.pass-host-reset.spec.ts
@@ -0,0 +1,305 @@
+/**
+ * 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 { randomId } from '@e2e/utils/common';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+/**
+ * Test for GitHub issue #3294
+ * Bug: pass_host is reset to default value "pass" when editing upstream nodes
+ * @see https://github.com/apache/apisix-dashboard/issues/3294
+ */
+test('should preserve pass_host value when editing upstream nodes', async ({
+ page,
+}) => {
+ const upstreamName = randomId('test-pass-host');
+
+ // Navigate to upstream add page
+ await upstreamsPom.toIndex(page);
+ await upstreamsPom.isIndexPage(page);
+ await upstreamsPom.getAddUpstreamBtn(page).click();
+ await upstreamsPom.isAddPage(page);
+
+ await test.step('create upstream with pass_host=node via UI', async () => {
+ // Fill in the Name field
+ await page.getByLabel('Name', { exact: true }).fill(upstreamName);
+
+ // Add a node
+ const nodesSection = page.getByRole('group', { name: 'Nodes' });
+ const addNodeBtn = page.getByRole('button', { name: 'Add a Node' });
+
+ await addNodeBtn.click();
+ const rows = nodesSection.locator('tr.ant-table-row');
+ const firstRow = rows.first();
+ await expect(firstRow).toBeVisible();
+
+ const hostInput = firstRow.locator('input').first();
+ await hostInput.click();
+ await hostInput.fill('my-service.my-namespace.svc');
+
+ // Click outside to trigger update
+ await nodesSection.click();
+
+ // Set pass_host to "node"
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ await passHostSection.getByRole('textbox', { name: 'Pass Host' }).click();
+ await page.getByRole('option', { name: 'node' }).click();
+
+ // Submit the form
+ await upstreamsPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, {
+ hasText: 'Add Upstream Successfully',
+ });
+ });
+
+ await test.step('verify auto navigate to detail page', async () => {
+ await upstreamsPom.isDetailPage(page);
+ });
+
+ await test.step('verify initial pass_host value is "node"', async () => {
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ await expect(passHostField).toHaveValue('node');
+ await expect(passHostField).toBeDisabled();
+ });
+
+ await test.step('click edit and add a new node', async () => {
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ const nodesSection = page.getByRole('group', { name: 'Nodes' });
+ const addNodeBtn = page.getByRole('button', { name: 'Add a Node' });
+
+ // Add a new node
+ await addNodeBtn.click();
+
+ // Fill in the new node details
+ const rows = nodesSection.locator('tr.ant-table-row');
+ const newRow = rows.nth(1);
+ await expect(newRow).toBeVisible();
+
+ const hostInput = newRow.locator('input').first();
+ await hostInput.click();
+ await hostInput.fill('another-service.svc');
+
+ const portInput = newRow.locator('input').nth(1);
+ await portInput.click();
+ await portInput.fill('8080');
+
+ const weightInput = newRow.locator('input').nth(2);
+ await weightInput.click();
+ await weightInput.fill('1');
+
+ // Click outside to trigger the update
+ await nodesSection.click();
+ });
+
+ await test.step('verify pass_host is still "node" before saving', async ()
=> {
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ // This is the bug check - pass_host should still be "node" not reset to
"pass"
+ await expect(passHostField).toHaveValue('node');
+ });
+
+ await test.step('save and verify pass_host is preserved', async () => {
+ const saveBtn = page.getByRole('button', { name: 'Save' });
+ await saveBtn.click();
+
+ await uiHasToastMsg(page, {
+ hasText: 'Edit Upstream Successfully',
+ });
+
+ // Verify we're back in detail view mode
+ await upstreamsPom.isDetailPage(page);
+
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ await expect(passHostField).toHaveValue('node');
+ await expect(passHostField).toBeDisabled();
+ });
+
+ await test.step('verify pass_host is preserved after page reload', async ()
=> {
+ await page.reload();
+ await page.waitForLoadState('load');
+ await upstreamsPom.isDetailPage(page);
+
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ await expect(passHostField).toHaveValue('node');
+ });
+
+ await test.step('delete upstream via UI', async () => {
+ // Navigate to list page first to avoid ambiguity with node delete buttons
+ await upstreamsPom.getUpstreamNavBtn(page).click();
+ await upstreamsPom.isIndexPage(page);
+
+ const row = page.getByRole('row', { name: upstreamName });
+ await row.getByRole('button', { name: 'Delete' }).click();
+
+ await page
+ .getByRole('dialog', { name: 'Delete Upstream' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+
+ await uiHasToastMsg(page, {
+ hasText: 'Delete Upstream Successfully',
+ });
+ await expect(page.getByRole('cell', { name: upstreamName })).toBeHidden();
+ });
+});
+
+/**
+ * Additional test to verify pass_host=rewrite is also preserved
+ */
+test('should preserve pass_host value "rewrite" when editing upstream nodes',
async ({
+ page,
+}) => {
+ const upstreamName = randomId('test-pass-host-rewrite');
+
+ // Navigate to upstream add page
+ await upstreamsPom.toIndex(page);
+ await upstreamsPom.isIndexPage(page);
+ await upstreamsPom.getAddUpstreamBtn(page).click();
+ await upstreamsPom.isAddPage(page);
+
+ await test.step('create upstream with pass_host=rewrite via UI', async () =>
{
+ // Fill in the Name field
+ await page.getByLabel('Name', { exact: true }).fill(upstreamName);
+
+ // Add a node
+ const nodesSection = page.getByRole('group', { name: 'Nodes' });
+ const addNodeBtn = page.getByRole('button', { name: 'Add a Node' });
+
+ await addNodeBtn.click();
+ const rows = nodesSection.locator('tr.ant-table-row');
+ const firstRow = rows.first();
+ await expect(firstRow).toBeVisible();
+
+ const hostInput = firstRow.locator('input').first();
+ await hostInput.click();
+ await hostInput.fill('my-service.svc');
+
+ // Click outside to trigger update
+ await nodesSection.click();
+
+ // Set pass_host to "rewrite"
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ await passHostSection.getByRole('textbox', { name: 'Pass Host' }).click();
+ await page.getByRole('option', { name: 'rewrite' }).click();
+
+ // Fill upstream_host (required when pass_host is "rewrite")
+ await page.getByLabel('Upstream Host').fill('custom.host.example.com');
+
+ // Submit the form
+ await upstreamsPom.getAddBtn(page).click();
+ await uiHasToastMsg(page, {
+ hasText: 'Add Upstream Successfully',
+ });
+ });
+
+ await test.step('verify auto navigate to detail page', async () => {
+ await upstreamsPom.isDetailPage(page);
+ });
+
+ await test.step('verify initial values', async () => {
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ await expect(passHostField).toHaveValue('rewrite');
+ await expect(passHostField).toBeDisabled();
+
+ const upstreamHostField = page.getByLabel('Upstream Host');
+ await expect(upstreamHostField).toHaveValue('custom.host.example.com');
+ });
+
+ await test.step('edit and modify nodes', async () => {
+ await page.getByRole('button', { name: 'Edit' }).click();
+
+ const nodesSection = page.getByRole('group', { name: 'Nodes' });
+ const rows = nodesSection.locator('tr.ant-table-row');
+ const firstRow = rows.first();
+
+ // Modify the existing node's weight
+ const weightInput = firstRow.locator('input').nth(2);
+ await weightInput.click();
+ await weightInput.fill('10');
+
+ // Click outside to trigger the update
+ await nodesSection.click();
+ });
+
+ await test.step('verify values before saving', async () => {
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ await expect(passHostField).toHaveValue('rewrite');
+
+ const upstreamHostField = page.getByLabel('Upstream Host');
+ await expect(upstreamHostField).toHaveValue('custom.host.example.com');
+ });
+
+ await test.step('save and verify values are preserved', async () => {
+ const saveBtn = page.getByRole('button', { name: 'Save' });
+ await saveBtn.click();
+
+ await uiHasToastMsg(page, {
+ hasText: 'Edit Upstream Successfully',
+ });
+
+ const passHostSection = page.getByRole('group', { name: 'Pass Host' });
+ const passHostField = passHostSection.getByRole('textbox', {
+ name: 'Pass Host',
+ exact: true,
+ });
+ await expect(passHostField).toHaveValue('rewrite');
+ });
+
+ await test.step('delete upstream via UI', async () => {
+ await upstreamsPom.getUpstreamNavBtn(page).click();
+ await upstreamsPom.isIndexPage(page);
+
+ const row = page.getByRole('row', { name: upstreamName });
+ await row.getByRole('button', { name: 'Delete' }).click();
+
+ await page
+ .getByRole('dialog', { name: 'Delete Upstream' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+
+ await uiHasToastMsg(page, {
+ hasText: 'Delete Upstream Successfully',
+ });
+ await expect(page.getByRole('cell', { name: upstreamName })).toBeHidden();
+ });
+});
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 764a78542..23c005c5c 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -54,6 +54,16 @@ export const uiHasToastMsg = async (
await expect(alertMsg).not.toBeVisible();
};
+export const uiEnsureSettingsClosed = async (page: Page) => {
+ const settingsModal = page.getByRole('dialog', { name: 'Settings' });
+ // Wait a bit for modal to potentially appear
+ await page.waitForTimeout(500);
+ if (await settingsModal.isVisible()) {
+ await settingsModal.getByRole('button', { name: 'Close' }).click();
+ await expect(settingsModal).toBeHidden();
+ }
+};
+
export async function uiCannotSubmitEmptyForm(page: Page, pom: CommonPOM) {
await pom.getAddBtn(page).click();
await pom.isAddPage(page);
diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts
index 21a26c44f..e97ec85ab 100644
--- a/e2e/utils/ui/upstreams.ts
+++ b/e2e/utils/ui/upstreams.ts
@@ -21,13 +21,15 @@ import type { APISIXType } from '@/types/schema/apisix';
import { genTLS } from '../common';
import type { Test } from '../test';
-import { uiFillHTTPStatuses } from '.';
+import {
+ uiEnsureSettingsClosed,
+ uiFillHTTPStatuses,
+} from '.';
/**
* Fill the upstream form with required fields
* @param ctx - Playwright page object or locator
- * @param upstreamName - Name for the upstream
- * @param nodes - Array of upstream nodes
+ * @param upstreamName - Name
*/
export async function uiFillUpstreamRequiredFields(
ctx: Page | Locator,
@@ -52,10 +54,10 @@ export async function uiFillUpstreamRequiredFields(
await firstRowHost.fill(upstream.nodes[1].host);
await expect(firstRowHost).toHaveValue(upstream.nodes[1].host);
- // Add second node - blur first, wait for useClickOutside state sync, then
click Add
+ // Add second node - blur first to trigger sync, then click Add
await firstRowHost.blur();
if (page) await page.waitForTimeout(500);
- await addNodeBtn.click();
+ await addNodeBtn.click({ force: true });
await expect(rows).toHaveCount(2, { timeout: 10000 });
const secondRowHost = rows.nth(1).getByRole('textbox').first();
await secondRowHost.fill(upstream.nodes[0].host);
@@ -100,13 +102,11 @@ export async function uiCheckUpstreamRequiredFields(
export async function uiFillUpstreamAllFields(
test: Test,
ctx: Page | Locator,
- /**
- * currently only name and desc are useful,
- * because I dont want to change too many fields in upstreams related tests
- */
- upstream: Partial<APISIXType['Upstream']>,
- page: Page = ctx as Page
+ upstream: Partial<APISIXType['Upstream']> = {},
+ page: Page = (ctx as Locator).page ? (ctx as Locator).page() : (ctx as Page)
) {
+ await uiEnsureSettingsClosed(page);
+
await test.step('fill in required fields', async () => {
// Fill in the required fields
// 1. Name (required)
@@ -156,8 +156,8 @@ export async function uiFillUpstreamAllFields(
// Add the second node - blur any focused input first, then click Add
await priorityInput.blur();
- await page.waitForTimeout(500);
- await addNodeBtn.click();
+ if (page) await page.waitForTimeout(500);
+ await addNodeBtn.click({ force: true });
await expect(rows).toHaveCount(2, { timeout: 10000 });
// Fill in the Host for the second node - click first then fill
@@ -246,15 +246,17 @@ export async function uiFillUpstreamAllFields(
await tlsSection
.getByRole('textbox', { name: 'Client Key', exact: true })
.fill(tls.key);
- await tlsSection.getByRole('switch', { name: 'Verify' }).click();
+ await tlsSection.getByText('Verify').scrollIntoViewIfNeeded();
+ await tlsSection.locator('.mantine-Switch-track').click({ force: true });
// 12. Health Check settings
// Activate active health check
const healthCheckSection = ctx.getByRole('group', {
name: 'Health Check',
});
- const checksEnabled = ctx.getByTestId('checksEnabled').locator('..');
- await checksEnabled.click();
+ const checksEnabled =
ctx.getByTestId('checksEnabled').locator('..').locator('.mantine-Switch-track');
+ await checksEnabled.scrollIntoViewIfNeeded();
+ await checksEnabled.click({ force: true });
// Set the Healthy part of Active health check settings
const activeSection = healthCheckSection.getByRole('group', {
@@ -290,11 +292,12 @@ export async function uiFillUpstreamAllFields(
'503'
);
- // Activate passive health check
- await healthCheckSection
+ const checksPassiveEnabled = healthCheckSection
.getByTestId('checksPassiveEnabled')
.locator('..')
- .click();
+ .locator('.mantine-Switch-track');
+ await checksPassiveEnabled.scrollIntoViewIfNeeded();
+ await checksPassiveEnabled.click({ force: true });
// Set the Healthy part of Passive health check settings
const passiveSection = healthCheckSection.getByRole('group', {
diff --git a/src/apis/routes.ts b/src/apis/routes.ts
index cb99f7177..50b4c4559 100644
--- a/src/apis/routes.ts
+++ b/src/apis/routes.ts
@@ -16,7 +16,6 @@
*/
import type { AxiosInstance } from 'axios';
-import type { RoutePostType } from
'@/components/form-slice/FormPartRoute/schema';
import { API_ROUTES, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from '@/config/constant';
import type { APISIXType } from '@/types/schema/apisix';
import type { PageSearchType } from '@/types/schema/pageSearch';
@@ -45,7 +44,7 @@ export const putRouteReq = (req: AxiosInstance, data:
APISIXType['Route']) => {
);
};
-export const postRouteReq = (req: AxiosInstance, data: RoutePostType) =>
+export const postRouteReq = (req: AxiosInstance, data: APISIXType['Route']) =>
req.post<unknown, APISIXType['RespRouteDetail']>(API_ROUTES, data);
export const deleteAllRoutes = async (req: AxiosInstance) => {
diff --git a/src/components/form-slice/FormPartRoute/util.ts
b/src/components/form-slice/FormPartRoute/util.ts
index 951880333..ef2439aa2 100644
--- a/src/components/form-slice/FormPartRoute/util.ts
+++ b/src/components/form-slice/FormPartRoute/util.ts
@@ -30,9 +30,9 @@ export const produceVarsToForm = produce((draft:
RoutePostType) => {
export const produceVarsToAPI = produce((draft: RoutePostType) => {
if (draft.vars && typeof draft.vars === 'string') {
- draft.vars = JSON.parse(draft.vars);
+ (draft as RoutePostType & { vars: unknown }).vars = JSON.parse(draft.vars);
}
-});
+}) as (draft: RoutePostType) => Omit<RoutePostType, 'vars'> & { vars?:
unknown[] };
export const produceRoute = pipeProduce(
produceRmUpstreamWhenHas('service_id', 'upstream_id'),
diff --git a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
index 0bb13c13f..6f91fb60c 100644
--- a/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
+++ b/src/components/form-slice/FormPartUpstream/FormItemNodes.tsx
@@ -16,7 +16,6 @@
*/
import { EditableProTable, type ProColumns } from '@ant-design/pro-components';
import { Button, InputWrapper, type InputWrapperProps } from '@mantine/core';
-import { useClickOutside } from '@mantine/hooks';
import { toJS } from 'mobx';
import { useLocalObservable } from 'mobx-react-lite';
import { nanoid } from 'nanoid';
@@ -54,9 +53,10 @@ const zValidateField = <T extends ZodRawShape, R extends
keyof T>(
const genRecord = (data?: DataSource | APISIXType['UpstreamNode']) => {
const d = data || zGetDefault(APISIX.UpstreamNode);
+ const id = (d as DataSource).id || nanoid();
return {
- id: nanoid(),
...d,
+ id,
} as DataSource;
};
@@ -181,8 +181,9 @@ export const FormItemNodes = <T extends FieldValues>(
},
values: [] as DataSource[],
setValues(data: DataSource[]) {
- if (equals(toJS(this.values), data)) return;
+ if (equals(parseToUpstreamNodes(toJS(this.values)),
parseToUpstreamNodes(data))) return;
this.values = data;
+ this.save();
},
append(data: DataSource) {
this.values.push(data);
@@ -195,6 +196,11 @@ export const FormItemNodes = <T extends FieldValues>(
get editableKeys() {
return this.disabled ? [] : this.values.map((item) => item.id);
},
+ save() {
+ const vals = parseToUpstreamNodes(toJS(this.values));
+ fOnChange?.(vals);
+ restProps.onChange?.(vals);
+ },
}));
useEffect(() => {
ob.setValues(parseToDataSource(value));
@@ -203,11 +209,7 @@ export const FormItemNodes = <T extends FieldValues>(
ob.setDisabled(disabled);
}, [disabled, ob]);
- const ref = useClickOutside(() => {
- const vals = parseToUpstreamNodes(toJS(ob.values));
- fOnChange?.(vals);
- restProps.onChange?.(vals);
- }, ['mouseup', 'touchend', 'mousedown', 'touchstart']);
+
return (
<InputWrapper
@@ -215,7 +217,6 @@ export const FormItemNodes = <T extends FieldValues>(
label={label}
required={required}
withAsterisk={withAsterisk}
- ref={ref}
>
<input name={fName} type="hidden" />
<AntdConfigProvider>
@@ -240,7 +241,10 @@ export const FormItemNodes = <T extends FieldValues>(
variant="transparent"
size="compact-xs"
px={0}
- onClick={() => ob.remove(row.id)}
+ onClick={() => {
+ ob.remove(row.id);
+ ob.save();
+ }}
>
{t('form.btn.delete')}
</Button>,
@@ -256,7 +260,10 @@ export const FormItemNodes = <T extends FieldValues>(
size="xs"
color="cyan"
style={{ borderColor: 'whitesmoke' }}
- onClick={() => ob.append(genRecord())}
+ onClick={() => {
+ ob.append(genRecord());
+ ob.save();
+ }}
{...(disabled && { display: 'none' })}
>
{t('form.upstreams.nodes.add')}
diff --git a/src/components/form-slice/FormPartUpstream/schema.ts
b/src/components/form-slice/FormPartUpstream/schema.ts
index 1e9c62692..7b962222f 100644
--- a/src/components/form-slice/FormPartUpstream/schema.ts
+++ b/src/components/form-slice/FormPartUpstream/schema.ts
@@ -20,8 +20,8 @@ import { APISIX } from '@/types/schema/apisix';
// We don't omit id now, as we need it for detail view
export const FormPartUpstreamSchema = APISIX.Upstream.extend({
- __checksEnabled: z.boolean().optional().default(false),
- __checksPassiveEnabled: z.boolean().optional().default(false),
+ __checksEnabled: z.boolean().default(false),
+ __checksPassiveEnabled: z.boolean().default(false),
});
export type FormPartUpstreamType = z.infer<typeof FormPartUpstreamSchema>;
diff --git a/src/components/form-slice/FormPartUpstream/util.ts
b/src/components/form-slice/FormPartUpstream/util.ts
index f4bf2b0f9..7229e5749 100644
--- a/src/components/form-slice/FormPartUpstream/util.ts
+++ b/src/components/form-slice/FormPartUpstream/util.ts
@@ -31,6 +31,22 @@ export const produceToUpstreamForm = (
d.__checksPassiveEnabled =
!!upstream.checks?.passive && isNotEmpty(upstream.checks.passive);
});
+export const produceToNestedUpstreamForm = produce((draft: Record<string,
unknown>) => {
+ const d = draft as Record<string, unknown> & {
+ upstream?: Record<string, unknown>;
+ checks?: { passive?: unknown };
+ __checksEnabled?: boolean;
+ __checksPassiveEnabled?: boolean;
+ };
+ if (d.upstream && typeof d.upstream === 'object' &&
!Array.isArray(d.upstream)) {
+ d.upstream = produceToUpstreamForm(d.upstream, d.upstream) as
Record<string, unknown>;
+ }
+ // Also handle top-level checks if they exist
+ if (d.checks) {
+ d.__checksEnabled = !!d.checks && isNotEmpty(d.checks);
+ d.__checksPassiveEnabled = !!d.checks?.passive &&
isNotEmpty(d.checks.passive);
+ }
+});
const isAllUndefined = (obj: Record<string, unknown>) =>
Object.values(obj).every(
diff --git a/src/components/form/Editor.tsx b/src/components/form/Editor.tsx
index f621ca313..4c6fb47c2 100644
--- a/src/components/form/Editor.tsx
+++ b/src/components/form/Editor.tsx
@@ -163,7 +163,7 @@ export const FormItemEditor = <T extends FieldValues>(
trigger(props.name);
}}
onMount={(editor) => {
- if (process.env.NODE_ENV === 'test') {
+ if (process.env.NODE_ENV !== 'production') {
window.__monacoEditor__ = editor;
}
}}
diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx
index ac26e94da..47d37a26b 100644
--- a/src/routes/routes/add.tsx
+++ b/src/routes/routes/add.tsx
@@ -29,10 +29,12 @@ import {
type RoutePostType,
} from '@/components/form-slice/FormPartRoute/schema';
import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
+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';
import type { APISIXType } from '@/types/schema/apisix';
+import { pipeProduce } from '@/utils/producer';
type Props = {
navigate: (res: APISIXType['RespRouteDetail']) => Promise<void>;
@@ -44,7 +46,14 @@ export const RouteAddForm = (props: Props) => {
const { t } = useTranslation();
const postRoute = useMutation({
- mutationFn: (d: RoutePostType) => postRouteReq(req, produceRoute(d)),
+ mutationFn: (d: RoutePostType) =>
+ postRouteReq(
+ req,
+ pipeProduce(
+ produceRmEmptyUpstreamFields,
+ produceRoute
+ )(d) as APISIXType['Route']
+ ),
async onSuccess(res) {
notifications.show({
message: t('info.add.success', { name: t('routes.singular') }),
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index 48f124783..7fc8856f3 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -17,13 +17,13 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Group, Skeleton } from '@mantine/core';
import { notifications } from '@mantine/notifications';
-import { useMutation, useQuery } from '@tanstack/react-query';
+import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import {
createFileRoute,
useNavigate,
useParams,
} from '@tanstack/react-router';
-import { useEffect, useMemo } from 'react';
+import { Suspense, useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBoolean } from 'react-use';
@@ -40,7 +40,11 @@ import {
produceRoute,
produceVarsToForm,
} from '@/components/form-slice/FormPartRoute/util';
-import { produceToUpstreamForm } from
'@/components/form-slice/FormPartUpstream/util';
+import {
+ produceRmEmptyUpstreamFields,
+ produceToNestedUpstreamForm,
+ 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';
@@ -48,6 +52,7 @@ import PageHeader from '@/components/page/PageHeader';
import { API_ROUTES } from '@/config/constant';
import { req } from '@/config/req';
import { type APISIXType } from '@/types/schema/apisix';
+import { pipeProduce } from '@/utils/producer';
type Props = {
readOnly: boolean;
@@ -59,18 +64,16 @@ const RouteDetailForm = (props: Props) => {
const { readOnly, setReadOnly, id } = props;
const { t } = useTranslation();
- const routeQuery = useQuery(getRouteQueryOptions(id));
- const { data: routeData, isLoading, refetch } = routeQuery;
+ const routeQuery = useSuspenseQuery(getRouteQueryOptions(id));
+ const { data: routeData, refetch } = routeQuery;
- const formDefaults = useMemo(() => {
- if (routeData?.value) {
- const upstreamProduced = produceToUpstreamForm(
- routeData.value.upstream || {},
- routeData.value
- );
- return produceVarsToForm(upstreamProduced);
- }
- }, [routeData]);
+ const formDefaults = useMemo(
+ () =>
+ produceVarsToForm(
+ produceToUpstreamForm(routeData.value.upstream || {}, routeData.value)
+ ),
+ [routeData.value]
+ );
const form = useForm({
resolver: zodResolver(RoutePutSchema),
@@ -78,17 +81,27 @@ const RouteDetailForm = (props: Props) => {
shouldFocusError: true,
mode: 'all',
disabled: readOnly,
+ defaultValues: formDefaults,
});
useEffect(() => {
- if (formDefaults && !isLoading) {
- form.reset(formDefaults);
- }
- }, [formDefaults, form, isLoading]);
+ if (!routeData.value) return;
+
+ const upstreamProduced = produceToNestedUpstreamForm(
+ routeData.value
+ );
+ form.reset(produceVarsToForm(upstreamProduced));
+ }, [routeData, form]);
const putRoute = useMutation({
mutationFn: (d: RoutePutType) =>
- putRouteReq(req, produceRoute(d) as APISIXType['Route']),
+ putRouteReq(
+ req,
+ pipeProduce(
+ produceRmEmptyUpstreamFields,
+ produceRoute
+ )(d) as APISIXType['Route']
+ ),
async onSuccess() {
notifications.show({
message: t('info.edit.success', { name: t('routes.singular') }),
@@ -97,12 +110,15 @@ const RouteDetailForm = (props: Props) => {
await refetch();
setReadOnly(true);
},
+ onError(err: Error & { response?: { data?: { error_msg?: string } } }) {
+ notifications.show({
+ title: 'Error',
+ message: err.response?.data?.error_msg || err.message || 'Failed to
update route',
+ color: 'red',
+ });
+ },
});
- if (isLoading) {
- return <Skeleton height={400} />;
- }
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => putRoute.mutateAsync(d))}>
@@ -155,13 +171,21 @@ export const RouteDetail = (props: RouteDetailProps) => {
),
})}
/>
- <FormTOCBox>
- <RouteDetailForm
- readOnly={readOnly}
- setReadOnly={setReadOnly}
- id={id}
- />
- </FormTOCBox>
+ <Suspense
+ fallback={
+ <FormTOCBox>
+ <Skeleton height={400} />
+ </FormTOCBox>
+ }
+ >
+ <FormTOCBox>
+ <RouteDetailForm
+ readOnly={readOnly}
+ setReadOnly={setReadOnly}
+ id={id}
+ />
+ </FormTOCBox>
+ </Suspense>
</>
);
};
diff --git a/src/routes/services/detail.$id/index.tsx
b/src/routes/services/detail.$id/index.tsx
index 4e4a518de..d6691d5b9 100644
--- a/src/routes/services/detail.$id/index.tsx
+++ b/src/routes/services/detail.$id/index.tsx
@@ -23,7 +23,7 @@ import {
useNavigate,
useParams,
} from '@tanstack/react-router';
-import { useEffect } from 'react';
+import { Suspense, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBoolean } from 'react-use';
@@ -32,7 +32,10 @@ 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 {
+ produceRmEmptyUpstreamFields,
+ produceToNestedUpstreamForm,
+} 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';
@@ -54,7 +57,7 @@ const ServiceDetailForm = (props: Props) => {
const { id } = useParams({ from: '/services/detail/$id' });
const serviceQuery = useSuspenseQuery(getServiceQueryOptions(id));
- const { data: serviceData, isLoading, refetch } = serviceQuery;
+ const { data: serviceData, refetch } = serviceQuery;
const form = useForm({
resolver: zodResolver(APISIX.Service),
@@ -62,19 +65,21 @@ const ServiceDetailForm = (props: Props) => {
shouldFocusError: true,
mode: 'all',
disabled: readOnly,
+ defaultValues: serviceData.value,
});
useEffect(() => {
- if (serviceData?.value && !isLoading) {
- form.reset(serviceData.value);
- }
- }, [serviceData, form, isLoading]);
+ form.reset(produceToNestedUpstreamForm(serviceData.value));
+ }, [serviceData, form]);
const putService = useMutation({
mutationFn: (d: APISIXType['Service']) =>
putServiceReq(
req,
- pipeProduce(produceRmUpstreamWhenHas('upstream_id'),
produceRmEmptyUpstreamFields)(d)
+ pipeProduce(
+ produceRmUpstreamWhenHas('upstream_id'),
+ produceRmEmptyUpstreamFields
+ )(d) as APISIXType['Service']
),
async onSuccess() {
notifications.show({
@@ -86,10 +91,6 @@ const ServiceDetailForm = (props: Props) => {
},
});
- if (isLoading) {
- return <Skeleton height={400} />;
- }
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => putService.mutateAsync(d))}>
@@ -140,9 +141,17 @@ function RouteComponent() {
),
})}
/>
- <FormTOCBox>
- <ServiceDetailForm readOnly={readOnly} setReadOnly={setReadOnly} />
- </FormTOCBox>
+ <Suspense
+ fallback={
+ <FormTOCBox>
+ <Skeleton height={400} />
+ </FormTOCBox>
+ }
+ >
+ <FormTOCBox>
+ <ServiceDetailForm readOnly={readOnly} setReadOnly={setReadOnly} />
+ </FormTOCBox>
+ </Suspense>
</>
);
}
diff --git a/src/routes/stream_routes/detail.$id.tsx
b/src/routes/stream_routes/detail.$id.tsx
index 4abffa63d..85a10e22e 100644
--- a/src/routes/stream_routes/detail.$id.tsx
+++ b/src/routes/stream_routes/detail.$id.tsx
@@ -15,15 +15,15 @@
* 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 { useMutation, useQuery } from '@tanstack/react-query';
+import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import {
createFileRoute,
useNavigate,
useParams,
} from '@tanstack/react-router';
-import { useEffect } from 'react';
+import { Suspense, useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBoolean } from 'react-use';
@@ -52,8 +52,8 @@ const StreamRouteDetailForm = (props: Props) => {
const { readOnly, setReadOnly, id } = props;
const { t } = useTranslation();
- const streamRouteQuery = useQuery(getStreamRouteQueryOptions(id));
- const { data: streamRouteData, isLoading, refetch } = streamRouteQuery;
+ const streamRouteQuery = useSuspenseQuery(getStreamRouteQueryOptions(id));
+ const { data: streamRouteData, refetch } = streamRouteQuery;
const form = useForm({
resolver: zodResolver(APISIX.StreamRoute),
@@ -61,13 +61,12 @@ const StreamRouteDetailForm = (props: Props) => {
shouldFocusError: true,
mode: 'all',
disabled: readOnly,
+ defaultValues: streamRouteData.value,
});
useEffect(() => {
- if (streamRouteData?.value && !isLoading) {
- form.reset(streamRouteData.value);
- }
- }, [streamRouteData, form, isLoading]);
+ form.reset(streamRouteData.value);
+ }, [streamRouteData, form]);
const putStreamRoute = useMutation({
mutationFn: (d: APISIXType['StreamRoute']) =>
@@ -82,10 +81,6 @@ const StreamRouteDetailForm = (props: Props) => {
},
});
- if (isLoading) {
- return <Skeleton height={400} />;
- }
-
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((d) => putStreamRoute.mutateAsync(d))}>
@@ -104,6 +99,8 @@ const StreamRouteDetailForm = (props: Props) => {
);
};
+
+
type StreamRouteDetailProps = Pick<Props, 'id'> & {
onDeleteSuccess: () => void;
};
@@ -139,13 +136,21 @@ export const StreamRouteDetail = (props:
StreamRouteDetailProps) => {
),
})}
/>
- <FormTOCBox>
- <StreamRouteDetailForm
- readOnly={readOnly}
- setReadOnly={setReadOnly}
- id={id}
- />
- </FormTOCBox>
+ <Suspense
+ fallback={
+ <FormTOCBox>
+ <Skeleton height={400} />
+ </FormTOCBox>
+ }
+ >
+ <FormTOCBox>
+ <StreamRouteDetailForm
+ readOnly={readOnly}
+ setReadOnly={setReadOnly}
+ id={id}
+ />
+ </FormTOCBox>
+ </Suspense>
</>
);
};
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index 28d2150e0..b03105720 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -62,7 +62,11 @@ const UpstreamAddForm = () => {
return (
<FormProvider {...form}>
- <form onSubmit={form.handleSubmit((d) => postUpstream.mutateAsync(d as
PostUpstreamType))}>
+ <form
+ onSubmit={form.handleSubmit((d) =>
+
postUpstream.mutateAsync(pipeProduce(produceRmEmptyUpstreamFields)(d))
+ )}
+ >
<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 9ed24dda9..1e972bc2c 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -27,15 +27,19 @@ import {
useNavigate,
useParams,
} from '@tanstack/react-router';
-import { useEffect, useMemo } from 'react';
+import { Suspense, useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useBoolean } from 'react-use';
+import type { z } from 'zod';
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 {
+ FormPartUpstreamSchema,
+ type FormPartUpstreamType,
+} from '@/components/form-slice/FormPartUpstream/schema';
import {
produceRmEmptyUpstreamFields,
produceToUpstreamForm,
@@ -67,20 +71,20 @@ const UpstreamDetailForm = (
const { t } = useTranslation();
const {
data: { value: upstreamData },
- isLoading,
refetch,
} = useSuspenseQuery(getUpstreamQueryOptions(id));
const formDefaults = useMemo(
- () => produceToUpstreamForm(upstreamData),
+ () => produceToUpstreamForm(upstreamData) as FormPartUpstreamType,
[upstreamData]
);
-
- const form = useForm({
+ type FormPartUpstreamInput = z.input<typeof FormPartUpstreamSchema>;
+ const form = useForm<FormPartUpstreamInput, unknown, FormPartUpstreamType>({
resolver: zodResolver(FormPartUpstreamSchema),
shouldUnregister: true,
mode: 'all',
disabled: readOnly,
+ defaultValues: formDefaults,
});
const putUpstream = useMutation({
@@ -110,21 +114,17 @@ const UpstreamDetailForm = (
});
useEffect(() => {
- if (upstreamData && !isLoading) {
- form.reset(formDefaults);
+ if (upstreamData) {
+ form.reset(produceToUpstreamForm(upstreamData));
}
- }, [formDefaults, form, isLoading, upstreamData]);
-
- if (isLoading) {
- return <Skeleton height={400} />;
- }
+ }, [upstreamData, form]);
return (
<FormTOCBox>
<FormProvider {...form}>
<form
- onSubmit={form.handleSubmit((d) => {
- putUpstream.mutateAsync(d as APISIXType['Upstream']);
+ onSubmit={form.handleSubmit((d: FormPartUpstreamType) => {
+
putUpstream.mutateAsync(pipeProduce(produceRmEmptyUpstreamFields)(d));
})}
>
<FormSectionGeneral readOnly />
@@ -175,11 +175,13 @@ function RouteComponent() {
),
})}
/>
- <UpstreamDetailForm
- id={id}
- readOnly={readOnly}
- setReadOnly={setReadOnly}
- />
+ <Suspense fallback={<Skeleton height={400} />}>
+ <UpstreamDetailForm
+ id={id}
+ readOnly={readOnly}
+ setReadOnly={setReadOnly}
+ />
+ </Suspense>
</>
);
}