mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(ui): type-check usage entity filters and address review findings
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
a46433f3cb
commit
d178e58c99
21 changed files with 190 additions and 79 deletions
|
|
@ -101,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", () => ({
|
||||
|
|
@ -800,7 +800,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
it("defaults Model Activity to public model names and toggles to litellm models", async () => {
|
||||
const { container } = render(<StatefulEntityUsage {...defaultProps} />);
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface TeamUserSpendCardProps {
|
|||
accessToken: string | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
teamIds: string[];
|
||||
teamIds: readonly string[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TeamUserSpendRow>[] = [
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ beforeAll(() => {
|
|||
vi.mock("@/components/networking", () => ({
|
||||
userDailyActivityCall: vi.fn(),
|
||||
userDailyActivityAggregatedCall: vi.fn(),
|
||||
teamDailyActivityAggregatedCall: vi.fn(),
|
||||
gatewayDailyActivityCall: vi.fn(),
|
||||
tagListCall: vi.fn(),
|
||||
}));
|
||||
|
|
@ -49,8 +50,10 @@ vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
|||
default: () => <div>Top Keys</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./EntityUsage/EntityUsage", () => ({
|
||||
default: ({
|
||||
vi.mock("./EntityUsage/EntityUsage", async () => {
|
||||
const React = await import("react");
|
||||
const { teamDailyActivityAggregatedCall } = await import("@/components/networking");
|
||||
const EntityUsage = ({
|
||||
entityType,
|
||||
entityList,
|
||||
selectedEntities,
|
||||
|
|
@ -60,21 +63,28 @@ vi.mock("./EntityUsage/EntityUsage", () => ({
|
|||
entityList: unknown;
|
||||
selectedEntities: readonly string[];
|
||||
onSelectedEntitiesChange: (ids: readonly string[]) => void;
|
||||
}) => (
|
||||
<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>
|
||||
),
|
||||
EntityList: [],
|
||||
}));
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (entityType === "team") {
|
||||
void teamDailyActivityAggregatedCall("test-token", new Date(), new Date(), ["team-1"]);
|
||||
}
|
||||
}, [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>,
|
||||
|
|
@ -116,7 +126,11 @@ vi.mock("@/components/shared/advanced_date_picker", async (importOriginal) => {
|
|||
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" },
|
||||
|
|
@ -126,7 +140,7 @@ vi.mock("@/components/shared/advanced_date_picker", async (importOriginal) => {
|
|||
{
|
||||
"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",
|
||||
),
|
||||
|
|
@ -185,6 +199,7 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
|
|||
describe("UsagePage", () => {
|
||||
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
|
||||
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
|
||||
const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall);
|
||||
const mockTagListCall = vi.mocked(networking.tagListCall);
|
||||
const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall);
|
||||
const mockUseCustomers = vi.mocked(useCustomers);
|
||||
|
|
@ -401,6 +416,7 @@ describe("UsagePage", () => {
|
|||
} as any);
|
||||
mockUserDailyActivityAggregatedCall.mockClear();
|
||||
mockUserDailyActivityCall.mockClear();
|
||||
mockTeamDailyActivityAggregatedCall.mockClear();
|
||||
mockTagListCall.mockClear();
|
||||
mockGatewayDailyActivityCall.mockClear();
|
||||
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
|
||||
|
|
@ -826,15 +842,10 @@ describe("UsagePage", () => {
|
|||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const usageSelect = screen.getByTestId("usage-view-select");
|
||||
act(() => {
|
||||
fireEvent.change(usageSelect, { target: { value: "team" } });
|
||||
fireEvent.change(screen.getByTestId("usage-view-select"), { target: { value: usageView } });
|
||||
});
|
||||
expect(screen.getAllByText("Entity Usage").length).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(usageSelect, { target: { value: usageView } });
|
||||
});
|
||||
expect(screen.queryByText("Entity Usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -1501,6 +1512,18 @@ describe("UsagePage", () => {
|
|||
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render or fetch a disallowed team view for an internal user", async () => {
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
renderAt("?view=team&team=team-1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId("entity-usage")).not.toBeInTheDocument();
|
||||
expect(mockTeamDailyActivityAggregatedCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -1532,5 +1555,11 @@ describe("UsagePage", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ 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 { allowedUsageOptions, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import { allowedUsageOptions, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
import UserRecordLink from "./UserRecordLink";
|
||||
import {
|
||||
cleanUsageUrl,
|
||||
|
|
@ -78,6 +78,7 @@ import {
|
|||
dateRangePatch,
|
||||
entitySelectionPatch,
|
||||
selectedEntitiesFromParams,
|
||||
DEFAULT_USAGE_VIEW,
|
||||
usageTabFromParams,
|
||||
usageTabPatch,
|
||||
usageUrlParsers,
|
||||
|
|
@ -127,8 +128,11 @@ 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],
|
||||
);
|
||||
|
||||
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
|
||||
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
|
||||
|
|
@ -136,12 +140,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
|
||||
const selectedUsageView = usageViewFromParams(urlParams);
|
||||
const selectedTab = usageTabFromParams(urlParams);
|
||||
// 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 usageView = allowedViews.includes(selectedUsageView) ? selectedUsageView : DEFAULT_USAGE_VIEW;
|
||||
|
||||
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
|
|
@ -151,10 +150,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : urlParams.user;
|
||||
|
||||
const { isLoading: organizationsLoading } = useOrganizations();
|
||||
const allowedViews = useMemo(
|
||||
() => allowedUsageOptions(userRole, canViewTagUsage, isOrgAdmin),
|
||||
[userRole, canViewTagUsage, isOrgAdmin],
|
||||
);
|
||||
const accessSettled = userRole !== null && !organizationsLoading;
|
||||
useEffect(() => {
|
||||
if (!accessSettled) return;
|
||||
|
|
@ -303,11 +298,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
|
||||
// Super responsive date change handler
|
||||
const handleDateChange = useCallback(
|
||||
(newValue: DateRangePickerValue) => {
|
||||
(newValue: DateRangePickerValue, presetShortLabel: string | null) => {
|
||||
// Instant visual feedback
|
||||
setIsDateChanging(true);
|
||||
|
||||
void setUrlParams(dateRangePatch(newValue));
|
||||
void setUrlParams(dateRangePatch(newValue, presetShortLabel));
|
||||
},
|
||||
[setUrlParams],
|
||||
);
|
||||
|
|
@ -497,7 +492,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
canViewTagUsage={canViewTagUsage}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
/>
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
<div className="flex items-center gap-2">
|
||||
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
|
||||
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
|
||||
<Link2 />
|
||||
Copy Share Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PaginationStatusAlerts
|
||||
isFetchingMore={paginatedResult.isFetchingMore}
|
||||
|
|
@ -538,10 +539,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => void copyToClipboard(window.location.href, "Link copied")}>
|
||||
<Link2 />
|
||||
Copy Share Link
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsAiChatOpen(true)}>
|
||||
<Sparkles />
|
||||
Ask AI
|
||||
|
|
@ -931,7 +928,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
)}
|
||||
{/* Organization Usage Panel */}
|
||||
|
||||
{usageView === "organization" && canViewOrganizationUsage && (
|
||||
{usageView === "organization" && (
|
||||
<EntityUsage
|
||||
accessToken={accessToken}
|
||||
entityType="organization"
|
||||
|
|
|
|||
|
|
@ -96,13 +96,16 @@ describe("UsageViewSelect", () => {
|
|||
expect(offers(container, optionName)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
|
||||
it.each([
|
||||
["Team Usage", false],
|
||||
["Tag Usage", true],
|
||||
] as const)("should offer %s to an internal user: %s", async (optionName, expected) => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
expect(offers(container, optionName)).toBe(true);
|
||||
expect(offers(container, optionName)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ const OPTIONS: OptionConfig[] = [
|
|||
label: "Team Usage",
|
||||
description: "View usage by team",
|
||||
icon: <Users className="size-4" />,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
value: "customer",
|
||||
|
|
|
|||
|
|
@ -94,7 +94,27 @@ 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))).toBe("?range=30d");
|
||||
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", () => {
|
||||
|
|
@ -106,7 +126,7 @@ describe("date range", () => {
|
|||
|
||||
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));
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { parseAsNativeArrayOf, parseAsString, type inferParserType } from "nuqs";
|
||||
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import { matchRelativeTimeOption, relativeTimeOptions } from "@/components/shared/advanced_date_picker";
|
||||
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";
|
||||
|
|
@ -120,9 +120,10 @@ export const dateRangeFromParams = (params: DateParams): DateRangePickerValue |
|
|||
return customRangeFromParams(params.from, params.to);
|
||||
};
|
||||
|
||||
export const dateRangePatch = (value: DateRangePickerValue): UsageUrlPatch => {
|
||||
const preset = matchRelativeTimeOption(value);
|
||||
if (preset) return { range: preset.shortLabel, from: null, to: null };
|
||||
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() };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,7 +181,7 @@ describe("UsersTable", () => {
|
|||
expect(onUserClick).toHaveBeenCalledWith("user-1", true);
|
||||
});
|
||||
|
||||
it("links the row menu to that user's usage", async () => {
|
||||
it("links the row menu to that user's usage when allowed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
|
|
@ -190,6 +193,15 @@ describe("UsersTable", () => {
|
|||
);
|
||||
});
|
||||
|
||||
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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -31,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
|
||||
|
|
@ -62,10 +63,12 @@ function UserRowActions({ user, onUserClick, onDeleteUser, onResetPassword }: Us
|
|||
<Copy />
|
||||
Copy user ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href={usageHrefForUser(user.user_id)} />} data-testid="user-action-view-usage">
|
||||
<ChartColumn />
|
||||
View usage
|
||||
</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 />
|
||||
|
|
@ -79,6 +82,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;
|
||||
|
|
@ -87,6 +91,7 @@ export interface UsersTableColumnsDeps {
|
|||
export const getUsersTableColumns = ({
|
||||
possibleUIRoles,
|
||||
includeSelection,
|
||||
canViewUserUsage,
|
||||
onUserClick,
|
||||
onDeleteUser,
|
||||
onResetPassword,
|
||||
|
|
@ -259,6 +264,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;
|
||||
|
|
@ -85,7 +85,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">
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export interface EntityUsageExportModalProps {
|
|||
entityType: EntityType;
|
||||
spendData: EntitySpendData;
|
||||
dateRange: DateRangePickerValue;
|
||||
selectedFilters: string[];
|
||||
selectedFilters: readonly string[];
|
||||
customTitle?: string;
|
||||
teams?: Team[];
|
||||
serverExport?: ServerExport;
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ export const generateExportData = (
|
|||
export const generateMetadata = (
|
||||
entityType: EntityType,
|
||||
dateRange: DateRangePickerValue,
|
||||
selectedFilters: string[],
|
||||
selectedFilters: readonly string[],
|
||||
exportScope: ExportScope,
|
||||
spendData: EntitySpendData,
|
||||
): ExportMetadata => {
|
||||
|
|
@ -450,7 +450,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,
|
||||
};
|
||||
|
|
@ -498,7 +498,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;
|
||||
|
|
|
|||
|
|
@ -1529,7 +1529,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,34 @@ 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("should select relative time option", () => {
|
||||
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
||||
interface AdvancedDatePickerProps {
|
||||
value: DateRangePickerValue;
|
||||
onValueChange: (value: DateRangePickerValue) => void;
|
||||
onValueChange: (value: DateRangePickerValue, presetShortLabel: string | null) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
showTimeRange?: boolean;
|
||||
|
|
@ -86,6 +86,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("");
|
||||
|
|
@ -201,6 +202,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"));
|
||||
|
|
@ -238,13 +240,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 },
|
||||
);
|
||||
|
|
@ -268,6 +270,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
// Reset selected option
|
||||
const matchingOption = getMatchingOption(value);
|
||||
setSelectedOption(matchingOption);
|
||||
setPickedPreset(null);
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
|
@ -362,7 +365,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"
|
||||
|
|
@ -377,7 +383,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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue