From af12bf491929961b8691d0a10cfc35e7215b341b Mon Sep 17 00:00:00 2001 From: "Olly (via MelvinBot)" Date: Mon, 14 Sep 2026 11:08:40 +0000 Subject: [PATCH 1/3] Show the category description hint on the expense creation description field Co-authored-by: Olly --- .../sections/DescriptionField.tsx | 11 ++- .../step/DynamicIOURequestStepDescription.tsx | 15 ++- .../DescriptionField.test.tsx | 96 +++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/unit/components/MoneyRequestConfirmationList/DescriptionField.test.tsx diff --git a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx index 6c38d0d39377..2c23c4f2299d 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx @@ -1,6 +1,7 @@ import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; +import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation'; import {ShowContextMenuActionsContext, ShowContextMenuStateContext} from '@components/ShowContextMenuContext'; import TextInput from '@components/TextInput'; @@ -29,7 +30,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import React, {useRef} from 'react'; import {View} from 'react-native'; -import {descriptionStateSelector} from './selectors'; +import {categoryStateSelector, descriptionStateSelector} from './selectors'; import useTransactionSelector from './useTransactionSelector'; type DescriptionFieldProps = { @@ -49,6 +50,12 @@ function DescriptionField({isDescriptionRequired, policy}: DescriptionFieldProps const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const descriptionState = useTransactionSelector(transactionID, descriptionStateSelector); + const categoryState = useTransactionSelector(transactionID, categoryStateSelector); + const policyCategories = usePolicyCategoriesForConfirmation(policy?.id); + + // A category can carry a hint telling the user what to write in the description, so show it under the input once + // that category is selected, the same way the dedicated description step does. + const descriptionHint = categoryState?.category ? (policyCategories?.[categoryState.category]?.commentHint ?? '') : ''; // `getDescription` returns raw `transaction.comment.comment`, which can be HTML for saved transactions. // We normalize to markdown so both the read-only and editable inputs receive a consistent format. @@ -116,6 +123,8 @@ function DescriptionField({isDescriptionRequired, policy}: DescriptionFieldProps maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} type="markdown" excludedMarkdownStyles={!policy ? ['mentionReport'] : []} + hint={descriptionHint} + shouldRenderHintAsHTML={!!descriptionHint} /> ) : ( diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index fdbf480bd77f..5dc4e720b5d0 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -15,6 +15,7 @@ import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; +import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -22,14 +23,14 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getSelectedWorkspacePolicyID, pickReportForPolicy, shouldUseTransactionDraft} from '@libs/IOUUtils'; import Parser from '@libs/Parser'; import shouldForceKeyboardIfAlreadyFocused from '@libs/shouldForceKeyboardIfAlreadyFocused'; import {hasReceipt} from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import {setMoneyRequestDescription} from '@userActions/IOU/MoneyRequest'; +import {getIOURequestPolicyID, setMoneyRequestDescription} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; import {updateMoneyRequestDescription} from '@userActions/IOU/UpdateMoneyRequest'; @@ -71,7 +72,15 @@ function DynamicIOURequestStepDescription({ const policy = usePolicy(report?.policyID); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); + const transactionReport = useReportOrReportDraft(transaction?.reportID); + const participantReport = useReportOrReportDraft(transaction?.participants?.at(0)?.reportID); + + // While creating an expense the route report isn't the workspace chat (it can be a seeded report ID or the self-DM), + // so resolve the policy from the transaction the same way the category step does before reading its categories. + const categoriesPolicyID = getSelectedWorkspacePolicyID(transaction, action) ?? getIOURequestPolicyID(transaction, pickReportForPolicy(report, transactionReport, participantReport)); + const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${categoriesPolicyID}`); + const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${categoriesPolicyID}`); + const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); diff --git a/tests/unit/components/MoneyRequestConfirmationList/DescriptionField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DescriptionField.test.tsx new file mode 100644 index 000000000000..58bceea0a5a7 --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/DescriptionField.test.tsx @@ -0,0 +1,96 @@ +import {render, screen} from '@testing-library/react-native'; + +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import DescriptionField from '@components/MoneyRequestConfirmationList/sections/DescriptionField'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyCategories} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../../utils/collections/policies'; +import createRandomTransaction from '../../../utils/collections/transaction'; +import waitForBatchedUpdatesWithAct from '../../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/TextInput', () => { + const {Text} = jest.requireActual>>('react-native'); + return ({label, hint}: {label?: string; hint?: string}) => ( + <> + {label} + {hint ? {hint} : null} + + ); +}); + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key.replace('common.', '')})); +jest.mock('@hooks/useThemeStyles', () => () => ({})); + +const transactionID = 'transactionID'; +const policyID = 'POLICY_WITH_CATEGORY_HINTS'; +const policy = {...createRandomPolicy(0), id: policyID}; + +const policyCategories: PolicyCategories = { + Advertising: {name: 'Advertising', enabled: true, areCommentsRequired: true, commentHint: 'Client name', externalID: '', origin: ''}, + Benefits: {name: 'Benefits', enabled: true, areCommentsRequired: true, externalID: '', origin: ''}, +}; + +const renderDescriptionField = () => + render( + + + , + ); + +const setUpDraftTransactionWithCategory = async (category: string) => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {...createRandomTransaction(0), transactionID, category}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, policyCategories); + await waitForBatchedUpdatesWithAct(); +}; + +describe('DescriptionField', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + + it("displays the selected category's description hint while creating an expense", async () => { + await setUpDraftTransactionWithCategory('Advertising'); + + renderDescriptionField(); + + expect(await screen.findByText('Client name')).toBeOnTheScreen(); + }); + + it('displays no hint when the selected category has no description hint', async () => { + await setUpDraftTransactionWithCategory('Benefits'); + + renderDescriptionField(); + + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText('Client name')).not.toBeOnTheScreen(); + }); + + it('displays no hint when no category is selected', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, policyCategories); + await waitForBatchedUpdatesWithAct(); + + renderDescriptionField(); + + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText('Client name')).not.toBeOnTheScreen(); + }); +}); From 3b5a428c635032f27c4fb34405c8d5b826313d48 Mon Sep 17 00:00:00 2001 From: "Olly (via MelvinBot)" Date: Thu, 17 Sep 2026 11:31:38 +0000 Subject: [PATCH 2/3] Use usePolicyCategoriesForConfirmation in the description step instead of duplicating the real/draft category read Co-authored-by: Olly --- .../iou/request/step/DynamicIOURequestStepDescription.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 5dc4e720b5d0..1103e1ccf050 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -1,6 +1,7 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation'; import TextInput from '@components/TextInput'; import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; @@ -78,9 +79,7 @@ function DynamicIOURequestStepDescription({ // While creating an expense the route report isn't the workspace chat (it can be a seeded report ID or the self-DM), // so resolve the policy from the transaction the same way the category step does before reading its categories. const categoriesPolicyID = getSelectedWorkspacePolicyID(transaction, action) ?? getIOURequestPolicyID(transaction, pickReportForPolicy(report, transactionReport, participantReport)); - const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${categoriesPolicyID}`); - const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${categoriesPolicyID}`); - const policyCategories = policyCategoriesReal ?? policyCategoriesDraft; + const policyCategories = usePolicyCategoriesForConfirmation(categoriesPolicyID); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); From 8d9eea721e252263dff9940322f5663751f968af Mon Sep 17 00:00:00 2001 From: "Olly (via MelvinBot)" Date: Thu, 17 Sep 2026 13:05:32 +0000 Subject: [PATCH 3/3] Revert DynamicIOURequestStepDescription policy resolution - the creation flow never reaches this step The standalone description step is only reachable with action=EDIT, where the route report is the transaction thread and already inherits the parent policyID. Resolving the policy from the transaction instead risked policy/policyTags/policyCategories disagreeing when passed together to updateMoneyRequestDescription. Co-authored-by: Olly --- .../step/DynamicIOURequestStepDescription.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 1103e1ccf050..fdbf480bd77f 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -1,7 +1,6 @@ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation'; import TextInput from '@components/TextInput'; import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; @@ -16,7 +15,6 @@ import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; -import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,14 +22,14 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getSelectedWorkspacePolicyID, pickReportForPolicy, shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Parser from '@libs/Parser'; import shouldForceKeyboardIfAlreadyFocused from '@libs/shouldForceKeyboardIfAlreadyFocused'; import {hasReceipt} from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import {getIOURequestPolicyID, setMoneyRequestDescription} from '@userActions/IOU/MoneyRequest'; +import {setMoneyRequestDescription} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; import {updateMoneyRequestDescription} from '@userActions/IOU/UpdateMoneyRequest'; @@ -73,13 +71,7 @@ function DynamicIOURequestStepDescription({ const policy = usePolicy(report?.policyID); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); - const transactionReport = useReportOrReportDraft(transaction?.reportID); - const participantReport = useReportOrReportDraft(transaction?.participants?.at(0)?.reportID); - - // While creating an expense the route report isn't the workspace chat (it can be a seeded report ID or the self-DM), - // so resolve the policy from the transaction the same way the category step does before reading its categories. - const categoriesPolicyID = getSelectedWorkspacePolicyID(transaction, action) ?? getIOURequestPolicyID(transaction, pickReportForPolicy(report, transactionReport, participantReport)); - const policyCategories = usePolicyCategoriesForConfirmation(categoriesPolicyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`);