Skip to content

Commit 3713104

Browse files
committed
feat(idl): add castaway sdk action in idl controls
add a castaway generate-sdk action that carries program and idlSource context, and gate external navigation behind a confirmation dialog.\n\nreuse the existing download dropdown interaction pattern for idl actions and update idl card tests for both program-metadata and anchor flows. Refs: HOO-382
1 parent f8d5ef1 commit 3713104

File tree

3 files changed

+154
-6
lines changed

3 files changed

+154
-6
lines changed

app/features/idl/ui/IdlCard.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { cn } from '@shared/utils';
77
import { useEffect, useMemo, useState } from 'react';
88
import { AlertTriangle, ExternalLink } from 'react-feather';
99

10+
import { clusterSlug } from '@/app/utils/cluster';
11+
1012
import { BaseWarningCard } from '../interactive-idl/ui/BaseWarningCard';
1113
import { IdlVariant, useIdlLastTransactionDate } from '../model/use-idl-last-transaction-date';
1214
import { IdlInstructionSection } from './IdlInstructionSection';
@@ -21,6 +23,7 @@ type IdlTab = {
2123

2224
export function IdlCard({ programId }: { programId: string }) {
2325
const { url, cluster } = useCluster();
26+
const network = clusterSlug(cluster);
2427
const { idl } = useAnchorProgram(programId, url, cluster);
2528
const { programMetadataIdl } = useProgramMetadataIdl(programId, url, cluster);
2629
const [activeTabIndex, setActiveTabIndex] = useState<number>();
@@ -149,6 +152,8 @@ export function IdlCard({ programId }: { programId: string }) {
149152
</Badge>
150153
}
151154
idl={activeTab.idl}
155+
idlSource={activeTab.id}
156+
network={network}
152157
programId={programId}
153158
searchStr={searchStr}
154159
onSearchChange={setSearchStr}

app/features/idl/ui/IdlSection.tsx

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,89 @@
11
import type { SupportedIdl } from '@entities/idl';
22
import { Button } from '@shared/ui/button';
3+
import {
4+
Dialog,
5+
DialogClose,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from '@shared/ui/dialog';
312
import { Input } from '@shared/ui/input';
413
import { Label } from '@shared/ui/label';
514
import { Switch } from '@shared/ui/switch';
6-
import { useMemo, useState } from 'react';
7-
import { Code, Download, Search } from 'react-feather';
15+
import { useEffect, useMemo, useRef, useState } from 'react';
16+
import { AlertCircle, Code, Download, ExternalLink, Search } from 'react-feather';
817

918
import { WalletProvider } from '@/app/providers/wallet-provider';
1019
import { triggerDownload } from '@/app/shared/lib/triggerDownload';
1120

21+
import { type IdlVariant } from '../model/use-idl-last-transaction-date';
1222
import { IdlRenderer } from './IdlRenderer';
1323

1424
export function IdlSection({
1525
idl,
1626
badge,
1727
programId,
28+
idlSource,
29+
network,
1830
searchStr,
1931
onSearchChange,
2032
}: {
2133
idl: SupportedIdl;
2234
badge: React.ReactNode;
2335
programId: string;
36+
idlSource: IdlVariant;
37+
network: string;
2438
searchStr: string;
2539
onSearchChange: (str: string) => void;
2640
}) {
2741
const [isExpanded, setIsExpanded] = useState(false);
2842
const [isRawIdlView, setIsRawIdlView] = useState(false);
43+
const [isCastawayDialogOpen, setIsCastawayDialogOpen] = useState(false);
44+
const downloadDropdownRef = useRef<HTMLButtonElement>(null);
2945

3046
const idlBase64 = useMemo(() => {
3147
return Buffer.from(JSON.stringify(idl, null, 2)).toString('base64');
3248
}, [idl]);
49+
const castawayUrl = useMemo(() => {
50+
const params = new URLSearchParams({ idlSource, network, program: programId });
51+
return `https://www.castaway.lol/?${params.toString()}`;
52+
}, [idlSource, network, programId]);
53+
54+
useEffect(() => {
55+
if (!downloadDropdownRef.current) {
56+
return;
57+
}
58+
59+
let isMounted = true;
60+
let dropdown: { dispose: () => void } | null = null;
61+
62+
void import('bootstrap/js/dist/dropdown').then(module => {
63+
if (!isMounted || !downloadDropdownRef.current) {
64+
return;
65+
}
66+
67+
const BsDropdown = module.default;
68+
dropdown = new BsDropdown(downloadDropdownRef.current, {
69+
popperConfig() {
70+
return { strategy: 'fixed' as const };
71+
},
72+
});
73+
});
74+
75+
return () => {
76+
isMounted = false;
77+
dropdown?.dispose();
78+
};
79+
}, []);
3380

3481
const handleDownloadIdl = () => triggerDownload(idlBase64, `${programId}-idl.json`);
82+
const handleOpenCastawayDialog = () => setIsCastawayDialogOpen(true);
83+
const handleCastawayContinue = () => {
84+
window.open(castawayUrl, '_blank', 'noopener,noreferrer');
85+
setIsCastawayDialogOpen(false);
86+
};
3587

3688
return (
3789
<>
@@ -58,10 +110,55 @@ export function IdlSection({
58110
</div>
59111
)}
60112
<div className="e-flex e-items-center e-gap-2">
61-
<Button variant="outline" size="sm" onClick={handleDownloadIdl}>
62-
<Download size={12} />
63-
Download
64-
</Button>
113+
<div className="dropdown e-overflow-visible">
114+
<Button
115+
variant="outline"
116+
size="sm"
117+
ref={downloadDropdownRef}
118+
data-bs-toggle="dropdown"
119+
type="button"
120+
aria-label="Download"
121+
>
122+
<Download size={12} />
123+
Download
124+
</Button>
125+
<div className="dropdown-menu-end dropdown-menu e-z-10">
126+
<div className="d-flex e-flex-col">
127+
<Button onClick={handleDownloadIdl}>Download IDL</Button>
128+
<Button onClick={handleOpenCastawayDialog}>Generate SDK</Button>
129+
</div>
130+
</div>
131+
</div>
132+
133+
<Dialog open={isCastawayDialogOpen} onOpenChange={setIsCastawayDialogOpen}>
134+
<DialogContent>
135+
<DialogHeader>
136+
<DialogTitle className="e-flex e-items-center e-gap-2">
137+
<AlertCircle className="e-text-destructive" size={16} />
138+
Leaving Solana Explorer
139+
</DialogTitle>
140+
</DialogHeader>
141+
<div className="e-space-y-2 e-pl-6">
142+
<DialogDescription>
143+
You are now leaving Explorer and going to Castaway.
144+
</DialogDescription>
145+
<DialogDescription className="e-break-all e-font-mono e-text-xs">
146+
{castawayUrl}
147+
</DialogDescription>
148+
</div>
149+
<DialogFooter>
150+
<DialogClose asChild>
151+
<Button variant="outline" size="sm">
152+
Cancel
153+
</Button>
154+
</DialogClose>
155+
<Button variant="default" size="sm" onClick={handleCastawayContinue}>
156+
Continue
157+
<ExternalLink size={12} />
158+
</Button>
159+
</DialogFooter>
160+
</DialogContent>
161+
</Dialog>
65162

66163
<Button
67164
variant={isRawIdlView ? 'accent' : 'outline'}

app/features/idl/ui/__tests__/IdlCard.spec.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,29 @@ describe('IdlCard', () => {
126126
});
127127
expect(screen.getByText(/Program Metadata IDL/)).toBeInTheDocument();
128128
expect(screen.queryByText(/Anchor IDL/)).not.toBeInTheDocument();
129+
130+
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
131+
const downloadButton = screen.getByRole('button', { name: 'Download' });
132+
fireEvent.click(downloadButton);
133+
const generateSdkButton = screen.getByRole('button', { name: 'Generate SDK' });
134+
fireEvent.click(generateSdkButton);
135+
136+
expect(screen.getByText('Leaving Solana Explorer')).toBeInTheDocument();
137+
expect(screen.getByText('You are now leaving Explorer and going to Castaway.')).toBeInTheDocument();
138+
139+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
140+
141+
expect(windowOpenSpy).toHaveBeenCalledTimes(1);
142+
const [openedUrl, target, features] = windowOpenSpy.mock.calls[0]!;
143+
const castawayUrl = new URL(openedUrl as string);
144+
expect(castawayUrl.origin).toBe('https://www.castaway.lol');
145+
expect(castawayUrl.pathname).toBe('/');
146+
expect(castawayUrl.searchParams.get('program')).toBe(programId);
147+
expect(castawayUrl.searchParams.get('idlSource')).toBe('program-metadata');
148+
expect(castawayUrl.searchParams.get('network')).toBe('mainnet-beta');
149+
expect(target).toBe('_blank');
150+
expect(features).toBe('noopener,noreferrer');
151+
windowOpenSpy.mockRestore();
129152
});
130153

131154
test('should render IdlCard with Anchor IDL when anchorIdl exists', async () => {
@@ -149,6 +172,29 @@ describe('IdlCard', () => {
149172
});
150173
expect(screen.getByText(/Anchor IDL/)).toBeInTheDocument();
151174
expect(screen.queryByText(/Program Metadata IDL/)).not.toBeInTheDocument();
175+
176+
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
177+
const downloadButton = screen.getByRole('button', { name: 'Download' });
178+
fireEvent.click(downloadButton);
179+
const generateSdkButton = screen.getByRole('button', { name: 'Generate SDK' });
180+
fireEvent.click(generateSdkButton);
181+
182+
expect(screen.getByText('Leaving Solana Explorer')).toBeInTheDocument();
183+
expect(screen.getByText('You are now leaving Explorer and going to Castaway.')).toBeInTheDocument();
184+
185+
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
186+
187+
expect(windowOpenSpy).toHaveBeenCalledTimes(1);
188+
const [openedUrl, target, features] = windowOpenSpy.mock.calls[0]!;
189+
const castawayUrl = new URL(openedUrl as string);
190+
expect(castawayUrl.origin).toBe('https://www.castaway.lol');
191+
expect(castawayUrl.pathname).toBe('/');
192+
expect(castawayUrl.searchParams.get('program')).toBe(programId);
193+
expect(castawayUrl.searchParams.get('idlSource')).toBe('anchor');
194+
expect(castawayUrl.searchParams.get('network')).toBe('mainnet-beta');
195+
expect(target).toBe('_blank');
196+
expect(features).toBe('noopener,noreferrer');
197+
windowOpenSpy.mockRestore();
152198
});
153199

154200
test('should render IdlCard tabs when both IDLs exist', async () => {

0 commit comments

Comments
 (0)