Skip to content
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8318,6 +8318,8 @@ const CONST = {
GPS_TOOLTIP: 'gpsTooltip',
HAS_FILTER_NEGATION: 'hasFilterNegation',
MILEAGE_RATE_AUTO_UPDATED: 'mileageRateAutoUpdated',
REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleReceiptCouplingTooltip',
REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleItemizedReceiptCouplingTooltip',
},
CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal',
AGENTS_RULES_BANNER: 'agentsRulesBanner',
Expand Down
7 changes: 6 additions & 1 deletion src/components/ProductTrainingContext/TOOLTIPS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import type {ValueOf} from 'type-fest';
const {CONCIERGE_LHN_GBR, OUTSTANDING_FILTER, ACCOUNT_SWITCHER, SCAN_TEST_DRIVE_CONFIRMATION, GPS_TOOLTIP, HAS_FILTER_NEGATION, MILEAGE_RATE_AUTO_UPDATED} =
CONST.PRODUCT_TRAINING_TOOLTIP_NAMES;

type ProductTrainingTooltipName = Exclude<ValueOf<typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES>, typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL>;
type ProductTrainingTooltipName = Exclude<
ValueOf<typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES>,
| typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MULTI_SCAN_EDUCATIONAL_MODAL
| typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP
| typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP
>;

type ShouldShowConditionProps = {
shouldUseNarrowLayout: boolean;
Expand Down
68 changes: 53 additions & 15 deletions src/components/RequireFieldsRules/FieldRequirementSettingRow.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
import Icon from '@components/Icon';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Switch from '@components/Switch';
import Text from '@components/Text';
import EducationalTooltip from '@components/Tooltip/EducationalTooltip';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {getRequireFieldsFieldCouplingTooltipKey, isRequireFieldsFieldCouplingDisabled} from '@libs/RequireFieldsRulesUtils';
import {dismissProductTraining} from '@libs/actions/Welcome';
import {
canClearRequireFieldsField,
getRequireFieldsFieldCouplingTooltipKey,
isRequireFieldsFieldCouplingDisabled,
REQUIRE_FIELDS_COUPLING_TOOLTIP_NAMES,
} from '@libs/RequireFieldsRulesUtils';
import type {FieldRequirementsDirection} from '@libs/RequireFieldsRulesUtils';
import isProductTrainingElementDismissed from '@libs/TooltipUtils';

import variables from '@styles/variables';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm';
import type {PolicyCategory} from '@src/types/onyx';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

import React, {useCallback, useState} from 'react';
import React, {useCallback} from 'react';
import {View} from 'react-native';

import FieldRequirementsDirectionToggle from './FieldRequirementsDirectionToggle';
Expand Down Expand Up @@ -54,16 +65,29 @@ function FieldRequirementSettingRow({
const theme = useTheme();
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close', 'Lightbulb']);
const [dismissedCouplingTooltipKey, setDismissedCouplingTooltipKey] = useState<string | undefined>();
const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING);

const isCouplingDisabled = isRequireFieldsFieldCouplingDisabled(fieldKey, effectiveForm, category, touchedFields, isEditing, clearedFields);
const couplingTooltipKey = getRequireFieldsFieldCouplingTooltipKey(fieldKey, effectiveForm, category, touchedFields, isEditing, clearedFields, couplingInteractionFields);
const couplingTooltip = couplingTooltipKey ? translate(`workspace.rules.requireFieldsRule.${couplingTooltipKey}`) : undefined;
const shouldDisplayCouplingTooltip = !!couplingTooltip && dismissedCouplingTooltipKey !== couplingTooltipKey;

const hideCouplingTooltip = useCallback(() => {
setDismissedCouplingTooltipKey(couplingTooltipKey);
}, [couplingTooltipKey]);
const couplingTooltipName = couplingTooltipKey ? REQUIRE_FIELDS_COUPLING_TOOLTIP_NAMES[couplingTooltipKey] : undefined;
// Wait for the NVP so an already-dismissed tooltip doesn't flash before the dismissal arrives.
const shouldDisplayCouplingTooltip =
!!couplingTooltip &&
!!couplingTooltipName &&
!isLoadingOnyxValue(dismissedProductTrainingMetadata) &&
!isProductTrainingElementDismissed(couplingTooltipName, dismissedProductTraining);

const hideCouplingTooltip = useCallback(
(isDismissedUsingCloseButton = false) => {
if (!couplingTooltipName) {
return;
}

dismissProductTraining(couplingTooltipName, isDismissedUsingCloseButton);
},
[couplingTooltipName],
);

const renderCouplingTooltipContent = useCallback(() => {
return (
Expand All @@ -83,7 +107,7 @@ function FieldRequirementSettingRow({
shouldUseAutoHitSlop
accessibilityLabel={translate('common.noThanks')}
role={CONST.ROLE.BUTTON}
onPress={hideCouplingTooltip}
onPress={() => hideCouplingTooltip(true)}
>
<Icon
src={expensifyIcons.Close}
Expand Down Expand Up @@ -122,14 +146,28 @@ function FieldRequirementSettingRow({
onSelectSetting(fieldKey, newSetting);
};

// Receipt fields have a third state (no override, so the policy-level requirement applies) that a switch cannot
// express, so they keep the direction pills. Description and Attendees are booleans and read better as a switch.
const hasWaiveState = canClearRequireFieldsField(fieldKey);
const isDisabled = !canWriteRules || isCouplingDisabled;

const rowContent = (
<View style={[styles.ph5, styles.pv3, styles.flexRow, styles.alignItemsCenter, styles.justifyContentBetween, styles.gap3, styles.flexWrap]}>
<Text style={[styles.flexShrink1, styles.pr3, styles.alignSelfCenter]}>{label}</Text>
<FieldRequirementsDirectionToggle
direction={setting}
disabled={!canWriteRules || isCouplingDisabled}
onSelect={handleSelectSetting}
/>
{hasWaiveState ? (
<FieldRequirementsDirectionToggle
direction={setting}
disabled={isDisabled}
onSelect={handleSelectSetting}
/>
) : (
<Switch
isOn={setting === CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE}
disabled={isDisabled}
accessibilityLabel={label}
onToggle={(isOn) => handleSelectSetting(isOn ? CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE : CONST.FIELD_REQUIREMENTS_DIRECTION.DO_NOT_REQUIRE)}
/>
)}
</View>
);

Expand All @@ -146,7 +184,7 @@ function FieldRequirementSettingRow({
anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}}
shiftHorizontal={variables.mileageRateTooltipShiftHorizontal}
shiftVertical={variables.mileageRateTooltipShiftVertical}
onTooltipPress={hideCouplingTooltip}
onTooltipPress={() => hideCouplingTooltip()}
shouldHideOnScroll
>
{rowContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ function FieldRequirementsDirectionToggle({direction, disabled = false, onSelect
const isRequireSelected = direction === CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE;
const isWaiveDirectionSelected = direction === CONST.FIELD_REQUIREMENTS_DIRECTION.DO_NOT_REQUIRE;

// Pressing the selected direction clears it, which drops the override so the policy-level requirement applies.
const selectDirection = (isSelected: boolean, nextDirection: FieldRequirementsDirection) => {
onSelect(isSelected ? undefined : nextDirection);
};

return (
<View style={[styles.flexRow, styles.border, styles.borderRadiusNormal]}>
<Button
onPress={() => onSelect(isRequireSelected ? undefined : CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE)}
onPress={() => selectDirection(isRequireSelected, CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE)}
isDisabled={disabled}
size={CONST.BUTTON_SIZE.SMALL}
style={styles.ph0}
Expand All @@ -39,7 +44,7 @@ function FieldRequirementsDirectionToggle({direction, disabled = false, onSelect
</Button.Text>
</Button>
<Button
onPress={() => onSelect(isWaiveDirectionSelected ? undefined : CONST.FIELD_REQUIREMENTS_DIRECTION.DO_NOT_REQUIRE)}
onPress={() => selectDirection(isWaiveDirectionSelected, CONST.FIELD_REQUIREMENTS_DIRECTION.DO_NOT_REQUIRE)}
isDisabled={disabled}
size={CONST.BUTTON_SIZE.SMALL}
style={styles.ph0}
Expand Down
60 changes: 46 additions & 14 deletions src/libs/RequireFieldsRulesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {Policy, PolicyCategories, PolicyCategory} from '@src/types/onyx';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';

import type {ValueOf} from 'type-fest';

import {
removePolicyCategoryItemizedReceiptsRequired,
removePolicyCategoryReceiptsRequired,
Expand Down Expand Up @@ -839,6 +841,15 @@ type RequireFieldsDisplayedSettingParams = {
isEditing: boolean;
};

/**
* Description and Attendees are stored as booleans, so "Don't require" is indistinguishable from having no
* override and there is no third state to deselect back to. Receipt fields do have one — no override at all,
* meaning the policy-level receipt requirement still applies — so only those can be cleared.
*/
function canClearRequireFieldsField(fieldKey: RequireFieldsRuleSettingFieldKey): boolean {
return fieldKey === INPUT_IDS.RECEIPT_SETTING || fieldKey === INPUT_IDS.ITEMIZED_RECEIPT_SETTING;
}

function getRequireFieldsDisplayedSetting({
fieldKey,
category,
Expand All @@ -849,25 +860,35 @@ function getRequireFieldsDisplayedSetting({
clearedFields,
isEditing,
}: RequireFieldsDisplayedSettingParams): FieldRequirementsDirection | undefined {
if (clearedFields?.has(fieldKey)) {
return undefined;
}
const displayedSetting = ((): FieldRequirementsDirection | undefined => {
if (clearedFields?.has(fieldKey)) {
return undefined;
}

if (touchedFields?.has(fieldKey)) {
return effectiveForm?.[fieldKey];
}
if (touchedFields?.has(fieldKey)) {
return effectiveForm?.[fieldKey];
}

// After changing category on edit, the draft holds the preserved rule settings and may
// remount without local touched state — read those explicit draft values directly.
if (isEditing && originalCategoryName && rawForm?.[INPUT_IDS.CATEGORY] && rawForm[INPUT_IDS.CATEGORY] !== originalCategoryName) {
return rawForm[fieldKey];
}
// After changing category on edit, the draft holds the preserved rule settings and may
// remount without local touched state — read those explicit draft values directly.
if (isEditing && originalCategoryName && rawForm?.[INPUT_IDS.CATEGORY] && rawForm[INPUT_IDS.CATEGORY] !== originalCategoryName) {
return rawForm[fieldKey];
}

if (isEditing) {
return getActiveFieldRequirementsDirection(category, fieldKey);
if (isEditing) {
return getActiveFieldRequirementsDirection(category, fieldKey);
}

return undefined;
})();

// A missing value on a boolean-backed field means Don't require, so show it selected rather than
// leaving the toggle blank. Receipt fields keep a blank state for "no override".
if (displayedSetting === undefined && !canClearRequireFieldsField(fieldKey)) {
return CONST.FIELD_REQUIREMENTS_DIRECTION.DO_NOT_REQUIRE;
Comment thread
Krishna2323 marked this conversation as resolved.
}

return undefined;
return displayedSetting;
}

/**
Expand Down Expand Up @@ -909,6 +930,15 @@ function isRequireFieldsFieldCouplingDisabled(

type RequireFieldsFieldCouplingTooltipKey = 'receiptDisabledWhenItemizedRequired' | 'itemizedDisabledWhenReceiptWaived';

/**
* Name this coupling tooltip is dismissed under in the dismissed-product-training NVP, so dismissing it once
* keeps it dismissed for later rules instead of only for the current mount.
*/
const REQUIRE_FIELDS_COUPLING_TOOLTIP_NAMES: Record<RequireFieldsFieldCouplingTooltipKey, ValueOf<typeof CONST.PRODUCT_TRAINING_TOOLTIP_NAMES>> = {
receiptDisabledWhenItemizedRequired: CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP,
itemizedDisabledWhenReceiptWaived: CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP,
};

function getRequireFieldsFieldCouplingTooltipKey(
fieldKey: RequireFieldsRuleSettingFieldKey,
effectiveForm: RequireFieldsRuleForm | undefined,
Expand Down Expand Up @@ -939,8 +969,10 @@ function getRequireFieldsFieldCouplingTooltipKey(
}

export {
canClearRequireFieldsField,
categoryHasAnyRequireFieldsRule,
deleteRequireFieldsRule,
REQUIRE_FIELDS_COUPLING_TOOLTIP_NAMES,
formatRequireFieldsRuleDescriptions,
getActiveFieldRequirementsDirection,
getEffectiveRequireFieldsRuleForm,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ function RequireFieldsRuleCategoryPageBase({policyID, categoryName}: RequireFiel

for (const fieldKey of SETTING_FIELD_KEYS) {
if (isEditing) {
// Edit drafts are seeded with DO_NOT_REQUIRE for inactive fields. Only carry over
// settings that are actually selected in the UI (active category overrides).
// Carry over whatever the row currently shows. Description and Attendees are boolean-backed,
// so they always resolve to a direction (Don't require when there is no override) and are
// always carried; the receipt fields keep their blank "no override" state and are skipped.
const displayedSetting = getRequireFieldsDisplayedSetting({
fieldKey,
category: selectedCategory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName,
label: translate('common.description'),
isVisible: true,
},
{
key: INPUT_IDS.ATTENDEES_SETTING,
label: translate('iou.attendees'),
isVisible: isAttendeeFieldApplicable,
},
{
key: INPUT_IDS.RECEIPT_SETTING,
label: translate('common.receipt'),
Expand All @@ -233,11 +238,6 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName,
label: translate('workspace.rules.requireFieldsRule.itemizedReceipt'),
isVisible: true,
},
{
key: INPUT_IDS.ATTENDEES_SETTING,
label: translate('iou.attendees'),
isVisible: isAttendeeFieldApplicable,
},
];

// Prefer the selected category, but fall back to the route category so edit validation
Expand Down
26 changes: 24 additions & 2 deletions src/types/onyx/DismissedProductTraining.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import CONST from '@src/CONST';

const {CONCIERGE_LHN_GBR, OUTSTANDING_FILTER, ACCOUNT_SWITCHER, SCAN_TEST_DRIVE_CONFIRMATION, MULTI_SCAN_EDUCATIONAL_MODAL, GPS_TOOLTIP, HAS_FILTER_NEGATION, MILEAGE_RATE_AUTO_UPDATED} =
CONST.PRODUCT_TRAINING_TOOLTIP_NAMES;
const {
CONCIERGE_LHN_GBR,
OUTSTANDING_FILTER,
ACCOUNT_SWITCHER,
SCAN_TEST_DRIVE_CONFIRMATION,
MULTI_SCAN_EDUCATIONAL_MODAL,
GPS_TOOLTIP,
HAS_FILTER_NEGATION,
MILEAGE_RATE_AUTO_UPDATED,
REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP,
REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP,
} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES;

/**
* This type is used to store the timestamp of when the user dismisses a product training ui elements.
Expand Down Expand Up @@ -78,6 +88,18 @@ type DismissedProductTraining = {
* When user dismisses the mileage rate auto-updated tooltip, we store the timestamp here.
*/
[MILEAGE_RATE_AUTO_UPDATED]: DismissedProductTrainingElement;

/**
* When user dismisses the require fields rule tooltip explaining why Receipt is locked while
* Itemized receipt is required, we store the timestamp here.
*/
[REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP]: DismissedProductTrainingElement;

/**
* When user dismisses the require fields rule tooltip explaining why Itemized receipt is locked
* while Receipt is waived, we store the timestamp here.
*/
[REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP]: DismissedProductTrainingElement;
};

export default DismissedProductTraining;
Loading