Skip to content

Commit 5c37fc4

Browse files
roryabrahamOSBotify
authored andcommitted
Merge pull request #98495 from Expensify/rory/fix-98482-category-thread-header
[CP Stg] Fix category change log thread header to match the system message (cherry picked from commit 96fad2f) (cherry-picked to staging by roryabraham)
1 parent a9b1a62 commit 5c37fc4

9 files changed

Lines changed: 397 additions & 14 deletions

File tree

src/libs/OptionsListUtils/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
getUpdatedCardFeedLiabilityMessage,
8080
getUpdatedCardFeedStatementPeriodMessage,
8181
getUpdateRoomDescriptionMessage,
82+
getWorkspaceCategoryUpdateMessage,
8283
getWorkspaceFeatureEnabledMessage,
8384
getWorkspaceTaxUpdateMessage,
8485
hasPendingDEWApprove,
@@ -88,6 +89,7 @@ import {
8889
isActionableMentionWhisper,
8990
isActionOfType,
9091
isAddCommentAction,
92+
isCategoryModificationAction,
9193
isClosedAction,
9294
isCreatedAction,
9395
isCreatedTaskReportAction,
@@ -975,6 +977,9 @@ function getLastMessageTextForReport({
975977
if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MCC_GROUP_CATEGORY)) {
976978
lastMessageTextFromReport = getMccGroupCategoryMessage(translate, lastReportAction);
977979
}
980+
if (lastReportAction?.actionName && isCategoryModificationAction(lastReportAction.actionName)) {
981+
lastMessageTextFromReport = getWorkspaceCategoryUpdateMessage(translate, lastReportAction, policy);
982+
}
978983
if (
979984
isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX) ||
980985
isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX) ||

src/libs/ReportActionsUtils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1850,6 +1850,15 @@ function isTaskAction(reportAction: OnyxEntry<ReportAction>): boolean {
18501850
* @param actionName - The name of the action
18511851
* @returns - Whether the action is a tag modification action
18521852
* */
1853+
function isCategoryModificationAction(actionName: string): boolean {
1854+
return (
1855+
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY ||
1856+
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY ||
1857+
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY ||
1858+
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME
1859+
);
1860+
}
1861+
18531862
function isTagModificationAction(actionName: string): boolean {
18541863
return (
18551864
actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG ||
@@ -3040,7 +3049,10 @@ function getWorkspaceCategoryUpdateMessage(translate: LocalizedTranslate, action
30403049

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

30463058
if (updatedField === 'enabled') {
@@ -4987,6 +4999,7 @@ export {
49874999
getMostRecentActiveDEWApproveFailedAction,
49885000
hasPendingDEWApprove,
49895001
isWhisperActionTargetedToOthers,
5002+
isCategoryModificationAction,
49905003
isTagModificationAction,
49915004
isIOUActionMatchingTransactionList,
49925005
isResolvedActionableWhisper,

src/libs/ReportNameUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
getUpdatedCardFeedStatementPeriodMessage,
9696
getUpdatedProhibitedExpensesMessage,
9797
getWorkspaceAttendeeTrackingUpdateMessage,
98+
getWorkspaceCategoryUpdateMessage,
9899
getWorkspaceCurrencyUpdateMessage,
99100
getWorkspaceCustomUnitRateAddedMessage,
100101
getWorkspaceCustomUnitRateDeletedMessage,
@@ -111,6 +112,7 @@ import {
111112
isActionableJoinRequest,
112113
isActionOfType,
113114
isCardIssuedAction,
115+
isCategoryModificationAction,
114116
isDynamicExternalWorkflowApproveFailedAction,
115117
isDynamicExternalWorkflowSubmitFailedAction,
116118
isMarkAsClosedAction,
@@ -699,6 +701,10 @@ function computeReportNameBasedOnReportAction({
699701
return getReimburserUpdateMessage(translate, parentReportAction);
700702
}
701703

704+
if (parentReportAction?.actionName && isCategoryModificationAction(parentReportAction.actionName)) {
705+
return getWorkspaceCategoryUpdateMessage(translate, parentReportAction, reportPolicy);
706+
}
707+
702708
if (
703709
isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX) ||
704710
isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX) ||

src/libs/SidebarUtils.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ import {
146146
getWorkspaceUpdateFieldMessage,
147147
isActionOfType,
148148
isCardIssuedAction,
149+
isCategoryModificationAction,
149150
isInviteOrRemovedAction,
150151
isLeavePolicyAction,
151152
isOldDotReportAction,
@@ -1108,12 +1109,7 @@ function getOptionData({
11081109
result.alternateText = Parser.htmlToText(getCompanyCardConnectionBrokenMessage(translate, lastAction));
11091110
} else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.PLAID_BALANCE_FAILURE)) {
11101111
result.alternateText = Parser.htmlToText(getPlaidBalanceFailureMessage(translate, lastAction));
1111-
} else if (
1112-
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY) ||
1113-
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY) ||
1114-
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY) ||
1115-
isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME)
1116-
) {
1112+
} else if (lastAction?.actionName && isCategoryModificationAction(lastAction.actionName)) {
11171113
result.alternateText = getWorkspaceCategoryUpdateMessage(translate, lastAction);
11181114
} else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES)) {
11191115
result.alternateText = getWorkspaceCategoriesUpdatedMessage(translate, lastAction);

src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ import {
139139
isActionableTrackExpense,
140140
isActionOfType,
141141
isCardIssuedAction,
142+
isCategoryModificationAction,
142143
isCreatedAction,
143144
isCreatedTaskReportAction,
144145
isDeletedAction as isDeletedActionReportActionsUtils,
@@ -1057,12 +1058,7 @@ const ContextMenuActions: ContextMenuAction[] = [
10571058
Clipboard.setString(getWorkspaceCurrencyUpdateMessage(translate, reportAction));
10581059
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) {
10591060
Clipboard.setString(getWorkspaceFrequencyUpdateMessage(translate, reportAction));
1060-
} else if (
1061-
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY ||
1062-
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY ||
1063-
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY ||
1064-
reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME
1065-
) {
1061+
} else if (reportAction?.actionName && isCategoryModificationAction(reportAction.actionName)) {
10661062
Clipboard.setString(getWorkspaceCategoryUpdateMessage(translate, reportAction));
10671063
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES) {
10681064
Clipboard.setString(getWorkspaceCategoriesUpdatedMessage(translate, reportAction));

tests/ui/components/HeaderViewTest.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,62 @@ describe('HeaderView', () => {
232232
);
233233
}
234234

235+
it('should display the localized category update message for a thread on a category update action', async () => {
236+
// Given an #admins room with a report action that made attendees required on a category
237+
const policyID = '400';
238+
const adminsReportID = '401';
239+
const threadReportID = '402';
240+
const rawServerMessage = 'updated the category "Advertising" by changing the Attendees from Not Required to Required';
241+
242+
const parentReportAction: ReportAction = {
243+
reportActionID: '4001',
244+
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
245+
created: '2026-01-01 00:00:00.000',
246+
message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: rawServerMessage, text: rawServerMessage}],
247+
originalMessage: {
248+
categoryName: 'Advertising',
249+
updatedField: 'areAttendeesRequired',
250+
oldValue: '',
251+
newValue: true,
252+
},
253+
};
254+
255+
const adminsReport = {
256+
...createRandomReport(Number(adminsReportID), CONST.REPORT.CHAT_TYPE.POLICY_ADMINS),
257+
type: CONST.REPORT.TYPE.CHAT,
258+
policyID,
259+
};
260+
261+
// And a chat thread opened on that action
262+
const threadReport = {
263+
...createRandomReport(Number(threadReportID), undefined),
264+
type: CONST.REPORT.TYPE.CHAT,
265+
policyID,
266+
parentReportID: adminsReportID,
267+
parentReportActionID: parentReportAction.reportActionID,
268+
};
269+
270+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsReportID}`, {
271+
[parentReportAction.reportActionID]: parentReportAction,
272+
});
273+
await waitForBatchedUpdates();
274+
275+
const adminsReportKey = `${ONYXKEYS.COLLECTION.REPORT}${adminsReportID}` as const;
276+
const threadReportKey = `${ONYXKEYS.COLLECTION.REPORT}${threadReportID}` as const;
277+
await Onyx.multiSet(
278+
createMock<KeyValueMapping>({
279+
[adminsReportKey]: adminsReport,
280+
[threadReportKey]: threadReport,
281+
}),
282+
);
283+
284+
renderHeader(threadReport.reportID);
285+
await waitForBatchedUpdatesWithAct();
286+
287+
// Then the thread header should show the same copy as the system message in the chat
288+
await waitFor(() => expect(screen.getByTestId('DisplayNames')).toHaveTextContent(translateLocal('workspaceActions.updateAreAttendeesRequired', 'Advertising', true)));
289+
});
290+
235291
it('should display the Book a call button in the 1:1 DM with the account manager', async () => {
236292
// Given a 1:1 DM with the assigned account manager who has a calendar link
237293
const report = createRegularChat(500, [currentUserAccountID, accountManagerAccountID]);

tests/unit/ContextMenuActionsCopyMessageTest.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,23 @@ describe('ContextMenuActions copy message', () => {
149149
expect(mockGetClipboardText).toHaveBeenCalledWith(expectedTranslationKey);
150150
expect(mockClipboard.setString).toHaveBeenCalledWith('mocked clipboard text');
151151
});
152+
153+
it('copies the localized message for a category update action', () => {
154+
mockClipboard.canSetHtml.mockReturnValue(false);
155+
156+
if (!copyMessageAction?.onPress) {
157+
throw new Error('Copy message context menu action was not found');
158+
}
159+
160+
copyMessageAction.onPress(
161+
false,
162+
createReportActionPayload({
163+
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
164+
message: [{html: ''}],
165+
originalMessage: {categoryName: 'Advertising', updatedField: 'areAttendeesRequired', oldValue: '', newValue: true},
166+
}),
167+
);
168+
169+
expect(mockClipboard.setString).toHaveBeenCalledWith('workspaceActions.updateAreAttendeesRequired');
170+
});
152171
});

tests/unit/ReportNameUtilsTest.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,119 @@ describe('ReportNameUtils', () => {
895895
expect(name).toBe('changed the "Office Supplies" category default tax rate to "Tax Rate 1 (5%)" (previously "Tax Exempt (0%)")');
896896
});
897897

898+
test.each([
899+
[CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY, {categoryName: 'Advertising'}, 'added the category "Advertising"'],
900+
[CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY, {categoryName: 'Advertising'}, 'removed the category "Advertising"'],
901+
[
902+
CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
903+
{categoryName: 'Advertising', updatedField: 'areAttendeesRequired', oldValue: '', newValue: true},
904+
'changed the "Advertising" category attendees to required (previously not required)',
905+
],
906+
[CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME, {oldName: 'Advertising', newName: 'Marketing'}, 'renamed the category "Advertising" to "Marketing"'],
907+
])('%s parent action renders the same message as the system message in the chat', (actionName, originalMessage, expected) => {
908+
const thread: Report = createWorkspaceThread(161);
909+
const parentAction = createMock<ReportAction>({
910+
actionName,
911+
reportActionID: String(thread.parentReportActionID),
912+
message: [],
913+
created: '',
914+
lastModified: '',
915+
actorAccountID: 1,
916+
person: [],
917+
originalMessage,
918+
});
919+
920+
const reportActionsCollection: Record<string, ReportActions> = {
921+
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${String(thread.parentReportID)}`]: {
922+
[String(thread.parentReportActionID)]: parentAction,
923+
},
924+
};
925+
926+
const name = computeReportName(
927+
thread,
928+
emptyCollections.reports,
929+
emptyCollections.policies,
930+
undefined,
931+
undefined,
932+
participantsPersonalDetails,
933+
reportActionsCollection,
934+
currentUserAccountID,
935+
);
936+
expect(name).toBe(expected);
937+
});
938+
939+
test('UPDATE_CATEGORY parent action renders the description hint as plain text', () => {
940+
const thread: Report = createWorkspaceThread(163);
941+
const parentAction = createMock<ReportAction>({
942+
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
943+
reportActionID: String(thread.parentReportActionID),
944+
message: [],
945+
created: '',
946+
lastModified: '',
947+
actorAccountID: 1,
948+
person: [],
949+
originalMessage: {
950+
categoryName: 'Advertising',
951+
updatedField: 'commentHint',
952+
oldValue: '',
953+
newValue: 'Client&#x27;s &amp; partner&#x27;s names',
954+
},
955+
});
956+
957+
const reportActionsCollection: Record<string, ReportActions> = {
958+
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${String(thread.parentReportID)}`]: {
959+
[String(thread.parentReportActionID)]: parentAction,
960+
},
961+
};
962+
963+
const name = computeReportName(
964+
thread,
965+
emptyCollections.reports,
966+
emptyCollections.policies,
967+
undefined,
968+
undefined,
969+
participantsPersonalDetails,
970+
reportActionsCollection,
971+
currentUserAccountID,
972+
);
973+
expect(name).toBe(`added the description hint "Client's & partner's names" to the category "Advertising"`);
974+
});
975+
976+
test('UPDATE_CATEGORY parent action formats the workspace default receipt amount with the policy currency', () => {
977+
const thread: Report = createWorkspaceThread(162);
978+
const parentAction = createMock<ReportAction>({
979+
actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY,
980+
reportActionID: String(thread.parentReportActionID),
981+
message: [],
982+
created: '',
983+
lastModified: '',
984+
actorAccountID: 1,
985+
person: [],
986+
originalMessage: {
987+
categoryName: 'Advertising',
988+
updatedField: 'maxAmountNoReceipt',
989+
oldValue: 0,
990+
newValue: '',
991+
},
992+
});
993+
994+
const reportActionsCollection: Record<string, ReportActions> = {
995+
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${String(thread.parentReportID)}`]: {
996+
[String(thread.parentReportActionID)]: parentAction,
997+
},
998+
};
999+
const policies: OnyxCollection<Policy> = {
1000+
[`${ONYXKEYS.COLLECTION.POLICY}${String(thread.policyID)}`]: createMock<Policy>({
1001+
id: String(thread.policyID),
1002+
maxExpenseAmountNoReceipt: 5000,
1003+
outputCurrency: 'EUR',
1004+
}),
1005+
};
1006+
1007+
const name = computeReportName(thread, emptyCollections.reports, policies, undefined, undefined, participantsPersonalDetails, reportActionsCollection, currentUserAccountID);
1008+
expect(name).toBe('changed the "Advertising" category to €50 • Default (previously Always require receipts)');
1009+
});
1010+
8981011
test('DELETE_CARD_FEED parent action', () => {
8991012
const thread: Report = createWorkspaceThread(101);
9001013
const parentAction = createMock<ReportAction>({

0 commit comments

Comments
 (0)