Skip to content
Merged
2 changes: 2 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5724,6 +5725,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU
reportFieldNameRequiredError: 'Bitte gib einen Berichtsfeldnamen ein',
reportFieldTypeRequiredError: 'Bitte wähle einen Berichtsfeldtyp aus',
circularReferenceError: 'Dieses Feld kann nicht auf sich selbst verweisen. Bitte aktualisieren.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Formelfeld ${value} nicht erkannt`,
reportFieldInitialValueRequiredError: 'Bitte wähle einen Anfangswert für ein Berichtsfeld aus',
genericFailureMessage: 'Beim Aktualisieren des Berichtfelds ist ein Fehler aufgetreten. Bitte versuche es erneut.',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5708,6 +5709,7 @@ const translations = {
reportFieldNameRequiredError: 'Please enter a report field name',
reportFieldTypeRequiredError: 'Please choose a report field type',
circularReferenceError: "This field can't refer to itself. Please update.",
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Formula field ${value} not recognized`,
reportFieldInitialValueRequiredError: 'Please choose a report field initial value',
genericFailureMessage: 'An error occurred while updating the report field. Please try again.',
},
Expand Down
3 changes: 2 additions & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import dedent from '@libs/StringUtils/dedent';
import CONST from '@src/CONST';
import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage';
import type en from './en';
import type {ConciergeBrokenCardConnectionParams, CreatedReportForUnapprovedTransactionsParams, PaidElsewhereParams} from './params';
import type {ConciergeBrokenCardConnectionParams, CreatedReportForUnapprovedTransactionsParams, PaidElsewhereParams, UnsupportedFormulaValueErrorParams} from './params';
import type {TranslationDeepObject} from './types';

/* eslint-disable max-len */
Expand Down Expand Up @@ -5557,6 +5557,7 @@ ${amount} para ${merchant} - ${date}`,
reportFieldNameRequiredError: 'Ingresa un nombre de campo de informe',
reportFieldTypeRequiredError: 'Elige un tipo de campo de informe',
circularReferenceError: 'Este campo no puede hacer referencia a sí mismo. Por favor, actualizar.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `El campo de fórmula ${value} no se reconoce`,
reportFieldInitialValueRequiredError: 'Elige un valor inicial de campo de informe',
genericFailureMessage: 'Se ha producido un error al actualizar el campo de informe. Por favor, inténtalo de nuevo.',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5745,6 +5746,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST.
reportFieldNameRequiredError: 'Veuillez saisir un nom de champ de note de frais',
reportFieldTypeRequiredError: 'Veuillez choisir un type de champ de note de frais',
circularReferenceError: 'Ce champ ne peut pas faire référence à lui-même. Veuillez le mettre à jour.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Champ de formule ${value} non reconnu`,
reportFieldInitialValueRequiredError: 'Veuillez choisir une valeur initiale pour le champ de note de frais',
genericFailureMessage: 'Une erreur s’est produite lors de la mise à jour du champ de note de frais. Veuillez réessayer.',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5717,6 +5718,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST.
reportFieldNameRequiredError: 'Inserisci un nome per il campo del report',
reportFieldTypeRequiredError: 'Scegli un tipo di campo del report',
circularReferenceError: 'Questo campo non può fare riferimento a se stesso. Aggiorna per favore.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Campo formula ${value} non riconosciuto`,
reportFieldInitialValueRequiredError: 'Scegli un valore iniziale per il campo del resoconto',
genericFailureMessage: 'Si è verificato un errore durante l’aggiornamento del campo del report. Riprova.',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5651,6 +5652,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO
reportFieldNameRequiredError: 'レポート項目名を入力してください',
reportFieldTypeRequiredError: 'レポートフィールドの種類を選択してください',
circularReferenceError: 'このフィールドを自分自身に参照することはできません。更新してください。',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `数式フィールド ${value} が認識されません`,
reportFieldInitialValueRequiredError: 'レポート項目の初期値を選択してください',
genericFailureMessage: 'レポートフィールドの更新中にエラーが発生しました。もう一度お試しください。',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5699,6 +5700,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_
reportFieldNameRequiredError: 'Voer een naam voor een rapportveld in',
reportFieldTypeRequiredError: 'Kies een veldtype voor het rapport',
circularReferenceError: 'Dit veld kan niet naar zichzelf verwijzen. Werk het alsjeblieft bij.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Formuleveld ${value} niet herkend`,
reportFieldInitialValueRequiredError: 'Kies een beginwaarde voor een rapportveld',
genericFailureMessage: 'Er is een fout opgetreden bij het bijwerken van het rapportveld. Probeer het opnieuw.',
},
Expand Down
5 changes: 5 additions & 0 deletions src/languages/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ type MarkReimbursedFromIntegrationParams = {amount: string; currency: string};

type ShareParams = {to: string};

type UnsupportedFormulaValueErrorParams = {
value: string;
};

type UnshareParams = {to: string};

type ConnectionNameParams = {
Expand Down Expand Up @@ -236,6 +240,7 @@ export type {
MarkReimbursedFromIntegrationParams,
ShareParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
ConnectionNameParams,
ExportAgainModalDescriptionParams,
UpdateRoleParams,
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5691,6 +5692,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy
reportFieldNameRequiredError: 'Wprowadź nazwę pola raportu',
reportFieldTypeRequiredError: 'Wybierz typ pola raportu',
circularReferenceError: 'To pole nie może odnosić się do siebie. Zaktualizuj je.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Pole formuły ${value} nie zostało rozpoznane`,
reportFieldInitialValueRequiredError: 'Wybierz początkową wartość pola raportu',
genericFailureMessage: 'Wystąpił błąd podczas aktualizowania pola raportu. Spróbuj ponownie.',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5698,6 +5699,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS
reportFieldNameRequiredError: 'Insira um nome de campo de relatório',
reportFieldTypeRequiredError: 'Escolha um tipo de campo de relatório',
circularReferenceError: 'Este campo não pode fazer referência a si mesmo. Atualize, por favor.',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `Campo de fórmula ${value} não reconhecido`,
reportFieldInitialValueRequiredError: 'Escolha um valor inicial para o campo de relatório',
genericFailureMessage: 'Ocorreu um erro ao atualizar o campo do relatório. Tente novamente.',
},
Expand Down
2 changes: 2 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
StepCounterParams,
SyncStageNameConnectionsParams,
UnshareParams,
UnsupportedFormulaValueErrorParams,
UpdatedBudgetParams,
UpdatedPolicyApprovalRuleParams,
UpdatedPolicyCategoryMaxAmountNoReceiptParams,
Expand Down Expand Up @@ -5547,6 +5548,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
reportFieldNameRequiredError: '请输入报表字段名称',
reportFieldTypeRequiredError: '请选择报表字段类型',
circularReferenceError: '此字段不能引用自身。请更新。',
unsupportedFormulaValueError: ({value}: UnsupportedFormulaValueErrorParams) => `无法识别公式字段 ${value}`,
reportFieldInitialValueRequiredError: '请选择报表字段的初始值',
genericFailureMessage: '更新报表字段时出错。请重试。',
},
Expand Down
2 changes: 1 addition & 1 deletion src/libs/Formula.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,4 +973,4 @@ function resolveReportFieldValue(

export {FORMULA_PART_TYPES, compute, parse, hasCircularReferences, resolveReportFieldValue};

export type {FormulaContext, FieldList, MinimalTransaction};
export type {FormulaContext, FieldList, FormulaPart, MinimalTransaction};
103 changes: 102 additions & 1 deletion src/libs/WorkspaceReportFieldUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type ONYXKEYS from '@src/ONYXKEYS';
import type {InputID} from '@src/types/form/WorkspaceReportFieldForm';
import type {PolicyReportField, PolicyReportFieldType} from '@src/types/onyx/Policy';
import {addErrorMessage} from './ErrorUtils';
import type {FormulaPart} from './Formula';
import {isRequiredFulfilled} from './ValidationUtils';

/**
Expand Down Expand Up @@ -96,7 +97,7 @@ function hasFormulaPartsInInitialValue(initialValue?: string): boolean {

// Dynamically require to avoid circular dependency with ReportActionsUtils
const {parse, FORMULA_PART_TYPES} = require('./Formula') as {
parse: (formula?: string) => Array<{type: string}>;
parse: (formula?: string) => FormulaPart[];
FORMULA_PART_TYPES: {FREETEXT: string};
};
return parse(initialValue).some((part) => part.type !== FORMULA_PART_TYPES.FREETEXT);
Expand All @@ -109,12 +110,112 @@ function isReportFieldNameExisting(fieldList: Record<string, PolicyReportField>
return Object.values(fieldList ?? {}).some((reportField) => reportField.name.toLowerCase() === fieldName.toLowerCase());
}

/**
* Returns the list of unsupported {report:*} formula parts in the initial value.
* Used to validate formula report fields so unsupported tokens (e.g. {report:i}) are rejected with a clear error.
*/
function getUnsupportedReportFieldFormulaParts(initialValue?: string): string[] {
if (!initialValue || typeof initialValue !== 'string') {
return [];
}

// Dynamically require to avoid circular dependency with ReportActionsUtils
const {parse, FORMULA_PART_TYPES} = require('./Formula') as {
parse: (formula?: string) => FormulaPart[];
FORMULA_PART_TYPES: {REPORT: string};
};

// cspell:ignore oldid
const supportedReportFields = new Set([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're missing some accepted formulas. Can you check here and test adding them?

https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#formulas

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @linhvovan29546

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean we're only validating the formula here

'id',
'oldid',
'title',
'status',
'displaystatus',
'expensescount',
'type',
'startdate',
'enddate',
'total',
'reimbursable',
'currency',
'policyname',
'workspacename',
'created',
'approve',
'submit',
'autoreporting',
]);

const supportedSubmitDirections = new Set(['from', 'to', 'date']);
const supportedSubmitPersonFields = new Set(['firstname', 'lastname', 'fullname', 'email', 'userid', 'customfield1', 'payrollid', 'customfield2']);
const supportedAutoReportingFields = new Set(['start', 'end']);

const unsupported: string[] = [];
const parts = parse(initialValue);

for (const part of parts) {
if (part.type !== FORMULA_PART_TYPES.REPORT) {
continue;
}

const [field, ...rest] = part.fieldPath ?? [];
const normalizedField = field?.trim().toLowerCase();

if (!normalizedField || !supportedReportFields.has(normalizedField)) {
unsupported.push(part.definition);
continue;
}

if (normalizedField === 'submit') {
const direction = rest.at(0)?.trim().toLowerCase();
if (!direction || !supportedSubmitDirections.has(direction)) {
unsupported.push(part.definition);
continue;
}

// report:submit:from and report:submit:to are valid without subfield.
if ((direction === 'from' || direction === 'to') && rest.length === 1) {
continue;
}

// report:submit:date is valid (format is optional and validated elsewhere)
if (direction === 'date') {
continue;
}

// report:submit:from:* and report:submit:to:* need a subfield
const submitField = rest.at(1)?.trim().toLowerCase();
if (!submitField || !supportedSubmitPersonFields.has(submitField)) {
Comment on lines +188 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Permit {report:submit:from}/{report:submit:to} tokens

This validation now treats report:submit:from and report:submit:to as invalid unless a third segment is present, so formulas using those 2-segment tokens will be blocked with unsupportedFormulaValueError. That conflicts with our documented/embedded formula vocabulary where both tokens are valid (see docs/articles/new-expensify/reports-and-expenses/Customize-and-Enforce-Report-Titles.md lines 62-63 and src/languages/en.ts line 5404), so this introduces a regression by rejecting formulas users are told to use.

Useful? React with 👍 / 👎.

unsupported.push(part.definition);
}
continue;
}

if (normalizedField === 'autoreporting') {
const subField = rest.at(0)?.trim().toLowerCase();
if (!subField || !supportedAutoReportingFields.has(subField)) {
unsupported.push(part.definition);
}
}

if (normalizedField === 'approve') {
if (rest.at(0)?.trim().toLowerCase() !== 'date') {
unsupported.push(part.definition);
}
}
}

return unsupported;
}

export {
getReportFieldTypeTranslationKey,
getReportFieldAlternativeTextTranslationKey,
validateReportFieldListValueName,
generateFieldID,
getReportFieldInitialValue,
getUnsupportedReportFieldFormulaParts,
hasFormulaPartsInInitialValue,
isReportFieldNameExisting,
};
11 changes: 10 additions & 1 deletion src/pages/workspace/reports/CreateReportFieldsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import {hasAccountingConnections} from '@libs/PolicyUtils';
import {isRequiredFulfilled} from '@libs/ValidationUtils';
import {hasFormulaPartsInInitialValue, isReportFieldNameExisting} from '@libs/WorkspaceReportFieldUtils';
import {getUnsupportedReportFieldFormulaParts, hasFormulaPartsInInitialValue, isReportFieldNameExisting} from '@libs/WorkspaceReportFieldUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
Expand Down Expand Up @@ -106,6 +106,15 @@ function WorkspaceCreateReportFieldsPage({
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', {
value: unsupportedFormulaParts.join(', '),
});
}
}

if (type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.reportFieldInitialValueRequiredError');
}
Expand Down
11 changes: 10 additions & 1 deletion src/pages/workspace/reports/ReportFieldsInitialValuePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import {getReportFieldKey} from '@libs/ReportUtils';
import {isRequiredFulfilled} from '@libs/ValidationUtils';
import {getReportFieldInitialValue} from '@libs/WorkspaceReportFieldUtils';
import {getReportFieldInitialValue, getUnsupportedReportFieldFormulaParts} from '@libs/WorkspaceReportFieldUtils';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
Expand Down Expand Up @@ -76,6 +76,15 @@ function ReportFieldsInitialValuePage({
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.circularReferenceError');
}

if ((reportField?.type === CONST.REPORT_FIELD_TYPES.TEXT || reportField?.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', {
value: unsupportedFormulaParts.join(', '),
});
}
}

if (reportField?.type === CONST.REPORT_FIELD_TYPES.LIST && availableListValuesLength > 0 && !isRequiredFulfilled(formInitialValue)) {
errors[INPUT_IDS.INITIAL_VALUE] = translate('workspace.reportFields.reportFieldInitialValueRequiredError');
}
Expand Down
Loading
Loading