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 d0cda707e test(routes): list page (#3069) d0cda707e is described below commit d0cda707ece302f49f4b6778427b725010b8d483 Author: YYYoung <isk...@outlook.com> AuthorDate: Tue May 20 15:18:11 2025 +0800 test(routes): list page (#3069) --- e2e/pom/routes.ts | 56 +++++++++++++++++++++++ e2e/tests/routes.list.spec.ts | 95 ++++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 2 +- src/apis/hooks.ts | 23 ++++++++++ src/apis/routes.ts | 47 ++++++++++---------- src/routes/routes/add.tsx | 14 +++--- src/routes/routes/detail.$id.tsx | 14 +++--- src/routes/routes/index.tsx | 29 +++--------- 8 files changed, 216 insertions(+), 64 deletions(-) diff --git a/e2e/pom/routes.ts b/e2e/pom/routes.ts new file mode 100644 index 000000000..96caf142f --- /dev/null +++ b/e2e/pom/routes.ts @@ -0,0 +1,56 @@ +/** + * 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 = { + getRouteNavBtn: (page: Page) => + page.getByRole('link', { name: 'Routes', exact: true }), + getAddRouteBtn: (page: Page) => + page.getByRole('button', { name: 'Add Route', exact: true }), +}; + +const assert = { + isIndexPage: async (page: Page) => { + await expect(page).toHaveURL((url) => url.pathname.endsWith('/routes')); + const title = page.getByRole('heading', { name: 'Routes' }); + await expect(title).toBeVisible(); + }, + isAddPage: async (page: Page) => { + await expect(page).toHaveURL((url) => url.pathname.endsWith('/routes/add')); + const title = page.getByRole('heading', { name: 'Add Route' }); + await expect(title).toBeVisible(); + }, + isDetailPage: async (page: Page) => { + await expect(page).toHaveURL((url) => + url.pathname.includes('/routes/detail') + ); + const title = page.getByRole('heading', { name: 'Route Detail' }); + await expect(title).toBeVisible(); + }, +}; + +const goto = { + toIndex: (page: Page) => uiGoto(page, '/routes'), + toAdd: (page: Page) => uiGoto(page, '/routes/add'), +}; + +export const routesPom = { + ...locator, + ...assert, + ...goto, +}; diff --git a/e2e/tests/routes.list.spec.ts b/e2e/tests/routes.list.spec.ts new file mode 100644 index 000000000..b6f171d92 --- /dev/null +++ b/e2e/tests/routes.list.spec.ts @@ -0,0 +1,95 @@ +/** + * 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 { setupPaginationTests } from '@e2e/utils/pagination-test-helper'; +import { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { expect, type Page } from '@playwright/test'; + +import { deleteAllRoutes, putRouteReq } from '@/apis/routes'; +import { API_ROUTES } from '@/config/constant'; +import type { APISIXType } from '@/types/schema/apisix'; + +test('should navigate to routes page', async ({ page }) => { + await test.step('navigate to routes page', async () => { + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + }); + + await test.step('verify routes page components', async () => { + await expect(routesPom.getAddRouteBtn(page)).toBeVisible(); + + // list table exists + const table = page.getByRole('table'); + await expect(table).toBeVisible(); + await expect(table.getByText('ID', { exact: true })).toBeVisible(); + await expect(table.getByText('Name', { exact: true })).toBeVisible(); + await expect(table.getByText('URI', { exact: true })).toBeVisible(); + await expect(table.getByText('Actions', { exact: true })).toBeVisible(); + }); +}); + +const routes: APISIXType['Route'][] = Array.from({ length: 11 }, (_, i) => ({ + id: `route_id_${i + 1}`, + name: `route_name_${i + 1}`, + uri: `/test_route_${i + 1}`, + desc: `Description for route ${i + 1}`, + methods: ['GET'], + upstream: { + nodes: [ + { + host: `node_${i + 1}`, + port: 80, + weight: 100, + }, + ], + }, +})); + +test.describe('page and page_size should work correctly', () => { + test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + await Promise.all(routes.map((d) => putRouteReq(e2eReq, d))); + }); + + test.afterAll(async () => { + await Promise.all( + routes.map((d) => e2eReq.delete(`${API_ROUTES}/${d.id}`)) + ); + }); + + // Setup pagination tests with route-specific configurations + const filterItemsNotInPage = async (page: Page) => { + // filter the item which not in the current page + // it should be random, so we need get all items in the table + const itemsInPage = await page + .getByRole('cell', { name: /route_name_/ }) + .all(); + const names = await Promise.all(itemsInPage.map((v) => v.textContent())); + return routes.filter((d) => !names.includes(d.name)); + }; + + setupPaginationTests(test, { + pom: routesPom, + items: routes, + filterItemsNotInPage, + getCell: (page, item) => + page.getByRole('cell', { name: item.name }).first(), + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 37c06212c..acc7f29c8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,7 +22,7 @@ import { env } from './e2e/utils/env'; * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './e2e', + testDir: './e2e/tests', outputDir: './test-results', fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/src/apis/hooks.ts b/src/apis/hooks.ts index 6b9cd4304..f1e6d569a 100644 --- a/src/apis/hooks.ts +++ b/src/apis/hooks.ts @@ -22,6 +22,8 @@ import { type PageSearchType } from '@/types/schema/pageSearch'; import { useSearchParams } from '@/utils/useSearchParams'; import { useTablePagination } from '@/utils/useTablePagination'; +import { getRouteListReq, getRouteReq } from './routes'; + export const getUpstreamListQueryOptions = (props: PageSearchType) => { return queryOptions({ queryKey: ['upstreams', props.page, props.page_size], @@ -36,3 +38,24 @@ export const useUpstreamList = () => { const pagination = useTablePagination({ data, setParams, params }); return { data, isLoading, refetch, pagination }; }; + +export const getRouteListQueryOptions = (props: PageSearchType) => { + return queryOptions({ + queryKey: ['routes', props.page, props.page_size], + queryFn: () => getRouteListReq(req, props), + }); +}; + +export const useRouteList = () => { + const { params, setParams } = useSearchParams('/routes/'); + const routeQuery = useSuspenseQuery(getRouteListQueryOptions(params)); + const { data, isLoading, refetch } = routeQuery; + const pagination = useTablePagination({ data, setParams, params }); + return { data, isLoading, refetch, pagination }; +}; + +export const getRouteQueryOptions = (id: string) => + queryOptions({ + queryKey: ['route', id], + queryFn: () => getRouteReq(req, id), + }); diff --git a/src/apis/routes.ts b/src/apis/routes.ts index 04a4b2013..c541bf70b 100644 --- a/src/apis/routes.ts +++ b/src/apis/routes.ts @@ -14,37 +14,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { queryOptions } from '@tanstack/react-query'; +import type { AxiosInstance } from 'axios'; import type { RoutePostType } from '@/components/form-slice/FormPartRoute/schema'; import { API_ROUTES } from '@/config/constant'; -import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; import type { PageSearchType } from '@/types/schema/pageSearch'; -export const getRouteListQueryOptions = (props: PageSearchType) => { - const { page, pageSize } = props; - return queryOptions({ - queryKey: ['routes', page, pageSize], - queryFn: () => - req - .get<unknown, APISIXType['RespRouteList']>(API_ROUTES, { - params: { page, page_size: pageSize }, - }) - .then((v) => v.data), - }); -}; +export const getRouteListReq = (req: AxiosInstance, params: PageSearchType) => + req + .get<undefined, APISIXType['RespRouteList']>(API_ROUTES, { params }) + .then((v) => v.data); -export const getRouteQueryOptions = (id: string) => - queryOptions({ - queryKey: ['route', id], - queryFn: () => - req - .get<unknown, APISIXType['RespRouteDetail']>(`${API_ROUTES}/${id}`) - .then((v) => v.data), - }); +export const getRouteReq = (req: AxiosInstance, id: string) => + req + .get<unknown, APISIXType['RespRouteDetail']>(`${API_ROUTES}/${id}`) + .then((v) => v.data); -export const putRouteReq = (data: APISIXType['Route']) => { +export const putRouteReq = (req: AxiosInstance, data: APISIXType['Route']) => { const { id, ...rest } = data; return req.put<APISIXType['Route'], APISIXType['RespRouteDetail']>( `${API_ROUTES}/${id}`, @@ -52,5 +39,17 @@ export const putRouteReq = (data: APISIXType['Route']) => { ); }; -export const postRouteReq = (data: RoutePostType) => +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, { + page: 1, + page_size: 1000, + pageSize: 1000, + }); + if (res.total === 0) return; + return await Promise.all( + res.list.map((d) => req.delete(`${API_ROUTES}/${d.value.id}`)) + ); +}; diff --git a/src/routes/routes/add.tsx b/src/routes/routes/add.tsx index 9b2c5f6eb..15f64a9a1 100644 --- a/src/routes/routes/add.tsx +++ b/src/routes/routes/add.tsx @@ -24,9 +24,13 @@ import { useTranslation } from 'react-i18next'; import { postRouteReq } from '@/apis/routes'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; -import { RoutePostSchema } from '@/components/form-slice/FormPartRoute/schema'; +import { + RoutePostSchema, + type RoutePostType, +} from '@/components/form-slice/FormPartRoute/schema'; import { FormTOCBox } from '@/components/form-slice/FormSection'; import PageHeader from '@/components/page/PageHeader'; +import { req } from '@/config/req'; import { pipeProduce } from '@/utils/producer'; const RouteAddForm = () => { @@ -34,7 +38,7 @@ const RouteAddForm = () => { const router = useRouter(); const postRoute = useMutation({ - mutationFn: postRouteReq, + mutationFn: (d: RoutePostType) => postRouteReq(req, pipeProduce()(d)), async onSuccess() { notifications.show({ message: t('info.add.success', { name: t('routes.singular') }), @@ -53,11 +57,7 @@ const RouteAddForm = () => { return ( <FormProvider {...form}> - <form - onSubmit={form.handleSubmit((d) => - postRoute.mutateAsync(pipeProduce()(d)) - )} - > + <form onSubmit={form.handleSubmit((d) => postRoute.mutateAsync(d))}> <FormPartRoute /> <FormSubmitBtn>{t('form.btn.add')}</FormSubmitBtn> </form> diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx index 593b33c56..c8ced1d40 100644 --- a/src/routes/routes/detail.$id.tsx +++ b/src/routes/routes/detail.$id.tsx @@ -28,7 +28,8 @@ import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useBoolean } from 'react-use'; -import { getRouteQueryOptions, putRouteReq } from '@/apis/routes'; +import { getRouteQueryOptions } from '@/apis/hooks'; +import { putRouteReq } from '@/apis/routes'; import { FormSubmitBtn } from '@/components/form/Btn'; import { FormPartRoute } from '@/components/form-slice/FormPartRoute'; import { FormTOCBox } from '@/components/form-slice/FormSection'; @@ -36,7 +37,8 @@ import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { API_ROUTES } from '@/config/constant'; -import { APISIX } from '@/types/schema/apisix'; +import { req } from '@/config/req'; +import { APISIX, type APISIXType } from '@/types/schema/apisix'; import { pipeProduce } from '@/utils/producer'; type Props = { @@ -67,7 +69,7 @@ const RouteDetailForm = (props: Props) => { }, [routeData, form, isLoading]); const putRoute = useMutation({ - mutationFn: putRouteReq, + mutationFn: (d: APISIXType['Route']) => putRouteReq(req, pipeProduce()(d)), async onSuccess() { notifications.show({ message: t('info.edit.success', { name: t('routes.singular') }), @@ -84,11 +86,7 @@ const RouteDetailForm = (props: Props) => { return ( <FormProvider {...form}> - <form - onSubmit={form.handleSubmit((d) => { - putRoute.mutateAsync(pipeProduce()(d)); - })} - > + <form onSubmit={form.handleSubmit((d) => putRoute.mutateAsync(d))}> <FormSectionGeneral /> <FormPartRoute /> {!readOnly && ( diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 89cf81897..2edd4808a 100644 --- a/src/routes/routes/index.tsx +++ b/src/routes/routes/index.tsx @@ -16,37 +16,24 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; -import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { getRouteListQueryOptions } from '@/apis/routes'; +import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; -import { ToAddPageBtn,ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; +import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; import { API_ROUTES } from '@/config/constant'; import { queryClient } from '@/config/global'; import type { APISIXType } from '@/types/schema/apisix'; import { pageSearchSchema } from '@/types/schema/pageSearch'; -import { usePagination } from '@/utils/usePagination'; const RouteList = () => { - const { pagination, handlePageChange, updateTotal } = usePagination({ - queryKey: 'routes', - }); - - const query = useSuspenseQuery(getRouteListQueryOptions(pagination)); - const { data, isLoading, refetch } = query; + const { data, isLoading, refetch, pagination } = useRouteList(); const { t } = useTranslation(); - useEffect(() => { - if (data?.total) { - updateTotal(data.total); - } - }, [data?.total, updateTotal]); - const columns = useMemo<ProColumns<APISIXType['RespRouteItem']>[]>(() => { return [ { @@ -105,13 +92,7 @@ const RouteList = () => { loading={isLoading} search={false} options={false} - pagination={{ - current: pagination.page, - pageSize: pagination.pageSize, - total: pagination.total, - showSizeChanger: true, - onChange: handlePageChange, - }} + pagination={pagination} cardProps={{ bodyStyle: { padding: 0 } }} toolbar={{ menu: {