diff --git a/.dockerignore b/.dockerignore index 5756b9f9d..afea64e57 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ !/.gitignore !/babel.config.json !/Dockerfile -!/formulon.d.ts !/LICENSE.md !/next-sitemap.config.js !/nx.json diff --git a/apps/jetstream-e2e/src/fixtures/fixtures.ts b/apps/jetstream-e2e/src/fixtures/fixtures.ts index 098548355..46d5bed32 100644 --- a/apps/jetstream-e2e/src/fixtures/fixtures.ts +++ b/apps/jetstream-e2e/src/fixtures/fixtures.ts @@ -4,6 +4,7 @@ import * as dotenv from 'dotenv'; import { ApiRequestUtils, AuthenticationPage, + FormulaEvaluatorPage, LoadSingleObjectPage, LoadWithoutFilePage, OrgGroupPage, @@ -48,6 +49,7 @@ type MyFixtures = { playwrightPage: PlaywrightPage; authenticationPage: AuthenticationPage; newUser: Awaited>; + formulaEvaluatorPage: FormulaEvaluatorPage; queryPage: QueryPage; loadSingleObjectPage: LoadSingleObjectPage; orgGroupPage: OrgGroupPage; @@ -115,6 +117,10 @@ export const test = base.extend({ newUser: async ({ authenticationPage }, use) => { await use(await authenticationPage.signUpAndVerifyEmail()); }, + formulaEvaluatorPage: async ({ page, apiRequestUtils }, use) => { + await apiRequestUtils.selectDefaultOrg(); + await use(new FormulaEvaluatorPage(page, apiRequestUtils)); + }, queryPage: async ({ page, apiRequestUtils }, use) => { await apiRequestUtils.selectDefaultOrg(); await use(new QueryPage(page, apiRequestUtils)); diff --git a/apps/jetstream-e2e/src/tests/formula-evaluator/formula-evaluator.spec.ts b/apps/jetstream-e2e/src/tests/formula-evaluator/formula-evaluator.spec.ts new file mode 100644 index 000000000..397ca393a --- /dev/null +++ b/apps/jetstream-e2e/src/tests/formula-evaluator/formula-evaluator.spec.ts @@ -0,0 +1,295 @@ +import { expect, test } from '../../fixtures/fixtures'; + +/** + * Tests use the record "A - formula test record" (Account 001al00002FzdpCAAR) which has: + * - Name: "A - formula test record" + * - AnnualRevenue: 350000000 + * - NumberOfEmployees: 9000 + * - NumberofLocations__c: 6 + * - BillingCity: "Burlington", BillingState: "NC", BillingPostalCode: "27215", BillingCountry: "USA" + * - Phone: "(336) 222-7000" + * - Industry: "Apparel" + * - Type: "Customer - Direct" + * - SLA__c: "Silver" + * - SLAExpirationDate__c: "2022-01-22" + * - SLASerialNumber__c: "5367" + * - Website: "www.burlington.com" + * - Fax: "Burlington Textiles Corp of America" + * - TickerSymbol: "BTXT" + * - Ownership: "Public" + * - Rating: "Warm" + * - Owner.Name: "Integration Test", Owner.FirstName: "Integration", Owner.LastName: "Test" + * - Parent.Name: "University of Arizona" + * - Description: null, AccountSource: null, ShippingCity: null + */ + +test('FORMULA EVALUATOR', async ({ formulaEvaluatorPage }) => { + await formulaEvaluatorPage.goto(); + await formulaEvaluatorPage.selectObject('Account'); + await formulaEvaluatorPage.searchAndSelectRecord('A - formula test record'); + // Skip return type validation since tests cover multiple return types + await formulaEvaluatorPage.setReturnType('--Skip Type Validation--'); + + // ─── Text formulas ─────────────────────────────────────────── + + await test.step('string concatenation with &', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('Name & " | " & Phone'); + expect(result).toBe('A - formula test record | (336) 222-7000'); + }); + + await test.step('UPPER and LOWER', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('UPPER(BillingCity) & ", " & LOWER(BillingState)'); + expect(result).toBe('BURLINGTON, nc'); + }); + + await test.step('LEFT and RIGHT', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('LEFT(TickerSymbol, 2) & RIGHT(TickerSymbol, 2)'); + expect(result).toBe('BTXT'); + }); + + await test.step('LEN', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('LEN(Phone)'); + expect(result).toBe('14'); + }); + + await test.step('CONTAINS', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(CONTAINS(Name, "formula"), "yes", "no")'); + expect(result).toBe('yes'); + }); + + await test.step('SUBSTITUTE', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('SUBSTITUTE(BillingCity, "Burlington", "Charlotte")'); + expect(result).toBe('Charlotte'); + }); + + await test.step('TRIM', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('TRIM(" hello ")'); + expect(result).toBe('hello'); + }); + + await test.step('LPAD', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('LPAD(SLASerialNumber__c, 8, "0")'); + expect(result).toBe('00005367'); + }); + + await test.step('BEGINS', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(BEGINS(Website, "www"), "yes", "no")'); + expect(result).toBe('yes'); + }); + + await test.step('MID', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('MID(BillingPostalCode, 1, 3)'); + expect(result).toBe('272'); + }); + + await test.step('FIND', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('FIND("formula", Name)'); + expect(result).toBe('5'); + }); + + // ─── Logical formulas ──────────────────────────────────────── + + await test.step('IF with field comparison', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(AnnualRevenue > 1000000, "Enterprise", "SMB")'); + expect(result).toBe('Enterprise'); + }); + + await test.step('CASE on picklist', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('CASE(Rating, "Hot", 3, "Warm", 2, "Cold", 1, 0)'); + expect(result).toBe('2'); + }); + + await test.step('AND', async () => { + const result = await formulaEvaluatorPage.evaluateFormula( + 'IF(AND(NumberOfEmployees > 1000, AnnualRevenue > 1000000), "Large", "Small")', + ); + expect(result).toBe('Large'); + }); + + await test.step('OR', async () => { + const result = await formulaEvaluatorPage.evaluateFormula( + 'IF(OR(ISPICKVAL(Industry, "Apparel"), ISPICKVAL(Industry, "Technology")), "Target", "Other")', + ); + expect(result).toBe('Target'); + }); + + await test.step('NOT', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('NOT(IsDeleted)'); + expect(result).toBe('true'); + }); + + await test.step('BLANKVALUE on null field', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('BLANKVALUE(Description, "No description")'); + expect(result).toBe('No description'); + }); + + await test.step('BLANKVALUE on non-null field', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('BLANKVALUE(Website, "N/A")'); + expect(result).toBe('www.burlington.com'); + }); + + await test.step('ISBLANK', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(ISBLANK(Description), "blank", "not blank")'); + expect(result).toBe('blank'); + }); + + // ─── Number and math formulas ──────────────────────────────── + + await test.step('arithmetic with record fields', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('AnnualRevenue / NumberOfEmployees'); + expect(Number(result)).toBeCloseTo(38888.89, 0); + }); + + await test.step('ROUND', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('ROUND(AnnualRevenue / NumberOfEmployees, 2)'); + expect(result).toBe('38888.89'); + }); + + await test.step('MOD', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('MOD(NumberOfEmployees, 7)'); + expect(result).toBe('5'); + }); + + await test.step('MAX and MIN', async () => { + const result = await formulaEvaluatorPage.evaluateFormula( + 'MAX(NumberOfEmployees, NumberofLocations__c) & "/" & MIN(NumberOfEmployees, NumberofLocations__c)', + ); + expect(result).toBe('9000/6'); + }); + + await test.step('ABS', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('ABS(NumberofLocations__c - NumberOfEmployees)'); + expect(result).toBe('8994'); + }); + + await test.step('CEILING and FLOOR', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('CEILING(NumberOfEmployees / 7)'); + expect(result).toBe('1286'); + }); + + await test.step('SQRT', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('SQRT(144)'); + expect(result).toBe('12'); + }); + + await test.step('POWER', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('POWER(NumberofLocations__c, 2)'); + expect(result).toBe('36'); + }); + + // ─── Date formulas ─────────────────────────────────────────── + + await test.step('YEAR, MONTH, DAY on record date field', async () => { + const result = await formulaEvaluatorPage.evaluateFormula( + 'YEAR(SLAExpirationDate__c) & "-" & MONTH(SLAExpirationDate__c) & "-" & DAY(SLAExpirationDate__c)', + ); + expect(result).toBe('2022-1-22'); + }); + + await test.step('TODAY returns current year', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('YEAR(TODAY())'); + expect(Number(result)).toBeGreaterThanOrEqual(2025); + }); + + await test.step('ADDMONTHS', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('MONTH(ADDMONTHS(SLAExpirationDate__c, 3))'); + expect(result).toBe('4'); + }); + + await test.step('date comparison', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(SLAExpirationDate__c < TODAY(), "Expired", "Active")'); + expect(result).toBe('Expired'); + }); + + // ─── Related record formulas ───────────────────────────────── + + await test.step('Owner.FirstName (related record)', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('Owner.FirstName'); + expect(result).toBe('Integration'); + }); + + await test.step('Owner.LastName (related record)', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('Owner.LastName'); + expect(result).toBe('Test'); + }); + + await test.step('Owner name concatenation', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('Owner.FirstName & " " & Owner.LastName'); + expect(result).toBe('Integration Test'); + }); + + await test.step('Parent.Name (related record)', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('Parent.Name'); + expect(result).toBe('University of Arizona'); + }); + + await test.step('mixed record and related fields', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('Name & " (owned by " & Owner.FirstName & ")"'); + expect(result).toBe('A - formula test record (owned by Integration)'); + }); + + // ─── Null handling ─────────────────────────────────────────── + + await test.step('treat blanks as zero for null field', async () => { + await formulaEvaluatorPage.setNullBehavior('ZERO'); + // AccountSource is null, so BLANKVALUE should return the fallback + const result = await formulaEvaluatorPage.evaluateFormula('BLANKVALUE(AccountSource, "None")'); + expect(result).toBe('None'); + // Reset null behavior back to BLANK so later tests are not affected + await formulaEvaluatorPage.setNullBehavior('BLANK'); + }); + + // ─── Picklist formulas ─────────────────────────────────────── + + await test.step('ISPICKVAL matching', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(ISPICKVAL(SLA__c, "Silver"), "match", "no match")'); + expect(result).toBe('match'); + }); + + await test.step('ISPICKVAL not matching', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(ISPICKVAL(SLA__c, "Gold"), "match", "no match")'); + expect(result).toBe('no match'); + }); + + // ─── Global variables ──────────────────────────────────────── + + await test.step('$User.FirstName', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('$User.FirstName'); + expect(result.length).toBeGreaterThan(0); + }); + + await test.step('$Organization.Name', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('$Organization.Name'); + expect(result.length).toBeGreaterThan(0); + }); + + // ─── Previously unsupported functions (broken in formulon) ── + + await test.step('ISNULL on null field', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(ISNULL(Description), "null", "has value")'); + expect(result).toBe('null'); + }); + + await test.step('ISNULL on non-null field', async () => { + const result = await formulaEvaluatorPage.evaluateFormula('IF(ISNULL(Website), "null", "has value")'); + expect(result).toBe('has value'); + }); + + await test.step('NULLVALUE on null number field', async () => { + // YearStarted is null on this record + const result = await formulaEvaluatorPage.evaluateFormula('NULLVALUE(YearStarted, 1999)'); + expect(result).toBe('1999'); + }); + + // ─── Error handling ────────────────────────────────────────── + + await test.step('syntax error shows error message', async () => { + const errorText = await formulaEvaluatorPage.evaluateFormulaExpectError('LEFT(Name'); + expect(errorText.length).toBeGreaterThan(0); + }); + + await test.step('unknown function shows error message', async () => { + const errorText = await formulaEvaluatorPage.evaluateFormulaExpectError('NOTAFUNCTION(Name)'); + expect(errorText.length).toBeGreaterThan(0); + }); +}); diff --git a/formulon.d.ts b/formulon.d.ts deleted file mode 100644 index f9b9c1d13..000000000 --- a/formulon.d.ts +++ /dev/null @@ -1,146 +0,0 @@ -declare module 'formulon' { - export type DataType = 'number' | 'text' | 'picklist' | 'multipicklist' | DataTypeNoOption; - export type DataTypeNoOption = 'checkbox' | 'date' | 'time' | 'datetime' | 'geolocation' | 'null'; - export type DataType = 'number' | 'text' | 'checkbox' | 'date' | 'time' | 'datetime' | 'geolocation' | 'null'; - export type FormulaData = Record; - export type FormulaDataValue = AstLiteral | AstLiteralText | AstLiteralNumber | AstLiteralPicklist; - export type FormulaResult = AstError | AstNotImplementedError | FormulaDataValue; - export type AstResult = AstCallExpression; - export type FunctionType = - | 'abs' - | 'add' - | 'addmonths' - | 'and' - | 'begins' - | 'blankvalue' - | 'br' - | 'case' - | 'casesafeid' - | 'ceiling' - | 'contains' - | 'currencyrate' - | 'date' - | 'datetimevalue' - | 'datevalue' - | 'day' - | 'distance' - | 'divide' - | 'equal' - | 'exp' - | 'exponentiate' - | 'find' - | 'floor' - | 'geolocation' - | 'getsessionid' - | 'greaterthan' - | 'greaterthanorequal' - | 'hour' - | 'hyperlink' - | 'if' - | 'image' - | 'includes' - | 'isblank' - | 'isnull' - | 'isnumber' - | 'ispickval' - | 'left' - | 'len' - | 'lessthan' - | 'lessthanorequal' - | 'ln' - | 'log' - | 'lower' - | 'lpad' - | 'max' - | 'mceiling' - | 'mfloor' - | 'mid' - | 'millisecond' - | 'min' - | 'minute' - | 'mod' - | 'month' - | 'multiply' - | 'not' - | 'now' - | 'nullvalue' - | 'or' - | 'regex' - | 'right' - | 'round' - | 'rpad' - | 'second' - | 'sqrt' - | 'substitute' - | 'subtract' - | 'text' - | 'timenow' - | 'timevalue' - | 'today' - | 'trim' - | 'unequal' - | 'upper' - | 'value' - | 'weekday' - | 'year'; - - export interface AstError { - type: 'error'; - errorType: 'ArgumentError'; - message: string; - name?: string; - FunctionType: string; - expected: number; - received: number; - } - - export interface AstNotImplementedError { - type: 'error'; - errorType: 'NotImplementedError'; - message: string; - name: string; - } - - export interface AstLiteral { - type: 'literal'; - value: string | number | boolean | null | Date; - dataType: DataTypeNoOption; - options?: Record | null; - } - - export interface AstLiteralText { - type: 'literal'; - value: string; - dataType: 'text'; - options: { - length: number; - }; - } - - export interface AstLiteralNumber { - type: 'literal'; - value: number; - dataType: 'number'; - options: { - length: number; - scale: number; - }; - } - - export interface AstLiteralPicklist { - type: 'literal'; - value: number; - dataType: 'picklist' | 'multipicklist'; - options: { values: string[] }; - } - - export interface AstCallExpression { - type: 'callExpression'; - id: string; - arguments: (FormulaResult | AstCallExpression)[]; - } - - export const parse: (formula: string, substitutions: FormulaData) => FormulaResult; - export const extract: (formula: string) => string[]; - export const ast: (formula: string) => AstCallExpression; -} diff --git a/libs/features/create-object-and-fields/src/CreateFieldsFormulaEditor.tsx b/libs/features/create-object-and-fields/src/CreateFieldsFormulaEditor.tsx index d3d037cc7..ceeda692d 100644 --- a/libs/features/create-object-and-fields/src/CreateFieldsFormulaEditor.tsx +++ b/libs/features/create-object-and-fields/src/CreateFieldsFormulaEditor.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/react'; import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { sanitizePastedEditorText, useDisposables } from '@jetstream/shared/ui-utils'; -import { getErrorMessage, getErrorMessageAndStackObj } from '@jetstream/shared/utils'; +import { NOOP, getErrorMessage, getErrorMessageAndStackObj } from '@jetstream/shared/utils'; import { SplitWrapper as Split } from '@jetstream/splitjs'; import { Field, FieldType, Maybe, NullNumberBehavior, SalesforceOrgUi } from '@jetstream/types'; import { Grid, KeyboardShortcut, Modal, Spinner, Tabs, Textarea } from '@jetstream/ui'; @@ -13,15 +13,19 @@ import { FieldValues, FormulaEvaluatorRecordSearch, FormulaEvaluatorResults, + FormulaEvaluatorReturnTypeCombobox, FormulaEvaluatorUserSearch, ManualFormulaRecord, SalesforceFieldType, + convertFormulaSecondaryTypeToEvaluatorType, + fieldTypeToReturnType, getFormulaData, registerCompletions, useAmplitude, } from '@jetstream/ui-core'; +import type { FieldSchema, FormulaContext, FormulaValue } from '@jetstreamapp/sf-formula-parser'; +import { evaluateFormula, extractFields, extractFieldsByCategory } from '@jetstreamapp/sf-formula-parser'; import Editor, { OnMount, useMonaco } from '@monaco-editor/react'; -import * as formulon from 'formulon'; import type { editor } from 'monaco-editor'; import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import CreateFieldsFormulaEditorManualField from './CreateFieldsFormulaEditorManualField'; @@ -59,13 +63,19 @@ export const CreateFieldsFormulaEditor = forwardRef([]); const [formulaFieldValues, setFormulaFieldValues] = useState({}); - const [results, setResults] = useState<{ formulaFields: formulon.FormulaData; parsedFormula: formulon.FormulaResult } | null>(null); + const [results, setResults] = useState<{ + context: FormulaContext; + result: FormulaValue; + returnType: ReturnType; + } | null>(null); const [selectedUserId, setSelectedUserId] = useState(''); const [recordId, setRecordId] = useState(''); const monaco = useMonaco(); + const formulaReturnType = convertFormulaSecondaryTypeToEvaluatorType(allValues.secondaryType.value as SalesforceFieldType); + useEffect(() => { isMounted.current = true; return () => { @@ -85,7 +95,7 @@ export const CreateFieldsFormulaEditor = forwardRef { setFormulaFields((prevValue) => { try { - const fields = formulon.extract(formulaValue || ''); + const fields = extractFields(formulaValue || ''); return fields; } catch (ex) { return prevValue; @@ -124,23 +134,25 @@ export const CreateFieldsFormulaEditor = forwardRef | undefined; + if (formulaFields.length) { let payload: Parameters[0] = { - fields: formulaFields, + categorizedFields, recordId, selectedOrg, selectedUserId, sobjectName: selectedSObjects[0] || '', - numberNullBehavior: (allValues.formulaTreatBlanksAs.value as NullNumberBehavior) || 'BLANK', }; if (testMethod === 'MANUAL' || !recordId) { - // ensure all fields are included in record + // Ensure all object fields are included in record with defaults const record = { ...formulaFieldValues, }; - formulaFields.forEach((field) => { + categorizedFields.objectFields.forEach((field) => { if (!record[field]) { record[field] = { type: 'string', @@ -149,13 +161,12 @@ export const CreateFieldsFormulaEditor = forwardRef
+ + +