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:
jesus 2026-09-27 07:38:31 +00:00
parent a46433f3cb
commit d178e58c99
21 changed files with 190 additions and 79 deletions

View file

@ -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();

View file

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

View file

@ -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();
});
});
});

View file

@ -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"

View file

@ -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);
});
});

View file

@ -67,6 +67,7 @@ const OPTIONS: OptionConfig[] = [
label: "Team Usage",
description: "View usage by team",
icon: <Users className="size-4" />,
adminOnly: true,
},
{
value: "customer",

View file

@ -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);

View file

@ -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() };
};

View file

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

View file

@ -36,6 +36,7 @@ interface HarnessOverrides {
rowCount?: number;
isLoading?: boolean;
selectionEnabled?: boolean;
canViewUserUsage?: boolean;
onUserClick?: (userId: string, openInEditMode?: boolean) => void;
onDeleteUser?: (user: UserInfo) => void;
onResetPassword?: (userId: string) => void;
@ -51,6 +52,7 @@ function Harness({
rowCount = 1,
isLoading = false,
selectionEnabled = false,
canViewUserUsage = true,
onUserClick = vi.fn(),
onDeleteUser = vi.fn(),
onResetPassword = vi.fn(),
@ -74,6 +76,7 @@ function Harness({
isLoading={isLoading}
possibleUIRoles={possibleUIRoles}
teams={[]}
canViewUserUsage={canViewUserUsage}
sorting={sorting}
onSortingChange={(updater) => {
setSorting(updater);
@ -178,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();

View file

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

View file

@ -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}

View file

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

View file

@ -27,7 +27,7 @@ interface UsageExportHeaderProps {
showFilters?: boolean;
filterLabel?: string;
filterPlaceholder?: string;
selectedFilters?: string[];
selectedFilters?: readonly string[];
onFiltersChange?: (filters: string[]) => void;
filterOptions?: Array<{ label: string; value: string }>;
filterSlot?: React.ReactNode;
@ -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">

View file

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

View file

@ -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);

View file

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

View file

@ -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,

View file

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

View file

@ -1,5 +1,6 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import AdvancedDatePicker from "./advanced_date_picker";
@ -166,6 +167,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} />);

View file

@ -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"