Skip to content

Commit 053ca5e

Browse files
authored
Merge pull request Expensify#85448 from ijmalik/85359-PR
Resolved issue_Category - Parent category is disabled when selected
2 parents 15da22a + 35699e3 commit 053ca5e

12 files changed

Lines changed: 139 additions & 214 deletions

File tree

src/CONST/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6515,7 +6515,7 @@ const CONST = {
65156515
PM: 'PM',
65166516
},
65176517
INDENTS: ' ',
6518-
PARENT_CHILD_SEPARATOR: ': ',
6518+
PARENT_CHILD_SEPARATOR: ':',
65196519
DISTANCE_MERCHANT_SEPARATOR: '@',
65206520
COLON: ':',
65216521
MAPBOX: {

src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
44
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
55
import useLocalize from '@hooks/useLocalize';
66
import useThemeStyles from '@hooks/useThemeStyles';
7-
import {getDecodedCategoryName} from '@libs/CategoryUtils';
7+
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
88
import Navigation from '@libs/Navigation/Navigation';
99
import {getCategory, willFieldBeAutomaticallyFilled} from '@libs/TransactionUtils';
1010
import CONST from '@src/CONST';
@@ -50,7 +50,7 @@ function CategoryField({
5050

5151
const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy';
5252
const iouCategory = getCategory(transaction);
53-
const decodedCategoryName = getDecodedCategoryName(iouCategory);
53+
const decodedCategoryName = getDecodedLeafCategoryName(iouCategory);
5454

5555
const getCategoryRightLabelIcon = () => (willFieldBeAutomaticallyFilled(transaction, 'category') ? icons.Sparkles : undefined);
5656
const getCategoryRightLabel = () => {

src/components/MoneyRequestReportView/MoneyRequestReportGroupHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import useLocalize from '@hooks/useLocalize';
88
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
99
import useTheme from '@hooks/useTheme';
1010
import useThemeStyles from '@hooks/useThemeStyles';
11+
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
1112
import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils';
1213
import variables from '@styles/variables';
1314
import CONST from '@src/CONST';
@@ -69,10 +70,9 @@ function MoneyRequestReportGroupHeader({
6970
const {shouldUseNarrowLayout: shouldUseNarrowLayoutHook} = useResponsiveLayoutOnWideRHP();
7071
const shouldUseNarrowLayout = shouldUseNarrowLayoutProp ?? shouldUseNarrowLayoutHook;
7172

72-
const cleanedGroupName = isGroupedByTag && group.groupName ? getCommaSeparatedTagNameWithSanitizedColons(group.groupName) : group.groupName;
73+
const cleanedGroupName = isGroupedByTag && group.groupName ? getCommaSeparatedTagNameWithSanitizedColons(group.groupName) : getDecodedLeafCategoryName(group.groupName);
7374
const displayName = cleanedGroupName || translate(isGroupedByTag ? 'reportLayout.noTag' : 'reportLayout.uncategorized');
7475
const formattedAmount = convertToDisplayString(group.subTotalAmount, currency);
75-
7676
const shouldShowCheckbox = isSelectionModeEnabled || !shouldUseNarrowLayout;
7777

7878
const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: 16} : [styles.labelStrong];

src/components/ReportActionItem/MoneyRequestView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {updateMoneyRequestBillable, updateMoneyRequestReimbursable, updateMoneyR
4545
import initSplitExpense from '@libs/actions/SplitExpenses';
4646
import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
4747
import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils';
48-
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
48+
import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
4949
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
5050
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
5151
import {getRateFromMerchant} from '@libs/MergeTransactionUtils';
@@ -691,7 +691,7 @@ function MoneyRequestView({
691691
const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined;
692692
const dateCopyValue = !canEditDate ? transactionDate : undefined;
693693
const categoryValue = updatedTransaction?.category ?? categoryForDisplay;
694-
const decodedCategoryName = getDecodedCategoryName(categoryValue);
694+
const decodedCategoryName = getDecodedLeafCategoryName(categoryValue);
695695
const categoryCopyValue = !canEdit ? decodedCategoryName : undefined;
696696
const cardCopyValue = cardProgramName;
697697
const taxRateValue = hasTaxValueChanged ? taxValue : (transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle ?? '');

src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived';
2222
import useTheme from '@hooks/useTheme';
2323
import useThemeStyles from '@hooks/useThemeStyles';
2424
import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils';
25-
import {getDecodedCategoryName} from '@libs/CategoryUtils';
25+
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
2626
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
2727
import {calculateAmount} from '@libs/IOUUtils';
2828
import Parser from '@libs/Parser';
@@ -391,7 +391,7 @@ function TransactionPreviewContent({
391391
numberOfLines={1}
392392
style={[isDeleted && styles.lineThrough, styles.textMicroSupporting, styles.pre, styles.flexShrink1]}
393393
>
394-
{getDecodedCategoryName(category ?? '')}
394+
{getDecodedLeafCategoryName(category ?? '')}
395395
</Text>
396396
</View>
397397
)}

src/components/SelectionList/ListItem/SplitListItem.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
1212
import useLocalize from '@hooks/useLocalize';
1313
import useTheme from '@hooks/useTheme';
1414
import useThemeStyles from '@hooks/useThemeStyles';
15-
import {getDecodedCategoryName} from '@libs/CategoryUtils';
15+
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
1616
import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils';
1717
import variables from '@styles/variables';
1818
import CONST from '@src/CONST';
@@ -94,7 +94,7 @@ function SplitListItem<TItem extends ListItem>({
9494
const textContentAccessibilityLabel = [
9595
splitItem.headerText,
9696
splitItem.merchant,
97-
splitItem.category ? getDecodedCategoryName(splitItem.category) : undefined,
97+
splitItem.category ? getDecodedLeafCategoryName(splitItem.category) : undefined,
9898
splitItem.tags?.at(0) ? getCommaSeparatedTagNameWithSanitizedColons(splitItem.tags.at(0) ?? '') : undefined,
9999
]
100100
.filter(Boolean)
@@ -173,7 +173,7 @@ function SplitListItem<TItem extends ListItem>({
173173
numberOfLines={1}
174174
style={[styles.textMicroSupporting, styles.pre, styles.flexShrink1]}
175175
>
176-
{getDecodedCategoryName(splitItem.category)}
176+
{getDecodedLeafCategoryName(splitItem.category)}
177177
</Text>
178178
</View>
179179
)}

src/components/TransactionItemRow/DataCells/CategoryCell.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {EditableCell, usePopoverEditState} from '@components/TransactionItemRow/
77
import type {EditableProps} from '@components/TransactionItemRow/EditableCell';
88
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
99
import useThemeStyles from '@hooks/useThemeStyles';
10-
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
10+
import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
1111
import type TransactionDataCellProps from './TransactionDataCellProps';
1212

1313
type CategoryCellProps = TransactionDataCellProps &
@@ -20,7 +20,7 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem
2020
const styles = useThemeStyles();
2121

2222
// For display: decoded category name for user-readable text
23-
const categoryForDisplay = isCategoryMissing(transactionItem?.category) ? '' : getDecodedCategoryName(transactionItem?.category ?? '');
23+
const categoryForDisplay = isCategoryMissing(transactionItem?.category) ? '' : getDecodedLeafCategoryName(transactionItem?.category ?? '');
2424

2525
// For picker comparison: raw category name (empty if missing, matches IOURequestStepCategory)
2626
const categoryForComparison = isCategoryMissing(transactionItem?.category) ? '' : (transactionItem?.category ?? '');

src/libs/CategoryOptionListUtils.ts

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {PolicyCategories} from '@src/types/onyx';
88
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
99
import {isEmptyObject} from '@src/types/utils/EmptyObject';
1010
import times from '@src/utils/times';
11-
import {getDecodedCategoryName} from './CategoryUtils';
11+
import {getDecodedCategoryName, processCategoryNameSegments} from './CategoryUtils';
1212
import type {OptionTree} from './OptionsListUtils';
1313
import tokenizedSearch from './tokenizedSearch';
1414

@@ -29,32 +29,11 @@ type Hierarchy = Record<string, Category & {[key: string]: Hierarchy & Category}
2929
* @param options - an initial object array
3030
* @param options[].enabled - a flag to enable/disable option in a list
3131
* @param options[].name - a name of an option
32-
* @param [isOneLine] - a flag to determine if text should be one line
3332
*/
34-
function getCategoryOptionTree(options: Record<string, Category> | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] {
33+
function getCategoryOptionTree(options: Record<string, Category> | Category[], selectedOptions: Category[] = []): OptionTree[] {
3534
const optionCollection = new Map<string, OptionTree>();
3635
for (const option of Object.values(options)) {
37-
if (isOneLine) {
38-
if (optionCollection.has(option.name)) {
39-
continue;
40-
}
41-
42-
const decodedCategoryName = getDecodedCategoryName(option.name);
43-
optionCollection.set(option.name, {
44-
text: decodedCategoryName,
45-
keyForList: option.name,
46-
searchText: option.name,
47-
tooltipText: decodedCategoryName,
48-
isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
49-
isSelected: !!option.isSelected,
50-
pendingAction: option.pendingAction,
51-
});
52-
53-
continue;
54-
}
55-
56-
const array = option.name.split(CONST.PARENT_CHILD_SEPARATOR);
57-
36+
const array = processCategoryNameSegments(option.name);
5837
for (let index = 0; index < array.length; index++) {
5938
const optionName = array.at(index);
6039
if (!optionName) {
@@ -63,20 +42,27 @@ function getCategoryOptionTree(options: Record<string, Category> | Category[], i
6342

6443
const indents = times(index, () => CONST.INDENTS).join('');
6544
const isChild = array.length - 1 === index;
66-
const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR);
45+
46+
// For leaf categories, use the original full name so it matches the policy.
47+
// For parent categories, build the path from the processed segments.
48+
const searchText = isChild ? option.name : array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR);
6749
const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText);
68-
const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
50+
const optionParent = !isChild && Object.values(options).find((op) => op.name === searchText);
51+
const parentOption = selectedParentOption ?? optionParent;
52+
53+
const isParentOptionDisabled = !parentOption || !parentOption.enabled || parentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
6954

7055
if (optionCollection.has(searchText)) {
7156
continue;
7257
}
73-
74-
const decodedCategoryName = getDecodedCategoryName(optionName);
58+
const leafName = getDecodedCategoryName(optionName.trim());
59+
const decodedCategoryName = getDecodedCategoryName(option.name);
60+
const tooltipText = isChild ? decodedCategoryName : getDecodedCategoryName(searchText);
7561
optionCollection.set(searchText, {
76-
text: `${indents}${decodedCategoryName}`,
62+
text: `${indents}${leafName}`,
7763
keyForList: searchText,
7864
searchText,
79-
tooltipText: decodedCategoryName,
65+
tooltipText,
8066
isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled,
8167
isSelected: isChild ? !!option.isSelected : !!selectedParentOption,
8268
pendingAction: option.pendingAction,
@@ -124,7 +110,7 @@ function getCategoryListSections({
124110
}
125111

126112
if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) {
127-
const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
113+
const data = getCategoryOptionTree(selectedOptionsWithDisabledState);
128114
categorySections.push({
129115
// "Selected" section
130116
title: '',
@@ -136,14 +122,42 @@ function getCategoryListSections({
136122
}
137123

138124
if (searchValue) {
125+
// Step 1: Combine selected and enabled categories for searching
139126
const categoriesForSearch = [...selectedOptionsWithDisabledState, ...enabledCategories];
140127

141-
const searchCategories: Category[] = tokenizedSearch(categoriesForSearch, searchValue, (category) => [category.name]).map((category) => ({
128+
// Step 2: Get search results using tokenizedSearch
129+
let searchCategories: Category[] = tokenizedSearch(categoriesForSearch, searchValue, (category) => [category.name]).map((category) => ({
130+
...category,
131+
// Temporarily store if it was selected
132+
wasSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name),
133+
}));
134+
135+
// Step 3: Deduplicate by name (keep first occurrence, which is likely the selected one if present)
136+
const seen = new Set<string>();
137+
searchCategories = searchCategories.filter((category) => {
138+
if (seen.has(category.name)) {
139+
return false;
140+
}
141+
seen.add(category.name);
142+
return true;
143+
});
144+
145+
// Step 4: Re-sort to restore hierarchical grouping
146+
// Convert back to Record format expected by sortCategories
147+
const categoriesRecord: Record<string, Category> = {};
148+
for (const category of searchCategories) {
149+
categoriesRecord[category.name] = category;
150+
}
151+
const searchSortedCategories = sortCategories(categoriesRecord, localeCompare);
152+
153+
// Step 5: Re-apply the isSelected flag (lost during sortCategories)
154+
const finalSearchCategories: Category[] = searchSortedCategories.map((category) => ({
142155
...category,
143156
isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name),
144157
}));
145158

146-
const data = getCategoryOptionTree(searchCategories, true);
159+
// Step 6: Generate the option tree and push the section
160+
const data = getCategoryOptionTree(finalSearchCategories);
147161
categorySections.push({
148162
// "Search" section
149163
title: '',
@@ -155,7 +169,7 @@ function getCategoryListSections({
155169
}
156170

157171
if (selectedOptions.length > 0) {
158-
const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
172+
const data = getCategoryOptionTree(selectedOptionsWithDisabledState);
159173
categorySections.push({
160174
// "Selected" section
161175
title: '',
@@ -168,7 +182,7 @@ function getCategoryListSections({
168182
const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.has(category.name));
169183

170184
if (numberOfEnabledCategories < CONST.STANDARD_LIST_ITEM_LIMIT) {
171-
const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState);
185+
const data = getCategoryOptionTree(filteredCategories, selectedOptionsWithDisabledState);
172186
categorySections.push({
173187
// "All" section when items amount less than the threshold
174188
title: '',
@@ -192,7 +206,7 @@ function getCategoryListSections({
192206
if (filteredRecentlyUsedCategories.length > 0) {
193207
const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow);
194208

195-
const data = getCategoryOptionTree(cutRecentlyUsedCategories, true);
209+
const data = getCategoryOptionTree(cutRecentlyUsedCategories);
196210
categorySections.push({
197211
// "Recent" section
198212
title: translate('common.recent'),
@@ -201,7 +215,7 @@ function getCategoryListSections({
201215
});
202216
}
203217

204-
const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState);
218+
const data = getCategoryOptionTree(filteredCategories, selectedOptionsWithDisabledState);
205219
categorySections.push({
206220
// "All" section when items amount more than the threshold
207221
title: translate('common.all'),
@@ -239,7 +253,7 @@ function sortCategories(categories: Record<string, Category>, localeCompare: Loc
239253
* }
240254
*/
241255
for (const category of sortedCategories) {
242-
const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR);
256+
const path = processCategoryNameSegments(category.name);
243257
const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy;
244258
lodashSet(hierarchy, path, {
245259
...existedValue,

src/libs/CategoryUtils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,49 @@ function getDecodedCategoryName(categoryName: string) {
147147
return Str.htmlDecode(categoryName);
148148
}
149149

150+
/**
151+
* Splits a category name on the colon separator, removes empty middle segments,
152+
* and merges a trailing empty segment into the previous part (preserving trailing colons).
153+
*
154+
* Examples:
155+
* "Food: Meat" → ["Food", "Meat"]
156+
* "A: B:" → ["A", "B:"]
157+
* "A: B: :" → ["A", "B:"]
158+
* "A: B::" → ["A", "B:"]
159+
* "A: B:::" → ["A", "B:"]
160+
* ":D" → ["D"]
161+
* "Plain" → ["Plain"]
162+
*/
163+
function processCategoryNameSegments(categoryName: string): string[] {
164+
const parts = categoryName.split(CONST.PARENT_CHILD_SEPARATOR);
165+
const result: string[] = [];
166+
167+
// Keep only parts that contain at least one non‑whitespace character.
168+
for (let i = 0; i < parts.length; i++) {
169+
const part = parts.at(i);
170+
if (part === undefined) {
171+
continue;
172+
}
173+
if (part.trim() !== '') {
174+
result.push(part);
175+
}
176+
}
177+
178+
// If the original name ends with a colon (allowing trailing spaces), append a colon to the last segment.
179+
const endsWithColon = categoryName.trim().endsWith(CONST.PARENT_CHILD_SEPARATOR);
180+
if (endsWithColon && result.length > 0) {
181+
result[result.length - 1] = result.at(result.length - 1) + CONST.PARENT_CHILD_SEPARATOR;
182+
}
183+
184+
return result;
185+
}
186+
187+
function getDecodedLeafCategoryName(categoryName: string): string {
188+
const segments = processCategoryNameSegments(categoryName);
189+
const leaf = segments.at(segments.length - 1) ?? '';
190+
return Str.htmlDecode(leaf.trim());
191+
}
192+
150193
function getAvailableNonPersonalPolicyCategories(policyCategories: OnyxCollection<PolicyCategories>, personalPolicyID: string | undefined) {
151194
return Object.fromEntries(
152195
Object.entries(policyCategories ?? {}).filter(([key, categories]) => {
@@ -171,5 +214,7 @@ export {
171214
isCategoryMissing,
172215
isCategoryDescriptionRequired,
173216
getDecodedCategoryName,
217+
getDecodedLeafCategoryName,
218+
processCategoryNameSegments,
174219
getAvailableNonPersonalPolicyCategories,
175220
};

0 commit comments

Comments
 (0)