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/Editor.tsx b/src/components/form/Editor.tsx index 761a5ebd5a..b095ac9eb9 100644 --- a/src/components/form/Editor.tsx +++ b/src/components/form/Editor.tsx @@ -16,7 +16,7 @@ */ import { InputWrapper, type InputWrapperProps, Skeleton } from '@mantine/core'; import { Editor } from '@monaco-editor/react'; -import { clsx } from 'clsx'; +import classNames from 'clsx'; import { useEffect, useMemo, useRef, useState } from 'react'; import { type FieldValues, @@ -132,7 +132,7 @@ export const FormItemEditor = ( )} 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(); + onReset?.(values); + 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/config/i18n.ts b/src/config/i18n.ts index e14746e528..4bf9b8509a 100644 --- a/src/config/i18n.ts +++ b/src/config/i18n.ts @@ -19,8 +19,8 @@ import { initReactI18next } from 'react-i18next'; import de_common from '@/locales/de/common.json'; import en_common from '@/locales/en/common.json'; -import zh_common from '@/locales/zh/common.json'; import es_common from '@/locales/es/common.json'; +import zh_common from '@/locales/zh/common.json'; export const resources = { en: { 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..ec1b017364 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", 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/locales/zh/common.json b/src/locales/zh/common.json index 0967ef424b..837716da7a 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -1 +1,38 @@ -{} + +{ + "form": { + "searchForm": { + "fields": { + "name": "名称", + "id": "ID", + "host": "主机", + "path": "路径", + "description": "描述", + "plugin": "插件", + "labels": "标签", + "version": "版本", + "status": "状态" + }, + "placeholders": { + "name": "名称", + "id": "ID", + "host": "主机", + "path": "路径", + "description": "描述", + "plugin": "插件", + "labels": "选择标签", + "version": "选择版本", + "status": "选择状态" + }, + "status": { + "all": "未发布/已发布", + "published": "已发布", + "unpublished": "未发布" + }, + "actions": { + "reset": "重置", + "search": "搜索" + } + } + } +} diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 6b44fbd776..03d48a22b0 100644 --- a/src/routes/routes/index.tsx +++ b/src/routes/routes/index.tsx @@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'; import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks'; import type { WithServiceIdFilter } 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'; @@ -30,6 +31,11 @@ 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 { + filterRoutes, + needsClientSideFiltering, +} from '@/utils/clientSideFilter'; +import { useSearchParams } from '@/utils/useSearchParams'; import type { ListPageKeys } from '@/utils/useTablePagination'; export type RouteListProps = { @@ -40,14 +46,70 @@ 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 } = useSearchParams(routeKey); const { t } = useTranslation(); + const handleSearch = (values: SearchFormValues) => { + // Send name filter to backend, keep others for client-side filtering + setParams({ + page: 1, + ...mapSearchParams(values), + }); + }; + + const handleReset = () => { + setParams({ + page: 1, + ...mapSearchParams({}), + }); + }; + + // Apply client-side filtering for parameters not supported by APISIX backend + const filteredData = useMemo(() => { + if (!data?.list) return []; + + // Check if we need client-side filtering (for host, path, description, etc.) + if (needsClientSideFiltering(params)) { + return filterRoutes(data.list, params); + } + + // If only backend-supported filters (name) are used, return data as-is + return data.list; + }, [data?.list, params]); + const columns = useMemo[]>(() => { return [ { @@ -95,9 +157,12 @@ export const RouteList = (props: RouteListProps) => { 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..5ed939b66f --- /dev/null +++ b/src/utils/clientSideFilter.ts @@ -0,0 +1,143 @@ +/** + * 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 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.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, + }; +};