Skip to content

Commit 3ca22c4

Browse files
andreea-eneclaude
andcommitted
feat(apollo-wind): add ConnectionPicker
A field and popover for choosing the connection an activity runs with, built on ResourcePicker for the search, folders, keyboard model and footer. ResourcePicker gains what the connection picker needs from it: a row subtitle, with a text form joined into the row's accessible name, search keywords the row does not print, trailing row actions revealed under the cursor, and an error/warning field status. Its trigger classes are exported so pickers built on it read as a pair. What ConnectionPicker adds is what only a connection has: - health on every row and on the field, as its leading dot, its border and a message underneath. A warning is named by its own reason's headline, falling back to "Needs attention" - broken connections sorted to the end of their folder - Fix on broken rows and under the field, Edit on every row and in the field menu, so both are reachable from the keyboard - an Add connection footer link, and with no connections at all the field itself starts one - a field menu to refresh the chosen connection's schema, showing progress while the returned promise is pending - an `error` message for validation, and the field's id and ARIA labelling forwarded to either trigger Rows and the field show the account, falling back to the connection name, since a node bound to the wrong account is the mistake worth catching. The name stays searchable. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 38b54c7 commit 3ca22c4

13 files changed

Lines changed: 1425 additions & 23 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Check, MoreHorizontal, Pencil, RefreshCw } from 'lucide-react';
2+
import { useEffect, useRef, useState } from 'react';
3+
import {
4+
DropdownMenu,
5+
DropdownMenuContent,
6+
DropdownMenuItem,
7+
DropdownMenuTrigger,
8+
} from '@/components/ui/dropdown-menu';
9+
import { cn } from '@/lib';
10+
11+
type RefreshState = 'idle' | 'refreshing' | 'done';
12+
13+
export interface ConnectionActionsMenuProps {
14+
onRefreshSchema?: () => void | Promise<void>;
15+
/**
16+
* Edits the chosen connection. The row's Edit control is a pointer shortcut
17+
* inside an atomic listbox option, so this is the route that reaches it from
18+
* the keyboard and assistive technology.
19+
*/
20+
onEdit?: () => void;
21+
}
22+
23+
/**
24+
* The field's menu of operations on the chosen connection.
25+
*
26+
* Editing closes the menu and hands off at once. The refresh item keeps the
27+
* menu open while it runs, so its progress and its outcome are seen in the
28+
* place the action was taken, then the menu closes on its own. The menu can
29+
* still be dismissed meanwhile: the refresh carries on, and reopening the
30+
* menu shows it still in progress.
31+
*/
32+
export function ConnectionActionsMenu({ onRefreshSchema, onEdit }: ConnectionActionsMenuProps) {
33+
const [open, setOpen] = useState(false);
34+
const [state, setState] = useState<RefreshState>('idle');
35+
const closeTimer = useRef<number | undefined>(undefined);
36+
const mounted = useRef(true);
37+
const openRef = useRef(false);
38+
39+
useEffect(() => {
40+
mounted.current = true;
41+
return () => {
42+
mounted.current = false;
43+
window.clearTimeout(closeTimer.current);
44+
};
45+
}, []);
46+
47+
const handleOpenChange = (next: boolean) => {
48+
openRef.current = next;
49+
setOpen(next);
50+
// A refresh in flight is left to finish; only a settled outcome resets.
51+
if (!next && state !== 'refreshing') {
52+
window.clearTimeout(closeTimer.current);
53+
setState('idle');
54+
}
55+
};
56+
57+
const refresh = async () => {
58+
if (!onRefreshSchema || state !== 'idle') return;
59+
setState('refreshing');
60+
try {
61+
await onRefreshSchema();
62+
} catch {
63+
// The consumer reports the failure. The item goes back to idle so the
64+
// refresh can be tried again from where it was started.
65+
if (mounted.current) setState('idle');
66+
return;
67+
}
68+
if (!mounted.current) return;
69+
// Dismissed while it ran: nobody is watching the item, so there is no
70+
// outcome to show and the menu stays closed.
71+
if (!openRef.current) {
72+
setState('idle');
73+
return;
74+
}
75+
setState('done');
76+
closeTimer.current = window.setTimeout(() => {
77+
openRef.current = false;
78+
setOpen(false);
79+
setState('idle');
80+
}, 900);
81+
};
82+
83+
return (
84+
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
85+
<DropdownMenuTrigger asChild>
86+
<button
87+
type="button"
88+
aria-label="Connection options"
89+
className="grid size-6 cursor-pointer place-items-center rounded text-foreground-muted transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
90+
>
91+
<MoreHorizontal size={14} />
92+
</button>
93+
</DropdownMenuTrigger>
94+
<DropdownMenuContent align="end" className="min-w-44">
95+
{onEdit && (
96+
<DropdownMenuItem disabled={state === 'refreshing'} onSelect={onEdit}>
97+
<Pencil />
98+
Edit connection
99+
</DropdownMenuItem>
100+
)}
101+
{onRefreshSchema && (
102+
<DropdownMenuItem
103+
onSelect={(event) => {
104+
event.preventDefault();
105+
void refresh();
106+
}}
107+
aria-busy={state === 'refreshing' || undefined}
108+
>
109+
{state === 'done' ? (
110+
<Check className="text-success" />
111+
) : (
112+
<RefreshCw className={cn(state === 'refreshing' && 'animate-spin')} />
113+
)}
114+
<span aria-live="polite">
115+
{state === 'refreshing'
116+
? 'Refreshing schema...'
117+
: state === 'done'
118+
? 'Schema refreshed'
119+
: 'Refresh schema'}
120+
</span>
121+
</DropdownMenuItem>
122+
)}
123+
</DropdownMenuContent>
124+
</DropdownMenu>
125+
);
126+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { cn } from '@/lib';
2+
import type { ConnectionStatus } from '../types';
3+
4+
/**
5+
* Fallback names for each state. `warning` covers any degraded state, so its
6+
* name stays generic; a connection's own reason names it more precisely.
7+
*/
8+
export const STATUS_LABELS: Record<ConnectionStatus, string> = {
9+
connected: 'Connected',
10+
warning: 'Needs attention',
11+
broken: 'Broken',
12+
};
13+
14+
const DOT_CLASSES: Record<ConnectionStatus, string> = {
15+
connected: 'bg-success',
16+
warning: 'bg-warning',
17+
broken: 'bg-error',
18+
};
19+
20+
const TEXT_CLASSES: Record<ConnectionStatus, string> = {
21+
connected: 'text-success',
22+
warning: 'text-warning',
23+
broken: 'text-error',
24+
};
25+
26+
export interface ConnectionStatusDotProps {
27+
status: ConnectionStatus;
28+
/** Spoken in place of the dot. Defaults to the status label. */
29+
label?: string;
30+
className?: string;
31+
}
32+
33+
/** The field's leading glyph: health at a glance, named for assistive technology. */
34+
export function ConnectionStatusDot({ status, label, className }: ConnectionStatusDotProps) {
35+
const name = label ?? STATUS_LABELS[status];
36+
return (
37+
<span
38+
role="img"
39+
aria-label={name}
40+
title={name}
41+
className={cn('size-2 shrink-0 rounded-full', DOT_CLASSES[status], className)}
42+
/>
43+
);
44+
}
45+
46+
/**
47+
* A row's second line. The dot takes the text colour, so the pair can never
48+
* disagree about which state they show.
49+
*/
50+
export function ConnectionStatusLine({
51+
status,
52+
label,
53+
}: {
54+
status: ConnectionStatus;
55+
label?: string;
56+
}) {
57+
return (
58+
<span className={cn('flex min-w-0 items-center gap-1.5 font-medium', TEXT_CLASSES[status])}>
59+
<span className="size-1.5 shrink-0 rounded-full bg-current" />
60+
<span className="min-w-0 truncate">{label ?? STATUS_LABELS[status]}</span>
61+
</span>
62+
);
63+
}

0 commit comments

Comments
 (0)