diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5755296f3bb..7f67d525400 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -435,7 +435,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; - [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags; + [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagList; [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 4eaff69a970..b01b2d79f83 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -176,8 +176,9 @@ const ROUTES = { getRoute: (reportID: string) => `r/${reportID}/avatar` as const, }, EDIT_REQUEST: { - route: 'r/:threadReportID/edit/:field', - getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}` as const, + route: 'r/:threadReportID/edit/:field/:tagIndex?', + getRoute: (threadReportID: string, field: ValueOf, tagIndex?: number) => + `r/${threadReportID}/edit/${field}${typeof tagIndex === 'number' ? `/${tagIndex}` : ''}` as const, }, EDIT_CURRENCY_REQUEST: { route: 'r/:threadReportID/edit/currency', @@ -224,8 +225,9 @@ const ROUTES = { getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const, }, EDIT_SPLIT_BILL: { - route: `r/:reportID/split/:reportActionID/edit/:field`, - getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}` as const, + route: `r/:reportID/split/:reportActionID/edit/:field/:tagIndex?`, + getRoute: (reportID: string, reportActionID: string, field: ValueOf, tagIndex?: number) => + `r/${reportID}/split/${reportActionID}/edit/${field}${typeof tagIndex === 'number' ? `/${tagIndex}` : ''}` as const, }, EDIT_SPLIT_BILL_CURRENCY: { route: 'r/:reportID/split/:reportActionID/edit/currency', @@ -369,9 +371,9 @@ const ROUTES = { getUrlWithBackToParam(`${action}/${iouType}/scan/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_TAG: { - route: ':action/:iouType/tag/:transactionID/:reportID', - getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`${action}/${iouType}/tag/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/tag/:tagIndex/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, tagIndex: number, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/tag/${tagIndex}/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_WAYPOINT: { route: ':action/:iouType/waypoint/:transactionID/:reportID/:pageIndex', diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index cebd885e232..0de601bc9f6 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -241,12 +241,10 @@ function MoneyRequestConfirmationList(props) { const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan; const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !props.isDistanceRequest && !isSplitWithScan; - // Fetches the first tag list of the policy - const policyTag = PolicyUtils.getTag(props.policyTags); - const policyTagList = lodashGet(policyTag, 'tags', {}); - const policyTagListName = lodashGet(policyTag, 'name', translate('common.tag')); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(props.policyTags), [props.policyTags]); + // A flag for showing the tags field - const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledOptions(_.values(policyTagList))); + const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledTags(policyTagLists)); // A flag for showing tax fields - tax rate and tax amount const shouldShowTax = props.isPolicyExpenseChat && lodashGet(props.policy, 'tax.trackingEnabled', props.policy.isTaxTrackingEnabled); @@ -781,33 +779,36 @@ function MoneyRequestConfirmationList(props) { rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''} /> )} - {shouldShowTags && ( - { - if (props.isEditingSplitBill) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.SPLIT, - props.transaction.transactionID, - props.reportID, - Navigation.getActiveRouteWithoutParams(), - ), - ); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID)); - }} - style={[styles.moneyRequestMenuItem]} - disabled={didConfirm} - interactive={!props.isReadOnly} - rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''} - /> - )} + {shouldShowTags && + _.map(policyTagLists, ({name}, index) => ( + { + if (props.isEditingSplitBill) { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.SPLIT, + index, + props.transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID)); + }} + style={[styles.moneyRequestMenuItem]} + disabled={didConfirm} + interactive={!props.isReadOnly} + rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''} + /> + ))} {shouldShowTax && ( {}, iouType: CONST.IOU.TYPE.REQUEST, iouCategory: '', - iouTag: '', iouIsBillable: false, onToggleBillable: () => {}, payeePersonalDetails: null, @@ -217,7 +213,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ iouCurrencyCode, iouIsBillable, iouMerchant, - iouTag, iouType, isDistanceRequest, isEditingSplitBill, @@ -270,13 +265,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const shouldShowDate = shouldShowSmartScanFields || isDistanceRequest; const shouldShowMerchant = shouldShowSmartScanFields && !isDistanceRequest; - // Fetches the first tag list of the policy - const policyTag = PolicyUtils.getTag(policyTags); - const policyTagList = lodashGet(policyTag, 'tags', {}); - const policyTagListName = lodashGet(policyTag, 'name', translate('common.tag')); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); // A flag for showing the tags field - const shouldShowTags = isPolicyExpenseChat && OptionsListUtils.hasEnabledOptions(_.values(policyTagList)); + const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); // A flag for showing tax rate const shouldShowTax = isPolicyExpenseChat && policy && lodashGet(policy, 'tax.trackingEnabled', policy.isTaxTrackingEnabled); @@ -765,17 +757,17 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ shouldShow: shouldShowCategories, isSupplementary: !isCategoryRequired, }, - { + ..._.map(policyTagLists, ({name}, index) => ({ item: ( Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.CREATE, iouType, index, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), ) } style={[styles.moneyRequestMenuItem]} @@ -786,7 +778,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ ), shouldShow: shouldShowTags, isSupplementary: !isTagRequired, - }, + })), { item: ( ; /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; + policyTagList: OnyxEntry; /** The expense report or iou report (only will have a value if this is a transaction thread) */ parentReport: OnyxEntry; @@ -81,7 +81,7 @@ function MoneyRequestView({ policyCategories, shouldShowHorizontalRule, transaction, - policyTags, + policyTagList, policy, transactionViolations, }: MoneyRequestViewProps) { @@ -130,9 +130,7 @@ function MoneyRequestView({ // if the policy of the report is either Collect or Control, then this report must be tied to workspace chat const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); - // Fetches only the first tag, for now - const policyTag = PolicyUtils.getTag(policyTags); - const policyTagsList = policyTag?.tags ?? {}; + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTagList), [policyTagList]); // Flags for showing categories and tags // transactionCategory can be an empty string @@ -140,11 +138,14 @@ function MoneyRequestView({ const shouldShowCategory = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); // transactionTag can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(Object.values(policyTagsList))); + const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)); const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true)); const {getViolationsForField} = useViolations(transactionViolations ?? []); - const hasViolations = useCallback((field: ViolationField): boolean => !!canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]); + const hasViolations = useCallback( + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']): boolean => !!canUseViolations && getViolationsForField(field, data).length > 0, + [canUseViolations, getViolationsForField], + ); let amountDescription = `${translate('iou.amount')}`; @@ -155,11 +156,10 @@ function MoneyRequestView({ Navigation.dismissModal(); return; } - // @ts-expect-error: the type used across the app for policyTags is not what is returned by Onyx, PolicyTagList represents that, but existing policy tag utils need a refactor to fix this - IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '', report?.reportID, newBillable, policy, policyTags, policyCategories); + IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '', report?.reportID, newBillable, policy, policyTagList, policyCategories); Navigation.dismissModal(); }, - [transaction, report, policy, policyTags, policyCategories], + [transaction, report, policy, policyTagList, policyCategories], ); if (isCardTransaction) { @@ -199,7 +199,7 @@ function MoneyRequestView({ const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; const getErrorForField = useCallback( - (field: ViolationField) => { + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']) => { // Checks applied when creating a new money request // NOTE: receipt field can return multiple violations, so we need to handle it separately const fieldChecks: Partial> = { @@ -225,8 +225,8 @@ function MoneyRequestView({ } // Return violations if there are any - if (canUseViolations && hasViolations(field)) { - const violations = getViolationsForField(field); + if (canUseViolations && hasViolations(field, data)) { + const violations = getViolationsForField(field, data); return ViolationsUtils.getViolationTranslation(violations[0], translate); } @@ -372,22 +372,36 @@ function MoneyRequestView({ /> )} - {shouldShowTag && ( - - - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) - } - brickRoadIndicator={getErrorForField('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('tag')} - /> - - )} + {shouldShowTag && + policyTagLists.map(({name}, index) => ( + + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, index, transaction?.transactionID ?? '', report.reportID), + ) + } + brickRoadIndicator={ + getErrorForField('tag', { + tagName: name, + }) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } + error={getErrorForField('tag', { + tagName: name, + })} + /> + + ))} {isCardTransaction && ( )} - {shouldShowBillable && ( <> @@ -434,7 +447,7 @@ export default withOnyx `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report.policyID}`, }, - policyTags: { + policyTagList: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report.policyID}`, }, parentReport: { diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index e258472eae9..94b91c66f15 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -12,15 +12,15 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, propTypes} from './tagPickerPropTypes'; -function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption, insets, onSubmit}) { +function TagPicker({selectedTag, tag, tagIndex, policyTags, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption, insets, onSubmit}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); const policyRecentlyUsedTagsList = lodashGet(policyRecentlyUsedTags, tag, []); - const policyTagList = PolicyUtils.getTagList(policyTags, tag); - const policyTagsCount = _.size(_.filter(policyTagList, (policyTag) => policyTag.enabled)); + const policyTagList = PolicyUtils.getTagList(policyTags, tagIndex); + const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList); const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; const shouldShowTextInput = !isTagsCountBelowThreshold; @@ -41,10 +41,10 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, should const enabledTags = useMemo(() => { if (!shouldShowDisabledAndSelectedOption) { - return policyTagList; + return policyTagList.tags; } const selectedNames = _.map(selectedOptions, (s) => s.name); - const tags = [...selectedOptions, ..._.filter(policyTagList, (policyTag) => policyTag.enabled && !selectedNames.includes(policyTag.name))]; + const tags = [...selectedOptions, ..._.filter(policyTagList.tags, (policyTag) => policyTag.enabled && !selectedNames.includes(policyTag.name))]; return tags; }, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]); diff --git a/src/components/TagPicker/tagPickerPropTypes.js b/src/components/TagPicker/tagPickerPropTypes.js index b98f7f6ef8e..cbdc73f5d05 100644 --- a/src/components/TagPicker/tagPickerPropTypes.js +++ b/src/components/TagPicker/tagPickerPropTypes.js @@ -12,6 +12,9 @@ const propTypes = { /** The name of tag list we are getting tags for */ tag: PropTypes.string.isRequired, + /** The index of a tag list */ + tagIndex: PropTypes.number.isRequired, + /** Callback to submit the selected tag */ onSubmit: PropTypes.func.isRequired, diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 70e66ab65a0..ea825b45bc0 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -59,7 +59,18 @@ function useViolations(violations: TransactionViolation[]) { return violationGroups ?? new Map(); }, [violations]); - const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]); + const getViolationsForField = useCallback( + (field: ViolationField, data?: TransactionViolation['data']) => { + const currentViolations = violationsByField.get(field) ?? []; + + if (data?.tagName) { + return currentViolations.filter((violation) => violation.data?.tagName === data.tagName); + } + + return currentViolations; + }, + [violationsByField], + ); return { getViolationsForField, diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index d70f4fc0810..ddcbca7c14f 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -99,4 +99,19 @@ function isValidMoneyRequestType(iouType: string): boolean { return moneyRequestType.includes(iouType); } -export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep}; +/** + * Inserts a newly selected tag into the already existed report tags like a string + * + * @param reportTags - currently selected tags for a report + * @param tag - a newly selected tag, that should be added to the reportTags + * @param tagIndex - the index of a tag list + * @returns + */ +function insertTagIntoReportTagsString(reportTags: string, tag: string, tagIndex: number): string { + const splittedReportTags = reportTags.split(CONST.COLON); + splittedReportTags[tagIndex] = tag; + + return splittedReportTags.join(CONST.COLON).replace(/:*$/, ''); +} + +export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType, navigateToStartMoneyRequestStep, insertTagIntoReportTagsString}; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index dd6de59b617..13a58834860 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -1,8 +1,8 @@ import Onyx from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyTags, ReportAction} from '@src/types/onyx'; +import type {PolicyTagList, ReportAction} from '@src/types/onyx'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import * as Localize from './Localize'; @@ -10,7 +10,7 @@ import * as PolicyUtils from './PolicyUtils'; import * as ReportUtils from './ReportUtils'; import type {ExpenseOriginalMessage} from './ReportUtils'; -let allPolicyTags: Record = {}; +let allPolicyTags: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_TAGS, waitForCollectionCallback: true, @@ -102,8 +102,6 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr } const reportActionOriginalMessage = reportAction?.originalMessage as ExpenseOriginalMessage | undefined; const policyID = ReportUtils.getReportPolicyID(reportID) ?? ''; - const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; - const policyTagListName = PolicyUtils.getTagListName(policyTags) || Localize.translateLocal('common.tag'); const removalFragments: string[] = []; const setFragments: string[] = []; @@ -188,16 +186,32 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr const hasModifiedTag = reportActionOriginalMessage && 'oldTag' in reportActionOriginalMessage && 'tag' in reportActionOriginalMessage; if (hasModifiedTag) { - buildMessageFragmentForValue( - PolicyUtils.getCleanedTagName(reportActionOriginalMessage?.tag ?? ''), - PolicyUtils.getCleanedTagName(reportActionOriginalMessage?.oldTag ?? ''), - policyTagListName, - true, - setFragments, - removalFragments, - changeFragments, - policyTagListName === Localize.translateLocal('common.tag'), - ); + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; + const transactionTag = reportActionOriginalMessage?.tag ?? ''; + const oldTransactionTag = reportActionOriginalMessage?.oldTag ?? ''; + const splittedTag = transactionTag.split(CONST.COLON); + const splittedOldTag = oldTransactionTag.split(CONST.COLON); + const localizedTagListName = Localize.translateLocal('common.tag'); + + Object.keys(policyTags).forEach((policyTagKey, index) => { + const policyTagListName = PolicyUtils.getTagListName(policyTags, index) || localizedTagListName; + + const newTag = splittedTag[index] ?? ''; + const oldTag = splittedOldTag[index] ?? ''; + + if (newTag !== oldTag) { + buildMessageFragmentForValue( + PolicyUtils.getCleanedTagName(newTag), + PolicyUtils.getCleanedTagName(oldTag), + policyTagListName, + true, + setFragments, + removalFragments, + changeFragments, + policyTagListName === localizedTagListName, + ); + } + }); } const hasModifiedBillable = reportActionOriginalMessage && 'oldBillable' in reportActionOriginalMessage && 'billable' in reportActionOriginalMessage; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d44b31ac65e..ba59bcf48c0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -346,6 +346,7 @@ type SplitDetailsNavigatorParamList = { reportID: string; reportActionID: string; currency: string; + tagIndex: string; }; [SCREENS.SPLIT_DETAILS.EDIT_CURRENCY]: undefined; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index e2af10649ba..45145a70f45 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -19,6 +19,7 @@ import type { PolicyCategories, PolicyCategory, PolicyTag, + PolicyTagList, Report, ReportAction, ReportActions, @@ -1160,6 +1161,15 @@ function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOpt return tagSections; } +/** + * Verifies that there is at least one enabled tag + */ +function hasEnabledTags(policyTagList: Array) { + const policyTagValueList = policyTagList.map(({tags}) => Object.values(tags)).flat(); + + return hasEnabledOptions(policyTagValueList); +} + type PolicyTaxRateWithDefault = { name: string; defaultExternalID: string; @@ -2025,6 +2035,7 @@ export { hasEnabledOptions, sortCategories, getCategoryOptionTree, + hasEnabledTags, formatMemberForList, formatSectionsFromSearchTerm, transformedTaxRates, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 90dfa8fde33..a8c0508e30b 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -3,7 +3,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Policy, PolicyMembers, PolicyTag, PolicyTags} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -153,49 +153,57 @@ function getIneligibleInvitees(policyMembers: OnyxEntry, personal } /** - * Gets the tag from policy tags, defaults to the first if no key is provided. + * Gets a tag name of policy tags based on a tag index. */ -function getTag(policyTags: OnyxEntry, tagKey?: keyof typeof policyTags): PolicyTag | undefined | EmptyObject { - if (isEmptyObject(policyTags)) { - return {}; +function getTagListName(policyTagList: OnyxEntry, tagIndex: number): string { + if (isEmptyObject(policyTagList)) { + return ''; } - const policyTagKey = tagKey ?? Object.keys(policyTags ?? {})[0]; + const policyTagKeys = Object.keys(policyTagList ?? {}); + const policyTagKey = policyTagKeys[tagIndex] ?? ''; - return policyTags?.[policyTagKey] ?? {}; + return policyTagList?.[policyTagKey]?.name ?? ''; } /** - * Gets the first tag name from policy tags. + * Gets all tag lists of a policy */ -function getTagListName(policyTags: OnyxEntry) { - if (Object.keys(policyTags ?? {})?.length === 0) { - return ''; +function getTagLists(policyTagList: OnyxEntry): Array { + if (isEmptyObject(policyTagList)) { + return []; } - const policyTagKeys = Object.keys(policyTags ?? {})[0] ?? []; - - return policyTags?.[policyTagKeys]?.name ?? ''; + return Object.values(policyTagList).filter((policyTagListValue) => policyTagListValue !== null); } /** - * Gets the tags of a policy for a specific key. Defaults to the first tag if no key is provided. + * Gets a tag list of a policy by a tag index */ -function getTagList(policyTags: OnyxCollection, tagKey: string) { - if (Object.keys(policyTags ?? {})?.length === 0) { - return {}; - } - - const policyTagKey = tagKey ?? Object.keys(policyTags ?? {})[0]; +function getTagList(policyTagList: OnyxEntry, tagIndex: number): PolicyTagList[keyof PolicyTagList] { + const tagLists = getTagLists(policyTagList); - return policyTags?.[policyTagKey]?.tags ?? {}; + return ( + tagLists[tagIndex] ?? { + name: '', + required: false, + tags: {}, + } + ); } /** * Cleans up escaping of colons (used to create multi-level tags, e.g. "Parent: Child") in the tag name we receive from the backend */ function getCleanedTagName(tag: string) { - return tag?.replace(/\\{1,2}:/g, ':'); + return tag?.replace(/\\{1,2}:/g, CONST.COLON); +} + +/** + * Gets a count of enabled tags of a policy + */ +function getCountOfEnabledTagsOfList(policyTags: PolicyTags) { + return Object.values(policyTags).filter((policyTag) => policyTag.enabled).length; } function isPendingDeletePolicy(policy: OnyxEntry): boolean { @@ -254,10 +262,11 @@ export { isSubmitAndClose, getMemberAccountIDsForWorkspace, getIneligibleInvitees, - getTag, + getTagLists, getTagListName, getTagList, getCleanedTagName, + getCountOfEnabledTagsOfList, isPendingDeletePolicy, isPolicyMember, isPaidGroupPolicy, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 504b2ac2796..6eb1a848a4b 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -365,9 +365,14 @@ function getBillable(transaction: OnyxEntry): boolean { } /** - * Return the tag from the transaction. This "tag" field has no "modified" complement. + * Return the tag from the transaction. When the tagIndex is passed, return the tag based on the index. + * This "tag" field has no "modified" complement. */ -function getTag(transaction: OnyxEntry): string { +function getTag(transaction: OnyxEntry, tagIndex?: number): string { + if (tagIndex !== undefined) { + return transaction?.tag?.split(CONST.COLON)[tagIndex] ?? ''; + } + return transaction?.tag ?? ''; } diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 0e14c153025..a7f15d948b6 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -2,6 +2,7 @@ import reject from 'lodash/reject'; import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; import type {Phrase, PhraseParameters} from '@libs/Localize'; +import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation} from '@src/types/onyx'; @@ -49,31 +50,67 @@ const ViolationsUtils = { } if (policyRequiresTags) { - const policyTagListName = Object.keys(policyTagList)[0]; - const policyTags = policyTagList[policyTagListName]?.tags; - const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'tagOutOfPolicy'); - const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === 'missingTag'); - const isTagInPolicy = policyTags ? !!policyTags[updatedTransaction.tag ?? '']?.enabled : false; - - // Add 'tagOutOfPolicy' violation if tag is not in policy - if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) { - newTransactionViolations.push({name: 'tagOutOfPolicy', type: 'violation', userMessage: ''}); - } + const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? []; + const policyTagKeys = Object.keys(policyTagList); - // Remove 'tagOutOfPolicy' violation if tag is in policy - if (hasTagOutOfPolicyViolation && updatedTransaction.tag && isTagInPolicy) { - newTransactionViolations = reject(newTransactionViolations, {name: 'tagOutOfPolicy'}); + if (policyTagKeys.length === 0) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + type: 'violation', + userMessage: '', + }); } - // Remove 'missingTag' violation if tag is valid according to policy - if (hasMissingTagViolation && isTagInPolicy) { - newTransactionViolations = reject(newTransactionViolations, {name: 'missingTag'}); - } + policyTagKeys.forEach((key, index) => { + const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && violation.data?.tagName === key); + const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG && violation.data?.tagName === key); + const selectedTag = selectedTags[index]; + const isTagInPolicy = Boolean(policyTagList[key]?.tags[selectedTag]?.enabled); - // Add 'missingTag violation' if tag is required and not set - if (!hasMissingTagViolation && !updatedTransaction.tag && policyRequiresTags) { - newTransactionViolations.push({name: 'missingTag', type: 'violation', userMessage: ''}); - } + // Add 'tagOutOfPolicy' violation if tag is not in policy + if (!hasTagOutOfPolicyViolation && selectedTag && !isTagInPolicy) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + type: 'violation', + userMessage: '', + data: { + tagName: key, + }, + }); + } + + // Remove 'tagOutOfPolicy' violation if tag is in policy + if (hasTagOutOfPolicyViolation && selectedTag && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, { + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + data: { + tagName: key, + }, + }); + } + + // Remove 'missingTag' violation if tag is valid according to policy + if (hasMissingTagViolation && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, { + name: CONST.VIOLATIONS.MISSING_TAG, + data: { + tagName: key, + }, + }); + } + + // Add 'missingTag violation' if tag is required and not set + if (!hasMissingTagViolation && !selectedTag && policyRequiresTags) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.MISSING_TAG, + type: 'violation', + userMessage: '', + data: { + tagName: key, + }, + }); + } + }); } return { @@ -82,6 +119,7 @@ const ViolationsUtils = { value: newTransactionViolations, }; }, + /** * Gets the translated message for each violation type. * diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 3f49890d1d0..1e0114e6c07 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -393,7 +393,7 @@ function buildOnyxDataForMoneyRequest( isNewChatReport: boolean, shouldCreateNewMoneyRequestReport: boolean, policy?: OnyxEntry, - policyTags?: OnyxEntry, + policyTagList?: OnyxEntry, policyCategories?: OnyxEntry, optimisticNextStep?: OnyxTypes.ReportNextStep | null, needsToBeManuallySubmitted = true, @@ -678,7 +678,7 @@ function buildOnyxDataForMoneyRequest( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTags ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -710,7 +710,7 @@ function getMoneyRequestInformation( tag: string | undefined, billable: boolean | undefined, policy: OnyxEntry | undefined, - policyTags: OnyxEntry | undefined, + policyTagList: OnyxEntry | undefined, policyCategories: OnyxEntry | undefined, payeeAccountID = userAccountID, payeeEmail = currentUserEmail, @@ -807,7 +807,6 @@ function getMoneyRequestInformation( ); const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); - const optimisticPolicyRecentlyUsedTags = Policy.buildOptimisticPolicyRecentlyUsedTags(iouReport.policyID, tag); // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction @@ -889,7 +888,7 @@ function getMoneyRequestInformation( isNewChatReport, shouldCreateNewMoneyRequestReport, policy, - policyTags, + policyTagList, policyCategories, optimisticNextStep, needsToBeManuallySubmitted, @@ -927,7 +926,7 @@ function createDistanceRequest( billable: boolean | undefined, validWaypoints: WaypointCollection, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function @@ -954,7 +953,7 @@ function createDistanceRequest( tag, billable, policy, - policyTags, + policyTagList, policyCategories, userAccountID, currentUserEmail, @@ -988,7 +987,7 @@ function createDistanceRequest( * @param transactionChanges * @param [transactionChanges.created] Present when updated the date field * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) - * @param policyTags + * @param policyTagList * @param policyCategories * @param onlyIncludeChangedFields * When 'true', then the returned params will only include the transaction details for the fields that were changed. @@ -1000,7 +999,7 @@ function getUpdateMoneyRequestParams( transactionThreadReportID: string, transactionChanges: TransactionChanges, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, onlyIncludeChangedFields: boolean, ): UpdateMoneyRequestData { @@ -1232,7 +1231,7 @@ function getUpdateMoneyRequestParams( updatedTransaction, currentTransactionViolations, !!policy.requiresTag, - policyTags ?? {}, + policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}, ), @@ -1279,13 +1278,13 @@ function updateMoneyRequestBillable( transactionThreadReportID: string, value: boolean, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges: TransactionChanges = { billable: value, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); } @@ -1295,13 +1294,13 @@ function updateMoneyRequestMerchant( transactionThreadReportID: string, value: string, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges: TransactionChanges = { merchant: value, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); } @@ -1311,13 +1310,13 @@ function updateMoneyRequestTag( transactionThreadReportID: string, tag: string, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges: TransactionChanges = { tag, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); } @@ -1327,13 +1326,13 @@ function updateMoneyRequestDistance( transactionThreadReportID: string, waypoints: WaypointCollection, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges: TransactionChanges = { waypoints, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE, params, onyxData); } @@ -1343,13 +1342,13 @@ function updateMoneyRequestCategory( transactionThreadReportID: string, category: string, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges: TransactionChanges = { category, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } @@ -1359,13 +1358,13 @@ function updateMoneyRequestDescription( transactionThreadReportID: string, comment: string, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges: TransactionChanges = { comment, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); } @@ -1375,10 +1374,10 @@ function updateDistanceRequest( transactionThreadReportID: string, transactionChanges: TransactionChanges, policy: OnyxTypes.Policy, - policyTags: OnyxTypes.PolicyTagList, + policyTagList: OnyxTypes.PolicyTagList, policyCategories: OnyxTypes.PolicyCategories, ) { - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, false); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, false); API.write(WRITE_COMMANDS.UPDATE_DISTANCE_REQUEST, params, onyxData); } @@ -1402,7 +1401,7 @@ function requestMoney( taxAmount = 0, billable?: boolean, policy?: OnyxEntry, - policyTags?: OnyxEntry, + policyTagList?: OnyxEntry, policyCategories?: OnyxEntry, gpsPoints = undefined, ) { @@ -1426,7 +1425,7 @@ function requestMoney( tag, billable, policy, - policyTags, + policyTagList, policyCategories, payeeAccountID, payeeEmail, @@ -2731,14 +2730,14 @@ function updateMoneyRequestAmountAndCurrency( currency: string, amount: number, policy: OnyxEntry, - policyTags: OnyxEntry, + policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { const transactionChanges = { amount, currency, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); } diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 5a65e2e69ac..292107e867a 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -41,7 +41,7 @@ import type { PersonalDetailsList, Policy, PolicyMember, - PolicyTags, + PolicyTagList, RecentlyUsedCategories, RecentlyUsedTags, ReimbursementAccount, @@ -175,7 +175,7 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedCategories = val), }); -let allPolicyTags: OnyxCollection = {}; +let allPolicyTags: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_TAGS, waitForCollectionCallback: true, @@ -1633,20 +1633,26 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID?: string, category return lodashUnion([category], policyRecentlyUsedCategories); } -function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, tag?: string): RecentlyUsedTags { - if (!policyID || !tag) { +function buildOptimisticPolicyRecentlyUsedTags(policyID?: string, reportTags?: string): RecentlyUsedTags { + if (!policyID || !reportTags) { return {}; } const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; - // For now it only uses the first tag of the policy, since multi-tags are not yet supported - const tagListKey = Object.keys(policyTags)[0]; + const policyTagKeys = Object.keys(policyTags); const policyRecentlyUsedTags = allRecentlyUsedTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`] ?? {}; + const newOptimisticPolicyRecentlyUsedTags: RecentlyUsedTags = {}; - return { - ...policyRecentlyUsedTags, - [tagListKey]: lodashUnion([tag], policyRecentlyUsedTags?.[tagListKey] ?? []), - }; + reportTags.split(CONST.COLON).forEach((tag, index) => { + if (!tag) { + return; + } + + const tagListKey = policyTagKeys[index]; + newOptimisticPolicyRecentlyUsedTags[tagListKey] = [...new Set([...tag, ...(policyRecentlyUsedTags[tagListKey] ?? [])])]; + }); + + return newOptimisticPolicyRecentlyUsedTags; } /** diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 69d88ca16c2..29917154a52 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -1,7 +1,7 @@ import lodashGet from 'lodash/get'; import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import categoryPropTypes from '@components/categoryPropTypes'; @@ -10,6 +10,7 @@ import tagPropTypes from '@components/tagPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; @@ -38,6 +39,9 @@ const propTypes = { /** reportID for the "transaction thread" */ threadReportID: PropTypes.string, + + /** The index of a tag list */ + tagIndex: PropTypes.string, }), }).isRequired, @@ -77,11 +81,11 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; const fieldToEdit = lodashGet(route, ['params', 'field'], ''); + const tagIndex = Number(lodashGet(route, ['params', 'tagIndex'], undefined)); - // For now, it always defaults to the first tag of the policy - const policyTag = PolicyUtils.getTag(policyTags); - const policyTagList = lodashGet(policyTag, 'tags', {}); - const tagListName = PolicyUtils.getTagListName(policyTags); + const tag = TransactionUtils.getTag(transaction, tagIndex); + const policyTagListName = PolicyUtils.getTagListName(policyTags, tagIndex); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); // A flag for verifying that the current report is a sub-report of a workspace chat const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); @@ -90,7 +94,7 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); // A flag for showing the tags page - const shouldShowTags = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagList))); + const shouldShowTags = useMemo(() => isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)), [isPolicyExpenseChat, policyTagLists, transactionTag]); // Decides whether to allow or disallow editing a money request useEffect(() => { @@ -124,14 +128,21 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p const saveTag = useCallback( ({tag: newTag}) => { let updatedTag = newTag; - if (newTag === transactionTag) { + if (newTag === tag) { // In case the same tag has been selected, reset the tag. updatedTag = ''; } - IOU.updateMoneyRequestTag(transaction.transactionID, report.reportID, updatedTag, policy, policyTags, policyCategories); + IOU.updateMoneyRequestTag( + transaction.transactionID, + report.reportID, + IOUUtils.insertTagIntoReportTagsString(transactionTag, updatedTag, tagIndex), + policy, + policyTags, + policyCategories, + ); Navigation.dismissModal(); }, - [transactionTag, transaction.transactionID, report.reportID, policy, policyTags, policyCategories], + [tag, transaction.transactionID, report.reportID, transactionTag, tagIndex, policy, policyTags, policyCategories], ); const saveCategory = useCallback( @@ -172,8 +183,9 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG && shouldShowTags) { return ( diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js index b2f576c499a..74643afa347 100644 --- a/src/pages/EditRequestTagPage.js +++ b/src/pages/EditRequestTagPage.js @@ -18,6 +18,9 @@ const propTypes = { /** The tag name to which the default tag belongs to */ tagName: PropTypes.string, + /** The index of a tag list */ + tagIndex: PropTypes.number.isRequired, + /** Callback to fire when the Save button is pressed */ onSubmit: PropTypes.func.isRequired, }; @@ -26,7 +29,7 @@ const defaultProps = { tagName: '', }; -function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) { +function EditRequestTagPage({defaultTag, policyID, tagName, tagIndex, onSubmit}) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -50,6 +53,7 @@ function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) { ; function EditSplitBillPage({route, transaction, draftTransaction, report}: EditSplitBillProps) { - const {field: fieldToEdit, reportID, reportActionID, currency} = route.params; + const {field: fieldToEdit, reportID, reportActionID, currency, tagIndex} = route.params; const { amount: transactionAmount, @@ -97,6 +97,7 @@ function EditSplitBillPage({route, transaction, draftTransaction, report}: EditS { setDraftSplitTransaction({tag: transactionChanges.tag.trim()}); }} diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 8c94bb32ffa..00055d6048f 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -430,7 +430,6 @@ function IOURequestStepConfirmation({ iouIsBillable={transaction.billable} onToggleBillable={setBillable} iouCategory={transaction.category} - iouTag={transaction.tag} onConfirm={createTransaction} onSendMoney={sendMoney} onSelectParticipant={addNewParticipant} diff --git a/src/pages/iou/request/step/IOURequestStepTag.js b/src/pages/iou/request/step/IOURequestStepTag.js index 1a26d10552f..20ab92d3d44 100644 --- a/src/pages/iou/request/step/IOURequestStepTag.js +++ b/src/pages/iou/request/step/IOURequestStepTag.js @@ -1,7 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import categoryPropTypes from '@components/categoryPropTypes'; import TagPicker from '@components/TagPicker'; import tagPropTypes from '@components/tagPropTypes'; @@ -10,8 +9,10 @@ import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import reportPropTypes from '@pages/reportPropTypes'; import {policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; @@ -57,16 +58,17 @@ function IOURequestStepTag({ policyTags, report, route: { - params: {action, transactionID, backTo, iouType}, + params: {action, tagIndex: rawTagIndex, transactionID, backTo, iouType}, }, - transaction: {tag}, + transaction, }) { const styles = useThemeStyles(); const {translate} = useLocalize(); - // Fetches the first tag list of the policy - const tagListKey = _.first(_.keys(policyTags)); - const policyTagListName = PolicyUtils.getTagListName(policyTags) || translate('common.tag'); + const tagIndex = Number(rawTagIndex); + const policyTagListName = PolicyUtils.getTagListName(policyTags, tagIndex); + const transactionTag = TransactionUtils.getTag(transaction); + const tag = TransactionUtils.getTag(transaction, tagIndex); const isEditing = action === CONST.IOU.ACTION.EDIT; const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; @@ -80,9 +82,9 @@ function IOURequestStepTag({ */ const updateTag = (selectedTag) => { const isSelectedTag = selectedTag.searchText === tag; - const updatedTag = !isSelectedTag ? selectedTag.searchText : ''; + const updatedTag = IOUUtils.insertTagIntoReportTagsString(transactionTag, isSelectedTag ? '' : selectedTag.searchText, tagIndex); if (isSplitBill && isEditing) { - IOU.setDraftSplitTransaction(transactionID, {tag: selectedTag.searchText}); + IOU.setDraftSplitTransaction(transactionID, {tag: updatedTag}); navigateBack(); return; } @@ -105,12 +107,14 @@ function IOURequestStepTag({ {({insets}) => ( <> {translate('iou.tagSelection', {tagName: policyTagListName})} + )} diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts index ff688419605..031f255b489 100644 --- a/src/types/onyx/PolicyTag.ts +++ b/src/types/onyx/PolicyTag.ts @@ -8,9 +8,6 @@ type PolicyTag = { /** "General Ledger code" that corresponds to this tag in an accounting system. Similar to an ID. */ // eslint-disable-next-line @typescript-eslint/naming-convention 'GL Code': string; - - /** Nested tags */ - tags: PolicyTags; }; type PolicyTags = Record; @@ -24,6 +21,7 @@ type PolicyTagList = Record< /** Flag that determines if tags are required */ required: boolean; + /** Nested tags */ tags: PolicyTags; } >; diff --git a/tests/unit/ViolationUtilsTest.js b/tests/unit/ViolationUtilsTest.js index 2b644c1fc82..4f03fe0a42f 100644 --- a/tests/unit/ViolationUtilsTest.js +++ b/tests/unit/ViolationUtilsTest.js @@ -129,10 +129,18 @@ describe('getViolationsOnyxData', () => { beforeEach(() => { policyRequiresTags = true; policyTags = { - Tag: { - name: 'Tag', + Meals: { + name: 'Meals', required: true, - tags: {Lunch: {enabled: true}, Dinner: {enabled: true}}, + tags: { + Lunch: {name: 'Lunch', enabled: true}, + Dinner: {name: 'Dinner', enabled: true}, + }, + Tag: { + name: 'Tag', + required: true, + tags: {Lunch: {enabled: true}, Dinner: {enabled: true}}, + }, }, }; transaction.tag = 'Lunch'; @@ -149,7 +157,7 @@ describe('getViolationsOnyxData', () => { const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); - expect(result.value).toEqual(expect.arrayContaining([missingTagViolation])); + expect(result.value).toEqual(expect.arrayContaining([{...missingTagViolation, data: {tagName: 'Meals'}}])); }); it('should add a tagOutOfPolicy violation when policy requires tags and tag is not in the policy', () => { @@ -169,7 +177,7 @@ describe('getViolationsOnyxData', () => { const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); - expect(result.value).toEqual(expect.arrayContaining([tagOutOfPolicyViolation, ...transactionViolations])); + expect(result.value).toEqual(expect.arrayContaining([{...tagOutOfPolicyViolation, data: {tagName: 'Meals'}}, ...transactionViolations])); }); it('should add missingTag violation to existing violations if transaction does not have a tag', () => { @@ -181,7 +189,7 @@ describe('getViolationsOnyxData', () => { const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); - expect(result.value).toEqual(expect.arrayContaining([missingTagViolation, ...transactionViolations])); + expect(result.value).toEqual(expect.arrayContaining([{...missingTagViolation, data: {tagName: 'Meals'}}, ...transactionViolations])); }); });