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: new OnchainName component #49

Merged
merged 19 commits into from
Feb 1, 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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const frameMetadata = getFrameMetadata({
```ts
type Button = {
label: string;
action?: "post" | "post_redirect";
action?: 'post' | 'post_redirect';
};

type FrameMetadata = {
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,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} />;
alvaroraminelli marked this conversation as resolved.
Show resolved Hide resolved
```

**@Param**
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, I think we can use types going forward to better describe what's going on. Check the latest README.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

soon we might have autogen docs that will be based on type correct?

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe... There is a difference between type information, and how to teach how to use something.

Vitepress will automate docs creation in some capacity, but the ART of teaching how to use a function is still a manual work.


- `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"
alvaroraminelli marked this conversation as resolved.
Show resolved Hide resolved
},
"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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for later, this will cause a Layout Shift. Something to consider in follow up PR.

}

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
Loading