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
2 changes: 1 addition & 1 deletion src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6403,7 +6403,7 @@ const CONST = {
PM: 'PM',
},
INDENTS: ' ',
PARENT_CHILD_SEPARATOR: ': ',
PARENT_CHILD_SEPARATOR: ':',
DISTANCE_MERCHANT_SEPARATOR: '@',
COLON: ':',
MAPBOX: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedCategoryName} from '@libs/CategoryUtils';
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getCategory, willFieldBeAutomaticallyFilled} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -50,7 +50,7 @@ function CategoryField({

const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy';
const iouCategory = getCategory(transaction);
const decodedCategoryName = getDecodedCategoryName(iouCategory);
const decodedCategoryName = getDecodedLeafCategoryName(iouCategory);

const getCategoryRightLabelIcon = () => (willFieldBeAutomaticallyFilled(transaction, 'category') ? icons.Sparkles : undefined);
const getCategoryRightLabel = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useLocalize from '@hooks/useLocalize';
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -69,10 +70,9 @@ function MoneyRequestReportGroupHeader({
const {shouldUseNarrowLayout: shouldUseNarrowLayoutHook} = useResponsiveLayoutOnWideRHP();
const shouldUseNarrowLayout = shouldUseNarrowLayoutProp ?? shouldUseNarrowLayoutHook;

const cleanedGroupName = isGroupedByTag && group.groupName ? getCommaSeparatedTagNameWithSanitizedColons(group.groupName) : group.groupName;
const cleanedGroupName = isGroupedByTag && group.groupName ? getCommaSeparatedTagNameWithSanitizedColons(group.groupName) : getDecodedLeafCategoryName(group.groupName);
const displayName = cleanedGroupName || translate(isGroupedByTag ? 'reportLayout.noTag' : 'reportLayout.uncategorized');
const formattedAmount = convertToDisplayString(group.subTotalAmount, currency);

const shouldShowCheckbox = isSelectionModeEnabled || !shouldUseNarrowLayout;

const textStyle = shouldUseNarrowLayout ? {fontSize: variables.fontSizeLabel, lineHeight: 16} : [styles.labelStrong];
Expand Down
4 changes: 2 additions & 2 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {updateMoneyRequestBillable, updateMoneyRequestReimbursable, updateMoneyR
import initSplitExpense from '@libs/actions/SplitExpenses';
import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils';
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getRateFromMerchant} from '@libs/MergeTransactionUtils';
Expand Down Expand Up @@ -679,7 +679,7 @@ function MoneyRequestView({
const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined;
const dateCopyValue = !canEditDate ? transactionDate : undefined;
const categoryValue = updatedTransaction?.category ?? categoryForDisplay;
const decodedCategoryName = getDecodedCategoryName(categoryValue);
const decodedCategoryName = getDecodedLeafCategoryName(categoryValue);
const categoryCopyValue = !canEdit ? decodedCategoryName : undefined;
const cardCopyValue = cardProgramName;
const taxRateValue = hasTaxValueChanged ? taxValue : (transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle ?? '');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils';
import {getDecodedCategoryName} from '@libs/CategoryUtils';
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {calculateAmount} from '@libs/IOUUtils';
import Parser from '@libs/Parser';
Expand Down Expand Up @@ -391,7 +391,7 @@ function TransactionPreviewContent({
numberOfLines={1}
style={[isDeleted && styles.lineThrough, styles.textMicroSupporting, styles.pre, styles.flexShrink1]}
>
{getDecodedCategoryName(category ?? '')}
{getDecodedLeafCategoryName(category ?? '')}
</Text>
</View>
)}
Expand Down
6 changes: 3 additions & 3 deletions src/components/SelectionList/ListItem/SplitListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedCategoryName} from '@libs/CategoryUtils';
import {getDecodedLeafCategoryName} from '@libs/CategoryUtils';
import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -94,7 +94,7 @@ function SplitListItem<TItem extends ListItem>({
const textContentAccessibilityLabel = [
splitItem.headerText,
splitItem.merchant,
splitItem.category ? getDecodedCategoryName(splitItem.category) : undefined,
splitItem.category ? getDecodedLeafCategoryName(splitItem.category) : undefined,
splitItem.tags?.at(0) ? getCommaSeparatedTagNameWithSanitizedColons(splitItem.tags.at(0) ?? '') : undefined,
]
.filter(Boolean)
Expand Down Expand Up @@ -173,7 +173,7 @@ function SplitListItem<TItem extends ListItem>({
numberOfLines={1}
style={[styles.textMicroSupporting, styles.pre, styles.flexShrink1]}
>
{getDecodedCategoryName(splitItem.category)}
{getDecodedLeafCategoryName(splitItem.category)}
</Text>
</View>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {EditableProps} from '@components/Table/EditableCell';
import TextWithTooltip from '@components/TextWithTooltip';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import type TransactionDataCellProps from './TransactionDataCellProps';

type CategoryCellProps = TransactionDataCellProps &
Expand All @@ -21,7 +21,7 @@ function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem
const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing} = usePopoverEditState({canEdit});

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

// For picker comparison: raw category name (empty if missing, matches IOURequestStepCategory)
const categoryForComparison = isCategoryMissing(transactionItem?.category) ? '' : (transactionItem?.category ?? '');
Expand Down
90 changes: 52 additions & 38 deletions src/libs/CategoryOptionListUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {PolicyCategories} from '@src/types/onyx';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import {getDecodedCategoryName} from './CategoryUtils';
import {getDecodedCategoryName, processCategoryNameSegments} from './CategoryUtils';
import type {OptionTree} from './OptionsListUtils';
import tokenizedSearch from './tokenizedSearch';

Expand All @@ -29,32 +29,11 @@ type Hierarchy = Record<string, Category & {[key: string]: Hierarchy & Category}
* @param options - an initial object array
* @param options[].enabled - a flag to enable/disable option in a list
* @param options[].name - a name of an option
* @param [isOneLine] - a flag to determine if text should be one line
*/
function getCategoryOptionTree(options: Record<string, Category> | Category[], isOneLine = false, selectedOptions: Category[] = []): OptionTree[] {
function getCategoryOptionTree(options: Record<string, Category> | Category[], selectedOptions: Category[] = []): OptionTree[] {
const optionCollection = new Map<string, OptionTree>();
for (const option of Object.values(options)) {
if (isOneLine) {
if (optionCollection.has(option.name)) {
continue;
}

const decodedCategoryName = getDecodedCategoryName(option.name);
optionCollection.set(option.name, {
text: decodedCategoryName,
keyForList: option.name,
searchText: option.name,
tooltipText: decodedCategoryName,
isDisabled: !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
isSelected: !!option.isSelected,
pendingAction: option.pendingAction,
});

continue;
}

const array = option.name.split(CONST.PARENT_CHILD_SEPARATOR);

const array = processCategoryNameSegments(option.name);
for (let index = 0; index < array.length; index++) {
const optionName = array.at(index);
if (!optionName) {
Expand All @@ -63,20 +42,27 @@ function getCategoryOptionTree(options: Record<string, Category> | Category[], i

const indents = times(index, () => CONST.INDENTS).join('');
const isChild = array.length - 1 === index;
const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR);

// For leaf categories, use the original full name so it matches the policy.
// For parent categories, build the path from the processed segments.
const searchText = isChild ? option.name : array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR);
const selectedParentOption = !isChild && Object.values(selectedOptions).find((op) => op.name === searchText);
const isParentOptionDisabled = !selectedParentOption || !selectedParentOption.enabled || selectedParentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
const optionParent = !isChild && Object.values(options).find((op) => op.name === searchText);

@ZhenjaHorbach ZhenjaHorbach Mar 16, 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.

I'm a bit confused
We reverted the original PR
But where are the changes from the original PR?

Could you open a PR with the main changes from the reverted PR + with all regression fixes?

const parentOption = selectedParentOption ?? optionParent;

const isParentOptionDisabled = !parentOption || !parentOption.enabled || parentOption.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;

if (optionCollection.has(searchText)) {
continue;
}

const decodedCategoryName = getDecodedCategoryName(optionName);
const leafName = getDecodedCategoryName(optionName.trim());
const decodedCategoryName = getDecodedCategoryName(option.name);
const tooltipText = isChild ? decodedCategoryName : getDecodedCategoryName(searchText);
optionCollection.set(searchText, {
text: `${indents}${decodedCategoryName}`,
text: `${indents}${leafName}`,
keyForList: searchText,
searchText,
tooltipText: decodedCategoryName,
tooltipText,
isDisabled: isChild ? !option.enabled || option.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : isParentOptionDisabled,
isSelected: isChild ? !!option.isSelected : !!selectedParentOption,
pendingAction: option.pendingAction,
Expand Down Expand Up @@ -124,7 +110,7 @@ function getCategoryListSections({
}

if (numberOfEnabledCategories === 0 && selectedOptions.length > 0) {
const data = getCategoryOptionTree(selectedOptionsWithDisabledState, true);
const data = getCategoryOptionTree(selectedOptionsWithDisabledState);
categorySections.push({
// "Selected" section
title: '',
Expand All @@ -136,14 +122,42 @@ function getCategoryListSections({
}

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

const searchCategories: Category[] = tokenizedSearch(categoriesForSearch, searchValue, (category) => [category.name]).map((category) => ({
// Step 2: Get search results using tokenizedSearch
let searchCategories: Category[] = tokenizedSearch(categoriesForSearch, searchValue, (category) => [category.name]).map((category) => ({
...category,
// Temporarily store if it was selected
wasSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name),
}));

// Step 3: Deduplicate by name (keep first occurrence, which is likely the selected one if present)
const seen = new Set<string>();
searchCategories = searchCategories.filter((category) => {
if (seen.has(category.name)) {
return false;
}
seen.add(category.name);
return true;
});

// Step 4: Re-sort to restore hierarchical grouping
// Convert back to Record format expected by sortCategories
const categoriesRecord: Record<string, Category> = {};
for (const category of searchCategories) {
categoriesRecord[category.name] = category;
}
const searchSortedCategories = sortCategories(categoriesRecord, localeCompare);

// Step 5: Re-apply the isSelected flag (lost during sortCategories)
const finalSearchCategories: Category[] = searchSortedCategories.map((category) => ({
...category,
isSelected: selectedOptions.some((selectedOption) => selectedOption.name === category.name),
}));

const data = getCategoryOptionTree(searchCategories, true);
// Step 6: Generate the option tree and push the section
const data = getCategoryOptionTree(finalSearchCategories);
categorySections.push({
// "Search" section
title: '',
Expand All @@ -155,7 +169,7 @@ function getCategoryListSections({
}

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

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

const data = getCategoryOptionTree(cutRecentlyUsedCategories, true);
const data = getCategoryOptionTree(cutRecentlyUsedCategories);
categorySections.push({
// "Recent" section
title: translate('common.recent'),
Expand All @@ -201,7 +215,7 @@ function getCategoryListSections({
});
}

const data = getCategoryOptionTree(filteredCategories, false, selectedOptionsWithDisabledState);
const data = getCategoryOptionTree(filteredCategories, selectedOptionsWithDisabledState);
categorySections.push({
// "All" section when items amount more than the threshold
title: translate('common.all'),
Expand Down Expand Up @@ -239,7 +253,7 @@ function sortCategories(categories: Record<string, Category>, localeCompare: Loc
* }
*/
for (const category of sortedCategories) {
const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR);
const path = processCategoryNameSegments(category.name);
const existedValue = lodashGet(hierarchy, path, {}) as Hierarchy;
lodashSet(hierarchy, path, {
...existedValue,
Expand Down
45 changes: 45 additions & 0 deletions src/libs/CategoryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,49 @@ function getDecodedCategoryName(categoryName: string) {
return Str.htmlDecode(categoryName);
}

/**
* Splits a category name on the colon separator, removes empty middle segments,
* and merges a trailing empty segment into the previous part (preserving trailing colons).
*
* Examples:
* "Food: Meat" → ["Food", "Meat"]
* "A: B:" → ["A", "B:"]
* "A: B: :" → ["A", "B:"]
* "A: B::" → ["A", "B:"]
* "A: B:::" → ["A", "B:"]
* ":D" → ["D"]
* "Plain" → ["Plain"]
*/
function processCategoryNameSegments(categoryName: string): string[] {
const parts = categoryName.split(CONST.PARENT_CHILD_SEPARATOR);
const result: string[] = [];

// Keep only parts that contain at least one non‑whitespace character.
for (let i = 0; i < parts.length; i++) {
const part = parts.at(i);
if (part === undefined) {
continue;
}
if (part.trim() !== '') {
result.push(part);
}
}

// If the original name ends with a colon (allowing trailing spaces), append a colon to the last segment.
const endsWithColon = categoryName.trim().endsWith(CONST.PARENT_CHILD_SEPARATOR);
if (endsWithColon && result.length > 0) {
result[result.length - 1] = result.at(result.length - 1) + CONST.PARENT_CHILD_SEPARATOR;
}

return result;
}

function getDecodedLeafCategoryName(categoryName: string): string {
const segments = processCategoryNameSegments(categoryName);
const leaf = segments.at(segments.length - 1) ?? '';
return Str.htmlDecode(leaf.trim());
}

function getAvailableNonPersonalPolicyCategories(policyCategories: OnyxCollection<PolicyCategories>, personalPolicyID: string | undefined) {
return Object.fromEntries(
Object.entries(policyCategories ?? {}).filter(([key, categories]) => {
Expand All @@ -171,5 +214,7 @@ export {
isCategoryMissing,
isCategoryDescriptionRequired,
getDecodedCategoryName,
getDecodedLeafCategoryName,
processCategoryNameSegments,
getAvailableNonPersonalPolicyCategories,
};
Loading
Loading