diff --git a/e2e/tests/routes.clear-upstream-field.spec.ts b/e2e/tests/routes.clear-upstream-field.spec.ts index 7d625f0436..18b558c93e 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(); // 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 4f1604a4b6..e8b76f6778 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 55261ae40e..b25e0a15d4 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 0000000000..66fa224077 --- /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 764a785423..23c005c5c6 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 21a26c44fc..e97ec85ab8 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, - page: Page = ctx as Page + upstream: Partial = {}, + 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 cb99f71776..50b4c45596 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(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 9518803339..ef2439aa28 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 & { 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 0bb13c13ff..6f91fb60cb 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 = ( 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 = ( }, 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 = ( 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 = ( ob.setDisabled(disabled); }, [disabled, ob]); - const ref = useClickOutside(() => { - const vals = parseToUpstreamNodes(toJS(ob.values)); - fOnChange?.(vals); - restProps.onChange?.(vals); - }, ['mouseup', 'touchend', 'mousedown', 'touchstart']); + return ( ( label={label} required={required} withAsterisk={withAsterisk} - ref={ref} > @@ -240,7 +241,10 @@ export const FormItemNodes = ( variant="transparent" size="compact-xs" px={0} - onClick={() => ob.remove(row.id)} + onClick={() => { + ob.remove(row.id); + ob.save(); + }} > {t('form.btn.delete')} , @@ -256,7 +260,10 @@ export const FormItemNodes = ( 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 1e9c62692f..7b962222f8 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; diff --git a/src/components/form-slice/FormPartUpstream/util.ts b/src/components/form-slice/FormPartUpstream/util.ts index f4bf2b0f9c..7229e57494 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) => { + const d = draft as Record & { + upstream?: Record; + 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; + } + // 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) => Object.values(obj).every( diff --git a/src/components/form/Editor.tsx b/src/components/form/Editor.tsx index f621ca313f..4c6fb47c2b 100644 --- a/src/components/form/Editor.tsx +++ b/src/components/form/Editor.tsx @@ -163,7 +163,7 @@ export const FormItemEditor = ( 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 ac26e94da0..47d37a26bf 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; @@ -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 48f1247830..7fc8856f3b 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 ; - } - return (
putRoute.mutateAsync(d))}> @@ -155,13 +171,21 @@ export const RouteDetail = (props: RouteDetailProps) => { ), })} /> - - - + + + + } + > + + + + ); }; diff --git a/src/routes/services/detail.$id/index.tsx b/src/routes/services/detail.$id/index.tsx index 4e4a518def..d6691d5b9f 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 ; - } - return ( putService.mutateAsync(d))}> @@ -140,9 +141,17 @@ function RouteComponent() { ), })} /> - - - + + + + } + > + + + + ); } diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index 4abffa63d9..85a10e22e6 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 ; - } - return ( putStreamRoute.mutateAsync(d))}> @@ -104,6 +99,8 @@ const StreamRouteDetailForm = (props: Props) => { ); }; + + type StreamRouteDetailProps = Pick & { onDeleteSuccess: () => void; }; @@ -139,13 +136,21 @@ export const StreamRouteDetail = (props: StreamRouteDetailProps) => { ), })} /> - - - + + + + } + > + + + + ); }; diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx index 28d2150e07..b03105720e 100644 --- a/src/routes/upstreams/add.tsx +++ b/src/routes/upstreams/add.tsx @@ -62,7 +62,11 @@ const UpstreamAddForm = () => { return ( - postUpstream.mutateAsync(d as PostUpstreamType))}> + + postUpstream.mutateAsync(pipeProduce(produceRmEmptyUpstreamFields)(d)) + )} + > {t('form.btn.add')} diff --git a/src/routes/upstreams/detail.$id.tsx b/src/routes/upstreams/detail.$id.tsx index 9ed24dda9c..1e972bc2c8 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; + const form = useForm({ 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 ; - } + }, [upstreamData, form]); return (
{ - putUpstream.mutateAsync(d as APISIXType['Upstream']); + onSubmit={form.handleSubmit((d: FormPartUpstreamType) => { + putUpstream.mutateAsync(pipeProduce(produceRmEmptyUpstreamFields)(d)); })} > @@ -175,11 +175,13 @@ function RouteComponent() { ), })} /> - + }> + + ); }