feat(ui): PolicySelector antd Select mode='multiple' \u2192 shadcn Popover chip-list

- Reuses the established multi-select-with-search popover pattern.
- Preserves policyVersionRef helper and getPolicyOptionEntries export
  (used by other policy files).
- Disabled placeholder ('premium feature') and loading placeholder
  ('Loading policies\u2026') both surface inside the trigger.

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 11:11:05 +00:00
parent b8acfb4f9f
commit fab8c8ec8d
No known key found for this signature in database

View file

@ -1,18 +1,26 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import React, { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Policy } from "./types";
import { getPoliciesList } from "../networking";
/** Prefix for policy version IDs in request body; must match backend POLICY_VERSION_ID_PREFIX. */
export const POLICY_VERSION_ID_PREFIX = "policy_";
/** Build the value sent in the request body: policy_<uuid> so backend executes this exact version. */
export function policyVersionRef(policyId: string): string {
return `${POLICY_VERSION_ID_PREFIX}${policyId}`;
}
/** Build select options from policies (filter non-draft, label with name/version/status). */
export function getPolicyOptionEntries(policies: Policy[]): { value: string; label: string }[] {
export function getPolicyOptionEntries(
policies: Policy[],
): { value: string; label: string }[] {
return policies
.filter((policy) => (policy.version_status ?? "draft") !== "draft")
.map((policy) => {
@ -39,7 +47,6 @@ interface PolicySelectorProps {
className?: string;
accessToken: string;
disabled?: boolean;
/** Called after policies are loaded; use to build value→label map for display elsewhere. */
onPoliciesLoaded?: (policies: Policy[]) => void;
}
@ -53,6 +60,8 @@ const PolicySelector: React.FC<PolicySelectorProps> = ({
}) => {
const [policies, setPolicies] = useState<Policy[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
const fetchPolicies = async () => {
@ -75,31 +84,100 @@ const PolicySelector: React.FC<PolicySelectorProps> = ({
fetchPolicies();
}, [accessToken, onPoliciesLoaded]);
const handlePolicyChange = (selectedValues: string[]) => {
onChange(selectedValues);
};
const options = useMemo(
() => getPolicyOptionEntries(policies),
[policies],
);
const selected = value ?? [];
const filteredOptions = useMemo(
() =>
options
.filter((o) => !selected.includes(o.value))
.filter((o) =>
query ? o.label.toLowerCase().includes(query.toLowerCase()) : true,
),
[options, selected, query],
);
const labelFor = (v: string) =>
options.find((o) => o.value === v)?.label ?? v;
const placeholder = disabled
? "Setting policies is a premium feature."
: loading
? "Loading policies…"
: "Select policies (production or published versions)";
return (
<div>
<Select
mode="multiple"
disabled={disabled}
placeholder={
disabled
? "Setting policies is a premium feature."
: "Select policies (production or published versions)"
}
onChange={handlePolicyChange}
value={value}
loading={loading}
className={className}
allowClear
options={getPolicyOptionEntries(policies)}
optionFilterProp="label"
showSearch
style={{ width: "100%" }}
/>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled || loading}
className={cn(
"min-h-9 w-full flex flex-wrap items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-left disabled:opacity-50",
className,
)}
>
{selected.length === 0 ? (
<span className="text-muted-foreground px-1">{placeholder}</span>
) : (
selected.map((v) => (
<Badge
key={v}
variant="secondary"
className="gap-1 inline-flex items-center"
>
{labelFor(v)}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange(selected.filter((s) => s !== v));
}}
className="inline-flex items-center"
aria-label={`Remove ${labelFor(v)}`}
>
<X size={12} />
</span>
</Badge>
))
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-2"
>
<Input
autoFocus
placeholder="Search policies…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 mb-2"
/>
<div className="max-h-60 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No matches
</div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.value}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent"
onClick={() => onChange([...selected, opt.value])}
>
{opt.label}
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
};