Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/hooks/useAccountIDToNameMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

/** 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;
4 changes: 3 additions & 1 deletion src/pages/tasks/DynamicNewTaskDescriptionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -81,7 +83,7 @@ function DynamicNewTaskDescriptionPage() {
<View style={styles.mb5}>
<InputWrapperWithRef
InputComponent={TextInput}
defaultValue={Parser.htmlToMarkdown(Parser.replace(task?.description ?? ''))}
defaultValue={Parser.htmlToMarkdown(Parser.replace(task?.description ?? ''), {accountIDToName})}
inputID={INPUT_IDS.TASK_DESCRIPTION}
label={translate('newTaskPage.descriptionOptional')}
accessibilityLabel={translate('newTaskPage.descriptionOptional')}
Expand Down
10 changes: 6 additions & 4 deletions src/pages/tasks/DynamicNewTaskDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';

import useAccountIDToNameMap from '@hooks/useAccountIDToNameMap';
import useAncestors from '@hooks/useAncestors';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
Expand Down Expand Up @@ -43,13 +44,14 @@ function DynamicNewTaskDetailsPage() {
});
const styles = useThemeStyles();
const {translate} = useLocalize();
const accountIDToName = useAccountIDToNameMap();
const [localTitle, setLocalTitle] = useState<string>();
const [localDescription, setLocalDescription] = useState<string>();
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);
Expand Down
4 changes: 3 additions & 1 deletion src/pages/tasks/DynamicNewTaskTitlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -88,7 +90,7 @@ function DynamicNewTaskTitlePage() {
<InputWrapperWithRef
InputComponent={TextInput}
role={CONST.ROLE.PRESENTATION}
defaultValue={Parser.htmlToMarkdown(task?.title ?? '')}
defaultValue={Parser.htmlToMarkdown(task?.title ?? '', {accountIDToName})}
ref={inputCallbackRef}
inputID={INPUT_IDS.TASK_TITLE}
label={translate('task.title')}
Expand Down
6 changes: 4 additions & 2 deletions src/pages/tasks/TaskDescriptionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,6 +47,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti
const styles = useThemeStyles();
const {translate} = useLocalize();
const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector});
const accountIDToName = useAccountIDToNameMap();

const validate = useCallback(
(values: FormOnyxValues<typeof ONYXKEYS.FORMS.EDIT_TASK_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.EDIT_TASK_FORM> => {
Expand All @@ -61,7 +63,7 @@ function TaskDescriptionPage({report, currentUserPersonalDetails}: TaskDescripti
);

const submit = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.EDIT_TASK_FORM>) => {
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);
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/pages/tasks/TaskTitlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof ONYXKEYS.FORMS.EDIT_TASK_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.EDIT_TASK_FORM> => {
Expand All @@ -65,7 +67,7 @@ function TaskTitlePage({report, currentUserPersonalDetails}: TaskTitlePageProps)
);

const submit = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.EDIT_TASK_FORM>) => {
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);
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/ParserTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
accountIDToName['123'] = 'alice@example.com';
expect(Parser.htmlToMarkdown('<mention-user accountID="123" />', {accountIDToName})).toBe('@alice@example.com');
});

test('returns @Hidden for mention when accountID is missing from the map', () => {
expect(Parser.htmlToMarkdown('<mention-user accountID="123" />', {accountIDToName: {}})).toBe('@Hidden');
});
});

describe('htmlToText', () => {
test('resolves mention accountID to @name via accountIDToName map', () => {
const accountIDToName: Record<string, string> = {};
accountIDToName['456'] = 'bob@example.com';
expect(Parser.htmlToText('<mention-user accountID="456" />', {accountIDToName})).toBe('@bob@example.com');
});

test('returns @Hidden for mention when accountID is missing from the map', () => {
expect(Parser.htmlToText('<mention-user accountID="456" />', {accountIDToName: {}})).toBe('@Hidden');
});
});
});
113 changes: 113 additions & 0 deletions tests/unit/useAccountIDToNameMapTest.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading