diff --git a/.changeset/calm-ravens-smile.md b/.changeset/calm-ravens-smile.md new file mode 100644 index 000000000000..96639c0bf8c9 --- /dev/null +++ b/.changeset/calm-ravens-smile.md @@ -0,0 +1,6 @@ +--- +"@domain/entity-contact": minor +"@features/flow-contacts": minor +--- + +Add address label validation and flow diff --git a/.changeset/live-33919-desktop-address-label.md b/.changeset/live-33919-desktop-address-label.md new file mode 100644 index 000000000000..5d425441d848 --- /dev/null +++ b/.changeset/live-33919-desktop-address-label.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": minor +--- + +Render the Desktop Contacts address label step diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx b/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx index 5caa858b8f59..8062489db4ed 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/__integrations__/Contacts.integration.test.tsx @@ -39,6 +39,15 @@ jest.mock("~/renderer/store", () => ({ resetStore: jest.fn(), })); +jest.mock("@ledgerhq/live-common/bridge/index", () => ({ + ...jest.requireActual( + "@ledgerhq/live-common/bridge/index", + ), + getAccountBridgeByFamily: jest.fn().mockResolvedValue({ + validateAddress: jest.fn().mockResolvedValue(true), + }), +})); + const contextMenuValue = { close: mockClose, view: CONTEXT_MENU_VIEW.myWallet, @@ -543,6 +552,68 @@ describe("Contacts integration", () => { expect(dialog.querySelector('[data-slot="dialog-body"]')).toHaveClass("!mb-0"); }); + it("should render the address and its prefilled name together before review", async () => { + const { store, user } = render( + + + } /> + + , + { + skipRouter: true, + initialState: contactsPageInitialState(), + }, + ); + + await user.click(screen.getByTestId("contacts-me-row")); + await user.click(screen.getByTestId("contacts-detail-add-address")); + + const dialog = screen.getByRole("dialog"); + act(() => { + store + .getState() + .modularDialog.dialogParams?.onAssetSelected?.(getCryptoCurrencyById("ethereum")); + }); + + const addressInput = await screen.findByTestId("contacts-add-address-input"); + const addressNameInput = screen.getByTestId("contacts-add-address-name-input"); + expect(screen.getByRole("dialog")).toBe(dialog); + expect(addressNameInput).toHaveValue("Ethereum"); + expect(addressNameInput).toHaveAttribute("maxlength", "32"); + expect(screen.getByTestId("contacts-add-address-confirm")).toBeDisabled(); + + fireEvent.change(addressInput, { + target: { value: "0x1ad23b2cf8d2e0591ea417eb82f7cd9746c53034" }, + }); + + const confirmationButton = screen.getByTestId("contacts-add-address-confirm"); + await waitFor(() => expect(confirmationButton).toBeEnabled()); + + await user.clear(addressNameInput); + await user.type(addressNameInput, "Ethereum 💎"); + + expect(screen.getByText("Special characters are not allowed.")).toBeVisible(); + expect(confirmationButton).toBeDisabled(); + + await user.clear(addressNameInput); + await user.type(addressNameInput, "Exchange"); + await user.click(confirmationButton); + + expect(screen.getByRole("dialog")).toBe(dialog); + expect(screen.getByTestId("contacts-add-address-review")).toBeVisible(); + + await user.click( + screen.getByRole("button", { name: "components.dialogHeader.goBackAriaLabel" }), + ); + expect(screen.getByTestId("contacts-add-address-input")).toBeVisible(); + expect(screen.getByTestId("contacts-add-address-name-input")).toHaveValue("Exchange"); + + await user.click(screen.getByTestId("contacts-add-address-confirm")); + await user.click(screen.getByTestId("contacts-add-address-review-continue")); + + expect(screen.getByTestId("contacts-add-address-success")).toBeVisible(); + }); + it("should expose the Add Address session started for Me", async () => { const { user } = render( diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/ContactsAddAddressFlowDialog.tsx b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/ContactsAddAddressFlowDialog.tsx index 4a9e5e433715..732058f6ac10 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/ContactsAddAddressFlowDialog.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/ContactsAddAddressFlowDialog.tsx @@ -3,12 +3,31 @@ import { DialogFlow, type DialogFlowScreenRegistry } from "LLD/components/Dialog import { ModularDialogFlow } from "LLD/features/ModularDialog/ModularDialogFlow"; import { ContactsAddAddressEntry, + ContactsAddAddressCompletion, + ContactsAddAddressName, type AddAddressFlowState, type ContactsAddAddressEntryLabels, + type ContactsAddAddressNameLabels, } from "@features/flow-contacts"; -import type { ContactsAddAddressFlowDialogProps } from "./types"; +import type { ContactsAddAddressFlowDialogProps, ContactsAddAddressReviewLabels } from "./types"; -type ContactsAddAddressDialogStep = "currency" | "address"; +type ContactsAddAddressDialogStep = "currency" | "address" | "name" | "review" | "success"; + +function resolveCurrentStep(state: AddAddressFlowState): ContactsAddAddressDialogStep { + switch (state.status) { + case "enteringAddress": + return "address"; + case "namingAddress": + return "name"; + case "reviewingAddress": + return "review"; + case "success": + return "success"; + case "selectingCurrency": + case "closed": + return "currency"; + } +} function isEnteringAddress( state: AddAddressFlowState, @@ -19,21 +38,75 @@ function isEnteringAddress( function createAddressContent( state: Extract, labels: ContactsAddAddressEntryLabels, + nameLabels: ContactsAddAddressNameLabels, onAddressChange: ContactsAddAddressFlowDialogProps["onAddressChange"], + onAddressLabelChange: ContactsAddAddressFlowDialogProps["onAddressLabelChange"], + onContinueFromAddressDetails: ContactsAddAddressFlowDialogProps["onContinueFromAddressDetails"], ): React.JSX.Element { return ( + ); +} + +function isNamingAddress( + state: AddAddressFlowState, +): state is Extract { + return state.status === "namingAddress"; +} + +function createNameContent( + state: Extract, + labels: ContactsAddAddressNameLabels, + onAddressLabelChange: ContactsAddAddressFlowDialogProps["onAddressLabelChange"], + onContinueFromName: ContactsAddAddressFlowDialogProps["onContinueFromName"], +): React.JSX.Element { + return ( + + ); +} + +function createCompletionContent( + state: Extract, + labels: ContactsAddAddressReviewLabels, + onContinueFromReview: ContactsAddAddressFlowDialogProps["onContinueFromReview"], + onClose: ContactsAddAddressFlowDialogProps["onClose"], +): React.JSX.Element { + const isReviewingAddress = state.status === "reviewingAddress"; + + return ( + ); } export function ContactsAddAddressFlowDialog({ state, - labels, + entryLabels, + nameLabels, + reviewLabels, onAddressChange, + onContinueFromAddressDetails, + onAddressLabelChange, + onContinueFromName, + onContinueFromReview, onBack, onClose, }: ContactsAddAddressFlowDialogProps): React.JSX.Element | null { @@ -45,7 +118,10 @@ export function ContactsAddAddressFlowDialog({ {modularDialog => { const isAddressEntry = isEnteringAddress(state); - const currentStep: ContactsAddAddressDialogStep = isAddressEntry ? "address" : "currency"; + const isAddressNaming = isNamingAddress(state); + const isReviewingAddress = state.status === "reviewingAddress"; + const isSuccess = state.status === "success"; + const currentStep = resolveCurrentStep(state); const screens: DialogFlowScreenRegistry = { currency: { content: modularDialog.content, @@ -60,13 +136,49 @@ export function ContactsAddAddressFlowDialog({ }, address: { content: isAddressEntry - ? createAddressContent(state, labels, onAddressChange) + ? createAddressContent( + state, + entryLabels, + nameLabels, + onAddressChange, + onAddressLabelChange, + onContinueFromAddressDetails, + ) + : modularDialog.content, + options: { + dialogHeaderProps: { density: "expanded", title: entryLabels.title }, + hasBackButton: true, + }, + }, + name: { + content: isAddressNaming + ? createNameContent(state, nameLabels, onAddressLabelChange, onContinueFromName) : modularDialog.content, options: { - dialogHeaderProps: { density: "expanded", title: labels.title }, + dialogHeaderProps: { density: "expanded", title: entryLabels.title }, + hasBackButton: true, + }, + }, + review: { + content: + isReviewingAddress || isSuccess + ? createCompletionContent(state, reviewLabels, onContinueFromReview, onClose) + : modularDialog.content, + options: { + dialogHeaderProps: { density: "expanded", title: entryLabels.title }, hasBackButton: true, }, }, + success: { + content: + isReviewingAddress || isSuccess + ? createCompletionContent(state, reviewLabels, onContinueFromReview, onClose) + : modularDialog.content, + options: { + dialogHeaderProps: { density: "expanded", title: entryLabels.title }, + hasBackButton: false, + }, + }, }; return ( @@ -77,7 +189,11 @@ export function ContactsAddAddressFlowDialog({ dialogContentProps: { className: "w-400 bg-canvas-sheet pb-0" }, }} isOpen - onBack={isAddressEntry ? onBack : modularDialog.onBack} + onBack={ + isAddressEntry || isAddressNaming || isReviewingAddress + ? onBack + : modularDialog.onBack + } onClose={onClose} screens={screens} /> diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/index.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/index.ts index 3f2af919d2a9..1953733131a7 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/index.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/index.ts @@ -1,2 +1,2 @@ export { ContactsAddAddressFlowDialog } from "./ContactsAddAddressFlowDialog"; -export type { ContactsAddAddressFlowDialogProps } from "./types"; +export type { ContactsAddAddressFlowDialogProps, ContactsAddAddressReviewLabels } from "./types"; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/types.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/types.ts index 726a1d3bd173..a6deabe3c1a8 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/types.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/components/ContactsAddAddressFlowDialog/types.ts @@ -2,12 +2,26 @@ import type { AddAddressFlowState, AddAddressInputSource, ContactsAddAddressEntryLabels, + ContactsAddAddressNameLabels, } from "@features/flow-contacts"; +export type ContactsAddAddressReviewLabels = Readonly<{ + title: string; + continue: string; + successTitle: string; + close: string; +}>; + export type ContactsAddAddressFlowDialogProps = Readonly<{ state: AddAddressFlowState; - labels: ContactsAddAddressEntryLabels; + entryLabels: ContactsAddAddressEntryLabels; + nameLabels: ContactsAddAddressNameLabels; + reviewLabels: ContactsAddAddressReviewLabels; onAddressChange: (address: string, inputMethod: AddAddressInputSource) => void; + onContinueFromAddressDetails: () => void; + onAddressLabelChange: (value: string) => void; + onContinueFromName: () => void; + onContinueFromReview: () => void; onBack: () => void; onClose: () => void; }>; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts index 4c8c411a5bf5..2d1b1f16ba10 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts @@ -1,7 +1,12 @@ import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; -import type { ContactId } from "@domain/entity-contact"; +import { + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, + DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, + INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME, + type ContactId, +} from "@domain/entity-contact"; import { CONTACTS_FEATURE_INTRODUCTION_HIGHLIGHTS, createContactsListViewModel, @@ -15,6 +20,7 @@ import { type AddAddressContact, type AddAddressFlowState, type ContactsAddAddressEntryLabels, + type ContactsAddAddressNameLabels, type ContactAddressDetailDialogProps, type ContactsLedgerSyncStatus, type ContactsListViewLabels, @@ -25,7 +31,10 @@ import { useContactsFeatureIntroductionPreference } from "../../hooks/useContact import { useContactsCurrencySelectionAdapter } from "../../hooks/useContactsCurrencySelectionAdapter"; import { useContactsAddressValidationAdapter } from "../../hooks/useContactsAddressValidationAdapter"; import { useContactDetailPaneAdapter } from "./useContactDetailPaneAdapter"; -import type { ContactsAddAddressFlowDialogProps } from "./components/ContactsAddAddressFlowDialog"; +import type { + ContactsAddAddressFlowDialogProps, + ContactsAddAddressReviewLabels, +} from "./components/ContactsAddAddressFlowDialog"; export type ContactsPageViewModel = Omit & Readonly<{ @@ -54,6 +63,10 @@ export function useContactsViewModel(): ContactsPageViewModel { completeCurrencySelection, goBack: goBackAddAddress, updateAddress, + updateAddressLabel, + continueFromAddressDetails, + continueFromName, + continueFromReview, close: closeAddAddress, } = useAddAddressFlowViewModel({ addressValidation }); const selectCurrencyForContact = useCallback( @@ -82,6 +95,14 @@ export function useContactsViewModel(): ContactsPageViewModel { closeAddAddress(); }, [cancelCurrencySelection, closeAddAddress]); const onBackAddAddress = useCallback(() => { + if ( + addAddressFlowState.status === "namingAddress" || + addAddressFlowState.status === "reviewingAddress" + ) { + goBackAddAddress(); + return; + } + if (addAddressFlowState.status !== "enteringAddress") { return; } @@ -104,22 +125,56 @@ export function useContactsViewModel(): ContactsPageViewModel { }), [t], ); + const addAddressNameLabels = useMemo( + () => ({ + inputLabel: t("contacts.addAddressName.inputLabel"), + continueToReview: t("contacts.addAddressName.continueToReview"), + validAddress: t("contacts.addAddressEntry.validAddress"), + validationErrors: { + [INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME]: t("contacts.addAddressName.invalidLabel"), + [DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME]: t("contacts.addAddressName.duplicateLabel"), + [CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME]: t("contacts.addAddressName.tooLongLabel"), + }, + }), + [t], + ); + const addAddressReviewLabels = useMemo( + () => ({ + title: t("contacts.addAddressReview.title"), + continue: t("contacts.addAddressReview.continue"), + successTitle: t("contacts.addAddressReview.successTitle"), + close: t("contacts.addAddressReview.close"), + }), + [t], + ); const addAddressFlowDialog = useMemo( () => ({ state: addAddressFlowState, - labels: addAddressEntryLabels, + entryLabels: addAddressEntryLabels, + nameLabels: addAddressNameLabels, + reviewLabels: addAddressReviewLabels, onAddressChange: (address, inputMethod) => { void updateAddress(address, inputMethod); }, + onContinueFromAddressDetails: continueFromAddressDetails, + onAddressLabelChange: updateAddressLabel, + onContinueFromName: continueFromName, + onContinueFromReview: continueFromReview, onBack: onBackAddAddress, onClose: onCloseAddAddress, }), [ addAddressEntryLabels, + addAddressNameLabels, + addAddressReviewLabels, addAddressFlowState, onBackAddAddress, onCloseAddAddress, updateAddress, + updateAddressLabel, + continueFromAddressDetails, + continueFromName, + continueFromReview, ], ); const { detail, addressDetailDialog, onOpenMe, onOpenContact } = diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 6800678c1634..3e870008766f 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -9305,6 +9305,19 @@ "validationUnavailable": "Address validation is temporarily unavailable.", "ensDisclaimer": "This ENS name resolves to the address shown above." }, + "addAddressName": { + "inputLabel": "Address name", + "continueToReview": "Continue to review", + "invalidLabel": "Special characters are not allowed.", + "duplicateLabel": "This address name is already used for this contact.", + "tooLongLabel": "Address names must be 32 characters or fewer." + }, + "addAddressReview": { + "title": "Review address", + "continue": "Confirm address", + "successTitle": "Address saved", + "close": "Close" + }, "addContactDrawer": { "namePlaceholder": "Contact name", "namingDisclaimer": "For privacy, avoid full names and surnames. Use a nickname or just a first name + initial, e.g. 'John S'.", diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 0d9dc3338d5c..cfb228de2124 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -10212,7 +10212,8 @@ "namingDisclaimer": "We recommend giving this address a name to easily find it when needed. It will be only visible by you.", "continueToReview": "Continue to review", "invalidLabel": "Special characters are not allowed.", - "duplicateLabel": "This address name is already used for this contact." + "duplicateLabel": "This address name is already used for this contact.", + "labelTooLong": "This address name is too long." }, "addAddressFlow": { "review": "Validation", diff --git a/apps/ledger-live-mobile/src/mvvm/features/Contacts/screens/ContactDetail/components/ContactsAddAddressFlowDrawer/useContactsAddAddressFlowDrawerViewModel.ts b/apps/ledger-live-mobile/src/mvvm/features/Contacts/screens/ContactDetail/components/ContactsAddAddressFlowDrawer/useContactsAddAddressFlowDrawerViewModel.ts index 015a20e46c9f..1d2d0e75880a 100644 --- a/apps/ledger-live-mobile/src/mvvm/features/Contacts/screens/ContactDetail/components/ContactsAddAddressFlowDrawer/useContactsAddAddressFlowDrawerViewModel.ts +++ b/apps/ledger-live-mobile/src/mvvm/features/Contacts/screens/ContactDetail/components/ContactsAddAddressFlowDrawer/useContactsAddAddressFlowDrawerViewModel.ts @@ -1,5 +1,6 @@ import { Platform } from "react-native"; import { + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME, } from "@domain/entity-contact"; @@ -92,6 +93,9 @@ export function useContactsAddAddressFlowDrawerViewModel({ [DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME]: t( "contacts.addAddressName.duplicateLabel", ), + [CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME]: t( + "contacts.addAddressName.labelTooLong", + ), }, }, bottomOffset, diff --git a/domain/entity/contact/src/errors.ts b/domain/entity/contact/src/errors.ts index a59cdf7a36e3..756a4e4b2e7e 100644 --- a/domain/entity/contact/src/errors.ts +++ b/domain/entity/contact/src/errors.ts @@ -19,9 +19,12 @@ export const INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME = "InvalidContactAddressLa export const DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME = "DuplicateContactAddressLabelError"; +export const CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME = "ContactAddressLabelTooLongError"; + export type ContactAddressLabelValidationErrorName = | typeof INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME - | typeof DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME; + | typeof DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME + | typeof CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME; export class InvalidContactAddressLabelError extends ContactError { override name = INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME; @@ -30,3 +33,7 @@ export class InvalidContactAddressLabelError extends ContactError { export class DuplicateContactAddressLabelError extends ContactError { override name = DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME; } + +export class ContactAddressLabelTooLongError extends ContactError { + override name = CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME; +} diff --git a/domain/entity/contact/src/schema.test.ts b/domain/entity/contact/src/schema.test.ts index a02a6998ff67..ffaacfabd903 100644 --- a/domain/entity/contact/src/schema.test.ts +++ b/domain/entity/contact/src/schema.test.ts @@ -98,6 +98,10 @@ describe("ContactAddressSchema", () => { expect(() => ContactAddressLabelSchema.parse("Ethereum 1\uFE0F\u20E3")).toThrow(); expect(() => ContactAddressLabelSchema.parse("Ethereum\nWallet")).toThrow(); }); + + it("rejects address labels longer than 32 characters", () => { + expect(() => ContactAddressLabelSchema.parse("a".repeat(33))).toThrow(); + }); }); describe("contact mock factories", () => { diff --git a/domain/entity/contact/src/schema.ts b/domain/entity/contact/src/schema.ts index 64fff8b33ca0..29bd600900e9 100644 --- a/domain/entity/contact/src/schema.ts +++ b/domain/entity/contact/src/schema.ts @@ -13,12 +13,16 @@ const ContactNamePattern = /^\p{L}[\p{L}\p{Mn}\p{Mc}]*(?:[\p{Zs}'\u2019-]\p{L}[\p{L}\p{Mn}\p{Mc}]*)*$/u; const ContactAddressLabelPattern = /^(?=.*[\p{L}\p{N}])[\p{L}\p{Mn}\p{Mc}\p{N}\p{P}\p{Zs}]+$/u; +export const CONTACT_ADDRESS_LABEL_MAX_LENGTH = 32; + export const ContactNameSchema = NonEmptyStringSchema.regex( ContactNamePattern, "Expected letters, spaces, apostrophes, or hyphens", ); -export const ContactAddressLabelSchema = NonEmptyStringSchema.regex(ContactAddressLabelPattern); +export const ContactAddressLabelSchema = NonEmptyStringSchema.regex(ContactAddressLabelPattern).max( + CONTACT_ADDRESS_LABEL_MAX_LENGTH, +); export const ContactAddressValueSchema = NonEmptyStringSchema; diff --git a/domain/entity/contact/src/validation.test.ts b/domain/entity/contact/src/validation.test.ts index 5e5d2e8d3fd1..9bfa027afa8a 100644 --- a/domain/entity/contact/src/validation.test.ts +++ b/domain/entity/contact/src/validation.test.ts @@ -1,4 +1,6 @@ import { + ContactAddressLabelTooLongError, + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, DuplicateContactAddressLabelError, DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, InvalidContactAddressLabelError, @@ -53,11 +55,21 @@ describe("contact address label validation", () => { expect(isValidContactAddressLabel("")).toBe(false); }); - it("accepts a valid draft label without imposing a length limit", () => { + it("rejects an address label longer than 32 characters", () => { const longLabel = "Ethereum ".repeat(50); expect(getContactAddressLabelValidationError("Ethereum")).toBeNull(); - expect(isValidContactAddressLabel(longLabel)).toBe(true); + expect(getContactAddressLabelValidationError(longLabel)).toBe( + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, + ); + expect(isValidContactAddressLabel(longLabel)).toBe(false); + }); + + it("ignores surrounding whitespace when checking the address label length", () => { + const labelWithWhitespace = ` ${"a".repeat(32)} `; + + expect(getContactAddressLabelValidationError(labelWithWhitespace)).toBeNull(); + expect(parseContactAddressLabel(labelWithWhitespace)).toBe("a".repeat(32)); }); it("reports invalid characters for a non-empty invalid draft label", () => { @@ -98,5 +110,8 @@ describe("contact address label validation", () => { expect(() => parseContactAddressLabel("ethereum", existingLabels)).toThrow( DuplicateContactAddressLabelError, ); + expect(() => parseContactAddressLabel("Ethereum ".repeat(50))).toThrow( + ContactAddressLabelTooLongError, + ); }); }); diff --git a/domain/entity/contact/src/validation.ts b/domain/entity/contact/src/validation.ts index 146a0f7c2489..43f64be80e7c 100644 --- a/domain/entity/contact/src/validation.ts +++ b/domain/entity/contact/src/validation.ts @@ -1,5 +1,7 @@ import { + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, + ContactAddressLabelTooLongError, DuplicateContactAddressLabelError, INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME, INVALID_CONTACT_NAME_ERROR_NAME, @@ -8,7 +10,11 @@ import { type ContactAddressLabelValidationErrorName, type ContactNameValidationErrorName, } from "./errors"; -import { ContactAddressLabelSchema, ContactNameSchema } from "./schema"; +import { + CONTACT_ADDRESS_LABEL_MAX_LENGTH, + ContactAddressLabelSchema, + ContactNameSchema, +} from "./schema"; import type { ContactAddressLabel, ContactName } from "./types"; export function getContactNameValidationError( @@ -47,10 +53,16 @@ export function getContactAddressLabelValidationError( draftLabel: string, existingLabels: readonly ContactAddressLabel[] = [], ): ContactAddressLabelValidationErrorName | null { - const parsed = ContactAddressLabelSchema.safeParse(draftLabel); + const trimmedDraftLabel = draftLabel.trim(); + + if (trimmedDraftLabel.length > CONTACT_ADDRESS_LABEL_MAX_LENGTH) { + return CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME; + } + + const parsed = ContactAddressLabelSchema.safeParse(trimmedDraftLabel); if (!parsed.success) { - return draftLabel.trim().length === 0 ? null : INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME; + return trimmedDraftLabel.length === 0 ? null : INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME; } const comparisonLabel = normalizeContactAddressLabelForComparison(parsed.data); @@ -75,7 +87,13 @@ export function parseContactAddressLabel( draftLabel: string, existingLabels: readonly ContactAddressLabel[] = [], ): ContactAddressLabel { - const parsed = ContactAddressLabelSchema.safeParse(draftLabel); + const trimmedDraftLabel = draftLabel.trim(); + + if (trimmedDraftLabel.length > CONTACT_ADDRESS_LABEL_MAX_LENGTH) { + throw new ContactAddressLabelTooLongError(); + } + + const parsed = ContactAddressLabelSchema.safeParse(trimmedDraftLabel); if (!parsed.success) { throw new InvalidContactAddressLabelError(); diff --git a/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressName.web.test.tsx b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressName.web.test.tsx new file mode 100644 index 000000000000..efd3c717a59a --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressName.web.test.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, + DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, + INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME, + ContactAddressLabelSchema, + ContactAddressValueSchema, +} from "@domain/entity-contact"; +import { ContactsAddAddressName } from "./ContactsAddAddressName.web"; +import type { ContactsAddAddressNameProps } from "./types"; + +const RESOLVED_ADDRESS = ContactAddressValueSchema.parse( + "0x1ad23b2cf8d2e0591ea417eb82f7cd9746c53034", +); + +function createProps( + overrides: Partial = {}, +): ContactsAddAddressNameProps { + return { + addressEntry: { + status: "valid", + value: RESOLVED_ADDRESS, + resolvedAddress: RESOLVED_ADDRESS, + inputMethod: "manual", + }, + addressLabel: { + status: "valid", + value: "Ethereum", + label: ContactAddressLabelSchema.parse("Ethereum"), + validationError: null, + }, + labels: { + inputLabel: "Address name", + continueToReview: "Continue to review", + validAddress: "Valid address", + validationErrors: { + [INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME]: "Special characters are not allowed.", + [DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME]: + "This address name is already used for this contact.", + [CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME]: + "Address names must be 32 characters or fewer.", + }, + }, + onAddressLabelChange: jest.fn(), + onContinue: jest.fn(), + ...overrides, + }; +} + +describe("ContactsAddAddressName", () => { + it("should render the confirmed address and prefilled label with the 32-character limit", () => { + render(); + + expect(screen.getByTestId("contacts-add-address-confirmed-input")).toHaveAttribute( + "value", + RESOLVED_ADDRESS, + ); + expect(screen.getByTestId("contacts-add-address-name-input")).toHaveValue("Ethereum"); + expect(screen.getByTestId("contacts-add-address-name-input")).toHaveAttribute( + "maxlength", + "32", + ); + expect(screen.getByTestId("contacts-add-address-name-continue")).toBeEnabled(); + }); + + it("should forward address-label edits", () => { + const onAddressLabelChange = jest.fn(); + render(); + + fireEvent.change(screen.getByTestId("contacts-add-address-name-input"), { + target: { value: "Exchange" }, + }); + + expect(onAddressLabelChange).toHaveBeenCalledWith("Exchange"); + }); + + it.each([ + [INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME, "Special characters are not allowed."], + [ + DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, + "This address name is already used for this contact.", + ], + [CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, "Address names must be 32 characters or fewer."], + ] as const)("should render the shared %s validation error", (validationError, message) => { + render( + , + ); + + expect(screen.getByTestId("contacts-add-address-name-input")).toHaveAttribute( + "helpertext", + message, + ); + expect(screen.getByTestId("contacts-add-address-name-continue")).toBeDisabled(); + }); + + it("should continue only with a valid address label", () => { + const onContinue = jest.fn(); + const { rerender } = render(); + + fireEvent.click(screen.getByTestId("contacts-add-address-name-continue")); + expect(onContinue).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(screen.getByTestId("contacts-add-address-name-continue")).toBeDisabled(); + }); +}); diff --git a/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressName.web.tsx b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressName.web.tsx new file mode 100644 index 000000000000..66fbcea5607f --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressName.web.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import type { ContactsAddAddressNameProps } from "./types"; +import { ContactsAddAddressNameView } from "./ContactsAddAddressNameView.web"; +import { useContactsAddAddressNameViewModel } from "./useContactsAddAddressNameViewModel.web"; + +export function ContactsAddAddressName(props: ContactsAddAddressNameProps): React.JSX.Element { + return ; +} diff --git a/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressNameView.web.test.tsx b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressNameView.web.test.tsx new file mode 100644 index 000000000000..54b313eff8e3 --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressNameView.web.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { + CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME, + ContactAddressLabelSchema, + ContactAddressValueSchema, + DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME, + INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME, +} from "@domain/entity-contact"; +import { ContactsAddAddressNameView } from "./ContactsAddAddressNameView.web"; +import type { ContactsAddAddressNameViewProps } from "./types"; + +const RESOLVED_ADDRESS = ContactAddressValueSchema.parse( + "0x1ad23b2cf8d2e0591ea417eb82f7cd9746c53034", +); + +function createProps( + overrides: Partial = {}, +): ContactsAddAddressNameViewProps { + return { + address: RESOLVED_ADDRESS, + addressLabel: { + status: "valid", + value: "Ethereum", + label: ContactAddressLabelSchema.parse("Ethereum"), + validationError: null, + }, + labels: { + inputLabel: "Address name", + continueToReview: "Continue to review", + validAddress: "Valid address", + validationErrors: { + [INVALID_CONTACT_ADDRESS_LABEL_ERROR_NAME]: "Special characters are not allowed.", + [DUPLICATE_CONTACT_ADDRESS_LABEL_ERROR_NAME]: + "This address name is already used for this contact.", + [CONTACT_ADDRESS_LABEL_TOO_LONG_ERROR_NAME]: "This address name is too long.", + }, + }, + isContinueEnabled: true, + onAddressLabelChange: jest.fn(), + onContinue: jest.fn(), + ...overrides, + }; +} + +describe("ContactsAddAddressNameView", () => { + it("should render the address details and forward input actions", () => { + const onAddressLabelChange = jest.fn(); + const onContinue = jest.fn(); + + render(); + + expect(screen.getByTestId("contacts-add-address-confirmed-input")).toHaveAttribute( + "value", + RESOLVED_ADDRESS, + ); + expect(screen.getByTestId("contacts-add-address-name-input")).toHaveValue("Ethereum"); + + fireEvent.change(screen.getByTestId("contacts-add-address-name-input"), { + target: { value: "Exchange" }, + }); + fireEvent.click(screen.getByTestId("contacts-add-address-name-continue")); + + expect(onAddressLabelChange).toHaveBeenCalledTimes(1); + expect(onContinue).toHaveBeenCalledTimes(1); + }); + + it("should display an invalid label and disable continuation", () => { + render( + , + ); + + expect(screen.getByTestId("contacts-add-address-name-input")).toHaveAttribute( + "helpertext", + "Special characters are not allowed.", + ); + expect(screen.getByTestId("contacts-add-address-name-continue")).toBeDisabled(); + }); +}); diff --git a/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressNameView.web.tsx b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressNameView.web.tsx new file mode 100644 index 000000000000..4d22dadc2391 --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/AddressName/ContactsAddAddressNameView.web.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { AddressInput, Button, TextInput } from "@ledgerhq/lumen-ui-react"; +import { LedgerLogo } from "@ledgerhq/lumen-ui-react/symbols"; +import { CONTACT_ADDRESS_LABEL_MAX_LENGTH } from "@domain/entity-contact"; +import type { ContactsAddAddressNameViewProps } from "./types"; + +export function ContactsAddAddressNameView({ + address, + addressLabel, + labels, + validationMessage, + isContinueEnabled, + onAddressLabelChange, + onContinue, +}: ContactsAddAddressNameViewProps): React.JSX.Element { + return ( +
+ + + +
+ ); +} diff --git a/features/flow/contacts/src/steps/AddAddress/AddressName/types.ts b/features/flow/contacts/src/steps/AddAddress/AddressName/types.ts new file mode 100644 index 000000000000..fe45aa425980 --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/AddressName/types.ts @@ -0,0 +1,28 @@ +import type { ChangeEvent } from "react"; +import type { ContactAddressLabelValidationErrorName } from "@domain/entity-contact"; +import type { AddAddressLabelState, ValidAddAddressEntryState } from "../types"; + +export type ContactsAddAddressNameLabels = Readonly<{ + inputLabel: string; + continueToReview: string; + validAddress: string; + validationErrors: Record; +}>; + +export type ContactsAddAddressNameProps = Readonly<{ + addressEntry: ValidAddAddressEntryState; + addressLabel: AddAddressLabelState; + labels: ContactsAddAddressNameLabels; + onAddressLabelChange: (value: string) => void; + onContinue: () => void; +}>; + +export type ContactsAddAddressNameViewProps = Readonly<{ + address: string; + addressLabel: AddAddressLabelState; + labels: ContactsAddAddressNameLabels; + validationMessage?: string; + isContinueEnabled: boolean; + onAddressLabelChange: (event: ChangeEvent) => void; + onContinue: () => void; +}>; diff --git a/features/flow/contacts/src/steps/AddAddress/AddressName/useContactsAddAddressNameViewModel.web.ts b/features/flow/contacts/src/steps/AddAddress/AddressName/useContactsAddAddressNameViewModel.web.ts new file mode 100644 index 000000000000..5186f844a3bc --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/AddressName/useContactsAddAddressNameViewModel.web.ts @@ -0,0 +1,32 @@ +import { useCallback, useMemo, type ChangeEvent } from "react"; +import type { ContactsAddAddressNameProps, ContactsAddAddressNameViewProps } from "./types"; + +export function useContactsAddAddressNameViewModel({ + addressEntry, + addressLabel, + labels, + onAddressLabelChange, + onContinue, +}: ContactsAddAddressNameProps): ContactsAddAddressNameViewProps { + const validationMessage = useMemo( + () => + addressLabel.validationError + ? labels.validationErrors[addressLabel.validationError] + : undefined, + [addressLabel.validationError, labels.validationErrors], + ); + const onChange = useCallback( + (event: ChangeEvent) => onAddressLabelChange(event.target.value), + [onAddressLabelChange], + ); + + return { + address: addressEntry.value, + addressLabel, + labels, + validationMessage, + isContinueEnabled: addressLabel.status === "valid", + onAddressLabelChange: onChange, + onContinue, + }; +} diff --git a/features/flow/contacts/src/steps/AddAddress/Completion/ContactsAddAddressCompletion.web.tsx b/features/flow/contacts/src/steps/AddAddress/Completion/ContactsAddAddressCompletion.web.tsx new file mode 100644 index 000000000000..861422d7e95a --- /dev/null +++ b/features/flow/contacts/src/steps/AddAddress/Completion/ContactsAddAddressCompletion.web.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { Button } from "@ledgerhq/lumen-ui-react"; +import type { AddAddressPlaceholderViewProps } from "../types"; + +export function ContactsAddAddressCompletion({ + title, + buttonLabel, + testID, + onContinue, +}: AddAddressPlaceholderViewProps): React.JSX.Element { + return ( +
+
+

{title}

+
+ +
+ ); +} diff --git a/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntry.web.types.ts b/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntry.web.types.ts index 07707d9cb8d6..90bfa6827230 100644 --- a/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntry.web.types.ts +++ b/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntry.web.types.ts @@ -1,10 +1,19 @@ import type { ChangeEvent, ClipboardEvent } from "react"; -import type { AddAddressEntryLabels, AddAddressEntryState, AddAddressInputSource } from "./types"; +import type { ContactsAddAddressNameLabels } from "./AddressName/types"; +import type { + AddAddressEntryLabels, + AddAddressEntryState, + AddAddressInputSource, + AddAddressLabelState, +} from "./types"; export type ContactsAddAddressEntryWebProps = Readonly<{ addressEntry: AddAddressEntryState; labels: AddAddressEntryLabels; onAddressChange: (address: string, inputMethod: AddAddressInputSource) => void; + addressLabel?: AddAddressLabelState; + nameLabels?: ContactsAddAddressNameLabels; + onAddressLabelChange?: (value: string) => void; onConfirm?: () => void; }>; @@ -14,8 +23,12 @@ export type ContactsAddAddressEntryWebViewProps = Readonly<{ inputStatus?: "error" | "success"; helperText?: string; showEnsDisclaimer: boolean; + addressLabel?: AddAddressLabelState; + nameLabels?: ContactsAddAddressNameLabels; + nameValidationMessage?: string; isConfirmEnabled: boolean; onChange: (event: ChangeEvent) => void; onPaste: (event: ClipboardEvent) => void; + onAddressLabelChange?: (event: ChangeEvent) => void; onConfirm?: () => void; }>; diff --git a/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntryView.web.tsx b/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntryView.web.tsx index 0828e81c4b93..d23d7e0c80fd 100644 --- a/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntryView.web.tsx +++ b/features/flow/contacts/src/steps/AddAddress/ContactsAddAddressEntryView.web.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { AddressInput, Banner, Button } from "@ledgerhq/lumen-ui-react"; +import { AddressInput, Banner, Button, TextInput } from "@ledgerhq/lumen-ui-react"; import { LedgerLogo } from "@ledgerhq/lumen-ui-react/symbols"; +import { CONTACT_ADDRESS_LABEL_MAX_LENGTH } from "@domain/entity-contact"; import type { ContactsAddAddressEntryWebViewProps } from "./ContactsAddAddressEntry.web.types"; export function ContactsAddAddressEntryView({ @@ -9,9 +10,13 @@ export function ContactsAddAddressEntryView({ inputStatus, helperText, showEnsDisclaimer, + addressLabel, + nameLabels, + nameValidationMessage, isConfirmEnabled, onChange, onPaste, + onAddressLabelChange, onConfirm, }: ContactsAddAddressEntryWebViewProps): React.JSX.Element { return ( @@ -37,6 +42,21 @@ export function ContactsAddAddressEntryView({ description={labels.ensDisclaimer} /> ) : null} + {addressLabel && nameLabels && onAddressLabelChange ? ( + + ) : null}