Commit 7bfbb3a1 authored by tom's avatar tom

remove old public tags page

parent 3acc8615
......@@ -2,7 +2,6 @@ import { getFeaturePayload } from 'configs/app/features/types';
import type {
UserInfo,
CustomAbis,
PublicTags,
ApiKeys,
VerifiedAddressResponse,
TokenInfoApplicationConfig,
......@@ -147,10 +146,6 @@ export const RESOURCES = {
pathParams: [ 'id' as const ],
filterFields: [ ],
},
public_tags: {
path: '/api/account/v2/user/public_tags/:id?',
pathParams: [ 'id' as const ],
},
private_tags_address: {
path: '/api/account/v2/user/tags/address/:id?',
pathParams: [ 'id' as const ],
......@@ -863,7 +858,6 @@ export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>
export type ResourcePayloadA<Q extends ResourceName> =
Q extends 'user_info' ? UserInfo :
Q extends 'custom_abi' ? CustomAbis :
Q extends 'public_tags' ? PublicTags :
Q extends 'private_tags_address' ? AddressTagsResponse :
Q extends 'private_tags_tx' ? TransactionTagsResponse :
Q extends 'api_keys' ? ApiKeys :
......
......@@ -255,12 +255,6 @@ export default function useNavItems(): ReturnType {
icon: 'privattags',
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',
nextRoute: { pathname: '/account/api-key' as const },
......
......@@ -27,7 +27,6 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/account/watchlist': 'Regular page',
'/account/api-key': 'Regular page',
'/account/custom-abi': 'Regular page',
'/account/public-tags-request': 'Regular page',
'/account/tag-address': 'Regular page',
'/account/verified-addresses': 'Root page',
'/withdrawals': 'Root page',
......
......@@ -30,7 +30,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/account/watchlist': DEFAULT_TEMPLATE,
'/account/api-key': DEFAULT_TEMPLATE,
'/account/custom-abi': DEFAULT_TEMPLATE,
'/account/public-tags-request': DEFAULT_TEMPLATE,
'/account/tag-address': DEFAULT_TEMPLATE,
'/account/verified-addresses': DEFAULT_TEMPLATE,
'/withdrawals': DEFAULT_TEMPLATE,
......
......@@ -25,7 +25,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/account/watchlist': '- watchlist',
'/account/api-key': '- API keys',
'/account/custom-abi': '- custom ABI',
'/account/public-tags-request': '- public tag requests',
'/account/tag-address': '- private tags',
'/account/verified-addresses': '- my verified addresses',
'/withdrawals': 'withdrawals',
......
......@@ -25,7 +25,6 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/account/watchlist': 'Watchlist',
'/account/api-key': 'API keys',
'/account/custom-abi': 'Custom ABI',
'/account/public-tags-request': 'Public tags',
'/account/tag-address': 'Private tags',
'/account/verified-addresses': 'Verified addresses',
'/withdrawals': 'Withdrawals',
......
......@@ -9,7 +9,6 @@ declare module "nextjs-routes" {
| StaticRoute<"/404">
| StaticRoute<"/account/api-key">
| StaticRoute<"/account/custom-abi">
| StaticRoute<"/account/public-tags-request">
| StaticRoute<"/account/tag-address">
| StaticRoute<"/account/verified-addresses">
| StaticRoute<"/account/watchlist">
......
......@@ -48,18 +48,6 @@ const oldUrls = [
source: '/account/custom_abi/new',
destination: '/account/custom-abi',
},
{
source: '/account/public_tags_request',
destination: '/account/public-tags-request',
},
{
source: '/account/public_tags_request/:id/edit',
destination: '/account/public-tags-request',
},
{
source: '/account/public_tags_request/new',
destination: '/account/public-tags-request',
},
// TRANSACTIONS
{
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const PublicTags = dynamic(() => import('ui/pages/PublicTags'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/account/public-tags-request">
<PublicTags/>
</PageNextJs>
);
};
export default Page;
export { account 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 { TX_HASH } from './tx';
......@@ -16,19 +16,6 @@ export const PRIVATE_TAG_TX: TransactionTag = {
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 = {
address: ADDRESS_PARAMS,
address_balance: '7072643779453701031672',
......
......@@ -103,23 +103,6 @@ export type WatchlistResponse = {
} | 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 interface CustomAbi {
......@@ -175,14 +158,6 @@ export type TransactionTagErrors = {
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 {
userId: string;
chainId: string;
......
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 { 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 }}
/>
);
}
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