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 <[email protected]>
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: {