@@ -8,7 +8,7 @@ import type {PolicyCategories} from '@src/types/onyx';
88import type * as OnyxCommon from '@src/types/onyx/OnyxCommon' ;
99import { isEmptyObject } from '@src/types/utils/EmptyObject' ;
1010import times from '@src/utils/times' ;
11- import { getDecodedCategoryName } from './CategoryUtils' ;
11+ import { getDecodedCategoryName , processCategoryNameSegments } from './CategoryUtils' ;
1212import type { OptionTree } from './OptionsListUtils' ;
1313import 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 ,
0 commit comments