From f160c30837737e4ec25a6811deab5c1d12dc1b6f Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Thu, 12 Sep 2024 14:05:50 +0300 Subject: [PATCH] fet-1340: Subname creation --- e2e/specs/stateless/createSubname.spec.ts | 5 +- playwright/pageObjects/subnamePage.ts | 5 +- public/locales/en/profile.json | 1 + src/hooks/useProfileEditorForm.tsx | 3 +- .../input/CreateSubname-flow.tsx | 354 +++++++++++++++--- 5 files changed, 309 insertions(+), 59 deletions(-) diff --git a/e2e/specs/stateless/createSubname.spec.ts b/e2e/specs/stateless/createSubname.spec.ts index ccffaf05e..7f5bccfc0 100644 --- a/e2e/specs/stateless/createSubname.spec.ts +++ b/e2e/specs/stateless/createSubname.spec.ts @@ -97,7 +97,7 @@ test('should not allow creating a subname with invalid characters', async ({ await subnamesPage.getAddSubnameButton.click() await subnamesPage.getAddSubnameInput.type('invalid name') - await expect(subnamesPage.getSubmitSubnameButton).toBeDisabled() + await expect(subnamesPage.getAddSubnameButton).toBeDisabled() await expect(page.getByText('Contains invalid characters')).toBeVisible() }) @@ -118,6 +118,7 @@ test('should allow creating a subname', async ({ page, makeName, login, makePage await subnamesPage.getAddSubnameButton.click() await subnamesPage.getAddSubnameInput.type('test') await subnamesPage.getSubmitSubnameButton.click() + await subnamesPage.getSubmitSubnameProfileButton.click() const transactionModal = makePageObject('TransactionModal') await transactionModal.autoComplete() @@ -150,6 +151,7 @@ test('should allow creating a subnames if the user is the wrapped owner', async await subnamesPage.getAddSubnameButton.click() await subnamesPage.getAddSubnameInput.fill('test') await subnamesPage.getSubmitSubnameButton.click() + await subnamesPage.getSubmitSubnameProfileButton.click() const transactionModal = makePageObject('TransactionModal') await transactionModal.autoComplete() @@ -226,6 +228,7 @@ test('should allow creating an expired wrapped subname', async ({ await subnamesPage.getAddSubnameButton.click() await subnamesPage.getAddSubnameInput.fill('test') await subnamesPage.getSubmitSubnameButton.click() + await subnamesPage.getSubmitSubnameProfileButton.click() await transactionModal.autoComplete() diff --git a/playwright/pageObjects/subnamePage.ts b/playwright/pageObjects/subnamePage.ts index 09714dd79..a52021f43 100644 --- a/playwright/pageObjects/subnamePage.ts +++ b/playwright/pageObjects/subnamePage.ts @@ -5,12 +5,10 @@ export class SubnamesPage { readonly page: Page readonly getAddSubnameButton: Locator - readonly getDisabledAddSubnameButton: Locator - readonly getAddSubnameInput: Locator - readonly getSubmitSubnameButton: Locator + readonly getSubmitSubnameProfileButton: Locator constructor(page: Page) { this.page = page @@ -18,6 +16,7 @@ export class SubnamesPage { this.getDisabledAddSubnameButton = this.page.getByTestId('add-subname-disabled-button') this.getAddSubnameInput = this.page.getByTestId('add-subname-input') this.getSubmitSubnameButton = this.page.getByTestId('create-subname-next') + this.getSubmitSubnameProfileButton = this.page.getByTestId('create-subname-profile-next') } async goto(name: string) { diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 2e85a2b20..9a6c109e1 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -388,6 +388,7 @@ "empty": "No subnames have been added", "noResults": "No results", "noMoreResults": "No more results", + "setProfile": "Set Profile", "addSubname": { "title": "Subnames let you create additional names from your existing name.", "learn": "Learn about subnames", diff --git a/src/hooks/useProfileEditorForm.tsx b/src/hooks/useProfileEditorForm.tsx index 69e77dc6a..18c72b518 100644 --- a/src/hooks/useProfileEditorForm.tsx +++ b/src/hooks/useProfileEditorForm.tsx @@ -172,7 +172,7 @@ export const useProfileEditorForm = (existingRecords: ProfileRecord[]) => { SUPPORTED_AVUP_ENDPOINTS.some((endpoint) => avatar?.startsWith(endpoint)) ) if (avatarIsChanged) { - setValue('avatar', avatar, { shouldDirty: true, shouldTouch: true }) + setValue('avatar', avatar || '', { shouldDirty: true, shouldTouch: true }) } } @@ -217,6 +217,7 @@ export const useProfileEditorForm = (existingRecords: ProfileRecord[]) => { const getAvatar = () => getValues('avatar') return { + isDirty: formState.isDirty, records, register, trigger, diff --git a/src/transaction-flow/input/CreateSubname-flow.tsx b/src/transaction-flow/input/CreateSubname-flow.tsx index a025c8aed..dbb8945f0 100644 --- a/src/transaction-flow/input/CreateSubname-flow.tsx +++ b/src/transaction-flow/input/CreateSubname-flow.tsx @@ -1,11 +1,24 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' +import { match } from 'ts-pattern' import { validateName } from '@ensdomains/ensjs/utils' -import { Button, Dialog, Input } from '@ensdomains/thorin' +import { Button, Dialog, Input, mq, PlusSVG } from '@ensdomains/thorin' +import { ConfirmationDialogView } from '@app/components/@molecules/ConfirmationDialogView/ConfirmationDialogView' +import { AvatarClickType } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarButton' +import { AvatarViewManager } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarViewManager' +import { AddProfileRecordView } from '@app/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView' +import { CustomProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/CustomProfileRecordInput' +import { ProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordInput' +import { ProfileRecordTextarea } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordTextarea' +import { profileEditorFormToProfileRecords } from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +import { WrappedAvatarButton } from '@app/components/pages/profile/[name]/registration/steps/Profile/WrappedAvatarButton' +import { ProfileRecord } from '@app/constants/profileRecordOptions' +import { useContractAddress } from '@app/hooks/chain/useContractAddress' import useDebouncedCallback from '@app/hooks/useDebouncedCallback' +import { useProfileEditorForm } from '@app/hooks/useProfileEditorForm' import { useValidateSubnameLabel } from '../../hooks/useValidateSubnameLabel' import { createTransactionItem } from '../transaction' @@ -16,10 +29,29 @@ type Data = { isWrapped: boolean } +type ModalOption = AvatarClickType | 'editor' | 'profile-editor' | 'add-record' | 'clear-eth' + export type Props = { data: Data } & TransactionDialogPassthrough +const ButtonContainer = styled.div( + ({ theme }) => css` + display: flex; + justify-content: center; + padding-bottom: ${theme.space['4']}; + `, +) + +const ButtonWrapper = styled.div(({ theme }) => [ + css` + width: ${theme.space.full}; + `, + mq.xs.min(css` + width: max-content; + `), +]) + const ParentLabel = styled.div( ({ theme }) => css` overflow: hidden; @@ -29,34 +61,112 @@ const ParentLabel = styled.div( `, ) -const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Props) => { - const { t } = useTranslation('profile') - +const useSubnameLabel = (data: Data) => { const [label, setLabel] = useState('') const [_label, _setLabel] = useState('') - const debouncedSetLabel = useDebouncedCallback(setLabel, 500) - const { valid, error, expiryLabel, isLoading: isUseValidateSubnameLabelLoading, - } = useValidateSubnameLabel({ name: parent, label, isWrapped }) + } = useValidateSubnameLabel({ + name: data.parent, + label, + isWrapped: data.isWrapped, + }) + + const debouncedSetLabel = useDebouncedCallback(setLabel, 500) + + const handleChange = (e: React.ChangeEvent) => { + try { + const normalised = validateName(e.target.value) + _setLabel(normalised) + debouncedSetLabel(normalised) + } catch { + _setLabel(e.target.value) + debouncedSetLabel(e.target.value) + } + } const isLabelsInsync = label === _label const isLoading = isUseValidateSubnameLabelLoading || !isLabelsInsync + return { + valid, + error, + expiryLabel, + isLoading, + label: _label, + debouncedLabel: label, + setLabel: handleChange, + } +} + +const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('profile') + const { t: registerT } = useTranslation('register') + + const [view, setView] = useState('editor') + + const { valid, error, expiryLabel, isLoading, debouncedLabel, label, setLabel } = useSubnameLabel( + { + parent, + isWrapped, + }, + ) + + const name = `${debouncedLabel}.${parent}` + + const defaultResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) + + const { + isDirty, + records, + register, + trigger, + control, + addRecords, + getValues, + removeRecordAtIndex, + removeRecordByGroupAndKey: removeRecordByTypeAndKey, + setAvatar, + labelForRecord, + secondaryLabelForRecord, + placeholderForRecord, + validatorForRecord, + errorForRecordAtIndex, + isDirtyForRecordAtIndex, + } = useProfileEditorForm([ + { + key: 'eth', + value: '', + type: 'text', + group: 'address', + }, + ]) + const handleSubmit = () => { + const payload = [ + createTransactionItem('createSubname', { + contract: isWrapped ? 'nameWrapper' : 'registry', + label: debouncedLabel, + parent, + }), + ] + if (isDirty) { + payload.push( + createTransactionItem('updateProfileRecords', { + name, + records: profileEditorFormToProfileRecords(getValues()), + resolverAddress: defaultResolverAddress, + clearRecords: false, + }) as never, + ) + } dispatch({ name: 'setTransactions', - payload: [ - createTransactionItem('createSubname', { - contract: isWrapped ? 'nameWrapper' : 'registry', - label, - parent, - }), - ], + payload, }) dispatch({ name: 'setFlowStage', @@ -64,48 +174,184 @@ const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Pro }) } + const [avatarFile, setAvatarFile] = useState() + const [avatarSrc, setAvatarSrc] = useState() + + const handleDeleteRecord = (record: ProfileRecord, index: number) => { + if (record.key === 'eth') return setView('clear-eth') + removeRecordAtIndex(index) + process.nextTick(() => trigger()) + } + return ( <> - - - .{parent}} - value={_label} - onChange={(e) => { - try { - const normalised = validateName(e.target.value) - _setLabel(normalised) - debouncedSetLabel(normalised) - } catch { - _setLabel(e.target.value) - debouncedSetLabel(e.target.value) - } - }} - error={ - error - ? t(`details.tabs.subnames.addSubname.dialog.error.${error}`, { date: expiryLabel }) - : undefined - } - /> - - - {t('action.cancel', { ns: 'common' })} - - } - trailing={ - - } - /> + {match(view) + .with('editor', () => ( + <> + + + .{parent}} + value={label} + onChange={setLabel} + error={ + error + ? t(`details.tabs.subnames.addSubname.dialog.error.${error}`, { + date: expiryLabel, + }) + : undefined + } + /> + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + )) + .with('profile-editor', () => ( + <> + + + setAvatar(avatar)} + onAvatarFileChange={(file) => setAvatarFile(file)} + onAvatarSrcChange={(src) => setAvatarSrc(src)} + /> + {records.map((field, index) => + match(field) + .with({ group: 'custom' }, () => ( + handleDeleteRecord(field, index)} + /> + )) + .with({ key: 'description' }, () => ( + handleDeleteRecord(field, index)} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + )) + .otherwise(() => ( + handleDeleteRecord(field, index)} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + )), + )} + + + + + + + setView('editor')}> + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + )) + .with('add-record', () => ( + { + addRecords(newRecords) + setView('profile-editor') + }} + onClose={() => setView('profile-editor')} + /> + )) + .with('upload', 'nft', (type) => ( + setView('profile-editor')} + type={type} + handleSubmit={(_, uri, display) => { + setAvatar(uri) + setAvatarSrc(display) + setView('profile-editor') + trigger() + }} + /> + )) + .with('clear-eth', () => ( + { + removeRecordByTypeAndKey('address', 'eth') + setView('profile-editor') + }} + onDecline={() => setView('profile-editor')} + /> + )) + .exhaustive()} ) }