mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
fix(ui): keep Virtual Keys list state in the URL so it survives leaving the page (#39481)
* fix(ui): keep Virtual Keys list state in the URL so it survives leaving the page The search term, sort, pagination and drawer filters lived in component state, so navigating away from Virtual Keys and back reset the table to an unfiltered first page. Move them into query state alongside the existing ?key= deep link, which also makes a filtered view shareable. * fix(ui): namespace the Virtual Keys filter params and bound page inputs The unprefixed team_id filter hijacked the /api-keys create-key deep link, which already takes team_id as a prefill, so ?create=true&team_id=X silently filtered the list underneath the modal. Prefix the four drawer filters. Now that page and page_size come from the address bar, clamp them to what /key/list accepts instead of forwarding 0, negatives or an int64-overflowing page straight through, and trim filter values arriving from a URL the same way the drawer already trims them. * fix(ui): fall back to a sortable column when the URL names an unknown one A hand-edited or stale sort_by reached /key/list, which 400s it, leaving the Virtual Keys page on its loading skeleton with no error. Validate it against the fields the table's own headers can produce, and clear sort_by rather than blanking it when a sort is reset so the URL stays clean. Also replaces a default-state URL assertion that ran before any query-state write could land, so it could not fail for the regression it named. * fix(ui): use TanStack's functionalUpdate instead of a hand-rolled updater resolver The local helper narrowed typeof updater === "function" against an unconstrained T, which TypeScript cannot do because T itself may be a function type, so next build failed to type check. table-core already exports the same helper.
This commit is contained in:
parent
066d5f0694
commit
658f50663d
3 changed files with 317 additions and 45 deletions
|
|
@ -4,6 +4,7 @@ import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
|
|||
import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest";
|
||||
import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils";
|
||||
import { VirtualKeysTable } from "./VirtualKeysTable";
|
||||
import { KEY_TABLE_SORT_FIELDS } from "./keyTableColumns";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo";
|
||||
import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
|
@ -176,8 +177,10 @@ const keysResult = (keys: KeyResponse[], data: Partial<KeysResponse> = {}, extra
|
|||
|
||||
const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" }));
|
||||
|
||||
const lastKeyParam = (onUrlUpdate: Mock<OnUrlUpdateFunction>) =>
|
||||
onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("key");
|
||||
const lastSearchParam = (onUrlUpdate: Mock<OnUrlUpdateFunction>, name: string) =>
|
||||
onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get(name);
|
||||
|
||||
const lastKeyParam = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => lastSearchParam(onUrlUpdate, "key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -510,7 +513,8 @@ describe("server-side filtering – the LIT-4080 regression guard", () => {
|
|||
});
|
||||
|
||||
it("drops the filter from the useKeys query when it is cleared", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />);
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
||||
openFilters();
|
||||
const userIdInput = await screen.findByPlaceholderText(/Enter User ID/);
|
||||
|
|
@ -520,6 +524,12 @@ describe("server-side filtering – the LIT-4080 regression guard", () => {
|
|||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" }));
|
||||
});
|
||||
// Let the filter reach the URL before clearing it: NuqsTestingAdapter runs
|
||||
// resetUrlUpdateQueueOnMount on every render, so a still-queued write can be
|
||||
// aborted by the re-render its own predecessor triggers.
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "filter_user")).toBe("user-42");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("datatable-clear-filters"));
|
||||
|
||||
|
|
@ -638,3 +648,183 @@ describe("Status column reflects blocked / expiry / scim metadata", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("table state lives in the URL so it survives leaving and returning to the page", () => {
|
||||
it("restores the search term, sort and pagination from the URL on mount", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, {
|
||||
searchParams: { key_search: "prod", sort_by: "spend", sort_order: "asc", page: "3", page_size: "25" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
3,
|
||||
25,
|
||||
expect.objectContaining({ selectedKeyAlias: "prod", sortBy: "spend", sortOrder: "asc" }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByPlaceholderText(/Search by key alias/)).toHaveValue("prod");
|
||||
});
|
||||
|
||||
it("restores the drawer filters from the URL on mount", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { filter_team: "team-1", filter_user: "user-42" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ teamID: "team-1", userID: "user-42" }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Test Team");
|
||||
});
|
||||
|
||||
it("writes the search term to the URL", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "key_search")).toBe("prod");
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the sort field and direction to the URL", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Key" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "sort_by")).toBe("key_alias");
|
||||
});
|
||||
expect(lastSearchParam(onUrlUpdate, "sort_order")).toBe("asc");
|
||||
});
|
||||
|
||||
it("writes an applied drawer filter to the URL and clears it again", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
||||
openFilters();
|
||||
fireEvent.change(await screen.findByPlaceholderText(/Enter User ID/), { target: { value: "user-42" } });
|
||||
fireEvent.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "filter_user")).toBe("user-42");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("datatable-clear-filters"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "filter_user")).toBeNull();
|
||||
});
|
||||
expect(screen.queryByTestId("filter-chip-user_id")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to page 1 when the search term changes", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { page: "3" }, onUrlUpdate });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(3, 50, expect.anything());
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "prod" }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "page")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the create-key deep link's team_id alone instead of filtering the list with it", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { create: "true", team_id: "team-1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ teamID: undefined }));
|
||||
});
|
||||
expect(screen.queryByTestId("filter-chip-team_id")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["0", 1],
|
||||
["-3", 1],
|
||||
])("clamps a hand-edited page of %s up to the first page", async (page, expected) => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { page } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(expected, 50, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["0", 1],
|
||||
["1000", 100],
|
||||
])("clamps a hand-edited page_size of %s into the range /key/list accepts", async (pageSize, expected) => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { page_size: pageSize } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, expected, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace off a filter that arrived from the URL", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { filter_user: " user-42 " } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default sort when the URL names a column the table cannot sort by", async () => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { sort_by: "totally_unknown_field" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }),
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(KEY_TABLE_SORT_FIELDS)("round-trips a %s sort from the URL", async (field) => {
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { sort_by: field, sort_order: "asc" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: field, sortOrder: "asc" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("clears sort_by from the URL when the Spend / Budget sort is reset", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { onUrlUpdate });
|
||||
|
||||
await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Spend ascending", "menuitem");
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "sort_by")).toBe("spend");
|
||||
});
|
||||
|
||||
await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Reset", "menuitem");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "sort_by")).toBeNull();
|
||||
});
|
||||
expect(lastSearchParam(onUrlUpdate, "sort_order")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the search param back out of the URL when the search box is cleared", async () => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<VirtualKeysTable />, { searchParams: { key_search: "prod" }, onUrlUpdate });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastSearchParam(onUrlUpdate, "key_search")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,34 +15,65 @@ import { SearchSelect } from "@/components/shared/SearchSelect";
|
|||
import { PageHeader } from "@/components/shared/PageHeader";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
import { getKeyTableColumns, KEY_TABLE_HIDDEN_COLUMNS } from "./keyTableColumns";
|
||||
import { getKeyTableColumns, KEY_TABLE_HIDDEN_COLUMNS, KEY_TABLE_SORT_FIELDS } from "./keyTableColumns";
|
||||
|
||||
interface VirtualKeysTableProps {
|
||||
headerActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
const FILTER_COLUMNS = ["team_id", "org_id", "user_id", "key_hash"] as const;
|
||||
type FilterColumn = (typeof FILTER_COLUMNS)[number];
|
||||
|
||||
const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => {
|
||||
const active = sorting[0];
|
||||
if (!active) return undefined;
|
||||
return active.desc ? "desc" : "asc";
|
||||
};
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
const FILTER_LABELS: Record<FilterColumn, string> = {
|
||||
team_id: "Team",
|
||||
org_id: "Organization",
|
||||
user_id: "User ID",
|
||||
key_hash: "Key ID",
|
||||
};
|
||||
|
||||
const DEFAULT_SORT_BY = "created_at";
|
||||
const DEFAULT_SORT_ORDER = "desc";
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
const MAX_PAGE = 100_000;
|
||||
|
||||
const boundedInteger = (min: number, max: number, fallback: number) =>
|
||||
createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = parseAsInteger.parse(value);
|
||||
return parsed === null ? null : Math.min(Math.max(parsed, min), max);
|
||||
},
|
||||
serialize: String,
|
||||
}).withDefault(fallback);
|
||||
|
||||
// The filters carry a prefix because /api-keys also takes team_id, key_alias and key_type
|
||||
// as create-key prefills; an unprefixed filter would hijack those deep links.
|
||||
const TABLE_STATE = {
|
||||
key_search: parseAsString.withDefault(""),
|
||||
sort_by: parseAsString.withDefault(DEFAULT_SORT_BY),
|
||||
sort_order: parseAsStringLiteral(["asc", "desc"] as const).withDefault(DEFAULT_SORT_ORDER),
|
||||
page: boundedInteger(1, MAX_PAGE, 1),
|
||||
page_size: boundedInteger(1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE),
|
||||
filter_team: parseAsString.withDefault(""),
|
||||
filter_org: parseAsString.withDefault(""),
|
||||
filter_user: parseAsString.withDefault(""),
|
||||
filter_key_id: parseAsString.withDefault(""),
|
||||
};
|
||||
|
||||
const toSortOrder = (active: SortingState[number]): "asc" | "desc" => (active.desc ? "desc" : "asc");
|
||||
|
||||
const filterValue = (filters: ColumnFiltersState, column: FilterColumn): string | null => {
|
||||
const value = filters.find((filter) => filter.id === column)?.value;
|
||||
return (typeof value === "string" ? value.trim() : "") || null;
|
||||
};
|
||||
|
||||
export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
||||
const { data: fetchedOrganizations } = useOrganizations();
|
||||
const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]);
|
||||
|
|
@ -50,32 +81,48 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
const allTeams = useMemo<Team[]>(() => fetchedTeams ?? [], [fetchedTeams]);
|
||||
|
||||
const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" }));
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [tablePagination, setTablePagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [tableState, setTableState] = useQueryStates(TABLE_STATE);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const searchInput = tableState.key_search;
|
||||
const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const getFilterValue = useCallback(
|
||||
(columnId: string): string | undefined => {
|
||||
const entry = columnFilters.find((filter) => filter.id === columnId);
|
||||
return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined;
|
||||
},
|
||||
[columnFilters],
|
||||
// A hand-edited sort_by the table cannot sort by would 400 at /key/list and leave the page loading.
|
||||
const sortBy = KEY_TABLE_SORT_FIELDS.includes(tableState.sort_by) ? tableState.sort_by : DEFAULT_SORT_BY;
|
||||
const sorting = useMemo<SortingState>(
|
||||
() => [{ id: sortBy, desc: tableState.sort_order === "desc" }],
|
||||
[sortBy, tableState.sort_order],
|
||||
);
|
||||
const tablePagination = useMemo<PaginationState>(
|
||||
() => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }),
|
||||
[tableState.page, tableState.page_size],
|
||||
);
|
||||
const { filter_team, filter_org, filter_user, filter_key_id } = tableState;
|
||||
const appliedFilters = useMemo(
|
||||
() => ({
|
||||
team_id: filter_team.trim(),
|
||||
org_id: filter_org.trim(),
|
||||
user_id: filter_user.trim(),
|
||||
key_hash: filter_key_id.trim(),
|
||||
}),
|
||||
[filter_team, filter_org, filter_user, filter_key_id],
|
||||
);
|
||||
const columnFilters = useMemo<ColumnFiltersState>(
|
||||
() =>
|
||||
FILTER_COLUMNS.filter((column) => appliedFilters[column]).map((column) => ({
|
||||
id: column,
|
||||
value: appliedFilters[column],
|
||||
})),
|
||||
[appliedFilters],
|
||||
);
|
||||
|
||||
const sortBy = sorting[0]?.id;
|
||||
const sortOrder = toSortOrder(sorting);
|
||||
|
||||
const keyListOptions = {
|
||||
teamID: getFilterValue("team_id"),
|
||||
organizationID: getFilterValue("org_id"),
|
||||
teamID: appliedFilters.team_id || undefined,
|
||||
organizationID: appliedFilters.org_id || undefined,
|
||||
selectedKeyAlias: searchQuery.trim() || undefined,
|
||||
userID: getFilterValue("user_id"),
|
||||
keyHash: getFilterValue("key_hash"),
|
||||
userID: appliedFilters.user_id || undefined,
|
||||
keyHash: appliedFilters.key_hash || undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
sortOrder: tableState.sort_order,
|
||||
expand: "user",
|
||||
};
|
||||
|
||||
|
|
@ -89,20 +136,47 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
const keyList = useMemo(() => keys?.keys ?? [], [keys]);
|
||||
const rowCount = keys?.total_count ?? 0;
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchInput(value);
|
||||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
void setTableState({ key_search: value || null, page: null });
|
||||
},
|
||||
[setTableState],
|
||||
);
|
||||
|
||||
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
|
||||
setSorting(updaterOrValue);
|
||||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
const handleSortingChange = useCallback<OnChangeFn<SortingState>>(
|
||||
(updaterOrValue) => {
|
||||
const active = functionalUpdate(updaterOrValue, sorting)[0];
|
||||
void setTableState({
|
||||
sort_by: active?.id ?? null,
|
||||
sort_order: active ? toSortOrder(active) : null,
|
||||
page: null,
|
||||
});
|
||||
},
|
||||
[sorting, setTableState],
|
||||
);
|
||||
|
||||
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
|
||||
setColumnFilters(updaterOrValue);
|
||||
setTablePagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}, []);
|
||||
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
|
||||
(updaterOrValue) => {
|
||||
const next = functionalUpdate(updaterOrValue, columnFilters);
|
||||
const nextFilters = {
|
||||
filter_team: filterValue(next, "team_id"),
|
||||
filter_org: filterValue(next, "org_id"),
|
||||
filter_user: filterValue(next, "user_id"),
|
||||
filter_key_id: filterValue(next, "key_hash"),
|
||||
page: null,
|
||||
};
|
||||
void setTableState(nextFilters);
|
||||
},
|
||||
[columnFilters, setTableState],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback<OnChangeFn<PaginationState>>(
|
||||
(updaterOrValue) => {
|
||||
const next = functionalUpdate(updaterOrValue, tablePagination);
|
||||
void setTableState({ page: next.pageIndex + 1, page_size: next.pageSize });
|
||||
},
|
||||
[tablePagination, setTableState],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => void setSelectedKeyId(key.token) }),
|
||||
|
|
@ -199,7 +273,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
|
|||
onSortingChange={handleSortingChange}
|
||||
paginationMode="server"
|
||||
pagination={tablePagination}
|
||||
onPaginationChange={setTablePagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
rowCount={rowCount}
|
||||
filterMode="server"
|
||||
columnFilters={columnFilters}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ const SPEND_BUDGET_SORT_FIELDS: DataTableSortField[] = [
|
|||
{ id: "max_budget", label: "Budget" },
|
||||
];
|
||||
|
||||
export const KEY_TABLE_SORT_FIELDS: readonly string[] = [
|
||||
"key_alias",
|
||||
"token",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
...SPEND_BUDGET_SORT_FIELDS.map((field) => field.id),
|
||||
];
|
||||
|
||||
const getKeyStatus = (key: KeyResponse): KeyStatus => {
|
||||
if (key.blocked === true) {
|
||||
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue