Commit b007a47e authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #93 from blockscout/public-keys-backend

public keys & backend
parents 29b8fd2b 09f3a1d1
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/public_tags/${ req.query.id }`;
};
const publicTagsHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default publicTagsHandler;
import type { PublicTags } from 'types/api/account';
import handler from 'lib/api/handler';
const publicKeysHandler = handler<PublicTags>(() => '/account/v1/user/public_tags', [ 'GET', 'POST' ]);
export default publicKeysHandler;
......@@ -72,6 +72,24 @@ export interface WatchlistAddressNew {
export type WatchlistAddresses = Array<WatchlistAddress>
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: string; // address_1;<address_2;address_3 etc.
additional_comment: string;
}
export type PublicTagNew = Omit<PublicTag, 'addresses' | 'id'> & {
addresses_array: Array<string>;
}
export type PublicTags = Array<PublicTag>;
export type CustomAbis = Array<CustomAbi>
export interface CustomAbi {
......
......@@ -54,7 +54,7 @@ const ApiKeysPage: React.FC = () => {
return (
<>
<SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
<Skeleton height="48px" width="156px" marginTop={ 8 }/>
</>
);
}
......
......@@ -5,7 +5,8 @@ import {
import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll';
import type { TPublicTagItem } from 'data/publicTags';
import type { PublicTag } from 'types/api/account';
import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
......@@ -20,9 +21,9 @@ const toastDescriptions = {
removed: 'Tags have been removed.',
} as Record<TToastAction, string>;
const PublicTags: React.FC = () => {
const PublicTagsComponent: React.FC = () => {
const [ screen, setScreen ] = useState<TScreen>('data');
const [ formData, setFormData ] = useState<TPublicTagItem>();
const [ formData, setFormData ] = useState<PublicTag>();
const toast = useToast();
......@@ -39,7 +40,7 @@ const PublicTags: React.FC = () => {
});
}, [ toast ]);
const changeToFormScreen = useCallback((data?: TPublicTagItem) => {
const changeToFormScreen = useCallback((data?: PublicTag) => {
setFormData(data);
setScreen('form');
animateScroll.scrollToTop({
......@@ -82,4 +83,4 @@ const PublicTags: React.FC = () => {
);
};
export default PublicTags;
export default PublicTagsComponent;
import { Flex, Text, FormControl, FormLabel, Textarea } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react';
import type { TPublicTag } from 'data/publicTags';
import type { PublicTags, PublicTag } from 'types/api/account';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
isOpen: boolean;
onClose: () => void;
tags: Array<TPublicTag>;
data: PublicTag;
onDeleteSuccess: () => void;
}
const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, tags = [], onDeleteSuccess }) => {
const onDelete = useCallback(() => {
// eslint-disable-next-line no-console
console.log('delete', tags);
onDeleteSuccess();
}, [ tags, onDeleteSuccess ]);
const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDeleteSuccess }) => {
const [ reason, setReason ] = useState<string>('');
const tags = data.tags.split(';');
const queryClient = useQueryClient();
const deleteApiKey = (reason: string) => {
const body = JSON.stringify({ remove_reason: reason });
return fetch(`/api/account/public-tags/${ data.id }`, { method: 'DELETE', body });
};
const mutation = useMutation(deleteApiKey, {
onSuccess: async() => {
queryClient.setQueryData([ 'public-tags' ], (prevData: PublicTags | undefined) => {
return prevData?.filter((item) => item.id !== data.id);
});
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
});
const onDelete = useCallback(() => {
mutation.mutate(reason);
onDeleteSuccess();
}, [ reason, mutation, onDeleteSuccess ]);
const onFieldChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
setReason(event.currentTarget.value);
}, []);
......@@ -31,7 +54,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, tags = [], onD
text = (
<>
<Text display="flex">Public tag</Text>
<Text fontWeight="600" whiteSpace="pre">{ ` "${ tags[0].name }" ` }</Text>
<Text fontWeight="600" whiteSpace="pre">{ ` "${ tags[0] }" ` }</Text>
<Text>will be removed.</Text>
</>
);
......@@ -40,15 +63,15 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, tags = [], onD
const tagsText: Array<JSX.Element | string> = [];
tags.forEach((tag, index) => {
if (index < tags.length - 2) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag.name }"` }</Text>);
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag }"` }</Text>);
tagsText.push(',');
}
if (index === tags.length - 2) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag.name }" ` }</Text>);
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag }" ` }</Text>);
tagsText.push('and');
}
if (index === tags.length - 1) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag.name }" ` }</Text>);
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag }" ` }</Text>);
}
});
text = (
......@@ -81,8 +104,9 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, tags = [], onD
onDelete={ onDelete }
title="Request to remove a public tag"
renderContent={ renderContent }
pending={ mutation.isLoading }
/>
);
};
export default DeletePublicTagModal;
export default React.memo(DeletePublicTagModal);
......@@ -8,14 +8,14 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import type { TPublicTagItem, TPublicTags } from 'data/publicTags';
import type { PublicTags, PublicTag } from 'types/api/account';
import PublicTagTableItem from './PublicTagTableItem';
interface Props {
data: TPublicTags;
onEditClick: (data: TPublicTagItem) => void;
onDeleteClick: (data: TPublicTagItem) => void;
data: PublicTags;
onEditClick: (data: PublicTag) => void;
onDeleteClick: (data: PublicTag) => void;
}
const PublicTagTable = ({ data, onEditClick, onDeleteClick }: Props) => {
......@@ -24,14 +24,14 @@ const PublicTagTable = ({ data, onEditClick, onDeleteClick }: Props) => {
<Table variant="simple" minWidth="600px">
<Thead>
<Tr>
<Th width="60%">Smart contract / Address (0x...)</Th>
<Th width="40%">Public tag</Th>
<Th width="200px">Submission date</Th>
<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: TPublicTagItem) => (
{ data.map((item) => (
<PublicTagTableItem
item={ item }
key={ item.id }
......
import {
Box,
Tag,
Text,
Tr,
Td,
HStack,
VStack,
useColorModeValue,
Text,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { TPublicTagItem, TPublicTagAddress, TPublicTag } from 'data/publicTags';
import type { PublicTag } from 'types/api/account';
import AddressIcon from 'ui/shared/AddressIcon';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
import DeleteButton from 'ui/shared/DeleteButton';
......@@ -18,9 +18,9 @@ import EditButton from 'ui/shared/EditButton';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props {
item: TPublicTagItem;
onEditClick: (data: TPublicTagItem) => void;
onDeleteClick: (data: TPublicTagItem) => void;
item: PublicTag;
onEditClick: (data: PublicTag) => void;
onDeleteClick: (data: PublicTag) => void;
}
const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
......@@ -32,19 +32,18 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
return (
<Tr alignItems="top" key={ item.id }>
<Td>
<VStack spacing={ 4 } alignItems="unset">
{ item.addresses.map((adr: TPublicTagAddress) => {
{ item.addresses.split(';').map((address) => {
return (
<HStack spacing={ 4 } key={ adr.address } overflow="hidden" alignItems="start">
<AddressIcon address={ adr.address }/>
<HStack spacing={ 4 } key={ address } overflow="hidden" alignItems="start">
<AddressIcon address={ address }/>
<Box overflow="hidden">
<AddressLinkWithTooltip address={ adr.address }/>
{ adr.addressName && <Text fontSize="sm" color={ secondaryColor } mt={ 0.5 }>{ adr.addressName }</Text> }
<AddressLinkWithTooltip address={ address }/>
{ /* will be added later */ }
{ /* <Text fontSize="sm" variant="secondary" mt={ 0.5 }>Address Name</Text> */ }
</Box>
</HStack>
);
......@@ -53,11 +52,11 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</Td>
<Td>
<VStack spacing={ 2 } alignItems="baseline">
{ item.tags.map((tag: TPublicTag) => {
{ item.tags.split(';').map((tag) => {
return (
<TruncatedTextTooltip label={ tag.name } key={ tag.name }>
<Tag color={ tag.colorHex || 'gray.600' } background={ tag.backgroundHex || 'gray.200' } lineHeight="24px">
{ tag.name }
<TruncatedTextTooltip label={ tag } key={ tag }>
<Tag variant="gray" lineHeight="24px">
{ tag }
</Tag>
</TruncatedTextTooltip>
);
......@@ -65,7 +64,9 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</VStack>
</Td>
<Td>
<Text fontSize="sm" color={ secondaryColor }>{ item.date }</Text>
<VStack alignItems="flex-start">
<Text fontSize="sm" fontWeight="500">Submitted</Text>
</VStack>
</Td>
<Td>
<HStack spacing={ 6 }>
......@@ -77,4 +78,4 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
);
};
export default PublicTagTableItem;
export default React.memo(PublicTagTableItem);
import { Box, Text, Button, useDisclosure } from '@chakra-ui/react';
import { Box, Text, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TPublicTagItem, TPublicTag } from 'data/publicTags';
import { publicTags } from 'data/publicTags';
import type { PublicTags, PublicTag } from 'types/api/account';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePublicTagModal from './DeletePublicTagModal';
import PublicTagTable from './PublicTagTable/PublicTagTable';
type Props = {
changeToFormScreen: (data?: TPublicTagItem) => void;
changeToFormScreen: (data?: PublicTag) => void;
onTagDelete: () => void;
}
const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const deleteModalProps = useDisclosure();
const [ deleteModalData, setDeleteModalData ] = useState<Array<TPublicTag>>([]);
const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>();
const { data, isLoading, isError } = useQuery<unknown, unknown, PublicTags>([ 'public-tags' ], async() => {
const response = await fetch('/api/account/public-tags');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
const onDeleteModalClose = useCallback(() => {
setDeleteModalData([]);
setDeleteModalData(undefined);
deleteModalProps.onClose();
}, [ deleteModalProps ]);
......@@ -25,15 +35,41 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
changeToFormScreen();
}, [ changeToFormScreen ]);
const onItemEditClick = useCallback((item: TPublicTagItem) => {
const onItemEditClick = useCallback((item: PublicTag) => {
changeToFormScreen(item);
}, [ changeToFormScreen ]);
const onItemDeleteClick = useCallback((item: TPublicTagItem) => {
setDeleteModalData(item.tags);
const onItemDeleteClick = useCallback((item: PublicTag) => {
setDeleteModalData(item);
deleteModalProps.onOpen();
}, [ deleteModalProps ]);
const content = (() => {
if (isLoading || isError) {
return (
<>
<SkeletonTable columns={ [ '50%', '25%', '25%', '108px' ] }/>
<Skeleton height="48px" width="270px" marginTop={ 8 }/>
</>
);
}
return (
<>
{ data.length > 0 && <PublicTagTable data={ data } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/> }
<Box marginTop={ 8 }>
<Button
variant="primary"
size="lg"
onClick={ changeToForm }
>
Request to add public tag
</Button>
</Box>
</>
);
})();
return (
<>
<Text marginBottom={ 12 }>
......@@ -42,22 +78,15 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
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.
</Text>
<PublicTagTable data={ publicTags } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/>
<Box marginTop={ 8 }>
<Button
variant="primary"
size="lg"
onClick={ changeToForm }
>
Request to add public tag
</Button>
</Box>
<DeletePublicTagModal
{ ...deleteModalProps }
onClose={ onDeleteModalClose }
tags={ deleteModalData }
onDeleteSuccess={ onTagDelete }
/>
{ content }
{ deleteModalData && (
<DeletePublicTagModal
{ ...deleteModalProps }
onClose={ onDeleteModalClose }
data={ deleteModalData }
onDeleteSuccess={ onTagDelete }
/>
) }
</>
);
};
......
......@@ -7,24 +7,24 @@ import type { Inputs } from './PublicTagsForm';
interface Props {
control: Control<Inputs>;
canReport: boolean;
isDisabled?: boolean;
}
export default function PublicTagFormAction({ control, canReport }: Props) {
export default function PublicTagFormAction({ control, isDisabled }: Props) {
const renderRadioGroup = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'action'>}) => {
return (
<RadioGroup defaultValue="add" value={ field.value } colorScheme="blue">
<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={ canReport }>
<Radio value="report" isDisabled={ isDisabled }>
I want to report an incorrect public tag
</Radio>
</Stack>
</RadioGroup>
);
}, [ canReport ]);
}, [ isDisabled ]);
return (
<Controller
......
......@@ -6,11 +6,13 @@ import {
Text,
HStack,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { Path } from 'react-hook-form';
import type { Path, SubmitHandler } from 'react-hook-form';
import { useForm, useFieldArray } from 'react-hook-form';
import type { TPublicTagItem, TPublicTag, TPublicTagAddress } from 'data/publicTags';
import type { PublicTags, PublicTag, PublicTagNew } from 'types/api/account';
import { EMAIL_REGEXP } from 'lib/validations/email';
import PublicTagFormAction from './PublicTagFormAction';
......@@ -20,45 +22,48 @@ import PublicTagsFormInput from './PublicTagsFormInput';
type Props = {
changeToDataScreen: (success?: boolean) => void;
data?: TPublicTagItem;
data?: PublicTag;
}
export type Inputs = {
userName: string;
userEmail: string;
companyName: string;
companyUrl: string;
fullName?: string;
email?: string;
companyName?: string;
companyUrl?: string;
action: 'add' | 'report';
tag: string;
addresses: Array<{
tags?: string;
addresses?: Array<{
name: string;
address: string;
}>;
comment: string;
comment?: string;
}
const placeholders = {
userName: 'Your name',
userEmail: 'Email',
fullName: 'Your name',
email: 'Email',
companyName: 'Company name',
companyUrl: 'Company website',
tag: 'Public tag (max 35 characters)',
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 = 170;
const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const queryClient = useQueryClient();
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
defaultValues: {
userName: data?.userName || '',
userEmail: data?.userEmail || '',
companyName: data?.companyName || '',
companyUrl: data?.companyUrl || '',
tag: data?.tags.map((tag: TPublicTag) => tag.name).join('; ') || '',
addresses: data?.addresses.map((adr: TPublicTagAddress, index: number) => ({ name: `address.${ index }.address`, address: adr.address })) ||
fullName: data?.full_name || '',
email: data?.email || '',
companyName: data?.company || '',
companyUrl: data?.website || '',
tags: data?.tags.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses.split(';').map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ],
comment: data?.comment || '',
comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report',
},
mode: 'all',
});
......@@ -69,10 +74,61 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
});
const onAddFieldClick = useCallback(() => append({ address: '' }), [ append ]);
const onRemoveFieldClick = useCallback((index: number) => () => remove(index), [ remove ]);
const updatePublicTag = (formData: Inputs) => {
const payload: PublicTagNew = {
full_name: formData.fullName || '',
email: formData.email || '',
company: formData.companyName || '',
website: formData.companyUrl || '',
is_owner: formData.action === 'add',
addresses_array: formData.addresses?.map(({ address }) => address) || [],
tags: formData.tags?.split(';').map((s) => s.trim()).join(';') || '',
additional_comment: formData.comment || '',
};
const body = JSON.stringify(payload);
if (!data?.id) {
return fetch('/api/account/public-tags', { method: 'POST', body });
}
return fetch(`/api/account/public-tags/${ data.id }`, { method: 'PUT', body });
};
const mutation = useMutation(updatePublicTag, {
onSuccess: async(data) => {
const response: PublicTag = await data.json();
queryClient.setQueryData([ '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 [ ...(prevData || []), response ];
});
changeToDataScreen(true);
},
// eslint-disable-next-line no-console
onError: console.error,
});
const onSubmit: SubmitHandler<Inputs> = useCallback((data) => {
mutation.mutate(data);
}, [ mutation ]);
const changeToData = useCallback(() => {
changeToDataScreen(true);
changeToDataScreen(false);
}, [ changeToDataScreen ]);
return (
......@@ -81,9 +137,9 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
<Grid templateColumns="1fr 1fr" rowGap={ 4 } columnGap={ 5 }>
<GridItem>
<PublicTagsFormInput<Inputs>
fieldName="userName"
fieldName="fullName"
control={ control }
label={ placeholders.userName }
label={ placeholders.fullName }
required
/>
</GridItem>
......@@ -96,11 +152,11 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
</GridItem>
<GridItem>
<PublicTagsFormInput<Inputs>
fieldName="userEmail"
fieldName="email"
control={ control }
label={ placeholders.userEmail }
label={ placeholders.email }
pattern={ EMAIL_REGEXP }
hasError={ Boolean(errors.userEmail) }
hasError={ Boolean(errors.email) }
required
/>
</GridItem>
......@@ -113,15 +169,15 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
</GridItem>
</Grid>
<Box marginTop={ 4 } marginBottom={ 8 }>
<PublicTagFormAction canReport={ Boolean(data) } control={ control }/>
<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="tag"
fieldName="tags"
control={ control }
label={ placeholders.tag }
hasError={ Boolean(errors.tag) }
label={ placeholders.tags }
hasError={ Boolean(errors.tags) }
required/>
</Box>
{ fields.map((field, index) => {
......@@ -145,8 +201,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
<Button
size="lg"
variant="primary"
onClick={ handleSubmit(changeToData) }
disabled={ Object.keys(errors).length > 0 }
onClick={ handleSubmit(onSubmit) }
disabled={ Object.keys(errors).length > 0 || mutation.isLoading }
>
Send request
</Button>
......@@ -162,4 +218,4 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
);
};
export default PublicTagsForm;
export default React.memo(PublicTagsForm);
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