mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): migrate common_components small selects + LabeledField to shadcn
- DurationSelect: antd Select \u2192 shadcn Select (Daily/Weekly/Monthly). - LabeledField: antd Typography.Text copyable indicator \u2192 custom hook using navigator.clipboard + lucide Copy/Check icon with Tooltip feedback. DefaultProxyAdminTag branch preserved. - OrganizationDropdown / ProjectDropdown: antd Select with allowClear + filterOption \u2192 shadcn Select with an explicit '__all__' sentinel value mapped back to an empty string on the callback side (primitives don't accept undefined like antd's allowClear). Loading state uses the disabled=true fallback since Radix Select has no spinner slot. Gates: TS \u2713 | Build \u2713. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
60bb2dce9d
commit
4c8dc48b91
4 changed files with 157 additions and 79 deletions
|
|
@ -1,4 +1,11 @@
|
|||
import { Select } from "antd";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DurationSelectProps {
|
||||
className?: string;
|
||||
|
|
@ -6,12 +13,21 @@ interface DurationSelectProps {
|
|||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function DurationSelect({ className, value, onChange }: DurationSelectProps) {
|
||||
export default function DurationSelect({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}: DurationSelectProps) {
|
||||
return (
|
||||
<Select className={className} value={value} onChange={onChange}>
|
||||
<Select.Option value="24h">Daily</Select.Option>
|
||||
<Select.Option value="7d">Weekly</Select.Option>
|
||||
<Select.Option value="30d">Monthly</Select.Option>
|
||||
<Select value={value} onValueChange={(v) => onChange?.(v)}>
|
||||
<SelectTrigger className={cn(className)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24h">Daily</SelectItem>
|
||||
<SelectItem value="7d">Weekly</SelectItem>
|
||||
<SelectItem value="30d">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import React from "react";
|
||||
import { Typography, Space } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DefaultProxyAdminTag from "./DefaultProxyAdminTag";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface LabeledFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
|
|
@ -13,6 +18,55 @@ interface LabeledFieldProps {
|
|||
defaultUserIdCheck?: boolean;
|
||||
}
|
||||
|
||||
const CopyableValue: React.FC<{
|
||||
value: string;
|
||||
label: string;
|
||||
truncate?: boolean;
|
||||
}> = ({ value, label, truncate }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_err) {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
truncate && "block max-w-[160px] truncate",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Copy ${label}`}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copied ? "Copied!" : `Copy ${label}`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function LabeledField({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -28,24 +82,25 @@ export default function LabeledField({
|
|||
|
||||
const valueEl = isDefaultUser ? (
|
||||
<DefaultProxyAdminTag userId={value} />
|
||||
) : isCopyable ? (
|
||||
<CopyableValue value={displayValue} label={label} truncate={truncate} />
|
||||
) : (
|
||||
<Text
|
||||
strong
|
||||
copyable={isCopyable ? { tooltips: [`Copy ${label}`, "Copied!"] } : false}
|
||||
ellipsis={truncate}
|
||||
style={truncate ? { maxWidth: 160, display: "block" } : undefined}
|
||||
<span
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
truncate && "block max-w-[160px] truncate",
|
||||
)}
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">{icon}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Space>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
{icon && <span className="text-xs">{icon}</span>}
|
||||
<span className="text-xs uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<div>{valueEl}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import React from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrganizationDropdownProps {
|
||||
organizations?: Organization[] | null;
|
||||
|
|
@ -11,8 +16,11 @@ interface OrganizationDropdownProps {
|
|||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
||||
organizations,
|
||||
value,
|
||||
|
|
@ -20,35 +28,34 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
|||
disabled,
|
||||
loading,
|
||||
style,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="All Organizations"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
style={{ minWidth: 280, ...style }}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const org = organizations?.find((o) => o.organization_id === option.key);
|
||||
if (!org) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const orgAlias = (org.organization_alias || "").toLowerCase();
|
||||
const orgId = (org.organization_id || "").toLowerCase();
|
||||
|
||||
return orgAlias.includes(searchTerm) || orgId.includes(searchTerm);
|
||||
}}
|
||||
value={value ?? ALL}
|
||||
onValueChange={(v) => onChange?.(v === ALL ? "" : v)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{organizations?.map((org) => (
|
||||
<Select.Option key={org.organization_id} value={org.organization_id}>
|
||||
<span className="font-medium">{org.organization_alias}</span>{" "}
|
||||
<Text type="secondary">({org.organization_id})</Text>
|
||||
</Select.Option>
|
||||
))}
|
||||
<SelectTrigger
|
||||
style={{ minWidth: 280, ...style }}
|
||||
className={cn(className)}
|
||||
>
|
||||
<SelectValue placeholder="All Organizations" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All Organizations</SelectItem>
|
||||
{organizations?.map((org) => (
|
||||
<SelectItem
|
||||
key={org.organization_id}
|
||||
value={org.organization_id ?? ""}
|
||||
>
|
||||
<span className="font-medium">{org.organization_alias}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({org.organization_id})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React from "react";
|
||||
import { Select, Spin } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
|
||||
interface ProjectDropdownProps {
|
||||
|
|
@ -13,6 +18,8 @@ interface ProjectDropdownProps {
|
|||
teamId?: string | null;
|
||||
}
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
|
||||
projects,
|
||||
value,
|
||||
|
|
@ -27,34 +34,27 @@ const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
|
|||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a project"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
allowClear
|
||||
notFoundContent={loading ? <Spin indicator={<LoadingOutlined spin />} size="small" /> : undefined}
|
||||
filterOption={(input, option) => {
|
||||
if (!option) return false;
|
||||
const project = filtered?.find((p) => p.project_id === option.key);
|
||||
if (!project) return false;
|
||||
|
||||
const searchTerm = input.toLowerCase().trim();
|
||||
const alias = (project.project_alias || "").toLowerCase();
|
||||
const id = (project.project_id || "").toLowerCase();
|
||||
|
||||
return alias.includes(searchTerm) || id.includes(searchTerm);
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
value={value ?? ALL}
|
||||
onValueChange={(v) => onChange?.(v === ALL ? "" : v)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{!loading &&
|
||||
filtered?.map((project) => (
|
||||
<Select.Option key={project.project_id} value={project.project_id}>
|
||||
<span className="font-medium">{project.project_alias || project.project_id}</span>{" "}
|
||||
<span className="text-gray-500">({project.project_id})</span>
|
||||
</Select.Option>
|
||||
))}
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Search or select a project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All Projects</SelectItem>
|
||||
{!loading &&
|
||||
filtered?.map((project) => (
|
||||
<SelectItem key={project.project_id} value={project.project_id}>
|
||||
<span className="font-medium">
|
||||
{project.project_alias || project.project_id}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({project.project_id})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue