01c70085db
Build & Push Docker Image / build (push) Successful in 6m52s
- costs: nullable notes (fix create without notes), drop renewalDate (schema + API + UI), gate POST/PATCH/DELETE to admin + costs feature - feature middleware: admin-aware hasFeature + getEntitlements union; /auth/me and login return resolved entitlements - users: tier enum (free/premium/enterprise) in create/update/list, admin UI tier select + tier badge - tags: GET /tags/all, TagInput autocomplete in new/edit tool forms, feature suggestions on focus, query invalidation on create/update - openapi: nullable ToolUpdate urls, ToolUpdate tier fields, listAllTags - Dockerfile: push-force to drop renewal_date column
82 lines
2.4 KiB
TypeScript
82 lines
2.4 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useListAllFeatures, getListAllFeaturesQueryKey } from "@workspace/api-client-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface FeatureInputProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
"data-testid"?: string;
|
|
}
|
|
|
|
export function FeatureInput({ value, onChange, placeholder, "data-testid": testId }: FeatureInputProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const allFeatures = useListAllFeatures({
|
|
query: {
|
|
queryKey: getListAllFeaturesQueryKey(),
|
|
refetchOnMount: "always",
|
|
staleTime: 0,
|
|
},
|
|
});
|
|
|
|
const known: string[] = allFeatures.data ?? [];
|
|
|
|
const q = value.trim();
|
|
const suggestions = known
|
|
.filter(
|
|
(f) =>
|
|
q.length === 0 ||
|
|
(f.toLowerCase().includes(q.toLowerCase()) && f.toLowerCase() !== q.toLowerCase()),
|
|
)
|
|
.slice(0, 6);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full">
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => {
|
|
onChange(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
placeholder={placeholder}
|
|
data-testid={testId}
|
|
autoComplete="off"
|
|
/>
|
|
{open && suggestions.length > 0 && (
|
|
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md text-sm overflow-hidden">
|
|
{suggestions.map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
className={cn(
|
|
"w-full text-left px-3 py-2 hover:bg-muted transition-colors text-foreground",
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
onChange(s);
|
|
setOpen(false);
|
|
}}
|
|
data-testid={`suggestion-feature-${s}`}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|