diff --git a/e2e/specs/stateless/advancedEditor.spec.ts b/e2e/specs/stateless/advancedEditor.spec.ts new file mode 100644 index 000000000..1f41c780c --- /dev/null +++ b/e2e/specs/stateless/advancedEditor.spec.ts @@ -0,0 +1,79 @@ +import { expect } from '@playwright/test' +import { test } from '@root/playwright' + +test('should be able to maintain state when returning from transaction modal to advanced editor', async ({ + login, + makeName, + makePageObject, +}) => { + const name = await makeName({ + label: 'profile', + type: 'legacy', + records: { + texts: [{ key: 'text', value: 'text' }], + coinTypes: [{ key: 'SOL', value: 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH' }], + contentHash: 'ipfs://bafybeico3uuyj3vphxpvbowchdwjlrlrh62awxscrnii7w7flu5z6fk77y', + abi: { + contentType: 1, + data: '{"test":"test"}', + }, + }, + }) + + const recordsPage = makePageObject('RecordsPage') + const advancedEditor = makePageObject('AdvancedEditorModal') + const transactionModal = makePageObject('TransactionModal') + + await recordsPage.goto(name) + await login.connect() + + // Validate records + await expect(recordsPage.getRecordValue('text', 'text')).toHaveText('text') + await expect(recordsPage.getRecordValue('address', 'sol')).toHaveText( + 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH', + ) + await expect(recordsPage.getRecordValue('contentHash')).toHaveText( + 'ipfs://bafybeico3uuyj3vphxpvbowchdwjlrlrh62awxscrnii7w7flu5z6fk77y', + ) + await expect(recordsPage.getRecordValue('abi')).toHaveText('"{\\"test\\":\\"test\\"}"') + + await recordsPage.editRecordsButton.click() + + // Validate advanced editor + await expect(await advancedEditor.recordInput('text', 'text')).toHaveValue('text') + await expect(await advancedEditor.recordInput('address', 'SOL')).toHaveValue( + 'HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH', + ) + await expect(await advancedEditor.recordInput('contentHash')).toHaveValue( + 'ipfs://bafybeico3uuyj3vphxpvbowchdwjlrlrh62awxscrnii7w7flu5z6fk77y', + ) + await expect(await advancedEditor.recordInput('abi')).toHaveValue('"{\\"test\\":\\"test\\"}"') + + await advancedEditor.recordClearButton('text', 'text').then((button) => button.click()) + await advancedEditor.recordClearButton('address', 'SOL').then((button) => button.click()) + await advancedEditor.recordInput('contentHash').then((input) => input.fill('')) + await advancedEditor.recordInput('abi').then((input) => input.fill('')) + + await advancedEditor.saveButton.click() + + // Validate transaction display item + await expect(transactionModal.displayItem('update')).toHaveText('4 records') + + await transactionModal.backButton.click() + + // Validate inputs have been rebuilt correctly + await expect(await advancedEditor.recordComponent('text', 'text')).toHaveCount(0) + await expect(await advancedEditor.recordComponent('address', 'SOL')).toHaveCount(0) + await expect(await advancedEditor.recordInput('contentHash')).toHaveValue('') + await expect(await advancedEditor.recordInput('abi')).toHaveValue('') + + await advancedEditor.saveButton.click() + + await transactionModal.autoComplete() + + // Validate change in records + await expect(recordsPage.getRecordButton('text', 'text')).toHaveCount(0) + await expect(recordsPage.getRecordButton('address', 'sol')).toHaveCount(0) + await expect(recordsPage.getRecordButton('contentHash')).toHaveCount(0) + await expect(recordsPage.getRecordButton('abi')).toHaveCount(0) +}) diff --git a/playwright/pageObjects/advancedEditorModal.ts b/playwright/pageObjects/advancedEditorModal.ts new file mode 100644 index 000000000..6e150163d --- /dev/null +++ b/playwright/pageObjects/advancedEditorModal.ts @@ -0,0 +1,37 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { Locator, Page } from '@playwright/test' + +export class AdvancedEditorModal { + readonly page: Page + + readonly saveButton: Locator + + constructor(page: Page) { + this.page = page + this.saveButton = this.page.getByTestId('advanced-editor').getByRole('button', { name: 'Save' }) + } + + tab(tab: 'text' | 'address' | 'other') { + return this.page.getByTestId(`${tab}-tab`) + } + + async recordComponent(type: 'text' | 'address' | 'contentHash' | 'abi', key?: string) { + if (['text', 'address'].includes(type)) { + await this.tab(type as 'text' | 'other').click() + return this.page.getByTestId(`record-input-${key}`) + } + await this.tab('other').click() + const _key = type === 'contentHash' ? 'Content Hash' : 'ABI' + return this.page.getByTestId(`record-input-${_key}`) + } + + async recordInput(type: 'text' | 'address' | 'contentHash' | 'abi', key?: string) { + const component = await this.recordComponent(type, key) + return component.locator('input') + } + + async recordClearButton(type: 'text' | 'address', key: string) { + const component = await this.recordComponent(type, key) + return component.locator('button') + } +} diff --git a/playwright/pageObjects/index.ts b/playwright/pageObjects/index.ts index f163b8895..8692e8f23 100644 --- a/playwright/pageObjects/index.ts +++ b/playwright/pageObjects/index.ts @@ -3,6 +3,7 @@ import { Page } from '@playwright/test' import { Web3ProviderBackend } from 'headless-web3-provider' import { AddressPage } from './addressPage' +import { AdvancedEditorModal } from './advancedEditorModal' import { EditRolesModal } from './editRolesModal' import { ExtendNamesModal } from './extendNamesModal' import { HomePage } from './homePage' @@ -10,6 +11,7 @@ import { MorePage } from './morePage' import { OwnershipPage } from './ownershipPage' import { PermissionsPage } from './permissionsPage' import { ProfilePage } from './profilePage' +import { RecordsPage } from './recordsPage' import { RegistrationPage } from './registrationPage' import { SendNameModal } from './sendNameModal' import { SubnamesPage } from './subnamePage' @@ -30,6 +32,8 @@ const pageObjects = { SendNameModal, SubnamesPage, TransactionModal, + RecordsPage, + AdvancedEditorModal, } type PageObjects = typeof pageObjects diff --git a/playwright/pageObjects/recordsPage.ts b/playwright/pageObjects/recordsPage.ts new file mode 100644 index 000000000..513f7e2de --- /dev/null +++ b/playwright/pageObjects/recordsPage.ts @@ -0,0 +1,26 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { Locator, Page } from '@playwright/test' + +export class RecordsPage { + readonly page: Page + + readonly editRecordsButton: Locator + + constructor(page: Page) { + this.page = page + this.editRecordsButton = this.page.getByRole('button', { name: 'Edit records' }) + } + + async goto(name: string) { + await this.page.goto(`/${name}?tab=records`) + } + + getRecordButton(type: 'text' | 'address' | 'contentHash' | 'abi', key?: string) { + const testId = key ? `name-details-${type}-${key.toLowerCase()}` : `name-details-${type}` + return this.page.getByTestId(testId) + } + + getRecordValue(type: 'text' | 'address' | 'contentHash' | 'abi', key?: string) { + return this.getRecordButton(type, key).locator('> div').last() + } +} diff --git a/playwright/pageObjects/transactionModal.ts b/playwright/pageObjects/transactionModal.ts index b3bda4e31..862a3289b 100644 --- a/playwright/pageObjects/transactionModal.ts +++ b/playwright/pageObjects/transactionModal.ts @@ -17,6 +17,8 @@ export class TransactionModal { readonly transactionModal: Locator + readonly backButton: Locator + constructor(page: Page, wallet: Web3ProviderBackend) { this.page = page this.wallet = wallet @@ -25,6 +27,7 @@ export class TransactionModal { this.completeButton = this.page.getByTestId('transaction-modal-complete-button') this.closeButton = this.page.getByTestId('close-icon') this.transactionModal = this.page.getByTestId('transaction-modal-inner') + this.backButton = this.page.locator('body .modal').getByRole('button', { name: 'Back' }) } async authorize() { @@ -53,4 +56,8 @@ export class TransactionModal { } while (isModalVisible) /* eslint-enable no-await-in-loop */ } + + displayItem(key: string, option = 'normal') { + return this.page.getByTestId(`display-item-${key}-${option}`).locator('> div').last() + } } diff --git a/src/components/@molecules/AdvancedEditor/AdvancedEditorTabContent.tsx b/src/components/@molecules/AdvancedEditor/AdvancedEditorTabContent.tsx index 940e1c2d9..290d8db3c 100644 --- a/src/components/@molecules/AdvancedEditor/AdvancedEditorTabContent.tsx +++ b/src/components/@molecules/AdvancedEditor/AdvancedEditorTabContent.tsx @@ -25,6 +25,7 @@ const TabContentContainer = styled.div( display: flex; flex-direction: column; gap: ${theme.space['3']}; + padding-right: ${theme.space['1']}; overflow: hidden; flex: 1; `, diff --git a/src/components/RecordItem.tsx b/src/components/RecordItem.tsx index 880ef89fb..e0dd5d851 100644 --- a/src/components/RecordItem.tsx +++ b/src/components/RecordItem.tsx @@ -11,7 +11,7 @@ const RecordItem = ({ itemKey?: string value: string showLegacy?: boolean - type: 'text' | 'address' | 'contentHash' + type: 'text' | 'address' | 'contentHash' | 'abi' }) => { const breakpoint = useBreakpoint() const keyLabel = showLegacy && itemKey ? itemKey?.replace('_LEGACY', '') : itemKey diff --git a/src/components/pages/profile/[name]/tabs/RecordsTab.tsx b/src/components/pages/profile/[name]/tabs/RecordsTab.tsx index 2256c7031..b266009f3 100644 --- a/src/components/pages/profile/[name]/tabs/RecordsTab.tsx +++ b/src/components/pages/profile/[name]/tabs/RecordsTab.tsx @@ -261,7 +261,7 @@ export const RecordsTab = ({ )} - {abi && } + {abi && } {canEdit && resolverAddress !== emptyAddress && ( diff --git a/src/hooks/useAdvancedEditor.test.ts b/src/hooks/useAdvancedEditor.test.ts new file mode 100644 index 000000000..87b402b2d --- /dev/null +++ b/src/hooks/useAdvancedEditor.test.ts @@ -0,0 +1,54 @@ +import { normalizeAbi } from './useAdvancedEditor' + +describe('normalizeAbi', () => { + it('should normalize abi that is a string', () => { + expect(normalizeAbi("test")).toEqual({ contentType: 1, data: 'test' }) + }) + + it('should normalize abi that is an empty string', () => { + expect(normalizeAbi("")).toEqual({ contentType: 1, data: '' }) + }) + + it('should normalize abi with a string for data', () => { + expect(normalizeAbi({ data: 'test' })).toEqual({ contentType: 1, data: 'test' }) + }) + + it('should normalize abi with an empty string for data', () => { + expect(normalizeAbi({ data: '' })).toEqual({ contentType: 1, data: '' }) + }) + + it('should normalize abi with an object for data', () => { + expect(normalizeAbi({ data: {test: 'test'} })).toEqual({ contentType: 1, data: '{"test":"test"}' }) + }) + it('should normalize abi with an array for data', () => { + expect(normalizeAbi({ data: ['test'] })).toEqual({ contentType: 1, data: '["test"]' }) + }) + + it('should normalize abi with an object for data', () => { + expect(normalizeAbi({ data: {test: 'test'} })).toEqual({ contentType: 1, data: '{"test":"test"}' }) + }) + + it('should NOT normalize abi that is a number', () => { + expect(normalizeAbi(5 as any)).toBeUndefined() + }) + + it('should NOT normalize abi that is null', () => { + expect(normalizeAbi(null as any)).toBeUndefined() + }) + + it('should NOT normalize abi that is undefined', () => { + expect(normalizeAbi(undefined as any)).toBeUndefined() + }) + + it('should NOT normalize abi with null for data', () => { + expect(normalizeAbi({ data: null as any})).toBeUndefined() + }) + + it('should NOT normalize abi with undefined for data', () => { + expect(normalizeAbi({ data: undefined as any})).toBeUndefined() + }) + + it('should NOT normalize abi with a number for data', () => { + expect(normalizeAbi({ data: 5 as any})).toBeUndefined() + }) +}) \ No newline at end of file diff --git a/src/hooks/useAdvancedEditor.ts b/src/hooks/useAdvancedEditor.ts index 69cc06f15..1771fc2b9 100644 --- a/src/hooks/useAdvancedEditor.ts +++ b/src/hooks/useAdvancedEditor.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { Pattern, match } from 'ts-pattern' +import { P, Pattern, match } from 'ts-pattern' import { RecordOptions } from '@ensdomains/ensjs/utils/recordHelpers' @@ -11,7 +11,6 @@ import useExpandableRecordsGroup from '@app/hooks/useExpandableRecordsGroup' import { DetailedProfile } from '@app/hooks/useNameDetails' import { useProfile } from '@app/hooks/useProfile' import { useResolverHasInterfaces } from '@app/hooks/useResolverHasInterfaces' -import { emptyAddress } from '@app/utils/constants' import { convertFormSafeKey, convertProfileToProfileFormObject, @@ -35,6 +34,15 @@ const getFieldsByType = (type: 'text' | 'addr' | 'contentHash', data: AdvancedEd return Object.fromEntries(entries) } +type NormalizedAbi = { contentType: number | undefined; data: string } +export const normalizeAbi = (abi: RecordOptions['abi'] | string): NormalizedAbi | undefined => { + return match(abi) + .with(P.string, (data) => ({ contentType: 1, data })) + .with({ data: P.string }, ({ data }) => ({ contentType: 1, data })) + .with({ data: {} }, ({ data }) => ({ contentType: 1, data: JSON.stringify(data) })) + .otherwise(() => undefined) +} + export type AdvancedEditorType = { text: { [key: string]: string @@ -166,6 +174,7 @@ const useAdvancedEditor = ({ profile, loading, overwrites, callback }: Props) => } })() + const [shouldRunOverwritesScript, setShouldRunOverwritesScript] = useState(false) useEffect(() => { if (profile) { const formObject = convertProfileToProfileFormObject(profile) @@ -189,33 +198,60 @@ const useAdvancedEditor = ({ profile, loading, overwrites, callback }: Props) => address: Object.keys(newDefaultValues.address) || [], text: Object.keys(newDefaultValues.text) || [], } + setExistingRecords(newExistingRecords) + setShouldRunOverwritesScript(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [profile]) + useEffect(() => { + if (shouldRunOverwritesScript) { overwrites?.texts?.forEach((text) => { const { key, value } = text const formKey = formSafeKey(key) - setValue(`text.${formKey}`, value, { shouldDirty: true }) - if (!newExistingRecords.text.includes(formKey)) { - newExistingRecords.text.push(formKey) + const isExisting = existingRecords.text.includes(formKey) + if (value && isExisting) { + setValue(`text.${formKey}`, value, { shouldDirty: true }) + } else if (value && !isExisting) { + addTextKey(formKey) + setValue(`text.${formKey}`, value, { shouldDirty: true }) + } else if (!value && isExisting) { + removeTextKey(formKey, false) + } else if (!value && !isExisting) { + removeTextKey(formKey, true) } }) overwrites?.coinTypes?.forEach((coinType) => { const { key, value } = coinType const formKey = formSafeKey(key) - setValue(`address.${formKey}`, value, { shouldDirty: true }) - if (!newExistingRecords.address.includes(formKey)) { - newExistingRecords.address.push(formKey) + const isExisting = existingRecords.address.includes(formKey) + if (value && isExisting) { + setValue(`address.${formKey}`, value, { shouldDirty: true }) + } else if (value && !isExisting) { + addAddressKey(formKey) + setValue(`address.${formKey}`, value, { shouldDirty: true }) + } else if (!value && isExisting) { + removeAddressKey(formKey, false) + } else if (!value && !isExisting) { + removeAddressKey(formKey, true) } }) - if (overwrites?.contentHash) { + if (typeof overwrites?.contentHash === 'string') { setValue('other.contentHash', overwrites.contentHash, { shouldDirty: true }) } - setExistingRecords(newExistingRecords) + const abi = normalizeAbi(overwrites?.abi) + if (abi) { + setValue('other.abi', abi, { shouldDirty: true }) + } + + setShouldRunOverwritesScript(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps - }, [profile]) + }, [existingRecords, shouldRunOverwritesScript]) const { hasInterface: hasABIInterface, isLoading: isLoadingABIInterface } = useResolverHasInterfaces(['IABIResolver'], profile?.resolverAddress, loading) @@ -236,11 +272,6 @@ const useAdvancedEditor = ({ profile, loading, overwrites, callback }: Props) => value, })) as { key: string; value: string }[] - const coinTypesWithZeroAddressses = coinTypes.map((coinType) => { - if (coinType.value) return coinType - return { key: coinType.key, value: emptyAddress } - }) - const contentHash = dirtyFields.other?.contentHash const abi = match(dirtyFields.other?.abi?.data) @@ -250,7 +281,7 @@ const useAdvancedEditor = ({ profile, loading, overwrites, callback }: Props) => const records = { texts, - coinTypes: coinTypesWithZeroAddressses, + coinTypes, contentHash, abi, } @@ -276,7 +307,6 @@ const useAdvancedEditor = ({ profile, loading, overwrites, callback }: Props) => handleTabClick, hasErrors, existingRecords, - setExistingRecords, existingTextKeys, newTextKeys, addTextKey, diff --git a/src/utils/records.ts b/src/utils/records.ts index 214c13def..9e1e944b8 100644 --- a/src/utils/records.ts +++ b/src/utils/records.ts @@ -1,3 +1,6 @@ +import { BigNumberish } from '@ethersproject/bignumber' +import { P, match as _match } from 'ts-pattern' + import { RecordOptions } from '@ensdomains/ensjs/utils/recordHelpers' import { ContentHash, Profile, RecordItem } from '@app/types' @@ -11,12 +14,27 @@ const contentHashTouple = (contentHash?: string, deleteLabel = 'delete'): [strin return [['contenthash', contentHash]] } +const abiTouple = ( + abi?: { contentType?: BigNumberish; data: string | object } | string, + deleteLabel = 'delete', +): [string, string][] => { + const abiStr = _match(abi) + .with(P.string, (_abi) => _abi) + .with({ data: P.string }, ({ data }) => data) + .with({ data: {} }, ({ data }) => JSON.stringify(data)) + .otherwise(() => undefined) + if (abiStr === '') return [[deleteLabel, 'abi']] + if (!abiStr) return [] + return [['abi', abiStr]] +} + export const recordOptionsToToupleList = ( records?: RecordOptions, deleteLabel = 'delete', ): [string, string][] => { return [ ...contentHashTouple(records?.contentHash, deleteLabel), + ...abiTouple(records?.abi, deleteLabel), ...(records?.texts?.map(({ key, value }) => [key, value]) || []), ...(records?.coinTypes?.map(({ key, value }) => [key, shortenAddress(value)]) || []), ].map(([key, value]) => (value ? [key, value] : [deleteLabel, key]))