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
5 changes: 5 additions & 0 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
getUpdatedCardFeedLiabilityMessage,
getUpdatedCardFeedStatementPeriodMessage,
getUpdateRoomDescriptionMessage,
getWorkspaceCategoryUpdateMessage,
getWorkspaceFeatureEnabledMessage,
getWorkspaceTaxUpdateMessage,
hasPendingDEWApprove,
Expand All @@ -88,6 +89,7 @@ import {
isActionableMentionWhisper,
isActionOfType,
isAddCommentAction,
isCategoryModificationAction,
isClosedAction,
isCreatedAction,
isCreatedTaskReportAction,
Expand Down Expand Up @@ -975,6 +977,9 @@ function getLastMessageTextForReport({
if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MCC_GROUP_CATEGORY)) {
lastMessageTextFromReport = getMccGroupCategoryMessage(translate, lastReportAction);
}
if (lastReportAction?.actionName && isCategoryModificationAction(lastReportAction.actionName)) {
lastMessageTextFromReport = getWorkspaceCategoryUpdateMessage(translate, lastReportAction, policy);
}
if (
isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX) ||
isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX) ||
Expand Down
15 changes: 14 additions & 1 deletion src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1850,6 +1850,15 @@ function isTaskAction(reportAction: OnyxEntry<ReportAction>): boolean {
* @param actionName - The name of the action
* @returns - Whether the action is a tag modification action
* */
function isCategoryModificationAction(actionName: string): boolean {
return (
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY ||
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY ||
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY ||
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME
);
}

function isTagModificationAction(actionName: string): boolean {
return (
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG ||
Expand Down Expand Up @@ -3040,7 +3049,10 @@ function getWorkspaceCategoryUpdateMessage(translate: LocalizedTranslate, action

if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY && categoryName) {
if (updatedField === 'commentHint') {
return translate('workspaceActions.updatedDescriptionHint', decodedOptionName, newValue as string | undefined, oldValue as string | undefined);
// Description hints are stored as HTML, so they have to be converted back to plain text to read correctly in a message
const newHint = typeof newValue === 'string' && newValue ? Parser.htmlToText(newValue) : undefined;
const oldHint = typeof oldValue === 'string' && oldValue ? Parser.htmlToText(oldValue) : undefined;
return translate('workspaceActions.updatedDescriptionHint', decodedOptionName, newHint, oldHint);
Comment on lines -3043 to +3055

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Category description hints are persisted as HTML, so a hint of Client's & partner's names is stored as Client&#x27;s &amp; partner&#x27;s names.

This seems separate bug.
Do you have repro step? I am not able to reproduce raw html rendering

@situchan situchan Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nvm, I reproduced with markdown input

i.e. <strong>Client</strong> names instead of Client names

}

if (updatedField === 'enabled') {
Expand Down Expand Up @@ -4987,6 +4999,7 @@ export {
getMostRecentActiveDEWApproveFailedAction,
hasPendingDEWApprove,
isWhisperActionTargetedToOthers,
isCategoryModificationAction,
isTagModificationAction,
isIOUActionMatchingTransactionList,
isResolvedActionableWhisper,
Expand Down
6 changes: 6 additions & 0 deletions src/libs/ReportNameUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import {
getUpdatedCardFeedStatementPeriodMessage,
getUpdatedProhibitedExpensesMessage,
getWorkspaceAttendeeTrackingUpdateMessage,
getWorkspaceCategoryUpdateMessage,
getWorkspaceCurrencyUpdateMessage,
getWorkspaceCustomUnitRateAddedMessage,
getWorkspaceCustomUnitRateDeletedMessage,
Expand All @@ -111,6 +112,7 @@ import {
isActionableJoinRequest,
isActionOfType,
isCardIssuedAction,
isCategoryModificationAction,
isDynamicExternalWorkflowApproveFailedAction,
isDynamicExternalWorkflowSubmitFailedAction,
isMarkAsClosedAction,
Expand Down Expand Up @@ -699,6 +701,10 @@ function computeReportNameBasedOnReportAction({
return getReimburserUpdateMessage(translate, parentReportAction);
}

if (parentReportAction?.actionName && isCategoryModificationAction(parentReportAction.actionName)) {
return getWorkspaceCategoryUpdateMessage(translate, parentReportAction, reportPolicy);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute category headers when policy defaults change

When this new branch handles an UPDATE_CATEGORY action for maxAmountNoReceipt or maxAmountNoItemizedReceipt, getWorkspaceCategoryUpdateMessage() formats the header from reportPolicy.maxExpenseAmountNoReceipt / maxExpenseAmountNoItemizedReceipt and outputCurrency (see ReportActionsUtils around the receipt-threshold cases). However the derived report-name cache only invalidates policy changes listed in hasPolicyRelevantFieldChanged() in src/libs/actions/OnyxDerived/configs/reportAttributes.ts, and those fields are not included there. If the workspace receipt default or currency changes after this thread title has been cached, the header can keep showing the old/default amount while the chat message recomputes from the latest policy, so please add these policy fields to the report-name invalidation or avoid depending on mutable policy data here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a regression, but probably a legitimate bug we can address in a follow-up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably DRY this since it's kinda repeated in src/libs/OptionsListUtils/index.ts

}

if (
isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX) ||
isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX) ||
Expand Down
8 changes: 2 additions & 6 deletions src/libs/SidebarUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ import {
getWorkspaceUpdateFieldMessage,
isActionOfType,
isCardIssuedAction,
isCategoryModificationAction,
isInviteOrRemovedAction,
isLeavePolicyAction,
isOldDotReportAction,
Expand Down Expand Up @@ -1108,12 +1109,7 @@ function getOptionData({
result.alternateText = Parser.htmlToText(getCompanyCardConnectionBrokenMessage(translate, lastAction));
} else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) {
result.alternateText = Parser.htmlToText(getPlaidBalanceFailureMessage(translate, lastAction));
} else if (
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY) ||
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY) ||
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY) ||
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME)
) {
} else if (lastAction?.actionName && isCategoryModificationAction(lastAction.actionName)) {
result.alternateText = getWorkspaceCategoryUpdateMessage(translate, lastAction);
} else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES)) {
result.alternateText = getWorkspaceCategoriesUpdatedMessage(translate, lastAction);
Expand Down
8 changes: 2 additions & 6 deletions src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {
isActionableTrackExpense,
isActionOfType,
isCardIssuedAction,
isCategoryModificationAction,
isCreatedAction,
isCreatedTaskReportAction,
isDeletedAction as isDeletedActionReportActionsUtils,
Expand Down Expand Up @@ -1057,12 +1058,7 @@ const ContextMenuActions: ContextMenuAction[] = [
Clipboard.setString(getWorkspaceCurrencyUpdateMessage(translate, reportAction));
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) {
Clipboard.setString(getWorkspaceFrequencyUpdateMessage(translate, reportAction));
} else if (
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY ||
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY ||
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY ||
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME
) {
} else if (reportAction?.actionName && isCategoryModificationAction(reportAction.actionName)) {
Clipboard.setString(getWorkspaceCategoryUpdateMessage(translate, reportAction));
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES) {
Clipboard.setString(getWorkspaceCategoriesUpdatedMessage(translate, reportAction));
Expand Down
56 changes: 56 additions & 0 deletions tests/ui/components/HeaderViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,62 @@ describe('HeaderView', () => {
);
}

it('should display the localized category update message for a thread on a category update action', async () => {
// Given an #admins room with a report action that made attendees required on a category
const policyID = '400';
const adminsReportID = '401';
const threadReportID = '402';
const rawServerMessage = 'updated the category "Advertising" by changing the Attendees from Not Required to Required';

const parentReportAction: ReportAction = {
reportActionID: '4001',
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
created: '2026-01-01 00:00:00.000',
message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: rawServerMessage, text: rawServerMessage}],
originalMessage: {
categoryName: 'Advertising',
updatedField: 'areAttendeesRequired',
oldValue: '',
newValue: true,
},
};

const adminsReport = {
...createRandomReport(Number(adminsReportID), CONST.REPORT.CHAT_TYPE.POLICY_ADMINS),
type: CONST.REPORT.TYPE.CHAT,
policyID,
};

// And a chat thread opened on that action
const threadReport = {
...createRandomReport(Number(threadReportID), undefined),
type: CONST.REPORT.TYPE.CHAT,
policyID,
parentReportID: adminsReportID,
parentReportActionID: parentReportAction.reportActionID,
};

await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsReportID}`, {
[parentReportAction.reportActionID]: parentReportAction,
});
await waitForBatchedUpdates();

const adminsReportKey = `${ONYXKEYS.COLLECTION.REPORT}${adminsReportID}` as const;
const threadReportKey = `${ONYXKEYS.COLLECTION.REPORT}${threadReportID}` as const;
await Onyx.multiSet(
createMock<KeyValueMapping>({
[adminsReportKey]: adminsReport,
[threadReportKey]: threadReport,
}),
);

renderHeader(threadReport.reportID);
await waitForBatchedUpdatesWithAct();

// Then the thread header should show the same copy as the system message in the chat
await waitFor(() => expect(screen.getByTestId('DisplayNames')).toHaveTextContent(translateLocal('workspaceActions.updateAreAttendeesRequired', 'Advertising', true)));
});

it('should display the Book a call button in the 1:1 DM with the account manager', async () => {
// Given a 1:1 DM with the assigned account manager who has a calendar link
const report = createRegularChat(500, [currentUserAccountID, accountManagerAccountID]);
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/ContextMenuActionsCopyMessageTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,23 @@ describe('ContextMenuActions copy message', () => {
expect(mockGetClipboardText).toHaveBeenCalledWith(expectedTranslationKey);
expect(mockClipboard.setString).toHaveBeenCalledWith('mocked clipboard text');
});

it('copies the localized message for a category update action', () => {
mockClipboard.canSetHtml.mockReturnValue(false);

if (!copyMessageAction?.onPress) {
throw new Error('Copy message context menu action was not found');
}

copyMessageAction.onPress(
false,
createReportActionPayload({
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
message: [{html: ''}],
originalMessage: {categoryName: 'Advertising', updatedField: 'areAttendeesRequired', oldValue: '', newValue: true},
}),
);

expect(mockClipboard.setString).toHaveBeenCalledWith('workspaceActions.updateAreAttendeesRequired');
});
});
113 changes: 113 additions & 0 deletions tests/unit/ReportNameUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,119 @@ describe('ReportNameUtils', () => {
expect(name).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")');
});

test.each([
[CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY, {categoryName: 'Advertising'}, 'added the category "Advertising"'],
[CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY, {categoryName: 'Advertising'}, 'removed the category "Advertising"'],
[
CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
{categoryName: 'Advertising', updatedField: 'areAttendeesRequired', oldValue: '', newValue: true},
'changed the "Advertising" category attendees to required (previously not required)',
],
[CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME, {oldName: 'Advertising', newName: 'Marketing'}, 'renamed the category "Advertising" to "Marketing"'],
])('%s parent action renders the same message as the system message in the chat', (actionName, originalMessage, expected) => {
const thread: Report = createWorkspaceThread(161);
const parentAction = createMock<ReportAction>({
actionName,
reportActionID: String(thread.parentReportActionID),
message: [],
created: '',
lastModified: '',
actorAccountID: 1,
person: [],
originalMessage,
});

const reportActionsCollection: Record<string, ReportActions> = {
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${String(thread.parentReportID)}`]: {
[String(thread.parentReportActionID)]: parentAction,
},
};

const name = computeReportName(
thread,
emptyCollections.reports,
emptyCollections.policies,
undefined,
undefined,
participantsPersonalDetails,
reportActionsCollection,
currentUserAccountID,
);
expect(name).toBe(expected);
});

test('UPDATE_CATEGORY parent action renders the description hint as plain text', () => {
const thread: Report = createWorkspaceThread(163);
const parentAction = createMock<ReportAction>({
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
reportActionID: String(thread.parentReportActionID),
message: [],
created: '',
lastModified: '',
actorAccountID: 1,
person: [],
originalMessage: {
categoryName: 'Advertising',
updatedField: 'commentHint',
oldValue: '',
newValue: 'Client&#x27;s &amp; partner&#x27;s names',
},
});

const reportActionsCollection: Record<string, ReportActions> = {
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${String(thread.parentReportID)}`]: {
[String(thread.parentReportActionID)]: parentAction,
},
};

const name = computeReportName(
thread,
emptyCollections.reports,
emptyCollections.policies,
undefined,
undefined,
participantsPersonalDetails,
reportActionsCollection,
currentUserAccountID,
);
expect(name).toBe(`added the description hint "Client's & partner's names" to the category "Advertising"`);
});

test('UPDATE_CATEGORY parent action formats the workspace default receipt amount with the policy currency', () => {
const thread: Report = createWorkspaceThread(162);
const parentAction = createMock<ReportAction>({
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
reportActionID: String(thread.parentReportActionID),
message: [],
created: '',
lastModified: '',
actorAccountID: 1,
person: [],
originalMessage: {
categoryName: 'Advertising',
updatedField: 'maxAmountNoReceipt',
oldValue: 0,
newValue: '',
},
});

const reportActionsCollection: Record<string, ReportActions> = {
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${String(thread.parentReportID)}`]: {
[String(thread.parentReportActionID)]: parentAction,
},
};
const policies: OnyxCollection<Policy> = {
[`${ONYXKEYS.COLLECTION.POLICY}${String(thread.policyID)}`]: createMock<Policy>({
id: String(thread.policyID),
maxExpenseAmountNoReceipt: 5000,
outputCurrency: 'EUR',
}),
};

const name = computeReportName(thread, emptyCollections.reports, policies, undefined, undefined, participantsPersonalDetails, reportActionsCollection, currentUserAccountID);
expect(name).toBe('changed the "Advertising" category to €50 • Default (previously Always require receipts)');
});

test('DELETE_CARD_FEED parent action', () => {
const thread: Report = createWorkspaceThread(101);
const parentAction = createMock<ReportAction>({
Expand Down
Loading
Loading