Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8192,6 +8192,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
44 changes: 34 additions & 10 deletions src/components/RequireFieldsRules/FieldRequirementSettingRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@ 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 +64,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 +106,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 @@ -128,6 +151,7 @@ function FieldRequirementSettingRow({
<FieldRequirementsDirectionToggle
direction={setting}
disabled={!canWriteRules || isCouplingDisabled}
canDeselect={canClearRequireFieldsField(fieldKey)}
onSelect={handleSelectSetting}
/>
</View>
Expand All @@ -146,7 +170,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 @@ -13,20 +13,36 @@ import {View} from 'react-native';
type FieldRequirementsDirectionToggleProps = {
direction?: FieldRequirementsDirection;
disabled?: boolean;

/** When false, pressing the selected direction does nothing instead of clearing it. */
canDeselect?: boolean;
onSelect: (direction: FieldRequirementsDirection | undefined) => void;
};

function FieldRequirementsDirectionToggle({direction, disabled = false, onSelect}: FieldRequirementsDirectionToggleProps) {
function FieldRequirementsDirectionToggle({direction, disabled = false, canDeselect = true, onSelect}: FieldRequirementsDirectionToggleProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();

const isRequireSelected = direction === CONST.FIELD_REQUIREMENTS_DIRECTION.REQUIRE;
const isWaiveDirectionSelected = direction === CONST.FIELD_REQUIREMENTS_DIRECTION.DO_NOT_REQUIRE;

const selectDirection = (isSelected: boolean, nextDirection: FieldRequirementsDirection) => {
if (!isSelected) {
onSelect(nextDirection);
return;
}

if (!canDeselect) {
return;
}

onSelect(undefined);
};

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 +55,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 @@ -91,8 +91,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
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