ubiquitous-invention/apps/web/components/teams/invite-recipient-combobox.tsx

350 lines
12 KiB
TypeScript
Raw Normal View History

feat(invites): smart recipient autocomplete combobox (Task 3, done) Closes Task-invite-recipient-autocomplete. The invite dialog's plain email input is replaced with a debounced combobox that surfaces the four real cases — existing member, pending invite, known user from a sibling workspace, brand-new email — before the inviter hits send. Subagent ran in parallel while the main thread shipped Task 2's UI; file-level non-overlap held (subagent stayed in apps/web/components/teams/invite-recipient-combobox.tsx and the shared types; main thread stayed in invite-dialog.tsx and the teams page). This commit folds the subagent's deliverable in plus the two-line wire-up that swaps the input for the combobox. Files (5 by subagent + 1 wire-up by main thread): @tasks/shared: * packages/shared/src/types/invite-suggestions.ts — InviteSuggestion union + pure mergeInviteSuggestions ranker. Lives in shared so client + server consume one type definition. * packages/shared/src/types/invite-suggestions.test.ts — 9 vitest cases covering kind ordering, dedupe (known_user vs member by userId, vs pending_invite by lowercased email), new_email suppression when other kinds cover the typed address, the 10- result limit, and email normalization. * packages/shared/src/types/index.ts — re-export. apps/web: * apps/web/server/routers/invites.ts — new `suggestRecipient` procedure on workspaceProcedure (owner/admin only). Implements the four kinds with the tenancy fence wired as a two-step query: first SELECT DISTINCT workspace_id FROM workspace_members WHERE user_id = inviter (the inviter's workspace pool), then inArray(workspaceMembers.workspaceId, pool) + ne(users.id, inviter) on the candidate join. Read the procedure JSDoc for the full set of invariants. All user-typed patterns escape through escapeIlike with the ESCAPE '\\' clause (mirrors search.ts). No existing exports modified. * apps/web/components/teams/invite-recipient-combobox.tsx — standalone controlled combobox. 200ms debounce, min-2-char gate, distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard nav, outside-click close. * apps/web/components/teams/invite-dialog.tsx (wire-up) — Input swapped for InviteRecipientCombobox. Added an onFocusExistingMember prop so a future teams-page integration can scroll/focus the matching row when a `member` suggestion is picked; for now the dialog just closes cleanly on member-pick. Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental from earlier teams-page work, none from this task's files); 6/6 type-check; 23/23 tests (14 baseline + 9 new). Acceptance criteria all met except the live-DB tenancy-fence integration test (skipped because apps/web has no vitest harness; unblocked by Task-bootstrap-vitest-for-apps-web P2). Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:39:09 -04:00
"use client";
import * as React from "react";
import { Clock, Loader2, Mail, UserCheck, UserPlus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import type { InviteSuggestion } from "@tasks/shared";
/**
* Standalone combobox for the invite recipient field. Fully controlled
* the parent owns the raw email string via `value`/`onChange` and may
* react to a non-`new_email` pick (existing member, pending invite,
* known user from a sibling workspace) via `onSelectSuggestion`.
*
* Behavior:
* - Debounces the query 200ms before hitting `invites.suggestRecipient`.
* - Shows nothing for queries shorter than 2 characters.
* - Distinct row styling per `kind` so the inviter can tell at a glance
* whether they're about to send a fresh invite vs. re-discover someone
* who's already in the workspace.
* - Keyboard: ArrowUp/Down to highlight, Enter to pick, Esc to close.
* - For a `new_email` pick we just fill the input via `onChange` and let
* the parent submit the form. For every other kind we also call
* `onSelectSuggestion` so the parent can e.g. close the dialog and
* scroll to the matching member row.
*/
export interface InviteRecipientComboboxProps {
workspaceSlug: string;
value: string;
onChange: (email: string) => void;
/**
* Called when the user picks an existing-member / pending-invite /
* known-user row. NOT called for `new_email`; for that kind, only
* `onChange(suggestion.email)` runs.
*/
onSelectSuggestion?: (suggestion: InviteSuggestion) => void;
placeholder?: string;
disabled?: boolean;
/** Optional id so an external `<label htmlFor>` can target the input. */
inputId?: string;
}
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
function formatExpires(expiresAt: Date): string {
const ms = expiresAt.getTime() - Date.now();
if (ms <= 0) return "expired";
const minutes = Math.round(ms / (1000 * 60));
if (minutes < 60) return minutes <= 1 ? "in 1 minute" : `in ${minutes} minutes`;
const hours = Math.round(minutes / 60);
if (hours < 24) return hours === 1 ? "in 1 hour" : `in ${hours} hours`;
const days = Math.round(hours / 24);
return days === 1 ? "in 1 day" : `in ${days} days`;
}
function suggestionKey(s: InviteSuggestion): string {
switch (s.kind) {
case "member":
return `member:${s.userId}`;
case "pending_invite":
return `invite:${s.inviteId}`;
case "known_user":
return `known:${s.userId}`;
case "new_email":
return `new:${s.email}`;
}
}
export function InviteRecipientCombobox({
workspaceSlug,
value,
onChange,
onSelectSuggestion,
placeholder = "alex@example.com",
disabled,
inputId,
}: InviteRecipientComboboxProps) {
const [open, setOpen] = React.useState(false);
const [activeIdx, setActiveIdx] = React.useState(0);
const listboxId = React.useId();
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const debouncedQuery = useDebounced(value.trim(), 200);
const enabled = debouncedQuery.length >= 2;
const suggestQuery = api.invites.suggestRecipient.useQuery(
{ workspace: workspaceSlug, query: debouncedQuery },
{ enabled, staleTime: 10_000 },
);
const suggestions = React.useMemo<InviteSuggestion[]>(
() => (enabled ? (suggestQuery.data ?? []) : []),
[enabled, suggestQuery.data],
);
React.useEffect(() => {
setActiveIdx(0);
}, [suggestions]);
React.useEffect(() => {
if (!enabled) setOpen(false);
}, [enabled]);
// Close when focus leaves the entire combobox (input + popup). Defer to
// the next tick so a click inside the popup registers before we close.
React.useEffect(() => {
if (!open) return;
const handleClick = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (containerRef.current && !containerRef.current.contains(target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const handleSelect = (suggestion: InviteSuggestion) => {
onChange(suggestion.email);
if (suggestion.kind !== "new_email") {
onSelectSuggestion?.(suggestion);
}
setOpen(false);
inputRef.current?.focus();
};
const popupOpen = open && enabled && (suggestQuery.isFetching || suggestions.length > 0);
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === "ArrowDown") {
if (suggestions.length === 0) return;
event.preventDefault();
setOpen(true);
setActiveIdx((i) => (i + 1) % suggestions.length);
} else if (event.key === "ArrowUp") {
if (suggestions.length === 0) return;
event.preventDefault();
setOpen(true);
setActiveIdx((i) => (i - 1 + suggestions.length) % suggestions.length);
} else if (event.key === "Enter") {
if (!popupOpen || suggestions.length === 0) return;
event.preventDefault();
const pick = suggestions[activeIdx];
if (pick) handleSelect(pick);
} else if (event.key === "Escape") {
if (popupOpen) {
event.preventDefault();
setOpen(false);
}
}
};
const activeId = popupOpen && suggestions[activeIdx]
? `${listboxId}-${suggestionKey(suggestions[activeIdx])}`
: undefined;
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
id={inputId}
type="email"
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(event) => {
onChange(event.target.value);
setOpen(true);
}}
onFocus={() => {
if (enabled) setOpen(true);
}}
onKeyDown={onKeyDown}
role="combobox"
aria-expanded={popupOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
/>
{popupOpen ? (
<div
className={cn(
"absolute left-0 right-0 top-full z-50 mt-2 rounded-lg",
"border border-border bg-popover p-1 shadow-lg outline-none",
)}
>
<ul
id={listboxId}
role="listbox"
aria-label="Invite recipient suggestions"
className="max-h-72 overflow-y-auto"
>
{suggestQuery.isFetching && suggestions.length === 0 ? (
<li className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" aria-hidden />
Searching
</li>
) : null}
{suggestions.map((suggestion, idx) => (
<SuggestionRow
key={suggestionKey(suggestion)}
id={`${listboxId}-${suggestionKey(suggestion)}`}
suggestion={suggestion}
active={idx === activeIdx}
onMouseEnter={() => setActiveIdx(idx)}
onClick={() => handleSelect(suggestion)}
/>
))}
</ul>
</div>
) : null}
</div>
);
}
interface SuggestionRowProps {
id: string;
suggestion: InviteSuggestion;
active: boolean;
onMouseEnter: () => void;
onClick: () => void;
}
function SuggestionRow({
id,
suggestion,
active,
onMouseEnter,
onClick,
}: SuggestionRowProps) {
const buttonBase = cn(
"flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-xs transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active && "bg-accent",
);
switch (suggestion.kind) {
case "member":
return (
<li id={id} role="option" aria-selected={active}>
<button
type="button"
// Prevent input from losing focus on mousedown so the click
// still fires before the outside-click handler closes the popup.
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={onMouseEnter}
onClick={onClick}
className={cn(buttonBase, "opacity-70 hover:opacity-100")}
>
<UserCheck className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{suggestion.name ?? suggestion.email}
</div>
<div className="truncate text-[11px] text-muted-foreground">
Already a member · click to focus their row
</div>
</div>
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
{suggestion.role}
</span>
</button>
</li>
);
case "pending_invite":
return (
<li id={id} role="option" aria-selected={active}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={onMouseEnter}
onClick={onClick}
className={buttonBase}
>
<Clock className="size-4 shrink-0 text-amber-600" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{suggestion.email}
</div>
<div className="truncate text-[11px] text-muted-foreground">
Invite already sent · expires {formatExpires(suggestion.expiresAt)}
</div>
</div>
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
{suggestion.role}
</span>
</button>
</li>
);
case "known_user":
return (
<li id={id} role="option" aria-selected={active}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={onMouseEnter}
onClick={onClick}
className={buttonBase}
>
<UserPlus className="size-4 shrink-0 text-emerald-600" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{suggestion.name ?? suggestion.email}
</div>
<div className="truncate text-[11px] text-muted-foreground">
Invite as member · in {suggestion.sharedWorkspaceCount} of your
{" "}workspace{suggestion.sharedWorkspaceCount === 1 ? "" : "s"}
</div>
</div>
</button>
</li>
);
case "new_email":
return (
<li id={id} role="option" aria-selected={active}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={onMouseEnter}
onClick={onClick}
className={buttonBase}
>
<Mail className="size-4 shrink-0 text-primary" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
Send invite to {suggestion.email}
</div>
<div className="truncate text-[11px] text-muted-foreground">
No existing account · a fresh invite link will be created
</div>
</div>
</button>
</li>
);
}
}