This commit is contained in:
jesus-berri 2026-09-30 16:57:02 -04:00 • committed by GitHub
commit 64fcc8b2cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 996 additions and 155 deletions

View file

@ -1140,7 +1140,7 @@
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 3
"count": 2
}
},
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {

View file

@ -1,7 +1,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { useState, type ComponentProps, type ReactNode } from "react";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import * as networking from "@/components/networking";
@ -9,6 +9,13 @@ import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from
import EntityUsage from "./EntityUsage";
import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
type EntityUsageProps = ComponentProps<typeof EntityUsage>;
function StatefulEntityUsage(props: Omit<EntityUsageProps, "selectedEntities" | "onSelectedEntitiesChange">) {
const [selectedEntities, setSelectedEntities] = useState<readonly string[]>([]);
return <EntityUsage {...props} selectedEntities={selectedEntities} onSelectedEntitiesChange={setSelectedEntities} />;
}
const emptySpendMetrics: SpendMetrics = {
spend: 0,
prompt_tokens: 0,
@ -94,7 +101,7 @@ vi.mock("./TopModelView", () => ({
}));
vi.mock("./TeamUserSpendCard", () => ({
default: ({ teamIds }: { teamIds: string[] }) => <div>{`team-user-spend:${teamIds.join("|")}`}</div>,
default: ({ teamIds }: { teamIds: readonly string[] }) => <div>{`team-user-spend:${teamIds.join("|")}`}</div>,
}));
vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({
@ -513,7 +520,7 @@ describe("EntityUsage", () => {
});
it("should render with tag entity type and display spend metrics", async () => {
render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -531,7 +538,7 @@ describe("EntityUsage", () => {
});
it("should render with team entity type and call team API", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -554,12 +561,12 @@ describe("EntityUsage", () => {
teamsResult([{ team_id: "team-alpha" }, { team_id: "litellm-dashboard" }, { team_id: "team-beta" }]),
);
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
expect(await screen.findByText("team-user-spend:team-alpha|team-beta")).toBeInTheDocument();
cleanup();
mockUseTeams.mockReturnValue(teamsResult([]));
render(<EntityUsage {...defaultProps} entityType="tag" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
@ -567,7 +574,7 @@ describe("EntityUsage", () => {
});
it("should render with organization entity type and call organization API", async () => {
render(<EntityUsage {...defaultProps} entityType="organization" />);
render(<StatefulEntityUsage {...defaultProps} entityType="organization" />);
await waitFor(() => {
expect(mockOrganizationDailyActivityCall).toHaveBeenCalled();
@ -582,7 +589,7 @@ describe("EntityUsage", () => {
});
it("should render with customer entity type and call customer API", async () => {
render(<EntityUsage {...defaultProps} entityType="customer" />);
render(<StatefulEntityUsage {...defaultProps} entityType="customer" />);
await waitFor(() => {
expect(mockCustomerDailyActivityCall).toHaveBeenCalled();
@ -597,7 +604,7 @@ describe("EntityUsage", () => {
});
it("should render with agent entity type and call agent API", async () => {
render(<EntityUsage {...defaultProps} entityType="agent" />);
render(<StatefulEntityUsage {...defaultProps} entityType="agent" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
@ -612,7 +619,7 @@ describe("EntityUsage", () => {
});
it("should render with user entity type and call user API", async () => {
render(<EntityUsage {...defaultProps} entityType="user" />);
render(<StatefulEntityUsage {...defaultProps} entityType="user" />);
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalled();
@ -627,7 +634,7 @@ describe("EntityUsage", () => {
});
it("should switch between tabs", async () => {
render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -676,7 +683,7 @@ describe("EntityUsage", () => {
];
it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -702,7 +709,7 @@ describe("EntityUsage", () => {
];
it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -733,7 +740,7 @@ describe("EntityUsage", () => {
mockTagDailyActivityCall.mockResolvedValue(emptyData);
render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -746,7 +753,7 @@ describe("EntityUsage", () => {
});
it("should display Model Activity tab for non-agent entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -756,7 +763,7 @@ describe("EntityUsage", () => {
});
it("should display Request / Token Consumption tab for agent entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="agent" />);
render(<StatefulEntityUsage {...defaultProps} entityType="agent" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
@ -766,7 +773,7 @@ describe("EntityUsage", () => {
});
it("should display Top Public Model Names title for non-agent entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -776,7 +783,7 @@ describe("EntityUsage", () => {
});
it("defaults Model Activity to public model names and toggles to litellm models", async () => {
const { container } = render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -802,7 +809,7 @@ describe("EntityUsage", () => {
});
it("should display Top Agents title for agent entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="agent" />);
render(<StatefulEntityUsage {...defaultProps} entityType="agent" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
@ -817,7 +824,7 @@ describe("EntityUsage", () => {
{ label: "Tag 2", value: "tag-2" },
];
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
render(<StatefulEntityUsage {...defaultProps} entityList={customEntityList} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -831,7 +838,7 @@ describe("EntityUsage", () => {
it("should fallback to team_alias when entityList is provided but entity does not exist", async () => {
const customEntityList = [{ label: "Tag 2", value: "tag-2" }];
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
render(<StatefulEntityUsage {...defaultProps} entityList={customEntityList} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -843,7 +850,7 @@ describe("EntityUsage", () => {
});
it("should fallback to team_alias when entityList is null", async () => {
render(<EntityUsage {...defaultProps} entityList={null} />);
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -855,7 +862,7 @@ describe("EntityUsage", () => {
});
it("should still request the filter when the caller's tag scope is empty", async () => {
render(<EntityUsage {...defaultProps} entityList={[]} />);
render(<StatefulEntityUsage {...defaultProps} entityList={[]} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -865,7 +872,7 @@ describe("EntityUsage", () => {
});
it("should not request the filter while the entity list is still unresolved", async () => {
render(<EntityUsage {...defaultProps} entityList={null} />);
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -875,7 +882,7 @@ describe("EntityUsage", () => {
});
it("should display Agent Activity tab for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -885,7 +892,7 @@ describe("EntityUsage", () => {
});
it("should not display Agent Activity tab for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -895,7 +902,7 @@ describe("EntityUsage", () => {
});
it("should display Top Agents Driving Spend card for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -905,7 +912,7 @@ describe("EntityUsage", () => {
});
it("should not display Top Agents Driving Spend card for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -915,7 +922,7 @@ describe("EntityUsage", () => {
});
it("should fetch agent activity data when entity type is team", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(
@ -929,7 +936,7 @@ describe("EntityUsage", () => {
});
it("should not fetch agent activity data for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -939,7 +946,7 @@ describe("EntityUsage", () => {
});
it("should switch to Agent Activity tab for team entity type", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -976,7 +983,7 @@ describe("EntityUsage", () => {
mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias);
render(<EntityUsage {...defaultProps} entityList={null} />);
render(<StatefulEntityUsage {...defaultProps} entityList={null} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -988,7 +995,7 @@ describe("EntityUsage", () => {
});
it("renders daily spend bars, per-entity bars, and the provider donut with cyan fills and a $ center total", async () => {
const { container } = render(<EntityUsage {...defaultProps} />);
const { container } = render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -1037,7 +1044,7 @@ describe("EntityUsage", () => {
mockUserDailyActivityCall.mockResolvedValue(spendDataForUser);
// entityList is null to simulate a spender missing from the paginated user list
render(<EntityUsage {...defaultProps} entityType="user" entityList={null} />);
render(<StatefulEntityUsage {...defaultProps} entityType="user" entityList={null} />);
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalled();
@ -1050,7 +1057,7 @@ describe("EntityUsage", () => {
});
it("renders the provider spend table logo from the bundled provider map", async () => {
render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
const logo = await screen.findByAltText("openai logo");
expect(logo).toHaveAttribute("src", expect.stringContaining("openai_small"));
@ -1061,7 +1068,7 @@ describe("EntityUsage", () => {
["organization", () => mockOrganizationDailyActivityCall, "Organization Spend Overview"],
["agent", () => mockAgentDailyActivityCall, "Agent Spend Overview"],
] as const)("fetches %s activity for an admin but not for an internal user", async (entityType, call, heading) => {
render(<EntityUsage {...defaultProps} entityType={entityType} />);
render(<StatefulEntityUsage {...defaultProps} entityType={entityType} />);
await waitFor(() => {
expect(call()).toHaveBeenCalled();
});
@ -1069,7 +1076,7 @@ describe("EntityUsage", () => {
cleanup();
call().mockClear();
render(<EntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" />);
render(<StatefulEntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" />);
expect(await screen.findByText(heading)).toBeInTheDocument();
expect(call()).not.toHaveBeenCalled();
});
@ -1082,7 +1089,9 @@ describe("EntityUsage", () => {
["organization", () => mockOrganizationDailyActivityCall, true],
["agent", () => mockAgentDailyActivityCall, false],
] as const)("fetches %s activity for an org admin: %s", async (entityType, call, expected) => {
render(<EntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" isOrgAdmin={true} />);
render(
<StatefulEntityUsage {...defaultProps} entityType={entityType} userRole="Internal User" isOrgAdmin={true} />,
);
if (expected) {
await waitFor(() => {
@ -1095,7 +1104,7 @@ describe("EntityUsage", () => {
});
it("keeps the team breakdown but drops its agent sub-fetch for an internal user", async () => {
render(<EntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -1108,7 +1117,7 @@ describe("EntityUsage", () => {
});
it("keeps the tag breakdown for an internal user", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" userRole="Internal User" />);
render(<StatefulEntityUsage {...defaultProps} entityType="tag" userRole="Internal User" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -1134,7 +1143,7 @@ describe("EntityUsage", () => {
};
mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider);
render(<EntityUsage {...defaultProps} />);
render(<StatefulEntityUsage {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("zzz-internal").length).toBeGreaterThan(0);
@ -1174,7 +1183,7 @@ describe("EntityUsage", () => {
],
});
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(screen.getByText("top-keys:sk-abc=30.75=alice@example.com")).toBeInTheDocument();
@ -1184,7 +1193,7 @@ describe("EntityUsage", () => {
});
it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
@ -1199,7 +1208,7 @@ describe("EntityUsage", () => {
it("falls back to the paginated team endpoint when the aggregated call fails", async () => {
mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
render(<EntityUsage {...defaultProps} entityType="team" />);
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
@ -1215,12 +1224,54 @@ describe("EntityUsage", () => {
const userCombobox = (): HTMLElement => within(userDropdown()).getByRole("combobox");
const renderUserUsage = async () => {
render(<EntityUsage {...defaultProps} entityType="user" entityList={null} />);
render(<StatefulEntityUsage {...defaultProps} entityType="user" entityList={null} />);
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
};
it("links the single picked user back to their Internal Users record", async () => {
render(
<EntityUsage
{...defaultProps}
entityType="user"
entityList={null}
selectedEntities={["user-001"]}
onSelectedEntitiesChange={vi.fn()}
/>,
);
expect(await screen.findByRole("link", { name: "View user record" })).toHaveAttribute(
"href",
"/ui/users?user=user-001",
);
});
it("shows no user record link until a user is picked", async () => {
await renderUserUsage();
expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument();
});
it("reports the picked user to the owner of the selection", async () => {
const user = userEvent.setup();
const onSelectedEntitiesChange = vi.fn();
render(
<EntityUsage
{...defaultProps}
entityType="user"
entityList={null}
selectedEntities={[]}
onSelectedEntitiesChange={onSelectedEntitiesChange}
/>,
);
await user.click(userCombobox());
await user.click(await screen.findByText("Alice (user-001)"));
expect(onSelectedEntitiesChange).toHaveBeenCalledWith(["user-001"]);
});
it("offers a user filter even when the caller preloaded no user page", async () => {
await renderUserUsage();

View file

@ -23,6 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import React, { type ReactNode, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import UserDropdown from "@/components/common_components/UserDropdown";
import UserRecordLink from "../UserRecordLink";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import { UsageExportHeader } from "@/components/EntityUsageExport";
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
@ -89,6 +90,8 @@ interface EntityUsageProps {
premiumUser: boolean;
dateValue: DateRangePickerValue;
isOrgAdmin?: boolean;
selectedEntities: readonly string[];
onSelectedEntitiesChange: (ids: readonly string[]) => void;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
@ -119,9 +122,10 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
userRole,
dateValue,
isOrgAdmin = false,
selectedEntities: selectedTags,
onSelectedEntitiesChange: setSelectedTags,
}) => {
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
@ -274,7 +278,10 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const entityFilterSlots: Partial<Record<EntityType, ReactNode>> = {
team: <TeamMultiSelect value={selectedTags} onChange={setSelectedTags} />,
user: (
<UserDropdown value={selectedTags[0] ?? null} onChange={(userId) => setSelectedTags(userId ? [userId] : [])} />
<div className="flex items-center gap-3">
<UserDropdown value={selectedTags[0] ?? null} onChange={(userId) => setSelectedTags(userId ? [userId] : [])} />
{selectedTags.length === 1 && <UserRecordLink userId={selectedTags[0]} />}
</div>
),
};
const filterSlot = entityFilterSlots[entityType];

View file

@ -24,7 +24,7 @@ interface TeamUserSpendCardProps {
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
teamIds: string[];
teamIds: readonly string[];
}
const columns: ColumnDef<TeamUserSpendRow>[] = [

View file

@ -7,11 +7,15 @@ import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { UrlUpdateEvent } from "nuqs/adapters/testing";
import { renderWithProviders } from "@/../tests/test-utils";
import { toast } from "@/lib/toast";
import type { Organization } from "@/components/networking";
import * as networking from "@/components/networking";
import UsagePage from "./UsagePageView";
const mockEntityUsageMounted = vi.hoisted(() => vi.fn());
// Polyfill ResizeObserver for test environment
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
@ -47,14 +51,38 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
default: () => <div>Top Keys</div>,
}));
vi.mock("./EntityUsage/EntityUsage", () => ({
default: ({ entityType, entityList }: { entityType: string; entityList: unknown }) => (
<div data-testid="entity-usage" data-entity-type={entityType} data-entity-list={JSON.stringify(entityList ?? null)}>
Entity Usage
</div>
),
EntityList: [],
}));
vi.mock("./EntityUsage/EntityUsage", async () => {
const React = await import("react");
const EntityUsage = ({
entityType,
entityList,
selectedEntities,
onSelectedEntitiesChange,
}: {
entityType: string;
entityList: unknown;
selectedEntities: readonly string[];
onSelectedEntitiesChange: (ids: readonly string[]) => void;
}) => {
React.useEffect(() => {
mockEntityUsageMounted(entityType);
}, [entityType]);
return (
<div
data-testid="entity-usage"
data-entity-type={entityType}
data-entity-list={JSON.stringify(entityList ?? null)}
data-selected={JSON.stringify(selectedEntities)}
>
Entity Usage
<button type="button" onClick={() => onSelectedEntitiesChange(["picked-1", "picked-2"])}>
pick entities
</button>
</div>
);
};
return { default: EntityUsage, EntityList: [] };
});
vi.mock("./EntityUsage/SpendByProvider", () => ({
default: () => <div>Spend By Provider</div>,
@ -64,7 +92,8 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
default: () => <div>Endpoint Usage</div>,
}));
vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => {
const actual = await importOriginal<typeof import("./UsageViewSelect/UsageViewSelect")>();
const React = await import("react");
const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => {
const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null;
@ -87,14 +116,19 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
);
};
UsageViewSelect.displayName = "UsageViewSelect";
return { UsageViewSelect };
return { ...actual, UsageViewSelect };
});
vi.mock("@/components/shared/advanced_date_picker", async () => {
vi.mock("@/components/shared/advanced_date_picker", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/shared/advanced_date_picker")>();
const React = await import("react");
// The button is how a test drives a range change; the real picker's own UI is
// not what any test here is asserting on.
const AdvancedDatePicker = ({ onValueChange }: { onValueChange?: (value: unknown) => void }) =>
const AdvancedDatePicker = ({
onValueChange,
}: {
onValueChange?: (value: unknown, presetShortLabel: string | null) => void;
}) =>
React.createElement(
"div",
{ "data-testid": "advanced-date-picker" },
@ -104,13 +138,13 @@ vi.mock("@/components/shared/advanced_date_picker", async () => {
{
"data-testid": "pick-a-different-range",
onClick: () =>
onValueChange?.({ from: new Date("2024-01-01T00:00:00Z"), to: new Date("2024-01-08T00:00:00Z") }),
onValueChange?.({ from: new Date("2024-01-01T00:00:00Z"), to: new Date("2024-01-08T00:00:00Z") }, null),
},
"pick",
),
);
AdvancedDatePicker.displayName = "AdvancedDatePicker";
return { default: AdvancedDatePicker };
return { ...actual, default: AdvancedDatePicker };
});
vi.mock("@/components/user_agent_activity", () => ({
@ -147,6 +181,10 @@ vi.mock("@/app/(dashboard)/hooks/useIsOrgAdmin", () => ({
default: vi.fn(() => false),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn(() => ({ data: undefined, isPending: true, isLoading: false })),
}));
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
@ -316,8 +354,10 @@ describe("UsagePage", () => {
userRole: "Internal User",
userRoleLabel: "Internal User",
isViewOnly: false,
loginMethod: null,
premiumUser: true,
disabledPersonalKeyCreation: false,
passwordResetRequired: false,
showSSOBanner: false,
};
@ -375,6 +415,7 @@ describe("UsagePage", () => {
} as any);
mockUserDailyActivityAggregatedCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockEntityUsageMounted.mockClear();
mockTagListCall.mockClear();
mockGatewayDailyActivityCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
@ -809,6 +850,7 @@ describe("UsagePage", () => {
act(() => {
fireEvent.change(usageSelect, { target: { value: usageView } });
});
expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument();
});
@ -1365,4 +1407,164 @@ describe("UsagePage", () => {
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
});
});
describe("URL state", () => {
const renderAt = (searchParams: string) => {
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const view = renderWithProviders(<UsagePage {...defaultProps} />, {
searchParams,
onUrlUpdate,
resetUrlUpdateQueueOnMount: false,
});
const lastQuery = (): string | undefined => onUrlUpdate.mock.lastCall?.[0].queryString;
return { ...view, onUrlUpdate, lastQuery };
};
it("preselects the user filter from ?user= and links back to that user's record", async () => {
renderAt("?user=user-001");
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
"user-001",
);
});
expect(screen.getByRole("link", { name: "View user record" })).toHaveAttribute("href", "/ui/users?user=user-001");
});
it("shows no user record link while the global view is unfiltered", async () => {
renderAt("");
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
null,
);
});
expect(screen.queryByRole("link", { name: "View user record" })).not.toBeInTheDocument();
});
it("opens the linked entity view with its selection and writes new picks back", async () => {
const { lastQuery } = renderAt("?view=team&team=team-1");
const entityUsage = await screen.findByTestId("entity-usage");
expect(entityUsage).toHaveAttribute("data-entity-type", "team");
expect(entityUsage).toHaveAttribute("data-selected", JSON.stringify(["team-1"]));
fireEvent.click(screen.getByRole("button", { name: "pick entities" }));
await waitFor(() => {
expect(lastQuery()).toBe("?view=team&team=picked-1&team=picked-2");
});
});
it("writes the chosen view and drops the previous view's filters", async () => {
const { lastQuery } = renderAt("?user=user-001&tab=keys&range=30d");
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: "customer" } });
await waitFor(() => {
expect(lastQuery()).toBe("?range=30d&view=customer");
});
});
it("restores the inner tab from the URL and writes tab changes", async () => {
const { lastQuery } = renderAt("?tab=models");
expect(await screen.findByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("tab", { name: "Key Activity" }));
await waitFor(() => {
expect(lastQuery()).toBe("?tab=keys");
});
});
it("fetches the custom range in the URL and writes a newly picked one", async () => {
const { lastQuery } = renderAt("?from=2025-03-01T00:00:00.000Z&to=2025-03-05T00:00:00.000Z");
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
new Date("2025-03-01T00:00:00.000Z"),
new Date("2025-03-05T00:00:00.000Z"),
null,
);
});
fireEvent.click(screen.getByTestId("pick-a-different-range"));
await waitFor(() => {
expect(lastQuery()).toBe("?from=2024-01-01T00:00:00.000Z&to=2024-01-08T00:00:00.000Z");
});
});
it("tells a viewer they lack access and sends them to the default view", async () => {
const warning = vi.spyOn(toast, "warning");
mockUseAuthorized.mockReturnValue(nonAdminSession);
const { lastQuery } = renderAt("?view=organization&org=org-1&range=30d");
await waitFor(() => {
expect(lastQuery()).toBe("?range=30d");
});
expect(warning).toHaveBeenCalledWith("You don't have access to that usage view", {
id: "usage-url-access-denied",
});
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
});
it("does not render a disallowed customer view for an internal user", async () => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
renderAt("?view=customer&customer=c-1");
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(mockEntityUsageMounted).not.toHaveBeenCalledWith("customer");
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
});
it("never fetches another user's usage for a non-admin who opens their link", async () => {
const warning = vi.spyOn(toast, "warning");
mockUseAuthorized.mockReturnValue(nonAdminSession);
const { lastQuery } = renderAt("?user=user-001");
await waitFor(() => {
expect(lastQuery()).toBe("");
});
expect(warning).toHaveBeenCalledWith("You don't have access to that usage view", {
id: "usage-url-access-denied",
});
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
"user-001",
);
});
it("copies the current page URL as the share link", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
renderAt("?tab=models");
fireEvent.click(await screen.findByRole("button", { name: "Copy Share Link" }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(window.location.href);
});
});
it("shows the share link on an entity view", async () => {
renderAt("?view=team");
expect(await screen.findByRole("button", { name: "Copy Share Link" })).toBeInTheDocument();
});
});
});

View file

@ -6,7 +6,8 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react";
import { ChevronDown, ChevronRight, Download, Info, Link2, Sparkles, X } from "lucide-react";
import { useQueryStates } from "nuqs";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -23,14 +24,17 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { toast } from "@/lib/toast";
import { hasCapability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
import UserDropdown from "@/components/common_components/UserDropdown";
import EntityUsageExportModal from "@/components/EntityUsageExport";
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
import type { EntityType } from "@/components/EntityUsageExport/types";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import { Team } from "@/components/key_team_helpers/key_list";
import {
@ -65,7 +69,21 @@ import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
import UserRecordLink from "./UserRecordLink";
import {
cleanUsageUrl,
dateRangeFromParams,
dateRangePatch,
entitySelectionPatch,
selectedEntitiesFromParams,
DEFAULT_USAGE_VIEW,
usageTabFromParams,
usageTabPatch,
usageUrlParsers,
usageViewFromParams,
usageViewPatch,
} from "../usageUrlState";
interface UsagePageProps {
teams: Team[];
@ -93,11 +111,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []);
const initialToDate = useMemo(() => new Date(), []);
// Single date state that directly triggers data fetching
const [dateValue, setDateValue] = useState<DateRangePickerValue>({
from: initialFromDate,
to: initialToDate,
});
const [urlParams, setUrlParams] = useQueryStates(usageUrlParsers);
const { range, from, to } = urlParams;
const dateValue = useMemo<DateRangePickerValue>(
() => dateRangeFromParams({ range, from, to }) ?? { from: initialFromDate, to: initialToDate },
[range, from, to, initialFromDate, initialToDate],
);
const [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
// No [] default: an unresolved query must stay undefined so the customer
@ -108,37 +127,42 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const isAdmin = all_admin_roles.includes(userRole || "");
const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || "");
const isOrgAdmin = useIsOrgAdmin();
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
const allowedViews = useMemo(
() => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin),
[userRole, canViewTagUsage, isOrgAdmin],
);
// For admins: null means global view (all users), a string means filter by that user
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
// Org-admin membership is read from the server, so unlike the other usage
// views this one can be revoked while the page is open. Derive the view in
// render rather than storing it, so the fallback lands on the same paint and
// the selector never holds a value it no longer offers.
const usageView: UsageOption =
selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView;
const selectedUsageView = usageViewFromParams(urlParams);
const selectedTab = usageTabFromParams(urlParams);
const usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW;
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const [showTokenBreakdown, setShowTokenBreakdown] = useState(false);
// Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render)
useEffect(() => {
if (!isAdmin && userID) {
setSelectedUserId(userID);
}
}, [isAdmin, userID]);
// For non-admins or "my-usage" view, always pass their own user_id
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user;
const { isLoading: organizationsLoading } = useOrganizations();
const accessSettled = userRole !== null && !organizationsLoading;
useEffect(() => {
if (!accessSettled) return;
const cleanup = cleanUsageUrl(urlParams, { allowedViews, isAdmin });
if (!cleanup) return;
if (cleanup.deniedAccess)
toast.warning("You don't have access to that usage view", { id: "usage-url-access-denied" });
void setUrlParams(cleanup.patch);
}, [accessSettled, urlParams, allowedViews, isAdmin, setUrlParams]);
const entitySelection = (entityType: EntityType) => ({
selectedEntities: selectedEntitiesFromParams(urlParams, entityType),
onSelectedEntitiesChange: (ids: readonly string[]) => void setUrlParams(entitySelectionPatch(entityType, ids)),
});
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
@ -267,13 +291,15 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
}, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
// Instant visual feedback
setIsDateChanging(true);
const handleDateChange = useCallback(
(newValue: DateRangePickerValue, presetShortLabel: string | null) => {
// Instant visual feedback
setIsDateChanging(true);
// Update date immediately for UI responsiveness
setDateValue(newValue);
}, []);
void setUrlParams(dateRangePatch(newValue, presetShortLabel));
},
[setUrlParams],
);
// Derived states from userSpendData
const totalSpend = userSpendData.metadata?.total_spend || 0;
@ -446,12 +472,22 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<div className="flex items-end justify-between gap-6 mb-4 w-full">
<UsageViewSelect
value={usageView}
onChange={(value) => setUsageView(value)}
onChange={(value) => void setUrlParams(usageViewPatch(value))}
userRole={userRole}
canViewTagUsage={canViewTagUsage}
isOrgAdmin={isOrgAdmin}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
<div className="flex items-center gap-2">
<AdvancedDatePicker
value={dateValue}
onValueChange={handleDateChange}
presetShortLabel={urlParams.range}
/>
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
<Link2 />
Copy Share Link
</Button>
</div>
</div>
<PaginationStatusAlerts
isFetchingMore={paginatedResult.isFetchingMore}
@ -466,10 +502,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
{isAdmin && usageView === "global" && (
<div className="mb-4">
<p className="mb-2 text-sm text-foreground">Filter by user</p>
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
<div className="flex items-center gap-3">
<UserDropdown value={urlParams.user} onChange={(user) => void setUrlParams({ user })} />
{urlParams.user && <UserRecordLink userId={urlParams.user} />}
</div>
</div>
)}
<Tabs defaultValue="cost">
<Tabs value={selectedTab} onValueChange={(tab: string) => void setUrlParams(usageTabPatch(tab))}>
<div className="flex justify-between items-center">
<TabsList className="mt-1">
<TabsTrigger value="cost" className="flex-none px-3">
@ -874,10 +913,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
)}
{/* Organization Usage Panel */}
{usageView === "organization" && canViewOrganizationUsage && (
{usageView === "organization" && (
<EntityUsage
accessToken={accessToken}
entityType="organization"
{...entitySelection("organization")}
userID={userID}
userRole={userRole}
isOrgAdmin={isOrgAdmin}
@ -897,6 +937,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<EntityUsage
accessToken={accessToken}
entityType="team"
{...entitySelection("team")}
userID={userID}
userRole={userRole}
entityList={
@ -915,6 +956,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<EntityUsage
accessToken={accessToken}
entityType="customer"
{...entitySelection("customer")}
userID={userID}
userRole={userRole}
entityList={
@ -953,6 +995,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<EntityUsage
accessToken={accessToken}
entityType="tag"
{...entitySelection("tag")}
userID={userID}
userRole={userRole}
entityList={allTags}
@ -965,6 +1008,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<EntityUsage
accessToken={accessToken}
entityType="agent"
{...entitySelection("agent")}
userID={userID}
userRole={userRole}
entityList={
@ -979,6 +1023,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<EntityUsage
accessToken={accessToken}
entityType="user"
{...entitySelection("user")}
userID={userID}
userRole={userRole}
entityList={null}

View file

@ -104,6 +104,27 @@ const OPTIONS: OptionConfig[] = [
adminOnly: true,
},
];
const visibleOptions = (userRole: string | null, canViewTagUsage: boolean, isOrgAdmin: boolean): OptionConfig[] => {
const isAdmin = all_admin_roles.includes(userRole ?? "");
return OPTIONS.filter((option) => {
if (option.capability) {
return hasCapability(userRole, option.capability, isOrgAdmin);
}
if (option.value === "tag" && canViewTagUsage) {
return true;
}
return !(option.adminOnly && !isAdmin);
});
};
export const USAGE_OPTIONS: readonly UsageOption[] = OPTIONS.map((option) => option.value);
export const allowedUsageOptions = (
userRole: string | null,
canViewTagUsage: boolean,
isOrgAdmin: boolean,
): readonly UsageOption[] => visibleOptions(userRole, canViewTagUsage, isOrgAdmin).map((option) => option.value);
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
value,
onChange,
@ -116,18 +137,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
}) => {
const isAdmin = all_admin_roles.includes(userRole ?? "");
const getFilteredOptions = () => {
return OPTIONS.filter((option) => {
if (option.capability) {
return hasCapability(userRole, option.capability, isOrgAdmin);
}
if (option.value === "tag" && canViewTagUsage) {
return true;
}
if (option.adminOnly && !isAdmin) {
return false;
}
return true;
}).map((option) => {
return visibleOptions(userRole, canViewTagUsage, isOrgAdmin).map((option) => {
let label = option.label;
let desc = option.description;
if (option.showForAdmin && option.showForNonAdmin) {

View file

@ -0,0 +1,16 @@
import { SquareArrowOutUpRight } from "lucide-react";
import Link from "next/link";
import { userRecordHref } from "../usageUrlState";
export default function UserRecordLink({ userId }: { userId: string }) {
return (
<Link
href={userRecordHref(userId)}
className="inline-flex shrink-0 items-center gap-1 text-sm text-primary underline underline-offset-2"
>
View user record
<SquareArrowOutUpRight className="size-3.5" />
</Link>
);
}

View file

@ -0,0 +1,192 @@
import moment from "moment";
import { createLoader, createSerializer } from "nuqs";
import { describe, expect, it } from "vitest";
import type { UsageOption } from "./components/UsageViewSelect/UsageViewSelect";
import { USAGE_OPTIONS } from "./components/UsageViewSelect/UsageViewSelect";
import {
cleanUsageUrl,
dateRangeFromParams,
dateRangePatch,
entitySelectionPatch,
selectedEntitiesFromParams,
usageHrefForUser,
usageTabFromParams,
usageTabPatch,
usageUrlParsers,
usageViewFromParams,
usageViewPatch,
type UsageUrlParams,
type UsageUrlPatch,
} from "./usageUrlState";
const load = createLoader(usageUrlParsers);
const serialize = createSerializer(usageUrlParsers);
const applyPatch = (query: string, patch: UsageUrlPatch): string => serialize(query, patch);
const adminAccess = { allowedViews: USAGE_OPTIONS, isAdmin: true };
const internalUserAccess = {
allowedViews: ["global", "tag"] as readonly UsageOption[],
isAdmin: false,
};
const clean = (query: string, access = adminAccess): { query: string; deniedAccess: boolean } | null => {
const cleanup = cleanUsageUrl(load(query), access);
return cleanup && { query: applyPatch(query, cleanup.patch), deniedAccess: cleanup.deniedAccess };
};
describe("usage view and tab", () => {
it("reads a known view and tab from the URL", () => {
const params = load("?view=team&tab=models");
expect(usageViewFromParams(params)).toBe("team");
expect(usageTabFromParams(params)).toBe("models");
});
it("falls back to the global view and cost tab when the URL names neither", () => {
const params = load("?view=bogus&tab=bogus");
expect(usageViewFromParams(params)).toBe("global");
expect(usageTabFromParams(params)).toBe("cost");
});
it("switching views drops the previous view's filters and tab but keeps the date range", () => {
expect(applyPatch("?user=u1&tab=keys&range=30d", usageViewPatch("team"))).toBe("?range=30d&view=team");
expect(applyPatch("?view=team&team=t1", usageViewPatch("global"))).toBe("");
});
it("keeps the default tab out of the URL", () => {
expect(applyPatch("?tab=models", usageTabPatch("cost"))).toBe("");
expect(applyPatch("", usageTabPatch("mcp"))).toBe("?tab=mcp");
});
});
describe("entity selection", () => {
it("stores teams as repeated keys so ids with commas survive", () => {
const query = applyPatch("?view=tag", entitySelectionPatch("tag", ["Credential: a,b", "prod"]));
expect(query).toBe("?view=tag&tag=Credential:+a,b&tag=prod");
expect(selectedEntitiesFromParams(load(query), "tag")).toEqual(["Credential: a,b", "prod"]);
});
it("maps the organization view to the org key", () => {
const query = applyPatch("?view=organization", entitySelectionPatch("organization", ["o1"]));
expect(query).toBe("?view=organization&org=o1");
expect(selectedEntitiesFromParams(load(query), "organization")).toEqual(["o1"]);
});
it("stores the user view's selection in the single user key", () => {
const query = applyPatch("?view=user", entitySelectionPatch("user", ["u1"]));
expect(query).toBe("?view=user&user=u1");
expect(selectedEntitiesFromParams(load(query), "user")).toEqual(["u1"]);
});
it("removes the key once the selection is cleared", () => {
expect(applyPatch("?view=team&team=t1", entitySelectionPatch("team", []))).toBe("?view=team");
expect(applyPatch("?view=user&user=u1", entitySelectionPatch("user", []))).toBe("?view=user");
});
});
describe("date range", () => {
it("stores a preset as a rolling range key", () => {
const lastThirtyDays = { from: moment().subtract(30, "days").startOf("day").toDate(), to: new Date() };
expect(applyPatch("?from=a&to=b", dateRangePatch(lastThirtyDays, "30d"))).toBe("?range=30d");
});
it("stores a custom range matching today as explicit dates", () => {
const today = { from: moment().startOf("day").toDate(), to: moment().endOf("day").toDate() };
expect(dateRangePatch(today, null)).toEqual({
range: null,
from: today.from.toISOString(),
to: today.to.toISOString(),
});
});
it("stores an unknown preset label as explicit dates", () => {
const today = { from: moment().startOf("day").toDate(), to: moment().endOf("day").toDate() };
expect(dateRangePatch(today, "not-a-preset")).toEqual({
range: null,
from: today.from.toISOString(),
to: today.to.toISOString(),
});
});
it("re-resolves a rolling range against the current day", () => {
const range = dateRangeFromParams(load("?range=7d"));
expect(moment(range?.from).isSame(moment().subtract(7, "days"), "day")).toBe(true);
expect(moment(range?.to).isSame(moment(), "day")).toBe(true);
});
it("round-trips a custom range as the same UTC instants", () => {
const custom = { from: new Date("2026-01-05T08:00:00.000Z"), to: new Date("2026-01-09T20:30:00.000Z") };
const query = applyPatch("?range=7d", dateRangePatch(custom, null));
expect(query).toBe("?from=2026-01-05T08:00:00.000Z&to=2026-01-09T20:30:00.000Z");
expect(dateRangeFromParams(load(query))).toEqual(custom);
});
it.each([
["an unknown preset", "?range=90d"],
["a malformed date", "?from=yesterday&to=2026-01-09T00:00:00.000Z"],
["a range that ends before it starts", "?from=2026-01-09T00:00:00.000Z&to=2026-01-05T00:00:00.000Z"],
["only one end of a custom range", "?from=2026-01-05T00:00:00.000Z"],
])("ignores %s", (_label, query) => {
expect(dateRangeFromParams(load(query))).toBeUndefined();
});
});
describe("cleanUsageUrl", () => {
it("leaves a valid URL alone", () => {
expect(clean("?view=team&team=t1&team=t2&range=30d")).toBeNull();
expect(clean("?user=u1&tab=keys&from=2026-01-05T00:00:00.000Z&to=2026-01-09T00:00:00.000Z")).toBeNull();
expect(clean("?view=tag&tag=prod", internalUserAccess)).toBeNull();
});
it("silently drops params that name nothing real", () => {
expect(clean("?view=bogus&tab=bogus&range=90d")).toEqual({ query: "", deniedAccess: false });
expect(clean("?from=yesterday&to=today&team=t1")).toEqual({ query: "", deniedAccess: false });
});
it("drops a tab on a view that has no tabs", () => {
expect(clean("?view=team&tab=models")).toEqual({ query: "?view=team", deniedAccess: false });
});
it("drops filters that belong to a different view", () => {
expect(clean("?view=team&team=t1&org=o1&user=u1")).toEqual({ query: "?view=team&team=t1", deniedAccess: false });
expect(clean("?view=my-usage&user=u1")).toEqual({ query: "?view=my-usage", deniedAccess: false });
});
it("prefers the preset when a URL carries both a preset and a custom range", () => {
expect(clean("?range=7d&from=2026-01-05T00:00:00.000Z&to=2026-01-09T00:00:00.000Z")).toEqual({
query: "?range=7d",
deniedAccess: false,
});
});
it("sends a viewer to the default view, keeping the dates, when the linked view is not theirs", () => {
expect(clean("?view=team&team=t1&range=30d", internalUserAccess)).toEqual({
query: "?range=30d",
deniedAccess: true,
});
});
it("denies another user's usage to a non-admin", () => {
expect(clean("?user=someone-else&tab=models", internalUserAccess)).toEqual({ query: "", deniedAccess: true });
});
});
describe("cross-page links", () => {
it("encodes the user id so it survives the round trip into the usage URL", () => {
const href = usageHrefForUser("alice+ops@example.com");
const params: UsageUrlParams = load(href.slice(href.indexOf("?")));
expect(params.user).toBe("alice+ops@example.com");
});
});

View file

@ -0,0 +1,178 @@
import { parseAsNativeArrayOf, parseAsString, type inferParserType } from "nuqs";
import type { EntityType } from "@/components/EntityUsageExport/types";
import { relativeTimeOptions } from "@/components/shared/advanced_date_picker";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { uiHref } from "@/utils/uiHref";
import { USAGE_OPTIONS, type UsageOption } from "./components/UsageViewSelect/UsageViewSelect";
export const DEFAULT_USAGE_VIEW: UsageOption = "global";
export const USAGE_TABS = ["cost", "models", "keys", "mcp", "endpoints"] as const;
export type UsageTab = (typeof USAGE_TABS)[number];
export const DEFAULT_USAGE_TAB: UsageTab = "cost";
const entityIds = parseAsNativeArrayOf(parseAsString);
export const usageUrlParsers = {
view: parseAsString,
tab: parseAsString,
range: parseAsString,
from: parseAsString,
to: parseAsString,
user: parseAsString,
team: entityIds,
org: entityIds,
customer: entityIds,
tag: entityIds,
agent: entityIds,
};
export type UsageUrlParams = inferParserType<typeof usageUrlParsers>;
export type UsageUrlPatch = { readonly [K in keyof UsageUrlParams]?: UsageUrlParams[K] | null };
type MultiEntityType = Exclude<EntityType, "user">;
type EntityKey = "user" | (typeof MULTI_ENTITY_KEYS)[MultiEntityType];
const MULTI_ENTITY_KEYS = {
organization: "org",
team: "team",
customer: "customer",
tag: "tag",
agent: "agent",
} as const satisfies Record<MultiEntityType, keyof UsageUrlParams>;
const VIEW_ENTITY_KEYS: Readonly<Record<UsageOption, readonly EntityKey[]>> = {
global: ["user"],
"my-usage": [],
organization: ["org"],
team: ["team"],
customer: ["customer"],
tag: ["tag"],
agent: ["agent"],
user: ["user"],
"user-agent-activity": [],
};
const TABBED_VIEWS: readonly UsageOption[] = ["global", "my-usage"];
const RESET_TO_DEFAULT_VIEW: UsageUrlPatch = {
view: null,
tab: null,
user: null,
team: null,
org: null,
customer: null,
tag: null,
agent: null,
};
const isUsageOption = (value: string | null): value is UsageOption => USAGE_OPTIONS.some((option) => option === value);
const isUsageTab = (value: string | null): value is UsageTab => USAGE_TABS.some((tab) => tab === value);
const hasEntityValue = (params: UsageUrlParams, key: EntityKey): boolean =>
key === "user" ? params.user !== null : params[key].length > 0;
export const usageViewFromParams = (params: UsageUrlParams): UsageOption =>
isUsageOption(params.view) ? params.view : DEFAULT_USAGE_VIEW;
export const usageTabFromParams = (params: UsageUrlParams): UsageTab =>
isUsageTab(params.tab) ? params.tab : DEFAULT_USAGE_TAB;
export const usageViewPatch = (view: UsageOption): UsageUrlPatch => ({
...RESET_TO_DEFAULT_VIEW,
view: view === DEFAULT_USAGE_VIEW ? null : view,
});
export const usageTabPatch = (tab: string): UsageUrlPatch => ({
tab: isUsageTab(tab) && tab !== DEFAULT_USAGE_TAB ? tab : null,
});
export const selectedEntitiesFromParams = (params: UsageUrlParams, entityType: EntityType): readonly string[] => {
if (entityType === "user") return params.user === null ? [] : [params.user];
return params[MULTI_ENTITY_KEYS[entityType]];
};
export const entitySelectionPatch = (entityType: EntityType, ids: readonly string[]): UsageUrlPatch => {
if (entityType === "user") return { user: ids[0] ?? null };
return { [MULTI_ENTITY_KEYS[entityType]]: ids.length > 0 ? [...ids] : null };
};
const parseIsoDate = (value: string): Date | undefined => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
const customRangeFromParams = (from: string, to: string): DateRangePickerValue | undefined => {
const fromDate = parseIsoDate(from);
const toDate = parseIsoDate(to);
if (!fromDate || !toDate || fromDate > toDate) return undefined;
return { from: fromDate, to: toDate };
};
type DateParams = Pick<UsageUrlParams, "range" | "from" | "to">;
export const dateRangeFromParams = (params: DateParams): DateRangePickerValue | undefined => {
if (params.range !== null) {
return relativeTimeOptions.find((option) => option.shortLabel === params.range)?.getValue();
}
if (params.from === null || params.to === null) return undefined;
return customRangeFromParams(params.from, params.to);
};
export const dateRangePatch = (value: DateRangePickerValue, presetShortLabel: string | null): UsageUrlPatch => {
if (presetShortLabel !== null && relativeTimeOptions.some((option) => option.shortLabel === presetShortLabel)) {
return { range: presetShortLabel, from: null, to: null };
}
if (!value.from || !value.to) return { range: null, from: null, to: null };
return { range: null, from: value.from.toISOString(), to: value.to.toISOString() };
};
const invalidDatePatch = (params: DateParams): UsageUrlPatch => {
const hasCustomRange = params.from !== null || params.to !== null;
if (params.range !== null) {
const rangeIsPreset = relativeTimeOptions.some((option) => option.shortLabel === params.range);
return {
...(rangeIsPreset ? {} : { range: null }),
...(hasCustomRange ? { from: null, to: null } : {}),
};
}
if (!hasCustomRange || dateRangeFromParams(params)) return {};
return { from: null, to: null };
};
export interface UsageUrlAccess {
readonly allowedViews: readonly UsageOption[];
readonly isAdmin: boolean;
}
export interface UsageUrlCleanup {
readonly patch: UsageUrlPatch;
readonly deniedAccess: boolean;
}
export const cleanUsageUrl = (params: UsageUrlParams, access: UsageUrlAccess): UsageUrlCleanup | null => {
const view = usageViewFromParams(params);
const deniedView = !access.allowedViews.includes(view);
const deniedUserFilter = view === "global" && params.user !== null && !access.isAdmin;
if (deniedView || deniedUserFilter) {
return { patch: { ...RESET_TO_DEFAULT_VIEW, ...invalidDatePatch(params) }, deniedAccess: true };
}
const viewKeys = VIEW_ENTITY_KEYS[view];
const strayEntityKeys = (["user", "team", "org", "customer", "tag", "agent"] as const).filter(
(key) => !viewKeys.includes(key) && hasEntityValue(params, key),
);
const invalidTab = params.tab !== null && (!TABBED_VIEWS.includes(view) || !isUsageTab(params.tab));
const patch: UsageUrlPatch = {
...(params.view !== null && !isUsageOption(params.view) ? { view: null } : {}),
...(invalidTab ? { tab: null } : {}),
...Object.fromEntries(strayEntityKeys.map((key) => [key, null])),
...invalidDatePatch(params),
};
return Object.keys(patch).length > 0 ? { patch, deniedAccess: false } : null;
};
export const usageHrefForUser = (userId: string): string => uiHref(`usage?${new URLSearchParams({ user: userId })}`);
export const userRecordHref = (userId: string): string => uiHref(`users?${new URLSearchParams({ user: userId })}`);

View file

@ -17,7 +17,7 @@ import {
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
import { all_admin_roles, isAdminRole, isProxyAdminRole } from "@/utils/roles";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -298,6 +298,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({
isLoading={userListQuery.isLoading || userListQuery.isPlaceholderData}
possibleUIRoles={possibleUIRoles}
teams={teams}
canViewUserUsage={all_admin_roles.includes(userRole ?? "")}
sorting={sorting}
onSortingChange={handleSortingChange}
pagination={pagination}

View file

@ -36,6 +36,7 @@ interface HarnessOverrides {
rowCount?: number;
isLoading?: boolean;
selectionEnabled?: boolean;
canViewUserUsage?: boolean;
onUserClick?: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser?: (user: UserInfo) => void;
onResetPassword?: (userId: string) => void;
@ -51,6 +52,7 @@ function Harness({
rowCount = 1,
isLoading = false,
selectionEnabled = false,
canViewUserUsage = true,
onUserClick = vi.fn(),
onDeleteUser = vi.fn(),
onResetPassword = vi.fn(),
@ -74,6 +76,7 @@ function Harness({
isLoading={isLoading}
possibleUIRoles={possibleUIRoles}
teams={[]}
canViewUserUsage={canViewUserUsage}
sorting={sorting}
onSortingChange={(updater) => {
setSorting(updater);
@ -178,6 +181,27 @@ describe("UsersTable", () => {
expect(onUserClick).toHaveBeenCalledWith("user-1", true);
});
it("links the row menu to that user's usage when allowed", async () => {
const user = userEvent.setup();
render(<Harness />);
await openRowMenu(user, "user-1");
expect(await screen.findByRole("menuitem", { name: "View usage" })).toHaveAttribute(
"href",
"/ui/usage?user=user-1",
);
});
it("hides the usage link when the viewer cannot view usage", async () => {
const user = userEvent.setup();
render(<Harness canViewUserUsage={false} />);
await openRowMenu(user, "user-1");
expect(screen.queryByRole("menuitem", { name: "View usage" })).not.toBeInTheDocument();
});
it("delegates delete and reset-password from the row menu", async () => {
const user = userEvent.setup();
const onDeleteUser = vi.fn();

View file

@ -33,6 +33,7 @@ interface UsersTableProps {
isLoading: boolean;
possibleUIRoles: Record<string, Record<string, string>> | null;
teams: UsersTableTeamOption[] | null;
canViewUserUsage: boolean;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
pagination: PaginationState;
@ -74,6 +75,7 @@ export function UsersTable({
isLoading,
possibleUIRoles,
teams,
canViewUserUsage,
sorting,
onSortingChange,
pagination,
@ -95,12 +97,13 @@ export function UsersTable({
const columnDeps = {
possibleUIRoles,
includeSelection: selectionEnabled,
canViewUserUsage,
onUserClick,
onDeleteUser,
onResetPassword,
};
return getUsersTableColumns(columnDeps);
}, [possibleUIRoles, selectionEnabled, onUserClick, onDeleteUser, onResetPassword]);
}, [possibleUIRoles, selectionEnabled, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword]);
const roleOptions = useMemo(
() =>

View file

@ -1,7 +1,8 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { ChartColumn, Copy, Info, KeyRound, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import Link from "next/link";
import { UserInfo } from "@/components/networking";
import { createSelectionColumn, DataTableSortHeader } from "@/components/shared/DataTable";
@ -16,6 +17,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { usageHrefForUser } from "@/app/(dashboard)/usage/_components/usageUrlState";
import { copyToClipboard } from "@/utils/dataUtils";
const SSO_ID_HINT =
@ -29,12 +31,13 @@ function isScimInactive(user: UserInfo): boolean {
interface UserRowActionsProps {
user: UserInfo;
canViewUserUsage: boolean;
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
}
function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
function UserRowActions({ user, canViewUserUsage, onUserClick, onDeleteUser, onResetPassword }: UserRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
@ -60,6 +63,15 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us
<Copy />
Copy user ID
</DropdownMenuItem>
{canViewUserUsage && (
<DropdownMenuItem
render={<Link href={usageHrefForUser(user.user_id)} />}
data-testid="user-action-view-usage"
>
<ChartColumn />
View usage
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteUser(user)} data-testid="user-action-delete">
<Trash2 />
@ -73,6 +85,7 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us
export interface UsersTableColumnsDeps {
possibleUIRoles: Record<string, Record<string, string>> | null;
includeSelection: boolean;
canViewUserUsage: boolean;
onUserClick: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser: (user: UserInfo) => void;
onResetPassword: (userId: string) => void;
@ -81,6 +94,7 @@ export interface UsersTableColumnsDeps {
export const getUsersTableColumns = ({
possibleUIRoles,
includeSelection,
canViewUserUsage,
onUserClick,
onDeleteUser,
onResetPassword,
@ -253,6 +267,7 @@ export const getUsersTableColumns = ({
<div className="flex justify-end">
<UserRowActions
user={row.original}
canViewUserUsage={canViewUserUsage}
onUserClick={onUserClick}
onDeleteUser={onDeleteUser}
onResetPassword={onResetPassword}

View file

@ -3,7 +3,7 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types
interface ExportSummaryProps {
dateRange: DateRangePickerValue;
selectedFilters: string[];
selectedFilters: readonly string[];
}
const ExportSummary: React.FC<ExportSummaryProps> = ({ dateRange, selectedFilters }) => {

View file

@ -27,7 +27,7 @@ interface UsageExportHeaderProps {
showFilters?: boolean;
filterLabel?: string;
filterPlaceholder?: string;
selectedFilters?: string[];
selectedFilters?: readonly string[];
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
filterSlot?: React.ReactNode;
@ -83,7 +83,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
multiple
disabled={isFilterDisabled}
items={optionValues}
value={selectedFilters}
value={[...selectedFilters]}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="w-full">

View file

@ -23,7 +23,7 @@ export interface EntityUsageExportModalProps {
entityType: EntityType;
spendData: EntitySpendData;
dateRange: DateRangePickerValue;
selectedFilters: string[];
selectedFilters: readonly string[];
customTitle?: string;
teams?: Team[];
}

View file

@ -420,7 +420,7 @@ export const generateExportData = (
export const generateMetadata = (
entityType: EntityType,
dateRange: DateRangePickerValue,
selectedFilters: string[],
selectedFilters: readonly string[],
exportScope: ExportScope,
spendData: EntitySpendData,
): ExportMetadata => {
@ -443,7 +443,7 @@ export const generateMetadata = (
from: dateRange.from?.toISOString(),
to: dateRange.to?.toISOString(),
},
filters_applied: selectedFilters.length > 0 ? selectedFilters : "None",
filters_applied: selectedFilters.length > 0 ? [...selectedFilters] : "None",
export_scope: exportScope,
summary,
};
@ -476,7 +476,7 @@ export const handleExportJSON = (
entityLabel: string,
entityType: EntityType,
dateRange: DateRangePickerValue,
selectedFilters: string[],
selectedFilters: readonly string[],
teamAliasMap: Record<string, string> = {},
): void => {
const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap);

View file

@ -4,7 +4,7 @@ import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
interface TeamMultiSelectProps {
value?: string[];
value?: readonly string[];
onChange?: (value: string[]) => void;
disabled?: boolean;
organizationId?: string | null;

View file

@ -1474,7 +1474,7 @@ export const teamSpendByUserCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
teamIds: string[],
teamIds: readonly string[],
): Promise<TeamUserSpendResponse> =>
apiClient.get<TeamUserSpendResponse>(`/team/spend/by_user`, {
accessToken,

View file

@ -22,7 +22,7 @@ import { usePaginatedCombobox } from "./usePaginatedCombobox";
interface PaginatedMultiSelectProps {
options: SearchSelectOption[];
value?: string[];
value?: readonly string[];
onValueChange: (value: string[]) => void;
onSearchChange: (query: string) => void;
onLoadMore: () => void;

View file

@ -1,5 +1,6 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import AdvancedDatePicker from "./advanced_date_picker";
@ -166,6 +167,82 @@ describe("AdvancedDatePicker", () => {
});
});
it("passes the clicked preset label when applying", async () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
openDropdown(container);
fireEvent.click(screen.getByText("Today"));
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "today");
await waitFor(() => {
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "today");
});
});
it("passes a null preset label when typed dates match today", async () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
const today = moment().format("YYYY-MM-DD");
openDropdown(container);
fireEvent.change(screen.getByDisplayValue("2025-01-01"), { target: { value: today } });
fireEvent.change(screen.getByDisplayValue("2025-01-31"), { target: { value: today } });
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), null);
await waitFor(() => {
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), null);
});
});
it("resets the preset label when the parent switches to a custom range", async () => {
const { container, rerender } = render(
<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />,
);
openDropdown(container);
fireEvent.click(screen.getByText("Today"));
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "today");
await waitFor(() => {
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "today");
});
mockOnValueChange.mockClear();
const customValue = {
from: new Date("2025-02-01T00:00:00.000Z"),
to: new Date("2025-02-10T23:59:59.999Z"),
};
rerender(<AdvancedDatePicker value={customValue} onValueChange={mockOnValueChange} presetShortLabel={null} />);
openDropdown(container);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), null);
await waitFor(() => {
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), null);
});
});
it("preserves the parent preset label when applying an unchanged range", async () => {
const value = {
from: moment().subtract(30, "days").startOf("day").toDate(),
to: moment().endOf("day").toDate(),
};
const { container } = render(
<AdvancedDatePicker value={value} onValueChange={mockOnValueChange} presetShortLabel="30d" />,
);
openDropdown(container);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, expect.anything(), "30d");
await waitFor(() => {
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, expect.anything(), "30d");
});
});
it("should select relative time option", () => {
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);

View file

@ -7,20 +7,21 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
interface AdvancedDatePickerProps {
value: DateRangePickerValue;
onValueChange: (value: DateRangePickerValue) => void;
onValueChange: (value: DateRangePickerValue, presetShortLabel: string | null) => void;
presetShortLabel?: string | null;
label?: string;
className?: string;
showTimeRange?: boolean;
align?: "left" | "right";
}
interface RelativeTimeOption {
export interface RelativeTimeOption {
label: string;
shortLabel: string;
getValue: () => { from: Date; to: Date };
}
const relativeTimeOptions: RelativeTimeOption[] = [
export const relativeTimeOptions: readonly RelativeTimeOption[] = [
{
label: "Today",
shortLabel: "today",
@ -63,12 +64,22 @@ const relativeTimeOptions: RelativeTimeOption[] = [
},
];
export function matchRelativeTimeOption(value: DateRangePickerValue): RelativeTimeOption | undefined {
const { from, to } = value;
if (!from || !to) return undefined;
return relativeTimeOptions.find((option) => {
const optionRange = option.getValue();
return moment(from).isSame(optionRange.from, "day") && moment(to).isSame(optionRange.to, "day");
});
}
/**
* Advanced Date Range Picker with dropdown, relative times, and custom inputs
*/
const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
value,
onValueChange,
presetShortLabel = null,
label = "Select Time Range",
className,
showTimeRange = true,
@ -77,6 +88,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
const [isOpen, setIsOpen] = useState(false);
const [tempValue, setTempValue] = useState<DateRangePickerValue>(value);
const [selectedOption, setSelectedOption] = useState<string | null>(null);
const [pickedPreset, setPickedPreset] = useState<string | null>(null);
// Custom date inputs only - removed time inputs
const [startDate, setStartDate] = useState("");
@ -84,24 +96,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
const dropdownRef = useRef<HTMLDivElement>(null);
// Function to check if current value matches a relative time option
const getMatchingOption = useCallback((currentValue: DateRangePickerValue): string | null => {
if (!currentValue.from || !currentValue.to) return null;
for (const option of relativeTimeOptions) {
const optionRange = option.getValue();
// Compare dates with some tolerance (to account for time differences)
const fromMatches = moment(currentValue.from).isSame(moment(optionRange.from), "day");
const toMatches = moment(currentValue.to).isSame(moment(optionRange.to), "day");
if (fromMatches && toMatches) {
return option.shortLabel;
}
}
return null;
}, []);
const getMatchingOption = useCallback(
(currentValue: DateRangePickerValue): string | null => matchRelativeTimeOption(currentValue)?.shortLabel ?? null,
[],
);
// Update selected option when value changes
useEffect(() => {
@ -140,7 +138,8 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
setEndDate(moment(value.to).format("YYYY-MM-DD"));
}
setTempValue(value);
}, [value]);
setPickedPreset(presetShortLabel ?? null);
}, [value, presetShortLabel]);
// Close dropdown when clicking outside
useEffect(() => {
@ -206,6 +205,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
// Update local state to reflect the selection (don't apply immediately)
setTempValue(newValue);
setSelectedOption(option.shortLabel);
setPickedPreset(option.shortLabel);
// Update the form inputs to reflect the selection
setStartDate(moment(from).format("YYYY-MM-DD"));
@ -243,13 +243,13 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
const handleApply = () => {
if (tempValue.from && tempValue.to && validation.isValid) {
// First call with immediate value for UI responsiveness
onValueChange(tempValue);
onValueChange(tempValue, pickedPreset);
// Then do the same background adjustment logic as the original component
requestIdleCallback(
() => {
const adjustedValue = adjustDateRange(tempValue);
onValueChange(adjustedValue);
onValueChange(adjustedValue, pickedPreset);
},
{ timeout: 100 },
);
@ -273,6 +273,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
// Reset selected option
const matchingOption = getMatchingOption(value);
setSelectedOption(matchingOption);
setPickedPreset(presetShortLabel ?? null);
setIsOpen(false);
};
@ -367,7 +368,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
onChange={(e) => {
setPickedPreset(null);
setStartDate(e.target.value);
}}
className={`w-65 px-3 py-2 text-sm border rounded-md cursor-pointer hover:border-ring focus:border-info focus:ring-1 focus:ring-ring ${
!validation.isValid
? "border-destructive/30 focus:border-destructive focus:ring-red-200"
@ -382,7 +386,10 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
onChange={(e) => {
setPickedPreset(null);
setEndDate(e.target.value);
}}
className={`w-65 px-3 py-2 text-sm border rounded-md cursor-pointer hover:border-ring focus:border-info focus:ring-1 focus:ring-ring ${
!validation.isValid
? "border-destructive/30 focus:border-destructive focus:ring-red-200"

View file

@ -37,6 +37,11 @@ describe("toast", () => {
expect(sonner.success).toHaveBeenCalledWith("saved", { description: "Model x", duration: 1500 });
});
it("passes an id through so repeated toasts collapse into one", () => {
toast.warning("no access", { id: "denied" });
expect(sonner.warning).toHaveBeenCalledWith("no access", expect.objectContaining({ id: "denied" }));
});
it("dismiss clears every toast", () => {
toast.dismiss();
expect(sonner.dismiss).toHaveBeenCalledWith();

View file

@ -7,6 +7,7 @@ export type ToastKind = "success" | "info" | "warning" | "error";
export type ToastOptions = {
readonly description?: ReactNode;
readonly durationMs?: number;
readonly id?: string;
};
type ErrorFacts = {
@ -127,6 +128,7 @@ const titleFor = ({ status, proxyType }: ErrorFacts): string => {
const show = (kind: ToastKind, message: ReactNode, options?: ToastOptions): void => {
sonner[kind](message, {
id: options?.id,
description: options?.description,
duration: options?.durationMs ?? DEFAULT_DURATION_MS[kind],
});

View file

@ -25,12 +25,18 @@ export const testQueryClient = new QueryClient({
interface ProviderOptions {
searchParams?: string | Record<string, string> | URLSearchParams;
onUrlUpdate?: OnUrlUpdateFunction;
resetUrlUpdateQueueOnMount?: boolean;
}
export const renderWithProviders = (ui: React.ReactElement, options?: RenderOptions & ProviderOptions) => {
const { searchParams, onUrlUpdate, ...renderOptions } = options ?? {};
const { searchParams, onUrlUpdate, resetUrlUpdateQueueOnMount, ...renderOptions } = options ?? {};
const Providers: React.FC<PropsWithChildren> = ({ children }) => (
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory>
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onUrlUpdate}
resetUrlUpdateQueueOnMount={resetUrlUpdateQueueOnMount}
hasMemory
>
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
);