From aa1b8bcdb825ffca4b5e161ddfd362bbe5ca2530 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 10 May 2026 19:30:22 +0300 Subject: [PATCH 1/9] fix: Attachment - Edit comment option is shown for .doc file --- src/libs/ReportUtils.ts | 13 +++++++------ src/libs/isReportMessageAttachment.ts | 25 ++++++++----------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1480bf4a695c..6aeaac9c1fdc 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -116,7 +116,6 @@ import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from './Error import getAttachmentDetails from './fileDownload/getAttachmentDetails'; import type {FormulaContext} from './Formula'; import getBase62ReportID from './getBase62ReportID'; -import {isReportMessageAttachment} from './isReportMessageAttachment'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {translateLocal} from './Localize'; import Log from './Log'; @@ -5024,13 +5023,16 @@ function canEditFieldOfMoneyRequest({ * Can only edit if: * * - It was written by the current user - * - It's an ADD_COMMENT that is not an attachment + * - It's an ADD_COMMENT or IOU action + * - It's not an optimistic attachment (still uploading) * - It's an expense where conditions for modifications are defined in canEditMoneyRequest method * - It's not pending deletion */ function canEditReportAction(reportAction: OnyxInputOrEntry, linkedTransaction: OnyxEntry): boolean { const isCommentOrIOU = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU; - const message = reportAction ? getReportActionMessageReportUtils(reportAction) : undefined; + + // Block editing only while the attachment is still uploading; once synced, attachments are editable. + const isOptimisticAttachment = !!reportAction?.isOptimisticAction && (!!reportAction?.isAttachmentOnly || !!reportAction?.isAttachmentWithText); // For money request actions on settled/approved/closed expense reports, the action cannot be edited. // canEditMoneyRequest has an admin/manager bypass for field-level edits (via canEditFieldOfMoneyRequest), @@ -5048,9 +5050,8 @@ function canEditReportAction(reportAction: OnyxInputOrEntry, linke return !!( reportAction?.actorAccountID === deprecatedCurrentUserAccountID && isCommentOrIOU && - (!isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction, linkedTransaction)) && // Returns true for non-IOU actions - !isReportMessageAttachment(message) && - ((!reportAction.isAttachmentWithText && !reportAction.isAttachmentOnly) || !reportAction.isOptimisticAction) && + (!isMoneyRequestAction(reportAction) || canEditMoneyRequest(reportAction, linkedTransaction)) && + !isOptimisticAttachment && !isDeletedAction(reportAction) && !isCreatedTaskReportAction(reportAction) && reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index 662d2e19922f..332630194ca8 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -1,37 +1,28 @@ -import {Str} from 'expensify-common'; import CONST from '@src/CONST'; import type {Message} from '@src/types/onyx/ReportAction'; const attachmentRegex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); +const attachmentOnlyHtmlRegex = /^\s*<(?:img|a|video)\s/i; /** - * Check whether a report action is Attachment or not. - * Ignore messages containing [Attachment] as the main content. Attachments are actions with only text as [Attachment]. - * - * @param message report action's message as text, html and translationKey + * Returns true for attachment-only messages (no user-typed text), false for attachment+text and non-attachment messages. */ function isReportMessageAttachment(message: Message | undefined): boolean { if (!message?.text || !message.html) { return false; } - if (message.translationKey) { - return message.text === CONST.ATTACHMENT_MESSAGE_TEXT && message.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; + // Optimistic attachment-only messages carry this translationKey; the server drops it after sync. + if (message.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT) { + return true; } - const hasAttachmentHtml = attachmentRegex.test(message.html); - - if (!hasAttachmentHtml) { + if (!attachmentRegex.test(message.html)) { return false; } - const isAttachmentMessageText = message.text === CONST.ATTACHMENT_MESSAGE_TEXT; - - if (isAttachmentMessageText) { - return true; - } - - return Str.isVideo(message.text); + // Attachment-only HTML starts with the attachment tag; attachment+text has user content before it. + return attachmentOnlyHtmlRegex.test(message.html); } // eslint-disable-next-line import/prefer-default-export From 0d4e9d2e53e19d1f05ce2e7ba56a2dc066614070 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 15 May 2026 21:13:24 +0300 Subject: [PATCH 2/9] refactor: use htmlparser2 for attachment-only detection --- src/libs/isReportMessageAttachment.ts | 31 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index 332630194ca8..e8a289ca379c 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -1,8 +1,8 @@ +import {Parser as HtmlParser} from 'htmlparser2'; import CONST from '@src/CONST'; import type {Message} from '@src/types/onyx/ReportAction'; -const attachmentRegex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); -const attachmentOnlyHtmlRegex = /^\s*<(?:img|a|video)\s/i; +const ATTACHMENT_TAGS = new Set(['a', 'img', 'video']); /** * Returns true for attachment-only messages (no user-typed text), false for attachment+text and non-attachment messages. @@ -17,12 +17,29 @@ function isReportMessageAttachment(message: Message | undefined): boolean { return true; } - if (!attachmentRegex.test(message.html)) { - return false; - } + // First node decides: attachment tag → attachment-only; anything else (text, plain link, mention) → attachment+text. + let result = false; + let firstNodeSeen = false; + + const parser = new HtmlParser({ + ontext: (text) => { + if (firstNodeSeen || !text.trim()) { + return; + } + firstNodeSeen = true; + }, + onopentag: (name, attribs) => { + if (firstNodeSeen) { + return; + } + firstNodeSeen = true; + result = ATTACHMENT_TAGS.has(name) && !!attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; + }, + }); + parser.write(message.html); + parser.end(); - // Attachment-only HTML starts with the attachment tag; attachment+text has user content before it. - return attachmentOnlyHtmlRegex.test(message.html); + return result; } // eslint-disable-next-line import/prefer-default-export From 21b9a79815dba6f20be73f0d7b40dd044235f834 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 15 May 2026 21:19:27 +0300 Subject: [PATCH 3/9] chore: whitelist ontext in cspell.json --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 6992446943a2..5ec4129b9d9e 100644 --- a/cspell.json +++ b/cspell.json @@ -530,6 +530,7 @@ "onloaderror", "onopentag", "onplayerror", + "ontext", "onxy", "ONYXKEY", "ONYXKEYS", From d6c224c3b5d8c05ca255a2cdc26315771596e3f5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 16 May 2026 00:23:43 +0300 Subject: [PATCH 4/9] test: add coverage for attachment-only detection and edit gating --- tests/unit/ReportUtilsTest.ts | 85 +++++++++++++++++++++ tests/unit/isReportMessageAttachmentTest.ts | 74 ++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6478f908203a..0a314e63eca0 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -5803,6 +5803,91 @@ describe('ReportUtils', () => { expect(canEditReportAction(moneyRequestAction, transaction)).toEqual(false); }); + + it('should return false for an optimistic attachment-only action (still uploading)', () => { + const transaction = createRandomTransaction(300); + const reportAction: ReportAction = { + reportActionID: '300', + actorAccountID: currentUserAccountID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [ + { + type: 'COMMENT', + html: 'file.doc', + text: '[Attachment]', + }, + ], + isAttachmentOnly: true, + isOptimisticAction: true, + created: '2025-03-05 16:34:27', + }; + + expect(canEditReportAction(reportAction, transaction)).toEqual(false); + }); + + it('should return false for an optimistic attachment+text action (still uploading)', () => { + const transaction = createRandomTransaction(301); + const reportAction: ReportAction = { + reportActionID: '301', + actorAccountID: currentUserAccountID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [ + { + type: 'COMMENT', + html: 'Hi

file.doc', + text: 'Hi\n[Attachment]', + }, + ], + isAttachmentWithText: true, + isOptimisticAction: true, + created: '2025-03-05 16:34:27', + }; + + expect(canEditReportAction(reportAction, transaction)).toEqual(false); + }); + + it('should return true for a synced attachment-only action (optimistic flags cleared)', () => { + const transaction = createRandomTransaction(302); + // After successData merge: isOptimisticAction=null, isAttachmentOnly persists from optimistic merge. + const reportAction: ReportAction = { + reportActionID: '302', + actorAccountID: currentUserAccountID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [ + { + type: 'COMMENT', + html: 'file.doc', + text: 'file.doc https://www.expensify.com/chat-attachments/123/file.doc', + }, + ], + isAttachmentOnly: true, + isOptimisticAction: null, + created: '2025-03-05 16:34:27', + }; + + expect(canEditReportAction(reportAction, transaction)).toEqual(true); + }); + + it('should return true for a synced attachment+text action', () => { + const transaction = createRandomTransaction(303); + const reportAction: ReportAction = { + reportActionID: '303', + actorAccountID: currentUserAccountID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [ + { + type: 'COMMENT', + html: 'Hi

file.doc', + text: 'Hi\nfile.doc https://www.expensify.com/chat-attachments/123/file.doc', + }, + ], + isAttachmentWithText: true, + isOptimisticAction: null, + created: '2025-03-05 16:34:27', + }; + + expect(canEditReportAction(reportAction, transaction)).toEqual(true); + }); }); describe('getChatByParticipants', () => { diff --git a/tests/unit/isReportMessageAttachmentTest.ts b/tests/unit/isReportMessageAttachmentTest.ts index 75faba9c8935..52dee4e31710 100644 --- a/tests/unit/isReportMessageAttachmentTest.ts +++ b/tests/unit/isReportMessageAttachmentTest.ts @@ -1,3 +1,4 @@ +import CONST from '@src/CONST'; import type {Message} from '@src/types/onyx/ReportAction'; import {isReportMessageAttachment} from '../../src/libs/isReportMessageAttachment'; @@ -21,4 +22,77 @@ describe('isReportMessageAttachment', () => { message = {text: '[Attachment]', html: '[Attachment]', type: ''}; expect(isReportMessageAttachment(message)).toBe(false); }); + + it('returns true for optimistic attachment-only via translationKey', () => { + const message: Message = { + text: '[Attachment]', + html: 'sample.doc', + type: '', + translationKey: CONST.TRANSLATION_KEYS.ATTACHMENT, + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns true for .doc attachment-only after OpenReport sync (text is "filename URL")', () => { + const message: Message = { + text: 'sample.doc https://staging.expensify.com/chat-attachments/4271684646135633435/w_87c70962804917e4c6866d052537133ae0a3060e.doc', + html: 'sample.doc', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns true for .pdf attachment-only', () => { + const message: Message = { + text: 'report.pdf https://www.expensify.com/chat-attachments/123/report.pdf', + html: 'report.pdf', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns true for video attachment-only', () => { + const message: Message = { + text: 'demo.mp4', + html: '', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns false for attachment+text (user text before the attachment)', () => { + const message: Message = { + text: 'Check this file\nsample.doc https://www.expensify.com/chat-attachments/123/sample.doc', + html: 'Check this file

sample.doc', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); + + it('returns false for a leading user URL + attachment (regression guard for codex bot P2)', () => { + const message: Message = { + text: 'https://example.com check this\nsample.doc https://www.expensify.com/chat-attachments/123/sample.doc', + html: 'https://example.com check this

sample.doc', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); + + it('returns false for a plain user link with only data-raw-href (no attachment)', () => { + const message: Message = { + text: 'https://example.com', + html: 'https://example.com', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); + + it('returns false for a mention before the attachment', () => { + const message: Message = { + text: '@user check this\nsample.doc https://www.expensify.com/chat-attachments/123/sample.doc', + html: '@user check this

sample.doc', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); }); From d42f03f1549b6976e5062e9092ef944e768f4443 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 16 May 2026 00:33:35 +0300 Subject: [PATCH 5/9] fix: treat caption appended after attachment as attachment+text --- src/libs/isReportMessageAttachment.ts | 32 +++++++++++++++------ tests/unit/ReportUtilsTest.ts | 6 ++-- tests/unit/isReportMessageAttachmentTest.ts | 18 ++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index e8a289ca379c..1da5b75f2a29 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -17,29 +17,43 @@ function isReportMessageAttachment(message: Message | undefined): boolean { return true; } - // First node decides: attachment tag → attachment-only; anything else (text, plain link, mention) → attachment+text. - let result = false; - let firstNodeSeen = false; + // Attachment-only = exactly one attachment tag and nothing else; any text/element outside it → attachment+text. + let attachmentCount = 0; + let hasOtherContent = false; + // Skip the filename text inside an open /sample.doc my caption', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); + + it('returns true when the anchor inner text (filename) is the only text', () => { + const message: Message = { + text: 'sample.doc https://www.expensify.com/chat-attachments/123/sample.doc', + html: 'sample.doc', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); }); From fabe04b245d4022f4a411e14b9f4867fe5e114c2 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sun, 17 May 2026 23:37:37 +0300 Subject: [PATCH 6/9] refactor: rename openAttachmentTag and add multi-attachment-only test --- src/libs/isReportMessageAttachment.ts | 16 ++++++++-------- tests/unit/isReportMessageAttachmentTest.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index 1da5b75f2a29..a3d4748f3f8c 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -17,37 +17,37 @@ function isReportMessageAttachment(message: Message | undefined): boolean { return true; } - // Attachment-only = exactly one attachment tag and nothing else; any text/element outside it → attachment+text. + // Attachment-only = one or more attachment tags and nothing else; any text/element outside them → attachment+text. let attachmentCount = 0; let hasOtherContent = false; - // Skip the filename text inside an open //a.docb.pdf', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); }); From 791171f54635067b297e953333ffed46dc3f5655 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 18 May 2026 09:47:33 +0300 Subject: [PATCH 7/9] perf: fast-path + memoize isReportMessageAttachment --- src/libs/isReportMessageAttachment.ts | 71 +++++++++++++------ .../isReportMessageAttachment.perf-test.ts | 37 ++++++++++ tests/unit/isReportMessageAttachmentTest.ts | 19 ++++- 3 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 tests/perf-test/isReportMessageAttachment.perf-test.ts diff --git a/src/libs/isReportMessageAttachment.ts b/src/libs/isReportMessageAttachment.ts index a3d4748f3f8c..275a2c8691c7 100644 --- a/src/libs/isReportMessageAttachment.ts +++ b/src/libs/isReportMessageAttachment.ts @@ -3,57 +3,86 @@ import CONST from '@src/CONST'; import type {Message} from '@src/types/onyx/ReportAction'; const ATTACHMENT_TAGS = new Set(['a', 'img', 'video']); +// Leading space + `="` (not a bare substring) so a URL query param like `?data-expensify-source=` isn't a false positive. +const ATTACHMENT_SOURCE_TOKEN = ` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="`; -/** - * Returns true for attachment-only messages (no user-typed text), false for attachment+text and non-attachment messages. - */ -function isReportMessageAttachment(message: Message | undefined): boolean { - if (!message?.text || !message.html) { +// Keyed by Onyx message identity: re-renders pass the same immutable object, so parse at most once per message. +const resultCache = new WeakMap(); + +function computeIsReportMessageAttachment(html: string, text: string, translationKey: string | undefined): boolean { + // Optimistic attachment-only messages carry this translationKey; the server drops it after sync. + if (translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT) { + return true; + } + + // Fast path: no source attribute → not an attachment (avoids the parser for most messages). + if (!html.includes(ATTACHMENT_SOURCE_TOKEN)) { return false; } - // Optimistic attachment-only messages carry this translationKey; the server drops it after sync. - if (message.translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT) { + // image/video attachment-only keep text "[Attachment]"; only .doc/.pdf (text = "filename URL") need the parser. + if (text === CONST.ATTACHMENT_MESSAGE_TEXT) { return true; } - // Attachment-only = one or more attachment tags and nothing else; any text/element outside them → attachment+text. - let attachmentCount = 0; + let hasAttachment = false; let hasOtherContent = false; - // Holds the tag name of the open /sample.doc', + type: '', + }, + // image attachment-only (translationKey path) + { + text: '[Attachment]', + html: '', + type: '', + translationKey: 'common.attachment', + }, + { + text: 'Here is the file\nsample.pdf https://www.expensify.com/chat-attachments/123/s.pdf', + html: 'Here is the file

sample.pdf', + type: '', + }, + {text: 'Just a normal chat message with some words in it', html: 'Just a normal chat message with some words in it', type: ''}, + {text: 'https://example.com', html: 'https://example.com', type: ''}, +]; + +// One classification pass over a long chat history (called per message on list renders / LHN). +const LONG_CHAT_HISTORY: Message[] = Array.from({length: 2000}, () => SAMPLE_MESSAGES).flat(); + +test('[isReportMessageAttachment] classify a 10k-message chat history', async () => { + await measureFunction(() => { + for (const message of LONG_CHAT_HISTORY) { + isReportMessageAttachment(message); + } + }); +}); diff --git a/tests/unit/isReportMessageAttachmentTest.ts b/tests/unit/isReportMessageAttachmentTest.ts index 5c88e24f7d11..31405e8443fa 100644 --- a/tests/unit/isReportMessageAttachmentTest.ts +++ b/tests/unit/isReportMessageAttachmentTest.ts @@ -114,7 +114,6 @@ describe('isReportMessageAttachment', () => { expect(isReportMessageAttachment(message)).toBe(true); }); - // Multi-upload is normally split into separate actions; if multiple attachments share one message, classification stays attachment-only. it('returns true for multiple attachments with no surrounding text', () => { const message: Message = { text: 'a.doc https://www.expensify.com/chat-attachments/1/a.doc\nb.pdf https://www.expensify.com/chat-attachments/2/b.pdf', @@ -123,4 +122,22 @@ describe('isReportMessageAttachment', () => { }; expect(isReportMessageAttachment(message)).toBe(true); }); + + it('returns true for an attachment whose anchor wraps nested markup', () => { + const message: Message = { + text: 'sample.doc https://www.expensify.com/chat-attachments/123/sample.doc', + html: 'presample.doc', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns false for an attachment with nested markup AND a trailing caption', () => { + const message: Message = { + text: 'sample.doc https://www.expensify.com/chat-attachments/123/sample.doc see this', + html: 'presample.doc see this', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); }); From 4edca7a1aee8357e2224ec851f72c8b0ee724d09 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 18 May 2026 10:12:02 +0300 Subject: [PATCH 8/9] test: cover guards, translationKey semantics, and full attachment matrix --- tests/unit/ReportUtilsTest.ts | 20 ++++++++++++ tests/unit/isReportMessageAttachmentTest.ts | 34 +++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 12e8368cb7a2..f270960eef98 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -5888,6 +5888,26 @@ describe('ReportUtils', () => { expect(canEditReportAction(reportAction, transaction)).toEqual(true); }); + + it('should return true for an optimistic plain-text comment (no attachment)', () => { + const transaction = createRandomTransaction(304); + const reportAction: ReportAction = { + reportActionID: '304', + actorAccountID: currentUserAccountID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [ + { + type: 'COMMENT', + html: 'hello', + text: 'hello', + }, + ], + isOptimisticAction: true, + created: '2025-03-05 16:34:27', + }; + + expect(canEditReportAction(reportAction, transaction)).toEqual(true); + }); }); describe('getChatByParticipants', () => { diff --git a/tests/unit/isReportMessageAttachmentTest.ts b/tests/unit/isReportMessageAttachmentTest.ts index 31405e8443fa..32362d5071c3 100644 --- a/tests/unit/isReportMessageAttachmentTest.ts +++ b/tests/unit/isReportMessageAttachmentTest.ts @@ -140,4 +140,38 @@ describe('isReportMessageAttachment', () => { }; expect(isReportMessageAttachment(message)).toBe(false); }); + + it('returns false for undefined or empty message', () => { + expect(isReportMessageAttachment(undefined)).toBe(false); + expect(isReportMessageAttachment({text: '', html: '', type: ''})).toBe(false); + expect(isReportMessageAttachment({text: 'x', html: undefined as unknown as string, type: ''})).toBe(false); + }); + + it('falls through when translationKey is set but is not the attachment key', () => { + const message: Message = { + text: 'sample.doc https://www.expensify.com/chat-attachments/123/sample.doc', + html: 'sample.doc', + type: '', + translationKey: 'common.download', + }; + expect(isReportMessageAttachment(message)).toBe(true); + }); + + it('returns false for image attachment + text', () => { + const message: Message = { + text: 'check this image\n[Attachment]', + html: 'check this image

', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); + + it('returns false for video attachment + text', () => { + const message: Message = { + text: 'watch this\ndemo.mp4 https://www.expensify.com/chat-attachments/123/demo.mp4', + html: 'watch this

', + type: '', + }; + expect(isReportMessageAttachment(message)).toBe(false); + }); }); From 6b0b0596ce01ef272d205f6c767a78f13055f1b8 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 18 May 2026 10:28:31 +0300 Subject: [PATCH 9/9] fix: drop PrivatePersonalDetails merge collateral --- .../UpdatePrivatePersonalDetailsParams.ts | 16 - src/libs/PersonalDetailsUtils.ts | 24 -- src/libs/actions/PersonalDetails.ts | 53 --- ...atePersonalDetailsConfirmMagicCodePage.tsx | 81 ---- .../PrivatePersonalDetailsPage.tsx | 396 ------------------ 5 files changed, 570 deletions(-) delete mode 100644 src/libs/API/parameters/UpdatePrivatePersonalDetailsParams.ts delete mode 100644 src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmMagicCodePage.tsx delete mode 100644 src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsPage.tsx diff --git a/src/libs/API/parameters/UpdatePrivatePersonalDetailsParams.ts b/src/libs/API/parameters/UpdatePrivatePersonalDetailsParams.ts deleted file mode 100644 index 177a5dfacfc6..000000000000 --- a/src/libs/API/parameters/UpdatePrivatePersonalDetailsParams.ts +++ /dev/null @@ -1,16 +0,0 @@ -type UpdatePrivatePersonalDetailsParams = { - legalFirstName: string; - legalLastName: string; - phoneNumber: string; - addressCity: string; - addressStreet: string; - addressStreet2: string; - addressZip: string; - addressCountry: string; - dob: string; - validateCode: string; - addressState: string; - addressProvince: string; -}; - -export default UpdatePrivatePersonalDetailsParams; diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 393fad87853e..81ed9e4a5909 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -4,8 +4,6 @@ import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsForm} from '@src/types/form'; -import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import type {OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePersonalDetails} from '@src/types/onyx'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -300,27 +298,6 @@ function getCurrentAddress(privatePersonalDetails: OnyxEntry, draftValues?: PersonalDetailsForm | null): PersonalDetailsForm { - const address = getCurrentAddress(privatePersonalDetails); - const [street1, street2] = getStreetLines(address?.street); - return { - [INPUT_IDS.LEGAL_FIRST_NAME]: draftValues?.[INPUT_IDS.LEGAL_FIRST_NAME] ?? privatePersonalDetails?.legalFirstName ?? '', - [INPUT_IDS.LEGAL_LAST_NAME]: draftValues?.[INPUT_IDS.LEGAL_LAST_NAME] ?? privatePersonalDetails?.legalLastName ?? '', - [INPUT_IDS.DATE_OF_BIRTH]: draftValues?.[INPUT_IDS.DATE_OF_BIRTH] ?? privatePersonalDetails?.dob ?? '', - [INPUT_IDS.PHONE_NUMBER]: draftValues?.[INPUT_IDS.PHONE_NUMBER] ?? privatePersonalDetails?.phoneNumber ?? '', - [INPUT_IDS.ADDRESS_LINE_1]: draftValues?.[INPUT_IDS.ADDRESS_LINE_1] ?? street1 ?? '', - [INPUT_IDS.ADDRESS_LINE_2]: draftValues?.[INPUT_IDS.ADDRESS_LINE_2] ?? street2 ?? '', - [INPUT_IDS.CITY]: draftValues?.[INPUT_IDS.CITY] ?? address?.city ?? '', - [INPUT_IDS.STATE]: draftValues?.[INPUT_IDS.STATE] ?? address?.state ?? '', - [INPUT_IDS.ZIP_POST_CODE]: draftValues?.[INPUT_IDS.ZIP_POST_CODE] ?? address?.zip ?? '', - [INPUT_IDS.COUNTRY]: draftValues?.[INPUT_IDS.COUNTRY] ?? address?.country ?? '', - }; -} - /** * Formats an address object into an easily readable string * @@ -488,7 +465,6 @@ export { getCurrentAddress, getFormattedAddress, getFormattedStreet, - getPrivatePersonalDetailsFormValues, getStreetLines, getEffectiveDisplayName, createDisplayName, diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index ff2c09fa89d8..b5b721145641 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -12,7 +12,6 @@ import type { UpdateHomeAddressParams, UpdateLegalNameParams, UpdatePhoneNumberParams, - UpdatePrivatePersonalDetailsParams, UpdatePronounsParams, UpdateSelectedTimezoneParams, UpdateUserAvatarParams, @@ -512,57 +511,6 @@ function deleteAvatar(currentUserPersonalDetails: Pick, validateCode: string, countryCode: number) { - const stateValue = (values.state ?? '').trim(); - const parameters: UpdatePrivatePersonalDetailsParams = { - legalFirstName: values.legalFirstName?.trim() ?? '', - legalLastName: values.legalLastName?.trim() ?? '', - phoneNumber: LoginUtils.appendCountryCode(values.phoneNumber?.trim() ?? '', countryCode), - addressCity: (values.city ?? '').trim(), - addressStreet: values.addressLine1?.trim() ?? '', - addressStreet2: values.addressLine2?.trim() ?? '', - addressZip: values.zipPostCode?.trim().toUpperCase() ?? '', - addressCountry: values.country ?? '', - addressState: stateValue, - addressProvince: values.country !== CONST.COUNTRY.US ? stateValue : '', - dob: values.dob ?? '', - validateCode, - }; - - API.write(WRITE_COMMANDS.UPDATE_PRIVATE_PERSONAL_DETAILS, parameters, { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - isLoading: true, - errorFields: {personalDetails: null}, - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - isLoading: false, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - isLoading: false, - errorFields: {personalDetails: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, - }, - }, - ], }); } @@ -608,7 +556,6 @@ export { clearPhoneNumberError, updatePronouns, updateSelectedTimezone, - updatePrivatePersonalDetails, updatePersonalDetailsAndShipExpensifyCards, clearPersonalDetailsErrors, buildSetPersonalDetailsAndShipExpensifyCardsParams, diff --git a/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmMagicCodePage.tsx b/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmMagicCodePage.tsx deleted file mode 100644 index bac67536154e..000000000000 --- a/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsConfirmMagicCodePage.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import ValidateCodeActionContent from '@components/ValidateCodeActionModal/ValidateCodeActionContent'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import usePrimaryContactMethod from '@hooks/usePrimaryContactMethod'; -import {clearPersonalDetailsErrors, updatePrivatePersonalDetails} from '@libs/actions/PersonalDetails'; -import {requestValidateCodeAction, resetValidateActionCodeSent} from '@libs/actions/User'; -import {normalizeCountryCode} from '@libs/CountryUtils'; -import {getLatestErrorField, getLatestErrorMessageField} from '@libs/ErrorUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {getPrivatePersonalDetailsFormValues} from '@libs/PersonalDetailsUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsForm} from '@src/types/form'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -function PrivatePersonalDetailsConfirmMagicCodePage() { - const {translate} = useLocalize(); - const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - const [draftValues] = useOnyx(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM_DRAFT); - const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const primaryLogin = usePrimaryContactMethod(); - - const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); - - // Errors may be written to either errorFields (preferred) or errors depending on - // how the backend reports the failure, so check both. - const personalDetailsErrorField = getLatestErrorField(privatePersonalDetails, 'personalDetails'); - const personalDetailsError = getLatestErrorMessageField(privatePersonalDetails); - const submitError = !isEmptyObject(personalDetailsErrorField) ? personalDetailsErrorField : personalDetailsError; - const hasErrors = !isEmptyObject(submitError) || !isEmptyObject(validateCodeAction?.errorFields); - - const clearError = () => { - if (!hasErrors) { - return; - } - clearPersonalDetailsErrors(); - }; - - const wasLoading = useRef(false); - useEffect(() => { - if (privatePersonalDetails?.isLoading) { - wasLoading.current = true; - return; - } - if (wasLoading.current && !hasErrors) { - wasLoading.current = false; - resetValidateActionCodeSent(); - Navigation.goBack(ROUTES.SETTINGS_PROFILE.route); - } - wasLoading.current = false; - }, [privatePersonalDetails?.isLoading, hasErrors]); - - const values = normalizeCountryCode(getPrivatePersonalDetailsFormValues(privatePersonalDetails, draftValues)) as PersonalDetailsForm; - - const handleSubmitForm = (validateCode: string) => { - updatePrivatePersonalDetails(values, validateCode, countryCode); - }; - - return ( - requestValidateCodeAction()} - validateCodeActionErrorField="personalDetails" - handleSubmitForm={handleSubmitForm} - validateError={submitError} - clearError={clearError} - onClose={() => { - resetValidateActionCodeSent(); - Navigation.goBack(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.route); - }} - isLoading={privatePersonalDetails?.isLoading} - /> - ); -} - -PrivatePersonalDetailsConfirmMagicCodePage.displayName = 'PrivatePersonalDetailsConfirmMagicCodePage'; - -export default PrivatePersonalDetailsConfirmMagicCodePage; diff --git a/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsPage.tsx b/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsPage.tsx deleted file mode 100644 index eae8cdb4172f..000000000000 --- a/src/pages/settings/Profile/PersonalDetails/PrivatePersonalDetailsPage.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import {useRoute} from '@react-navigation/native'; -import {subYears} from 'date-fns'; -import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useEffect, useState} from 'react'; -import {View} from 'react-native'; -import CountrySelector from '@components/CountrySelector'; -import DatePicker from '@components/DatePicker'; -import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import type {State} from '@components/StateSelector'; -import StateSelector from '@components/StateSelector'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {clearDraftValues} from '@libs/actions/FormActions'; -import {normalizeCountryCode} from '@libs/CountryUtils'; -import {appendCountryCode} from '@libs/LoginUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {getCurrentAddress, getStreetLines} from '@libs/PersonalDetailsUtils'; -import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import {doesContainReservedWord, getAgeRequirementError, isRequiredFulfilled, isValidDisplayName, isValidPhoneNumber} from '@libs/ValidationUtils'; -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; - -function PrivatePersonalDetailsPage() { - const route = useRoute>(); - const fieldToFocus = route.params?.fieldToFocus; - const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const legalFirstName = privatePersonalDetails?.legalFirstName ?? ''; - const legalLastName = privatePersonalDetails?.legalLastName ?? ''; - const phoneNumber = privatePersonalDetails?.phoneNumber ?? ''; - const dob = privatePersonalDetails?.dob ?? ''; - const address = normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address | undefined; - const [street1, street2Fallback] = getStreetLines(address?.street); - const initialStreet1 = street1 ?? ''; - const initialStreet2 = address?.street2 ?? street2Fallback ?? ''; - const city = address?.city ?? ''; - const state = address?.state ?? ''; - const zip = address?.zip ?? ''; - const country = address?.country ?? ''; - - const [selectedCountry, setSelectedCountry] = useState(country); - const [selectedState, setSelectedState] = useState(() => { - // If the stored state value is a full name (e.g. "California"), resolve it to a 2-letter code - // so the StateSelector dropdown can display it correctly. - if (state && !(state in COMMON_CONST.STATES)) { - const match = Object.entries(COMMON_CONST.STATES).find(([, v]) => v.stateName.toLowerCase() === state.toLowerCase()); - return match ? match[0] : state; - } - return state; - }); - - useEffect( - () => () => { - clearDraftValues(ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM); - }, - [], - ); - - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - - const firstNameValue = values[INPUT_IDS.LEGAL_FIRST_NAME] ?? ''; - if (!firstNameValue.trim()) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.fieldRequired'); - } else if (!isValidDisplayName(firstNameValue)) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('privatePersonalDetails.error.cannotIncludeCommaOrSemicolon'); - } else if (firstNameValue.length > CONST.LEGAL_NAME.MAX_LENGTH) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('common.error.characterLimitExceedCounter', firstNameValue.length, CONST.LEGAL_NAME.MAX_LENGTH); - } else if (doesContainReservedWord(firstNameValue, CONST.DISPLAY_NAME.RESERVED_NAMES)) { - errors[INPUT_IDS.LEGAL_FIRST_NAME] = translate('personalDetails.error.containsReservedWord'); - } - - const lastNameValue = values[INPUT_IDS.LEGAL_LAST_NAME] ?? ''; - if (!lastNameValue.trim()) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.fieldRequired'); - } else if (!isValidDisplayName(lastNameValue)) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('privatePersonalDetails.error.cannotIncludeCommaOrSemicolon'); - } else if (lastNameValue.length > CONST.LEGAL_NAME.MAX_LENGTH) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('common.error.characterLimitExceedCounter', lastNameValue.length, CONST.LEGAL_NAME.MAX_LENGTH); - } else if (doesContainReservedWord(lastNameValue, CONST.DISPLAY_NAME.RESERVED_NAMES)) { - errors[INPUT_IDS.LEGAL_LAST_NAME] = translate('personalDetails.error.containsReservedWord'); - } - - const dobValue = values[INPUT_IDS.DATE_OF_BIRTH] ?? ''; - if (!dobValue) { - errors[INPUT_IDS.DATE_OF_BIRTH] = translate('common.error.fieldRequired'); - } else { - const dateError = getAgeRequirementError(translate, dobValue, CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT, CONST.DATE_BIRTH.MAX_AGE); - if (dateError) { - errors[INPUT_IDS.DATE_OF_BIRTH] = dateError; - } - } - - const phoneValue = values[INPUT_IDS.PHONE_NUMBER] ?? ''; - if (!isRequiredFulfilled(phoneValue)) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); - } else { - const phoneWithCountryCode = appendCountryCode(phoneValue, countryCode); - if (!isValidPhoneNumber(phoneWithCountryCode)) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.phoneNumber'); - } - } - - const streetValue = values[INPUT_IDS.ADDRESS_LINE_1] ?? ''; - if (!streetValue.trim()) { - errors[INPUT_IDS.ADDRESS_LINE_1] = translate('common.error.fieldRequired'); - } - - const cityValue = values[INPUT_IDS.CITY] ?? ''; - if (!cityValue.trim()) { - errors[INPUT_IDS.CITY] = translate('common.error.fieldRequired'); - } - - const stateValue = values[INPUT_IDS.STATE] || selectedState || ''; - const effectiveCountry = (values[INPUT_IDS.COUNTRY] || selectedCountry) ?? ''; - if (!stateValue.trim()) { - errors[INPUT_IDS.STATE] = translate('common.error.fieldRequired'); - } - - const zipValue = values[INPUT_IDS.ZIP_POST_CODE] ?? ''; - const countryRegexDetails = effectiveCountry ? (CONST.COUNTRY_ZIP_REGEX_DATA?.[effectiveCountry] as {regex?: RegExp; samples?: string}) : undefined; - const countrySpecificZipRegex = countryRegexDetails?.regex; - if (countrySpecificZipRegex) { - if (!countrySpecificZipRegex.test(zipValue.trim().toUpperCase())) { - if (isRequiredFulfilled(zipValue.trim())) { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat', countryRegexDetails?.samples ?? ''); - } else { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('common.error.fieldRequired'); - } - } - } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(zipValue.trim().toUpperCase())) { - errors[INPUT_IDS.ZIP_POST_CODE] = translate('privatePersonalDetails.error.incorrectZipFormat'); - } - - if (!effectiveCountry) { - errors[INPUT_IDS.COUNTRY] = translate('common.error.fieldRequired'); - } - - return errors; - }; - - const hasChanges = (values: FormOnyxValues): boolean => { - if ((values[INPUT_IDS.LEGAL_FIRST_NAME] ?? '') !== legalFirstName) { - return true; - } - if ((values[INPUT_IDS.LEGAL_LAST_NAME] ?? '') !== legalLastName) { - return true; - } - if ((values[INPUT_IDS.DATE_OF_BIRTH] ?? '') !== dob) { - return true; - } - if ((values[INPUT_IDS.PHONE_NUMBER] ?? '') !== phoneNumber) { - return true; - } - if ((values[INPUT_IDS.ADDRESS_LINE_1] ?? '') !== initialStreet1) { - return true; - } - if ((values[INPUT_IDS.ADDRESS_LINE_2] ?? '') !== initialStreet2) { - return true; - } - if ((values[INPUT_IDS.CITY] ?? '') !== city) { - return true; - } - if ((values[INPUT_IDS.STATE] ?? '') !== state) { - return true; - } - if ((values[INPUT_IDS.ZIP_POST_CODE] ?? '') !== zip) { - return true; - } - if ((values[INPUT_IDS.COUNTRY] ?? '') !== country) { - return true; - } - return false; - }; - - const onSubmit = (values: FormOnyxValues) => { - if (!hasChanges(values)) { - Navigation.goBack(); - return; - } - Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE); - }; - - const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'PrivatePersonalDetailsPage', isLoadingApp}; - - if (isLoadingApp) { - return ( - - ); - } - - return ( - - - Navigation.goBack()} - /> - - {translate('privatePersonalDetails.basicDetails')} - - - - - - - - - - - - - {translate('privatePersonalDetails.address')} - - - - - - - - - - {selectedCountry === CONST.COUNTRY.US ? ( - - setSelectedState((value ?? '') as string)} - shouldSaveDraft - /> - - ) : ( - - - - )} - - - - - { - const newCountry = (value ?? '') as Country | ''; - setSelectedCountry(newCountry); - if (newCountry === CONST.COUNTRY.US && selectedState && !(selectedState in COMMON_CONST.STATES)) { - const match = Object.entries(COMMON_CONST.STATES).find(([, v]) => v.stateName.toLowerCase() === selectedState.toLowerCase()); - if (match) { - setSelectedState(match[0]); - } - } - }} - shouldSaveDraft - /> - - - - - ); -} - -PrivatePersonalDetailsPage.displayName = 'PrivatePersonalDetailsPage'; - -export default PrivatePersonalDetailsPage;