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
6 changes: 5 additions & 1 deletion src/components/CategoryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ type CategoryPickerProps = CategoryPickerOnyxProps & {
policyID: string;
selectedCategory?: string;
onSubmit: (item: ListItem) => void;

/** Whether SectionList should use custom ScrollView */
shouldUseCustomScrollView?: boolean;
};

function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedCategories, policyCategoriesDraft, onSubmit}: CategoryPickerProps) {
function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedCategories, policyCategoriesDraft, onSubmit, shouldUseCustomScrollView = false}: CategoryPickerProps) {
const {translate} = useLocalize();
const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');

Expand Down Expand Up @@ -84,6 +87,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
ListItem={RadioListItem}
initiallyFocusedOptionKey={selectedOptionKey ?? undefined}
isRowMultilineSupported
shouldUseCustomScrollView={shouldUseCustomScrollView}
/>
);
}
Expand Down
8 changes: 7 additions & 1 deletion src/components/SelectionList/BaseSelectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native';
import isEmpty from 'lodash/isEmpty';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, SectionListData, SectionListRenderItemInfo} from 'react-native';
import type {LayoutChangeEvent, SectionList as RNSectionList, TextInput as RNTextInput, ScrollViewProps, SectionListData, SectionListRenderItemInfo} from 'react-native';
import {View} from 'react-native';
import Button from '@components/Button';
import Checkbox from '@components/Checkbox';
import FixedFooter from '@components/FixedFooter';
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
import {PressableWithFeedback} from '@components/Pressable';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import ScrollView from '@components/ScrollView';
import SectionList from '@components/SectionList';
import ShowMoreButton from '@components/ShowMoreButton';
import Text from '@components/Text';
Expand Down Expand Up @@ -73,6 +74,7 @@ function BaseSelectionList<TItem extends ListItem>(
shouldStopPropagation = false,
shouldShowTooltips = true,
shouldUseDynamicMaxToRenderPerBatch = false,
shouldUseCustomScrollView = false,
rightHandSideComponent,
isLoadingNewOptions = false,
onLayout,
Expand Down Expand Up @@ -423,6 +425,9 @@ function BaseSelectionList<TItem extends ListItem>(
</>
);

// eslint-disable-next-line react/jsx-props-no-spreading
const scrollComponent = shouldUseCustomScrollView ? (props: ScrollViewProps) => <ScrollView {...props} /> : undefined;

const renderItem = ({item, index, section}: SectionListRenderItemInfo<TItem, SectionWithIndexOffset<TItem>>) => {
const normalizedIndex = index + (section?.indexOffset ?? 0);
const isDisabled = !!section.isDisabled || item.isDisabled;
Expand Down Expand Up @@ -701,6 +706,7 @@ function BaseSelectionList<TItem extends ListItem>(
{!listHeaderContent && header()}
<SectionList
removeClippedSubviews={removeClippedSubviews}
renderScrollComponent={scrollComponent}
ref={listRef}
sections={slicedSections}
stickySectionHeadersEnabled={false}
Expand Down
12 changes: 12 additions & 0 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ type ListItem = {
/** ID of the report */
reportID?: string;

/** ID of the policy */
policyID?: string;

/** ID of the group */
groupID?: string;

/** ID of the category */
categoryID?: string;

/** Whether this option should show subscript */
shouldShowSubscript?: boolean | null;

Expand Down Expand Up @@ -416,6 +425,9 @@ type BaseSelectionListProps<TItem extends ListItem> = Partial<ChildrenProps> & {
/** Whether to use dynamic maxToRenderPerBatch depending on the visible number of elements */
shouldUseDynamicMaxToRenderPerBatch?: boolean;

/** Whether SectionList should use custom ScrollView */
shouldUseCustomScrollView?: boolean;

/** Whether keyboard shortcuts should be disabled */
disableKeyboardShortcuts?: boolean;

Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2823,6 +2823,8 @@ export default {
disableCategory: 'Disable category',
enableCategories: 'Enable categories',
enableCategory: 'Enable category',
defaultSpendCategories: 'Default spend categories',
spendCategoriesDescription: 'Customize how merchant spend is categorized for credit card transactions and scanned receipts.',
deleteFailureMessage: 'An error occurred while deleting the category, please try again.',
categoryName: 'Category name',
requiresCategory: 'Members must categorize all expenses',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2871,6 +2871,8 @@ export default {
disableCategory: 'Desactivar categoría',
enableCategories: 'Activar categorías',
enableCategory: 'Activar categoría',
defaultSpendCategories: 'Categorías de gasto predeterminadas',
spendCategoriesDescription: 'Personaliza cómo se categorizan los gastos de los comerciantes para las transacciones con tarjeta de crédito y los recibos escaneados.',
Comment thread
BartoszGrajdek marked this conversation as resolved.
deleteFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.',
categoryName: 'Nombre de la categoría',
requiresCategory: 'Los miembros deben clasificar todos los gastos',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
type SetWorkspaceDefaultSpendCategoryParams = {
policyID: string;
groupID: string;
category: string;
};

export default SetWorkspaceDefaultSpendCategoryParams;
1 change: 1 addition & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './Se
export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams';
export type {default as SetWorkspacePayerParams} from './SetWorkspacePayerParams';
export type {default as SetWorkspaceReimbursementParams} from './SetWorkspaceReimbursementParams';
export type {default as SetWorkspaceDefaultSpendCategoryParams} from './SetWorkspaceDefaultSpendCategoryParams';
export type {default as SetPolicyRequiresTag} from './SetPolicyRequiresTag';
export type {default as SetPolicyTagsRequired} from './SetPolicyTagsRequired';
export type {default as RenamePolicyTaglistParams} from './RenamePolicyTaglistParams';
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const WRITE_COMMANDS = {
SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode',
SET_WORKSPACE_PAYER: 'SetWorkspacePayer',
SET_WORKSPACE_REIMBURSEMENT: 'SetWorkspaceReimbursement',
SET_WORKSPACE_DEFAULT_SPEND_CATEGORY: 'SetPolicyDefaultSpendCategory',
DISMISS_REFERRAL_BANNER: 'DismissReferralBanner',
UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale',
OPEN_APP: 'OpenApp',
Expand Down Expand Up @@ -540,6 +541,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams;
[WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams;
[WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams;
[WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY]: Parameters.SetWorkspaceDefaultSpendCategoryParams;
[WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams;
[WRITE_COMMANDS.TRACK_EXPENSE]: Parameters.TrackExpenseParams;
[WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES]: Parameters.EnablePolicyCategoriesParams;
Expand Down
41 changes: 41 additions & 0 deletions src/libs/actions/Policy/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3454,6 +3454,46 @@ function upgradeToCorporate(policyID: string, featureName: string) {
API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData});
}

function setWorkspaceDefaultSpendCategory(policyID: string, groupID: string, category: string) {
Comment thread
BartoszGrajdek marked this conversation as resolved.
const policy = getPolicy(policyID);
if (!policy) {
return;
}

const {mccGroup} = policy;

const optimisticData: OnyxUpdate[] = mccGroup
? [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `policy_${policyID}`,
value: {
mccGroup: {
...mccGroup,
[groupID]: {
category,
groupID,
},
},
},
},
]
: [];

const failureData: OnyxUpdate[] = mccGroup
? [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `policy_${policyID}`,
value: {
mccGroup,
},
},
]
: [];

API.write(WRITE_COMMANDS.SET_WORKSPACE_DEFAULT_SPEND_CATEGORY, {policyID, groupID, category}, {optimisticData, successData: [], failureData});
}
/**
* Call the API to set the receipt required amount for the given policy
* @param policyID - id of the policy to set the receipt required amount
Expand Down Expand Up @@ -3841,6 +3881,7 @@ export {
openWorkspaceReimburseView,
setPolicyIDForReimburseView,
clearOnyxDataForReimburseView,
setWorkspaceDefaultSpendCategory,
setRateForReimburseView,
setUnitForReimburseView,
generateDefaultWorkspaceName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, {useState} from 'react';
import BaseListItem from '@components/SelectionList/BaseListItem';
import type {BaseListItemProps, ListItem} from '@components/SelectionList/types';
import useThemeStyles from '@hooks/useThemeStyles';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import CategorySelector from '@pages/workspace/distanceRates/CategorySelector';
import * as Policy from '@userActions/Policy/Policy';

function SpendCategorySelectorListItem<TItem extends ListItem>({item, onSelectRow, isFocused}: BaseListItemProps<TItem>) {
const styles = useThemeStyles();
const [isCategoryPickerVisible, setIsCategoryPickerVisible] = useState(false);
const {policyID, groupID, categoryID} = item;

if (!policyID || !groupID) {
return;
}

const onSelect = (data: TItem) => {
setIsCategoryPickerVisible(true);
onSelectRow(data);
};

const setNewCategory = (selectedCategory: ListItem) => {
if (!selectedCategory.text) {
return;
}
Policy.setWorkspaceDefaultSpendCategory(policyID, groupID, selectedCategory.text);
};
Comment on lines +23 to +28

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When setting a new category we had to check that it's not the current selected one i.e. newCategory != currentCategory. This is because we have a case where if you go offline, delete the current category and select it again the following API calls will be queued:

  1. Delete category
  2. Set spend category

And in the second API call we will be setting the spend category to a category that no longer exists which will causes an error.

(Comnig from #49295)


return (
<BaseListItem
item={item}
wrapperStyle={[isFocused && styles.sidebarLinkActive]}
pressableStyle={[styles.mt2]}
onSelectRow={onSelect}
isFocused={isFocused}
showTooltip
keyForList={item.keyForList}
>
<CategorySelector
wrapperStyle={[styles.ph5]}
focused={isFocused}
policyID={policyID}
label={groupID[0].toUpperCase() + groupID.slice(1)}
defaultValue={categoryID}
setNewCategory={setNewCategory}
isPickerVisible={isCategoryPickerVisible}
showPickerModal={() => setIsCategoryPickerVisible(true)}
hidePickerModal={() => {
setIsCategoryPickerVisible(false);
blurActiveElement();
}}
shouldUseCustomScrollView
/>
</BaseListItem>
);
}

SpendCategorySelectorListItem.displayName = 'SpendCategorySelectorListItem';

export default SpendCategorySelectorListItem;
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import React from 'react';
import React, {useMemo} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import type {ListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as OptionsListUtils from '@libs/OptionsListUtils';
Expand All @@ -15,6 +18,7 @@ import {setWorkspaceRequiresCategory} from '@userActions/Policy/Category';
import * as Policy from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import SpendCategorySelectorListItem from './SpendCategorySelectorListItem';

type WorkspaceCategoriesSettingsPageProps = WithPolicyConnectionsProps;

Expand All @@ -24,6 +28,7 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet
const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0;
const policyID = route.params.policyID ?? '-1';
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
const [currentPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy);

const toggleSubtitle = isConnectedToAccounting && currentConnectionName ? `${translate('workspace.categories.needCategoryForExportToIntegration')} ${currentConnectionName}.` : undefined;
Expand All @@ -32,7 +37,32 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet
setWorkspaceRequiresCategory(policyID, value);
};

const {sections} = useMemo(() => {
if (!(currentPolicy && currentPolicy.mccGroup)) {
return {sections: [{data: []}]};
}

return {
sections: [
{
data: Object.entries(currentPolicy.mccGroup).map(
([mccKey, mccGroup]) =>
({
categoryID: mccGroup.category,
keyForList: mccKey,
groupID: mccKey,
policyID,
tabIndex: -1,
} as ListItem),
),
},
],
};
}, [currentPolicy, policyID]);

const hasEnabledOptions = OptionsListUtils.hasEnabledOptions(policyCategories ?? {});
const isToggleDisabled = !policy?.areCategoriesEnabled || !hasEnabledOptions || isConnectedToAccounting;

return (
<AccessOrNotFoundWrapper
policyID={policyID}
Expand All @@ -45,20 +75,33 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet
testID={WorkspaceCategoriesSettingsPage.displayName}
>
<HeaderWithBackButton title={translate('common.settings')} />
<View style={styles.flexGrow1}>
<ToggleSettingOptionRow
title={translate('workspace.categories.requiresCategory')}
subtitle={toggleSubtitle}
switchAccessibilityLabel={translate('workspace.categories.requiresCategory')}
isActive={policy?.requiresCategory ?? false}
onToggle={updateWorkspaceRequiresCategory}
pendingAction={policy?.pendingFields?.requiresCategory}
disabled={!policy?.areCategoriesEnabled || !hasEnabledOptions || isConnectedToAccounting}
wrapperStyle={[styles.mt2, styles.mh4]}
errors={policy?.errorFields?.requiresCategory ?? undefined}
onCloseError={() => Policy.clearPolicyErrorField(policy?.id ?? '-1', 'requiresCategory')}
shouldPlaceSubtitleBelowSwitch
/>
<ToggleSettingOptionRow
title={translate('workspace.categories.requiresCategory')}
subtitle={toggleSubtitle}
switchAccessibilityLabel={translate('workspace.categories.requiresCategory')}
isActive={policy?.requiresCategory ?? false}
Comment thread
BartoszGrajdek marked this conversation as resolved.
onToggle={updateWorkspaceRequiresCategory}
pendingAction={policy?.pendingFields?.requiresCategory}
disabled={isToggleDisabled}
wrapperStyle={[styles.pv2, styles.mh5]}
errors={policy?.errorFields?.requiresCategory ?? undefined}
onCloseError={() => Policy.clearPolicyErrorField(policy?.id ?? '-1', 'requiresCategory')}
shouldPlaceSubtitleBelowSwitch
/>
<View style={[styles.containerWithSpaceBetween]}>
{!!currentPolicy && sections[0].data.length > 0 && (
<SelectionList
headerContent={
<View style={[styles.mh5, styles.mt2, styles.mb1]}>
<Text style={[styles.headerText]}>{translate('workspace.categories.defaultSpendCategories')}</Text>
<Text style={[styles.mt1, styles.lh20]}>{translate('workspace.categories.spendCategoriesDescription')}</Text>
Comment thread
BartoszGrajdek marked this conversation as resolved.
</View>
}
sections={sections}
ListItem={SpendCategorySelectorListItem}
onSelectRow={() => {}}
Comment thread
BartoszGrajdek marked this conversation as resolved.
/>
)}
</View>
</ScreenWrapper>
</AccessOrNotFoundWrapper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ type CategorySelectorModalProps = {

/** Label to display on field */
label: string;

/** Whether SectionList should use custom ScrollView */
shouldUseCustomScrollView: boolean;
};

function CategorySelectorModal({policyID, isVisible, currentCategory, onCategorySelected, onClose, label}: CategorySelectorModalProps) {
function CategorySelectorModal({policyID, isVisible, currentCategory, onCategorySelected, onClose, label, shouldUseCustomScrollView}: CategorySelectorModalProps) {
const styles = useThemeStyles();

return (
Expand All @@ -54,6 +57,7 @@ function CategorySelectorModal({policyID, isVisible, currentCategory, onCategory
policyID={policyID}
selectedCategory={currentCategory}
onSubmit={onCategorySelected}
shouldUseCustomScrollView={shouldUseCustomScrollView}
/>
</ScreenWrapper>
</Modal>
Expand Down
Loading