mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge b7a8b47497 into f285229b51
This commit is contained in:
commit
64fcc8b2cf
26 changed files with 996 additions and 155 deletions
|
|
@ -1140,7 +1140,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface TeamUserSpendCardProps {
|
|||
accessToken: string | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
teamIds: string[];
|
||||
teamIds: readonly string[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TeamUserSpendRow>[] = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 })}`);
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export interface EntityUsageExportModalProps {
|
|||
entityType: EntityType;
|
||||
spendData: EntitySpendData;
|
||||
dateRange: DateRangePickerValue;
|
||||
selectedFilters: string[];
|
||||
selectedFilters: readonly string[];
|
||||
customTitle?: string;
|
||||
teams?: Team[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue