This is an automated email from the ASF dual-hosted git repository.

guoqqqi 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 ba6e7c0a5 fix: unify stream-route submit pipelines, drop phantom 
plugin_config_id (#3437)
ba6e7c0a5 is described below

commit ba6e7c0a5395f795eef81789c06ade0ff7f029f9
Author: Yuhan <[email protected]>
AuthorDate: Mon Jul 27 11:09:11 2026 +0800

    fix: unify stream-route submit pipelines, drop phantom plugin_config_id 
(#3437)
---
 ...ream-routes.no-phantom-plugin-config-id.spec.ts | 107 +++++++++++++++++++++
 src/components/form-slice/FormPartRoute/index.tsx  |  23 +++--
 .../form-slice/FormPartStreamRoute/index.tsx       |   4 +-
 .../form-slice/FormPartStreamRoute/util.test.ts    |  93 ++++++++++++++++++
 .../form-slice/FormPartStreamRoute/util.ts         |  42 ++++----
 src/routes/stream_routes/detail.$id.tsx            |   4 +-
 src/types/schema/apisix/stream_routes.ts           |   5 +-
 7 files changed, 248 insertions(+), 30 deletions(-)

diff --git 
a/e2e/tests/regression/stream-routes.no-phantom-plugin-config-id.spec.ts 
b/e2e/tests/regression/stream-routes.no-phantom-plugin-config-id.spec.ts
new file mode 100644
index 000000000..8df71a30c
--- /dev/null
+++ b/e2e/tests/regression/stream-routes.no-phantom-plugin-config-id.spec.ts
@@ -0,0 +1,107 @@
+/**
+ * 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.
+ */
+
+// Regression for a data-integrity item of apache/apisix-dashboard#3417:
+// the stream-route form reused the HTTP-route plugins section, which
+// renders a "Plugin Config ID" input — a field the stream_routes
+// resource does not have (the Admin API rejects it with 400, and the
+// dashboard's zod resolver strips it before the request): anything the
+// user typed there was silently discarded on a "successful" save.
+
+import { routesPom } from '@e2e/pom/routes';
+import { streamRoutesPom } from '@e2e/pom/stream_routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllStreamRoutes } from '@/apis/stream_routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.beforeAll(async () => {
+  await deleteAllStreamRoutes(e2eReq);
+});
+
+test.afterAll(async () => {
+  await deleteAllStreamRoutes(e2eReq);
+});
+
+test('stream route forms do not offer the unsupported Plugin Config ID', async 
({
+  page,
+}) => {
+  await streamRoutesPom.toAdd(page);
+  await streamRoutesPom.isAddPage(page);
+  // the plugins section itself must stay
+  await expect(
+    page.getByRole('button', { name: 'Select Plugins' })
+  ).toBeVisible();
+  await expect(page.getByLabel('Plugin Config ID')).toHaveCount(0);
+
+  const name = randomId('reg-no-pcid');
+  const res = await e2eReq.put<{ value: APISIXType['StreamRoute'] }>(
+    `/stream_routes/${name}`,
+    {
+      server_port: 9100,
+      upstream: { type: 'roundrobin', nodes: { 'no-pcid.local:80': 1 } },
+    }
+  );
+  await uiGoto(page, '/stream_routes/detail/$id', { id: res.data.value.id });
+  await streamRoutesPom.isDetailPage(page);
+  await expect(page.getByLabel('Plugin Config ID')).toHaveCount(0);
+});
+
+// #3437 review: the Admin API accepts a stream-route name, so it must
+// survive a dashboard edit-save instead of being deleted by the producer.
+test('a stream route keeps its name across a no-op edit-save', async ({
+  page,
+}) => {
+  const id = randomId('reg-sr-name');
+  const name = `sr name ${id}`;
+  const res = await e2eReq.put<{ value: APISIXType['StreamRoute'] }>(
+    `/stream_routes/${id}`,
+    {
+      name,
+      server_port: 9100,
+      upstream: { type: 'roundrobin', nodes: { 'sr-name.local:80': 1 } },
+    }
+  );
+
+  await uiGoto(page, '/stream_routes/detail/$id', { id: res.data.value.id });
+  await streamRoutesPom.isDetailPage(page);
+  // the name must render (form now shows it) and survive edit-save
+  await expect(page.locator('input[name="name"]')).toHaveValue(name);
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByRole('button', { name: 'Save' }).click();
+  await expect(
+    page.getByRole('alert').filter({ hasText: /success/i })
+  ).toBeVisible();
+
+  const after = await e2eReq.get<{ value: APISIXType['StreamRoute'] }>(
+    `/stream_routes/${res.data.value.id}`
+  );
+  expect((after.data.value as { name?: string }).name).toBe(name);
+});
+
+test('the HTTP route form still offers Plugin Config ID', async ({ page }) => {
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+  await routesPom.getAddRouteBtn(page).click();
+  await routesPom.isAddPage(page);
+  await expect(page.getByLabel('Plugin Config ID')).toBeVisible();
+});
diff --git a/src/components/form-slice/FormPartRoute/index.tsx 
b/src/components/form-slice/FormPartRoute/index.tsx
index bb483eba0..9d3638954 100644
--- a/src/components/form-slice/FormPartRoute/index.tsx
+++ b/src/components/form-slice/FormPartRoute/index.tsx
@@ -125,17 +125,26 @@ export const FormSectionUpstream = () => {
   );
 };
 
-export const FormSectionPlugins = () => {
+export type FormSectionPluginsProps = {
+  /** stream_routes has no plugin_config_id — the Admin API rejects it */
+  showConfigId?: boolean;
+};
+export const FormSectionPlugins = (props: FormSectionPluginsProps) => {
+  const { showConfigId = true } = props;
   const { t } = useTranslation();
   const { control } = useFormContext<RoutePostType>();
   return (
     <FormSection legend={t('form.plugins.label')}>
-      <FormItemTextInput
-        control={control}
-        name="plugin_config_id"
-        label={t('form.plugins.configId')}
-      />
-      <Divider my="xs" label={t('or')} />
+      {showConfigId && (
+        <>
+          <FormItemTextInput
+            control={control}
+            name="plugin_config_id"
+            label={t('form.plugins.configId')}
+          />
+          <Divider my="xs" label={t('or')} />
+        </>
+      )}
       <FormItemPlugins name="plugins" />
     </FormSection>
   );
diff --git a/src/components/form-slice/FormPartStreamRoute/index.tsx 
b/src/components/form-slice/FormPartStreamRoute/index.tsx
index 5c0bdf5ae..4614776be 100644
--- a/src/components/form-slice/FormPartStreamRoute/index.tsx
+++ b/src/components/form-slice/FormPartStreamRoute/index.tsx
@@ -97,11 +97,11 @@ const FormSectionStreamRouteProtocol = () => {
 export const FormPartStreamRoute = () => {
   return (
     <>
-      <FormPartBasic showName={false} />
+      <FormPartBasic />
       <FormSectionStreamRouteBasic />
       <FormSectionService />
       <FormSectionUpstream />
-      <FormSectionPlugins />
+      <FormSectionPlugins showConfigId={false} />
       <FormSectionStreamRouteProtocol />
     </>
   );
diff --git a/src/components/form-slice/FormPartStreamRoute/util.test.ts 
b/src/components/form-slice/FormPartStreamRoute/util.test.ts
new file mode 100644
index 000000000..b47e27bb9
--- /dev/null
+++ b/src/components/form-slice/FormPartStreamRoute/util.test.ts
@@ -0,0 +1,93 @@
+/**
+ * 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 { describe, expect, it } from 'vitest';
+
+import type { StreamRoutePostType } from './schema';
+import { produceStreamRoute } from './util';
+
+// Regression for a data-integrity item of #3417: stream routes were the
+// only resource whose create and edit paths ran DIFFERENT cleaning
+// pipelines — create used a bare pipe without pipeProduce (no __-key
+// removal, no empty-value cleaning, no empty-plugin restore; only the
+// zod resolver's key-stripping stood between UI flags and the Admin API,
+// which rejects unknown root keys with 400), while edit borrowed the
+// HTTP-route producer. produceStreamRoute now wraps pipeProduce and is
+// used by both paths.
+
+const base = {
+  server_port: 9100,
+  upstream: { type: 'roundrobin', nodes: { 'a.local:80': 1 } },
+} as unknown as StreamRoutePostType;
+
+describe('produceStreamRoute', () => {
+  it('strips __-prefixed UI flags', () => {
+    const val = {
+      ...base,
+      __checksEnabled: true,
+      upstream: {
+        ...base.upstream,
+        __checksPassiveEnabled: false,
+      },
+    } as unknown as StreamRoutePostType;
+    const out = produceStreamRoute(val) as Record<string, unknown>;
+    expect('__checksEnabled' in out).toBe(false);
+    expect(
+      '__checksPassiveEnabled' in (out.upstream as Record<string, unknown>)
+    ).toBe(false);
+  });
+
+  it('cleans empty-string fields', () => {
+    const val = { ...base, desc: '' } as unknown as StreamRoutePostType;
+    const out = produceStreamRoute(val) as Record<string, unknown>;
+    expect('desc' in out).toBe(false);
+  });
+
+  it('preserves plugins with empty config', () => {
+    const val = {
+      ...base,
+      plugins: { 'key-auth': {} },
+    } as unknown as StreamRoutePostType;
+    const out = produceStreamRoute(val) as Record<string, unknown>;
+    expect(out.plugins).toEqual({ 'key-auth': {} });
+  });
+
+  // #3437 review (LiteSun): the Admin API accepts a stream-route `name`
+  // (201) but rejects `status` (400). The producer must preserve name so
+  // an API-created named stream route does not lose its name on an
+  // edit-save, while still stripping the unsupported status.
+  it('keeps name, strips status, and drops empty protocol', () => {
+    const val = {
+      ...base,
+      name: 'n1',
+      status: 1,
+      protocol: { conf: {} },
+    } as unknown as StreamRoutePostType;
+    const out = produceStreamRoute(val) as Record<string, unknown>;
+    expect(out.name).toBe('n1');
+    expect('status' in out).toBe(false);
+    expect('protocol' in out).toBe(false);
+  });
+
+  it('still drops the inline upstream when a reference id is present', () => {
+    const val = {
+      ...base,
+      upstream_id: 'u1',
+    } as unknown as StreamRoutePostType;
+    const out = produceStreamRoute(val) as Record<string, unknown>;
+    expect('upstream' in out).toBe(false);
+  });
+});
diff --git a/src/components/form-slice/FormPartStreamRoute/util.ts 
b/src/components/form-slice/FormPartStreamRoute/util.ts
index 1b26d84dd..a2f8237b5 100644
--- a/src/components/form-slice/FormPartStreamRoute/util.ts
+++ b/src/components/form-slice/FormPartStreamRoute/util.ts
@@ -15,28 +15,34 @@
  * limitations under the License.
  */
 import { produce } from 'immer';
-import { pipe } from 'rambdax';
 
 import { produceRmEmptyUpstreamFields } from 
'@/components/form-slice/FormPartUpstream/util';
 import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
+import { pipeProduce } from '@/utils/producer';
 
 import type { StreamRoutePostType } from './schema';
 
-export const produceStreamRoute = (val: StreamRoutePostType) =>
-  pipe(
-    produceRmEmptyUpstreamFields,
-    (produceRmUpstreamWhenHas('service_id', 'upstream_id') as unknown as (
-      d: StreamRoutePostType
-    ) => StreamRoutePostType),
-    produce((draft: StreamRoutePostType) => {
-      // Stream Routes do not support name and status
-      const d = draft as StreamRoutePostType & { name?: string; status?: 
number };
-      delete d.name;
-      delete d.status;
+/**
+ * Shared by BOTH the create and edit paths — stream routes used to be the
+ * only resource whose two paths ran different cleaning pipelines (create
+ * skipped pipeProduce entirely; edit borrowed the HTTP-route producer).
+ * pipeProduce supplies the __-flag removal, empty-value cleaning and
+ * empty-plugin restore every other resource gets (#3417).
+ */
+export const produceStreamRoute = pipeProduce(
+  produceRmUpstreamWhenHas('service_id', 'upstream_id'),
+  produceRmEmptyUpstreamFields,
+  produce((draft: StreamRoutePostType) => {
+    // The Admin API accepts a stream-route `name` (kept — the form renders
+    // it, and an API-created name must survive an edit-save, #3437 review)
+    // but rejects `status` (stripped; the form never renders it, but a
+    // reused generic component could introduce it).
+    const d = draft as StreamRoutePostType & { status?: number };
+    delete d.status;
 
-      // Cleanup protocol if name is missing
-      if (draft.protocol && !draft.protocol.name) {
-        delete draft.protocol;
-      }
-    })
-  )(val);
+    // Cleanup protocol if name is missing
+    if (draft.protocol && !draft.protocol.name) {
+      delete draft.protocol;
+    }
+  })
+);
diff --git a/src/routes/stream_routes/detail.$id.tsx 
b/src/routes/stream_routes/detail.$id.tsx
index 6682a7a65..3b66a7618 100644
--- a/src/routes/stream_routes/detail.$id.tsx
+++ b/src/routes/stream_routes/detail.$id.tsx
@@ -31,8 +31,8 @@ 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 { produceStreamRoute } from 
'@/components/form-slice/FormPartStreamRoute/util';
 import { produceToNestedUpstreamForm } from 
'@/components/form-slice/FormPartUpstream/util';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
@@ -79,7 +79,7 @@ const StreamRouteDetailForm = (props: Props) => {
 
   const putStreamRoute = useMutation({
     mutationFn: (d: APISIXType['StreamRoute']) =>
-      putStreamRouteReq(req, produceRoute(d)),
+      putStreamRouteReq(req, produceStreamRoute(d)),
     async onSuccess() {
       notifications.show({
         message: t('info.edit.success', { name: t('streamRoutes.singular') }),
diff --git a/src/types/schema/apisix/stream_routes.ts 
b/src/types/schema/apisix/stream_routes.ts
index 77757abe0..c1f75d776 100644
--- a/src/types/schema/apisix/stream_routes.ts
+++ b/src/types/schema/apisix/stream_routes.ts
@@ -45,7 +45,10 @@ const StreamRoute = z
     protocol: StreamRouteProtocol.partial().optional(),
   })
   .partial()
-  .merge(APISIXCommon.Basic.omit({ name: true, status: true }))
+  // the Admin API accepts name/desc/labels on a stream route but rejects
+  // status — omit only status so the edit form (which resolves against
+  // this schema) does not strip a stored name (#3437 review)
+  .merge(APISIXCommon.Basic.omit({ status: true }))
   .merge(APISIXCommon.Info);
 
 export const APISIXStreamRoutes = {

Reply via email to