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 1c5c40840 test(hot-path): upstream -> service -> route (#3076)
1c5c40840 is described below

commit 1c5c40840cf452b4de5eb96bdf05c53ed35d637d
Author: YYYoung <isk...@outlook.com>
AuthorDate: Fri May 23 10:18:59 2025 +0800

    test(hot-path): upstream -> service -> route (#3076)
---
 e2e/pom/services.ts                                |  60 +++
 e2e/tests/hot-path.upstream-service-route.spec.ts  | 435 +++++++++++++++++++++
 e2e/tests/routes.crud-all-fields.spec.ts           |  32 +-
 e2e/tests/routes.crud-required-fields.spec.ts      |  36 +-
 e2e/utils/ui/index.ts                              |   9 +
 src/apis/routes.ts                                 |  21 +-
 src/apis/services.ts                               |  20 +-
 src/apis/upstreams.ts                              |  26 +-
 .../form-slice/FormItemPlugins/index.tsx           |   2 +-
 src/config/constant.ts                             |   2 +
 src/routes/routes/add.tsx                          |  11 +-
 src/routes/routes/detail.$id.tsx                   |   4 +-
 src/routes/services/add.tsx                        |  14 +-
 src/routes/services/detail.$id.tsx                 |   6 +-
 src/utils/form-producer.ts                         |   7 +-
 src/utils/producer.ts                              |  21 +-
 16 files changed, 626 insertions(+), 80 deletions(-)

diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts
new file mode 100644
index 000000000..b3b4685d1
--- /dev/null
+++ b/e2e/pom/services.ts
@@ -0,0 +1,60 @@
+/**
+ * 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 { uiGoto } from '@e2e/utils/ui';
+import { expect, type Page } from '@playwright/test';
+
+const locator = {
+  getServiceNavBtn: (page: Page) =>
+    page.getByRole('link', { name: 'Services', exact: true }),
+  getAddServiceBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add Service', exact: true }),
+  getAddBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add', exact: true }),
+};
+
+const assert = {
+  isIndexPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) => url.pathname.endsWith('/services'));
+    const title = page.getByRole('heading', { name: 'Services' });
+    await expect(title).toBeVisible();
+  },
+  isAddPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.endsWith('/services/add')
+    );
+    const title = page.getByRole('heading', { name: 'Add Service' });
+    await expect(title).toBeVisible();
+  },
+  isDetailPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.includes('/services/detail')
+    );
+    const title = page.getByRole('heading', { name: 'Service Detail' });
+    await expect(title).toBeVisible();
+  },
+};
+
+const goto = {
+  toIndex: (page: Page) => uiGoto(page, '/services'),
+  toAdd: (page: Page) => uiGoto(page, '/services/add'),
+};
+
+export const servicesPom = {
+  ...locator,
+  ...assert,
+  ...goto,
+};
diff --git a/e2e/tests/hot-path.upstream-service-route.spec.ts 
b/e2e/tests/hot-path.upstream-service-route.spec.ts
new file mode 100644
index 000000000..8f1c63bb8
--- /dev/null
+++ b/e2e/tests/hot-path.upstream-service-route.spec.ts
@@ -0,0 +1,435 @@
+/**
+ * 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 { servicesPom } from '@e2e/pom/services';
+import { upstreamsPom } from '@e2e/pom/upstreams';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiClearEditor, uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices } from '@/apis/services';
+import { deleteAllUpstreams } from '@/apis/upstreams';
+import { API_SERVICES, API_UPSTREAMS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+  await deleteAllServices(e2eReq);
+  await deleteAllUpstreams(e2eReq);
+});
+
+test('can create upstream -> service -> route', async ({ page }) => {
+  const selectPluginsBtn = page.getByRole('button', {
+    name: 'Select Plugins',
+  });
+  const selectPluginsDialog = page.getByRole('dialog', {
+    name: 'Select Plugins',
+  });
+
+  /**
+   * 1. Create Upstream
+   * Name: HTTPBIN Server
+   * Node
+   * Host:Port: httpbin.org:443
+   * Scheme: HTTPS
+   */
+  const upstream: Partial<APISIXType['Upstream']> = {
+    // will be set in test
+    id: undefined,
+    name: randomId('HTTPBIN Server'),
+    scheme: 'https',
+    nodes: [{ host: 'httpbin.org', port: 443 }],
+  };
+  await test.step('create upstream', async () => {
+    // Navigate to the upstream list page
+    await upstreamsPom.toIndex(page);
+    await upstreamsPom.isIndexPage(page);
+
+    // Click the add upstream button
+    await upstreamsPom.getAddUpstreamBtn(page).click();
+    await upstreamsPom.isAddPage(page);
+
+    // Fill in basic fields
+    await page.getByLabel('Name', { exact: true }).fill(upstream.name);
+
+    // Configure nodes section
+    const nodesSection = page.getByRole('group', { name: 'Nodes' });
+    const addNodeBtn = page.getByRole('button', { name: 'Add a Node' });
+
+    // Add node
+    await addNodeBtn.click();
+    const rows = nodesSection.locator('tr.ant-table-row');
+
+    // Fill host
+    const hostInput = rows.first().locator('input').first();
+    await hostInput.click();
+    await hostInput.fill(upstream.nodes[0].host);
+
+    // Fill port
+    const portInput = rows.first().locator('input').nth(1);
+    await portInput.click();
+    await portInput.fill(upstream.nodes[0].port.toString());
+
+    // Set scheme to HTTPS
+    await page.getByRole('textbox', { name: 'Scheme' }).click();
+    await page.getByRole('option', { name: upstream.scheme }).click();
+
+    const postReq = page.waitForResponse(
+      (r) => r.url().includes(API_UPSTREAMS) && r.request().method() === 'POST'
+    );
+    // Submit the form
+    await upstreamsPom.getAddBtn(page).click();
+
+    // Intercept the response, get id from response
+    const res = await postReq;
+    const data = (await res.json()) as 
APISIXType['RespUpstreamDetail']['data'];
+    expect(data).toHaveProperty('value.id');
+
+    // Wait for success message
+    await uiHasToastMsg(page, {
+      hasText: 'Add Upstream Successfully',
+    });
+    // Verify automatic redirection to detail page
+    await upstreamsPom.isDetailPage(page);
+
+    // Get id from url
+    const url = page.url();
+    const id = url.split('/').pop();
+    expect(id).toBeDefined();
+    expect(data.value.id).toBe(id);
+
+    // Set id to upstream
+    upstream.id = id;
+
+    // Verify the upstream name
+    const name = page.getByLabel('Name', { exact: true });
+    await expect(name).toHaveValue(upstream.name);
+    await expect(name).toBeDisabled();
+
+    // Verify scheme
+    const schemeField = page.getByRole('textbox', {
+      name: 'Scheme',
+      exact: true,
+    });
+    await expect(schemeField).toHaveValue(upstream.scheme);
+    await expect(schemeField).toBeDisabled();
+  });
+
+  /**
+   * 2. Create Service
+   * Name: HTTPBIN Service
+   * Upstream: Reference the upstream created above
+   * Plugins: Enable limit-count with custom configuration
+   */
+  const servicePluginName = 'limit-count';
+  const service: Partial<APISIXType['Service']> = {
+    // will be set in test
+    id: undefined,
+    name: randomId('HTTPBIN Service'),
+    upstream_id: upstream.id,
+    plugins: {
+      [servicePluginName]: {
+        count: 10,
+        time_window: 60,
+        rejected_code: 429,
+        key: 'remote_addr',
+      },
+    },
+  };
+  await test.step('create service', async () => {
+    // upstream id should be set
+    expect(service.upstream_id).not.toBeUndefined();
+
+    // Navigate to the services list page
+    await servicesPom.toIndex(page);
+    await servicesPom.isIndexPage(page);
+
+    // Click the add service button
+    await servicesPom.getAddServiceBtn(page).click();
+    await servicesPom.isAddPage(page);
+
+    // Fill in basic fields
+    await page.getByLabel('Name', { exact: true }).first().fill(service.name);
+
+    // Select upstream
+    const upstreamSection = page.getByRole('group', {
+      name: 'Upstream',
+      exact: true,
+    });
+
+    // Set upstream id
+    await upstreamSection
+      .locator('input[name="upstream_id"]')
+      .fill(upstream.id);
+
+    // Add plugins
+    await selectPluginsBtn.click();
+
+    // Search for plugin
+    const selectPluginsDialog = page.getByRole('dialog', {
+      name: 'Select Plugins',
+    });
+    const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+    await searchInput.fill(servicePluginName);
+
+    // Add the plugin
+    await selectPluginsDialog
+      .getByTestId(`plugin-${servicePluginName}`)
+      .getByRole('button', { name: 'Add' })
+      .click();
+
+    // Configure the plugin
+    const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+    const editorLoading = addPluginDialog.getByTestId('editor-loading');
+    await expect(editorLoading).toBeHidden();
+
+    // Clear the editor and add custom configuration
+    const editor = addPluginDialog.getByRole('code').getByRole('textbox');
+    await uiClearEditor(page);
+
+    // Add plugin configuration
+    await editor.fill(JSON.stringify(service.plugins?.[servicePluginName]));
+
+    // Add the plugin
+    await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+    await expect(addPluginDialog).toBeHidden();
+
+    // Verify the plugin was added
+    const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+    await expect(
+      pluginsSection.getByTestId(`plugin-${servicePluginName}`)
+    ).toBeVisible();
+
+    const postReq = page.waitForResponse(
+      (r) => r.url().includes(API_SERVICES) && r.request().method() === 'POST'
+    );
+    // Submit the form
+    await servicesPom.getAddBtn(page).click();
+
+    // intercept the response, get id from response
+    const response = await postReq;
+    const data =
+      (await response.json()) as APISIXType['RespServiceDetail']['data'];
+    expect(data).toHaveProperty('value.id');
+
+    // Wait for success message
+    await uiHasToastMsg(page, {
+      hasText: 'Add Service Successfully',
+    });
+    // Verify we're on the service detail page
+    await servicesPom.isDetailPage(page);
+
+    // Get id from url
+    const url = page.url();
+    const id = url.split('/').pop();
+    expect(id).toBeDefined();
+    expect(data.value.id).toBe(id);
+
+    // Set id to service
+    service.id = id;
+
+    // Verify the service name
+    const name = page.getByLabel('Name', { exact: true }).first();
+    await expect(name).toHaveValue(service.name);
+    await expect(name).toBeDisabled();
+  });
+
+  /**
+   * 3. Create Route
+   * Name: Generate UUID
+   * Uri: /uuid
+   * Methods: GET
+   * Service: Reference the service created above
+   * Plugins: Enable CORS plugin with custom configuration (constraint 
allow_origins = "httpbin.local")
+   */
+  const routePluginName = 'cors';
+  const route: Partial<APISIXType['Route']> = {
+    name: randomId('Generate UUID'),
+    uri: '/uuid',
+    methods: ['GET'],
+    service_id: service.id,
+    plugins: {
+      [routePluginName]: {
+        allow_origins: 'https://httpbin.local:80',
+      },
+    },
+  };
+  await test.step('create route', async () => {
+    // service id should be set
+    expect(route.service_id).not.toBeUndefined();
+
+    // Navigate to the route list page
+    await routesPom.toIndex(page);
+    await routesPom.isIndexPage(page);
+
+    // Click the add route button
+    await routesPom.getAddRouteBtn(page).click();
+    await routesPom.isAddPage(page);
+
+    // Fill in basic fields
+    await page.getByLabel('Name', { exact: true }).first().fill(route.name);
+    await page.getByLabel('URI', { exact: true }).fill(route.uri);
+
+    // Select HTTP methods
+    await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+    await page.getByRole('option', { name: 'GET' }).click();
+    await page.keyboard.press('Escape');
+
+    // Select service reference
+    const serviceSection = page.getByRole('group', { name: 'Service' });
+    await serviceSection.locator('input[name="service_id"]').fill(service.id);
+
+    // Add plugins
+    await selectPluginsBtn.click();
+
+    // Search for plugin
+    const searchInput = selectPluginsDialog.getByPlaceholder('Search');
+    await searchInput.fill(routePluginName);
+
+    // Add the plugin
+    await selectPluginsDialog
+      .getByTestId(`plugin-${routePluginName}`)
+      .getByRole('button', { name: 'Add' })
+      .click();
+
+    // Configure the plugin
+    const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
+    const editorLoading = addPluginDialog.getByTestId('editor-loading');
+    await expect(editorLoading).toBeHidden();
+
+    // Clear the editor and add custom configuration
+    const editor = addPluginDialog.getByRole('code').getByRole('textbox');
+    await uiClearEditor(page);
+
+    // Add plugin configuration
+    await editor.fill(JSON.stringify(route.plugins?.[routePluginName]));
+
+    // Add the plugin
+    await addPluginDialog.getByRole('button', { name: 'Add' }).click();
+    await expect(addPluginDialog).toBeHidden();
+
+    // Verify the plugin was added
+    const pluginsSection = page.getByRole('group', { name: 'Plugins' });
+    await expect(
+      pluginsSection.getByTestId(`plugin-${routePluginName}`)
+    ).toBeVisible();
+
+    // Submit the form
+    await routesPom.getAddBtn(page).click();
+
+    // Wait for success message
+    await uiHasToastMsg(page, {
+      hasText: 'Add Route Successfully',
+    });
+
+    // Verify we're on the route detail page
+    await routesPom.isDetailPage(page);
+
+    // Verify the route name
+    const name = page.getByLabel('Name', { exact: true }).first();
+    await expect(name).toHaveValue(route.name);
+    await expect(name).toBeDisabled();
+  });
+
+  /**
+   * 4. Verification
+   * Ensure all created values exist
+   */
+  await test.step('verify all created resources', async () => {
+    // Verify upstream exists in list
+    await upstreamsPom.toIndex(page);
+    await upstreamsPom.isIndexPage(page);
+    await expect(page.getByRole('cell', { name: upstream.name 
})).toBeVisible();
+
+    // Verify service exists in list
+    await page.getByRole('link', { name: 'Services' }).click();
+    await expect(page.getByRole('heading', { name: 'Services' 
})).toBeVisible();
+    await expect(page.getByRole('cell', { name: service.name })).toBeVisible();
+
+    // Verify route exists in list
+    await routesPom.toIndex(page);
+    await routesPom.isIndexPage(page);
+    await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+
+    // Navigate to route detail to verify service and plugin
+    await page
+      .getByRole('row', { name: route.name })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await routesPom.isDetailPage(page);
+
+    // Verify URI
+    const uri = page.getByLabel('URI', { exact: true });
+    await expect(uri).toHaveValue(route.uri);
+
+    // Verify HTTP methods
+    const methods = page
+      .getByRole('textbox', { name: 'HTTP Methods' })
+      .locator('..');
+    await expect(methods).toContainText('GET');
+
+    // Verify CORS plugin is present
+    await expect(page.getByText('cors')).toBeVisible();
+
+    // Verify service id is present
+    await expect(page.locator('input[name="service_id"]')).toHaveValue(
+      service.id
+    );
+
+    // Navigate to service detail to verify upstream and plugin
+    await servicesPom.toIndex(page);
+    await servicesPom.isIndexPage(page);
+    await page
+      .getByRole('row', { name: service.name })
+      .getByRole('button', { name: 'View' })
+      .click();
+
+    // Verify limit-count plugin is present
+    await expect(page.getByText(servicePluginName)).toBeVisible();
+
+    // Verify upstream id is present
+    await 
expect(page.locator('input[name="upstream_id"]').first()).toHaveValue(
+      upstream.id
+    );
+
+    // Verify service name is present
+    await expect(
+      page.getByRole('textbox', { name: 'Name', exact: true }).first()
+    ).toHaveValue(service.name);
+
+    // Navigate to upstream detail to verify nodes
+    await upstreamsPom.toIndex(page);
+    await upstreamsPom.isIndexPage(page);
+    await page
+      .getByRole('row', { name: upstream.name })
+      .getByRole('button', { name: 'View' })
+      .click();
+
+    // Verify nodes are present
+    await expect(
+      page.getByRole('cell', { name: upstream.nodes[0].host })
+    ).toBeVisible();
+
+    // Verify upstream name is present
+    await expect(
+      page.getByRole('textbox', { name: 'Name', exact: true })
+    ).toHaveValue(upstream.name);
+  });
+});
diff --git a/e2e/tests/routes.crud-all-fields.spec.ts 
b/e2e/tests/routes.crud-all-fields.spec.ts
index 9487f7517..31928b773 100644
--- a/e2e/tests/routes.crud-all-fields.spec.ts
+++ b/e2e/tests/routes.crud-all-fields.spec.ts
@@ -18,7 +18,7 @@ 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 { uiClearEditor, uiHasToastMsg } from '@e2e/utils/ui';
 import { uiFillUpstreamAllFields } from '@e2e/utils/ui/upstreams';
 import { expect } from '@playwright/test';
 
@@ -119,18 +119,11 @@ test('should CRUD route with all fields', async ({ page 
}) => {
       .getByRole('button', { name: 'Add' })
       .click();
 
-    const clearEditor = async () => {
-      await page.evaluate(() => {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        (window as any).monaco.editor.getEditors()[0]?.setValue('');
-      });
-    };
-
     const addPluginDialog = page.getByRole('dialog', { name: 'Add Plugin' });
     const editorLoading = addPluginDialog.getByTestId('editor-loading');
     await expect(editorLoading).toBeHidden();
     const editor = addPluginDialog.getByRole('code').getByRole('textbox');
-    await clearEditor();
+    await uiClearEditor(page);
     await editor.fill('{"hide_credentials": true}');
     // add plugin
     await addPluginDialog.getByRole('button', { name: 'Add' }).click();
@@ -170,8 +163,7 @@ test('should CRUD route with all fields', async ({ page }) 
=> {
     ).toBeVisible();
 
     // clear the editor, will show JSON format is not valid
-    await clearEditor();
-    await editor.fill('');
+    await uiClearEditor(page);
     await expect(
       addPluginDialog.getByText('JSON format is not valid')
     ).toBeVisible();
@@ -204,22 +196,8 @@ test('should CRUD route with all fields', async ({ page }) 
=> {
     });
   });
 
-  await test.step('verify route in list page after creation', async () => {
-    // After creation, we should be redirected to the routes list page
-    await routesPom.isIndexPage(page);
-
-    // Verify our newly created route appears in the list
-    await expect(
-      page.getByRole('cell', { name: routeNameWithAllFields })
-    ).toBeVisible();
-  });
-
-  await test.step('navigate to route detail page and verify all fields', async 
() => {
-    // Click on the route name to go to the detail page
-    await page
-      .getByRole('row', { name: routeNameWithAllFields })
-      .getByRole('button', { name: 'View' })
-      .click();
+  await test.step('auto navigate to route detail page and verify all fields', 
async () => {
+    // After creation, we should be redirected to the routes detail page
     await routesPom.isDetailPage(page);
 
     // Verify the route details
diff --git a/e2e/tests/routes.crud-required-fields.spec.ts 
b/e2e/tests/routes.crud-required-fields.spec.ts
index c2123ce6f..c277df55a 100644
--- a/e2e/tests/routes.crud-required-fields.spec.ts
+++ b/e2e/tests/routes.crud-required-fields.spec.ts
@@ -78,35 +78,20 @@ test('should CRUD route with required fields', async ({ 
page }) => {
     });
   });
 
-  await test.step('redirects to routes list page after creation', async () => {
-    // After creation, we should be redirected to the routes list page
-    await routesPom.isIndexPage(page);
-    
-    // Verify our newly created route appears in the list
-    await expect(page.getByRole('cell', { name: routeName })).toBeVisible();
-  });
-
-  // We've already verified the route is in the list page in the previous step
-
-  await test.step('navigate to route detail page', async () => {
-    // Click on the route name to go to the detail page
-    await page
-      .getByRole('row', { name: routeName })
-      .getByRole('button', { name: 'View' })
-      .click();
+  await test.step('auto navigate to route detail page', async () => {
     await routesPom.isDetailPage(page);
-    
+
     // Verify the route details
     // Verify ID exists
     const ID = page.getByRole('textbox', { name: 'ID', exact: true });
     await expect(ID).toBeVisible();
     await expect(ID).toBeDisabled();
-    
+
     // Verify the route name
     const name = page.getByLabel('Name', { exact: true }).first();
     await expect(name).toHaveValue(routeName);
     await expect(name).toBeDisabled();
-    
+
     // Verify the route URI
     const uri = page.getByLabel('URI', { exact: true });
     await expect(uri).toHaveValue(routeUri);
@@ -161,6 +146,19 @@ test('should CRUD route with required fields', async ({ 
page }) => {
     await expect(row).toBeVisible();
   });
 
+  await test.step('route should exist in list page', async () => {
+    await routesPom.getRouteNavBtn(page).click();
+    await routesPom.isIndexPage(page);
+    await expect(page.getByRole('cell', { name: routeName })).toBeVisible();
+
+    // Click on the route name to go to the detail page
+    await page
+      .getByRole('row', { name: routeName })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await routesPom.isDetailPage(page);
+  });
+
   await test.step('delete route in detail page', async () => {
     // We're already on the detail page from the previous step
 
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 80db6337d..580ce2eaf 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import type { CommonPOM } from '@e2e/pom/type';
+import { type Monaco } from '@monaco-editor/react';
 import { expect, type Locator, type Page } from '@playwright/test';
 
 import type { FileRouteTypes } from '@/routeTree.gen';
@@ -52,3 +53,11 @@ export async function uiFillHTTPStatuses(
     await input.press('Enter');
   }
 }
+
+export async function uiClearEditor(page: Page) {
+  await page.evaluate(() => {
+    (window as unknown as { monaco?: Monaco })?.monaco?.editor
+      ?.getEditors()[0]
+      ?.setValue('');
+  });
+};
diff --git a/src/apis/routes.ts b/src/apis/routes.ts
index 2eeadacc7..fc5af3717 100644
--- a/src/apis/routes.ts
+++ b/src/apis/routes.ts
@@ -17,7 +17,7 @@
 import type { AxiosInstance } from 'axios';
 
 import type { RoutePostType } from 
'@/components/form-slice/FormPartRoute/schema';
-import { API_ROUTES } from '@/config/constant';
+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';
 
@@ -43,12 +43,19 @@ export const postRouteReq = (req: AxiosInstance, data: 
RoutePostType) =>
   req.post<unknown, APISIXType['RespRouteDetail']>(API_ROUTES, data);
 
 export const deleteAllRoutes = async (req: AxiosInstance) => {
-  const res = await getRouteListReq(req, {
+  const totalRes = await getRouteListReq(req, {
     page: 1,
-    page_size: 1000,
+    page_size: PAGE_SIZE_MIN,
   });
-  if (res.total === 0) return;
-  return await Promise.all(
-    res.list.map((d) => req.delete(`${API_ROUTES}/${d.value.id}`))
-  );
+  const total = totalRes.total;
+  if (total === 0) return;
+  for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) {
+    const res = await getRouteListReq(req, {
+      page: 1,
+      page_size: PAGE_SIZE_MAX,
+    });
+    await Promise.all(
+      res.list.map((d) => req.delete(`${API_ROUTES}/${d.value.id}`))
+    );
+  }
 };
diff --git a/src/apis/services.ts b/src/apis/services.ts
index dd7eb9aa6..e8ed19f24 100644
--- a/src/apis/services.ts
+++ b/src/apis/services.ts
@@ -16,7 +16,7 @@
  */
 import type { AxiosInstance } from 'axios';
 
-import { API_SERVICES } from '@/config/constant';
+import { API_SERVICES, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from '@/config/constant';
 import type { APISIXType } from '@/types/schema/apisix';
 import type { PageSearchType } from '@/types/schema/pageSearch';
 
@@ -50,3 +50,21 @@ export const postServiceReq = (req: AxiosInstance, data: 
ServicePostType) =>
     API_SERVICES,
     data
   );
+
+export const deleteAllServices = async (req: AxiosInstance) => {
+  const totalRes = await getServiceListReq(req, {
+    page: 1,
+    page_size: PAGE_SIZE_MIN,
+  });
+  const total = totalRes.total;
+  if (total === 0) return;
+  for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) {
+    const res = await getServiceListReq(req, {
+      page: 1,
+      page_size: PAGE_SIZE_MAX,
+    });
+    await Promise.all(
+      res.list.map((d) => req.delete(`${API_SERVICES}/${d.value.id}`))
+    );
+  }
+};
diff --git a/src/apis/upstreams.ts b/src/apis/upstreams.ts
index a7f484834..3780c2410 100644
--- a/src/apis/upstreams.ts
+++ b/src/apis/upstreams.ts
@@ -17,11 +17,14 @@
 
 import type { AxiosInstance } from 'axios';
 
-import { API_UPSTREAMS } from '@/config/constant';
+import { API_UPSTREAMS, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from 
'@/config/constant';
 import type { APISIXType } from '@/types/schema/apisix';
 import type { PageSearchType } from '@/types/schema/pageSearch';
 
-export const getUpstreamListReq = (req: AxiosInstance, params: PageSearchType) 
=>
+export const getUpstreamListReq = (
+  req: AxiosInstance,
+  params: PageSearchType
+) =>
   req
     .get<undefined, APISIXType['RespUpstreamList']>(API_UPSTREAMS, { params })
     .then((v) => v.data);
@@ -52,12 +55,19 @@ export const putUpstreamReq = (
 };
 
 export const deleteAllUpstreams = async (req: AxiosInstance) => {
-  const res = await getUpstreamListReq(req, {
+  const totalRes = await getUpstreamListReq(req, {
     page: 1,
-    page_size: 1000,
+    page_size: PAGE_SIZE_MIN,
   });
-  if (res.total === 0) return;
-  return await Promise.all(
-    res.list.map((d) => req.delete(`${API_UPSTREAMS}/${d.value.id}`))
-  );
+  const total = totalRes.total;
+  if (total === 0) return;
+  for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) {
+    const res = await getUpstreamListReq(req, {
+      page: 1,
+      page_size: PAGE_SIZE_MAX,
+    });
+    await Promise.all(
+      res.list.map((d) => req.delete(`${API_UPSTREAMS}/${d.value.id}`))
+    );
+  }
 };
diff --git a/src/components/form-slice/FormItemPlugins/index.tsx 
b/src/components/form-slice/FormItemPlugins/index.tsx
index 7186ad527..746067cb2 100644
--- a/src/components/form-slice/FormItemPlugins/index.tsx
+++ b/src/components/form-slice/FormItemPlugins/index.tsx
@@ -91,7 +91,7 @@ export const FormItemPlugins = <T extends FieldValues>(
       return difference(this.allPluginNames, this.selected);
     },
     save() {
-      const obj = Object.fromEntries(this.__map);
+      const obj = Object.fromEntries(toJS(this.__map));
       fOnChange(obj);
     },
     update(config: PluginConfig) {
diff --git a/src/config/constant.ts b/src/config/constant.ts
index 0112b5170..4cf5814df 100644
--- a/src/config/constant.ts
+++ b/src/config/constant.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 export const BASE_PATH = '/ui';
+export const PAGE_SIZE_MIN = 10;
+export const PAGE_SIZE_MAX = 500;
 export const API_HEADER_KEY = 'X-API-KEY';
 export const API_PREFIX = '/apisix/admin';
 export const API_ROUTES = '/routes';
diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx
index 15f64a9a1..73c083c3f 100644
--- a/src/routes/routes/add.tsx
+++ b/src/routes/routes/add.tsx
@@ -31,6 +31,7 @@ import {
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
 import { pipeProduce } from '@/utils/producer';
 
 const RouteAddForm = () => {
@@ -38,13 +39,17 @@ const RouteAddForm = () => {
   const router = useRouter();
 
   const postRoute = useMutation({
-    mutationFn: (d: RoutePostType) => postRouteReq(req, pipeProduce()(d)),
-    async onSuccess() {
+    mutationFn: (d: RoutePostType) =>
+      postRouteReq(req, 
pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)),
+    async onSuccess(res) {
       notifications.show({
         message: t('info.add.success', { name: t('routes.singular') }),
         color: 'green',
       });
-      await router.navigate({ to: '/routes' });
+      await router.navigate({
+        to: '/routes/detail/$id',
+        params: { id: res.data.value.id },
+      });
     },
   });
 
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index debe733ea..8e30383b7 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -40,6 +40,7 @@ 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 = {
@@ -72,7 +73,8 @@ const RouteDetailForm = (props: Props) => {
   }, [routeData, form, isLoading]);
 
   const putRoute = useMutation({
-    mutationFn: (d: APISIXType['Route']) => putRouteReq(req, pipeProduce()(d)),
+    mutationFn: (d: APISIXType['Route']) =>
+      putRouteReq(req, pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)),
     async onSuccess() {
       notifications.show({
         message: t('info.edit.success', { name: t('routes.singular') }),
diff --git a/src/routes/services/add.tsx b/src/routes/services/add.tsx
index 469e380ab..e6d7ec3ca 100644
--- a/src/routes/services/add.tsx
+++ b/src/routes/services/add.tsx
@@ -28,6 +28,7 @@ import { ServicePostSchema } from 
'@/components/form-slice/FormPartService/schem
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import PageHeader from '@/components/page/PageHeader';
 import { req } from '@/config/req';
+import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
 import { pipeProduce } from '@/utils/producer';
 
 const ServiceAddForm = () => {
@@ -35,13 +36,20 @@ const ServiceAddForm = () => {
   const router = useRouter();
 
   const postService = useMutation({
-    mutationFn: (d: ServicePostType) => postServiceReq(req, pipeProduce()(d)),
-    async onSuccess() {
+    mutationFn: (d: ServicePostType) =>
+      postServiceReq(
+        req,
+        pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(d)
+      ),
+    async onSuccess(res) {
       notifications.show({
         message: t('info.add.success', { name: t('services.singular') }),
         color: 'green',
       });
-      await router.navigate({ to: '/services' });
+      await router.navigate({
+        to: '/services/detail/$id',
+        params: { id: res.data.value.id },
+      });
     },
   });
 
diff --git a/src/routes/services/detail.$id.tsx 
b/src/routes/services/detail.$id.tsx
index bf73a60bc..c36d2bf86 100644
--- a/src/routes/services/detail.$id.tsx
+++ b/src/routes/services/detail.$id.tsx
@@ -39,6 +39,7 @@ import PageHeader from '@/components/page/PageHeader';
 import { API_SERVICES } 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 = {
@@ -70,7 +71,10 @@ const ServiceDetailForm = (props: Props) => {
 
   const putService = useMutation({
     mutationFn: (d: APISIXType['Service']) =>
-      putServiceReq(req, pipeProduce()(d)),
+      putServiceReq(
+        req,
+        pipeProduce(produceRmUpstreamWhenHas('upstream_id'))(d)
+      ),
     async onSuccess() {
       notifications.show({
         message: t('info.edit.success', { name: t('services.singular') }),
diff --git a/src/utils/form-producer.ts b/src/utils/form-producer.ts
index c3293013f..7686e8300 100644
--- a/src/utils/form-producer.ts
+++ b/src/utils/form-producer.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 import { produce } from 'immer';
-import { all, map, values } from 'rambdax';
+import { all, isNotEmpty, map, values } from 'rambdax';
 
 import type { APISIXType } from '@/types/schema/apisix';
 
@@ -35,3 +35,8 @@ export const produceTime = 
produce<Partial<APISIXType['Info']>>((draft) => {
   if (draft.create_time) delete draft.create_time;
   if (draft.update_time) delete draft.update_time;
 });
+
+export const produceRmUpstreamWhenHas = (idKey: 'upstream_id' | 'service_id') 
=>
+  produce((draft) => {
+    if (draft[idKey] && isNotEmpty(draft.upstream)) delete draft.upstream;
+  });
diff --git a/src/utils/producer.ts b/src/utils/producer.ts
index 4c87a6500..588b5edb4 100644
--- a/src/utils/producer.ts
+++ b/src/utils/producer.ts
@@ -50,16 +50,21 @@ export const produceRmDoubleUnderscoreKeys = 
produce((draft) => {
   rmDoubleUnderscoreKeys(draft);
 });
 
-type PipeParams = Parameters<typeof pipe>;
-type R = PipeParams extends [PipeParams[0], ...infer R] ? R : never;
-export const pipeProduce = (...funcs: R) => {
+/**
+ * FIXME: type error
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const pipeProduce = (...funcs: ((a: any) => unknown)[]) => {
   return <T>(val: T) =>
-    produce(val, (draft) =>
-      pipe(
-        ...funcs,
+    produce(val, (draft) => {
+      const fs = funcs;
+      return pipe(
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+        // @ts-expect-error
+        ...fs,
         produceRmDoubleUnderscoreKeys,
         produceTime,
         produceDeepCleanEmptyKeys()
-      )(draft)
-    ) as T;
+      )(draft) as never;
+    }) as T;
 };


Reply via email to