Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions app/address/[address]/files/__tests__/page-client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { render, screen, waitFor } from '@testing-library/react';
import { vi } from 'vitest';

import MetaplexFilesPageClient from '../page-client';

// Mock the dependencies
vi.mock('@/app/components/common/LoadingCard', () => ({
LoadingCard: () => <div data-testid="loading-card">Loading...</div>,
}));

vi.mock('@components/account/ParsedAccountRenderer', () => ({
ParsedAccountRenderer: ({
address,
renderComponent: RenderComponentProp,
}: {
address: string;
renderComponent: React.ComponentType<any>;
}) => {
// Mock account data for testing
const mockAccount = {
data: {
parsed: {
nftData: {
metadata: {
data: {
uri: 'https://example.com/metadata.json',
},
},
},
parsed: { type: 'mint' },
program: 'spl-token',
},
},
pubkey: { toString: () => address },
};
const mockOnNotFound = vi.fn(() => {
throw new Error('Not found');
});

return (
<div data-testid="parsed-account-renderer">
<RenderComponentProp account={mockAccount} onNotFound={mockOnNotFound} />
</div>
);
},
}));

vi.mock('@components/account/MetaplexFilesCard', () => ({
MetaplexFilesCard: ({ account, onNotFound: _onNotFound }: { account: any; onNotFound: () => never }) => (
<div data-testid="metaplex-files-card">MetaplexFilesCard for {account?.pubkey?.toString()}</div>
),
}));

describe('MetaplexFilesPageClient', () => {
it('renders ParsedAccountRenderer with correct address', () => {
const testAddress = 'DemoKeypair1111111111111111111111111111111111';
const props = {
params: {
address: testAddress,
},
};

render(<MetaplexFilesPageClient {...props} />);

expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument();
});

it('renders MetaplexFilesCard within Suspense boundary', async () => {
const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const props = {
params: {
address: testAddress,
},
};

render(<MetaplexFilesPageClient {...props} />);

await waitFor(() => {
expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument();
});
});

it('renders the correct component structure', () => {
const testAddress = 'DemoKeypair1111111111111111111111111111111111';
const props = {
params: {
address: testAddress,
},
};

render(<MetaplexFilesPageClient {...props} />);

// Should render the parsed account renderer with the component structure
expect(screen.getByTestId('parsed-account-renderer')).toBeInTheDocument();
expect(screen.getByTestId('metaplex-files-card')).toBeInTheDocument();
});
});
41 changes: 41 additions & 0 deletions app/address/[address]/files/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';

import MetaplexFilesPage from '../page';

// Mock the page-client component
vi.mock('../page-client', () => ({
default: ({ params }: { params: { address: string } }) => (
<div data-testid="metaplex-files-page-client">Metaplex Files for address: {params.address}</div>
),
}));

describe('MetaplexFilesPage', () => {
it('renders the page with correct props', () => {
const props = {
params: {
address: 'DemoKeypair1111111111111111111111111111111111',
},
};

render(<MetaplexFilesPage {...props} />);

expect(screen.getByTestId('metaplex-files-page-client')).toBeInTheDocument();
expect(
screen.getByText('Metaplex Files for address: DemoKeypair1111111111111111111111111111111111')
).toBeInTheDocument();
});

it('passes address parameter correctly', () => {
const testAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const props = {
params: {
address: testAddress,
},
};

render(<MetaplexFilesPage {...props} />);

expect(screen.getByText(`Metaplex Files for address: ${testAddress}`)).toBeInTheDocument();
});
});
28 changes: 28 additions & 0 deletions app/address/[address]/files/page-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import { MetaplexFilesCard } from '@components/account/MetaplexFilesCard';
import { ParsedAccountRenderer } from '@components/account/ParsedAccountRenderer';
import React, { Suspense } from 'react';

import { LoadingCard } from '@/app/components/common/LoadingCard';

type Props = Readonly<{
params: {
address: string;
};
}>;

function MetaplexFilesCardRenderer({
account,
onNotFound,
}: React.ComponentProps<React.ComponentProps<typeof ParsedAccountRenderer>['renderComponent']>) {
return (
<Suspense fallback={<LoadingCard />}>
{<MetaplexFilesCard account={account} onNotFound={onNotFound} />}
</Suspense>
);
}

export default function MetaplexFilesPageClient({ params: { address } }: Props) {
return <ParsedAccountRenderer address={address} renderComponent={MetaplexFilesCardRenderer} />;
}
11 changes: 11 additions & 0 deletions app/address/[address]/files/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import MetaplexFilesPageClient from './page-client';

type Props = Readonly<{
params: {
address: string;
};
}>;

export default function MetaplexFilesPage(props: Props) {
return <MetaplexFilesPageClient {...props} />;
}
17 changes: 14 additions & 3 deletions app/address/[address]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Instructions',
},
],
'spl-token-2022:mint:metaplexNFT': [
'spl-token-2022:mint:metaplex': [
{
path: 'metadata',
slug: 'metadata',
Expand All @@ -119,6 +119,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
slug: 'attributes',
title: 'Attributes',
},
{
path: 'files',
slug: 'files',
title: 'Files',
},
],
'spl-token:mint': [
{
Expand All @@ -132,7 +137,7 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
title: 'Instructions',
},
],
'spl-token:mint:metaplexNFT': [
'spl-token:mint:metaplex': [
{
path: 'metadata',
slug: 'metadata',
Expand All @@ -143,6 +148,11 @@ const TABS_LOOKUP: { [id: string]: Tab[] } = {
slug: 'attributes',
title: 'Attributes',
},
{
path: 'files',
slug: 'files',
title: 'Files',
},
],
stake: [
{
Expand Down Expand Up @@ -380,6 +390,7 @@ export type MoreTabs =
| 'rewards'
| 'metadata'
| 'attributes'
| 'files'
| 'domains'
| 'security'
| 'idl'
Expand Down Expand Up @@ -443,7 +454,7 @@ function getTabs(pubkey: PublicKey, account: Account): TabComponent[] {
(programTypeKey === 'spl-token:mint' || programTypeKey == 'spl-token-2022:mint') &&
(parsedData as TokenProgramData).nftData
) {
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplexNFT`]);
tabs.push(...TABS_LOOKUP[`${programTypeKey}:metaplex`]);
}

// Compressed NFT tabs
Expand Down
103 changes: 103 additions & 0 deletions app/components/account/MetaplexFilesCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ErrorCard } from '@components/common/ErrorCard';
import { LoadingCard } from '@components/common/LoadingCard';
import { Account, isTokenProgramData } from '@providers/accounts';
import React from 'react';

import { getProxiedUri } from '@/app/features/metadata/utils';
import { useCluster } from '@/app/providers/cluster';
import { useCompressedNft } from '@/app/providers/compressed-nft';

interface File {
uri: string;
type: string;
cdn?: boolean;
}

export function MetaplexFilesCard({ account, onNotFound }: { account?: Account; onNotFound: () => never }) {
const { url } = useCluster();
const compressedNft = useCompressedNft({ address: account?.pubkey.toString() ?? '', url });

const parsedData = account?.data?.parsed;
if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint' || !parsedData.nftData) {
if (compressedNft && compressedNft.compression.compressed) {
return <NormalMetaplexFilesCard metadataUri={compressedNft.content.json_uri} />;
}
return onNotFound();
}
return <NormalMetaplexFilesCard metadataUri={parsedData.nftData.metadata.data.uri} />;
}

function NormalMetaplexFilesCard({ metadataUri }: { metadataUri: string }) {
const [files, setFiles] = React.useState<File[]>([]);
const [status, setStatus] = React.useState<'loading' | 'success' | 'error'>('loading');

React.useEffect(() => {
async function fetchMetadataFiles() {
try {
const response = await fetch(getProxiedUri(metadataUri));
if (!response.ok) {
throw new Error('Failed to fetch metadata');
}

const metadata = await response.json();
// Verify if the attributes value is an array
if (Array.isArray(metadata.properties.files)) {
// Filter files to keep objects matching schema
const filteredFiles = metadata.properties.files.filter((file: any) => {
return (
typeof file === 'object' && typeof file.uri === 'string' && typeof file.type === 'string'
);
});

setFiles(filteredFiles);
setStatus('success');
} else {
throw new Error('Files is not an array');
}
} catch (error) {
setStatus('error');
}
}
fetchMetadataFiles();
}, [metadataUri]);

if (status === 'loading') {
return <LoadingCard />;
}

if (status === 'error') {
return <ErrorCard text="Failed to fetch files" />;
}

const filesList: React.ReactNode[] = files.map(({ uri, type }) => {
return (
<tr key={`${uri}:${type}`}>
<td>
<a href={uri} target="_blank" rel="noopener noreferrer">
{uri}
</a>
</td>
<td>{type}</td>
</tr>
);
});

return (
<div className="card">
<div className="card-header align-items-center">
<h3 className="card-header-title">Files</h3>
</div>
<div className="table-responsive mb-0">
<table className="table table-sm table-nowrap card-table">
<thead>
<tr>
<th className="text-muted w-1">File URI</th>
<th className="text-muted w-1">File Type</th>
</tr>
</thead>
<tbody className="list">{filesList}</tbody>
</table>
</div>
</div>
);
}
Loading
Loading