Skip to content

Commit 9568ba0

Browse files
authored
chore(dashboard): reuse existing claude creds when migrating demo agent fixes NV-7856 (#11343)
1 parent d8b2fc0 commit 9568ba0

3 files changed

Lines changed: 383 additions & 43 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { type IIntegration } from '@novu/shared';
2+
import { AnimatePresence, motion } from 'motion/react';
3+
import { useEffect, useMemo, useRef, useState } from 'react';
4+
import { RiAddLine, RiAlertLine, RiCheckLine, RiExpandUpDownLine } from 'react-icons/ri';
5+
import {
6+
DemoCredentialBadge,
7+
DemoCredentialDropdownItem,
8+
} from '@/components/integrations/components/demo-credential-badge';
9+
import { isDemoIntegration } from '@/components/integrations/components/utils/helpers';
10+
import { Badge } from '@/components/primitives/badge';
11+
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/primitives/command';
12+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover';
13+
import { cn } from '@/utils/ui';
14+
import { getClaudeManagedAgentIntegrations } from './claude-managed-integrations';
15+
import type { ConnectorOption } from './connector-options';
16+
17+
const GROUP_HEADING_CLASSNAME =
18+
'**:[[cmdk-group-heading]]:text-text-soft **:[[cmdk-group-heading]]:text-label-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:leading-4 **:[[cmdk-group-heading]]:px-1 **:[[cmdk-group-heading]]:py-1';
19+
20+
export type IntegrationDropdownStatus = 'idle' | 'valid' | 'missing';
21+
22+
type IntegrationDropdownProps = {
23+
connector: ConnectorOption;
24+
selectedIntegrationId?: string;
25+
integrations: IIntegration[] | undefined;
26+
status?: IntegrationDropdownStatus;
27+
showStatusBadge?: boolean;
28+
disabled?: boolean;
29+
setupLabel?: string;
30+
emptyLabel?: string;
31+
/** When true, demo credentials (e.g. `NovuAnthropic`) are hidden from the dropdown. */
32+
excludeDemo?: boolean;
33+
onSelectIntegration: (integration: IIntegration) => void;
34+
onRequestSetupCredentials: () => void;
35+
};
36+
37+
function StatusBadge({ status }: { status: IntegrationDropdownStatus }) {
38+
if (status === 'valid') {
39+
return (
40+
<span className="bg-success-base flex size-3.5 items-center justify-center rounded-full">
41+
<RiCheckLine className="text-static-white size-2.5" aria-hidden />
42+
</span>
43+
);
44+
}
45+
46+
if (status === 'missing') {
47+
return <RiAlertLine className="text-warning-base size-3.5" aria-hidden />;
48+
}
49+
50+
return null;
51+
}
52+
53+
function TriggerGlyph({ status, showBadge }: { status: IntegrationDropdownStatus; showBadge: boolean }) {
54+
return (
55+
<span className="flex shrink-0 items-center gap-1">
56+
<AnimatePresence initial={false}>
57+
{showBadge && status !== 'idle' ? (
58+
<motion.span
59+
key="status-badge"
60+
initial={{ opacity: 0, scale: 0.8 }}
61+
animate={{ opacity: 1, scale: 1 }}
62+
exit={{ opacity: 0, scale: 0.8 }}
63+
transition={{ duration: 0.2, ease: 'easeOut' }}
64+
className="flex items-center"
65+
>
66+
<StatusBadge status={status} />
67+
</motion.span>
68+
) : null}
69+
</AnimatePresence>
70+
<RiExpandUpDownLine className="text-text-soft size-3.5" aria-hidden />
71+
</span>
72+
);
73+
}
74+
75+
export function IntegrationDropdown({
76+
connector,
77+
selectedIntegrationId,
78+
integrations,
79+
status = 'idle',
80+
showStatusBadge = false,
81+
disabled,
82+
setupLabel,
83+
emptyLabel = 'No integrations yet.',
84+
excludeDemo = false,
85+
onSelectIntegration,
86+
onRequestSetupCredentials,
87+
}: IntegrationDropdownProps) {
88+
const [open, setOpen] = useState(false);
89+
const prevOpenRef = useRef(false);
90+
91+
useEffect(() => {
92+
prevOpenRef.current = open;
93+
}, [open]);
94+
95+
const matchingIntegrations = useMemo(() => {
96+
if (!connector.providerId) return [];
97+
98+
const all = getClaudeManagedAgentIntegrations(integrations, connector.providerId);
99+
100+
return excludeDemo ? all.filter((integration) => !isDemoIntegration(integration.providerId)) : all;
101+
}, [integrations, connector.providerId, excludeDemo]);
102+
103+
const selectedIntegration = useMemo(
104+
() => matchingIntegrations.find((i) => i._id === selectedIntegrationId),
105+
[matchingIntegrations, selectedIntegrationId]
106+
);
107+
108+
const setupItemLabel = setupLabel ?? `Setup ${connector.providerLabel ?? 'provider'} credentials`;
109+
110+
const renderTriggerSecondary = () => {
111+
if (!selectedIntegration) {
112+
return null;
113+
}
114+
115+
if (isDemoIntegration(selectedIntegration.providerId)) {
116+
return <DemoCredentialBadge className="min-w-0 max-w-full" />;
117+
}
118+
119+
return (
120+
<Badge
121+
color="gray"
122+
variant="lighter"
123+
size="md"
124+
className="border-stroke-weak bg-bg-weak min-w-0 max-w-full truncate rounded-sm border"
125+
>
126+
<span className="truncate">{selectedIntegration.name}</span>
127+
</Badge>
128+
);
129+
};
130+
131+
return (
132+
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
133+
<PopoverTrigger asChild disabled={disabled}>
134+
<button
135+
type="button"
136+
disabled={disabled}
137+
className="border-stroke-soft bg-bg-white flex h-8 w-full items-center justify-between overflow-hidden rounded-md border px-2 py-1 shadow-xs disabled:opacity-60"
138+
>
139+
<div className="flex min-w-0 flex-1 items-center gap-1.5">
140+
{connector.icon}
141+
<span className="text-text-strong text-label-xs shrink-0 font-medium leading-4">{connector.label}</span>
142+
{renderTriggerSecondary()}
143+
</div>
144+
<TriggerGlyph status={status} showBadge={showStatusBadge} />
145+
</button>
146+
</PopoverTrigger>
147+
<PopoverContent
148+
align="start"
149+
sideOffset={4}
150+
portal={false}
151+
className="pointer-events-auto flex max-h-[min(360px,var(--radix-popover-content-available-height))] w-(--radix-popover-trigger-width) min-w-[320px] flex-col overflow-hidden p-0"
152+
>
153+
<Command shouldFilter={false} loop className="flex min-h-0 flex-1 flex-col overflow-hidden">
154+
<CommandList className="min-h-0 flex-1 overflow-y-auto p-1">
155+
<CommandEmpty className="text-text-soft text-label-xs py-4">{emptyLabel}</CommandEmpty>
156+
<CommandGroup className="p-0">
157+
<CommandItem
158+
value={`__setup-${connector.id}`}
159+
onSelect={() => {
160+
onRequestSetupCredentials();
161+
setOpen(false);
162+
}}
163+
className="flex cursor-pointer items-center gap-1.5 rounded-md p-1"
164+
>
165+
{connector.icon}
166+
<span className="text-text-sub text-label-xs min-w-0 flex-1 truncate font-medium leading-4">
167+
{setupItemLabel}
168+
</span>
169+
<RiAddLine className="text-text-soft size-3.5 shrink-0" aria-hidden />
170+
</CommandItem>
171+
</CommandGroup>
172+
173+
{matchingIntegrations.length > 0 ? (
174+
<CommandGroup heading="Existing" className={GROUP_HEADING_CLASSNAME}>
175+
{matchingIntegrations.map((integration) => {
176+
const isCurrent = integration._id === selectedIntegrationId;
177+
const isDemo = isDemoIntegration(integration.providerId);
178+
179+
return (
180+
<CommandItem
181+
key={integration._id}
182+
value={`integration-${integration._id}`}
183+
onSelect={() => {
184+
onSelectIntegration(integration);
185+
setOpen(false);
186+
}}
187+
className="flex min-w-0 cursor-pointer p-0"
188+
>
189+
{isDemo ? (
190+
<DemoCredentialDropdownItem
191+
providerId={integration.providerId}
192+
providerDisplayName={integration.name}
193+
isSelected={isCurrent}
194+
/>
195+
) : (
196+
<div
197+
className={cn(
198+
'flex w-full min-w-0 items-center gap-1.5 break-normal p-1',
199+
isCurrent && 'bg-bg-muted'
200+
)}
201+
>
202+
{connector.icon}
203+
<span className="text-text-sub text-label-xs min-w-0 flex-1 truncate font-medium leading-4">
204+
{integration.name}
205+
</span>
206+
<span className="text-text-soft text-label-xs shrink-0 truncate font-mono">
207+
{integration.identifier}
208+
</span>
209+
</div>
210+
)}
211+
</CommandItem>
212+
);
213+
})}
214+
</CommandGroup>
215+
) : null}
216+
</CommandList>
217+
</Command>
218+
</PopoverContent>
219+
</Popover>
220+
);
221+
}

0 commit comments

Comments
 (0)