Fix Add API-only user > Specific API endpoints table search (#49613)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
LeAnn
2026-07-22 08:10:02 -07:00
committed by GitHub
parent 6d0ecf1b9f
commit 8677e1cf53
3 changed files with 277 additions and 11 deletions
@@ -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.
@@ -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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[
{ method: LIST_HOSTS.method, path: LIST_HOSTS.path },
]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={jest.fn()}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[]}
onSelectionChange={onSelectionChange}
/>
);
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(
<ApiEndpointSelectorTable
selectedEndpoints={[
{ method: LIST_HOSTS.method, path: LIST_HOSTS.path },
]}
onSelectionChange={onSelectionChange}
/>
);
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([]);
});
});
@@ -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}
/>
</div>