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:
Cursor Agent 2026-04-23 10:37:26 +00:00
parent 60bb2dce9d
commit 4c8dc48b91
No known key found for this signature in database
4 changed files with 157 additions and 79 deletions

View file

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

View file

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

View file

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

View file

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