This is an automated email from the ASF dual-hosted git repository. young 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 127391c9e fix(routes, stream_routes): clear upstream when has upstream_id (#3151) 127391c9e is described below commit 127391c9eddc5ee2b91ec911552287b7494da632 Author: YYYoung <isk...@outlook.com> AuthorDate: Fri Jul 25 15:20:53 2025 +0800 fix(routes, stream_routes): clear upstream when has upstream_id (#3151) * fix(routes, stream_routes): clear upstream when has upstream_id * test: add related cases --- e2e/tests/routes.clear-upstream-field.spec.ts | 258 ++++++++++++++++++++++++ e2e/utils/ui/routes.ts | 33 +++ src/components/form-slice/FormPartRoute/util.ts | 22 ++ src/routes/routes/add.tsx | 6 +- src/routes/routes/detail.$id.tsx | 6 +- src/routes/stream_routes/add.tsx | 8 +- src/routes/stream_routes/detail.$id.tsx | 8 +- src/utils/form-producer.ts | 8 +- 8 files changed, 327 insertions(+), 22 deletions(-) diff --git a/e2e/tests/routes.clear-upstream-field.spec.ts b/e2e/tests/routes.clear-upstream-field.spec.ts new file mode 100644 index 000000000..599a91d1f --- /dev/null +++ b/e2e/tests/routes.clear-upstream-field.spec.ts @@ -0,0 +1,258 @@ +/** + * 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 { uiHasToastMsg } from '@e2e/utils/ui'; +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 type { APISIXType } from '@/types/schema/apisix'; + +const upstreamName = randomId('test-upstream'); +const serviceName = randomId('test-service'); +const routeNameForUpstreamId = randomId('test-route-upstream-id'); +const routeNameForServiceId = randomId('test-route-service-id'); +const routeUri1 = '/test-route-upstream-id'; +const routeUri2 = '/test-route-service-id'; + +const upstreamNodes: APISIXType['UpstreamNode'][] = [ + { host: 'test.com', port: 80, weight: 100 }, + { host: 'test2.com', port: 80, weight: 100 }, +]; + +let testUpstreamId: string; +let testServiceId: string; + +// Common helper functions +async function fillBasicRouteFields( + page: Page, + routeName: string, + routeUri: string, + method: string +) { + await page.getByLabel('Name', { exact: true }).first().fill(routeName); + await page.getByLabel('URI', { exact: true }).fill(routeUri); + + // Select HTTP method + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: method }).click(); +} + +async function fillUpstreamFields( + page: Page, + upstreamName: string, + upstreamDesc: string +) { + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + + await uiFillUpstreamRequiredFields(upstreamSection, { + nodes: upstreamNodes, + name: upstreamName, + desc: upstreamDesc, + }); + + return upstreamSection; +} + +async function verifyRouteData( + page: Page, + expectedIdField: 'upstream_id' | 'service_id', + expectedIdValue: string +) { + await routesPom.isDetailPage(page); + + // Get the route ID from URL + const url = page.url(); + const routeId = url.split('/').pop(); + expect(routeId).toBeDefined(); + + // Fetch route data via API to verify the upstream field was cleared + const routeResponse = await getRouteReq(e2eReq, routeId!); + const routeData = routeResponse.value; + + // Verify the expected ID field is preserved + expect(routeData[expectedIdField]).toBe(expectedIdValue); + + // Verify upstream field is cleared (should be undefined or empty) + expect(routeData.upstream).toBeUndefined(); + + // Verify in UI - the ID field should have the value and be disabled + const idField = page.locator(`input[name="${expectedIdField}"]`); + await expect(idField).toHaveValue(expectedIdValue); + await expect(idField).toBeDisabled(); + + return routeId!; +} + +async function editRouteAndAddUpstream( + page: Page, + upstreamName: string, + upstreamDesc: string +) { + // Click Edit button to enter edit mode + 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(); + + // Add upstream configuration + await fillUpstreamFields(page, upstreamName, upstreamDesc); + + // Submit the changes + await page.getByRole('button', { name: 'Save' }).click(); + await uiHasToastMsg(page, { + hasText: 'success', + }); +} + +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, + nodes: upstreamNodes, + }); + testUpstreamId = upstreamResponse.data.value.id; + + // Create a test service for testing service_id scenario + const serviceResponse = await postServiceReq(e2eReq, { + name: serviceName, + desc: 'Test service for route upstream field clearing', + }); + testServiceId = serviceResponse.data.value.id; +}); + +test.afterAll(async () => { + await deleteAllRoutes(e2eReq); + await deleteAllServices(e2eReq); + await deleteAllUpstreams(e2eReq); +}); + +test('should clear upstream field when upstream_id exists (create and edit)', async ({ + page, +}) => { + await routesPom.toAdd(page); + + await test.step('create route with both upstream and upstream_id', async () => { + // Fill basic route fields + await fillBasicRouteFields(page, routeNameForUpstreamId, routeUri1, 'GET'); + + // Fill upstream fields + const upstreamSection = await fillUpstreamFields( + page, + 'test-upstream-inline', + 'test inline upstream' + ); + + // Set upstream_id (this should cause upstream field to be cleared) + const upstreamIdInput = upstreamSection.locator( + 'input[name="upstream_id"]' + ); + await upstreamIdInput.fill(testUpstreamId); + + // verify upstream_id has value + await expect(upstreamIdInput).toHaveValue(testUpstreamId); + + // Submit the form + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Route Successfully', + }); + }); + + await test.step('verify upstream field is cleared after creation', async () => { + await verifyRouteData(page, 'upstream_id', testUpstreamId); + }); + + await test.step('edit route and add upstream configuration again', async () => { + await editRouteAndAddUpstream( + page, + 'test-upstream-edit-1', + 'test upstream for editing' + ); + }); + + await test.step('verify upstream field is still cleared after editing', async () => { + await verifyRouteData(page, 'upstream_id', testUpstreamId); + await uiDeleteRoute(page); + }); +}); + +test('should clear upstream field when service_id exists (create and edit)', async ({ + page, +}) => { + await routesPom.toAdd(page); + + await test.step('create route with both upstream and service_id', async () => { + // Fill basic route fields + await fillBasicRouteFields(page, routeNameForServiceId, routeUri2, 'POST'); + + // Fill upstream fields + await fillUpstreamFields( + page, + 'test-upstream-inline-2', + 'test inline upstream 2' + ); + + // Set service_id (this should cause upstream field to be cleared) + const serviceSection = page.getByRole('group', { name: 'Service' }); + await serviceSection + .locator('input[name="service_id"]') + .fill(testServiceId); + // verify service_id has value + await expect(page.getByLabel('Service ID', { exact: true })).toHaveValue( + testServiceId + ); + + // Submit the form + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Route Successfully', + }); + }); + + await test.step('verify upstream field is cleared after creation', async () => { + await verifyRouteData(page, 'service_id', testServiceId); + }); + + await test.step('edit route and add upstream configuration again', async () => { + await editRouteAndAddUpstream( + page, + 'test-upstream-edit-2', + 'test upstream for editing 2' + ); + }); + + await test.step('verify upstream field is still cleared after editing', async () => { + await verifyRouteData(page, 'service_id', testServiceId); + await uiDeleteRoute(page); + }); +}); diff --git a/e2e/utils/ui/routes.ts b/e2e/utils/ui/routes.ts new file mode 100644 index 000000000..3a2209167 --- /dev/null +++ b/e2e/utils/ui/routes.ts @@ -0,0 +1,33 @@ +/** + * 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 type { Page } from '@playwright/test'; + +import { uiHasToastMsg } from '.'; + +export async function uiDeleteRoute(page: Page) { + // Delete the route for cleanup + await page.getByRole('button', { name: 'Delete' }).click(); + await page + .getByRole('dialog', { name: 'Delete Route' }) + .getByRole('button', { name: 'Delete' }) + .click(); + await uiHasToastMsg(page, { + hasText: 'Delete Route Successfully', + }); + await routesPom.isIndexPage(page); +} diff --git a/src/components/form-slice/FormPartRoute/util.ts b/src/components/form-slice/FormPartRoute/util.ts new file mode 100644 index 000000000..9ae7f3cc3 --- /dev/null +++ b/src/components/form-slice/FormPartRoute/util.ts @@ -0,0 +1,22 @@ +/** + * 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 { produceRmUpstreamWhenHas } from '@/utils/form-producer'; +import { pipeProduce } from '@/utils/producer'; + +export const produceRoute = pipeProduce( + produceRmUpstreamWhenHas('service_id', 'upstream_id') +); diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx index c6b62fc62..ac26e94da 100644 --- a/src/routes/routes/add.tsx +++ b/src/routes/routes/add.tsx @@ -28,12 +28,11 @@ import { RoutePostSchema, type RoutePostType, } from '@/components/form-slice/FormPartRoute/schema'; +import { produceRoute } from '@/components/form-slice/FormPartRoute/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 { produceRmUpstreamWhenHas } from '@/utils/form-producer'; -import { pipeProduce } from '@/utils/producer'; type Props = { navigate: (res: APISIXType['RespRouteDetail']) => Promise<void>; @@ -45,8 +44,7 @@ export const RouteAddForm = (props: Props) => { const { t } = useTranslation(); const postRoute = useMutation({ - mutationFn: (d: RoutePostType) => - postRouteReq(req, pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)), + mutationFn: (d: RoutePostType) => postRouteReq(req, produceRoute(d)), 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 c9f6903bc..1f5ab4724 100644 --- a/src/routes/routes/detail.$id.tsx +++ b/src/routes/routes/detail.$id.tsx @@ -32,6 +32,7 @@ import { getRouteQueryOptions } from '@/apis/hooks'; import { putRouteReq } from '@/apis/routes'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; +import { produceRoute } from '@/components/form-slice/FormPartRoute/util'; import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/util'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; @@ -40,8 +41,6 @@ import PageHeader from '@/components/page/PageHeader'; import { API_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; -import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; -import { pipeProduce } from '@/utils/producer'; type Props = { readOnly: boolean; @@ -73,8 +72,7 @@ const RouteDetailForm = (props: Props) => { }, [routeData, form, isLoading]); const putRoute = useMutation({ - mutationFn: (d: APISIXType['Route']) => - putRouteReq(req, pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)), + mutationFn: (d: APISIXType['Route']) => putRouteReq(req, produceRoute(d)), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('routes.singular') }), diff --git a/src/routes/stream_routes/add.tsx b/src/routes/stream_routes/add.tsx index a0884fa68..39f02ebd7 100644 --- a/src/routes/stream_routes/add.tsx +++ b/src/routes/stream_routes/add.tsx @@ -23,6 +23,7 @@ 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, @@ -33,8 +34,6 @@ import PageHeader from '@/components/page/PageHeader'; import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent'; import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; -import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; -import { pipeProduce } from '@/utils/producer'; type Props = { navigate: (res: APISIXType['RespStreamRouteDetail']) => Promise<void>; @@ -47,10 +46,7 @@ export const StreamRouteAddForm = (props: Props) => { const postStreamRoute = useMutation({ mutationFn: (d: StreamRoutePostType) => - postStreamRouteReq( - req, - pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) - ), + postStreamRouteReq(req, produceRoute(d)), async onSuccess(res) { notifications.show({ message: t('info.add.success', { name: t('streamRoutes.singular') }), diff --git a/src/routes/stream_routes/detail.$id.tsx b/src/routes/stream_routes/detail.$id.tsx index 4dbcf2b6f..4abffa63d 100644 --- a/src/routes/stream_routes/detail.$id.tsx +++ b/src/routes/stream_routes/detail.$id.tsx @@ -31,6 +31,7 @@ import { useBoolean } from 'react-use'; import { getStreamRouteQueryOptions } from '@/apis/hooks'; import { putStreamRouteReq } 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 { FormTOCBox } from '@/components/form-slice/FormSection'; import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; @@ -40,8 +41,6 @@ import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_route import { API_STREAM_ROUTES } from '@/config/constant'; import { req } from '@/config/req'; import { APISIX, type APISIXType } from '@/types/schema/apisix'; -import { produceRmUpstreamWhenHas } from '@/utils/form-producer'; -import { pipeProduce } from '@/utils/producer'; type Props = { readOnly: boolean; @@ -72,10 +71,7 @@ const StreamRouteDetailForm = (props: Props) => { const putStreamRoute = useMutation({ mutationFn: (d: APISIXType['StreamRoute']) => - putStreamRouteReq( - req, - pipeProduce(produceRmUpstreamWhenHas('service_id'))(d) - ), + putStreamRouteReq(req, produceRoute(d)), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('streamRoutes.singular') }), diff --git a/src/utils/form-producer.ts b/src/utils/form-producer.ts index 7686e8300..3765cf1a5 100644 --- a/src/utils/form-producer.ts +++ b/src/utils/form-producer.ts @@ -36,7 +36,11 @@ export const produceTime = produce<Partial<APISIXType['Info']>>((draft) => { if (draft.update_time) delete draft.update_time; }); -export const produceRmUpstreamWhenHas = (idKey: 'upstream_id' | 'service_id') => +export const produceRmUpstreamWhenHas = ( + ...idKeys: ('upstream_id' | 'service_id')[] +) => produce((draft) => { - if (draft[idKey] && isNotEmpty(draft.upstream)) delete draft.upstream; + if (idKeys.some((idKey) => draft[idKey]) && isNotEmpty(draft.upstream)) { + delete draft.upstream; + } });