Skip to content

Commit eac9889

Browse files
committed
feat: add new files section for Metaplex tokens
1 parent 887664c commit eac9889

File tree

7 files changed

+663
-3
lines changed

7 files changed

+663
-3
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import { vi } from 'vitest';
3+
4+
import MetaplexFilesPageClient from '../page-client';
5+
6+
// Mock the dependencies
7+
vi.mock('@/app/components/common/LoadingCard', () => ({
8+
LoadingCard: () => <div data-testid="loading-card">Loading...</div>,
9+
}));
10+
11+
vi.mock('@components/account/ParsedAccountRenderer', () => ({
12+
ParsedAccountRenderer: ({
13+
address,
14+
renderComponent: RenderComponentProp,
15+
}: {
16+
address: string;
17+
renderComponent: React.ComponentType<any>;
18+
}) => {
19+
// Mock account data for testing
20+
const mockAccount = {
21+
data: {
22+
parsed: {
23+
nftData: {
24+
metadata: {
25+
data: {
26+
uri: 'https://example.com/metadata.json',
27+
},
28+
},
29+
},
30+
parsed: { type: 'mint' },
31+
program: 'spl-token',
32+
},
33+
},
34+
pubkey: { toString: () => address },
35+
};
36+
const mockOnNotFound = vi.fn(() => {
37+
throw new Error('Not found');
38+
});
39+
40+
return (
41+
<div data-testid="parsed-account-renderer">
42+
<RenderComponentProp account={mockAccount} onNotFound={mockOnNotFound} />
43+
</div>
44+
);
45+
},
46+
}));
47+
48+
vi.mock('@components/account/MetaplexFilesCard', () => ({
49+
MetaplexFilesCard: ({ account, onNotFound: _onNotFound }: { account: any; onNotFound: () => never }) => (
50+
<div data-testid="metaplex-files-card">MetaplexFilesCard for {account?.pubkey?.toString()}</div>
51+
),
52+
}));
53+
54+
describe('MetaplexFilesPageClient', () => {
55+
it('renders ParsedAccountRenderer with correct address', () => {
56+
const testAddress = 'DemoKeypair1111111111111111111111111111111111';
57+
const props = {
58+
params: {
59+
address: testAddress,
60+
},
61+
};
62+
63+
render(<MetaplexFilesPageClient {...props} />);
64+
65+
expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument();
66+
});
67+
68+
it('renders MetaplexFilesCard within Suspense boundary', async () => {
69+
const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
70+
const props = {
71+
params: {
72+
address: testAddress,
73+
},
74+
};
75+
76+
render(<MetaplexFilesPageClient {...props} />);
77+
78+
await waitFor(() => {
79+
expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument();
80+
});
81+
});
82+
83+
it('renders the correct component structure', () => {
84+
const testAddress = 'DemoKeypair1111111111111111111111111111111111';
85+
const props = {
86+
params: {
87+
address: testAddress,
88+
},
89+
};
90+
91+
render(<MetaplexFilesPageClient {...props} />);
92+
93+
// Should render the parsed account renderer with the component structure
94+
expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument();
95+
expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument();
96+
});
97+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { vi } from 'vitest';
3+
4+
import MetaplexFilesPage from '../page';
5+
6+
// Mock the page-client component
7+
vi.mock('../page-client', () => ({
8+
default: ({ params }: { params: { address: string } }) => (
9+
<div data-testid="metaplex-files-page-client">Metaplex Files for address: {params.address}</div>
10+
),
11+
}));
12+
13+
describe('MetaplexFilesPage', () => {
14+
it('renders the page with correct props', () => {
15+
const props = {
16+
params: {
17+
address: 'DemoKeypair1111111111111111111111111111111111',
18+
},
19+
};
20+
21+
render(<MetaplexFilesPage {...props} />);
22+
23+
expect(screen.getByTestId('metaplex-files-page-client')).toBeInTheDocument();
24+
expect(
25+
screen.getByText('Metaplex Files for address: DemoKeypair1111111111111111111111111111111111')
26+
).toBeInTheDocument();
27+
});
28+
29+
it('passes address parameter correctly', () => {
30+
const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
31+
const props = {
32+
params: {
33+
address: testAddress,
34+
},
35+
};
36+
37+
render(<MetaplexFilesPage {...props} />);
38+
39+
expect(screen.getByText(`Metaplex Files for address: ${testAddress}`)).toBeInTheDocument();
40+
});
41+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client';
2+
3+
import { MetaplexFilesCard } from '@components/account/MetaplexFilesCard';
4+
import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
5+
import React, { Suspense } from 'react';
6+
7+
import { LoadingCard } from '@/app/components/common/LoadingCard';
8+
9+
type Props = Readonly<{
10+
params: {
11+
address: string;
12+
};
13+
}>;
14+
15+
function MetaplexFilesCardRenderer({
16+
account,
17+
onNotFound,
18+
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
19+
return (
20+
<Suspense fallback={<LoadingCard />}>
21+
{<MetaplexFilesCard account={account} onNotFound={onNotFound} />}
22+
</Suspense>
23+
);
24+
}
25+
26+
export default function MetaplexFilesPageClient({ params: { address } }: Props) {
27+
return <ParsedAccountRenderer address={address} renderComponent={MetaplexFilesCardRenderer} />;
28+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import MetaplexFilesPageClient from './page-client';
2+
3+
type Props = Readonly<{
4+
params: {
5+
address: string;
6+
};
7+
}>;
8+
9+
export default function MetaplexFilesPage(props: Props) {
10+
return <MetaplexFilesPageClient {...props} />;
11+
}

app/address/[address]/layout.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
108108
title: 'Instructions',
109109
},
110110
],
111-
'spl-token-2022:mint:metaplexNFT': [
111+
'spl-token-2022:mint:metaplex': [
112112
{
113113
path: 'metadata',
114114
slug: 'metadata',
@@ -119,6 +119,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
119119
slug: 'attributes',
120120
title: 'Attributes',
121121
},
122+
{
123+
path: 'files',
124+
slug: 'files',
125+
title: 'Files',
126+
},
122127
],
123128
'spl-token:mint': [
124129
{
@@ -132,7 +137,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
132137
title: 'Instructions',
133138
},
134139
],
135-
'spl-token:mint:metaplexNFT': [
140+
'spl-token:mint:metaplex': [
136141
{
137142
path: 'metadata',
138143
slug: 'metadata',
@@ -143,6 +148,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
143148
slug: 'attributes',
144149
title: 'Attributes',
145150
},
151+
{
152+
path: 'files',
153+
slug: 'files',
154+
title: 'Files',
155+
},
146156
],
147157
stake: [
148158
{
@@ -380,6 +390,7 @@ export type MoreTabs =
380390
| 'rewards'
381391
| 'metadata'
382392
| 'attributes'
393+
| 'files'
383394
| 'domains'
384395
| 'security'
385396
| 'idl'
@@ -443,7 +454,7 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
443454
(programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') &&
444455
(parsedData as TokenProgramData).nftData
445456
) {
446-
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]);
457+
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplex`]);
447458
}
448459

449460
// Compressed NFT tabs
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { ErrorCard } from '@components/common/ErrorCard';
2+
import { LoadingCard } from '@components/common/LoadingCard';
3+
import { Account, isTokenProgramData } from '@providers/accounts';
4+
import React from 'react';
5+
6+
import { getProxiedUri } from '@/app/features/metadata/utils';
7+
import { useCluster } from '@/app/providers/cluster';
8+
import { useCompressedNft } from '@/app/providers/compressed-nft';
9+
10+
interface File {
11+
uri: string;
12+
type: string;
13+
cdn?: boolean;
14+
}
15+
16+
export function MetaplexFilesCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) {
17+
const { url } = useCluster();
18+
const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url });
19+
20+
const parsedData = account?.data?.parsed;
21+
if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
22+
if (compressedNft && compressedNft.compression.compressed) {
23+
return <NormalMetaplexFilesCard metadataUri={compressedNft.content.json_uri} />;
24+
}
25+
return onNotFound();
26+
}
27+
return <NormalMetaplexFilesCard metadataUri={parsedData.nftData.metadata.data.uri} />;
28+
}
29+
30+
function NormalMetaplexFilesCard({ metadataUri }: { metadataUri: string }) {
31+
const [files, setFiles] = React.useState<File[]>([]);
32+
const [status, setStatus] = React.useState<'loading' | 'success' | 'error'>('loading');
33+
34+
async function fetchMetadataFiles() {
35+
try {
36+
const response = await fetch(getProxiedUri(metadataUri));
37+
const metadata = await response.json();
38+
// Verify if the attributes value is an array
39+
if (Array.isArray(metadata.properties.files)) {
40+
// Filter files to keep objects matching schema
41+
const filteredFiles = metadata.properties.files.filter((file: any) => {
42+
return typeof file === 'object' && typeof file.uri === 'string' && typeof file.type === 'string';
43+
});
44+
45+
setFiles(filteredFiles);
46+
setStatus('success');
47+
} else {
48+
throw new Error('Files is not an array');
49+
}
50+
} catch (error) {
51+
setStatus('error');
52+
}
53+
}
54+
55+
React.useEffect(() => {
56+
fetchMetadataFiles();
57+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
58+
59+
if (status === 'loading') {
60+
return <LoadingCard />;
61+
}
62+
63+
if (status === 'error') {
64+
return <ErrorCard text="Failed to fetch files" />;
65+
}
66+
67+
const filesList: React.ReactNode[] = files.map(({ uri, type }) => {
68+
return (
69+
<tr key={`${uri}:${type}`}>
70+
<td>
71+
<a href={uri} target="_blank" rel="noopener noreferrer">
72+
{uri}
73+
</a>
74+
</td>
75+
<td>{type}</td>
76+
</tr>
77+
);
78+
});
79+
80+
return (
81+
<div className="card">
82+
<div className="card-header align-items-center">
83+
<h3 className="card-header-title">Files</h3>
84+
</div>
85+
<div className="table-responsive mb-0">
86+
<table className="table table-sm table-nowrap card-table">
87+
<thead>
88+
<tr>
89+
<th className="text-muted w-1">File URI</th>
90+
<th className="text-muted w-1">File Type</th>
91+
</tr>
92+
</thead>
93+
<tbody className="list">{filesList}</tbody>
94+
</table>
95+
</div>
96+
</div>
97+
);
98+
}

0 commit comments

Comments
 (0)