mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
Merge 69547bf203 into 85dc7cb62e
This commit is contained in:
commit
fcadf04cf0
6 changed files with 377 additions and 107 deletions
|
|
@ -64,8 +64,9 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
|
|||
default: () => <div>Endpoint Usage</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
|
||||
vi.mock("./UsageViewSelect/UsageViewSelect", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const actual = await importOriginal<typeof import("./UsageViewSelect/UsageViewSelect")>();
|
||||
const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => {
|
||||
const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null;
|
||||
return React.createElement(
|
||||
|
|
@ -87,7 +88,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
|
|||
);
|
||||
};
|
||||
UsageViewSelect.displayName = "UsageViewSelect";
|
||||
return { UsageViewSelect };
|
||||
return { ...actual, UsageViewSelect };
|
||||
});
|
||||
|
||||
vi.mock("@/components/shared/advanced_date_picker", async () => {
|
||||
|
|
@ -353,6 +354,10 @@ describe("UsagePage", () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// UsagePageView now persists the selected view/tab/date-range to
|
||||
// localStorage; without clearing it here, whichever test runs first
|
||||
// pollutes every test after it in this file.
|
||||
localStorage.clear();
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
isLoading: false,
|
||||
isAuthorized: true,
|
||||
|
|
@ -1364,5 +1369,68 @@ describe("UsagePage", () => {
|
|||
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should persist the selected activity tab across a reload", async () => {
|
||||
const { unmount } = renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Model Activity"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("litellmUsageActivityTab")).toBe("models");
|
||||
});
|
||||
|
||||
unmount();
|
||||
mockUserDailyActivityAggregatedCall.mockClear();
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Model Activity" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tab", { name: "Cost" })).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("persisted usage view", () => {
|
||||
it.each(["agent", "organization", "customer", "user"])(
|
||||
"should not restore a persisted %s usage view for a session without access to it",
|
||||
async (persistedView) => {
|
||||
// The persisted view can outlive the session that set it (role change,
|
||||
// org-admin membership revoked, a different account signing in on the
|
||||
// same browser). Restoring it verbatim would land a non-admin on a
|
||||
// selector value with no matching panel and no way to get back except
|
||||
// manually reselecting a different option.
|
||||
localStorage.setItem("litellmUsageView", persistedView);
|
||||
mockUseAuthorized.mockReturnValue(nonAdminSession);
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} organizations={mockOrganizations} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("global");
|
||||
},
|
||||
);
|
||||
|
||||
it("should restore a persisted usage view that the session still has access to", async () => {
|
||||
localStorage.setItem("litellmUsageView", "team");
|
||||
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect((screen.getByTestId("usage-view-select") as HTMLSelectElement).value).toBe("team");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import {
|
|||
} from "@/components/networking";
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
import { ChartLoader } from "@/components/shared/chart_loader";
|
||||
import { getMatchingRelativeOption, getRelativeRangeByShortLabel } from "@/components/shared/date_range_presets";
|
||||
import { getLocalStorageItem, removeLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
|
||||
import { Tag } from "@/components/tag_management/types";
|
||||
import UserAgentActivity from "@/components/user_agent_activity";
|
||||
import ViewUserSpend from "@/components/view_user_spend";
|
||||
|
|
@ -65,13 +67,87 @@ 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 { isUsageOptionAvailable, UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
|
||||
|
||||
interface UsagePageProps {
|
||||
teams: Team[];
|
||||
organizations: Organization[];
|
||||
}
|
||||
|
||||
const USAGE_DATE_RANGE_PRESET_KEY = "litellmUsageDateRangePreset";
|
||||
const USAGE_DATE_RANGE_CUSTOM_KEY = "litellmUsageDateRangeCustom";
|
||||
const USAGE_VIEW_KEY = "litellmUsageView";
|
||||
const USAGE_ACTIVITY_TAB_KEY = "litellmUsageActivityTab";
|
||||
|
||||
const VALID_ACTIVITY_TABS = ["cost", "models", "keys", "mcp", "endpoints"] as const;
|
||||
type ActivityTab = (typeof VALID_ACTIVITY_TABS)[number];
|
||||
|
||||
const VALID_USAGE_OPTIONS: UsageOption[] = [
|
||||
"global",
|
||||
"my-usage",
|
||||
"organization",
|
||||
"team",
|
||||
"customer",
|
||||
"tag",
|
||||
"agent",
|
||||
"user",
|
||||
"user-agent-activity",
|
||||
];
|
||||
|
||||
const DEFAULT_DATE_RANGE = (): DateRangePickerValue => ({
|
||||
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
to: new Date(),
|
||||
});
|
||||
|
||||
/** Resolves the persisted time range. Relative presets ("7d", "today", ...) are
|
||||
* recomputed fresh from `now` on every load, so a saved preset never goes stale. */
|
||||
function loadPersistedDateRange(): DateRangePickerValue {
|
||||
const presetLabel = getLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY);
|
||||
if (presetLabel) {
|
||||
const range = getRelativeRangeByShortLabel(presetLabel);
|
||||
if (range) return range;
|
||||
}
|
||||
|
||||
const customRaw = getLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY);
|
||||
if (customRaw) {
|
||||
try {
|
||||
const { from, to } = JSON.parse(customRaw) as { from: string; to: string };
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (!isNaN(fromDate.getTime()) && !isNaN(toDate.getTime())) {
|
||||
return { from: fromDate, to: toDate };
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_DATE_RANGE();
|
||||
}
|
||||
|
||||
function persistDateRange(value: DateRangePickerValue): void {
|
||||
const presetLabel = getMatchingRelativeOption(value);
|
||||
if (presetLabel) {
|
||||
setLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY, presetLabel);
|
||||
removeLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY);
|
||||
return;
|
||||
}
|
||||
removeLocalStorageItem(USAGE_DATE_RANGE_PRESET_KEY);
|
||||
if (value.from && value.to) {
|
||||
setLocalStorageItem(USAGE_DATE_RANGE_CUSTOM_KEY, JSON.stringify({ from: value.from, to: value.to }));
|
||||
}
|
||||
}
|
||||
|
||||
function loadPersistedUsageView(): UsageOption {
|
||||
const stored = getLocalStorageItem(USAGE_VIEW_KEY);
|
||||
return VALID_USAGE_OPTIONS.includes(stored as UsageOption) ? (stored as UsageOption) : "global";
|
||||
}
|
||||
|
||||
function loadPersistedActivityTab(): ActivityTab {
|
||||
const stored = getLocalStorageItem(USAGE_ACTIVITY_TAB_KEY);
|
||||
return (VALID_ACTIVITY_TABS as readonly string[]).includes(stored ?? "") ? (stored as ActivityTab) : "cost";
|
||||
}
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
// Aggregated endpoint: try first, fall back to paginated if unavailable
|
||||
|
|
@ -89,15 +165,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
// Separate loading states for better UX
|
||||
const [isDateChanging, setIsDateChanging] = useState(false);
|
||||
|
||||
// Create initial dates outside of state to prevent recreation
|
||||
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,
|
||||
});
|
||||
// Single date state that directly triggers data fetching. Initialized from
|
||||
// localStorage so a saved selection survives a page reload; relative presets
|
||||
// ("7d", "today", ...) are recomputed fresh here rather than replayed as a
|
||||
// frozen timestamp, so "now" always tracks the actual current day.
|
||||
const [dateValue, setDateValue] = useState<DateRangePickerValue>(() => loadPersistedDateRange());
|
||||
|
||||
const [fetchedTags, setFetchedTags] = useState<FetchedForRange<EntityList[]> | null>(null);
|
||||
// No [] default: an unresolved query must stay undefined so the customer
|
||||
|
|
@ -118,14 +190,29 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
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, setUsageViewState] = useState<UsageOption>(() => loadPersistedUsageView());
|
||||
const setUsageView = useCallback((value: UsageOption) => {
|
||||
setUsageViewState(value);
|
||||
setLocalStorageItem(USAGE_VIEW_KEY, value);
|
||||
}, []);
|
||||
// Capabilities (org-admin membership, tag usage) are read from the server and
|
||||
// a persisted view is restored from localStorage, so either can grant a view
|
||||
// the current session no longer has. 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 = isUsageOptionAvailable(selectedUsageView, {
|
||||
userRole,
|
||||
canViewTagUsage,
|
||||
isOrgAdmin,
|
||||
})
|
||||
? selectedUsageView
|
||||
: "global";
|
||||
|
||||
const [activityTab, setActivityTabState] = useState<ActivityTab>(() => loadPersistedActivityTab());
|
||||
const setActivityTab = useCallback((value: string) => {
|
||||
setActivityTabState(value as ActivityTab);
|
||||
setLocalStorageItem(USAGE_ACTIVITY_TAB_KEY, value);
|
||||
}, []);
|
||||
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
|
||||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
|
|
@ -273,8 +360,35 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
|
||||
// Update date immediately for UI responsiveness
|
||||
setDateValue(newValue);
|
||||
persistDateRange(newValue);
|
||||
}, []);
|
||||
|
||||
// If the user leaves this tab open across midnight (or backgrounded on
|
||||
// mobile), a relative preset like "Today"/"7d" should track the real
|
||||
// current day again once they come back, not stay pinned to load time.
|
||||
useEffect(() => {
|
||||
const refreshPresetIfStale = () => {
|
||||
const presetLabel = getMatchingRelativeOption(dateValue);
|
||||
if (!presetLabel) return;
|
||||
const freshRange = getRelativeRangeByShortLabel(presetLabel);
|
||||
if (!freshRange) return;
|
||||
if (
|
||||
freshRange.from.getTime() !== dateValue.from?.getTime() ||
|
||||
freshRange.to.getTime() !== dateValue.to?.getTime()
|
||||
) {
|
||||
setDateValue(freshRange);
|
||||
persistDateRange(freshRange);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", refreshPresetIfStale);
|
||||
window.addEventListener("focus", refreshPresetIfStale);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", refreshPresetIfStale);
|
||||
window.removeEventListener("focus", refreshPresetIfStale);
|
||||
};
|
||||
}, [dateValue]);
|
||||
|
||||
// Derived states from userSpendData
|
||||
const totalSpend = userSpendData.metadata?.total_spend || 0;
|
||||
|
||||
|
|
@ -469,7 +583,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="cost">
|
||||
<Tabs value={activityTab} onValueChange={setActivityTab}>
|
||||
<div className="flex justify-between items-center">
|
||||
<TabsList className="mt-1">
|
||||
<TabsTrigger value="cost" className="flex-none px-3">
|
||||
|
|
|
|||
|
|
@ -104,6 +104,37 @@ const OPTIONS: OptionConfig[] = [
|
|||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
export interface UsageOptionAvailability {
|
||||
userRole: string | null;
|
||||
canViewTagUsage?: boolean;
|
||||
isOrgAdmin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for whether a given usage view is available to the
|
||||
* current user, mirroring the capability matrix in OPTIONS below. Used both
|
||||
* to filter the select's menu and to validate a usage view restored from
|
||||
* persisted state (e.g. localStorage), whose role may have changed since.
|
||||
*/
|
||||
export const isUsageOptionAvailable = (
|
||||
value: UsageOption,
|
||||
{ userRole, canViewTagUsage = false, isOrgAdmin = false }: UsageOptionAvailability,
|
||||
): boolean => {
|
||||
const option = OPTIONS.find((o) => o.value === value);
|
||||
if (!option) return false;
|
||||
const isAdmin = all_admin_roles.includes(userRole ?? "");
|
||||
if (option.capability) {
|
||||
return hasCapability(userRole, option.capability, isOrgAdmin);
|
||||
}
|
||||
if (option.value === "tag" && canViewTagUsage) {
|
||||
return true;
|
||||
}
|
||||
if (option.adminOnly && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -116,18 +147,9 @@ 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 OPTIONS.filter((option) =>
|
||||
isUsageOptionAvailable(option.value, { userRole, canViewTagUsage, isOrgAdmin }),
|
||||
).map((option) => {
|
||||
let label = option.label;
|
||||
let desc = option.description;
|
||||
if (option.showForAdmin && option.showForNonAdmin) {
|
||||
|
|
|
|||
|
|
@ -235,4 +235,43 @@ describe("AdvancedDatePicker", () => {
|
|||
expect(applyButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close dropdown when Apply is clicked", async () => {
|
||||
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
|
||||
|
||||
openDropdown(container);
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Today")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close dropdown on Apply even without requestIdleCallback (Safari)", async () => {
|
||||
// Safari has never implemented requestIdleCallback. Simulate that by
|
||||
// removing the polyfill installed in the beforeAll above for this one
|
||||
// test, so a regression that puts setIsOpen(false) after the
|
||||
// requestIdleCallback call (and lets its ReferenceError swallow it)
|
||||
// fails here instead of only in real Safari.
|
||||
const original = window.requestIdleCallback;
|
||||
// @ts-expect-error - deleting a browser API to emulate Safari's absence of it
|
||||
delete window.requestIdleCallback;
|
||||
|
||||
try {
|
||||
const { container } = render(<AdvancedDatePicker value={defaultValue} onValueChange={mockOnValueChange} />);
|
||||
|
||||
openDropdown(container);
|
||||
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Apply"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Today")).not.toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
window.requestIdleCallback = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { cn } from "@/lib/cva.config";
|
|||
import type { DateRangePickerValue } from "./date_picker_types";
|
||||
import moment from "moment";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getMatchingRelativeOption,
|
||||
RelativeTimeOption,
|
||||
relativeTimeOptions,
|
||||
} from "@/components/shared/date_range_presets";
|
||||
|
||||
interface AdvancedDatePickerProps {
|
||||
value: DateRangePickerValue;
|
||||
|
|
@ -14,55 +19,6 @@ interface AdvancedDatePickerProps {
|
|||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
interface RelativeTimeOption {
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
getValue: () => { from: Date; to: Date };
|
||||
}
|
||||
|
||||
const relativeTimeOptions: RelativeTimeOption[] = [
|
||||
{
|
||||
label: "Today",
|
||||
shortLabel: "today",
|
||||
getValue: () => ({
|
||||
from: moment().startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
shortLabel: "7d",
|
||||
getValue: () => ({
|
||||
from: moment().subtract(7, "days").startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
shortLabel: "30d",
|
||||
getValue: () => ({
|
||||
from: moment().subtract(30, "days").startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Month to date",
|
||||
shortLabel: "MTD",
|
||||
getValue: () => ({
|
||||
from: moment().startOf("month").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Year to date",
|
||||
shortLabel: "YTD",
|
||||
getValue: () => ({
|
||||
from: moment().startOf("year").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Advanced Date Range Picker with dropdown, relative times, and custom inputs
|
||||
*/
|
||||
|
|
@ -85,23 +41,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 => getMatchingRelativeOption(currentValue),
|
||||
[],
|
||||
);
|
||||
|
||||
// Update selected option when value changes
|
||||
useEffect(() => {
|
||||
|
|
@ -245,16 +188,21 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
|
|||
// First call with immediate value for UI responsiveness
|
||||
onValueChange(tempValue);
|
||||
|
||||
// Then do the same background adjustment logic as the original component
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
const adjustedValue = adjustDateRange(tempValue);
|
||||
onValueChange(adjustedValue);
|
||||
},
|
||||
{ timeout: 100 },
|
||||
);
|
||||
|
||||
// Close immediately - Safari has no requestIdleCallback, and scheduling
|
||||
// the close after it (as before) meant the thrown ReferenceError there
|
||||
// silently skipped this line, leaving the dropdown stuck open.
|
||||
setIsOpen(false);
|
||||
|
||||
// Then do the same background adjustment logic as the original component.
|
||||
// Safari doesn't implement requestIdleCallback, so fall back to setTimeout.
|
||||
const scheduleIdle: (callback: () => void) => void =
|
||||
typeof requestIdleCallback === "function"
|
||||
? (callback) => requestIdleCallback(callback, { timeout: 100 })
|
||||
: (callback) => setTimeout(callback, 0);
|
||||
scheduleIdle(() => {
|
||||
const adjustedValue = adjustDateRange(tempValue);
|
||||
onValueChange(adjustedValue);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import moment from "moment";
|
||||
|
||||
// Structurally compatible with @tremor/react's DateRangePickerValue, defined
|
||||
// locally so this module doesn't need a (now-discouraged) tremor import.
|
||||
interface DateRangeLike {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
export interface RelativeTimeOption {
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
getValue: () => { from: Date; to: Date };
|
||||
}
|
||||
|
||||
export const relativeTimeOptions: RelativeTimeOption[] = [
|
||||
{
|
||||
label: "Today",
|
||||
shortLabel: "today",
|
||||
getValue: () => ({
|
||||
from: moment().startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
shortLabel: "7d",
|
||||
getValue: () => ({
|
||||
from: moment().subtract(7, "days").startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
shortLabel: "30d",
|
||||
getValue: () => ({
|
||||
from: moment().subtract(30, "days").startOf("day").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Month to date",
|
||||
shortLabel: "MTD",
|
||||
getValue: () => ({
|
||||
from: moment().startOf("month").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Year to date",
|
||||
shortLabel: "YTD",
|
||||
getValue: () => ({
|
||||
from: moment().startOf("year").toDate(),
|
||||
to: moment().endOf("day").toDate(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/** Returns the shortLabel of the relative preset that matches `value`, or null if it's a custom range. */
|
||||
export function getMatchingRelativeOption(value: DateRangeLike): string | null {
|
||||
if (!value.from || !value.to) return null;
|
||||
|
||||
for (const option of relativeTimeOptions) {
|
||||
const optionRange = option.getValue();
|
||||
const fromMatches = moment(value.from).isSame(moment(optionRange.from), "day");
|
||||
const toMatches = moment(value.to).isSame(moment(optionRange.to), "day");
|
||||
if (fromMatches && toMatches) {
|
||||
return option.shortLabel;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recomputes a fresh {from, to} for a stored preset shortLabel (e.g. "today" always resolves to the current day). */
|
||||
export function getRelativeRangeByShortLabel(shortLabel: string): { from: Date; to: Date } | null {
|
||||
const option = relativeTimeOptions.find((o) => o.shortLabel === shortLabel);
|
||||
return option ? option.getValue() : null;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue