Commit 86e33cae authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #1919 from blockscout/tom2drum/issue-1826

Public tags: form re-design
parents fbf69443 69d71495
...@@ -17,6 +17,7 @@ export { default as metasuites } from './metasuites'; ...@@ -17,6 +17,7 @@ export { default as metasuites } from './metasuites';
export { default as mixpanel } from './mixpanel'; export { default as mixpanel } from './mixpanel';
export { default as multichainButton } from './multichainButton'; export { default as multichainButton } from './multichainButton';
export { default as nameService } from './nameService'; export { default as nameService } from './nameService';
export { default as publicTagsSubmission } from './publicTagsSubmission';
export { default as restApiDocs } from './restApiDocs'; export { default as restApiDocs } from './restApiDocs';
export { default as rollup } from './rollup'; export { default as rollup } from './rollup';
export { default as safe } from './safe'; export { default as safe } from './safe';
......
import type { Feature } from './types';
import services from '../services';
import { getEnvValue } from '../utils';
import addressMetadata from './addressMetadata';
const apiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const title = 'Public tag submission';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (services.reCaptcha.siteKey && addressMetadata.isEnabled && apiHost) {
return Object.freeze({
title,
isEnabled: true,
api: {
endpoint: apiHost,
basePath: '',
},
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -353,21 +353,6 @@ const accountSchema = yup ...@@ -353,21 +353,6 @@ const accountSchema = yup
}), }),
}); });
const adminServiceSchema = yup
.object()
.shape({
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup
.string()
.when([ 'NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', 'NEXT_PUBLIC_MARKETPLACE_ENABLED' ], {
is: (value1: boolean, value2: boolean) => value1 || value2,
then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.max(
-1,
'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED or NEXT_PUBLIC_MARKETPLACE_ENABLED is not set to "true"',
),
}),
});
const featuredNetworkSchema: yup.ObjectSchema<FeaturedNetwork> = yup const featuredNetworkSchema: yup.ObjectSchema<FeaturedNetwork> = yup
.object() .object()
.shape({ .shape({
...@@ -600,6 +585,7 @@ const schema = yup ...@@ -600,6 +585,7 @@ const schema = yup
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_CONTRACT_INFO_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_NAME_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_METADATA_SERVICE_API_HOST: yup.string().test(urlTest), NEXT_PUBLIC_METADATA_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup.string().matches(regexp.HEX_REGEXP), NEXT_PUBLIC_GRAPHIQL_TRANSACTION: yup.string().matches(regexp.HEX_REGEXP),
NEXT_PUBLIC_WEB3_WALLETS: yup NEXT_PUBLIC_WEB3_WALLETS: yup
.mixed() .mixed()
...@@ -660,7 +646,6 @@ const schema = yup ...@@ -660,7 +646,6 @@ const schema = yup
.concat(rollupSchema) .concat(rollupSchema)
.concat(beaconChainSchema) .concat(beaconChainSchema)
.concat(bridgedTokensSchema) .concat(bridgedTokensSchema)
.concat(sentrySchema) .concat(sentrySchema);
.concat(adminServiceSchema);
export default schema; export default schema;
...@@ -569,6 +569,17 @@ This feature allows name tags and other public tags for addresses. ...@@ -569,6 +569,17 @@ This feature allows name tags and other public tags for addresses.
&nbsp; &nbsp;
### Public tag submission
This feature allows you to submit an application with a public address tag.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_METADATA_SERVICE_API_HOST | `string` | Metadata Service API endpoint url | Required | - | `https://metadata.services.blockscout.com` |
| NEXT_PUBLIC_ADMIN_SERVICE_API_HOST | `string` | Admin Service API endpoint url | Required | - | `https://admin-rs.services.blockscout.com` |
&nbsp;
### Data Availability ### Data Availability
This feature enables views related to blob transactions (EIP-4844), such as the Blob Txns tab on the Transactions page and the Blob details page. This feature enables views related to blob transactions (EIP-4844), such as the Blob Txns tab on the Transactions page and the Blob details page.
......
...@@ -2,7 +2,6 @@ import { getFeaturePayload } from 'configs/app/features/types'; ...@@ -2,7 +2,6 @@ import { getFeaturePayload } from 'configs/app/features/types';
import type { import type {
UserInfo, UserInfo,
CustomAbis, CustomAbis,
PublicTags,
ApiKeys, ApiKeys,
VerifiedAddressResponse, VerifiedAddressResponse,
TokenInfoApplicationConfig, TokenInfoApplicationConfig,
...@@ -32,7 +31,7 @@ import type { ...@@ -32,7 +31,7 @@ import type {
AddressCoinBalanceHistoryChartOld, AddressCoinBalanceHistoryChartOld,
} from 'types/api/address'; } from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses'; import type { AddressesResponse } from 'types/api/addresses';
import type { AddressMetadataInfo } from 'types/api/addressMetadata'; import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata';
import type { TxBlobs, Blob } from 'types/api/blobs'; import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts'; import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
...@@ -147,10 +146,6 @@ export const RESOURCES = { ...@@ -147,10 +146,6 @@ export const RESOURCES = {
pathParams: [ 'id' as const ], pathParams: [ 'id' as const ],
filterFields: [ ], filterFields: [ ],
}, },
public_tags: {
path: '/api/account/v2/user/public_tags/:id?',
pathParams: [ 'id' as const ],
},
private_tags_address: { private_tags_address: {
path: '/api/account/v2/user/tags/address/:id?', path: '/api/account/v2/user/tags/address/:id?',
pathParams: [ 'id' as const ], pathParams: [ 'id' as const ],
...@@ -245,7 +240,7 @@ export const RESOURCES = { ...@@ -245,7 +240,7 @@ export const RESOURCES = {
filterFields: [ 'name' as const, 'only_active' as const ], filterFields: [ 'name' as const, 'only_active' as const ],
}, },
// METADATA SERVICE // METADATA SERVICE & PUBLIC TAGS
address_metadata_info: { address_metadata_info: {
path: '/api/v1/metadata', path: '/api/v1/metadata',
endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint, endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
...@@ -256,6 +251,17 @@ export const RESOURCES = { ...@@ -256,6 +251,17 @@ export const RESOURCES = {
endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint, endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
basePath: getFeaturePayload(config.features.addressMetadata)?.api.basePath, basePath: getFeaturePayload(config.features.addressMetadata)?.api.basePath,
}, },
address_metadata_tag_types: {
path: '/api/v1/public-tag-types',
endpoint: getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
basePath: getFeaturePayload(config.features.addressMetadata)?.api.basePath,
},
public_tag_application: {
path: '/api/v1/chains/:chainId/metadata-submissions/tag',
pathParams: [ 'chainId' as const ],
endpoint: getFeaturePayload(config.features.publicTagsSubmission)?.api.endpoint,
basePath: getFeaturePayload(config.features.publicTagsSubmission)?.api.basePath,
},
// VISUALIZATION // VISUALIZATION
visualize_sol2uml: { visualize_sol2uml: {
...@@ -863,7 +869,6 @@ export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q> ...@@ -863,7 +869,6 @@ export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>
export type ResourcePayloadA<Q extends ResourceName> = export type ResourcePayloadA<Q extends ResourceName> =
Q extends 'user_info' ? UserInfo : Q extends 'user_info' ? UserInfo :
Q extends 'custom_abi' ? CustomAbis : Q extends 'custom_abi' ? CustomAbis :
Q extends 'public_tags' ? PublicTags :
Q extends 'private_tags_address' ? AddressTagsResponse : Q extends 'private_tags_address' ? AddressTagsResponse :
Q extends 'private_tags_tx' ? TransactionTagsResponse : Q extends 'private_tags_tx' ? TransactionTagsResponse :
Q extends 'api_keys' ? ApiKeys : Q extends 'api_keys' ? ApiKeys :
...@@ -956,6 +961,7 @@ Q extends 'optimistic_l2_deposits_count' ? number : ...@@ -956,6 +961,7 @@ Q extends 'optimistic_l2_deposits_count' ? number :
Q extends 'optimistic_l2_txn_batches_count' ? number : Q extends 'optimistic_l2_txn_batches_count' ? number :
Q extends 'config_backend_version' ? BackendVersionConfig : Q extends 'config_backend_version' ? BackendVersionConfig :
Q extends 'address_metadata_info' ? AddressMetadataInfo : Q extends 'address_metadata_info' ? AddressMetadataInfo :
Q extends 'address_metadata_tag_types' ? PublicTagTypesResponse :
never; never;
// !!! IMPORTANT !!! // !!! IMPORTANT !!!
// See comment above // See comment above
......
...@@ -237,6 +237,11 @@ export default function useNavItems(): ReturnType { ...@@ -237,6 +237,11 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/gas-tracker' as const }, nextRoute: { pathname: '/gas-tracker' as const },
isActive: pathname.startsWith('/gas-tracker'), isActive: pathname.startsWith('/gas-tracker'),
}, },
config.features.publicTagsSubmission.isEnabled && {
text: 'Submit public tag',
nextRoute: { pathname: '/public-tags/submit' as const },
isActive: pathname.startsWith('/public-tags/submit'),
},
...config.UI.sidebar.otherLinks, ...config.UI.sidebar.otherLinks,
].filter(Boolean), ].filter(Boolean),
}, },
...@@ -255,12 +260,6 @@ export default function useNavItems(): ReturnType { ...@@ -255,12 +260,6 @@ export default function useNavItems(): ReturnType {
icon: 'privattags', icon: 'privattags',
isActive: pathname === '/account/tag-address', isActive: pathname === '/account/tag-address',
}, },
{
text: 'Public tags',
nextRoute: { pathname: '/account/public-tags-request' as const },
icon: 'publictags',
isActive: pathname === '/account/public-tags-request',
},
{ {
text: 'API keys', text: 'API keys',
nextRoute: { pathname: '/account/api-key' as const }, nextRoute: { pathname: '/account/api-key' as const },
......
...@@ -27,9 +27,9 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -27,9 +27,9 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/account/watchlist': 'Regular page', '/account/watchlist': 'Regular page',
'/account/api-key': 'Regular page', '/account/api-key': 'Regular page',
'/account/custom-abi': 'Regular page', '/account/custom-abi': 'Regular page',
'/account/public-tags-request': 'Regular page',
'/account/tag-address': 'Regular page', '/account/tag-address': 'Regular page',
'/account/verified-addresses': 'Root page', '/account/verified-addresses': 'Root page',
'/public-tags/submit': 'Regular page',
'/withdrawals': 'Root page', '/withdrawals': 'Root page',
'/visualize/sol2uml': 'Regular page', '/visualize/sol2uml': 'Regular page',
'/csv-export': 'Regular page', '/csv-export': 'Regular page',
......
/* eslint-disable max-len */
import type { Route } from 'nextjs-routes'; import type { Route } from 'nextjs-routes';
// equal og:description // equal og:description
...@@ -30,9 +31,9 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -30,9 +31,9 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/account/watchlist': DEFAULT_TEMPLATE, '/account/watchlist': DEFAULT_TEMPLATE,
'/account/api-key': DEFAULT_TEMPLATE, '/account/api-key': DEFAULT_TEMPLATE,
'/account/custom-abi': DEFAULT_TEMPLATE, '/account/custom-abi': DEFAULT_TEMPLATE,
'/account/public-tags-request': DEFAULT_TEMPLATE,
'/account/tag-address': DEFAULT_TEMPLATE, '/account/tag-address': DEFAULT_TEMPLATE,
'/account/verified-addresses': DEFAULT_TEMPLATE, '/account/verified-addresses': DEFAULT_TEMPLATE,
'/public-tags/submit': 'Propose a new public tag for your address, contract or set of contracts for your dApp. Our team will review and approve your submission. Public tags are incredible tool which helps users identify contracts and addresses.',
'/withdrawals': DEFAULT_TEMPLATE, '/withdrawals': DEFAULT_TEMPLATE,
'/visualize/sol2uml': DEFAULT_TEMPLATE, '/visualize/sol2uml': DEFAULT_TEMPLATE,
'/csv-export': DEFAULT_TEMPLATE, '/csv-export': DEFAULT_TEMPLATE,
......
...@@ -25,9 +25,9 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -25,9 +25,9 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/account/watchlist': '- watchlist', '/account/watchlist': '- watchlist',
'/account/api-key': '- API keys', '/account/api-key': '- API keys',
'/account/custom-abi': '- custom ABI', '/account/custom-abi': '- custom ABI',
'/account/public-tags-request': '- public tag requests',
'/account/tag-address': '- private tags', '/account/tag-address': '- private tags',
'/account/verified-addresses': '- my verified addresses', '/account/verified-addresses': '- my verified addresses',
'/public-tags/submit': 'submit public tag',
'/withdrawals': 'withdrawals', '/withdrawals': 'withdrawals',
'/visualize/sol2uml': 'Solidity UML diagram', '/visualize/sol2uml': 'Solidity UML diagram',
'/csv-export': 'export data to CSV', '/csv-export': 'export data to CSV',
......
...@@ -25,9 +25,9 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -25,9 +25,9 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/account/watchlist': 'Watchlist', '/account/watchlist': 'Watchlist',
'/account/api-key': 'API keys', '/account/api-key': 'API keys',
'/account/custom-abi': 'Custom ABI', '/account/custom-abi': 'Custom ABI',
'/account/public-tags-request': 'Public tags',
'/account/tag-address': 'Private tags', '/account/tag-address': 'Private tags',
'/account/verified-addresses': 'Verified addresses', '/account/verified-addresses': 'Verified addresses',
'/public-tags/submit': 'Submit public tag',
'/withdrawals': 'Withdrawals', '/withdrawals': 'Withdrawals',
'/visualize/sol2uml': 'Solidity UML diagram', '/visualize/sol2uml': 'Solidity UML diagram',
'/csv-export': 'Export data to CSV file', '/csv-export': 'Export data to CSV file',
......
export const COLOR_HEX_REGEXP = /^#[A-Fa-f\d]{3,6}$/;
export const validator = (value: string | undefined) => {
if (!value || value.length === 0) {
return true;
}
if (value.length !== 4 && value.length !== 7) {
return 'Invalid length';
}
if (!COLOR_HEX_REGEXP.test(value)) {
return 'Invalid hex code';
}
return true;
};
export const publicTagTypes = {
tagTypes: [
{
id: '96f9db76-02fc-477d-a003-640a0c5e7e15',
type: 'name' as const,
description: 'Alias for the address',
},
{
id: 'e75f396e-f52a-44c9-8790-a1dbae496b72',
type: 'generic' as const,
description: 'Group classification for the address',
},
{
id: '11a2d4f3-412e-4eb7-b663-86c6f48cdec3',
type: 'information' as const,
description: 'Tags with custom data for the address, e.g. additional link to project, or classification details, or minor account details',
},
{
id: 'd37443d4-748f-4314-a4a0-283b666e9f29',
type: 'classifier' as const,
description: 'E.g. "ERC20", "Contract", "CEX", "DEX", "NFT"',
},
{
id: 'ea9d0f91-9b46-44ff-be70-128bac468f6f',
type: 'protocol' as const,
description: 'Special tag type for protocol-related contracts, e.g. for bridges',
},
{
id: 'd2600acb-473c-445f-ac72-ed6fef53e06a',
type: 'note' as const,
description: 'Short general-purpose description for the address',
},
],
};
...@@ -240,3 +240,14 @@ export const login: GetServerSideProps<Props> = async(context) => { ...@@ -240,3 +240,14 @@ export const login: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const publicTagsSubmit: GetServerSideProps<Props> = async(context) => {
if (!config.features.publicTagsSubmission.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
...@@ -9,7 +9,6 @@ declare module "nextjs-routes" { ...@@ -9,7 +9,6 @@ declare module "nextjs-routes" {
| StaticRoute<"/404"> | StaticRoute<"/404">
| StaticRoute<"/account/api-key"> | StaticRoute<"/account/api-key">
| StaticRoute<"/account/custom-abi"> | StaticRoute<"/account/custom-abi">
| StaticRoute<"/account/public-tags-request">
| StaticRoute<"/account/tag-address"> | StaticRoute<"/account/tag-address">
| StaticRoute<"/account/verified-addresses"> | StaticRoute<"/account/verified-addresses">
| StaticRoute<"/account/watchlist"> | StaticRoute<"/account/watchlist">
...@@ -45,6 +44,7 @@ declare module "nextjs-routes" { ...@@ -45,6 +44,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/op/[hash]", { "hash": string }> | DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops"> | StaticRoute<"/ops">
| StaticRoute<"/output-roots"> | StaticRoute<"/output-roots">
| StaticRoute<"/public-tags/submit">
| StaticRoute<"/search-results"> | StaticRoute<"/search-results">
| StaticRoute<"/stats"> | StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]", { "hash": string }>
......
...@@ -49,16 +49,8 @@ const oldUrls = [ ...@@ -49,16 +49,8 @@ const oldUrls = [
destination: '/account/custom-abi', destination: '/account/custom-abi',
}, },
{ {
source: '/account/public_tags_request', source: '/account/public-tags-request',
destination: '/account/public-tags-request', destination: '/public-tags/submit',
},
{
source: '/account/public_tags_request/:id/edit',
destination: '/account/public-tags-request',
},
{
source: '/account/public_tags_request/new',
destination: '/account/public-tags-request',
}, },
// TRANSACTIONS // TRANSACTIONS
......
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react'; import React from 'react';
import PageNextJs from 'nextjs/PageNextJs'; import PageNextJs from 'nextjs/PageNextJs';
const PublicTags = dynamic(() => import('ui/pages/PublicTags'), { ssr: false }); import PublicTagsSubmit from 'ui/pages/PublicTagsSubmit';
const Page: NextPage = () => { const Page: NextPage = () => {
return ( return (
<PageNextJs pathname="/account/public-tags-request"> <PageNextJs pathname="/public-tags/submit">
<PublicTags/> <PublicTagsSubmit/>
</PageNextJs> </PageNextJs>
); );
}; };
export default Page; export default Page;
export { account as getServerSideProps } from 'nextjs/getServerSideProps'; export { publicTagsSubmit as getServerSideProps } from 'nextjs/getServerSideProps';
import type { PublicTag, AddressTag, TransactionTag, ApiKey, CustomAbi, VerifiedAddress, TokenInfoApplication, WatchlistAddress } from 'types/api/account'; import type { AddressTag, TransactionTag, ApiKey, CustomAbi, VerifiedAddress, TokenInfoApplication, WatchlistAddress } from 'types/api/account';
import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams'; import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams';
import { TX_HASH } from './tx'; import { TX_HASH } from './tx';
...@@ -16,19 +16,6 @@ export const PRIVATE_TAG_TX: TransactionTag = { ...@@ -16,19 +16,6 @@ export const PRIVATE_TAG_TX: TransactionTag = {
transaction_hash: TX_HASH, transaction_hash: TX_HASH,
}; };
export const PUBLIC_TAG: PublicTag = {
additional_comment: 'my comment',
addresses: [ ADDRESS_HASH ],
addresses_with_info: [ ADDRESS_PARAMS ],
company: 'Blockscout',
email: 'john.doe@example.com',
full_name: 'name',
id: 1,
is_owner: true,
tags: 'placeholder',
website: 'example.com',
};
export const WATCH_LIST_ITEM_WITH_TOKEN_INFO: WatchlistAddress = { export const WATCH_LIST_ITEM_WITH_TOKEN_INFO: WatchlistAddress = {
address: ADDRESS_PARAMS, address: ADDRESS_PARAMS,
address_balance: '7072643779453701031672', address_balance: '7072643779453701031672',
......
...@@ -21,6 +21,12 @@ const typography = { ...@@ -21,6 +21,12 @@ const typography = {
lineHeight: '32px', lineHeight: '32px',
fontFamily: 'heading', fontFamily: 'heading',
}, },
h4: {
fontSize: 'md',
fontWeight: '500',
lineHeight: '24px',
fontFamily: 'heading',
},
}, },
}; };
......
...@@ -103,23 +103,6 @@ export type WatchlistResponse = { ...@@ -103,23 +103,6 @@ export type WatchlistResponse = {
} | null; } | null;
} }
export interface PublicTag {
website: string;
tags: string; // tag_1;tag_2;tag_3 etc.
is_owner: boolean;
id: number;
full_name: string;
email: string;
company: string;
addresses: Array<string>;
addresses_with_info: Array<AddressParam>;
additional_comment: string;
}
export type PublicTagNew = Omit<PublicTag, 'id' | 'addresses_with_info'>
export type PublicTags = Array<PublicTag>;
export type CustomAbis = Array<CustomAbi> export type CustomAbis = Array<CustomAbi>
export interface CustomAbi { export interface CustomAbi {
...@@ -175,14 +158,6 @@ export type TransactionTagErrors = { ...@@ -175,14 +158,6 @@ export type TransactionTagErrors = {
identity_id?: Array<string>; identity_id?: Array<string>;
} }
export type PublicTagErrors = {
additional_comment: Array<string>;
addresses: Array<string>;
email: Array<string>;
full_name: Array<string>;
tags: Array<string>;
}
export interface VerifiedAddress { export interface VerifiedAddress {
userId: string; userId: string;
chainId: string; chainId: string;
......
...@@ -34,3 +34,15 @@ export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'> ...@@ -34,3 +34,15 @@ export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'>
warpcastHandle?: string; warpcastHandle?: string;
} | null; } | null;
} }
// TAG SUBMISSION
export interface PublicTagType {
id: string;
type: AddressMetadataTagType;
description: string;
}
export interface PublicTagTypesResponse {
tagTypes: Array<PublicTagType>;
}
import { Button, chakra, Flex } from '@chakra-ui/react'; import { Alert, Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
...@@ -11,9 +11,9 @@ import type { ResourceName } from 'lib/api/resources'; ...@@ -11,9 +11,9 @@ import type { ResourceName } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob'; import downloadBlob from 'lib/downloadBlob';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import CsvExportFormField from './CsvExportFormField'; import CsvExportFormField from './CsvExportFormField';
import CsvExportFormReCaptcha from './CsvExportFormReCaptcha';
interface Props { interface Props {
hash: string; hash: string;
...@@ -76,6 +76,13 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla ...@@ -76,6 +76,13 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]); }, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
const disabledFeatureMessage = (
<Alert status="error">
CSV export is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
);
return ( return (
<FormProvider { ...formApi }> <FormProvider { ...formApi }>
<chakra.form <chakra.form
...@@ -85,7 +92,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla ...@@ -85,7 +92,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap"> <Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> } { exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> } { exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
<CsvExportFormReCaptcha formApi={ formApi }/> <FormFieldReCaptcha disabledFeatureMessage={ disabledFeatureMessage }/>
</Flex> </Flex>
<Button <Button
variant="solid" variant="solid"
......
import { useRouter } from 'next/router';
import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll';
import type { PublicTag } from 'types/api/account';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import useToast from 'lib/hooks/useToast';
import getQueryParamString from 'lib/router/getQueryParamString';
import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
import PageTitle from 'ui/shared/Page/PageTitle';
type TScreen = 'data' | 'form';
type TToastAction = 'added' | 'removed';
const toastDescriptions = {
added: 'Your request sent to moderator. Waiting for...',
removed: 'Tags have been removed.',
} as Record<TToastAction, string>;
const PublicTagsComponent: React.FC = () => {
const router = useRouter();
const addressHash = getQueryParamString(router.query.address);
const [ screen, setScreen ] = useState<TScreen>(addressHash ? 'form' : 'data');
const [ formData, setFormData ] = useState<Partial<PublicTag> | undefined>(addressHash ? { addresses: [ addressHash ] } : undefined);
const toast = useToast();
useRedirectForInvalidAuthToken();
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/public-tags-request' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const showToast = useCallback((action: TToastAction) => {
toast({
position: 'top-right',
title: 'Success',
description: toastDescriptions[action],
colorScheme: 'green',
status: 'success',
variant: 'subtle',
isClosable: true,
icon: null,
});
}, [ toast ]);
const changeToFormScreen = useCallback((data?: PublicTag) => {
setFormData(data);
setScreen('form');
animateScroll.scrollToTop({
duration: 500,
delay: 100,
});
}, []);
const changeToDataScreen = useCallback((success?: boolean) => {
if (success) {
showToast('added');
}
setScreen('data');
animateScroll.scrollToTop({
duration: 500,
delay: 100,
});
}, [ showToast ]);
const onTagDelete = useCallback(() => showToast('removed'), [ showToast ]);
const onGoBack = useCallback(() => setScreen('data'), [ ]);
let content;
let header;
if (screen === 'data') {
content = <PublicTagsData changeToFormScreen={ changeToFormScreen } onTagDelete={ onTagDelete }/>;
header = 'Public tags';
} else {
content = <PublicTagsForm changeToDataScreen={ changeToDataScreen } data={ formData }/>;
header = formData ? 'Request to edit a public tag/label' : 'Request a public tag/label';
}
const backLink = {
label: 'Public tags',
onClick: onGoBack,
};
return (
<>
<PageTitle
title={ header }
backLink={ screen === 'form' ? backLink : undefined }
display={{ base: 'block', lg: 'inline-flex' }}
/>
{ content }
</>
);
};
export default PublicTagsComponent;
import React from 'react';
import type { FormSubmitResult } from 'ui/publicTags/submit/types';
import useApiQuery from 'lib/api/useApiQuery';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import PublicTagsSubmitForm from 'ui/publicTags/submit/PublicTagsSubmitForm';
import PublicTagsSubmitResult from 'ui/publicTags/submit/PublicTagsSubmitResult';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import PageTitle from 'ui/shared/Page/PageTitle';
type Screen = 'form' | 'result' | 'initializing' | 'error';
const PublicTagsSubmit = () => {
const [ screen, setScreen ] = React.useState<Screen>('initializing');
const [ submitResult, setSubmitResult ] = React.useState<FormSubmitResult>();
const userQuery = useFetchProfileInfo();
const configQuery = useApiQuery('address_metadata_tag_types', { queryOptions: { enabled: !userQuery.isLoading } });
React.useEffect(() => {
if (!configQuery.isPending) {
setScreen(configQuery.isError ? 'error' : 'form');
}
}, [ configQuery.isError, configQuery.isPending ]);
const handleFormSubmitResult = React.useCallback((result: FormSubmitResult) => {
setSubmitResult(result);
setScreen('result');
}, []);
const content = (() => {
switch (screen) {
case 'initializing':
return <ContentLoader/>;
case 'error':
return <DataFetchAlert/>;
case 'form':
return <PublicTagsSubmitForm config={ configQuery.data } onSubmitResult={ handleFormSubmitResult } userInfo={ userQuery.data }/>;
case 'result':
return <PublicTagsSubmitResult data={ submitResult }/>;
default:
return null;
}
})();
return (
<>
<PageTitle title="Request a public tag/label"/>
{ content }
</>
);
};
export default PublicTagsSubmit;
import { Box, Text, FormControl, FormLabel, Textarea, useColorModeValue } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react';
import type { PublicTags, PublicTag } from 'types/api/account';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
isOpen: boolean;
onClose: () => void;
data: PublicTag;
onDeleteSuccess: () => void;
}
const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDeleteSuccess }) => {
const [ reason, setReason ] = useState<string>('');
const tags = data.tags.split(';');
const queryClient = useQueryClient();
const apiFetch = useApiFetch();
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const deleteApiKey = useCallback(() => {
const body = { remove_reason: reason };
return apiFetch('public_tags', {
pathParams: { id: String(data.id) },
fetchParams: { method: 'DELETE', body },
});
}, [ data.id, apiFetch, reason ]);
const onSuccess = useCallback(async() => {
onDeleteSuccess();
queryClient.setQueryData([ resourceKey('public_tags') ], (prevData: PublicTags | undefined) => {
return prevData?.filter((item) => item.id !== data.id);
});
}, [ queryClient, data, onDeleteSuccess ]);
const onFieldChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
setReason(event.currentTarget.value);
}, []);
const renderContent = useCallback(() => {
let text;
if (tags.length === 1) {
text = (
<>
<Text display="inline" as="span">Public tag</Text>
<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tags[0] }" ` }</Text>
<Text as="span">will be removed.</Text>
</>
);
}
if (tags.length > 1) {
const tagsText: Array<JSX.Element | string> = [];
tags.forEach((tag, index) => {
if (index < tags.length - 2) {
tagsText.push(<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tag }"` }</Text>);
tagsText.push(',');
}
if (index === tags.length - 2) {
tagsText.push(<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>);
tagsText.push('and');
}
if (index === tags.length - 1) {
tagsText.push(<Text fontWeight="700" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>);
}
});
text = (
<>
<Text as="span">Public tags</Text>{ tagsText }<Text as="span">will be removed.</Text>
</>
);
}
return (
<>
<Box marginBottom={ 8 }>
{ text }
</Box>
<FormControl variant="floating" id="tag-delete" backgroundColor={ formBackgroundColor }>
<Textarea
size="lg"
value={ reason }
onChange={ onFieldChange }
/>
<FormLabel>Why do you want to remove tags?</FormLabel>
</FormControl>
</>
);
}, [ tags, reason, onFieldChange, formBackgroundColor ]);
return (
<DeleteModal
isOpen={ isOpen }
onClose={ onClose }
title="Request to remove a public tag"
renderContent={ renderContent }
mutationFn={ deleteApiKey }
onSuccess={ onSuccess }
/>
);
};
export default React.memo(DeletePublicTagModal);
import { VStack, Text, HStack, Skeleton } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { PublicTag } from 'types/api/account';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: PublicTag;
isLoading?: boolean;
onEditClick: (data: PublicTag) => void;
onDeleteClick: (data: PublicTag) => void;
}
const PublicTagListItem = ({ item, isLoading, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<ListItemMobile>
<VStack spacing={ 3 } alignItems="flex-start" maxW="100%">
<VStack spacing={ 4 } alignItems="unset" maxW="100%">
{ item.addresses_with_info.map((address) => (
<AddressEntity
key={ address.hash }
address={ address }
isLoading={ isLoading }
fontWeight="600"
w="100%"
/>
)) }
</VStack>
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Public tags</Text>
<HStack spacing={ 2 } alignItems="baseline">
{ item.tags.split(';').map((tag) => <Tag key={ tag } isLoading={ isLoading } isTruncated>{ tag }</Tag>) }
</HStack>
</HStack>
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Status</Text>
<Skeleton fontSize="sm" color="text_secondary" isLoaded={ !isLoading } display="inline-block">
<span>Submitted</span>
</Skeleton>
</HStack>
</VStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick } isLoading={ isLoading }/>
</ListItemMobile>
);
};
export default React.memo(PublicTagListItem);
import {
Table,
Thead,
Tbody,
Tr,
Th,
} from '@chakra-ui/react';
import React from 'react';
import type { PublicTags, PublicTag } from 'types/api/account';
import PublicTagTableItem from './PublicTagTableItem';
interface Props {
data?: PublicTags;
isLoading?: boolean;
onEditClick: (data: PublicTag) => void;
onDeleteClick: (data: PublicTag) => void;
}
const PublicTagTable = ({ data, isLoading, onEditClick, onDeleteClick }: Props) => {
return (
<Table variant="simple" minWidth="600px">
<Thead>
<Tr>
<Th width="50%">Smart contract / Address (0x...)</Th>
<Th width="25%">Public tag</Th>
<Th width="25%">Request status</Th>
<Th width="108px"></Th>
</Tr>
</Thead>
<Tbody>
{ data?.map((item, index) => (
<PublicTagTableItem
key={ item.id + (isLoading ? String(index) : '') }
item={ item }
isLoading={ isLoading }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
)) }
</Tbody>
</Table>
);
};
export default PublicTagTable;
import {
Tr,
Td,
VStack,
Box,
Skeleton,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { PublicTag } from 'types/api/account';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: PublicTag;
isLoading?: boolean;
onEditClick: (data: PublicTag) => void;
onDeleteClick: (data: PublicTag) => void;
}
const PublicTagTableItem = ({ item, isLoading, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<Tr alignItems="top" key={ item.id }>
<Td>
<VStack spacing={ 4 } alignItems="unset">
{ item.addresses_with_info.map((address) => (
<AddressEntity
key={ address.hash }
address={ address }
isLoading={ isLoading }
fontWeight="600"
py="2px"
/>
)) }
</VStack>
</Td>
<Td>
<VStack spacing={ 2 } alignItems="baseline">
{ item.tags.split(';').map((tag) => <Tag key={ tag } isLoading={ isLoading } isTruncated>{ tag }</Tag>) }
</VStack>
</Td>
<Td>
<Skeleton fontSize="sm" fontWeight="500" py="2px" isLoaded={ !isLoading } display="inline-block">
Submitted
</Skeleton>
</Td>
<Td>
<Box py="2px">
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick } isLoading={ isLoading }/>
</Box>
</Td>
</Tr>
);
};
export default React.memo(PublicTagTableItem);
import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import type { PublicTag } from 'types/api/account';
import useApiQuery from 'lib/api/useApiQuery';
import { PUBLIC_TAG } from 'stubs/account';
import PublicTagListItem from 'ui/publicTags/PublicTagTable/PublicTagListItem';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DeletePublicTagModal from './DeletePublicTagModal';
import PublicTagTable from './PublicTagTable/PublicTagTable';
type Props = {
changeToFormScreen: (data?: PublicTag) => void;
onTagDelete: () => void;
}
const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const deleteModalProps = useDisclosure();
const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>();
const { data, isPlaceholderData, isError } = useApiQuery('public_tags', {
queryOptions: {
placeholderData: Array(3).fill(PUBLIC_TAG),
},
});
const onDeleteModalClose = useCallback(() => {
setDeleteModalData(undefined);
deleteModalProps.onClose();
}, [ deleteModalProps ]);
const changeToForm = useCallback(() => {
changeToFormScreen();
}, [ changeToFormScreen ]);
const onItemEditClick = useCallback((item: PublicTag) => {
changeToFormScreen(item);
}, [ changeToFormScreen ]);
const onItemDeleteClick = useCallback((item: PublicTag) => {
setDeleteModalData(item);
deleteModalProps.onOpen();
}, [ deleteModalProps ]);
const description = (
<AccountPageDescription>
You can request a public category tag which is displayed to all Blockscout users.
Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag.
Clicking a tag opens a page with related information and helps provide context and data organization.
Requests are sent to a moderator for review and approval. This process can take several days.
</AccountPageDescription>
);
if (isError) {
return <DataFetchAlert/>;
}
const list = (
<>
<Box display={{ base: 'block', lg: 'none' }}>
{ data?.map((item, index) => (
<PublicTagListItem
key={ item.id + (isPlaceholderData ? String(index) : '') }
item={ item }
isLoading={ isPlaceholderData }
onDeleteClick={ onItemDeleteClick }
onEditClick={ onItemEditClick }
/>
)) }
</Box>
<Box display={{ base: 'none', lg: 'block' }}>
<PublicTagTable data={ data } isLoading={ isPlaceholderData } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/>
</Box>
</>
);
return (
<>
{ description }
{ Boolean(data?.length) && list }
<Skeleton mt={ 8 } isLoaded={ !isPlaceholderData } display="inline-block">
<Button
size="lg"
onClick={ changeToForm }
>
Request to add public tag
</Button>
</Skeleton>
{ deleteModalData && (
<DeletePublicTagModal
{ ...deleteModalProps }
onClose={ onDeleteModalClose }
data={ deleteModalData }
onDeleteSuccess={ onTagDelete }
/>
) }
</>
);
};
export default PublicTagsData;
import { RadioGroup, Radio, Stack } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Inputs } from './PublicTagsForm';
interface Props {
control: Control<Inputs>;
isDisabled?: boolean;
}
export default function PublicTagFormAction({ control, isDisabled }: Props) {
const renderRadioGroup = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'action'>}) => {
return (
<RadioGroup defaultValue="add" colorScheme="blue" { ...field }>
<Stack spacing={ 5 }>
<Radio value="add">
I want to add tags for my project
</Radio>
<Radio value="report" isDisabled={ isDisabled }>
I want to report an incorrect public tag
</Radio>
</Stack>
</RadioGroup>
);
}, [ isDisabled ]);
return (
<Controller
name="action"
control={ control }
render={ renderRadioGroup }
/>
);
}
import type { InputProps } from '@chakra-ui/react';
import { IconButton, Flex } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
import IconSvg from 'ui/shared/IconSvg';
import type { Inputs } from './PublicTagsForm';
interface Props {
control: Control<Inputs>;
index: number;
fieldsLength: number;
error?: FieldError;
onAddFieldClick: (e: React.SyntheticEvent) => void;
onRemoveFieldClick: (index: number) => (e: React.SyntheticEvent) => void;
size?: InputProps['size'];
}
const MAX_INPUTS_NUM = 10;
export default function PublicTagFormAction({ control, index, fieldsLength, error, onAddFieldClick, onRemoveFieldClick, size }: Props) {
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, `addresses.${ number }.address`>}) => {
return (
<AddressInput<Inputs, `addresses.${ number }.address`>
field={ field }
error={ error }
size={ size }
placeholder="Smart contract / Address (0x...)"
/>
);
}, [ error, size ]);
return (
<Flex flexDir="column" rowGap={ 5 } alignItems="flex-end">
<Controller
name={ `addresses.${ index }.address` }
control={ control }
render={ renderAddressInput }
rules={{
pattern: ADDRESS_REGEXP,
required: index === 0,
}}
/>
<Flex
columnGap={ 5 }
position={{ base: 'static', lg: 'absolute' }}
left={{ base: 'auto', lg: 'calc(100% + 20px)' }}
h="100%"
alignItems="center"
>
{ fieldsLength > 1 && (
<IconButton
aria-label="delete"
variant="outline"
w="30px"
h="30px"
onClick={ onRemoveFieldClick(index) }
icon={ <IconSvg name="minus" w="20px" h="20px"/> }
/>
) }
{ index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && (
<IconButton
aria-label="add"
variant="outline"
w="30px"
h="30px"
onClick={ onAddFieldClick }
icon={ <IconSvg name="plus" w="20px" h="20px"/> }
/>
) }
</Flex>
</Flex>
);
}
import type { InputProps } from '@chakra-ui/react';
import { FormControl, Textarea } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from './PublicTagsForm';
const TEXT_INPUT_MAX_LENGTH = 255;
interface Props {
control: Control<Inputs>;
error?: FieldError;
size?: InputProps['size'];
}
export default function PublicTagFormComment({ control, error, size }: Props) {
const renderComment = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'comment'>}) => {
return (
<FormControl variant="floating" id={ field.name } size={ size } isRequired>
<Textarea
{ ...field }
isInvalid={ Boolean(error) }
/>
<InputPlaceholder text="Specify the reason for adding tags and color preference(s)" error={ error }/>
</FormControl>
);
}, [ error, size ]);
return (
<Controller
name="comment"
control={ control }
render={ renderComment }
rules={{
maxLength: TEXT_INPUT_MAX_LENGTH,
required: true,
}}
/>
);
}
import {
Button,
Box,
Grid,
GridItem,
Text,
HStack,
chakra,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { FieldError, Path, SubmitHandler } from 'react-hook-form';
import { useForm, useFieldArray } from 'react-hook-form';
import type { PublicTags, PublicTag, PublicTagNew, PublicTagErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { EMAIL_REGEXP } from 'lib/validations/email';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
import PublicTagFormAction from './PublicTagFormAction';
import PublicTagFormAddressInput from './PublicTagFormAddressInput';
import PublicTagFormComment from './PublicTagFormComment';
import PublicTagsFormInput from './PublicTagsFormInput';
type Props = {
changeToDataScreen: (success?: boolean) => void;
data?: Partial<PublicTag>;
}
export type Inputs = {
fullName?: string;
email?: string;
companyName?: string;
companyUrl?: string;
action: 'add' | 'report';
tags?: string;
addresses?: Array<{
name: string;
address: string;
}>;
comment?: string;
}
const placeholders = {
fullName: 'Your name',
email: 'Email',
companyName: 'Company name',
companyUrl: 'Company website',
tags: 'Public tag (max 35 characters)',
comment: 'Specify the reason for adding tags and color preference(s).',
} as Record<Path<Inputs>, string>;
const ADDRESS_INPUT_BUTTONS_WIDTH = 100;
const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const queryClient = useQueryClient();
const apiFetch = useApiFetch();
const inputSize = { base: 'md', lg: 'lg' };
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
defaultValues: {
fullName: data?.full_name || '',
email: data?.email || '',
companyName: data?.company || '',
companyUrl: data?.website || '',
tags: data?.tags?.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses?.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ],
comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report',
},
mode: 'onTouched',
});
const { fields, append, remove } = useFieldArray({
name: 'addresses',
control,
});
const [ isAlertVisible, setAlertVisible ] = useState(false);
const onAddFieldClick = useCallback(() => append({ address: '', name: '' }), [ append ]);
const onRemoveFieldClick = useCallback((index: number) => () => remove(index), [ remove ]);
const updatePublicTag = (formData: Inputs) => {
const body: PublicTagNew = {
full_name: formData.fullName || '',
email: formData.email || '',
company: formData.companyName || '',
website: formData.companyUrl || '',
is_owner: formData.action === 'add',
addresses: formData.addresses?.map(({ address }) => address) || [],
tags: formData.tags?.split(';').map((s) => s.trim()).join(';') || '',
additional_comment: formData.comment || '',
};
if (!data?.id) {
return apiFetch('public_tags', { fetchParams: { method: 'POST', body } });
}
return apiFetch('public_tags', {
pathParams: { id: String(data.id) },
fetchParams: { method: 'PUT', body },
});
};
const mutation = useMutation({
mutationFn: updatePublicTag,
onSuccess: async(data) => {
const response = data as unknown as PublicTag;
queryClient.setQueryData([ resourceKey('public_tags') ], (prevData: PublicTags | undefined) => {
const isExisting = prevData && prevData.some((item) => item.id === response.id);
if (isExisting) {
return prevData.map((item) => {
if (item.id === response.id) {
return response;
}
return item;
});
}
return [ response, ...(prevData || []) ];
});
changeToDataScreen(true);
},
onError: (error: ResourceErrorAccount<PublicTagErrors>) => {
const errorMap = error.payload?.errors;
if (errorMap?.full_name || errorMap?.email || errorMap?.tags || errorMap?.addresses || errorMap?.additional_comment) {
errorMap?.full_name && setError('fullName', { type: 'custom', message: getErrorMessage(errorMap, 'full_name') });
errorMap?.email && setError('email', { type: 'custom', message: getErrorMessage(errorMap, 'email') });
errorMap?.tags && setError('tags', { type: 'custom', message: getErrorMessage(errorMap, 'tags') });
errorMap?.addresses && setError('addresses.0.address', { type: 'custom', message: getErrorMessage(errorMap, 'addresses') });
errorMap?.additional_comment && setError('comment', { type: 'custom', message: getErrorMessage(errorMap, 'additional_comment') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = useCallback((data) => {
setAlertVisible(false);
mutation.mutate(data);
}, [ mutation ]);
return (
<chakra.form
noValidate
width={{ base: 'auto', lg: `calc(100% - ${ ADDRESS_INPUT_BUTTONS_WIDTH }px)` }}
maxWidth="844px"
onSubmit={ handleSubmit(onSubmit) }
>
{ isAlertVisible && <Box mb={ 4 }><FormSubmitAlert/></Box> }
<Text size="sm" variant="secondary" paddingBottom={ 5 }>Company info</Text>
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 5 }>
<GridItem>
<PublicTagsFormInput<Inputs>
fieldName="fullName"
control={ control }
label={ placeholders.fullName }
error={ errors.fullName }
required
size={ inputSize }
/>
</GridItem>
<GridItem>
<PublicTagsFormInput<Inputs>
fieldName="companyName"
control={ control }
label={ placeholders.companyName }
error={ errors.companyName }
size={ inputSize }
/>
</GridItem>
<GridItem>
<PublicTagsFormInput<Inputs>
fieldName="email"
control={ control }
label={ placeholders.email }
pattern={ EMAIL_REGEXP }
error={ errors.email }
required
size={ inputSize }
/>
</GridItem>
<GridItem>
<PublicTagsFormInput<Inputs>
fieldName="companyUrl"
control={ control }
label={ placeholders.companyUrl }
error={ errors?.companyUrl }
size={ inputSize }
/>
</GridItem>
</Grid>
<Box marginTop={{ base: 5, lg: 8 }} marginBottom={{ base: 5, lg: 8 }}>
<PublicTagFormAction control={ control }/>
</Box>
<Text size="sm" variant="secondary" marginBottom={ 5 }>Public tags (2 tags maximum, please use &quot;;&quot; as a divider)</Text>
<Box marginBottom={ 4 }>
<PublicTagsFormInput<Inputs>
fieldName="tags"
control={ control }
label={ placeholders.tags }
error={ errors.tags }
required
size={ inputSize }
/>
</Box>
{ fields.map((field, index) => {
return (
<Box position="relative" key={ field.id } marginBottom={ 4 }>
<PublicTagFormAddressInput
control={ control }
error={ errors?.addresses?.[index]?.address as FieldError }
index={ index }
fieldsLength={ fields.length }
onAddFieldClick={ onAddFieldClick }
onRemoveFieldClick={ onRemoveFieldClick }
size={ inputSize }
/>
</Box>
);
}) }
<Box marginBottom={ 8 }>
<PublicTagFormComment control={ control } error={ errors.comment } size={ inputSize }/>
</Box>
<HStack spacing={ 6 }>
<Button
size="lg"
type="submit"
isDisabled={ !isDirty }
isLoading={ mutation.isPending }
>
Send request
</Button>
</HStack>
</chakra.form>
);
};
export default React.memo(PublicTagsForm);
import type { InputProps } from '@chakra-ui/react';
import { FormControl, Input } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const TEXT_INPUT_MAX_LENGTH = 255;
interface Props<TInputs extends FieldValues> {
fieldName: Path<TInputs>;
label: string;
required?: boolean;
control: Control<TInputs, object>;
pattern?: RegExp;
error?: FieldError;
size?: InputProps['size'];
}
export default function PublicTagsFormInput<Inputs extends FieldValues>({
label,
control,
required,
fieldName,
pattern,
error,
size,
}: Props<Inputs>) {
const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired={ required } size={ size }>
<Input
{ ...field }
required={ required }
isInvalid={ Boolean(error) }
maxLength={ TEXT_INPUT_MAX_LENGTH }
/>
<InputPlaceholder text={ label } error={ error }/>
</FormControl>
);
}, [ label, required, error, size ]);
return (
<Controller
name={ fieldName }
control={ control }
render={ renderInput }
rules={{ pattern, required }}
/>
);
}
import { Box } from '@chakra-ui/react';
import React from 'react';
import { publicTagTypes as configMock } from 'mocks/metadata/publicTagTypes';
import { base as useInfoMock } from 'mocks/user/profile';
import { expect, test } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import * as mocks from './mocks';
import PublicTagsSubmitForm from './PublicTagsSubmitForm';
const onSubmitResult = () => {};
test('base view +@mobile', async({ render }) => {
const component = await render(
<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}>
<PublicTagsSubmitForm config={ configMock } onSubmitResult={ onSubmitResult } userInfo={ useInfoMock }/>
</Box>,
);
await component.getByLabel(/Smart contract \/ Address/i).fill(mocks.address1);
await component.getByLabel(/add/i).nth(1).click();
await component.getByLabel('Tag (max 35 characters)*').fill(mocks.tag1.name);
await component.getByLabel(/label url/i).fill(mocks.tag1.meta.tagUrl);
await component.getByLabel(/background \(hex\)/i).fill(mocks.tag1.meta.bgColor);
await component.getByLabel(/text \(hex\)/i).fill(mocks.tag1.meta.textColor);
await component.getByLabel(/add/i).nth(3).click();
await component.getByLabel(/connection/i).focus();
await component.getByLabel(/connection/i).blur();
await expect(component).toHaveScreenshot({
mask: [ component.locator('.recaptcha') ],
maskColor: pwConfig.maskColor,
});
});
import { Button, chakra, Grid, GridItem } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields, FormSubmitResult } from './types';
import type { UserInfo } from 'types/api/account';
import type { PublicTagTypesResponse } from 'types/api/addressMetadata';
import appConfig from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorObj from 'lib/errors/getErrorObj';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useIsMobile from 'lib/hooks/useIsMobile';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import Hint from 'ui/shared/Hint';
import PublicTagsSubmitFieldAddresses from './fields/PublicTagsSubmitFieldAddresses';
import PublicTagsSubmitFieldCompanyName from './fields/PublicTagsSubmitFieldCompanyName';
import PublicTagsSubmitFieldCompanyWebsite from './fields/PublicTagsSubmitFieldCompanyWebsite';
import PublicTagsSubmitFieldDescription from './fields/PublicTagsSubmitFieldDescription';
import PublicTagsSubmitFieldRequesterEmail from './fields/PublicTagsSubmitFieldRequesterEmail';
import PublicTagsSubmitFieldRequesterName from './fields/PublicTagsSubmitFieldRequesterName';
import PublicTagsSubmitFieldTags from './fields/PublicTagsSubmitFieldTags';
import { convertFormDataToRequestsBody, getFormDefaultValues } from './utils';
interface Props {
config?: PublicTagTypesResponse | undefined;
userInfo?: UserInfo | undefined;
onSubmitResult: (result: FormSubmitResult) => void;
}
const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
const isMobile = useIsMobile();
const router = useRouter();
const apiFetch = useApiFetch();
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: getFormDefaultValues(router.query, userInfo),
});
React.useEffect(() => {
if (
router.query.addresses ||
router.query.requesterName ||
router.query.requesterEmail ||
router.query.companyName ||
router.query.companyWebsite
) {
router.replace({ pathname: '/public-tags/submit' }, undefined, { shallow: true });
}
}, [ router ]);
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
const requestsBody = convertFormDataToRequestsBody(data);
const result = await Promise.all(requestsBody.map(async(body) => {
return apiFetch<'public_tag_application', unknown, { message: string }>('public_tag_application', {
pathParams: { chainId: appConfig.chain.id },
fetchParams: {
method: 'POST',
body: { submission: body },
},
})
.then(() => ({ error: null, payload: body }))
.catch((error: unknown) => {
const errorObj = getErrorObj(error);
const messageFromPayload = getErrorObjPayload<{ message?: string }>(errorObj)?.message;
const messageFromError = errorObj && 'message' in errorObj && typeof errorObj.message === 'string' ? errorObj.message : undefined;
const message = messageFromPayload || messageFromError || 'Something went wrong.';
return { error: message, payload: body };
});
}));
onSubmitResult(result);
}, [ apiFetch, onSubmitResult ]);
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<Grid
columnGap={ 3 }
rowGap={ 3 }
templateColumns={{ base: '1fr', lg: '1fr 1fr minmax(0, 200px)', xl: '1fr 1fr minmax(0, 250px)' }}
>
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4">
Company info
</GridItem>
<PublicTagsSubmitFieldRequesterName/>
<PublicTagsSubmitFieldRequesterEmail/>
{ !isMobile && <div/> }
<PublicTagsSubmitFieldCompanyName/>
<PublicTagsSubmitFieldCompanyWebsite/>
{ !isMobile && <div/> }
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4" mt={{ base: 3, lg: 5 }}>
Public tags/labels
<Hint label="Submit a public tag proposal for our moderation team to review" ml={ 1 } color="link"/>
</GridItem>
<PublicTagsSubmitFieldAddresses/>
<PublicTagsSubmitFieldTags tagTypes={ config?.tagTypes }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<PublicTagsSubmitFieldDescription/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 3 }}>
<FormFieldReCaptcha/>
</GridItem>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 3 }
isDisabled={ !formApi.formState.isValid }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send request"
w="min-content"
>
Send request
</Button>
</Grid>
</chakra.form>
</FormProvider>
);
};
export default React.memo(PublicTagsSubmitForm);
import React from 'react';
import { expect, test, devices } from 'playwright/lib';
import * as mocks from './mocks';
import PublicTagsSubmitResult from './PublicTagsSubmitResult';
test('all success result view +@mobile', async({ render }) => {
const component = await render(<PublicTagsSubmitResult data={ mocks.allSuccessResponses }/>);
await expect(component).toHaveScreenshot();
});
test('result with errors view', async({ render }) => {
const component = await render(<PublicTagsSubmitResult data={ mocks.mixedResponses }/>);
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('result with errors view', async({ render }) => {
const component = await render(<PublicTagsSubmitResult data={ mocks.mixedResponses }/>);
await expect(component).toHaveScreenshot();
});
});
import { Alert, Box, Button, Flex, Grid, GridItem } from '@chakra-ui/react';
import _pickBy from 'lodash/pickBy';
import React from 'react';
import type { FormSubmitResult } from './types';
import { route } from 'nextjs-routes';
import makePrettyLink from 'lib/makePrettyLink';
import LinkExternal from 'ui/shared/links/LinkExternal';
import PublicTagsSubmitResultSuccess from './result/PublicTagsSubmitResultSuccess';
import PublicTagsSubmitResultWithErrors from './result/PublicTagsSubmitResultWithErrors';
import { groupSubmitResult } from './utils';
interface Props {
data: FormSubmitResult | undefined;
}
const PublicTagsSubmitResult = ({ data }: Props) => {
const groupedData = React.useMemo(() => groupSubmitResult(data), [ data ]);
if (!groupedData) {
return null;
}
const hasErrors = groupedData.items.some((item) => item.error !== null);
const companyWebsite = makePrettyLink(groupedData.companyWebsite);
const startOverButtonQuery = hasErrors ? _pickBy({
requesterName: groupedData.requesterName,
requesterEmail: groupedData.requesterEmail,
companyName: groupedData.companyName,
companyWebsite: groupedData.companyWebsite,
}, Boolean) : undefined;
return (
<div>
{ !hasErrors && (
<Alert status="success" mb={ 6 }>
Success! All tags went into moderation pipeline and soon will appear in the explorer.
</Alert>
) }
<Box as="h2" textStyle="h4">Company info</Box>
<Grid rowGap={ 3 } columnGap={ 6 } gridTemplateColumns="170px 1fr" mt={ 6 }>
<GridItem>Your name</GridItem>
<GridItem>{ groupedData.requesterName }</GridItem>
<GridItem>Email</GridItem>
<GridItem>{ groupedData.requesterEmail }</GridItem>
{ groupedData.companyName && (
<>
<GridItem>Company name</GridItem>
<GridItem>{ groupedData.companyName }</GridItem>
</>
) }
{ companyWebsite && (
<>
<GridItem>Company website</GridItem>
<GridItem>
<LinkExternal href={ companyWebsite.url }>{ companyWebsite.domain }</LinkExternal>
</GridItem>
</>
) }
</Grid>
<Box as="h2" textStyle="h4" mt={ 8 } mb={ 5 }>Public tags/labels</Box>
{ hasErrors ? <PublicTagsSubmitResultWithErrors data={ groupedData }/> : <PublicTagsSubmitResultSuccess data={ groupedData }/> }
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 6 } mt={ 8 } rowGap={ 3 }>
{ hasErrors && (
<Button size="lg" variant="outline" as="a" href={ route({ pathname: '/public-tags/submit', query: startOverButtonQuery }) }>
Start over
</Button>
) }
<Button size="lg" as="a" href={ route({ pathname: '/public-tags/submit' }) }>Add new tag</Button>
</Flex>
</div>
);
};
export default React.memo(PublicTagsSubmitResult);
import { FormControl, GridItem, IconButton, Input } from '@chakra-ui/react';
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import IconSvg from 'ui/shared/IconSvg';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const LIMIT = 20;
const PublicTagsSubmitFieldAddresses = () => {
const { control, formState, register } = useFormContext<FormFields>();
const { fields, insert, remove } = useFieldArray<FormFields, 'addresses'>({
name: 'addresses',
control,
});
const isDisabled = formState.isSubmitting;
const handleAddFieldClick = React.useCallback((event: React.MouseEvent) => {
const index = Number(event.currentTarget.getAttribute('data-index'));
if (!Object.is(index, NaN)) {
insert(index + 1, { hash: '' });
}
}, [ insert ]);
const handleRemoveFieldClick = React.useCallback((event: React.MouseEvent) => {
const index = Number(event.currentTarget.getAttribute('data-index'));
if (!Object.is(index, NaN)) {
remove(index);
}
}, [ remove ]);
return (
<>
{ fields.map((field, index) => {
const error = formState.errors?.addresses?.[ index ]?.hash;
return (
<React.Fragment key={ field.id }>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormControl variant="floating" isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...register(`addresses.${ index }.hash`, { required: true, pattern: ADDRESS_REGEXP }) }
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Smart contract / Address (0x...)" error={ error }/>
</FormControl>
</GridItem>
<GridItem display="flex" alignItems="center" columnGap={ 5 } justifyContent={{ base: 'flex-end', lg: 'flex-start' }}>
{ fields.length < LIMIT && index === fields.length - 1 && (
<IconButton
aria-label="add"
data-index={ index }
variant="outline"
boxSize="30px"
onClick={ handleAddFieldClick }
icon={ <IconSvg name="plus" boxSize={ 5 }/> }
isDisabled={ isDisabled }
/>
) }
{ fields.length > 1 && (
<IconButton
aria-label="delete"
data-index={ index }
variant="outline"
boxSize="30px"
onClick={ handleRemoveFieldClick }
icon={ <IconSvg name="minus" boxSize={ 5 }/> }
isDisabled={ isDisabled }
/>
) }
</GridItem>
</React.Fragment>
);
}) }
</>
);
};
export default React.memo(PublicTagsSubmitFieldAddresses);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldCompanyName = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'companyName'>({ control, name: 'companyName' });
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Company name" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldCompanyName);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { validator as urlValidator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldCompanyWebsite = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'companyWebsite'>({ control, name: 'companyWebsite', rules: { validate: urlValidator } });
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Company website" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldCompanyWebsite);
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import useIsMobile from 'lib/hooks/useIsMobile';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const MAX_LENGTH = 80;
const PublicTagsSubmitFieldDescription = () => {
const isMobile = useIsMobile();
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'description'>({
control,
name: 'description',
rules: { maxLength: MAX_LENGTH, required: true },
});
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
maxH="160px"
maxLength={ MAX_LENGTH }
/>
<InputPlaceholder
text={ isMobile ? 'Confirm the connection between addresses and tags.' : 'Provide a comment to confirm the connection between addresses and tags.' }
error={ fieldState.error }
/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldDescription);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldRequesterEmail = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'requesterEmail'>({
control,
name: 'requesterEmail',
rules: { required: true, pattern: EMAIL_REGEXP },
});
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Email" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldRequesterEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldRequesterName = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'requesterName'>({ control, name: 'requesterName', rules: { required: true } });
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Your name" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldRequesterName);
import { chakra, Flex, FormControl, Grid, GridItem, IconButton, Input, Textarea, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { type FieldError, type FieldErrorsImpl, type Merge, type UseFormRegister } from 'react-hook-form';
import type { FormFields, FormFieldTag } from '../types';
import type { PublicTagType } from 'types/api/addressMetadata';
import useIsMobile from 'lib/hooks/useIsMobile';
import { validator as colorValidator } from 'lib/validations/color';
import { validator as urlValidator } from 'lib/validations/url';
import EntityTag from 'ui/shared/EntityTags/EntityTag';
import IconSvg from 'ui/shared/IconSvg';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import PublicTagsSubmitFieldTagColor from './PublicTagsSubmitFieldTagColor';
import PublicTagsSubmitFieldTagType from './PublicTagsSubmitFieldTagType';
interface Props {
index: number;
field: FormFieldTag;
tagTypes: Array<PublicTagType> | undefined;
register: UseFormRegister<FormFields>;
errors: Merge<FieldError, FieldErrorsImpl<FormFieldTag>> | undefined;
isDisabled: boolean;
onAddClick?: (index: number) => void;
onRemoveClick?: (index: number) => void;
}
const PublicTagsSubmitFieldTag = ({ index, isDisabled, register, errors, onAddClick, onRemoveClick, tagTypes, field }: Props) => {
const isMobile = useIsMobile();
const bgColorDefault = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const bgColorError = useColorModeValue('red.50', 'red.900');
const handleAddClick = React.useCallback(() => {
onAddClick?.(index);
}, [ index, onAddClick ]);
const handleRemoveClick = React.useCallback(() => {
onRemoveClick?.(index);
}, [ index, onRemoveClick ]);
return (
<>
<GridItem colSpan={{ base: 1, lg: 2 }} p="10px" borderRadius="base" bgColor={ errors ? bgColorError : bgColorDefault }>
<Grid
rowGap={ 3 }
columnGap={ 3 }
templateColumns={{ base: '1fr', lg: 'repeat(4, 1fr)' }}
>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormControl variant="floating" isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...register(`tags.${ index }.name`, { required: true, maxLength: 35 }) }
isInvalid={ Boolean(errors?.name) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Tag (max 35 characters)" error={ errors?.name }/>
</FormControl>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<PublicTagsSubmitFieldTagType index={ index } tagTypes={ tagTypes } isDisabled={ isDisabled }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormControl variant="floating" size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...register(`tags.${ index }.url`, { validate: urlValidator }) }
isInvalid={ Boolean(errors?.url) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Label URL" error={ errors?.url }/>
</FormControl>
</GridItem>
<PublicTagsSubmitFieldTagColor
fieldType="bgColor"
fieldName={ `tags.${ index }.bgColor` }
placeholder="Background (Hex)"
index={ index }
register={ register }
error={ errors?.bgColor }
isDisabled={ isDisabled }
/>
<PublicTagsSubmitFieldTagColor
fieldType="textColor"
fieldName={ `tags.${ index }.textColor` }
placeholder="Text (Hex)"
index={ index }
register={ register }
error={ errors?.textColor }
isDisabled={ isDisabled }
/>
<GridItem colSpan={{ base: 1, lg: 4 }}>
<FormControl variant="floating" size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...register(`tags.${ index }.tooltipDescription`, { maxLength: 80 }) }
isInvalid={ Boolean(errors?.tooltipDescription) }
isDisabled={ isDisabled }
autoComplete="off"
maxH="160px"
/>
<InputPlaceholder
text="Label description (max 80 characters)"
error={ errors?.tooltipDescription }
/>
</FormControl>
</GridItem>
</Grid>
</GridItem>
<GridItem py={{ lg: '10px' }}>
<Flex
alignItems="center"
columnGap={ 5 }
justifyContent={{ base: 'flex-end', lg: 'flex-start' }}
h={{ base: 'auto', lg: '80px' }}
>
{ onAddClick && (
<IconButton
aria-label="add"
data-index={ index }
variant="outline"
boxSize="30px"
onClick={ handleAddClick }
icon={ <IconSvg name="plus" boxSize={ 5 }/> }
isDisabled={ isDisabled }
/>
) }
{ onRemoveClick && (
<IconButton
aria-label="delete"
data-index={ index }
variant="outline"
boxSize="30px"
onClick={ handleRemoveClick }
icon={ <IconSvg name="minus" boxSize={ 5 }/> }
isDisabled={ isDisabled }
/>
) }
</Flex>
{ !isMobile && (
<Flex flexDir="column" alignItems="flex-start" mt={ 10 } rowGap={ 2 }>
<EntityTag data={{
name: field.name || 'Tag name',
tagType: field.type.value,
meta: {
tagUrl: field.url,
bgColor: field.bgColor && colorValidator(field.bgColor) === true ? field.bgColor : undefined,
textColor: field.textColor && colorValidator(field.textColor) === true ? field.textColor : undefined,
tooltipDescription: field.tooltipDescription,
},
slug: 'new',
ordinal: 0,
}}/>
<chakra.span color="text_secondary" fontSize="sm">
{ tagTypes?.find(({ type }) => type === field.type.value)?.description }
</chakra.span>
</Flex>
) }
</GridItem>
</>
);
};
export default PublicTagsSubmitFieldTag;
import { Circle, FormControl, Input, InputGroup, InputRightElement, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useFormContext, type FieldError, type UseFormRegister } from 'react-hook-form';
import type { FormFields } from '../types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { validator as colorValidator } from 'lib/validations/color';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type ColorFieldTypes = 'bgColor' | 'textColor';
interface Props<Type extends ColorFieldTypes> {
fieldType: Type;
fieldName: `tags.${ number }.${ Type }`;
index: number;
isDisabled: boolean;
register: UseFormRegister<FormFields>;
error: FieldError | undefined;
placeholder: string;
}
const PublicTagsSubmitFieldTagColor = <Type extends ColorFieldTypes>({ isDisabled, error, fieldName, placeholder, fieldType }: Props<Type>) => {
const { register } = useFormContext<FormFields>();
const circleBgColorDefault = {
bgColor: useColorModeValue('gray.100', 'gray.700'),
textColor: useColorModeValue('blackAlpha.800', 'whiteAlpha.800'),
};
const isMobile = useIsMobile();
const field = register(fieldName, { validate: colorValidator, maxLength: 7 });
const [ value, setValue ] = React.useState('');
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = (() => {
const value = event.target.value;
if (value) {
if (value.length === 1 && value[0] !== '#') {
return `#${ value }`;
}
}
return value;
})();
setValue(nextValue);
field.onChange(event);
}, [ field ]);
return (
<FormControl variant="floating" size={{ base: 'md', lg: 'lg' }}>
<InputGroup size={ isMobile ? 'md' : 'lg' }>
<Input
{ ...field }
onChange={ handleChange }
value={ value }
isInvalid={ Boolean(error) }
isDisabled={ isDisabled }
autoComplete="off"
maxLength={ 7 }
/>
<InputPlaceholder text={ placeholder } error={ error }/>
<InputRightElement w="30px" h="auto" right={ 4 } top="50%" transform="translateY(-50%)" zIndex={ 10 }>
<Circle
size="30px"
bgColor={ value && colorValidator(value) === true ? value : circleBgColorDefault[fieldType] }
borderColor="gray.300"
borderWidth="1px"
/>
</InputRightElement>
</InputGroup>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldTagColor);
import { chakra, Flex, FormControl } from '@chakra-ui/react';
import type { GroupBase, SelectComponentsConfig, SingleValueProps } from 'chakra-react-select';
import { chakraComponents } from 'chakra-react-select';
import _capitalize from 'lodash/capitalize';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import type { PublicTagType } from 'types/api/addressMetadata';
import type { Option } from 'ui/shared/FancySelect/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
index: number;
tagTypes: Array<PublicTagType> | undefined;
isDisabled: boolean;
}
const PublicTagsSubmitFieldTagType = ({ index, tagTypes, isDisabled }: Props) => {
const isMobile = useIsMobile();
const { control, watch } = useFormContext<FormFields>();
const typeOptions = React.useMemo(() => tagTypes?.map((type) => ({
value: type.type,
label: _capitalize(type.type),
})), [ tagTypes ]);
const fieldValue = watch(`tags.${ index }.type`).value;
const selectComponents: SelectComponentsConfig<Option, boolean, GroupBase<Option>> = React.useMemo(() => {
type SingleValueComponentProps = SingleValueProps<Option, boolean, GroupBase<Option>> & { children: React.ReactNode }
const SingleValue = ({ children, ...props }: SingleValueComponentProps) => {
switch (fieldValue) {
case 'name': {
return (
<chakraComponents.SingleValue { ...props }>
<Flex alignItems="center" columnGap={ 1 }>
<IconSvg name="publictags_slim" boxSize={ 4 } flexShrink={ 0 } color="gray.400"/>
{ children }
</Flex>
</chakraComponents.SingleValue>
);
}
case 'protocol':
case 'generic': {
return (
<chakraComponents.SingleValue { ...props }>
<chakra.span color="gray.400">#</chakra.span> { children }
</chakraComponents.SingleValue>
);
}
default: {
return (<chakraComponents.SingleValue { ...props }>{ children }</chakraComponents.SingleValue>);
}
}
};
return { SingleValue };
}, [ fieldValue ]);
const renderControl = React.useCallback(({ field }: { field: ControllerRenderProps<FormFields, `tags.${ number }.type`> }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<FancySelect
{ ...field }
options={ typeOptions }
size={ isMobile ? 'md' : 'lg' }
placeholder="Tag type"
isDisabled={ isDisabled }
isRequired
isAsync={ false }
isSearchable={ false }
components={ selectComponents }
/>
</FormControl>
);
}, [ isDisabled, isMobile, selectComponents, typeOptions ]);
return (
<Controller
name={ `tags.${ index }.type` }
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(PublicTagsSubmitFieldTagType);
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import type { PublicTagType } from 'types/api/addressMetadata';
import PublicTagsSubmitFieldTag from './PublicTagsSubmitFieldTag';
const LIMIT = 5;
interface Props {
tagTypes: Array<PublicTagType> | undefined;
}
const PublicTagsSubmitFieldTags = ({ tagTypes }: Props) => {
const { control, formState, register, watch } = useFormContext<FormFields>();
const { fields, insert, remove } = useFieldArray<FormFields, 'tags'>({
name: 'tags',
control,
});
const isDisabled = formState.isSubmitting;
const handleAddFieldClick = React.useCallback((index: number) => {
insert(index + 1, {
name: '',
type: { label: 'Name', value: 'name' },
url: undefined,
bgColor: undefined,
textColor: undefined,
tooltipDescription: undefined,
});
}, [ insert ]);
const handleRemoveFieldClick = React.useCallback((index: number) => {
remove(index);
}, [ remove ]);
return (
<>
{ fields.map((field, index) => {
const errors = formState.errors?.tags?.[ index ];
return (
<PublicTagsSubmitFieldTag
key={ field.id }
field={ watch(`tags.${ index }`) }
index={ index }
tagTypes={ tagTypes }
register={ register }
errors={ errors }
isDisabled={ isDisabled }
onAddClick={ fields.length < LIMIT && index === fields.length - 1 ? handleAddFieldClick : undefined }
onRemoveClick={ fields.length > 1 ? handleRemoveFieldClick : undefined }
/>
);
}) }
</>
);
};
export default React.memo(PublicTagsSubmitFieldTags);
import type { FormSubmitResultItem } from './types';
export const address1 = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5851';
export const address2 = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5852';
export const address3 = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5853';
export const address4 = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5854';
export const address5 = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5855';
export const baseFields = {
requesterName: 'John Doe',
requesterEmail: 'jonh.doe@duck.me',
companyName: 'DuckDuckMe',
companyWebsite: 'https://duck.me',
description: 'Quack quack',
};
export const tag1 = {
name: 'Unicorn Uproar',
tagType: 'name' as const,
meta: {
tagUrl: 'https://example.com',
bgColor: '#ff1493',
textColor: '#FFFFFF',
tooltipDescription: undefined,
},
};
export const tag2 = {
name: 'Hello',
tagType: 'generic' as const,
meta: {
tooltipDescription: 'Hello, it is me... I was wondering if after all these years you would like to meet',
},
};
export const tag3 = {
name: 'duck owner 🦆',
tagType: 'classifier' as const,
meta: {
bgColor: '#fff300',
},
};
export const allSuccessResponses: Array<FormSubmitResultItem> = [
address1,
address2,
address3,
address4,
address5,
]
.map((address) => ([ tag1, tag2, tag3 ].map((tag) => ({
error: null,
payload: {
...baseFields,
...tag,
address,
},
}))))
.flat();
export const mixedResponses: Array<FormSubmitResultItem> = [
// address1
{
error: null,
payload: { address: address1, ...tag1 },
},
{
error: 'Some error',
payload: { address: address1, ...tag2 },
},
{
error: 'Some error',
payload: { address: address1, ...tag3 },
},
// address2
{
error: 'Some error',
payload: { address: address2, ...tag2 },
},
{
error: 'Some error',
payload: { address: address2, ...tag3 },
},
// address3
{
error: 'Some error',
payload: { address: address3, ...tag1 },
},
{
error: 'Another nasty error',
payload: { address: address3, ...tag2 },
},
{
error: null,
payload: { address: address3, ...tag3 },
},
].map((item) => ({ ...item, payload: { ...item.payload, ...baseFields } }));
import { Box, Flex, Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { FormSubmitResultGrouped } from '../types';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EntityTag from 'ui/shared/EntityTags/EntityTag';
interface Props {
data: FormSubmitResultGrouped;
}
const PublicTagsSubmitResultSuccess = ({ data }: Props) => {
return (
<Grid gridTemplateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 3 } columnGap={ 3 }>
<GridItem overflow="hidden">
<Box fontSize="sm" color="text_secondary" fontWeight={ 500 }>Smart contract / Address (0x...)</Box>
<Flex flexDir="column" rowGap={ 2 } mt={ 2 }>
{ data.items
.map(({ addresses }) => addresses)
.flat()
.map((hash) => (
<AddressEntity
key={ hash }
address={{ hash }}
noIcon
/>
)) }
</Flex>
</GridItem>
<GridItem>
<Box fontSize="sm" color="text_secondary" fontWeight={ 500 }>Tag</Box>
<Flex rowGap={ 2 } columnGap={ 2 } mt={ 2 } justifyContent="flex-start" flexWrap="wrap">
{ data.items
.map(({ tags }) => tags)
.flat()
.map((tag) => (
<EntityTag
key={ tag.name }
truncate
data={{
...tag,
slug: '',
ordinal: 0,
}}/>
)) }
</Flex>
</GridItem>
</Grid>
);
};
export default React.memo(PublicTagsSubmitResultSuccess);
import { Box, Button, Flex, Grid, GridItem, useColorModeValue } from '@chakra-ui/react';
import _pickBy from 'lodash/pickBy';
import React from 'react';
import type { FormSubmitResultGrouped } from '../types';
import { route } from 'nextjs-routes';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EntityTag from 'ui/shared/EntityTags/EntityTag';
interface Props {
data: FormSubmitResultGrouped;
}
const PublicTagsSubmitResultWithErrors = ({ data }: Props) => {
const isMobile = useIsMobile();
const bgColorSuccess = useColorModeValue('green.50', 'green.800');
const bgColorError = useColorModeValue('red.50', 'red.800');
return (
<Flex flexDir="column" rowGap={ 3 }>
{ data.items.map((item, index) => {
const startOverButtonQuery = _pickBy({
addresses: item.addresses,
requesterName: data.requesterName,
requesterEmail: data.requesterEmail,
companyName: data.companyName,
companyWebsite: data.companyWebsite,
}, Boolean);
return (
<Flex key={ index } flexDir={{ base: 'column', lg: 'row' }}>
<Box flexGrow={ 1 }>
<Grid
gridTemplateColumns={{ base: '1fr', lg: '1fr 1fr' }}
bgColor={ item.error ? bgColorError : bgColorSuccess }
borderRadius="base"
rowGap={ 3 }
>
<GridItem px={{ base: 4, lg: 6 }} pt={{ base: 2, lg: 4 }} pb={{ base: 0, lg: 4 }} overflow="hidden">
<Box fontSize="sm" color="text_secondary" fontWeight={ 500 }>Smart contract / Address (0x...)</Box>
<Flex flexDir="column" rowGap={ 2 } mt={ 2 }>
{ item.addresses.map((hash) => (
<AddressEntity
key={ hash }
address={{ hash }}
noIcon
/>
)) }
</Flex>
</GridItem>
<GridItem px={{ base: 4, lg: 6 }} pb={{ base: 2, lg: 4 }} pt={{ base: 0, lg: 4 }}>
<Box fontSize="sm" color="text_secondary" fontWeight={ 500 }>Tag</Box>
<Flex rowGap={ 2 } columnGap={ 2 } mt={ 2 } justifyContent="flex-start" flexWrap="wrap">
{ item.tags.map((tag) => (
<EntityTag
key={ tag.name }
truncate
data={{ ...tag, slug: '', ordinal: 0 }}
/>
)) }
</Flex>
</GridItem>
</Grid>
{ item.error && <Box color="red.500" mt={ 1 } fontSize="sm">{ item.error }</Box> }
</Box>
{ item.error && (
<Button
variant="outline"
size="sm"
flexShrink={ 0 }
mt={{ base: 1, lg: 6 }}
ml={{ base: 0, lg: 6 }}
w="min-content"
as="a"
href={ route({ pathname: '/public-tags/submit', query: startOverButtonQuery }) }
>
Start over
</Button>
) }
{ !item.error && !isMobile && <Box w="95px" ml={ 6 } flexShrink={ 0 }/> }
</Flex>
);
}) }
</Flex>
);
};
export default React.memo(PublicTagsSubmitResultWithErrors);
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
export interface FormFields {
requesterName: string;
requesterEmail: string;
companyName: string | undefined;
companyWebsite: string | undefined;
addresses: Array<{ hash: string }>;
tags: Array<FormFieldTag>;
description: string | undefined;
reCaptcha: string;
}
export interface FormFieldTag {
name: string;
type: {
label: string;
value: AddressMetadataTagType;
};
url: string | undefined;
bgColor: string | undefined;
textColor: string | undefined;
tooltipDescription: string | undefined;
}
export interface SubmitRequestBody {
requesterName: string;
requesterEmail: string;
companyName?: string;
companyWebsite?: string;
address: string;
name: string;
tagType: AddressMetadataTagType;
description?: string;
meta: {
bgColor?: string;
textColor?: string;
tagUrl?: string;
tooltipDescription?: string;
};
}
export interface FormSubmitResultItem {
error: string | null;
payload: SubmitRequestBody;
}
export type FormSubmitResult = Array<FormSubmitResultItem>;
export interface FormSubmitResultGrouped {
requesterName: string;
requesterEmail: string;
companyName?: string;
companyWebsite?: string;
items: Array<FormSubmitResultItemGrouped>;
}
export interface FormSubmitResultItemGrouped {
error: string | null;
addresses: Array<string>;
tags: Array<Pick<SubmitRequestBody, 'name' | 'tagType' | 'meta'>>;
}
import * as mocks from './mocks';
import { convertFormDataToRequestsBody, convertTagApiFieldsToFormFields, groupSubmitResult } from './utils';
describe('function convertFormDataToRequestsBody()', () => {
it('should convert form data to requests body', () => {
const formData = {
...mocks.baseFields,
reCaptcha: 'xxx',
addresses: [ { hash: mocks.address1 }, { hash: mocks.address2 } ],
tags: [ convertTagApiFieldsToFormFields(mocks.tag1), convertTagApiFieldsToFormFields(mocks.tag2) ],
};
const result = convertFormDataToRequestsBody(formData);
expect(result).toMatchObject([
{ address: mocks.address1, name: mocks.tag1.name, tagType: mocks.tag1.tagType },
{ address: mocks.address1, name: mocks.tag2.name, tagType: mocks.tag2.tagType },
{ address: mocks.address2, name: mocks.tag1.name, tagType: mocks.tag1.tagType },
{ address: mocks.address2, name: mocks.tag2.name, tagType: mocks.tag2.tagType },
]);
});
});
describe('function groupSubmitResult()', () => {
it('group success result', () => {
const result = groupSubmitResult(mocks.allSuccessResponses);
expect(result).toMatchObject({
requesterName: mocks.baseFields.requesterName,
requesterEmail: mocks.baseFields.requesterEmail,
companyName: mocks.baseFields.companyName,
companyWebsite: mocks.baseFields.companyWebsite,
items: [
{
error: null,
addresses: [ mocks.address1, mocks.address2, mocks.address3, mocks.address4, mocks.address5 ],
tags: [ mocks.tag1, mocks.tag2, mocks.tag3 ],
},
],
});
});
it('group result with error', () => {
const result = groupSubmitResult(mocks.mixedResponses);
expect(result).toMatchObject({
requesterName: mocks.baseFields.requesterName,
requesterEmail: mocks.baseFields.requesterEmail,
companyName: mocks.baseFields.companyName,
companyWebsite: mocks.baseFields.companyWebsite,
items: [
{
error: null,
addresses: [ mocks.address1 ],
tags: [ mocks.tag1 ],
},
{
error: null,
addresses: [ mocks.address3 ],
tags: [ mocks.tag3 ],
},
{
error: 'Some error',
addresses: [ mocks.address1, mocks.address2 ],
tags: [ mocks.tag2, mocks.tag3 ],
},
{
error: 'Some error',
addresses: [ mocks.address3 ],
tags: [ mocks.tag1 ],
},
{
error: 'Another nasty error',
addresses: [ mocks.address3 ],
tags: [ mocks.tag2 ],
},
],
});
});
});
import _isEqual from 'lodash/isEqual';
import _pickBy from 'lodash/pickBy';
import type { FormFieldTag, FormFields, FormSubmitResult, FormSubmitResultGrouped, FormSubmitResultItemGrouped, SubmitRequestBody } from './types';
import type { UserInfo } from 'types/api/account';
import type { Route } from 'nextjs-routes';
import getQueryParamString from 'lib/router/getQueryParamString';
export function convertFormDataToRequestsBody(data: FormFields): Array<SubmitRequestBody> {
const result: Array<SubmitRequestBody> = [];
for (const address of data.addresses) {
for (const tag of data.tags) {
result.push({
requesterName: data.requesterName,
requesterEmail: data.requesterEmail,
companyName: data.companyName,
companyWebsite: data.companyWebsite,
address: address.hash,
name: tag.name,
tagType: tag.type.value,
description: data.description,
meta: _pickBy({
bgColor: tag.bgColor,
textColor: tag.textColor,
tagUrl: tag.url,
tooltipDescription: tag.tooltipDescription,
}, Boolean),
});
}
}
return result;
}
export function convertTagApiFieldsToFormFields(tag: Pick<SubmitRequestBody, 'name' | 'tagType' | 'meta'>): FormFieldTag {
return {
name: tag.name,
type: { label: tag.tagType, value: tag.tagType },
url: tag.meta.tagUrl,
bgColor: tag.meta.bgColor,
textColor: tag.meta.textColor,
tooltipDescription: tag.meta.tooltipDescription,
};
}
export function groupSubmitResult(data: FormSubmitResult | undefined): FormSubmitResultGrouped | undefined {
if (!data) {
return;
}
const _items: Array<FormSubmitResultItemGrouped> = [];
// group by error and address
for (const item of data) {
const existingItem = _items.find(({ error, addresses }) => error === item.error && addresses.length === 1 && addresses[0] === item.payload.address);
if (existingItem) {
existingItem.tags.push({ name: item.payload.name, tagType: item.payload.tagType, meta: item.payload.meta });
continue;
}
_items.push({
error: item.error,
addresses: [ item.payload.address ],
tags: [ { name: item.payload.name, tagType: item.payload.tagType, meta: item.payload.meta } ],
});
}
const items: Array<FormSubmitResultItemGrouped> = [];
// merge items with the same error and tags
for (const item of _items) {
const existingItem = items.find(({ error, tags }) => error === item.error && _isEqual(tags, item.tags));
if (existingItem) {
existingItem.addresses.push(...item.addresses);
continue;
}
items.push(item);
}
return {
requesterName: data[0].payload.requesterName,
requesterEmail: data[0].payload.requesterEmail,
companyName: data[0].payload.companyName,
companyWebsite: data[0].payload.companyWebsite,
items: items.sort((a, b) => {
if (a.error && !b.error) {
return 1;
}
if (!a.error && b.error) {
return -1;
}
return 0;
}),
};
}
export function getFormDefaultValues(query: Route['query'], userInfo: UserInfo | undefined) {
return {
addresses: getAddressesFromQuery(query),
requesterName: getQueryParamString(query?.requesterName) || userInfo?.nickname || userInfo?.name || undefined,
requesterEmail: getQueryParamString(query?.requesterEmail) || userInfo?.email || undefined,
companyName: getQueryParamString(query?.companyName),
companyWebsite: getQueryParamString(query?.companyWebsite),
tags: [ { name: '', type: { label: 'Name', value: 'name' as const } } ],
};
}
function getAddressesFromQuery(query: Route['query']) {
if (!query?.addresses) {
return [ { hash: '' } ];
}
if (Array.isArray(query.addresses)) {
return query.addresses.map((hash) => ({ hash }));
}
return [ { hash: query.addresses } ];
}
...@@ -51,7 +51,7 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => { ...@@ -51,7 +51,7 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
}, },
{ {
render: (props: ItemProps) => <PublicTagMenuItem { ...props }/>, render: (props: ItemProps) => <PublicTagMenuItem { ...props }/>,
enabled: !isTxPage, enabled: !isTxPage && config.features.publicTagsSubmission.isEnabled,
}, },
].filter(({ enabled }) => enabled); ].filter(({ enabled }) => enabled);
......
...@@ -23,7 +23,7 @@ const PublicTagMenuItem = ({ className, hash, onBeforeClick, type }: Props) => { ...@@ -23,7 +23,7 @@ const PublicTagMenuItem = ({ className, hash, onBeforeClick, type }: Props) => {
return; return;
} }
router.push({ pathname: '/account/public-tags-request', query: { address: hash } }); router.push({ pathname: '/public-tags/submit', query: { addresses: [ hash ] } });
}, [ hash, onBeforeClick, router ]); }, [ hash, onBeforeClick, router ]);
const element = (() => { const element = (() => {
......
...@@ -39,7 +39,7 @@ const EntityTagPopover = ({ data, children }: Props) => { ...@@ -39,7 +39,7 @@ const EntityTagPopover = ({ data, children }: Props) => {
<PopoverTrigger> <PopoverTrigger>
{ children } { children }
</PopoverTrigger> </PopoverTrigger>
<PopoverContent bgColor={ bgColor } borderRadius="sm"> <PopoverContent bgColor={ bgColor } borderRadius="sm" maxW="300px" w="fit-content">
<PopoverArrow bgColor={ bgColor }/> <PopoverArrow bgColor={ bgColor }/>
<DarkMode> <DarkMode>
<PopoverBody color="white" p={ 2 } fontSize="sm" display="flex" flexDir="column" rowGap={ 2 }> <PopoverBody color="white" p={ 2 } fontSize="sm" display="flex" flexDir="column" rowGap={ 2 }>
......
import { Alert } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import ReCaptcha from 'react-google-recaptcha'; import ReCaptcha from 'react-google-recaptcha';
import type { UseFormReturn } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import type { FormFields } from './types';
import config from 'configs/app'; import config from 'configs/app';
interface Props { interface Props {
formApi: UseFormReturn<FormFields>; disabledFeatureMessage?: JSX.Element;
} }
const CsvExportFormReCaptcha = ({ formApi }: Props) => { const FormFieldReCaptcha = ({ disabledFeatureMessage }: Props) => {
const { register, unregister, trigger, clearErrors, setValue, resetField, setError, formState } = useFormContext();
const ref = React.useRef<ReCaptcha>(null); const ref = React.useRef<ReCaptcha>(null);
React.useEffect(() => { React.useEffect(() => {
formApi.register('reCaptcha', { required: true, shouldUnregister: true }); register('reCaptcha', { required: true, shouldUnregister: true });
return () => { return () => {
formApi.unregister('reCaptcha'); unregister('reCaptcha');
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
ref.current?.reset(); ref.current?.reset();
formApi.trigger('reCaptcha'); trigger('reCaptcha');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ formApi.formState.submitCount ]); }, [ formState.submitCount ]);
const handleReCaptchaChange = React.useCallback((token: string | null) => { const handleReCaptchaChange = React.useCallback((token: string | null) => {
if (token) { if (token) {
formApi.clearErrors('reCaptcha'); clearErrors('reCaptcha');
formApi.setValue('reCaptcha', token, { shouldValidate: true }); setValue('reCaptcha', token, { shouldValidate: true });
} }
}, [ formApi ]); }, [ clearErrors, setValue ]);
const handleReCaptchaExpire = React.useCallback(() => { const handleReCaptchaExpire = React.useCallback(() => {
formApi.resetField('reCaptcha'); resetField('reCaptcha');
formApi.setError('reCaptcha', { type: 'required' }); setError('reCaptcha', { type: 'required' });
}, [ formApi ]); }, [ resetField, setError ]);
const feature = config.features.csvExport;
if (!feature.isEnabled) { if (!config.services.reCaptcha.siteKey) {
return ( return disabledFeatureMessage ?? null;
<Alert status="error">
CSV export is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
);
} }
return ( return (
<ReCaptcha <ReCaptcha
className="recaptcha" className="recaptcha"
ref={ ref } ref={ ref }
sitekey={ feature.reCaptcha.siteKey } sitekey={ config.services.reCaptcha.siteKey }
onChange={ handleReCaptchaChange } onChange={ handleReCaptchaChange }
onExpired={ handleReCaptchaExpire } onExpired={ handleReCaptchaExpire }
/> />
); );
}; };
export default CsvExportFormReCaptcha; export default FormFieldReCaptcha;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment