test: upstream list pages (#3056)
diff --git a/e2e/pom/upstreams.ts b/e2e/pom/upstreams.ts
new file mode 100644
index 0000000..c20de51
--- /dev/null
+++ b/e2e/pom/upstreams.ts
@@ -0,0 +1,51 @@
+/**
+ * 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 = {
+ getUpstreamNavBtn: (page: Page) =>
+ page.getByRole('link', { name: 'Upstreams' }),
+ getAddUpstreamBtn: (page: Page) =>
+ page.getByRole('button', { name: 'Add Upstream' }),
+};
+
+const assert = {
+ isIndexPage: async (page: Page) => {
+ await expect(page).toHaveURL((url) => url.pathname.endsWith('/upstreams'));
+ const title = page.getByRole('heading', { name: 'Upstreams' });
+ await expect(title).toBeVisible();
+ },
+ isAddPage: async (page: Page) => {
+ await expect(page).toHaveURL((url) =>
+ url.pathname.endsWith('/upstreams/add')
+ );
+ const title = page.getByRole('heading', { name: 'Add Upstream' });
+ await expect(title).toBeVisible();
+ },
+};
+
+const goto = {
+ toIndex: (page: Page) => uiGoto(page, '/upstreams'),
+ toAdd: (page: Page) => uiGoto(page, '/upstreams/add'),
+};
+
+export const upstreamsPom = {
+ ...locator,
+ ...assert,
+ ...goto,
+};
diff --git a/e2e/tests/upstreams.list.spec.ts b/e2e/tests/upstreams.list.spec.ts
new file mode 100644
index 0000000..705c08c
--- /dev/null
+++ b/e2e/tests/upstreams.list.spec.ts
@@ -0,0 +1,94 @@
+/**
+ * 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 { upstreamsPom } from '@e2e/pom/upstreams';
+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 { deleteAllUpstreams, putUpstreamReq } from '@/apis/upstreams';
+import { API_UPSTREAMS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test('should navigate to upstreams page', async ({ page }) => {
+ await test.step('navigate to upstreams page', async () => {
+ await upstreamsPom.getUpstreamNavBtn(page).click();
+ await upstreamsPom.isIndexPage(page);
+ });
+
+ await test.step('verify upstreams page components', async () => {
+ await expect(upstreamsPom.getAddUpstreamBtn(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('Labels', { exact: true })).toBeVisible();
+ await expect(table.getByText('Actions', { exact: true })).toBeVisible();
+ });
+});
+
+const upstreams: APISIXType['Upstream'][] = Array.from(
+ { length: 11 },
+ (_, i) => ({
+ id: `upstream_id_${i + 1}`,
+ name: `upstream_name_${i + 1}`,
+ 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 deleteAllUpstreams(e2eReq);
+ await Promise.all(upstreams.map((d) => putUpstreamReq(e2eReq, d)));
+ });
+
+ test.afterAll(async () => {
+ await Promise.all(
+ upstreams.map((d) => e2eReq.delete(`${API_UPSTREAMS}/${d.id}`))
+ );
+ });
+
+ // Setup pagination tests with upstream-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: /upstream_name_/ })
+ .all();
+ const names = await Promise.all(itemsInPage.map((v) => v.textContent()));
+ return upstreams.filter((d) => !names.includes(d.name));
+ };
+
+ setupPaginationTests(test, {
+ pom: upstreamsPom,
+ items: upstreams,
+ filterItemsNotInPage,
+ getCell: (page, item) =>
+ page.getByRole('cell', { name: item.name }).first(),
+ });
+});
+
diff --git a/e2e/utils/common.ts b/e2e/utils/common.ts
index 6646fc2..3528a7c 100644
--- a/e2e/utils/common.ts
+++ b/e2e/utils/common.ts
@@ -14,9 +14,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { readFile } from 'node:fs/promises';
+import { access, readFile } from 'node:fs/promises';
import path from 'node:path';
+import { nanoid } from 'nanoid';
import { parse } from 'yaml';
type APISIXConf = {
@@ -29,3 +30,14 @@
const res = parse(file) as APISIXConf;
return { adminKey: res.deployment.admin.admin_key[0].key };
};
+
+export const fileExists = async (filePath: string) => {
+ try {
+ await access(filePath);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const randomId = (info: string) => `${info}_${nanoid()}`;
diff --git a/e2e/utils/pagination-test-helper.ts b/e2e/utils/pagination-test-helper.ts
new file mode 100644
index 0000000..28c1226
--- /dev/null
+++ b/e2e/utils/pagination-test-helper.ts
@@ -0,0 +1,230 @@
+/**
+ * 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 { expect, type Locator, type Page } from '@playwright/test';
+
+import { test as realTest } from './test';
+
+export interface PaginationTestConfig<T> {
+ pom: {
+ toIndex: (page: Page) => Promise<unknown>;
+ isIndexPage: (page: Page) => Promise<void>;
+ };
+ items: T[];
+ filterItemsNotInPage: (page: Page) => Promise<T[]>;
+ getCell: (page: Page, item: T) => Locator;
+}
+
+export function setupPaginationTests<T>(
+ test: typeof realTest,
+ { pom, items, filterItemsNotInPage, getCell }: PaginationTestConfig<T>
+) {
+ const defaultPageNum = 1;
+ const defaultPageSize = 10;
+ const newPageSize = 20;
+ const newPageNum = 2;
+
+ const getPageSizeSelection = (page: Page, size: number) => {
+ return page
+ .locator('.ant-select-selection-item')
+ .filter({ hasText: new RegExp(`${size} / page`) })
+ .first();
+ };
+
+ const getPageSizeOption = (page: Page, size: number) => {
+ return page.getByRole('option', { name: `${size} / page` });
+ };
+
+ const getPageNum = (page: Page, num: number) => {
+ return page.getByRole('listitem', { name: `${num}` });
+ };
+
+ const itemIsVisible = async (page: Page, item: T) => {
+ const cell = getCell(page, item);
+ await expect(cell).toBeVisible();
+ };
+
+ const itemIsHidden = async (page: Page, item: T) => {
+ const cell = getCell(page, item);
+ await expect(cell).toBeHidden();
+ };
+
+ test('can use the pagination of the table to switch', async ({ page }) => {
+ await test.step('navigate to list page', async () => {
+ await pom.toIndex(page);
+ await pom.isIndexPage(page);
+ });
+
+ await test.step('default page info should exists', async () => {
+ // page_size should exist in url
+ await expect(page).toHaveURL(
+ (url) =>
+ url.searchParams.get('page_size') === defaultPageSize.toString()
+ );
+ // page_size should exist in table
+ await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+
+ // pageNum should exist in url
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('page') === defaultPageNum.toString()
+ );
+ // pageNum should exist in table
+ await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+
+ const itemsNotInPage = await filterItemsNotInPage(page);
+ // items not in page should not be visible
+ for (const item of itemsNotInPage) {
+ await itemIsHidden(page, item);
+ }
+ });
+
+ await test.step(`can switch page size to ${newPageSize}`, async () => {
+ // click page size selection, then click new page size option
+ await getPageSizeSelection(page, defaultPageSize).click();
+ await getPageSizeOption(page, newPageSize).click();
+
+ await expect(getPageSizeSelection(page, newPageSize)).toBeVisible();
+ await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+ // old page_size should be hidden
+ await expect(getPageSizeSelection(page, defaultPageSize)).toBeHidden();
+
+ // page_size should exist in url
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('page_size') === newPageSize.toString()
+ );
+ // pageNum should exist in url
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('page') === defaultPageNum.toString()
+ );
+
+ // all items should be visible
+ for (const item of items) {
+ await itemIsVisible(page, item);
+ }
+ });
+
+ await test.step('switch to default', async () => {
+ await getPageSizeSelection(page, newPageSize).click();
+ await getPageSizeOption(page, defaultPageSize).click();
+
+ await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+ await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+ await expect(getPageSizeSelection(page, newPageSize)).toBeHidden();
+ });
+
+ await test.step(`can switch page num to ${newPageNum}`, async () => {
+ const itemsNotInPage = await filterItemsNotInPage(page);
+ // click page num
+ await getPageNum(page, defaultPageNum).click();
+ await getPageNum(page, newPageNum).click();
+
+ // pageNum should exist in url
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('page') === newPageNum.toString()
+ );
+ await pom.isIndexPage(page);
+
+ // items not in page should be visible
+ for (const item of itemsNotInPage) {
+ await itemIsVisible(page, item);
+ }
+ });
+ });
+
+ test('can use the search params in the URL to switch', async ({ page }) => {
+ await test.step('navigate to list page', async () => {
+ await pom.toIndex(page);
+ await pom.isIndexPage(page);
+ });
+
+ await test.step('default page info should exists', async () => {
+ // page_size should exist in url
+ await expect(page).toHaveURL(
+ (url) =>
+ url.searchParams.get('page_size') === defaultPageSize.toString()
+ );
+ // page_size should exist in table
+ await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+
+ // pageNum should exist in url
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('page') === defaultPageNum.toString()
+ );
+ // pageNum should exist in table
+ await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+
+ // items not in page should not be visible
+ const itemsNotInPage = await filterItemsNotInPage(page);
+ for (const item of itemsNotInPage) {
+ await itemIsHidden(page, item);
+ }
+ });
+
+ await test.step(`can switch page size to ${newPageSize}`, async () => {
+ const url = new URL(page.url());
+ url.searchParams.set('page_size', newPageSize.toString());
+ await page.goto(url.toString());
+
+ // check pagination
+ await expect(getPageSizeSelection(page, newPageSize)).toBeVisible();
+ await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+ await expect(getPageSizeSelection(page, defaultPageSize)).toBeHidden();
+
+ // pagination should exist in url
+ await expect(page).toHaveURL((url) => {
+ return (
+ url.searchParams.get('page_size') === newPageSize.toString() &&
+ url.searchParams.get('page') === defaultPageNum.toString()
+ );
+ });
+
+ // all items should be visible
+ for (const item of items) {
+ await itemIsVisible(page, item);
+ }
+ });
+
+ await test.step('switch to default', async () => {
+ const url = new URL(page.url());
+ url.searchParams.set('page_size', defaultPageSize.toString());
+ await page.goto(url.toString());
+
+ await expect(getPageSizeSelection(page, defaultPageSize)).toBeVisible();
+ await expect(getPageNum(page, defaultPageNum)).toBeVisible();
+ await expect(getPageSizeSelection(page, newPageSize)).toBeHidden();
+ });
+
+ await test.step(`can switch page num to ${newPageNum}`, async () => {
+ const itemsNotInPage = await filterItemsNotInPage(page);
+
+ const url = new URL(page.url());
+ url.searchParams.set('page', newPageNum.toString());
+ await page.goto(url.toString());
+
+ // pageNum should exist in url
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('page') === newPageNum.toString()
+ );
+ await pom.isIndexPage(page);
+
+ // items not in page should be visible
+ for (const item of itemsNotInPage) {
+ await itemIsVisible(page, item);
+ }
+ });
+ });
+}
diff --git a/e2e/utils/test.ts b/e2e/utils/test.ts
index b6ddd2e..5abc1ad 100644
--- a/e2e/utils/test.ts
+++ b/e2e/utils/test.ts
@@ -14,12 +14,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { existsSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
import path from 'node:path';
-import { test as baseTest } from '@playwright/test';
+import { expect, test as baseTest } from '@playwright/test';
-import { getAPISIXConf } from './common';
+import { fileExists, getAPISIXConf } from './common';
import { env } from './env';
export const test = baseTest.extend<object, { workerStorageState: string }>({
@@ -32,9 +32,14 @@
test.info().project.outputDir,
`.auth/${id}.json`
);
+ const { adminKey } = await getAPISIXConf();
- if (existsSync(fileName)) {
- return await use(fileName);
+ // file exists and contains admin key, use it
+ if (
+ (await fileExists(fileName)) &&
+ (await readFile(fileName)).toString().includes(adminKey)
+ ) {
+ return use(fileName);
}
const page = await browser.newPage({ storageState: undefined });
@@ -44,19 +49,14 @@
// we need to authenticate
const settingsModal = page.getByRole('dialog', { name: 'Settings' });
- if (await settingsModal.isVisible()) {
- const { adminKey } = await getAPISIXConf();
-
- const adminKeyInput = page.getByRole('textbox', { name: 'Admin Key' });
- await adminKeyInput.clear();
- await adminKeyInput.fill(adminKey);
- await page
- .getByRole('dialog', { name: 'Settings' })
- .getByRole('button')
- .click();
-
- await page.reload();
- }
+ await expect(settingsModal).toBeVisible();
+ const adminKeyInput = page.getByRole('textbox', { name: 'Admin Key' });
+ await adminKeyInput.clear();
+ await adminKeyInput.fill(adminKey);
+ await page
+ .getByRole('dialog', { name: 'Settings' })
+ .getByRole('button')
+ .click();
await page.context().storageState({ path: fileName });
await page.close();
diff --git a/e2e/utils/ui.ts b/e2e/utils/ui.ts
new file mode 100644
index 0000000..015601b
--- /dev/null
+++ b/e2e/utils/ui.ts
@@ -0,0 +1,25 @@
+/**
+ * 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 type { Page } from '@playwright/test';
+
+import type { FileRouteTypes } from '@/routeTree.gen';
+
+import { env } from './env';
+
+export const uiGoto = (page: Page, path: FileRouteTypes['to']) => {
+ return page.goto(`${env.E2E_TARGET_URL}${path.substring(1)}`);
+};
diff --git a/src/apis/hooks.ts b/src/apis/hooks.ts
new file mode 100644
index 0000000..6b9cd43
--- /dev/null
+++ b/src/apis/hooks.ts
@@ -0,0 +1,38 @@
+/**
+ * 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 { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
+
+import { getUpstreamListReq } from '@/apis/upstreams';
+import { req } from '@/config/req';
+import { type PageSearchType } from '@/types/schema/pageSearch';
+import { useSearchParams } from '@/utils/useSearchParams';
+import { useTablePagination } from '@/utils/useTablePagination';
+
+export const getUpstreamListQueryOptions = (props: PageSearchType) => {
+ return queryOptions({
+ queryKey: ['upstreams', props.page, props.page_size],
+ queryFn: () => getUpstreamListReq(req, props),
+ });
+};
+
+export const useUpstreamList = () => {
+ const { params, setParams } = useSearchParams('/upstreams/');
+ const upstreamQuery = useSuspenseQuery(getUpstreamListQueryOptions(params));
+ const { data, isLoading, refetch } = upstreamQuery;
+ const pagination = useTablePagination({ data, setParams, params });
+ return { data, isLoading, refetch, pagination };
+};
diff --git a/src/apis/upstreams.ts b/src/apis/upstreams.ts
index c893e6b..1938b38 100644
--- a/src/apis/upstreams.ts
+++ b/src/apis/upstreams.ts
@@ -14,27 +14,51 @@
* 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 { API_UPSTREAMS } from '@/config/constant';
-import { req } from '@/config/req';
import type { APISIXType } from '@/types/schema/apisix';
+import type { PageSearchType } from '@/types/schema/pageSearch';
-export const getUpstreamReq = (id: string) =>
- queryOptions({
- queryKey: ['upstream', id],
- queryFn: () =>
- req
- .get<unknown, APISIXType['RespUpstreamDetail']>(
- `${API_UPSTREAMS}/${id}`
- )
- .then((v) => v.data),
- });
+export const getUpstreamListReq = (req: AxiosInstance, params: PageSearchType) =>
+ req
+ .get<undefined, APISIXType['RespUpstreamList']>(API_UPSTREAMS, { params })
+ .then((v) => v.data);
-export const putUpstreamReq = (data: APISIXType['Upstream']) => {
+export const getUpstreamReq = (req: AxiosInstance, id: string) =>
+ req
+ .get<unknown, APISIXType['RespUpstreamDetail']>(`${API_UPSTREAMS}/${id}`)
+ .then((v) => v.data);
+
+export const postUpstreamReq = (
+ req: AxiosInstance,
+ data: Partial<APISIXType['Upstream']>
+) =>
+ req.post<APISIXType['Upstream'], APISIXType['RespUpstreamDetail']>(
+ API_UPSTREAMS,
+ data
+ );
+
+export const putUpstreamReq = (
+ req: AxiosInstance,
+ data: APISIXType['Upstream']
+) => {
const { id, ...rest } = data;
return req.put<APISIXType['Upstream'], APISIXType['RespUpstreamDetail']>(
`${API_UPSTREAMS}/${id}`,
rest
);
};
+
+export const deleteAllUpstreams = async (req: AxiosInstance) => {
+ const res = await getUpstreamListReq(req, {
+ page: 1,
+ page_size: 1000,
+ pageSize: 1000,
+ });
+ if (res.total === 0) return;
+ return await Promise.all(
+ res.list.map((d) => req.delete(`${API_UPSTREAMS}/${d.value.id}`))
+ );
+};
diff --git a/src/components/page/ToAddPageBtn.tsx b/src/components/page/ToAddPageBtn.tsx
index 8767d31..8a071d3 100644
--- a/src/components/page/ToAddPageBtn.tsx
+++ b/src/components/page/ToAddPageBtn.tsx
@@ -21,9 +21,6 @@
import type { FileRoutesByTo } from '@/routeTree.gen';
import IconPlus from '~icons/material-symbols/add';
-type FilterKeys<T, R extends string> = {
- [K in keyof T as K extends `${string}${R}` ? K : never]: T[K];
-};
type ToAddPageBtnProps = {
to: keyof FilterKeys<FileRoutesByTo, 'add'>;
label: string;
diff --git a/src/routes/upstreams/add.tsx b/src/routes/upstreams/add.tsx
index 55d1c7f..2445040 100644
--- a/src/routes/upstreams/add.tsx
+++ b/src/routes/upstreams/add.tsx
@@ -22,14 +22,13 @@
import { useTranslation } from 'react-i18next';
import type { z } from 'zod';
+import { postUpstreamReq } from '@/apis/upstreams';
import { FormSubmitBtn } from '@/components/form/Btn';
import { FormPartUpstream } from '@/components/form-slice/FormPartUpstream';
import { FormPartUpstreamSchema } from '@/components/form-slice/FormPartUpstream/schema';
import { FormTOCBox } from '@/components/form-slice/FormSection';
import PageHeader from '@/components/page/PageHeader';
-import { API_UPSTREAMS } from '@/config/constant';
import { req } from '@/config/req';
-import { type APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
const PostUpstreamSchema = FormPartUpstreamSchema.omit({
@@ -42,8 +41,7 @@
const { t } = useTranslation();
const router = useRouter();
const postUpstream = useMutation({
- mutationFn: (data: PostUpstreamType) =>
- req.post<unknown, APISIXType['RespUpstreamDetail']>(API_UPSTREAMS, data),
+ mutationFn: (data: PostUpstreamType) => postUpstreamReq(req, data),
async onSuccess(data) {
notifications.show({
message: t('info.add.success', { name: t('upstreams.singular') }),
diff --git a/src/routes/upstreams/detail.$id.tsx b/src/routes/upstreams/detail.$id.tsx
index 1ac7bcd..7d8e539 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -17,7 +17,11 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Group,Skeleton } from '@mantine/core';
import { notifications } from '@mantine/notifications';
-import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
+import {
+ queryOptions,
+ useMutation,
+ useSuspenseQuery,
+} from '@tanstack/react-query';
import {
createFileRoute,
useNavigate,
@@ -38,6 +42,7 @@
import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
import PageHeader from '@/components/page/PageHeader';
import { API_UPSTREAMS } from '@/config/constant';
+import { req } from '@/config/req';
import type { APISIXType } from '@/types/schema/apisix';
import { pipeProduce } from '@/utils/producer';
@@ -46,6 +51,12 @@
setReadOnly: (v: boolean) => void;
};
+const getUpstreamQueryOptions = (id: string) =>
+ queryOptions({
+ queryKey: ['upstream', id],
+ queryFn: () => getUpstreamReq(req, id),
+ });
+
const UpstreamDetailForm = (
props: Props & Pick<APISIXType['Upstream'], 'id'>
) => {
@@ -55,7 +66,7 @@
data: { value: upstreamData },
isLoading,
refetch,
- } = useSuspenseQuery(getUpstreamReq(id));
+ } = useSuspenseQuery(getUpstreamQueryOptions(id));
const form = useForm({
resolver: zodResolver(FormPartUpstreamSchema),
@@ -65,7 +76,7 @@
});
const putUpstream = useMutation({
- mutationFn: putUpstreamReq,
+ mutationFn: (data: APISIXType['Upstream']) => putUpstreamReq(req, data),
async onSuccess() {
notifications.show({
message: t('info.edit.success', { name: t('upstreams.singular') }),
diff --git a/src/routes/upstreams/index.tsx b/src/routes/upstreams/index.tsx
index 9b7eeb3..74bca7b 100644
--- a/src/routes/upstreams/index.tsx
+++ b/src/routes/upstreams/index.tsx
@@ -16,57 +16,23 @@
*/
import type { ProColumns } from '@ant-design/pro-components';
import { ProTable } from '@ant-design/pro-components';
-import { queryOptions, 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 { getUpstreamListQueryOptions, useUpstreamList } 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_UPSTREAMS } from '@/config/constant';
import { queryClient } from '@/config/global';
-import { req } from '@/config/req';
import type { APISIXType } from '@/types/schema/apisix';
-import {
- pageSearchSchema,
- type PageSearchType,
-} from '@/types/schema/pageSearch';
-import { usePagination } from '@/utils/usePagination';
-
-const genUpstreamsQueryOptions = (props: PageSearchType) => {
- const { page, pageSize } = props;
- return queryOptions({
- queryKey: ['upstreams', page, pageSize],
- queryFn: () =>
- req
- .get<unknown, APISIXType['RespUpstreamList']>(API_UPSTREAMS, {
- params: {
- page,
- page_size: pageSize,
- },
- })
- .then((v) => v.data),
- });
-};
+import { pageSearchSchema } from '@/types/schema/pageSearch';
function RouteComponent() {
const { t } = useTranslation();
-
- // Use the pagination hook
- const { pagination, handlePageChange, updateTotal } = usePagination({
- queryKey: 'upstreams',
- });
-
- const upstreamQuery = useSuspenseQuery(genUpstreamsQueryOptions(pagination));
- const { data, isLoading, refetch } = upstreamQuery;
-
- useEffect(() => {
- if (data?.total) {
- updateTotal(data.total);
- }
- }, [data?.total, updateTotal]);
+ const { data, isLoading, refetch, pagination } = useUpstreamList();
const columns = useMemo<
ProColumns<APISIXType['RespUpstreamList']['data']['list'][number]>[]
@@ -101,7 +67,6 @@
title: t('form.upstreams.updateTime'),
key: 'update_time',
valueType: 'dateTime',
- sorter: true,
renderText: (text) => {
if (!text) return '-';
return new Date(Number(text) * 1000).toISOString();
@@ -141,13 +106,7 @@
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: {
@@ -179,5 +138,5 @@
validateSearch: pageSearchSchema,
loaderDeps: ({ search }) => search,
loader: ({ deps }) =>
- queryClient.ensureQueryData(genUpstreamsQueryOptions(deps)),
+ queryClient.ensureQueryData(getUpstreamListQueryOptions(deps)),
});
diff --git a/src/types/schema/pageSearch.ts b/src/types/schema/pageSearch.ts
index 3bc27d2..dc2f25b 100644
--- a/src/types/schema/pageSearch.ts
+++ b/src/types/schema/pageSearch.ts
@@ -16,19 +16,39 @@
*/
import { z } from 'zod';
-export const pageSearchSchema = z.object({
- page: z
- .union([z.string(), z.number()])
- .optional()
- .default(1)
- .transform((val) => (val ? Number(val) : 1)),
- pageSize: z
- .union([z.string(), z.number()])
- .optional()
- .default(10)
- .transform((val) => (val ? Number(val) : 10)),
- name: z.string().optional(),
- label: z.string().optional(),
-});
+/**
+ * To deprecate pageSize without modifying existing code, use preprocessing.
+ */
+export const pageSearchSchema = z.preprocess(
+ (data) => {
+ // If pageSize is provided but page_size isn't, use pageSize value for page_size
+ const inputData = data as Record<string, unknown>;
+ if (inputData?.pageSize && inputData?.page_size === undefined) {
+ return { ...inputData, page_size: inputData.pageSize };
+ }
+ return data;
+ },
+ z
+ .object({
+ page: z
+ .union([z.string(), z.number()])
+ .optional()
+ .default(1)
+ .transform((val) => (val ? Number(val) : 1)),
+ pageSize: z
+ .union([z.string(), z.number()])
+ .optional()
+ .default(10)
+ .transform((val) => (val ? Number(val) : 10)),
+ page_size: z
+ .union([z.string(), z.number()])
+ .optional()
+ .default(10)
+ .transform((val) => (val ? Number(val) : 10)),
+ name: z.string().optional(),
+ label: z.string().optional(),
+ })
+ .passthrough()
+);
export type PageSearchType = z.infer<typeof pageSearchSchema>;
diff --git a/src/types/vite-env.d.ts b/src/types/vite-env.d.ts
index 299c06c..70155fb 100644
--- a/src/types/vite-env.d.ts
+++ b/src/types/vite-env.d.ts
@@ -16,3 +16,7 @@
*/
/// <reference types="vite/client" />
/// <reference types="unplugin-info/client" />
+
+type FilterKeys<T, R extends string> = {
+ [K in keyof T as K extends `${string}${R}` ? K : never]: T[K];
+};
diff --git a/src/utils/usePagination.ts b/src/utils/usePagination.ts
index 0d8abba..d30bfd0 100644
--- a/src/utils/usePagination.ts
+++ b/src/utils/usePagination.ts
@@ -75,6 +75,7 @@
const [pagination, setPagination] = useState<PaginationState>({
page: urlPage,
+ page_size: urlPageSize,
pageSize: urlPageSize,
total: initialTotal,
});
diff --git a/src/utils/useSearchParams.ts b/src/utils/useSearchParams.ts
new file mode 100644
index 0000000..35fc831
--- /dev/null
+++ b/src/utils/useSearchParams.ts
@@ -0,0 +1,54 @@
+/**
+ * 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 {
+ getRouteApi,
+ type RegisteredRouter,
+ type RouteIds,
+ useNavigate,
+} from '@tanstack/react-router';
+import { useCallback } from 'react';
+
+import type { PageSearchType } from '@/types/schema/pageSearch';
+
+type RouteTreeIds = RouteIds<RegisteredRouter['routeTree']>;
+
+export const useSearchParams = <T extends RouteTreeIds>(routeId: T) => {
+ const { useSearch } = getRouteApi<T>(routeId);
+ const navigate = useNavigate();
+ const params = useSearch();
+ type Params = typeof params;
+
+ const setParams = useCallback(
+ (props: Partial<Params>) => {
+ return navigate({
+ to: '.',
+ search: (prev: object) => ({ ...prev, ...props }),
+ });
+ },
+ [navigate]
+ );
+ const resetParams = useCallback(
+ () => navigate({ to: '.', search: {}, replace: true }),
+ [navigate]
+ );
+
+ return { params: params as PageSearchType, setParams, resetParams } as const;
+};
+
+export type UseSearchParams<T extends RouteTreeIds> = ReturnType<
+ typeof useSearchParams<T>
+>;
diff --git a/src/utils/useTablePagination.ts b/src/utils/useTablePagination.ts
new file mode 100644
index 0000000..602a510
--- /dev/null
+++ b/src/utils/useTablePagination.ts
@@ -0,0 +1,58 @@
+/**
+ * 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 type { TablePaginationConfig } from 'antd';
+import { useCallback, useMemo } from 'react';
+
+import type { FileRoutesByTo } from '@/routeTree.gen';
+import type { APISIXListResponse } from '@/types/schema/apisix/type';
+import { pageSearchSchema } from '@/types/schema/pageSearch';
+
+import type { UseSearchParams } from './useSearchParams';
+
+type ListPageKeys = `${keyof FilterKeys<FileRoutesByTo, 's'>}/`;
+type Props<T> = {
+ data: APISIXListResponse<T>;
+ /** if params is from useSearchParams, refetch is not needed */
+ refetch?: () => void;
+} & Pick<UseSearchParams<ListPageKeys>, 'params' | 'setParams'>;
+
+export const useTablePagination = <T>(props: Props<T>) => {
+ const { data, refetch, setParams } = props;
+ const params = useMemo(
+ () => pageSearchSchema.parse(props.params),
+ [props.params]
+ );
+ const { page, page_size } = params;
+
+ const onChange: TablePaginationConfig['onChange'] = useCallback(
+ (page: number, pageSize: number) => {
+ setParams({ page, page_size: pageSize });
+ refetch?.();
+ },
+ [refetch, setParams]
+ );
+
+ const pagination = {
+ current: page,
+ pageSize: page_size,
+ total: data.total ?? 0,
+ showSizeChanger: true,
+ onChange: onChange,
+ } as TablePaginationConfig;
+ return pagination;
+};