diff --git a/src/CONST.ts b/src/CONST.ts index 5bb2b06360d7..62b0dc4af07c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -240,6 +240,7 @@ const CONST = { TASKS: 'tasks', THREADS: 'threads', CUSTOM_STATUS: 'customStatus', + NEW_DOT_CATEGORIES: 'newDotCategories', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a2809c439931..ddc2aa4de620 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -375,7 +375,7 @@ type OnyxValues = { // Collections [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; - [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: unknown; + [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategory; [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMember; diff --git a/src/components/CategoryPicker/categoryPickerPropTypes.js b/src/components/CategoryPicker/categoryPickerPropTypes.js index ccc1643021ce..b8e24c199a73 100644 --- a/src/components/CategoryPicker/categoryPickerPropTypes.js +++ b/src/components/CategoryPicker/categoryPickerPropTypes.js @@ -14,11 +14,23 @@ const propTypes = { /* Onyx Props */ /** Collection of categories attached to a policy */ policyCategories: PropTypes.objectOf(categoryPropTypes), + + /* Onyx Props */ + /** Collection of recently used categories attached to a policy */ + policyRecentlyUsedCategories: PropTypes.arrayOf(PropTypes.string), + + /* Onyx Props */ + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: PropTypes.shape({ + category: PropTypes.string.isRequired, + }), }; const defaultProps = { policyID: '', - policyCategories: null, + policyCategories: {}, + policyRecentlyUsedCategories: [], + iou: {}, }; export {propTypes, defaultProps}; diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index 163ab6673ca2..91c7e82e7887 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -1,47 +1,88 @@ -import React, {useMemo} from 'react'; -import _ from 'underscore'; +import React, {useMemo, useState} from 'react'; import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; import ONYXKEYS from '../../ONYXKEYS'; import {propTypes, defaultProps} from './categoryPickerPropTypes'; -import OptionsList from '../OptionsList'; import styles from '../../styles/styles'; -import ScreenWrapper from '../ScreenWrapper'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; +import CONST from '../../CONST'; +import * as IOU from '../../libs/actions/IOU'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import OptionsSelector from '../OptionsSelector'; +import useLocalize from '../../hooks/useLocalize'; + +function CategoryPicker({policyCategories, reportID, iouType, iou, policyRecentlyUsedCategories}) { + const {translate} = useLocalize(); + const [searchValue, setSearchValue] = useState(''); + + const policyCategoriesCount = _.size(policyCategories); + const isCategoriesCountBelowThreshold = policyCategoriesCount < CONST.CATEGORY_LIST_THRESHOLD; -function CategoryPicker({policyCategories, reportID, iouType}) { - const sections = useMemo(() => { - const categoryList = _.chain(policyCategories) - .values() - .map((category) => ({ - text: category.name, - keyForList: category.name, - tooltipText: category.name, - })) - .value(); + const selectedOptions = useMemo(() => { + if (!iou.category) { + return []; + } return [ { - data: categoryList, + name: iou.category, + enabled: true, + accountID: null, }, ]; - }, [policyCategories]); + }, [iou.category]); + + const initialFocusedIndex = useMemo(() => { + if (isCategoriesCountBelowThreshold && selectedOptions.length > 0) { + return _.chain(policyCategories) + .values() + .findIndex((category) => category.name === selectedOptions[0].name, true) + .value(); + } + + return 0; + }, [policyCategories, selectedOptions, isCategoriesCountBelowThreshold]); + + const sections = useMemo( + () => OptionsListUtils.getNewChatOptions({}, {}, [], searchValue, selectedOptions, [], false, false, true, policyCategories, policyRecentlyUsedCategories, false).categoryOptions, + [policyCategories, policyRecentlyUsedCategories, searchValue, selectedOptions], + ); + + const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, searchValue); + const shouldShowTextInput = !isCategoriesCountBelowThreshold; const navigateBack = () => { Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID)); }; + const updateCategory = (category) => { + if (category.searchText === iou.category) { + IOU.resetMoneyRequestCategory(); + } else { + IOU.setMoneyRequestCategory(category.searchText); + } + + navigateBack(); + }; + return ( - - {({safeAreaPaddingBottomStyle}) => ( - - )} - + ); } @@ -53,4 +94,10 @@ export default withOnyx({ policyCategories: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, }, + policyRecentlyUsedCategories: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`, + }, + iou: { + key: ONYXKEYS.IOU, + }, })(CategoryPicker); diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index a7695c939907..87dc0e6795b4 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -33,6 +33,7 @@ import ConfirmedRoute from './ConfirmedRoute'; import transactionPropTypes from './transactionPropTypes'; import DistanceRequestUtils from '../libs/DistanceRequestUtils'; import * as IOU from '../libs/actions/IOU'; +import Permissions from '../libs/Permissions'; const propTypes = { /** Callback to inform parent modal of success */ @@ -90,6 +91,9 @@ const propTypes = { email: PropTypes.string.isRequired, }), + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + /** The policyID of the request */ policyID: PropTypes.string, @@ -144,6 +148,7 @@ const defaultProps = { session: { email: null, }, + betas: [], policyID: '', reportID: '', ...withCurrentUserPersonalDetailsDefaultProps, @@ -171,7 +176,7 @@ function MoneyRequestConfirmationList(props) { const {unit, rate, currency} = props.mileageRate; const distance = lodashGet(transaction, 'routes.route0.distance', 0); const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0; - const shouldCategoryEditable = !_.isEmpty(props.policyCategories) && !props.isDistanceRequest; + const shouldCategoryBeEditable = !_.isEmpty(props.policyCategories) && Permissions.canUseCategories(props.betas); const formattedAmount = CurrencyUtils.convertToDisplayString( shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount, @@ -484,7 +489,7 @@ function MoneyRequestConfirmationList(props) { disabled={didConfirm || props.isReadOnly || !isTypeRequest} /> )} - {shouldCategoryEditable && ( + {shouldCategoryBeEditable && ( `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, }, diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index adaa4457bbd9..50aff23dc9d0 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -39,6 +39,9 @@ const propTypes = { /** Whether we should show the selected state */ showSelectedState: PropTypes.bool, + /** Whether we highlight selected option */ + highlightSelected: PropTypes.bool, + /** Whether this item is selected */ isSelected: PropTypes.bool, @@ -57,6 +60,9 @@ const propTypes = { /** Whether to remove the lateral padding and align the content with the margins */ shouldDisableRowInnerPadding: PropTypes.bool, + /** Whether to wrap large text up to 2 lines */ + isMultilineSupported: PropTypes.bool, + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), ...withLocalizePropTypes, @@ -65,12 +71,14 @@ const propTypes = { const defaultProps = { hoverStyle: styles.sidebarLinkHover, showSelectedState: false, + highlightSelected: false, isSelected: false, boldStyle: false, showTitleTooltip: false, onSelectRow: undefined, isDisabled: false, optionIsFocused: false, + isMultilineSupported: false, style: null, shouldHaveOptionSeparator: false, shouldDisableRowInnerPadding: false, @@ -89,9 +97,11 @@ class OptionRow extends Component { return ( this.state.isDisabled !== nextState.isDisabled || this.props.isDisabled !== nextProps.isDisabled || + this.props.isMultilineSupported !== nextProps.isMultilineSupported || this.props.isSelected !== nextProps.isSelected || this.props.shouldHaveOptionSeparator !== nextProps.shouldHaveOptionSeparator || this.props.showSelectedState !== nextProps.showSelectedState || + this.props.highlightSelected !== nextProps.highlightSelected || this.props.showTitleTooltip !== nextProps.showTitleTooltip || !_.isEqual(this.props.option.icons, nextProps.option.icons) || this.props.optionIsFocused !== nextProps.optionIsFocused || @@ -119,7 +129,7 @@ class OptionRow extends Component { let pressableRef = null; const textStyle = this.props.optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = this.props.boldStyle || this.props.option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, this.props.style, styles.pre); + const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, this.props.style, styles.pre, this.state.isDisabled ? styles.optionRowDisabled : {}); const alternateTextStyle = StyleUtils.combineStyles( textStyle, styles.optionAlternateText, @@ -182,6 +192,7 @@ class OptionRow extends Component { this.props.optionIsFocused ? styles.sidebarLinkActive : null, this.props.shouldHaveOptionSeparator && styles.borderTop, !this.props.onSelectRow && !this.props.isDisabled ? styles.cursorDefault : null, + this.props.isSelected && this.props.highlightSelected && styles.optionRowSelected, ]} accessibilityLabel={this.props.option.text} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} @@ -216,7 +227,7 @@ class OptionRow extends Component { fullTitle={this.props.option.text} displayNamesWithTooltips={displayNamesWithTooltips} tooltipEnabled={this.props.showTitleTooltip} - numberOfLines={1} + numberOfLines={this.props.isMultilineSupported ? 2 : 1} textStyles={displayNameStyle} shouldUseFullTitle={ this.props.option.isChatRoom || @@ -249,6 +260,14 @@ class OptionRow extends Component { )} {this.props.showSelectedState && } + {this.props.isSelected && this.props.highlightSelected && ( + + + + )} {Boolean(this.props.option.customIcon) && ( diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index 8f63cfc29959..252b015edd45 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -56,10 +56,12 @@ function BaseOptionsList({ shouldDisableRowInnerPadding, disableFocusOptions, canSelectMultipleOptions, + highlightSelectedOptions, onSelectRow, boldStyle, isDisabled, innerRef, + isRowMultilineSupported, }) { const flattenedData = useRef(); const previousSections = usePrevious(sections); @@ -175,12 +177,14 @@ function BaseOptionsList({ hoverStyle={optionHoveredStyle} optionIsFocused={!disableFocusOptions && !isItemDisabled && focusedIndex === index + section.indexOffset} onSelectRow={onSelectRow} - isSelected={Boolean(_.find(selectedOptions, (option) => option.accountID === item.accountID))} + isSelected={Boolean(_.find(selectedOptions, (option) => option.accountID === item.accountID || option.name === item.searchText))} showSelectedState={canSelectMultipleOptions} + highlightSelected={highlightSelectedOptions} boldStyle={boldStyle} isDisabled={isItemDisabled} shouldHaveOptionSeparator={index > 0 && shouldHaveOptionSeparator} shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} + isMultilineSupported={isRowMultilineSupported} /> ); }; diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 74c13c7f2455..a2479c878041 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -40,6 +40,9 @@ const propTypes = { /** Whether we can select multiple options or not */ canSelectMultipleOptions: PropTypes.bool, + /** Whether we highlight selected options */ + highlightSelectedOptions: PropTypes.bool, + /** Whether to show headers above each section or not */ hideSectionHeaders: PropTypes.bool, @@ -78,6 +81,9 @@ const propTypes = { /** Whether to show the scroll bar */ showScrollIndicator: PropTypes.bool, + + /** Whether to wrap large text up to 2 lines */ + isRowMultilineSupported: PropTypes.bool, }; const defaultProps = { @@ -88,6 +94,7 @@ const defaultProps = { focusedIndex: 0, selectedOptions: [], canSelectMultipleOptions: false, + highlightSelectedOptions: false, hideSectionHeaders: false, disableFocusOptions: false, boldStyle: false, @@ -101,6 +108,7 @@ const defaultProps = { shouldHaveOptionSeparator: false, shouldDisableRowInnerPadding: false, showScrollIndicator: false, + isRowMultilineSupported: false, }; export {propTypes, defaultProps}; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 338de90a0509..ee3840bff69d 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -139,6 +139,10 @@ class BaseOptionsSelector extends Component { * @returns {Number} */ getInitiallyFocusedIndex(allOptions) { + if (_.isNumber(this.props.initialFocusedIndex)) { + return this.props.initialFocusedIndex; + } + if (this.props.selectedOptions.length > 0) { return this.props.selectedOptions.length; } @@ -374,6 +378,7 @@ class BaseOptionsSelector extends Component { showTitleTooltip={this.props.showTitleTooltip} isDisabled={this.props.isDisabled} shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} + highlightSelectedOptions={this.props.highlightSelectedOptions} onLayout={() => { if (this.props.selectedOptions.length === 0) { this.scrollToIndex(this.state.focusedIndex, false); @@ -388,6 +393,7 @@ class BaseOptionsSelector extends Component { listStyles={this.props.listStyles} isLoading={!this.props.shouldShowOptions} showScrollIndicator={this.props.showScrollIndicator} + isRowMultilineSupported={this.props.isRowMultilineSupported} /> ); return ( diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 02b807bf66c1..dff0a16c0b3e 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -53,6 +53,9 @@ const propTypes = { /** Whether we can select multiple options */ canSelectMultipleOptions: PropTypes.bool, + /** Whether we highlight selected options */ + highlightSelectedOptions: PropTypes.bool, + /** Whether any section headers should be visible */ hideSectionHeaders: PropTypes.bool, @@ -106,6 +109,12 @@ const propTypes = { /** Whether to use default padding and flex styles for children */ shouldUseStyleForChildren: PropTypes.bool, + + /** Whether to wrap large text up to 2 lines */ + isRowMultilineSupported: PropTypes.bool, + + /** Initial focused index value */ + initialFocusedIndex: PropTypes.number, }; const defaultProps = { @@ -116,6 +125,7 @@ const defaultProps = { selectedOptions: [], headerMessage: '', canSelectMultipleOptions: false, + highlightSelectedOptions: false, hideSectionHeaders: false, boldStyle: false, showTitleTooltip: false, @@ -136,6 +146,8 @@ const defaultProps = { shouldShowTextInput: true, onChangeText: () => {}, shouldUseStyleForChildren: true, + isRowMultilineSupported: false, + initialFocusedIndex: undefined, }; export {propTypes, defaultProps}; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index d26ad48430b0..7629a1acc0a6 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -652,12 +652,8 @@ function getCategoryOptionTree(options, isOneLine = false) { /** * Build the section list for categories * - * @param {Object[]} categories - * @param {String} categories[].name - * @param {Boolean} categories[].enabled - * @param {Object[]} recentlyUsedCategories - * @param {String} recentlyUsedCategories[].name - * @param {Boolean} recentlyUsedCategories[].enabled + * @param {Object} categories + * @param {String[]} recentlyUsedCategories * @param {Object[]} selectedOptions * @param {String} selectedOptions[].name * @param {String} searchInputValue @@ -666,11 +662,12 @@ function getCategoryOptionTree(options, isOneLine = false) { */ function getCategoryListSections(categories, recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow) { const categorySections = []; - const numberOfCategories = _.size(categories); + const categoriesValues = _.values(categories); + const numberOfCategories = _.size(categoriesValues); let indexOffset = 0; if (!_.isEmpty(searchInputValue)) { - const searchCategories = _.filter(categories, (category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase())); + const searchCategories = _.filter(categoriesValues, (category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase())); categorySections.push({ // "Search" section @@ -689,15 +686,21 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt title: '', shouldShow: false, indexOffset, - data: getCategoryOptionTree(categories), + data: getCategoryOptionTree(categoriesValues), }); return categorySections; } const selectedOptionNames = _.map(selectedOptions, (selectedOption) => selectedOption.name); - const filteredRecentlyUsedCategories = _.filter(recentlyUsedCategories, (category) => !_.includes(selectedOptionNames, category.name)); - const filteredCategories = _.filter(categories, (category) => !_.includes(selectedOptionNames, category.name)); + const filteredRecentlyUsedCategories = _.map( + _.filter(recentlyUsedCategories, (category) => !_.includes(selectedOptionNames, category)), + (category) => ({ + name: category, + enabled: lodashGet(categories, `${category}.enabled`, false), + }), + ); + const filteredCategories = _.filter(categoriesValues, (category) => !_.includes(selectedOptionNames, category.name)); if (!_.isEmpty(selectedOptions)) { categorySections.push({ @@ -776,7 +779,7 @@ function getOptions( }, ) { if (includeCategories) { - const categoryOptions = getCategoryListSections(_.values(categories), recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow); + const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow); return { recentReports: [], @@ -1127,7 +1130,7 @@ function getIOUConfirmationOptionsFromParticipants(participants, amountText) { * @param {boolean} [includeP2P] * @param {boolean} [includeCategories] * @param {Object} [categories] - * @param {Array} [recentlyUsedCategories] + * @param {Array} [recentlyUsedCategories] * @param {boolean} [canInviteUser] * @returns {Object} */ diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index f37cd5bb5bf3..a916d52f5e0a 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -86,6 +86,14 @@ function canUseCustomStatus(betas) { return _.contains(betas, CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas); } +/** + * @param {Array} betas + * @returns {Boolean} + */ +function canUseCategories(betas) { + return _.contains(betas, CONST.BETAS.NEW_DOT_CATEGORIES) || canUseAllBetas(betas); +} + /** * Link previews are temporarily disabled. * @returns {Boolean} @@ -104,5 +112,6 @@ export default { canUsePolicyRooms, canUseTasks, canUseCustomStatus, + canUseCategories, canUseLinkPreviews, }; diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index 8cdb0c0271e6..ee833911ccd4 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -33,6 +33,7 @@ Onyx.connect({ * @param {Object} [receipt] * @param {String} [filename] * @param {String} [existingTransactionID] When creating a distance request, an empty transaction has already been created with a transactionID. In that case, the transaction here needs to have it's transactionID match what was already generated. + * @param {String} [category] * @returns {Object} */ function buildOptimisticTransaction( @@ -47,6 +48,7 @@ function buildOptimisticTransaction( receipt = {}, filename = '', existingTransactionID = null, + category = '', ) { // transactionIDs are random, positive, 64-bit numeric strings. // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) @@ -74,6 +76,7 @@ function buildOptimisticTransaction( pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, receipt, filename, + category, }; } diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 2102ed9223c8..33f2bbf374a4 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -44,6 +44,13 @@ Onyx.connect({ }, }); +let allRecentlyUsedCategories = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, + waitForCollectionCallback: true, + callback: (val) => (allRecentlyUsedCategories = val), +}); + let userAccountID = ''; let currentUserEmail = ''; Onyx.connect({ @@ -83,6 +90,7 @@ function resetMoneyRequestInfo(id = '') { comment: '', participants: [], merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, + category: '', created, receiptPath: '', receiptSource: '', @@ -99,6 +107,7 @@ function buildOnyxDataForMoneyRequest( iouAction, optimisticPersonalDetailListAction, reportPreviewAction, + optimisticRecentlyUsedCategories, isNewChatReport, isNewIOUReport, ) { @@ -148,6 +157,14 @@ function buildOnyxDataForMoneyRequest( }, ]; + if (!_.isEmpty(optimisticRecentlyUsedCategories)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`, + value: optimisticRecentlyUsedCategories, + }); + } + if (!_.isEmpty(optimisticPersonalDetailListAction)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -319,6 +336,8 @@ function buildOnyxDataForMoneyRequest( * @param {Number} [payeeAccountID] * @param {String} [payeeEmail] * @param {Object} [receipt] + * @param {String} [existingTransactionID] + * @param {String} [category] * @returns {Object} data * @returns {String} data.payerEmail * @returns {Object} data.iouReport @@ -332,7 +351,6 @@ function buildOnyxDataForMoneyRequest( * @returns {Object} data.onyxData.optimisticData * @returns {Object} data.onyxData.successData * @returns {Object} data.onyxData.failureData - * @param {String} [existingTransactionID] */ function getMoneyRequestInformation( report, @@ -345,7 +363,8 @@ function getMoneyRequestInformation( payeeAccountID = userAccountID, payeeEmail = currentUserEmail, receipt = undefined, - existingTransactionID = null, + existingTransactionID = undefined, + category = undefined, ) { const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login); const payerAccountID = Number(participant.accountID); @@ -410,8 +429,17 @@ function getMoneyRequestInformation( receiptObject, filename, existingTransactionID, + category, ); + const uniquePolicyRecentlyUsedCategories = allRecentlyUsedCategories + ? _.filter( + allRecentlyUsedCategories[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`], + (recentlyUsedPolicyCategory) => recentlyUsedPolicyCategory !== category, + ) + : []; + const optimisticPolicyRecentlyUsedCategories = [category, ...uniquePolicyRecentlyUsedCategories]; + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. @@ -480,6 +508,7 @@ function getMoneyRequestInformation( iouAction, optimisticPersonalDetailListAction, reportPreviewAction, + optimisticPolicyRecentlyUsedCategories, isNewChatReport, isNewIOUReport, ); @@ -509,11 +538,12 @@ function getMoneyRequestInformation( * @param {String} comment * @param {String} created * @param {String} [transactionID] + * @param {String} [category] * @param {Number} amount * @param {String} currency * @param {String} merchant */ -function createDistanceRequest(report, participant, comment, created, transactionID, amount, currency, merchant) { +function createDistanceRequest(report, participant, comment, created, transactionID, category, amount, currency, merchant) { const optimisticReceipt = { source: ReceiptGeneric, state: CONST.IOU.RECEIPT_STATE.OPEN, @@ -530,6 +560,7 @@ function createDistanceRequest(report, participant, comment, created, transactio null, optimisticReceipt, transactionID, + category, ); API.write( 'CreateDistanceRequest', @@ -544,6 +575,7 @@ function createDistanceRequest(report, participant, comment, created, transactio reportPreviewReportActionID: reportPreviewAction.reportActionID, waypoints: JSON.stringify(TransactionUtils.getValidWaypoints(transaction.comment.waypoints, true)), created, + category, }, onyxData, ); @@ -564,8 +596,9 @@ function createDistanceRequest(report, participant, comment, created, transactio * @param {Object} participant * @param {String} comment * @param {Object} [receipt] + * @param {String} [category] */ -function requestMoney(report, amount, currency, created, merchant, payeeEmail, payeeAccountID, participant, comment, receipt = undefined) { +function requestMoney(report, amount, currency, created, merchant, payeeEmail, payeeAccountID, participant, comment, receipt = undefined, category = undefined) { const {payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation( report, participant, @@ -577,6 +610,8 @@ function requestMoney(report, amount, currency, created, merchant, payeeEmail, p payeeAccountID, payeeEmail, receipt, + undefined, + category, ); API.write( @@ -596,6 +631,7 @@ function requestMoney(report, amount, currency, created, merchant, payeeEmail, p createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction.reportActionID, receipt, + category, }, onyxData, ); @@ -878,6 +914,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco oneOnOneIOUAction, oneOnOnePersonalDetailListAction, oneOnOneReportPreviewAction, + [], isNewOneOnOneChatReport, shouldCreateNewOneOnOneIOUReport, ); @@ -1835,6 +1872,17 @@ function setMoneyRequestMerchant(merchant) { Onyx.merge(ONYXKEYS.IOU, {merchant: merchant.trim()}); } +/** + * @param {String} category + */ +function setMoneyRequestCategory(category) { + Onyx.merge(ONYXKEYS.IOU, {category}); +} + +function resetMoneyRequestCategory() { + Onyx.merge(ONYXKEYS.IOU, {category: ''}); +} + /** * @param {Object[]} participants */ @@ -1913,6 +1961,8 @@ export { setMoneyRequestCurrency, setMoneyRequestDescription, setMoneyRequestMerchant, + setMoneyRequestCategory, + resetMoneyRequestCategory, setMoneyRequestParticipants, setMoneyRequestReceipt, createEmptyTransaction, diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 8db5140d1092..91847061a18b 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -134,9 +134,19 @@ function MoneyRequestConfirmPage(props) { selectedParticipants[0], trimmedComment, receipt, + props.iou.category, ); }, - [props.report, props.iou.amount, props.iou.currency, props.iou.created, props.iou.merchant, props.currentUserPersonalDetails.login, props.currentUserPersonalDetails.accountID], + [ + props.report, + props.iou.amount, + props.iou.currency, + props.iou.created, + props.iou.merchant, + props.currentUserPersonalDetails.login, + props.currentUserPersonalDetails.accountID, + props.iou.category, + ], ); /** @@ -151,12 +161,13 @@ function MoneyRequestConfirmPage(props) { trimmedComment, props.iou.created, props.iou.transactionID, + props.iou.category, props.iou.amount, props.iou.currency, props.iou.merchant, ); }, - [props.report, props.iou.created, props.iou.transactionID, props.iou.amount, props.iou.currency, props.iou.merchant], + [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.amount, props.iou.currency, props.iou.merchant], ); const createTransaction = useCallback( @@ -272,6 +283,7 @@ function MoneyRequestConfirmPage(props) { iouAmount={props.iou.amount} iouComment={props.iou.comment} iouCurrencyCode={props.iou.currency} + iouCategory={props.iou.category} onConfirm={createTransaction} onSendMoney={sendMoney} onSelectParticipant={(option) => { diff --git a/src/styles/styles.js b/src/styles/styles.js index 1c1340600a51..093b106cfc52 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1472,7 +1472,7 @@ const styles = { optionDisplayName: { fontFamily: fontFamily.EXP_NEUE, - height: variables.alternateTextHeight, + minHeight: variables.alternateTextHeight, lineHeight: variables.lineHeightXLarge, ...whiteSpace.noWrap, }, @@ -1509,6 +1509,14 @@ const styles = { paddingBottom: 12, }, + optionRowSelected: { + backgroundColor: themeColors.activeComponentBG, + }, + + optionRowDisabled: { + color: themeColors.textSupporting, + }, + optionRowCompact: { height: variables.optionRowHeightCompact, paddingTop: 12, diff --git a/src/types/onyx/PolicyCategory.ts b/src/types/onyx/PolicyCategory.ts new file mode 100644 index 000000000000..adaf16e1acec --- /dev/null +++ b/src/types/onyx/PolicyCategory.ts @@ -0,0 +1,22 @@ +type PolicyCategory = { + /** Name of a category */ + name: string; + + /** Flag that determines if a category is active and able to be selected */ + enabled: boolean; + + /** If true, not adding a comment to a transaction with this category will trigger a violation */ + areCommentsRequired: boolean; + + /** "General Ledger code" that corresponds to this category in an accounting system. Similar to an ID. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + 'GL Code': string; + + /** An ID for this category from an external accounting system */ + externalID: string; + + /** The external accounting service that this category comes from */ + origin: string; +}; + +export default PolicyCategory; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d908c0b36ce1..9612c06d75a6 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -37,6 +37,7 @@ import OnyxUpdatesFromServer from './OnyxUpdatesFromServer'; import Download from './Download'; import PolicyMember from './PolicyMember'; import Policy from './Policy'; +import PolicyCategory from './PolicyCategory'; import Report from './Report'; import ReportAction from './ReportAction'; import ReportActionReactions from './ReportActionReactions'; @@ -85,6 +86,7 @@ export type { Download, PolicyMember, Policy, + PolicyCategory, Report, ReportAction, ReportActionReactions, diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 7dc47619ffed..f1a251e4e433 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -656,16 +656,7 @@ describe('OptionsListUtils', () => { const search = 'Food'; const emptySearch = ''; const wrongSearch = 'bla bla'; - const recentlyUsedCategories = { - Taxi: { - enabled: false, - name: 'Taxi', - }, - Restaurant: { - enabled: true, - name: 'Restaurant', - }, - }; + const recentlyUsedCategories = ['Taxi', 'Restaurant']; const selectedOptions = [ { name: 'Medical',