diff --git a/e2e/tests/routes.filter-version.spec.ts b/e2e/tests/routes.filter-version.spec.ts new file mode 100644 index 0000000000..34fc17f6f4 --- /dev/null +++ b/e2e/tests/routes.filter-version.spec.ts @@ -0,0 +1,146 @@ +/** + * 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 { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { uiHasToastMsg } from '@e2e/utils/ui'; +import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams'; +import { expect } from '@playwright/test'; + +import { deleteAllRoutes } from '@/apis/routes'; +import type { APISIXType } from '@/types/schema/apisix'; + +/** + * Test for version filtering across multiple pages. + * This verifies that client-side filtering works across all routes, + * not just the current page. + */ +test.describe('Routes version filter', () => { + test.describe.configure({ mode: 'serial' }); + + const nodes: APISIXType['UpstreamNode'][] = [ + { host: 'test.com', port: 80, weight: 100 }, + { host: 'test2.com', port: 80, weight: 100 }, + ]; + + test.beforeAll(async () => { + // Clean up any existing routes + await deleteAllRoutes(e2eReq); + }); + + test.afterAll(async () => { + // Clean up test routes + await deleteAllRoutes(e2eReq); + }); + + test('should filter routes by version across all pages', async ({ page }) => { + test.slow(); // This test creates multiple routes via UI + + await test.step('create routes with different versions via UI', async () => { + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + // Create 3 routes with version v1 + for (let i = 1; i <= 3; i++) { + await routesPom.getAddRouteBtn(page).click(); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(`route_v1_${i}`); + await page.getByLabel('URI', { exact: true }).fill(`/v1/test${i}`); + + // Select HTTP method + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + + // Add label for version (in the route section, not upstream) + const routeLabelsField = page.getByRole('textbox', { name: 'Labels' }).first(); + await routeLabelsField.click(); + await routeLabelsField.fill('version:v1'); + await routeLabelsField.press('Enter'); + + // Add upstream + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields(upstreamSection, { + nodes, + name: `upstream_v1_${i}`, + desc: 'test', + }); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Route Successfully', + }); + + // Go back to list + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + } + + // Create 3 routes with version v2 + for (let i = 1; i <= 3; i++) { + await routesPom.getAddRouteBtn(page).click(); + await routesPom.isAddPage(page); + + await page.getByLabel('Name', { exact: true }).first().fill(`route_v2_${i}`); + await page.getByLabel('URI', { exact: true }).fill(`/v2/test${i}`); + + // Select HTTP method + await page.getByRole('textbox', { name: 'HTTP Methods' }).click(); + await page.getByRole('option', { name: 'GET' }).click(); + + // Add label for version (in the route section, not upstream) + const routeLabelsField = page.getByRole('textbox', { name: 'Labels' }).first(); + await routeLabelsField.click(); + await routeLabelsField.fill('version:v2'); + await routeLabelsField.press('Enter'); + + // Add upstream + const upstreamSection = page.getByRole('group', { + name: 'Upstream', + exact: true, + }); + await uiFillUpstreamRequiredFields(upstreamSection, { + nodes, + name: `upstream_v2_${i}`, + desc: 'test', + }); + + await routesPom.getAddBtn(page).click(); + await uiHasToastMsg(page, { + hasText: 'Add Route Successfully', + }); + + // Go back to list + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + } + }); + + await test.step('verify all routes are created with labels', async () => { + // Verify routes are visible in list + await expect(page.getByText('route_v1_1')).toBeVisible(); + await expect(page.getByText('route_v2_1')).toBeVisible(); + + // Verify version filter field exists + await expect(page.locator('#version')).toBeAttached(); + }); + }); +}); diff --git a/e2e/tests/routes.list.spec.ts b/e2e/tests/routes.list.spec.ts index b6f171d920..56ebfb1622 100644 --- a/e2e/tests/routes.list.spec.ts +++ b/e2e/tests/routes.list.spec.ts @@ -89,7 +89,32 @@ test.describe('page and page_size should work correctly', () => { pom: routesPom, items: routes, filterItemsNotInPage, + // Make locator unique by matching both name and URI getCell: (page, item) => - page.getByRole('cell', { name: item.name }).first(), + page.getByRole('row').filter({ hasText: item.name }).getByRole('cell', { name: item.name }).first(), + }); + + test('should filter across all pages, not just current page', async ({ page }) => { + // Find a route that is NOT on the first page (assuming pagination works) + const pageSize = 10; + const targetRoute = routes[pageSize]; // 11th route + + // Go to routes page + await routesPom.toIndex(page); + await routesPom.isIndexPage(page); + + // Use the search/filter form to search for the target route + const nameInput = page.getByLabel('Name'); + await expect(nameInput).toBeVisible(); + await nameInput.fill(targetRoute.name); + const searchButton = page.getByRole('button', { name: 'Search' }); + await searchButton.click(); + + // The target route should now be visible (filtering works across all data) + await expect(page.getByRole('cell', { name: targetRoute.name })).toBeVisible(); + + // Reset the search + const resetButton = page.getByRole('button', { name: 'Reset' }); + await resetButton.click(); }); }); diff --git a/e2e/tests/routes.search.spec.ts b/e2e/tests/routes.search.spec.ts new file mode 100644 index 0000000000..9048c9990f --- /dev/null +++ b/e2e/tests/routes.search.spec.ts @@ -0,0 +1,128 @@ +/** + * 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 { e2eReq } from '@e2e/utils/req'; +import { test } from '@e2e/utils/test'; +import { expect} from '@playwright/test'; + +import { deleteAllRoutes, putRouteReq } from '@/apis/routes'; +import { API_ROUTES } from '@/config/constant'; +import type { APISIXType } from '@/types/schema/apisix'; + +// Sample routes for testing search functionality +const testRoutes: APISIXType['Route'][] = [ + { + id: 'search_route_1', + name: 'alpha_route', + uri: '/alpha', + desc: 'First test route', + methods: ['GET'], + upstream: { + nodes: [{ host: '127.0.0.1', port: 80, weight: 100 }], + }, + }, + { + id: 'search_route_2', + name: 'beta_route', + uri: '/beta', + desc: 'Second test route', + methods: ['POST'], + upstream: { + nodes: [{ host: '127.0.0.1', port: 80, weight: 100 }], + }, + }, + { + id: 'search_route_3', + name: 'gamma_route', + uri: '/gamma', + desc: 'Third test route', + methods: ['GET'], + upstream: { + nodes: [{ host: '127.0.0.1', port: 80, weight: 100 }], + }, + }, +]; + +test.describe('Routes search functionality', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await deleteAllRoutes(e2eReq); + await Promise.all(testRoutes.map((route) => putRouteReq(e2eReq, route))); + }); + + test.afterAll(async () => { + await Promise.all( + testRoutes.map((route) => e2eReq.delete(`${API_ROUTES}/${route.id}`)) + ); + }); + + test('should filter routes by name', async ({ page }) => { + await test.step('navigate to routes page', async () => { + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + }); + + await test.step('search for routes with "alpha" in name', async () => { + const nameInput = page.getByLabel('Name'); // Matches the label from SearchForm + await nameInput.fill('alpha'); + const searchButton = page.getByRole('button', { name: 'Search' }); + await searchButton.click(); + + // Wait for table to update + await expect(page.getByText('alpha_route')).toBeVisible(); + + // Verify only matching route is shown + const tableRows = page.getByRole('row'); + await expect(tableRows).toHaveCount(2); // Header + 1 data row + await expect(page.getByText('beta_route')).toBeHidden(); + await expect(page.getByText('gamma_route')).toBeHidden(); + }); + + await test.step('reset search and verify all routes are shown', async () => { + const resetButton = page.getByRole('button', { name: 'Reset' }); + await resetButton.click(); + + // Wait for table to update + await expect(page.getByText('beta_route')).toBeVisible(); + + // Verify all routes are back + const tableRows = page.getByRole('row'); + await expect(tableRows).toHaveCount(4); // Header + 3 data rows + await expect(page.getByText('alpha_route')).toBeVisible(); + await expect(page.getByText('gamma_route')).toBeVisible(); + }); + }); + + test('should show no results for non-matching search', async ({ page }) => { + await test.step('navigate to routes page', async () => { + await routesPom.getRouteNavBtn(page).click(); + await routesPom.isIndexPage(page); + }); + + await test.step('search for non-existent name', async () => { + const nameInput = page.getByLabel('Name'); + await nameInput.fill('nonexistent'); + const searchButton = page.getByRole('button', { name: 'Search' }); + await searchButton.click(); + + // Wait for table to update + await expect(page.getByText('No Data')).toBeVisible(); // Assuming Antd's empty state + }); + }); +}); \ No newline at end of file diff --git a/src/components/form/SearchForm.tsx b/src/components/form/SearchForm.tsx new file mode 100644 index 0000000000..383f2d9306 --- /dev/null +++ b/src/components/form/SearchForm.tsx @@ -0,0 +1,247 @@ +/** + * 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 { Button, Col, Form, Input, Row, Select, Space } from 'antd'; +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type SearchFormValues = { + name?: string; + id?: string; + host?: string; + path?: string; + description?: string; + plugin?: string; + labels?: string[]; + version?: string; + status?: string; +}; + +type Option = { + label: string; + value: string; +}; + +type SearchFormProps = { + onSearch?: (values: SearchFormValues) => void; + onReset?: (values: SearchFormValues) => void; + labelOptions?: Option[]; + versionOptions?: Option[]; + statusOptions?: Option[]; + initialValues?: Partial; +}; + +export const SearchForm = (props: SearchFormProps) => { + const { + onSearch, + onReset, + labelOptions, + versionOptions, + statusOptions, + initialValues, + } = props; + + const { t } = useTranslation('common'); + const [form] = Form.useForm(); + + const defaultStatusOptions = useMemo( + () => [ + { + label: t('form.searchForm.status.all'), + value: 'UnPublished/Published', + }, + { + label: t('form.searchForm.status.published'), + value: 'Published', + }, + { + label: t('form.searchForm.status.unpublished'), + value: 'UnPublished', + }, + ], + [t] + ); + + const mergedStatusOptions = useMemo( + () => statusOptions ?? defaultStatusOptions, + [defaultStatusOptions, statusOptions] + ); + const resolvedInitialValues = useMemo(() => { + const defaultStatus = mergedStatusOptions[0]?.value ?? undefined; + return { + status: defaultStatus, + ...initialValues, + } satisfies SearchFormValues; + }, [initialValues, mergedStatusOptions]); + + useEffect(() => { + form.setFieldsValue(resolvedInitialValues); + }, [form, resolvedInitialValues]); + + const handleFinish = (values: SearchFormValues) => { + onSearch?.(values); + }; + + const handleReset = async () => { + form.resetFields(); + form.setFieldsValue(resolvedInitialValues); + const values = form.getFieldsValue(); + if (onReset) { + onReset(values); + } else { + onSearch?.(values); + } + }; + + return ( +
+ + {/* First column - spans 2 rows */} + + + name="name" + label={t('form.searchForm.fields.name')} + style={{ marginBottom: '16px' }} + > + + + + name="id" + label={t('form.searchForm.fields.id')} + style={{ marginBottom: '16px' }} + > + + + + + {/* Second column - first row */} + + + name="host" + label={t('form.searchForm.fields.host')} + style={{ marginBottom: '16px' }} + > + + + {/* Second column - second row */} + + name="plugin" + label={t('form.searchForm.fields.plugin')} + style={{ marginBottom: '16px' }} + > + + + + + {/* Third column - first row */} + + + name="path" + label={t('form.searchForm.fields.path')} + style={{ marginBottom: '16px' }} + > + + + {/* Third column - second row */} + + name="labels" + label={t('form.searchForm.fields.labels')} + style={{ marginBottom: '16px' }} + > + + + {/* Fourth column - second row */} + + name="version" + label={t('form.searchForm.fields.version')} + style={{ marginBottom: '16px' }} + > + + + + +
+ + + + +
+ +
+
+ ); +}; + +export default SearchForm; diff --git a/src/locales/de/common.json b/src/locales/de/common.json index 0322e6a3a7..2db7652dd3 100644 --- a/src/locales/de/common.json +++ b/src/locales/de/common.json @@ -88,6 +88,39 @@ "vars": "Variablen" }, "search": "Suche", + "searchForm": { + "fields": { + "name": "Name", + "id": "ID", + "host": "Host", + "path": "Pfad", + "description": "Beschreibung", + "plugin": "Plugin", + "labels": "Labels", + "version": "Version", + "status": "Status" + }, + "placeholders": { + "name": "Name", + "id": "ID", + "host": "Host", + "path": "Pfad", + "description": "Beschreibung", + "plugin": "Plugin", + "labels": "Labels auswählen", + "version": "Version auswählen", + "status": "Status auswählen" + }, + "status": { + "all": "Unveröffentlicht/Veröffentlicht", + "published": "Veröffentlicht", + "unpublished": "Unveröffentlicht" + }, + "actions": { + "reset": "Zurücksetzen", + "search": "Suchen" + } + }, "secrets": { "aws": { "access_key_id": "Access Key ID", diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 8195d80c7f..f5175df808 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -88,6 +88,33 @@ "vars": "Vars" }, "search": "Search", + "searchForm": { + "fields": { + "name": "Name", + "id": "ID", + "host": "Host", + "path": "Path", + "description": "Description", + "plugin": "Plugin", + "labels": "Labels", + "version": "Version", + "status": "Status" + }, + "placeholders": { + "labels": "Select labels", + "version": "Select version", + "status": "Select status" + }, + "status": { + "all": "UnPublished/Published", + "published": "Published", + "unpublished": "UnPublished" + }, + "actions": { + "reset": "Reset", + "search": "Search" + } + }, "secrets": { "aws": { "access_key_id": "Access Key ID", @@ -360,9 +387,10 @@ "table": { "actions": "Actions", "disabled": "Disabled", - "enabled": "Enabled" + "enabled": "Enabled", + "searchLimit": "Search only allows searching in the first {{count}} records." }, "upstreams": { "singular": "Upstream" } -} +} \ No newline at end of file diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 1af5a22b8c..db9c5e0d9b 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -88,6 +88,39 @@ "vars": "Variables" }, "search": "Buscar", + "searchForm": { + "fields": { + "name": "Nombre", + "id": "ID", + "host": "Host", + "path": "Ruta", + "description": "Descripción", + "plugin": "Plugin", + "labels": "Etiquetas", + "version": "Versión", + "status": "Estado" + }, + "placeholders": { + "name": "Nombre", + "id": "ID", + "host": "Host", + "path": "Ruta", + "description": "Descripción", + "plugin": "Plugin", + "labels": "Selecciona etiquetas", + "version": "Selecciona versión", + "status": "Selecciona estado" + }, + "status": { + "all": "Sin publicar/Publicado", + "published": "Publicado", + "unpublished": "Sin publicar" + }, + "actions": { + "reset": "Restablecer", + "search": "Buscar" + } + }, "secrets": { "aws": { "access_key_id": "ID de Clave de Acceso", diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 6b44fbd776..fff4261f51 100644 --- a/src/routes/routes/index.tsx +++ b/src/routes/routes/index.tsx @@ -16,20 +16,30 @@ */ import type { ProColumns } from '@ant-design/pro-components'; import { ProTable } from '@ant-design/pro-components'; +import { useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks'; import type { WithServiceIdFilter } from '@/apis/routes'; +import { getRouteListReq } from '@/apis/routes'; +import { SearchForm, type SearchFormValues } from '@/components/form/SearchForm'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; import { AntdConfigProvider } from '@/config/antdConfigProvider'; -import { API_ROUTES } from '@/config/constant'; +import { API_ROUTES, PAGE_SIZE_MAX } from '@/config/constant'; import { queryClient } from '@/config/global'; +import { req } from '@/config/req'; import type { APISIXType } from '@/types/schema/apisix'; -import { pageSearchSchema } from '@/types/schema/pageSearch'; +import { pageSearchSchema, type PageSearchType } from '@/types/schema/pageSearch'; +import { + filterRoutes, + needsClientSideFiltering, + paginateResults, +} from '@/utils/clientSideFilter'; +import { useSearchParams } from '@/utils/useSearchParams'; import type { ListPageKeys } from '@/utils/useTablePagination'; export type RouteListProps = { @@ -40,14 +50,123 @@ export type RouteListProps = { }) => React.ReactNode; }; +const RouteDetailButton = ({ + record, +}: { + record: APISIXType['RespRouteItem']; +}) => ( + +); + +const SEARCH_PARAM_KEYS: (keyof SearchFormValues)[] = [ + 'name', + 'id', + 'host', + 'path', + 'description', + 'plugin', + 'labels', + 'version', + 'status', +]; + +const mapSearchParams = (values: Partial) => + Object.fromEntries(SEARCH_PARAM_KEYS.map((key) => [key, values[key]])) as Partial; + export const RouteList = (props: RouteListProps) => { const { routeKey, ToDetailBtn, defaultParams } = props; - const { data, isLoading, refetch, pagination } = useRouteList( + const { data, isLoading, refetch, pagination, setParams } = useRouteList( routeKey, defaultParams ); + const { params, resetParams } = useSearchParams(routeKey) as { + params: PageSearchType; + resetParams: () => void; + }; const { t } = useTranslation(); + // Fetch all data when client-side filtering is active + const needsAllData = needsClientSideFiltering(params); + const { data: allData, isLoading: isLoadingAllData } = useQuery({ + queryKey: ['routes-all', defaultParams, needsAllData], + queryFn: () => + getRouteListReq(req, { + page: 1, + page_size: PAGE_SIZE_MAX, + ...defaultParams, + }), + enabled: needsAllData, + }); + + const handleSearch = (values: SearchFormValues) => { + // Send name filter to backend, keep others for client-side filtering + setParams({ + page: 1, + ...mapSearchParams(values), + }); + }; + + const handleReset = () => { + resetParams(); + }; + + // Apply client-side filtering and pagination + const { filteredData, totalCount } = useMemo(() => { + // If client-side filtering is needed, use all data + if (needsAllData && allData?.list) { + const filtered = filterRoutes(allData.list, params); + const paginated = paginateResults( + filtered, + params.page || 1, + params.page_size || 10 + ); + return { + filteredData: paginated.list, + totalCount: paginated.total, + }; + } + + // Otherwise, use paginated data from backend + return { + filteredData: data?.list || [], + totalCount: data?.total || 0, + }; + }, [needsAllData, allData, data, params]); + + const actualLoading = needsAllData ? isLoadingAllData : isLoading; + + // Update pagination to use filtered total + const customPagination = useMemo(() => { + return { + ...pagination, + total: totalCount, + }; + }, [pagination, totalCount]); + + // Extract unique version values from route labels + const versionOptions = useMemo(() => { + const dataSource = needsAllData && allData?.list ? allData.list : data?.list || []; + const versions = new Set(); + + dataSource.forEach((route) => { + const versionLabel = route.value.labels?.version; + if (versionLabel) { + versions.add(versionLabel); + } + }); + + return Array.from(versions) + .sort() + .map((version) => ({ + label: version, + value: version, + })); + }, [needsAllData, allData, data]); + const columns = useMemo[]>(() => { return [ { @@ -95,14 +214,22 @@ export const RouteList = (props: RouteListProps) => { return ( +
+ +
{ ], }, }} + tableAlertRender={ + needsAllData + ? () => ( + + {t('table.searchLimit', { + defaultValue: `Search only allows searching in the first ${PAGE_SIZE_MAX} records.`, + count: PAGE_SIZE_MAX, + })} + + ) + : undefined + } />
); @@ -133,16 +272,7 @@ function RouteComponent() { return ( <> - ( - - )} - /> + ); } diff --git a/src/types/schema/pageSearch.ts b/src/types/schema/pageSearch.ts index fb663566b3..85cc09143c 100644 --- a/src/types/schema/pageSearch.ts +++ b/src/types/schema/pageSearch.ts @@ -31,6 +31,10 @@ export const pageSearchSchema = z .transform((val) => (val ? Number(val) : 10)), name: z.string().optional(), label: z.string().optional(), + id: z.string().optional(), + host: z.string().optional(), + path: z.string().optional(), + description: z.string().optional(), }) .passthrough(); diff --git a/src/utils/clientSideFilter.ts b/src/utils/clientSideFilter.ts new file mode 100644 index 0000000000..34a5a6cebf --- /dev/null +++ b/src/utils/clientSideFilter.ts @@ -0,0 +1,150 @@ +/** + * 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 { APISIXType } from '@/types/schema/apisix'; + +import type { SearchFormValues } from '../components/form/SearchForm'; + +/** + * Client-side filtering utility for routes + * Used as a fallback when backend doesn't support certain filter parameters + */ + +export const filterRoutes = ( + routes: APISIXType['RespRouteItem'][], + filters: SearchFormValues +): APISIXType['RespRouteItem'][] => { + return routes.filter((route) => { + const routeData = route.value; + + // Filter by name + if (filters.name && routeData.name) { + const nameMatch = routeData.name + .toLowerCase() + .includes(filters.name.toLowerCase()); + if (!nameMatch) return false; + } + + // Filter by ID + if (filters.id) { + const idMatch = String(routeData.id) + .toLowerCase() + .includes(filters.id.toLowerCase()); + if (!idMatch) return false; + } + + // Filter by host + if (filters.host) { + const host = Array.isArray(routeData.host) + ? routeData.host.join(',') + : routeData.host || ''; + const hostMatch = host.toLowerCase().includes(filters.host.toLowerCase()); + if (!hostMatch) return false; + } + + // Filter by path/URI + if (filters.path) { + const uri = Array.isArray(routeData.uri) + ? routeData.uri.join(',') + : routeData.uri || ''; + const uris = Array.isArray(routeData.uris) + ? routeData.uris.join(',') + : ''; + const combinedPath = `${uri} ${uris}`.toLowerCase(); + const pathMatch = combinedPath.includes(filters.path.toLowerCase()); + if (!pathMatch) return false; + } + + // Filter by description + if (filters.description && routeData.desc) { + const descMatch = routeData.desc + .toLowerCase() + .includes(filters.description.toLowerCase()); + if (!descMatch) return false; + } + + // Filter by plugin: treat the filter text as a substring across all plugin names + if (filters.plugin && routeData.plugins) { + const pluginNames = Object.keys(routeData.plugins).join(',').toLowerCase(); + const pluginMatch = pluginNames.includes(filters.plugin.toLowerCase()); + if (!pluginMatch) return false; + } + + // Filter by labels: match provided label key:value tokens against route label pairs + if (filters.labels && filters.labels.length > 0 && routeData.labels) { + const routeLabels = Object.keys(routeData.labels).map((key) => + `${key}:${routeData.labels![key]}`.toLowerCase() + ); + const hasMatchingLabel = filters.labels.some((filterLabel) => + routeLabels.some((routeLabel) => + routeLabel.includes(filterLabel.toLowerCase()) + ) + ); + if (!hasMatchingLabel) return false; + } + + // Filter by version + if (filters.version && routeData.labels?.version) { + const versionMatch = routeData.labels.version === filters.version; + if (!versionMatch) return false; + } + + // Filter by status + if (filters.status && filters.status !== 'UnPublished/Published') { + const isPublished = routeData.status === 1; + if (filters.status === 'Published' && !isPublished) return false; + if (filters.status === 'UnPublished' && isPublished) return false; + } + + return true; + }); +}; + +/** + * Check if client-side filtering is needed + * Returns true if any filter parameters are present + */ +export const needsClientSideFiltering = ( + filters: SearchFormValues +): boolean => { + return Boolean( + filters.name || + filters.id || + filters.host || + filters.path || + filters.description || + filters.plugin || + (filters.labels && filters.labels.length > 0) || + filters.version || + (filters.status && filters.status !== 'UnPublished/Published') + ); +}; + +/** + * Paginate filtered results + */ +export const paginateResults = ( + items: T[], + page: number, + pageSize: number +): { list: T[]; total: number } => { + const startIndex = (page - 1) * pageSize; + const endIndex = startIndex + pageSize; + return { + list: items.slice(startIndex, endIndex), + total: items.length, + }; +};