From f902a4a2b05b1232a53dc6f785c4bde7e092412a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:42:16 +0530 Subject: [PATCH 1/6] Add None option for default custom unit category --- src/components/CategoryPicker/index.tsx | 29 +++++++++++++++++-- .../index.tsx | 5 +++- .../DynamicDefaultCategorySelectorPage.tsx | 9 ++++-- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 737f722ce394..5f9bb4a4adc8 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -25,6 +25,7 @@ type CategoryPickerProps = { policyID: string | undefined; selectedCategory?: string; onSubmit: (item: ListItem) => void; + shouldShowNoneOption?: boolean; /** * If enabled, the content will have a bottom padding equal to account for the safe bottom area inset. @@ -46,7 +47,7 @@ const getSelectedOptions = (selectedCategory?: string): Category[] => { ]; }; -function CategoryPicker({selectedCategory, policyID, onSubmit, addBottomSafeAreaPadding = false}: CategoryPickerProps) { +function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOption = false, addBottomSafeAreaPadding = false}: CategoryPickerProps) { const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -71,7 +72,29 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, addBottomSafeArea translate, }); - const categoryData = sections?.at(0)?.data ?? []; + const shouldShowNoneOptionForSearch = shouldShowNoneOption && translate('common.none').toLowerCase().includes(debouncedSearchValue.toLowerCase()); + const noneOption: ListItem[] = shouldShowNoneOptionForSearch + ? [ + { + text: translate('common.none'), + keyForList: CONST.SEARCH.NONE_OPTION_KEY, + searchText: '', + isSelected: !selectedCategory, + }, + ] + : []; + const sectionsWithNoneOption = shouldShowNoneOptionForSearch + ? [ + { + title: '', + data: noneOption, + sectionIndex: -1, + }, + ...sections, + ] + : sections; + + const categoryData = sectionsWithNoneOption.flatMap((section) => section.data); const categoriesCount = getEnabledCategoriesCount(categories); const selectedOptionKey = categoryData.find((category) => category.searchText === selectedCategory)?.keyForList; @@ -87,7 +110,7 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, addBottomSafeArea return ( = CONST.STANDARD_LIST_ITEM_LIMIT} diff --git a/src/components/CustomUnitDefaultCategorySelector/index.tsx b/src/components/CustomUnitDefaultCategorySelector/index.tsx index 45b1e3c8ec5a..3ce21b18d9ee 100644 --- a/src/components/CustomUnitDefaultCategorySelector/index.tsx +++ b/src/components/CustomUnitDefaultCategorySelector/index.tsx @@ -1,5 +1,6 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; @@ -35,8 +36,10 @@ type CustomUnitDefaultCategorySelectorProps = { function CustomUnitDefaultCategorySelector({defaultValue = '', wrapperStyle, label, focused, customUnitID, interactive = true}: CustomUnitDefaultCategorySelectorProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); const decodedCategoryName = getDecodedCategoryName(defaultValue); + const title = decodedCategoryName || translate('common.none'); const descStyle = decodedCategoryName.length === 0 ? styles.textNormal : null; const onPress = () => { @@ -46,7 +49,7 @@ function CustomUnitDefaultCategorySelector({defaultValue = '', wrapperStyle, lab return ( { - if (!selectedCategory.searchText) { + const isNoneSelected = selectedCategory.keyForList === CONST.SEARCH.NONE_OPTION_KEY; + if (!isNoneSelected && !selectedCategory.searchText) { return; } - if (currentCategory === selectedCategory.searchText) { + const newCategory = isNoneSelected ? '' : (selectedCategory.searchText ?? ''); + if (currentCategory === newCategory) { Navigation.goBack(backPath); return; } - setPolicyCustomUnitDefaultCategory(policyID, customUnitID, currentCategory, selectedCategory.searchText); + setPolicyCustomUnitDefaultCategory(policyID, customUnitID, currentCategory, newCategory); Navigation.goBack(backPath); }; @@ -67,6 +69,7 @@ function DynamicDefaultCategorySelectorPage({route}: DynamicDefaultCategorySelec policyID={policyID} selectedCategory={currentCategory} onSubmit={onCategorySelected} + shouldShowNoneOption addBottomSafeAreaPadding /> From c211baceb2c5b71dc49c4c7d1f397b71d8a0e49f Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:51:38 +0530 Subject: [PATCH 2/6] Fix None category option typing --- src/components/CategoryPicker/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 5f9bb4a4adc8..16854b53b4dd 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -14,6 +14,7 @@ import {getCategoryListSections} from '@libs/CategoryOptionListUtils'; import type {Category} from '@libs/CategoryOptionListUtils'; import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; +import type {OptionTree} from '@libs/OptionsListUtils/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -73,12 +74,14 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt }); const shouldShowNoneOptionForSearch = shouldShowNoneOption && translate('common.none').toLowerCase().includes(debouncedSearchValue.toLowerCase()); - const noneOption: ListItem[] = shouldShowNoneOptionForSearch + const noneOption: OptionTree[] = shouldShowNoneOptionForSearch ? [ { text: translate('common.none'), keyForList: CONST.SEARCH.NONE_OPTION_KEY, searchText: '', + tooltipText: translate('common.none'), + isDisabled: false, isSelected: !selectedCategory, }, ] From 93797b2795c2134c29489ad44146de159d75a7cf Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:55:04 +0530 Subject: [PATCH 3/6] Use custom unit update when clearing default category --- src/libs/actions/Policy/Category.ts | 20 ++++++++++++++++--- .../DynamicDefaultCategorySelectorPage.tsx | 5 +++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index e63d7c958fbc..a962ddf1b522 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -31,7 +31,7 @@ import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import {goBackWhenEnableFeature} from '@libs/PolicyUtils'; +import {goBackWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; import {pushTransactionAutoSelectionsOnyxData, pushTransactionViolationsOnyxData} from '@libs/ReportUtils'; import {getFinishOnboardingTaskOnyxData} from '@userActions/Task'; @@ -40,7 +40,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, PolicyCategory, Report, ReportAction} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; -import type {ApprovalRule, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; +import type {ApprovalRule, CustomUnit, ExpenseRule, MccGroup} from '@src/types/onyx/Policy'; import type {PolicyCategoryExpenseLimitType} from '@src/types/onyx/PolicyCategory'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -1454,7 +1454,7 @@ function enablePolicyCategories(policyData: PolicyData, enabled: boolean, should } } -function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: string, oldCategory: string | undefined, category: string) { +function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: string, oldCategory: string | undefined, category: string, customUnit?: CustomUnit) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1504,6 +1504,20 @@ function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: stri }, ]; + if (category === '' && customUnit) { + const updatedCustomUnit = removePendingFieldsFromCustomUnit({ + ...customUnit, + defaultCategory: category, + }); + const params = { + policyID, + customUnit: JSON.stringify(updatedCustomUnit), + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, params, {optimisticData, successData, failureData}); + return; + } + const params = { policyID, customUnitID, diff --git a/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx b/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx index 32555761a4db..a16e23fd330b 100644 --- a/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx +++ b/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx @@ -31,7 +31,8 @@ function DynamicDefaultCategorySelectorPage({route}: DynamicDefaultCategorySelec const styles = useThemeStyles(); const {translate} = useLocalize(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`); - const currentCategory = policy?.customUnits?.[customUnitID]?.defaultCategory ?? ''; + const customUnit = policy?.customUnits?.[customUnitID]; + const currentCategory = customUnit?.defaultCategory ?? ''; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.DEFAULT_CATEGORY_SELECTOR.path); const onCategorySelected = (selectedCategory: ListItem) => { @@ -44,7 +45,7 @@ function DynamicDefaultCategorySelectorPage({route}: DynamicDefaultCategorySelec Navigation.goBack(backPath); return; } - setPolicyCustomUnitDefaultCategory(policyID, customUnitID, currentCategory, newCategory); + setPolicyCustomUnitDefaultCategory(policyID, customUnitID, currentCategory, newCategory, customUnit); Navigation.goBack(backPath); }; From edce406ce798f71ad6612f0db4c209c864e16c6a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:00:14 +0530 Subject: [PATCH 4/6] Show None option next to selected category --- src/components/CategoryPicker/index.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 16854b53b4dd..86c746d27e38 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -86,15 +86,14 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt }, ] : []; + const noneOptionSection = { + title: '', + data: noneOption, + sectionIndex: -1, + }; + const selectedCategorySectionIndex = sections.findIndex((section) => section.data.some((category) => category.searchText === selectedCategory)); const sectionsWithNoneOption = shouldShowNoneOptionForSearch - ? [ - { - title: '', - data: noneOption, - sectionIndex: -1, - }, - ...sections, - ] + ? [...sections.slice(0, selectedCategorySectionIndex + 1), noneOptionSection, ...sections.slice(selectedCategorySectionIndex + 1)] : sections; const categoryData = sectionsWithNoneOption.flatMap((section) => section.data); From d4a5f09a8560c2cad869fe1701f1b5d18cdd34b7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:56:09 +0530 Subject: [PATCH 5/6] Share None option picker logic --- src/components/CategoryPicker/index.tsx | 26 +++++++++---------- .../Search/SearchSingleSelectionPicker.tsx | 14 ++-------- src/libs/OptionsListUtils/index.ts | 17 ++++++++++++ 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/components/CategoryPicker/index.tsx b/src/components/CategoryPicker/index.tsx index 86c746d27e38..16f811681165 100644 --- a/src/components/CategoryPicker/index.tsx +++ b/src/components/CategoryPicker/index.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getCategoryListSections} from '@libs/CategoryOptionListUtils'; import type {Category} from '@libs/CategoryOptionListUtils'; import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; -import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; +import {getHeaderMessageForNonUserList, getNoneOption} from '@libs/OptionsListUtils'; import type {OptionTree} from '@libs/OptionsListUtils/types'; import CONST from '@src/CONST'; @@ -73,18 +73,17 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt translate, }); - const shouldShowNoneOptionForSearch = shouldShowNoneOption && translate('common.none').toLowerCase().includes(debouncedSearchValue.toLowerCase()); - const noneOption: OptionTree[] = shouldShowNoneOptionForSearch - ? [ - { - text: translate('common.none'), - keyForList: CONST.SEARCH.NONE_OPTION_KEY, + const noneOption: OptionTree[] = shouldShowNoneOption + ? getNoneOption(debouncedSearchValue, !selectedCategory, translate).map( + (option): OptionTree => ({ + text: option.text, + keyForList: option.keyForList, searchText: '', - tooltipText: translate('common.none'), + tooltipText: option.text, isDisabled: false, - isSelected: !selectedCategory, - }, - ] + isSelected: option.isSelected, + }), + ) : []; const noneOptionSection = { title: '', @@ -92,9 +91,8 @@ function CategoryPicker({selectedCategory, policyID, onSubmit, shouldShowNoneOpt sectionIndex: -1, }; const selectedCategorySectionIndex = sections.findIndex((section) => section.data.some((category) => category.searchText === selectedCategory)); - const sectionsWithNoneOption = shouldShowNoneOptionForSearch - ? [...sections.slice(0, selectedCategorySectionIndex + 1), noneOptionSection, ...sections.slice(selectedCategorySectionIndex + 1)] - : sections; + const sectionsWithNoneOption = + noneOption.length > 0 ? [...sections.slice(0, selectedCategorySectionIndex + 1), noneOptionSection, ...sections.slice(selectedCategorySectionIndex + 1)] : sections; const categoryData = sectionsWithNoneOption.flatMap((section) => section.data); const categoriesCount = getEnabledCategoriesCount(categories); diff --git a/src/components/Search/SearchSingleSelectionPicker.tsx b/src/components/Search/SearchSingleSelectionPicker.tsx index 25278ad8394d..3058001be328 100644 --- a/src/components/Search/SearchSingleSelectionPicker.tsx +++ b/src/components/Search/SearchSingleSelectionPicker.tsx @@ -5,10 +5,10 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; +import {getNoneOption} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; -import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; @@ -53,17 +53,7 @@ function SearchSingleSelectionPicker({ }, [initiallySelectedItem]); const searchLower = debouncedSearchTerm?.toLowerCase(); - const noneItem = - allowNoneOption && translate('common.none').toLowerCase().includes(searchLower) - ? [ - { - text: translate('common.none'), - keyForList: CONST.SEARCH.NONE_OPTION_KEY, - isSelected: !selectedItem?.value, - value: '', - }, - ] - : []; + const noneItem = allowNoneOption ? getNoneOption(debouncedSearchTerm, !selectedItem?.value, translate) : []; const initiallySelectedItemSection = initiallySelectedItem?.name.toLowerCase().includes(searchLower) || initiallySelectedItem?.searchableText?.toLowerCase().includes(searchLower) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 95204ae05c6d..d5aa342aec08 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2979,6 +2979,22 @@ function getHeaderMessageForNonUserList(hasSelectableOptions: boolean, searchVal return ''; } +function getNoneOption(searchValue: string, isSelected: boolean, translate: LocalizedTranslate) { + const noneText = translate('common.none'); + if (!noneText.toLowerCase().includes(searchValue.toLowerCase())) { + return []; + } + + return [ + { + text: noneText, + keyForList: CONST.SEARCH.NONE_OPTION_KEY, + isSelected, + value: '', + }, + ]; +} + /** * Helper method to check whether an option can show tooltip or not */ @@ -3397,6 +3413,7 @@ export { getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, + getNoneOption, getParticipantsOption, getPersonalDetailsForAccountIDs, getPolicyExpenseReportOption, From 5932e91e0b496b408cec2fc1a28958822a23b5bc Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:25:15 +0530 Subject: [PATCH 6/6] Avoid multiple API writes in category update --- src/libs/actions/Policy/Category.ts | 38 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index a962ddf1b522..31d9dbe58337 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -1504,27 +1504,25 @@ function setPolicyCustomUnitDefaultCategory(policyID: string, customUnitID: stri }, ]; - if (category === '' && customUnit) { - const updatedCustomUnit = removePendingFieldsFromCustomUnit({ - ...customUnit, - defaultCategory: category, - }); - const params = { - policyID, - customUnit: JSON.stringify(updatedCustomUnit), - }; - - API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT, params, {optimisticData, successData, failureData}); - return; - } - - const params = { - policyID, - customUnitID, - category, - }; + const shouldUpdateCustomUnit = category === '' && customUnit !== undefined; + const command = shouldUpdateCustomUnit ? WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT : WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY; + const params = shouldUpdateCustomUnit + ? { + policyID, + customUnit: JSON.stringify( + removePendingFieldsFromCustomUnit({ + ...customUnit, + defaultCategory: category, + }), + ), + } + : { + policyID, + customUnitID, + category, + }; - API.write(WRITE_COMMANDS.SET_CUSTOM_UNIT_DEFAULT_CATEGORY, params, { + API.write(command, params, { optimisticData, successData, failureData,