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: {

Reply via email to