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
5 changes: 3 additions & 2 deletions src/libs/CopyPolicySettingsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {Part} from './actions/Policy/CopyPolicySettings';

import {isAuthenticationError} from './actions/connections';
import {PART_TO_POLICY_FEATURE} from './actions/Policy/CopyPolicySettings';
import {canPolicyAccessFeature, isCollectPolicy, isTimeTrackingEnabled, isWorkspaceProvisionedForTravel} from './PolicyUtils';
import {canPolicyAccessFeature, isCollectPolicy, isInvoiceFieldsEnabled, isTimeTrackingEnabled, isWorkspaceProvisionedForTravel} from './PolicyUtils';

type FeatureRow = {
part: Part;
Expand Down Expand Up @@ -316,7 +316,8 @@ function getControlOnlySelectedParts(targetPolicies: ReadonlyArray<Policy | unde
if (collectTargets.length === 0) {
return [];
}
const hasInvoiceFields = !!sourcePolicy?.areInvoiceFieldsEnabled || Object.values(sourcePolicy?.fieldList ?? {}).some((field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE);
const hasInvoiceFields =
isInvoiceFieldsEnabled(sourcePolicy ?? undefined) || Object.values(sourcePolicy?.fieldList ?? {}).some((field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE);
return selectedParts.filter((part) => {
const featureName = part === 'invoices' && hasInvoiceFields ? CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED : PART_TO_POLICY_FEATURE[part];
if (!featureName) {
Expand Down
12 changes: 12 additions & 0 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1793,10 +1793,21 @@ function arePolicyRulesEnabled(policy: OnyxEntry<Policy>, policyCategories?: Pol
return hasAnyCategoryRules(policyCategories ?? undefined);
}

/**
* Whether Invoice Fields is enabled for the policy.
* Respects the `areInvoiceFieldsEnabled` toggle and verifies the policy has access to the feature (Control only).
*/
function isInvoiceFieldsEnabled(policy: OnyxEntry<Policy> | null): boolean {
return !!policy?.areInvoiceFieldsEnabled && canPolicyAccessFeature(policy ?? undefined, CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED);
}

function isPolicyFeatureEnabled(policy: OnyxEntry<Policy>, featureName: PolicyFeatureName, policyCategories?: PolicyCategories | null): boolean {
if (featureName === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED) {
return arePolicyRulesEnabled(policy, policyCategories);
}
if (featureName === CONST.POLICY.MORE_FEATURES.ARE_INVOICE_FIELDS_ENABLED) {
return isInvoiceFieldsEnabled(policy);
}
if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) {
return !!policy?.tax?.trackingEnabled;
}
Expand Down Expand Up @@ -3702,6 +3713,7 @@ export {
getActivePoliciesWithExpenseChatAndPerDiemEnabled,
isPerDiemEnabled,
isPerDiemEligiblePolicy,
isInvoiceFieldsEnabled,
getTravelStep,
isWorkspaceProvisionedForTravel,
hasAcceptedTravelTerms,
Expand Down
18 changes: 15 additions & 3 deletions src/libs/WorkspaceReportFieldUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ function hasFormulaPartsInInitialValue(initialValue?: string): boolean {
}

/**
* Checks if a report field name already exists in the policy's field list (case-insensitive).
* Finds an existing report field with the specified name in the policy's field list (case-insensitive).
*/
function isReportFieldNameExisting(fieldList: Record<string, PolicyReportField> | undefined, fieldName: string, expectedTarget?: ValueOf<typeof CONST.REPORT_FIELD_TARGETS>): boolean {
return Object.values(fieldList ?? {}).some((reportField) => {
function getExistingReportFieldByName(
fieldList: Record<string, PolicyReportField> | undefined,
fieldName: string,
expectedTarget?: ValueOf<typeof CONST.REPORT_FIELD_TARGETS>,
): PolicyReportField | undefined {
return Object.values(fieldList ?? {}).find((reportField) => {
if (!isReportFieldTargetValid(reportField, expectedTarget)) {
return false;
}
Expand All @@ -124,6 +128,13 @@ function isReportFieldNameExisting(fieldList: Record<string, PolicyReportField>
});
}

/**
* Checks if a report field name already exists in the policy's field list (case-insensitive).
*/
function isReportFieldNameExisting(fieldList: Record<string, PolicyReportField> | undefined, fieldName: string, expectedTarget?: ValueOf<typeof CONST.REPORT_FIELD_TARGETS>): boolean {
return !!getExistingReportFieldByName(fieldList, fieldName, expectedTarget);
}

/**
* Determines whether a report field matches the expected target.
*/
Expand Down Expand Up @@ -276,6 +287,7 @@ export {
getUnsupportedReportFieldFormulaParts,
hasFormulaPartsInInitialValue,
isReportFieldNameExisting,
getExistingReportFieldByName,
isReportFieldTargetValid,
getReportFieldsForTarget,
isReportFieldImportedFromIntegration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ import {
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types';
import type {PolicyCopySettingsNavigatorParamList} from '@libs/Navigation/types';
import {createFilteredMemberCountSelector, createInvoiceConfigurationTextSelector, getDistanceRateCustomUnit, getPerDiemCustomUnit, isCollectPolicy} from '@libs/PolicyUtils';
import {
createFilteredMemberCountSelector,
createInvoiceConfigurationTextSelector,
getDistanceRateCustomUnit,
getPerDiemCustomUnit,
isCollectPolicy,
isInvoiceFieldsEnabled,
} from '@libs/PolicyUtils';
import {formatAddressToString} from '@libs/ReportActionsUtils';
import {getReportFieldsByPolicyID} from '@libs/ReportUtils';

Expand Down Expand Up @@ -135,7 +142,7 @@ function CopyPolicySettingsSelectFeaturesPage() {
hasWorkflowRules: !!workflows?.length,
hasWorkspaceRules: !!rules?.length,
codingRulesCount,
hasInvoiceConfiguration: !!sourcePolicy?.areInvoicesEnabled && (!!invoiceConfigurationText || invoiceFieldsCount > 0),
hasInvoiceConfiguration: !!sourcePolicy?.areInvoicesEnabled && (!!invoiceConfigurationText || invoiceFieldsCount > 0 || isInvoiceFieldsEnabled(sourcePolicy)),
isCollectPolicy: isCollectPolicy(sourcePolicy),
};

Expand Down
108 changes: 58 additions & 50 deletions src/pages/workspace/fields/CreateFieldsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {addErrorMessage} from '@libs/ErrorUtils';
import {hasCircularReferences} from '@libs/Formula';
import Navigation from '@libs/Navigation/Navigation';
import {isRequiredFulfilled} from '@libs/ValidationUtils';
import {getReportFieldsForTarget, getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue, isReportFieldNameExisting} from '@libs/WorkspaceReportFieldUtils';
import {getExistingReportFieldByName, getReportFieldsForTarget, getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue} from '@libs/WorkspaceReportFieldUtils';

import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import InitialListValueSelector from '@pages/workspace/reports/InitialListValueSelector';
Expand Down Expand Up @@ -88,63 +88,71 @@ function CreateFieldsPage({policy, policyID, isInvoiceField, listValuesRoute, ge
[availableListValuesLength, formDraft, isInvoiceField, policy, policyReportIDs],
);

const validateForm = useCallback(
(values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM> => {
const {name, type, initialValue: formInitialValue} = values;
const errors: FormInputErrors<typeof ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM> = {};

if (!isRequiredFulfilled(name)) {
errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.invoiceFieldNameRequiredError' : 'workspace.reportFields.reportFieldNameRequiredError');
} else if (isReportFieldNameExisting(policy?.fieldList, name)) {
errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.existingInvoiceFieldNameError' : 'workspace.reportFields.existingReportFieldNameError');
} else if ([...name].length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) {
addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', [...name].length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH));
}
const getExistingFieldNameError = (name: string) => {
const existingField = getExistingReportFieldByName(policy?.fieldList, name);
if (!existingField) {
return undefined;
}

if (!isRequiredFulfilled(type)) {
errors[INPUT_IDS.TYPE] = translate(isInvoiceField ? 'workspace.invoiceFields.invoiceFieldTypeRequiredError' : 'workspace.reportFields.reportFieldTypeRequiredError');
}
return translate(
existingField.target === CONST.REPORT_FIELD_TARGETS.INVOICE ? 'workspace.invoiceFields.existingInvoiceFieldNameError' : 'workspace.reportFields.existingReportFieldNameError',
);
};

if (type === CONST.REPORT_FIELD_TYPES.TEXT && !!formInitialValue && formInitialValue.length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('common.error.characterLimitExceedCounter', formInitialValue.length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH);
}
const validateForm = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM> => {
const {name, type, initialValue: formInitialValue} = values;
const errors: FormInputErrors<typeof ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM> = {};

if (
(type === CONST.REPORT_FIELD_TYPES.TEXT || type === CONST.REPORT_FIELD_TYPES.FORMULA) &&
hasCircularReferences(formInitialValue, name, getReportFieldsForTarget(policy?.fieldList, fieldTarget))
) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError');
}
const existingFieldNameError = getExistingFieldNameError(name);

if ((type === CONST.REPORT_FIELD_TYPES.TEXT || type === CONST.REPORT_FIELD_TYPES.FORMULA) && !!formInitialValue && !errors[INPUT_IDS.INITIAL_VALUE]) {
const unsupportedFormulaParts = getUnsupportedReportFieldFormulaParts(formInitialValue);
if (unsupportedFormulaParts.length > 0) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.unsupportedFormulaValueError', unsupportedFormulaParts.join(', '));
}
}
if (!isRequiredFulfilled(name)) {
errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.invoiceFieldNameRequiredError' : 'workspace.reportFields.reportFieldNameRequiredError');
} else if (existingFieldNameError) {
errors[INPUT_IDS.NAME] = existingFieldNameError;
} else if ([...name].length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) {
addErrorMessage(errors, INPUT_IDS.NAME, translate('common.error.characterLimitExceedCounter', [...name].length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH));
}

if (type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) {
errors[INPUT_IDS.INITIAL_VALUE] = translate(
isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError',
);
}
if (!isRequiredFulfilled(type)) {
errors[INPUT_IDS.TYPE] = translate(isInvoiceField ? 'workspace.invoiceFields.invoiceFieldTypeRequiredError' : 'workspace.reportFields.reportFieldTypeRequiredError');
}

return errors;
},
[availableListValuesLength, fieldTarget, isInvoiceField, policy?.fieldList, translate],
);
if (type === CONST.REPORT_FIELD_TYPES.TEXT && !!formInitialValue && formInitialValue.length > CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('common.error.characterLimitExceedCounter', formInitialValue.length, CONST.WORKSPACE_REPORT_FIELD_POLICY_MAX_LENGTH);
}

const validateName = useCallback(
(values: Record<string, string>) => {
const errors: Record<string, string> = {};
const name = values[INPUT_IDS.NAME];
if (isReportFieldNameExisting(policy?.fieldList, name)) {
errors[INPUT_IDS.NAME] = translate(isInvoiceField ? 'workspace.invoiceFields.existingInvoiceFieldNameError' : 'workspace.reportFields.existingReportFieldNameError');
if (
(type === CONST.REPORT_FIELD_TYPES.TEXT || type === CONST.REPORT_FIELD_TYPES.FORMULA) &&
hasCircularReferences(formInitialValue, name, getReportFieldsForTarget(policy?.fieldList, fieldTarget))
) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError');
}

if ((type === CONST.REPORT_FIELD_TYPES.TEXT || type === CONST.REPORT_FIELD_TYPES.FORMULA) && !!formInitialValue && !errors[INPUT_IDS.INITIAL_VALUE]) {
const unsupportedFormulaParts = getUnsupportedReportFieldFormulaParts(formInitialValue);
if (unsupportedFormulaParts.length > 0) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.unsupportedFormulaValueError', unsupportedFormulaParts.join(', '));
}
return errors;
},
[isInvoiceField, policy?.fieldList, translate],
);
}

if (type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) {
errors[INPUT_IDS.INITIAL_VALUE] = translate(
isInvoiceField ? 'workspace.invoiceFields.invoiceFieldInitialValueRequiredError' : 'workspace.reportFields.reportFieldInitialValueRequiredError',
);
}

return errors;
};

const validateName = (values: Record<string, string>) => {
const errors: Record<string, string> = {};
const name = values[INPUT_IDS.NAME];
const existingFieldNameError = getExistingFieldNameError(name);
if (existingFieldNameError) {
errors[INPUT_IDS.NAME] = existingFieldNameError;
}
return errors;
};

const handleOnValueCommitted = (initialValue: string) => {
setDraftValues(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import usePolicy from '@hooks/usePolicy';

import {enablePolicyInvoiceFields} from '@libs/actions/Policy/Policy';
import {isInvoiceFieldsEnabled} from '@libs/PolicyUtils';

import WorkspaceFieldsSection from '@pages/workspace/fields/WorkspaceFieldsSection';

Expand All @@ -22,7 +23,7 @@ function WorkspaceInvoiceFieldsSection({policyID}: WorkspaceInvoiceFieldsSection
<WorkspaceFieldsSection
policy={policy}
policyID={policyID}
isEnabled={!!policy?.areInvoiceFieldsEnabled}
isEnabled={isInvoiceFieldsEnabled(policy)}
pendingAction={policy?.pendingFields?.areInvoiceFieldsEnabled}
fieldFilter={(field) => field.target === CONST.REPORT_FIELD_TARGETS.INVOICE}
titleKey="workspace.common.invoiceFields"
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/CopyPolicySettingsUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ describe('CopyPolicySettingsUtils', () => {
distancePolicy.areDistanceRatesEnabled = true;
expect(isCopyPolicySettingsPartEnabledOnSource('distanceRates', {...baseContext, policy: distancePolicy})).toBe(true);
});

it('shows invoices only when the feature is enabled and has invoice configuration', () => {
const invoicePolicy = createRandomPolicy(12);
invoicePolicy.areInvoicesEnabled = true;

expect(isCopyPolicySettingsPartEnabledOnSource('invoices', {...baseContext, policy: invoicePolicy, hasInvoiceConfiguration: true})).toBe(true);
expect(isCopyPolicySettingsPartEnabledOnSource('invoices', {...baseContext, policy: invoicePolicy, hasInvoiceConfiguration: false})).toBe(false);

const disabledInvoicePolicy = createRandomPolicy(13);
disabledInvoicePolicy.areInvoicesEnabled = false;
expect(isCopyPolicySettingsPartEnabledOnSource('invoices', {...baseContext, policy: disabledInvoicePolicy, hasInvoiceConfiguration: true})).toBe(false);
});
});

describe('isTargetCompatibleForAccountingPart', () => {
Expand Down Expand Up @@ -448,6 +460,18 @@ describe('CopyPolicySettingsUtils', () => {
expect(getControlOnlySelectedParts([collectTarget(1)], ['rules'] as Part[])).toEqual([]);
});

it('treats invoices as Control-only when source policy has invoice fields enabled', () => {
const sourcePolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: true};
const result = getControlOnlySelectedParts([collectTarget(1)], ['invoices'] as Part[], sourcePolicy);
expect(result).toContain('invoices');
});

it('does not treat invoices as Control-only when source policy has no invoice fields', () => {
const sourcePolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), areInvoiceFieldsEnabled: false};
const result = getControlOnlySelectedParts([collectTarget(1)], ['invoices'] as Part[], sourcePolicy);
expect(result).not.toContain('invoices');
});

it('returns nothing when there are no Collect targets', () => {
expect(getControlOnlySelectedParts([controlTarget(1)], ['perDiem'] as Part[])).toEqual([]);
});
Expand Down
Loading
Loading