mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
refactor(ui): migrate cost-tracking to shadcn (#36631)
* refactor(ui): migrate cost-tracking helpers to shadcn * fix(ui): restore export menu keyboard navigation
This commit is contained in:
parent
2b9e3db6b0
commit
7d12f21e31
4 changed files with 127 additions and 127 deletions
|
|
@ -234,9 +234,6 @@
|
|||
"src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": {
|
||||
|
|
@ -260,17 +257,9 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/cost-tracking/_components/pricing_calculator/use_multi_cost_estimate.ts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import CodeBlock from "@/components/CodeBlock";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const HowItWorks: React.FC = () => {
|
||||
const [responseCost, setResponseCost] = useState("");
|
||||
|
|
@ -9,8 +10,10 @@ const HowItWorks: React.FC = () => {
|
|||
const calculatedDiscount = useMemo(() => {
|
||||
const cost = parseFloat(responseCost);
|
||||
const discount = parseFloat(discountAmount);
|
||||
const hasInvalidCost = isNaN(cost) || cost === 0;
|
||||
const hasInvalidDiscount = isNaN(discount) || discount === 0;
|
||||
|
||||
if (isNaN(cost) || isNaN(discount) || cost === 0 || discount === 0) {
|
||||
if (hasInvalidCost || hasInvalidDiscount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -28,30 +31,30 @@ const HowItWorks: React.FC = () => {
|
|||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900 text-sm mb-1">Cost Calculation</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
<h3 className="mb-1 text-sm font-medium text-foreground">Cost Calculation</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Discounts are applied to provider costs:{" "}
|
||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded-sm text-xs">
|
||||
<code className="rounded-sm bg-muted px-1.5 py-0.5 text-xs text-foreground">
|
||||
final_cost = base_cost × (1 - discount%/100)
|
||||
</code>
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900 text-sm mb-1">Example</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
<h3 className="mb-1 text-sm font-medium text-foreground">Example</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A 5% discount on a $10.00 request results in: $10.00 × (1 - 0.05) = $9.50
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium text-gray-900 text-sm mb-1">Valid Range</Text>
|
||||
<Text className="text-xs text-gray-600">Discount percentages must be between 0% and 100%</Text>
|
||||
<h3 className="mb-1 text-sm font-medium text-foreground">Valid Range</h3>
|
||||
<p className="text-xs text-muted-foreground">Discount percentages must be between 0% and 100%</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Text className="font-medium text-gray-900 text-sm mb-2">Validating Discounts</Text>
|
||||
<Text className="text-xs text-gray-600 mb-3">
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="mb-2 text-sm font-medium text-foreground">Validating Discounts</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Make a test request and check the response headers to verify discounts are applied:
|
||||
</Text>
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl -X POST -i http://your-proxy:4000/chat/completions \\
|
||||
|
|
@ -62,78 +65,80 @@ const HowItWorks: React.FC = () => {
|
|||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}'`}
|
||||
/>
|
||||
<Text className="text-xs text-gray-600 mt-3 mb-2">Look for these headers in the response:</Text>
|
||||
<p className="mb-2 mt-3 text-xs text-muted-foreground">Look for these headers in the response:</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded-sm text-xs font-mono text-gray-800 whitespace-nowrap">
|
||||
<code className="whitespace-nowrap rounded-sm bg-muted px-2 py-1 font-mono text-xs text-foreground">
|
||||
x-litellm-response-cost
|
||||
</code>
|
||||
<Text className="text-xs text-gray-600">Final cost after discount</Text>
|
||||
<p className="text-xs text-muted-foreground">Final cost after discount</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded-sm text-xs font-mono text-gray-800 whitespace-nowrap">
|
||||
<code className="whitespace-nowrap rounded-sm bg-muted px-2 py-1 font-mono text-xs text-foreground">
|
||||
x-litellm-response-cost-original
|
||||
</code>
|
||||
<Text className="text-xs text-gray-600">Original cost before discount</Text>
|
||||
<p className="text-xs text-muted-foreground">Original cost before discount</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<code className="bg-gray-100 px-2 py-1 rounded-sm text-xs font-mono text-gray-800 whitespace-nowrap">
|
||||
<code className="whitespace-nowrap rounded-sm bg-muted px-2 py-1 font-mono text-xs text-foreground">
|
||||
x-litellm-response-cost-discount-amount
|
||||
</code>
|
||||
<Text className="text-xs text-gray-600">Amount discounted</Text>
|
||||
<p className="text-xs text-muted-foreground">Amount discounted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Text className="font-medium text-gray-900 text-sm mb-3">Discount Calculator</Text>
|
||||
<Text className="text-xs text-gray-600 mb-3">
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-foreground">Discount Calculator</h3>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Enter values from your response headers to verify the discount:
|
||||
</Text>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
</p>
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Label htmlFor="response-cost" className="mb-1 block text-xs">
|
||||
Response Cost (x-litellm-response-cost)
|
||||
</label>
|
||||
<TextInput
|
||||
</Label>
|
||||
<Input
|
||||
id="response-cost"
|
||||
placeholder="0.0171938125"
|
||||
value={responseCost}
|
||||
onValueChange={setResponseCost}
|
||||
onChange={(event) => setResponseCost(event.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
<Label htmlFor="discount-amount" className="mb-1 block text-xs">
|
||||
Discount Amount (x-litellm-response-cost-discount-amount)
|
||||
</label>
|
||||
<TextInput
|
||||
</Label>
|
||||
<Input
|
||||
id="discount-amount"
|
||||
placeholder="0.0009049375"
|
||||
value={discountAmount}
|
||||
onValueChange={setDiscountAmount}
|
||||
onChange={(event) => setDiscountAmount(event.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calculatedDiscount && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<Text className="text-sm font-medium text-blue-900 mb-2">Calculated Results</Text>
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Calculated Results</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-xs text-blue-800">Original Cost:</Text>
|
||||
<code className="text-xs font-mono text-blue-900">${calculatedDiscount.originalCost}</code>
|
||||
<p className="text-xs text-muted-foreground">Original Cost:</p>
|
||||
<code className="font-mono text-xs text-foreground">${calculatedDiscount.originalCost}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-xs text-blue-800">Final Cost:</Text>
|
||||
<code className="text-xs font-mono text-blue-900">${calculatedDiscount.finalCost}</code>
|
||||
<p className="text-xs text-muted-foreground">Final Cost:</p>
|
||||
<code className="font-mono text-xs text-foreground">${calculatedDiscount.finalCost}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text className="text-xs text-blue-800">Discount Amount:</Text>
|
||||
<code className="text-xs font-mono text-blue-900">${calculatedDiscount.discountAmount}</code>
|
||||
<p className="text-xs text-muted-foreground">Discount Amount:</p>
|
||||
<code className="font-mono text-xs text-foreground">${calculatedDiscount.discountAmount}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-blue-300">
|
||||
<Text className="text-xs font-semibold text-blue-900">Discount Applied:</Text>
|
||||
<Text className="text-sm font-bold text-blue-900">{calculatedDiscount.discountPercentage}%</Text>
|
||||
<div className="flex items-center justify-between border-t border-border pt-2">
|
||||
<p className="text-xs font-semibold text-foreground">Discount Applied:</p>
|
||||
<p className="text-sm font-bold text-foreground">{calculatedDiscount.discountPercentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../../../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../../../tests/test-utils";
|
||||
import MultiExportDropdown from "./multi_export_dropdown";
|
||||
import type { MultiModelResult } from "./types";
|
||||
|
||||
|
|
@ -78,41 +77,44 @@ describe("MultiExportDropdown", () => {
|
|||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
|
||||
expect(screen.getByText("Export as PDF")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export as CSV")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("menuitem", { name: "Export as PDF" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Export as CSV" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide the export menu when the Export button is clicked again", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
expect(screen.getByText("Export as PDF")).toBeInTheDocument();
|
||||
const trigger = screen.getByRole("button", { name: /^export$/i });
|
||||
await user.click(trigger);
|
||||
await screen.findByRole("menuitem", { name: "Export as PDF" });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
expect(screen.queryByText("Export as PDF")).not.toBeInTheDocument();
|
||||
await user.click(trigger);
|
||||
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
|
||||
});
|
||||
|
||||
it("should call exportMultiToPDF and close the menu when Export as PDF is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
await user.click(screen.getByText("Export as PDF"));
|
||||
const trigger = screen.getByRole("button", { name: /^export$/i });
|
||||
await user.click(trigger);
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Export as PDF" }));
|
||||
|
||||
expect(exportMultiToPDF).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Export as PDF")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
|
||||
});
|
||||
|
||||
it("should call exportMultiToCSV and close the menu when Export as CSV is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
await user.click(screen.getByText("Export as CSV"));
|
||||
const trigger = screen.getByRole("button", { name: /^export$/i });
|
||||
await user.click(trigger);
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Export as CSV" }));
|
||||
|
||||
expect(exportMultiToCSV).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Export as CSV")).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
|
||||
});
|
||||
|
||||
it("should pass the multiResult to the export functions", async () => {
|
||||
|
|
@ -121,7 +123,7 @@ describe("MultiExportDropdown", () => {
|
|||
renderWithProviders(<MultiExportDropdown multiResult={multiResult} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
await user.click(screen.getByText("Export as PDF"));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Export as PDF" }));
|
||||
|
||||
expect(exportMultiToPDF).toHaveBeenCalledWith(multiResult);
|
||||
});
|
||||
|
|
@ -135,10 +137,41 @@ describe("MultiExportDropdown", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^export$/i }));
|
||||
expect(screen.getByText("Export as PDF")).toBeInTheDocument();
|
||||
const trigger = screen.getByRole("button", { name: /^export$/i });
|
||||
await user.click(trigger);
|
||||
await screen.findByRole("menuitem", { name: "Export as PDF" });
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId("outside"));
|
||||
expect(screen.queryByText("Export as PDF")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("outside"));
|
||||
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
|
||||
});
|
||||
|
||||
it("should focus and navigate export options with the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /^export$/i });
|
||||
trigger.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
const pdfOption = await screen.findByRole("menuitem", { name: "Export as PDF" });
|
||||
await waitFor(() => expect(pdfOption).toHaveFocus());
|
||||
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("menuitem", { name: "Export as CSV" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("should close the menu and restore trigger focus when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<MultiExportDropdown multiResult={makeMultiResult(true)} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /^export$/i });
|
||||
trigger.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await screen.findByRole("menuitem", { name: "Export as PDF" });
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@tremor/react";
|
||||
import { DownloadOutlined, FilePdfOutlined, FileExcelOutlined } from "@ant-design/icons";
|
||||
import React from "react";
|
||||
import { Download, FileSpreadsheet, FileText } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MultiModelResult } from "./types";
|
||||
import { exportMultiToPDF, exportMultiToCSV } from "./multi_export_utils";
|
||||
|
||||
|
|
@ -9,62 +15,29 @@ interface MultiExportDropdownProps {
|
|||
}
|
||||
|
||||
const MultiExportDropdown: React.FC<MultiExportDropdownProps> = ({ multiResult }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasResults = multiResult.entries.some((e) => e.result !== null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!hasResults) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={menuRef}>
|
||||
<Button size="xs" variant="secondary" icon={DownloadOutlined} onClick={() => setIsOpen(!isOpen)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={buttonVariants({ variant: "secondary", size: "xs" })}>
|
||||
<Download />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => {
|
||||
exportMultiToPDF(multiResult);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined className="mr-3 text-red-500" />
|
||||
Export as PDF
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => {
|
||||
exportMultiToCSV(multiResult);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileExcelOutlined className="mr-3 text-green-600" />
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => exportMultiToPDF(multiResult)}>
|
||||
<FileText />
|
||||
Export as PDF
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => exportMultiToCSV(multiResult)}>
|
||||
<FileSpreadsheet />
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue