Skip to content

Commit

Permalink
feat: new OnchainName component (coinbase#49)
Browse files Browse the repository at this point in the history
  • Loading branch information
alvaroraminelli authored Feb 1, 2024
1 parent dce6f67 commit 8af8505
Show file tree
Hide file tree
Showing 19 changed files with 1,558 additions and 21 deletions.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,54 @@ type FrameMetadataResponse = Record<string, string>;
<br />
<br />

## Identity Kit 👤

### OnchainName

The `OnchainName` component is used to display ENS names associated with Ethereum addresses. When an ENS name is not available, it defaults to showing a truncated version of the address.

```tsx
import { OnchainName } from '@coinbase/onchainkit';

<OnchainName address="0x1234567890abcdef1234567890abcdef12345678" sliced={false} />;
```

**@Param**

- `address`: Ethereum address to be resolved from ENS.
- `className`: Optional CSS class for custom styling.
- `sliced`: Determines if the address should be sliced when no ENS name is available.
- `props`: Additional HTML attributes for the span element.

**@Returns**

```ts
type JSX.Element;
```

```tsx
import { useOnchainName } from '@coinbase/onchainkit';

const { ensName, isLoading } = useOnchainName('0x1234567890abcdef1234567890abcdef12345678');
```

**@Param**

```ts
type UseOnchainName = {
address?: `0x${string}`;
};
```

**@Returns**

```ts
type UseOnchainNameResponse = {
ensName: string | null | undefined;
isLoading: boolean;
};
```

## The Team and Our Community ☁️ 🌁 ☁️

OnchainKit is all about community; for any questions, feel free to:
Expand Down
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testMatch: ['**/?(*.)+(spec|test|integ).ts'],
testMatch: ['**/?(*.)+(spec|test|integ).{ts,tsx}'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};
12 changes: 12 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Jest is an amazing test runner and has some awesome assertion APIs
// built in by default. However, there are times when having more
// specific matchers (assertions) would be far more convenient.
// https://jest-extended.jestcommunity.dev/docs/matchers/
import 'jest-extended';
// Enable jest-dom functions
import '@testing-library/jest-dom';

import { TextEncoder, TextDecoder } from 'util';

global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder as typeof global.TextDecoder;
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,30 @@
"release:publish": "yarn install && yarn build && changeset publish",
"release:version": "changeset version && yarn install --immutable"
},
"peerDependencies": {
"react": "^18",
"react-dom": "^18",
"viem": "^2.5.0"
},
"devDependencies": {
"@changesets/changelog-github": "^0.4.8",
"@changesets/cli": "^2.26.2",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0",
"@types/jest": "^29.5.11",
"@types/react": "^18",
"@types/react-dom": "^18",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-extended": "^4.0.2",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.9",
"react": "^18",
"react-dom": "^18",
"rimraf": "^5.0.5",
"ts-jest": "^29.1.2",
"typescript": "~5.3.3",
"viem": "^2.5.0",
"yarn": "^1.22.21"
},
"publishConfig": {
Expand Down
62 changes: 62 additions & 0 deletions src/components/OnchainName.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { OnchainName } from './OnchainName';
import { useOnchainName } from '../hooks/useOnchainName';
import { getSlicedAddress } from '../core/address';

// Mocking the hooks and utilities
jest.mock('../hooks/useOnchainName', () => ({
useOnchainName: jest.fn(),
}));
jest.mock('../core/address', () => ({
getSlicedAddress: jest.fn(),
}));

const mockSliceAddress = (addr: string) => addr.slice(0, 6) + '...' + addr.slice(-4);

describe('OnchainAddress', () => {
const testAddress = '0x1234567890abcdef1234567890abcdef12345678';
const testName = 'testname.eth';

beforeEach(() => {
jest.clearAllMocks();
(getSlicedAddress as jest.Mock).mockImplementation(mockSliceAddress);
});

it('displays ENS name when available', () => {
(useOnchainName as jest.Mock).mockReturnValue({ ensName: testName, isLoading: false });

render(<OnchainName address={testAddress} />);

expect(screen.getByText(testName)).toBeInTheDocument();
expect(getSlicedAddress).toHaveBeenCalledTimes(0);
});

it('displays sliced address when ENS name is not available and sliced is true as default', () => {
(useOnchainName as jest.Mock).mockReturnValue({ ensName: null, isLoading: false });

render(<OnchainName address={testAddress} />);

expect(screen.getByText(mockSliceAddress(testAddress))).toBeInTheDocument();
});

it('displays empty when ens still fetching', () => {
(useOnchainName as jest.Mock).mockReturnValue({ ensName: null, isLoading: true });

render(<OnchainName address={testAddress} />);

expect(screen.queryByText(mockSliceAddress(testAddress))).not.toBeInTheDocument();
expect(getSlicedAddress).toHaveBeenCalledTimes(0);
});

it('displays full address when ENS name is not available and sliced is false', () => {
(useOnchainName as jest.Mock).mockReturnValue({ ensName: null, isLoading: false });

render(<OnchainName address={testAddress} sliced={false} />);

expect(screen.getByText(testAddress)).toBeInTheDocument();
});
});
43 changes: 43 additions & 0 deletions src/components/OnchainName.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useMemo } from 'react';
import { getSlicedAddress } from '../core/address';
import { useOnchainName } from '../hooks/useOnchainName';
import type { Address } from 'viem';

type OnchainNameProps = {
address: Address;
className?: string;
sliced?: boolean;
props?: React.HTMLAttributes<HTMLSpanElement>;
};

/**
* OnchainName is a React component that renders the user name from an Ethereum address.
* It displays the ENS name if available; otherwise, it shows either a sliced version of the address
* or the full address, based on the 'sliced' prop. By default, 'sliced' is set to true.
*
* @param {Address} address - Ethereum address to be displayed.
* @param {string} [className] - Optional CSS class for custom styling.
* @param {boolean} [sliced=true] - Determines if the address should be sliced when no ENS name is available.
* @param {React.HTMLAttributes<HTMLSpanElement>} [props] - Additional HTML attributes for the span element.
*/
export function OnchainName({ address, className, sliced = true, props }: OnchainNameProps) {
const { ensName, isLoading } = useOnchainName(address);

// wrapped in useMemo to prevent unnecessary recalculations.
const normalizedAddress = useMemo(() => {
if (!ensName && !isLoading && sliced) {
return getSlicedAddress(address);
}
return address;
}, [address, isLoading]);

if (isLoading) {
return null;
}

return (
<span className={className} {...props}>
{ensName ?? normalizedAddress}
</span>
);
}
8 changes: 8 additions & 0 deletions src/core/address.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getSlicedAddress } from './address';

describe('getSlicedAddress', () => {
it('should return a string of class names', () => {
const address = getSlicedAddress('0x1234567890123456789012345678901234567890');
expect(address).toEqual('0x123...7890');
});
});
9 changes: 9 additions & 0 deletions src/core/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* getSlicedAddress returns the first 5 and last 4 characters of an address.
*/
export const getSlicedAddress = (address: `0x${string}` | undefined) => {
if (!address) {
return '';
}
return `${address.slice(0, 5)}...${address.slice(-4)}`;
};
65 changes: 65 additions & 0 deletions src/hooks/useOnchainActionWithCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @jest-environment jsdom
*/

import { renderHook, waitFor } from '@testing-library/react';
import { useOnchainActionWithCache } from './useOnchainActionWithCache';
import { InMemoryStorage } from '../store/inMemoryStorageService';

jest.mock('../store/inMemoryStorageService', () => ({
InMemoryStorage: {
getData: jest.fn(),
setData: jest.fn(),
},
}));

describe('useOnchainActionWithCache', () => {
const mockAction = jest.fn();
const actionKey = 'testKey';

beforeEach(() => {
jest.clearAllMocks();
});

it('initializes with loading state and undefined data', () => {
const { result } = renderHook(() => useOnchainActionWithCache(mockAction, actionKey));

expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
});

it('fetches data and updates state', async () => {
const testData = 'testData';
mockAction.mockResolvedValue(testData);

const { result } = renderHook(() => useOnchainActionWithCache(mockAction, actionKey));

await waitFor(() => {
expect(mockAction).toHaveBeenCalled();
expect(result.current.data).toBe(testData);
expect(result.current.isLoading).toBe(false);
});
});

it('caches data when an actionKey is provided', async () => {
const testData = 'testData';
mockAction.mockResolvedValue(testData);

renderHook(() => useOnchainActionWithCache(mockAction, actionKey));

await waitFor(() => {
expect(InMemoryStorage.setData).toHaveBeenCalledWith(actionKey, testData);
});
});

it('does not cache data when actionKey is empty', async () => {
const testData = 'testData';
mockAction.mockResolvedValue(testData);

renderHook(() => useOnchainActionWithCache(mockAction, ''));

await waitFor(() => {
expect(InMemoryStorage.setData).not.toHaveBeenCalled();
});
});
});
51 changes: 51 additions & 0 deletions src/hooks/useOnchainActionWithCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { ActionFunction, ActionKey, StorageValue } from '../types';
import { InMemoryStorage } from '../store/inMemoryStorageService'; // Adjust the import path as needed

type ExtractStorageValue<T> = T extends StorageValue ? T : never;

/**
* A generic hook to fetch and store data using a specified storage service.
* It fetches data based on the given dependencies and stores it using the provided storage service.
* @param action - The action function to fetch data.
* @param actionKey - A key associated with the action for caching purposes.
* @returns The data fetched by the action function and a boolean indicating whether the data is being fetched.
*/
export function useOnchainActionWithCache<T>(action: ActionFunction<T>, actionKey: ActionKey) {
const [data, setData] = useState<StorageValue>(undefined);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
let isSubscribed = true;

const callAction = async () => {
let fetchedData: StorageValue;
// Use cache only if actionKey is not empty
if (actionKey) {
fetchedData = await InMemoryStorage.getData(actionKey);
}

// If no cached data or actionKey is empty, fetch new data
if (!fetchedData) {
fetchedData = (await action()) as ExtractStorageValue<T>;
// Cache the data only if actionKey is not empty
if (actionKey) {
await InMemoryStorage.setData(actionKey, fetchedData);
}
}

if (isSubscribed) {
setData(fetchedData);
setIsLoading(false);
}
};

void callAction();

return () => {
isSubscribed = false;
};
}, [actionKey, action]);

return { data, isLoading };
}
Loading

0 comments on commit 8af8505

Please sign in to comment.