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