From 8677e1cf5389bc52cc96b09f1e345d53172ee7ea Mon Sep 17 00:00:00 2001 From: LeAnn <97471894+Leanngove@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:10:02 -0700 Subject: [PATCH] Fix Add API-only user > Specific API endpoints table search (#49613) **Related issue:** Resolves #49707 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Improved API endpoint search with relevance-based ranking across endpoint names and paths. * Search results now prioritize exact, prefix, whole-word, and partial matches. * Added path-based matching and clearer empty-state behavior. * Removed pagination in favor of the existing results scrollbar. * **Bug Fixes** * Preserved relevance ordering instead of applying an incorrect default sort. * Excluded already-selected endpoints from search results. --- ...specific-API-endpoints-search-improvements | 2 + .../ApiEndpointSelectorTable.tests.tsx | 226 ++++++++++++++++++ .../ApiEndpointSelectorTable.tsx | 60 ++++- 3 files changed, 277 insertions(+), 11 deletions(-) create mode 100644 changes/47430-specific-API-endpoints-search-improvements create mode 100644 frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx diff --git a/changes/47430-specific-API-endpoints-search-improvements b/changes/47430-specific-API-endpoints-search-improvements new file mode 100644 index 0000000000..8f0f14ca50 --- /dev/null +++ b/changes/47430-specific-API-endpoints-search-improvements @@ -0,0 +1,2 @@ +- Removed pagination from the "Select API endpoints" search results table (Settings > Users > Add API-only user > Specific API endpoints), relying on the results dropdown's existing scrollbar instead. +- Ranked "Select API endpoints" search results by relevance (exact match, then prefix, then whole-word, then substring, matched against both name and path) instead of leaving them in an unranked catalog order, and fixed a bug where the table's default sort silently discarded that ranking. diff --git a/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx new file mode 100644 index 0000000000..e87bbf657e --- /dev/null +++ b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tests.tsx @@ -0,0 +1,226 @@ +import React from "react"; + +import { screen, waitFor, within } from "@testing-library/react"; +import { createCustomRenderer } from "test/test-utils"; + +import apiEndpointsAPI from "services/entities/api_endpoints"; +import { IApiEndpoint } from "interfaces/api_endpoint"; + +import ApiEndpointSelectorTable from "./ApiEndpointSelectorTable"; + +jest.mock("services/entities/api_endpoints"); + +const LIST_HOSTS: IApiEndpoint = { + method: "GET", + path: "/api/v1/fleet/hosts", + display_name: "List hosts", + deprecated: false, +}; + +const UNINSTALL_SOFTWARE: IApiEndpoint = { + method: "POST", + path: "/api/v1/fleet/hosts/:id/software/:software_title_id/uninstall", + display_name: "Uninstall software", + deprecated: false, +}; + +const GET_HOST_SOFTWARE: IApiEndpoint = { + method: "GET", + path: "/api/v1/fleet/hosts/:id/software", + display_name: "List host's software", + deprecated: false, +}; + +const DEPRECATED_ENDPOINT: IApiEndpoint = { + method: "GET", + path: "/api/v1/fleet/packs", + display_name: "List packs", + deprecated: true, +}; + +const MOCK_ENDPOINTS: IApiEndpoint[] = [ + UNINSTALL_SOFTWARE, + GET_HOST_SOFTWARE, + DEPRECATED_ENDPOINT, + LIST_HOSTS, +]; + +describe("ApiEndpointSelectorTable", () => { + const render = createCustomRenderer({ withBackendMock: true }); + + beforeEach(() => { + (apiEndpointsAPI.loadAll as jest.Mock).mockResolvedValue(MOCK_ENDPOINTS); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("does not show a results dropdown when the search box is empty", async () => { + render( + + ); + + await waitFor(() => expect(apiEndpointsAPI.loadAll).toHaveBeenCalled()); + expect(screen.queryByText("List hosts")).not.toBeInTheDocument(); + }); + + it("ranks a broad, single-word search by relevance instead of catalog order", async () => { + const { user } = render( + + ); + + await user.type( + screen.getByPlaceholderText("Search by name or path"), + "hosts" + ); + + const names = await screen.findAllByText( + /^(List hosts|List host's software|Uninstall software)$/ + ); + // "List hosts" is a whole-word match on a shallower path than the other + // two "hosts"-containing endpoints, so it should rank first. + expect(names[0]).toHaveTextContent("List hosts"); + }); + + it("ranks an exact name match first even when it isn't first in catalog order", async () => { + const { user } = render( + + ); + + await user.type( + screen.getByPlaceholderText("Search by name or path"), + "list hosts" + ); + + const results = await screen.findAllByText( + /^(List hosts|List host's software)$/ + ); + expect(results).toHaveLength(1); + expect(results[0]).toHaveTextContent("List hosts"); + }); + + it("matches on path as well as name", async () => { + const { user } = render( + + ); + + await user.type( + screen.getByPlaceholderText("Search by name or path"), + "uninstall" + ); + + await screen.findByText("Uninstall software"); + expect(screen.queryByText("List hosts")).not.toBeInTheDocument(); + }); + + it("excludes already-selected endpoints from the search results", async () => { + const { user } = render( + + ); + + await user.type( + screen.getByPlaceholderText("Search by name or path"), + "hosts" + ); + + await screen.findByText("Uninstall software"); + // "List hosts" should only appear once now, in the selected-endpoints + // table, not also in the search results dropdown. + expect(screen.getAllByText("List hosts")).toHaveLength(1); + }); + + it("shows an empty state when nothing matches", async () => { + const { user } = render( + + ); + + await user.type( + screen.getByPlaceholderText("Search by name or path"), + "nonexistent-endpoint" + ); + + await screen.findByText("No matching API endpoints."); + }); + + it("shows a deprecated badge for deprecated endpoints", async () => { + const { user } = render( + + ); + + await user.type( + screen.getByPlaceholderText("Search by name or path"), + "packs" + ); + + await screen.findByText("List packs"); + expect(screen.getByText("Deprecated")).toBeInTheDocument(); + }); + + it("adds the clicked endpoint to the selection and clears the search text", async () => { + const onSelectionChange = jest.fn(); + const { user } = render( + + ); + + const searchInput = screen.getByPlaceholderText("Search by name or path"); + await user.type(searchInput, "list hosts"); + + const result = await screen.findByText("List hosts"); + await user.click(result); + + await waitFor(() => { + expect(onSelectionChange).toHaveBeenCalledWith([ + { method: LIST_HOSTS.method, path: LIST_HOSTS.path }, + ]); + }); + expect(searchInput).toHaveValue(""); + }); + + it("removes an endpoint from the selected-endpoints table", async () => { + const onSelectionChange = jest.fn(); + const { user } = render( + + ); + + const selectedRow = (await screen.findByText("List hosts")).closest("tr"); + if (!selectedRow) { + throw new Error("Expected to find the selected endpoint's table row"); + } + + await user.click(within(selectedRow).getByRole("button")); + + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx index 02d063ac4e..fac1388a3c 100644 --- a/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx +++ b/frontend/pages/admin/ManageUsersPage/components/ApiEndpointSelectorTable/ApiEndpointSelectorTable.tsx @@ -35,6 +35,37 @@ interface IApiEndpointRow extends IApiEndpoint { const normalizePath = (s: string) => s.toLowerCase().replace(/:[a-z0-9_]+/g, ":_"); +/** Split on path separators, whitespace, and word-boundary punctuation so + * both names ("List hosts") and paths ("/api/v1/fleet/hosts") can be + * compared word-by-word. */ +const WORD_SPLIT_RE = /[\s/_-]+/; + +/** Score how well a single field matches the query: exact match ranks + * highest, then prefix match, then whole-word match, then any substring + * match. Returns 0 when there's no match at all. */ +const scoreField = (field: string, query: string): number => { + if (!field || !query) return 0; + if (field === query) return 100; + if (field.startsWith(query)) return 90; + if (field.split(WORD_SPLIT_RE).filter(Boolean).includes(query)) return 70; + if (field.includes(query)) return 50; + return 0; +}; + +/** An endpoint's relevance is the strongest match across its name and path — + * a strong hit on one field can outrank a weak hit on the other. Method + * matches (e.g. searching "post") are ranked below any name/path match. */ +const scoreEndpoint = (ep: IApiEndpointRow, query: string): number => + Math.max( + scoreField(ep.display_name.toLowerCase(), query), + scoreField(normalizePath(ep.path), query), + ep.method.toLowerCase().includes(query) ? 10 : 0 + ); + +/** Fewer path segments = a broader, higher-level endpoint. Used to break + * score ties so e.g. `/hosts` sorts before `/hosts/:id/software`. */ +const pathDepth = (path: string) => path.split("/").filter(Boolean).length; + interface IApiEndpointSelectorTableProps { selectedEndpoints: IApiEndpointRef[]; onSelectionChange: (endpoints: IApiEndpointRef[]) => void; @@ -122,20 +153,23 @@ const ApiEndpointSelectorTable = ({ [apiEndpoints] ); - // Filter search results: match search text and exclude already-selected. + // Filter search results: match search text and exclude already-selected, + // then rank by relevance (best match across name/path first, broader + // paths breaking ties) rather than leaving them in catalog order. // Path parameter names (e.g. `:id`, `:host_id`) are normalized so searching // "/hosts/:id/report" matches "/hosts/:host_id/report". const searchResults: IApiEndpointRow[] = useMemo(() => { if (isEmpty(searchText)) return []; const query = normalizePath(searchText); - return allRows.filter((ep) => { - if (selectedEndpoints.some((s) => endpointKey(s) === ep.id)) return false; - return ( - ep.display_name.toLowerCase().includes(query) || - normalizePath(ep.path).includes(query) || - ep.method.toLowerCase().includes(query) - ); - }); + return allRows + .filter((ep) => !selectedEndpoints.some((s) => endpointKey(s) === ep.id)) + .map((ep) => ({ ep, score: scoreEndpoint(ep, query) })) + .filter(({ score }) => score > 0) + .sort( + (a, b) => + b.score - a.score || pathDepth(a.ep.path) - pathDepth(b.ep.path) + ) + .map(({ ep }) => ep); }, [allRows, searchText, selectedEndpoints]); const selectedRows: IApiEndpointRow[] = useMemo( @@ -240,8 +274,12 @@ const ApiEndpointSelectorTable = ({ isAllPagesSelected={false} disableCount disableMultiRowSelect - isClientSidePagination - pageSize={10} + disablePagination + // Without this, TableContainer's default sort (by a "name" + // column that doesn't exist here) silently re-shuffles rows via + // react-table's built-in sorting, discarding the relevance + // order computed above. + manualSortBy onClickRow={handleRowSelect} />