Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add view components #1483

Merged
merged 7 commits into from
Oct 23, 2024
Merged
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
18 changes: 18 additions & 0 deletions src/internal/svg/baseSvg.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const baseSvg = (
<svg
data-testid="ock-baseSvg"
role="img"
aria-label="base"
width="100%"
height="100%"
viewBox="0 0 146 146"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="73" cy="73" r="73" fill="#0052FF" />
<path
d="M73.323 123.729C101.617 123.729 124.553 100.832 124.553 72.5875C124.553 44.343 101.617 21.4463 73.323 21.4463C46.4795 21.4463 24.4581 42.0558 22.271 68.2887H89.9859V76.8864H22.271C24.4581 103.119 46.4795 123.729 73.323 123.729Z"
fill="white"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the fill always meant to be white here? Is the intention for it to NOT update based off of the current theme?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, I didn't think the base logo should change

/>
</svg>
);
30 changes: 30 additions & 0 deletions src/internal/svg/defaultNFTSvg.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { fill } from '../../styles/theme';

export const defaultNFTSvg = (
<svg
data-testid="ock-defaultNFTSvg"
role="img"
aria-label="loading"
width="100%"
height="100%"
viewBox="0 0 527.008 525"
xmlns="http://www.w3.org/2000/svg"
>
<rect
fill="#F3F4F6"
width="100%"
height="100%"
className={fill.alternate}
/>
<path
d="M232.062 258.667C232.062 268.125 236.209 276.614 242.783 282.417H284.675C291.249 276.614 295.396 268.125 295.396 258.667C295.396 241.178 281.218 227 263.729 227C246.24 227 232.062 241.178 232.062 258.667ZM265.697 253.74L276.646 257.792L265.697 261.843L261.646 272.792L257.594 261.843L246.646 257.792L257.594 253.74L261.646 242.792L265.697 253.74ZM274.146 237.792L276.172 243.266L281.646 245.292L276.172 247.317L274.146 252.792L272.12 247.317L266.646 245.292L272.12 243.266L274.146 237.792Z"
fill="#6B7280"
className={fill.defaultReverse}
/>
<path
d="M287.479 288.25H240.813L234.979 297H293.312L287.479 288.25Z"
fill="#6B7280"
className={fill.defaultReverse}
/>
</svg>
);
113 changes: 113 additions & 0 deletions src/nft/components/view/NFTAudio.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import '@testing-library/jest-dom';
import { fireEvent, render } from '@testing-library/react';
import {
type Mock,
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { useNFTContext } from '../NFTProvider';
import { NFTAudio } from './NFTAudio';

const mockAnimationUrl = 'https://example.com/audio.mp3';

vi.mock('../NFTProvider', () => ({
useNFTContext: vi.fn(),
}));

describe('NFTAudio', () => {
beforeEach(() => {
(useNFTContext as Mock).mockReturnValue({
animationUrl: mockAnimationUrl,
});
});

afterEach(() => {
vi.clearAllMocks();
});

it('should render', () => {
const { getByTestId } = render(<NFTAudio />);
expect(getByTestId('ockNFTAudio')).toBeInTheDocument();
});

it('should call onLoading when audio starts loading', () => {
const onLoading = vi.fn();
const { getByTestId } = render(<NFTAudio onLoading={onLoading} />);
fireEvent.loadStart(getByTestId('ockNFTAudio'));
expect(onLoading).toHaveBeenCalledWith(mockAnimationUrl);
});

it('should call onLoaded when audio is loaded', () => {
const onLoaded = vi.fn();
const { getByTestId } = render(<NFTAudio onLoaded={onLoaded} />);
fireEvent.loadedData(getByTestId('ockNFTAudio'));
expect(onLoaded).toHaveBeenCalled();
});

it('should handle error when audio fails to load', () => {
const onError = vi.fn();

const { getByTestId } = render(<NFTAudio onError={onError} />);

fireEvent.error(getByTestId('ockNFTAudio'));

expect(onError).toHaveBeenCalledWith({
error: 'error',
code: 'NmNAc01',
message: 'Error loading audio',
});
});

it('should handle string error when audio fails to load', () => {
const onError = vi.fn();

const { getByTestId } = render(<NFTAudio onError={onError} />);

getByTestId('ockNFTAudio').onerror?.('string error');

expect(onError).toHaveBeenCalledWith({
error: 'string error',
code: 'NmNAc01',
message: 'Error loading audio',
});
});

it('should reset state when audio ends', () => {
const { getByTestId } = render(<NFTAudio />);
const audio = getByTestId('ockNFTAudio') as HTMLAudioElement;

fireEvent.ended(audio);
expect(audio.paused).toBe(true);
});

it('should play when button is clicked', () => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() =>
Promise.resolve(),
);
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});

const { getByRole, getByTestId } = render(<NFTAudio />);
const button = getByRole('button');
const audio = getByTestId('ockNFTAudio') as HTMLAudioElement;

// play
fireEvent.click(button);
expect(audio.play).toHaveBeenCalled();

// pause
fireEvent.click(button);
expect(audio.pause).toHaveBeenCalled();
});

it('should not render if animationUrl is not provided', () => {
(useNFTContext as Mock).mockReturnValue({
animationUrl: null,
});
const { container } = render(<NFTAudio />);
expect(container).toBeEmptyDOMElement();
});
});
95 changes: 95 additions & 0 deletions src/nft/components/view/NFTAudio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { background, cn } from '../../../styles/theme';
import type { NFTError } from '../../types';
import { useNFTContext } from '../NFTProvider';

type NFTAudioReact = {
className?: string;
onLoading?: (mediaUrl: string) => void;
onLoaded?: () => void;
onError?: (error: NFTError) => void;
};

export function NFTAudio({
className,
onLoading,
onLoaded,
onError,
}: NFTAudioReact) {
const { animationUrl } = useNFTContext();
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);

useEffect(() => {
function onEnded() {
setIsPlaying(false);
}

if (animationUrl && audioRef?.current) {
audioRef.current.onloadstart = () => {
onLoading?.(animationUrl);
};

audioRef.current.onloadeddata = () => {
onLoaded?.();
};

audioRef.current.addEventListener('ended', onEnded);

audioRef.current.onerror = (error: string | Event) => {
onError?.({
error: typeof error === 'string' ? error : error.type,
code: 'NmNAc01', // NFT module NFTAudio component 01 error
message: 'Error loading audio',
});
};
}
}, [animationUrl, onLoading, onLoaded, onError]);

const handlePlayPause = useCallback(() => {
if (isPlaying) {
audioRef.current?.pause();
setIsPlaying(false);
} else {
audioRef.current?.play();
setIsPlaying(true);
}
}, [isPlaying]);

if (!animationUrl) {
return null;
}

return (
<div className={cn('max-h-350 w-350 max-w-350', className)}>
<button
type="button"
className={cn(
background.reverse,
'ml-6 inline-flex h-[42px] w-[42px] cursor-pointer items-center justify-center rounded-full',
)}
onClick={handlePlayPause}
>
<div
className={cn(
'ml-px box-border h-[18px] transition-all ease-[100ms] will-change-[border-width]',
'border-transparent border-l-[var(--ock-bg-default)] hover:border-l-[var(--ock-bg-default-hover)]',
{
'border-[length:0_0_0_16px] border-double': isPlaying,
'border-[length:9px_0_9px_16px] border-solid': !isPlaying,
},
)}
/>
</button>
<audio
ref={audioRef}
data-testid="ockNFTAudio"
autoPlay={false}
controls={false}
src={animationUrl}
>
<track kind="captions" />
</audio>
</div>
);
}
120 changes: 120 additions & 0 deletions src/nft/components/view/NFTImage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import '@testing-library/jest-dom';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { act } from 'react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { useNFTContext } from '../NFTProvider';
import { NFTImage } from './NFTImage';

const mockContext = {
imageUrl: 'https://example.com/nft-image.png',
description: 'Test NFT Image',
};

vi.mock('../NFTProvider', () => ({
useNFTContext: vi.fn(),
}));

describe('NFTImage', () => {
beforeEach(() => {
(useNFTContext as Mock).mockReturnValue(mockContext);

// mock Image constructor to call load/error events based on src
Object.defineProperty(global.Image.prototype, 'src', {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: test
set(src) {
if (src === 'error' && this.onerror) {
this.onerror('mocked error');
}
if (src === 'uiEventError' && this.onerror) {
this.onerror({ type: 'mocked error' });
}
if (src === 'loaded' && this.onload) {
this.onload();
}
},
});
});

it('should render default SVG while loading', () => {
const { getByTestId } = render(<NFTImage />);
expect(getByTestId('ock-defaultNFTSvg')).toBeInTheDocument();
});

it('should call onLoading with the image URL', () => {
const onLoading = vi.fn();
render(<NFTImage onLoading={onLoading} />);
expect(onLoading).toHaveBeenCalledWith(mockContext.imageUrl);
});

it('should call onLoaded when the image loads successfully', async () => {
(useNFTContext as Mock).mockReturnValue({
imageUrl: 'loaded',
description: 'Test NFT Image',
});
const onLoaded = vi.fn();
render(<NFTImage onLoaded={onLoaded} />);
await waitFor(() => expect(onLoaded).toHaveBeenCalled());
});

it('should call onError when the image fails to load', async () => {
(useNFTContext as Mock).mockReturnValue({
imageUrl: 'error',
description: 'Test NFT Image',
});
const onError = vi.fn();
render(<NFTImage onError={onError} />);
await waitFor(() =>
expect(onError).toHaveBeenCalledWith({
error: 'mocked error',
code: 'NmNIc01',
message: 'Error loading image',
}),
);
});

it('should call onError when there is a uiEvent error', async () => {
(useNFTContext as Mock).mockReturnValue({
imageUrl: 'uiEventError',
description: 'Test NFT Image',
});
const onError = vi.fn();
render(<NFTImage onError={onError} />);
await waitFor(() =>
expect(onError).toHaveBeenCalledWith({
error: 'mocked error',
code: 'NmNIc01',
message: 'Error loading image',
}),
);
});

it('should hide the svg on transition end', async () => {
(useNFTContext as Mock).mockReturnValue({
imageUrl: 'transitionend',
description: 'Test NFT Image',
});
const { getByTestId, queryByTestId } = render(<NFTImage />);
await act(async () => {
fireEvent.transitionEnd(getByTestId('ockNFTImage'));
});
expect(queryByTestId('ock-defaultNFTSvg')).toBeNull();
});

it('should retry loading the image when retry button is clicked', async () => {
(useNFTContext as Mock).mockReturnValue({
imageUrl: 'error',
description: 'Test NFT Image',
});
const onError = vi.fn();
const { getByText } = render(<NFTImage onError={onError} />);
await waitFor(() => expect(onError).toHaveBeenCalled());

fireEvent.click(getByText('retry'));
await waitFor(() => expect(onError).toHaveBeenCalledTimes(2));
});

it('should apply the provided className', () => {
const { container } = render(<NFTImage className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
Loading