diff --git a/src/hooks/useAccountIDToNameMap.ts b/src/hooks/useAccountIDToNameMap.ts new file mode 100644 index 000000000000..c4b29604873d --- /dev/null +++ b/src/hooks/useAccountIDToNameMap.ts @@ -0,0 +1,29 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import {useMemo} from 'react'; + +import useOnyx from './useOnyx'; + +/** Mapping from accountID to user name (login or displayName). */ +type AccountIDToNameMap = Record; + +/** Build the accountID -> name map from PERSONAL_DETAILS_LIST. */ +function buildAccountIDToNameMap(personalDetailsList: PersonalDetailsList | undefined): AccountIDToNameMap { + const map: AccountIDToNameMap = {}; + for (const personalDetails of Object.values(personalDetailsList ?? {})) { + if (!personalDetails) { + continue; + } + map[personalDetails.accountID] = personalDetails.login ?? personalDetails.displayName ?? ''; + } + return map; +} + +/** Returns an accountID -> name (login or displayName) map built from PERSONAL_DETAILS_LIST. */ +function useAccountIDToNameMap(): AccountIDToNameMap { + const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + return useMemo(() => buildAccountIDToNameMap(personalDetailsList), [personalDetailsList]); +} + +export default useAccountIDToNameMap; diff --git a/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx b/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx index 6cd22912e74a..c14f68f3151a 100644 --- a/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDescriptionPage.tsx @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; @@ -36,6 +37,7 @@ function DynamicNewTaskDescriptionPage() { const {translate} = useLocalize(); const [task, taskMetadata] = useOnyx(ONYXKEYS.TASK); const {inputCallbackRef, inputRef} = useAutoFocusInput(); + const accountIDToName = useAccountIDToNameMap(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK_DESCRIPTION.path); const goBack = () => Navigation.goBack(backPath); @@ -81,7 +83,7 @@ function DynamicNewTaskDescriptionPage() { (); const [localDescription, setLocalDescription] = useState(); - const taskTitle = localTitle ?? Parser.htmlToMarkdown(Parser.replace(task?.title ?? '')); - const taskDescription = localDescription ?? Parser.htmlToMarkdown(Parser.replace(task?.description ?? '')); + const taskTitle = localTitle ?? Parser.htmlToMarkdown(Parser.replace(task?.title ?? ''), {accountIDToName}); + const taskDescription = localDescription ?? Parser.htmlToMarkdown(Parser.replace(task?.description ?? ''), {accountIDToName}); - const titleDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskTitle)); - const descriptionDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskDescription)); + const titleDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskTitle), {accountIDToName}); + const descriptionDefaultValue = Parser.htmlToMarkdown(Parser.replace(taskDescription), {accountIDToName}); const {inputCallbackRef} = useAutoFocusInput(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK_DETAILS.path); diff --git a/src/pages/tasks/DynamicNewTaskTitlePage.tsx b/src/pages/tasks/DynamicNewTaskTitlePage.tsx index ac344e871332..86960c069da5 100644 --- a/src/pages/tasks/DynamicNewTaskTitlePage.tsx +++ b/src/pages/tasks/DynamicNewTaskTitlePage.tsx @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; @@ -35,6 +36,7 @@ function DynamicNewTaskTitlePage() { const {inputCallbackRef} = useAutoFocusInput(); const [task, taskMetadata] = useOnyx(ONYXKEYS.TASK); const {translate} = useLocalize(); + const accountIDToName = useAccountIDToNameMap(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK_TITLE.path); const goBack = () => Navigation.goBack(backPath); @@ -88,7 +90,7 @@ function DynamicNewTaskTitlePage() { ): FormInputErrors => { @@ -61,7 +63,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti ); const submit = (values: FormOnyxValues) => { - if (values.description !== Parser.htmlToMarkdown(report?.description ?? '') && !isEmptyObject(report)) { + if (values.description !== Parser.htmlToMarkdown(report?.description ?? '', {accountIDToName}) && !isEmptyObject(report)) { // Set the description of the report in the store and then call EditTask API // to update the description of the report on the server editTask(report, {description: values.description}, delegateEmail); @@ -127,7 +129,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti name={INPUT_IDS.DESCRIPTION} label={translate('newTaskPage.descriptionOptional')} accessibilityLabel={translate('newTaskPage.descriptionOptional')} - defaultValue={Parser.htmlToMarkdown(report?.description ?? '')} + defaultValue={Parser.htmlToMarkdown(report?.description ?? '', {accountIDToName})} ref={(element: AnimatedTextInputRef | null) => { if (!element) { return; diff --git a/src/pages/tasks/TaskTitlePage.tsx b/src/pages/tasks/TaskTitlePage.tsx index ec9d38dce012..9dcef4c080c8 100644 --- a/src/pages/tasks/TaskTitlePage.tsx +++ b/src/pages/tasks/TaskTitlePage.tsx @@ -9,6 +9,7 @@ import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -45,6 +46,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) const styles = useThemeStyles(); const {translate} = useLocalize(); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); + const accountIDToName = useAccountIDToNameMap(); const validate = useCallback( ({title}: FormOnyxValues): FormInputErrors => { @@ -65,7 +67,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) ); const submit = (values: FormOnyxValues) => { - if (values.title !== Parser.htmlToMarkdown(report?.reportName ?? '') && !isEmptyObject(report)) { + if (values.title !== Parser.htmlToMarkdown(report?.reportName ?? '', {accountIDToName}) && !isEmptyObject(report)) { // Set the title of the report in the store and then call EditTask API // to update the title of the report on the server editTask(report, {title: values.title}, delegateEmail); @@ -118,7 +120,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps) name={INPUT_IDS.TITLE} label={translate('task.title')} accessibilityLabel={translate('task.title')} - defaultValue={Parser.htmlToMarkdown(report?.reportName ?? '', {})} + defaultValue={Parser.htmlToMarkdown(report?.reportName ?? '', {accountIDToName})} ref={(element: AnimatedTextInputRef | null) => { if (!element) { return; diff --git a/tests/unit/ParserTest.ts b/tests/unit/ParserTest.ts index bac3148bf6aa..acaeb0a6db61 100644 --- a/tests/unit/ParserTest.ts +++ b/tests/unit/ParserTest.ts @@ -47,5 +47,27 @@ describe('Parser', () => { test('returns an empty string for an empty input', () => { expect(Parser.htmlToMarkdown('')).toBe(''); }); + + test('resolves mention accountID to @name via accountIDToName map', () => { + const accountIDToName: Record = {}; + accountIDToName['123'] = 'alice@example.com'; + expect(Parser.htmlToMarkdown('', {accountIDToName})).toBe('@alice@example.com'); + }); + + test('returns @Hidden for mention when accountID is missing from the map', () => { + expect(Parser.htmlToMarkdown('', {accountIDToName: {}})).toBe('@Hidden'); + }); + }); + + describe('htmlToText', () => { + test('resolves mention accountID to @name via accountIDToName map', () => { + const accountIDToName: Record = {}; + accountIDToName['456'] = 'bob@example.com'; + expect(Parser.htmlToText('', {accountIDToName})).toBe('@bob@example.com'); + }); + + test('returns @Hidden for mention when accountID is missing from the map', () => { + expect(Parser.htmlToText('', {accountIDToName: {}})).toBe('@Hidden'); + }); }); }); diff --git a/tests/unit/useAccountIDToNameMapTest.tsx b/tests/unit/useAccountIDToNameMapTest.tsx new file mode 100644 index 000000000000..17ec926b35ff --- /dev/null +++ b/tests/unit/useAccountIDToNameMapTest.tsx @@ -0,0 +1,113 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +function createPersonalDetails(accountID: number, overrides: Partial = {}): PersonalDetails { + return { + accountID, + login: `user${accountID}@example.com`, + displayName: `User ${accountID}`, + ...overrides, + } as PersonalDetails; +} + +function buildList(entries: Array<[number, PersonalDetails | null]>): PersonalDetailsList { + const list: PersonalDetailsList = {}; + for (const [id, details] of entries) { + list[String(id)] = details; + } + return list; +} + +const renderAccountIDToNameMap = async () => { + const hook = renderHook(() => useAccountIDToNameMap()); + await act(async () => { + await waitForBatchedUpdates(); + }); + return hook; +}; + +describe('useAccountIDToNameMap', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('returns an empty object when personalDetailsList is not set', async () => { + const {result} = await renderAccountIDToNameMap(); + expect(result.current).toEqual({}); + }); + + it('maps accountID to login when login is present', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com', displayName: 'Alice'})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['1']).toBe('alice@example.com'); + }); + + it('falls back to displayName when login is undefined', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[2, createPersonalDetails(2, {login: undefined, displayName: 'Bob'})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['2']).toBe('Bob'); + }); + + it('falls back to an empty string when both login and displayName are undefined', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[3, createPersonalDetails(3, {login: undefined, displayName: undefined})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['3']).toBe(''); + }); + + it('maps multiple accounts and skips null entries', async () => { + await Onyx.set( + ONYXKEYS.PERSONAL_DETAILS_LIST, + buildList([ + [10, createPersonalDetails(10, {login: 'eve@example.com', displayName: 'Eve'})], + [11, createPersonalDetails(11, {login: undefined, displayName: 'Frank'})], + [12, null], + ]), + ); + + const {result} = await renderAccountIDToNameMap(); + + expect(result.current['10']).toBe('eve@example.com'); + expect(result.current['11']).toBe('Frank'); + expect(result.current['12']).toBeUndefined(); + }); + + it('reflects updates to personal details', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com'})]])); + const {result} = await renderAccountIDToNameMap(); + expect(result.current['1']).toBe('alice@example.com'); + + const accountID = 1; + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountID]: {login: 'alice2@example.com'}}); + await waitForBatchedUpdates(); + }); + + expect(result.current['1']).toBe('alice2@example.com'); + }); + + it('keeps a stable reference across re-renders when personal details do not change', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildList([[1, createPersonalDetails(1, {login: 'alice@example.com'})]])); + const {result, rerender} = await renderAccountIDToNameMap(); + const firstResult = result.current; + + rerender({}); + await act(async () => { + await waitForBatchedUpdates(); + }); + + expect(result.current).toBe(firstResult); + }); +});