feat(emblem): create example emblem component (#6013)

This commit is contained in:
Edd 2024-03-19 11:10:21 +00:00 committed by GitHub
parent 1df8ba0972
commit 3e78d55c0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 524 additions and 0 deletions

View File

@ -0,0 +1,30 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.json"],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"ignoredFiles": ["{projectRoot}/rollup.config.{js,ts,mjs,mts}"]
}
]
}
}
]
}

29
libs/emblem/.swcrc Normal file
View File

@ -0,0 +1,29 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
},
"keepClassNames": true,
"externalHelpers": true,
"loose": true
},
"module": {
"type": "es6"
},
"sourceMaps": true,
"exclude": [
"jest.config.ts",
".*\\.spec.tsx?$",
".*\\.test.tsx?$",
"./src/jest-setup.ts$",
"./**/jest-setup.ts$",
".*.js$"
]
}

40
libs/emblem/README.md Normal file
View File

@ -0,0 +1,40 @@
# Emblem
Uses https://icon.vega.xyz to source an image for icons, either by asset & vega chain ID or contract & source chain ID
## Components
All of the components ultimately render an [`img`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) tag, and all properties can be overriden.
### Emblem
Wrapper component for [EmblemByAsset](#emblembyasset) and [EmblemByContract](#emblembycontract). Depending on the props, it returns an icon for an asset by one of the below subcomponents
| Property Name | Required or Optional | Description |
| --------------- | -------------------- | ---------------------------------------------------------------- |
| asset | Optional | The ID of the Vega Asset. |
| chainId | Optional | The ID of the Vega Chain. |
| vegaChainId | Optional | The ID of the Vega Chain (e.g. `vega-fairground-2020305051805`). |
| contractAddress | Optional | The address of the smart contract on its origin chain. |
### EmblemByAsset
Renders an icon for a given Vega Asset ID.
| Property Name | Required or Optional | Description |
| ------------- | -------------------- | ---------------------------------------------------------------- |
| asset | Required | The ID of the Vega Asset. |
| vegaChainId | Required | The ID of the Vega Chain (e.g. `vega-fairground-2020305051805`). |
### EmblemByContract
Renders an icon for a given smart contract address on its origin chain.
| Property Name | Required or Optional | Description |
| ------------- | -------------------- | ----------------------------------------------------------- |
| contract | Required | The address of the contract representing the asset. |
| chainId | Required | The ID of the origin chain (e.g. `1` for Ethereum Mainnet). |
## Building
Run `nx build emblem` to build the library.

View File

@ -0,0 +1,13 @@
/* eslint-disable */
export default {
displayName: 'emblem',
preset: '../../jest.preset.js',
globals: {},
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/emblem',
setupFilesAfterEnv: ['./src/setup-tests.ts'],
};

23
libs/emblem/project.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "emblem",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/emblem/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/emblem/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/emblem/jest.config.ts"
}
}
},
"tags": []
}

View File

@ -0,0 +1,39 @@
import { render } from '@testing-library/react';
import { EmblemByAsset } from './asset-emblem';
import React from 'react';
describe('EmblemByAsset', () => {
it('should render successfully', () => {
const props = {
vegaChain: 'vega-chain',
asset: '123',
alt: 'Emblem',
};
const { getByAltText } = render(<EmblemByAsset {...props} />);
const emblemImage = getByAltText('Emblem');
expect(emblemImage).toBeInTheDocument();
expect(emblemImage).toHaveAttribute(
'src',
'https://icon.vega.xyz/vega/vega-chain/asset/123/logo.svg'
);
});
it('should use default vega chain if vegaChain prop is not provided', () => {
const props = {
asset: '123',
};
const { getByAltText } = render(<EmblemByAsset {...props} />);
const emblemImage = getByAltText('Emblem');
expect(emblemImage).toBeInTheDocument();
expect(emblemImage).toHaveAttribute(
'src',
'https://icon.vega.xyz/vega/vega-mainnet-0011/asset/123/logo.svg'
);
});
});

View File

@ -0,0 +1,22 @@
import { URL_BASE, DEFAULT_VEGA_CHAIN, FILENAME } from '../config';
import { EmblemBase } from './emblem-base';
export type EmblemByAssetProps = {
asset: string;
vegaChain?: string;
contract?: never;
};
/**
* Given a Vega asset ID, it will render an emblem for the asset
*
* @param asset string the asset ID
* @param vegaChain string the vega chain ID (default: Vega Mainnet)
* @returns React.Node
*/
export function EmblemByAsset(p: EmblemByAssetProps) {
const chain = p.vegaChain ? p.vegaChain : DEFAULT_VEGA_CHAIN;
const url = `${URL_BASE}/vega/${chain}/asset/${p.asset}/${FILENAME}`;
return <EmblemBase src={url} {...p} />;
}

View File

@ -0,0 +1,40 @@
import { render } from '@testing-library/react';
import { EmblemByContract } from './contract-emblem';
import React from 'react';
describe('EmblemByContract', () => {
it('should render successfully', () => {
const props = {
chainId: 'custom-chain',
contract: '123',
alt: 'Emblem',
};
const { getByAltText } = render(<EmblemByContract {...props} />);
const emblemImage = getByAltText('Emblem');
expect(emblemImage).toBeInTheDocument();
expect(emblemImage).toHaveAttribute(
'src',
'https://icon.vega.xyz/chain/custom-chain/asset/123/logo.svg'
);
});
it('should use default vega chain if vegaChain prop is not provided', () => {
const props = {
contract: '123',
alt: 'Emblem',
};
const { getByAltText } = render(<EmblemByContract {...props} />);
const emblemImage = getByAltText('Emblem');
expect(emblemImage).toBeInTheDocument();
expect(emblemImage).toHaveAttribute(
'src',
'https://icon.vega.xyz/chain/1/asset/123/logo.svg'
);
});
});

View File

@ -0,0 +1,21 @@
import { URL_BASE, DEFAULT_CHAIN, FILENAME } from '../config';
import { EmblemBase } from './emblem-base';
export type EmblemByContractProps = {
contract: string;
chainId?: string;
asset?: never;
};
/**
* Given a contract address and a chain ID, it will render an emblem for the contract
* @param contract string the contract address
* @param chainId string? (default: Ethereum Mainnet)
* @returns React.Node
*/
export function EmblemByContract(p: EmblemByContractProps) {
const chain = p.chainId ? p.chainId : DEFAULT_CHAIN;
const url = `${URL_BASE}/chain/${chain}/asset/${p.contract}/${FILENAME}`;
return <EmblemBase src={url} {...p} />;
}

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import { EmblemBase } from './emblem-base';
import React from 'react';
describe('EmblemBase', () => {
test('renders image with fallback URL when source is not provided', () => {
render(<EmblemBase />);
const imageElement = screen.getByAltText('Emblem');
expect(imageElement).toBeInTheDocument();
expect(imageElement).toHaveAttribute(
'src',
'https://icon.vega.xyz/missing.svg'
);
});
test('renders image with provided source', () => {
const src = 'https://example.com/image.png';
render(<EmblemBase src={src} />);
const imageElement = screen.getByAltText('Emblem');
expect(imageElement).toBeInTheDocument();
expect(imageElement).toHaveAttribute('src', src);
});
test('renders image with provided alt text', () => {
const alt = 'Custom Emblem';
render(<EmblemBase alt={alt} />);
const imageElement = screen.getByAltText(alt);
expect(imageElement).toBeInTheDocument();
});
test('sets the fallback URL as the source when source fails to load', () => {
const src = 'https://example.com/non-existent-image.png';
render(<EmblemBase src={src} />);
const imageElement = screen.getByAltText('Emblem');
expect(imageElement).toBeInTheDocument();
// Simulate the error event on the image element
const errorEvent = new Event('error');
imageElement.dispatchEvent(errorEvent);
expect(imageElement).toHaveAttribute(
'src',
'https://icon.vega.xyz/missing.svg'
);
});
});

View File

@ -0,0 +1,26 @@
import type { ImgHTMLAttributes } from 'react';
import { FALLBACK_URL } from '../config/index';
export type ImgProps = ImgHTMLAttributes<HTMLImageElement>;
/**
* Renders an image tag with a known fallback if the emblem does not exist.
* @param url string the URL of the emblem, probably calculated in EmblemByAsset or EmblemByContract
* @returns React.Node
*/
export function EmblemBase(p: ImgProps) {
const renderFallback = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
e.currentTarget.src = FALLBACK_URL;
};
return (
<img
src={p.src || FALLBACK_URL}
onError={renderFallback}
alt={p.alt || 'Emblem'}
width="20"
height="20"
className="inline-block w-5 h-5 mx-2"
/>
);
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { render } from '@testing-library/react';
import { Emblem } from './emblem';
describe('Emblem', () => {
it('renders EmblemByAsset component when props are of type EmblemByAsset (including chain)', () => {
const props = {
asset: '123',
vegaChain: 'mainnet',
alt: 'Emblem',
};
const { getByAltText } = render(
<Emblem data-testId="emblem-by-asset" {...props} />
);
const emblemByAssetComponent = getByAltText('Emblem');
expect(emblemByAssetComponent).toBeInTheDocument();
expect(emblemByAssetComponent.getAttribute('src')).toContain('asset');
expect(emblemByAssetComponent.getAttribute('src')).not.toContain('chain');
});
it('renders EmblemByAsset component when props are of type EmblemByAsset (excluding chain)', () => {
const props = {
asset: '123',
alt: 'Emblem',
};
const { getByAltText } = render(
<Emblem data-testId="emblem-by-asset" {...props} />
);
const emblemByAssetComponent = getByAltText('Emblem');
expect(emblemByAssetComponent).toBeInTheDocument();
expect(emblemByAssetComponent.getAttribute('src')).toContain('asset');
expect(emblemByAssetComponent.getAttribute('src')).not.toContain('chain');
});
it('renders EmblemByContract component when props are of type EmblemByContract (including chain)', () => {
const props = {
contract: '456',
chainId: '1',
alt: 'Emblem',
};
const { getByAltText } = render(
<Emblem data-testId="emblem-by-contract" {...props} />
);
const emblemByContractComponent = getByAltText('Emblem');
expect(emblemByContractComponent).toBeInTheDocument();
expect(emblemByContractComponent.getAttribute('src')).toContain('chain');
expect(emblemByContractComponent.getAttribute('src')).not.toContain(
'/vega/'
);
});
it('renders EmblemByContract component when props are of type EmblemByContract (excluding chain)', () => {
const props = {
contract: '456',
alt: 'Emblem',
};
const { getByAltText } = render(
<Emblem data-testId="emblem-by-contract" {...props} />
);
const emblemByContractComponent = getByAltText('Emblem');
expect(emblemByContractComponent).toBeInTheDocument();
expect(emblemByContractComponent.getAttribute('src')).toContain('chain');
expect(emblemByContractComponent.getAttribute('src')).not.toContain(
'/vega/'
);
});
});

View File

@ -0,0 +1,21 @@
import type { EmblemByAssetProps } from './asset-emblem';
import type { EmblemByContractProps } from './contract-emblem';
import type { ImgProps } from './emblem-base';
import { isEmblemByAsset } from './lib/type-guard';
import { EmblemByAsset } from './asset-emblem';
import { EmblemByContract } from './contract-emblem';
export type EmblemProps = ImgProps &
(EmblemByAssetProps | EmblemByContractProps);
/**
* A generic component that will render an emblem for a Vega asset or a contract, depending on the props
* @returns React.Node
*/
export function Emblem(props: EmblemProps) {
if (isEmblemByAsset(props)) {
return <EmblemByAsset {...props} />;
}
return <EmblemByContract {...props} />;
}

View File

@ -0,0 +1,28 @@
import { isEmblemByAsset } from './type-guard';
describe('isEmblemByAsset', () => {
it('should return true if args is of type EmblemByAssetProps', () => {
const args = { asset: 'exampleAsset', vegaChainId: 'fairground-123' };
const result = isEmblemByAsset(args);
expect(result).toBe(true);
});
it('should return true if args is of type EmblemByAssetProps, even without a vegaChainId (defaults to mainnet)', () => {
const args = { asset: 'exampleAsset' };
const result = isEmblemByAsset(args);
expect(result).toBe(true);
});
it('should return false if args is of type EmblemByContractProps', () => {
const args = { contract: 'exampleContract', chainId: '1' };
const result = isEmblemByAsset(args);
expect(result).toBe(false);
});
it('should return false if args does not have the required property', () => {
const args = { otherProp: 'exampleProp' };
// @ts-expect-error intentionally bad type to test type guard
const result = isEmblemByAsset(args);
expect(result).toBe(false);
});
});

View File

@ -0,0 +1,12 @@
import type { EmblemByAssetProps } from '../asset-emblem';
import type { EmblemByContractProps } from '../contract-emblem';
/**
* Type guard for generic Emblem component, which ends up rendering either an EmblemByContract
* or EmblemByAsset depending on the arguments
*/
export function isEmblemByAsset(
args: EmblemByAssetProps | EmblemByContractProps
): args is EmblemByAssetProps {
return (args as EmblemByAssetProps).asset !== undefined;
}

View File

@ -0,0 +1,6 @@
export const URL_BASE = 'https://icon.vega.xyz';
export const FILENAME = 'logo.svg';
export const FALLBACK_URL = `${URL_BASE}/missing.svg`;
export const DEFAULT_VEGA_CHAIN = 'vega-mainnet-0011';
export const DEFAULT_CHAIN = '1';

4
libs/emblem/src/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './components/emblem-base';
export * from './components/emblem';
export * from './components/contract-emblem';
export * from './components/asset-emblem';

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

23
libs/emblem/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -26,6 +26,7 @@
"@vegaprotocol/datagrid": ["libs/datagrid/src/index.ts"],
"@vegaprotocol/deal-ticket": ["libs/deal-ticket/src/index.ts"],
"@vegaprotocol/deposits": ["libs/deposits/src/index.ts"],
"@vegaprotocol/emblem": ["libs/emblem/src/index.ts"],
"@vegaprotocol/environment": ["libs/environment/src/index.ts"],
"@vegaprotocol/fills": ["libs/fills/src/index.ts"],
"@vegaprotocol/funding-payments": ["libs/funding-payments/src/index.ts"],