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;
+    }
   });

Reply via email to