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:
@@ -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.
|
||||
+226
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+49
-11
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user