diff --git a/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx b/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx index 9c2c6a6e9..468dffef8 100644 --- a/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx +++ b/apps/jetstream/src/app/components/create-object-and-fields/CreateFields.tsx @@ -196,9 +196,11 @@ export const CreateFields: FunctionComponent = () => { {rows.map((row, i) => ( 1} selectedOrg={selectedOrg} + selectedSObjects={selectedSObjects} values={row} allValid={row._allValid} onChange={(field, value) => changeRow(row._key, field, value)} diff --git a/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsFormulaEditor.tsx b/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsFormulaEditor.tsx new file mode 100644 index 000000000..3946b10d0 --- /dev/null +++ b/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsFormulaEditor.tsx @@ -0,0 +1,332 @@ +import { css } from '@emotion/react'; +import { logger } from '@jetstream/shared/client-logger'; +import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { SplitWrapper as Split } from '@jetstream/splitjs'; +import { Maybe, SalesforceOrgUi } from '@jetstream/types'; +import { Grid, KeyboardShortcut, Modal, Spinner, Textarea } from '@jetstream/ui'; +import Editor, { OnMount, useMonaco } from '@monaco-editor/react'; +import * as formulon from 'formulon'; +import { Field } from 'jsforce'; +import type { editor } from 'monaco-editor'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { registerCompletions } from '../formula-evaluator/formula-evaluator.editor-utils'; +import { + FieldDefinition, + FieldValue, + FieldValueState, + FieldValues, + SalesforceFieldType, +} from '../shared/create-fields/create-fields-types'; +import FormulaEvaluatorRecordSearch from '../shared/formula-evaluator/FormulaEvaluatorRecordSearch'; +import FormulaEvaluatorResults from '../shared/formula-evaluator/FormulaEvaluatorResults'; +import FormulaEvaluatorUserSearch from '../shared/formula-evaluator/FormulaEvaluatorUserSearch'; +import { getFormulaData } from '../shared/formula-evaluator/formula-evaluator.utils'; + +// Lazy import +const prettier = import('prettier/standalone'); +const prettierBabelParser = import('prettier/parser-babel'); + +export interface CreateFieldsFormulaEditorProps { + id: string; + selectedOrg: SalesforceOrgUi; + selectedSObjects: string[]; + field: FieldDefinition; + disabled: Maybe; + allValues: FieldValues; + rows: FieldValues[]; + valueState: FieldValueState; + onChange: (value: FieldValue) => void; + onBlur: () => void; +} + +export const CreateFieldsFormulaEditor = forwardRef( + ({ id, selectedOrg, selectedSObjects = [], allValues, field, valueState, disabled = false, rows, onChange, onBlur }, ref) => { + const { value, touched, errorMessage } = valueState; + const isMounted = useRef(true); + const editorRef = useRef(); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [fieldErrorMessage, setFieldErrorMessage] = useState(null); + const [formulaErrorMessage, setFormulaErrorMessage] = useState(null); + const [formulaValue, setFormulaValue] = useState((value as string) || ''); + const [additionalFieldMetadata, setAdditionalFieldMetadata] = useState[]>([]); + + const [results, setResults] = useState<{ formulaFields: formulon.FormulaData; parsedFormula: formulon.FormulaResult } | null>(null); + + const [selectedUserId, setSelectedUserId] = useState(''); + const [recordId, setRecordId] = useState(''); + + const monaco = useMonaco(); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + monaco && registerCompletions(monaco, selectedOrg, selectedSObjects[0], additionalFieldMetadata); + }, [monaco, selectedOrg, selectedSObjects[0], additionalFieldMetadata]); + + useEffect(() => { + if (isOpen) { + setFormulaValue((value as string) || ''); + setAdditionalFieldMetadata( + rows + .filter((row) => row.fullName.value && allValues.fullName.value !== row.fullName.value) + .map((row): Partial => { + return { + name: (row.fullName.value as string) || '', + label: `${(row.label.value as string) || ''} (NEW FIELD)`, + type: row.type.value as any, + relationshipName: (row.relationshipName.value as string) || null, + referenceTo: row.referenceTo.value ? [row.referenceTo.value as string] : [], + }; + }) + ); + } + }, [isOpen, rows]); + + /** + * Open a modal or side drawer to edit the formula + * show an editor + * ideally would be able to test the formula, but might be challenging in this case as some fields might not exist yet + * we should include other fields user is creating as options to select to validate the formula + */ + + const handleTestFormula = useCallback(async (value: string) => { + try { + // if (testFormulaDisabled) { + // return; + // } + setLoading(true); + setFieldErrorMessage(null); + setFormulaErrorMessage(null); + setResults(null); + const fields = formulon.extract(value); + // TODO: see if any fields do not yet exist + let formulaFields: formulon.FormulaData = {}; + if (fields.length) { + // FIXME: what about fields that do not yet exist? + // FIXME: what if there are no records on this object? + const response = await getFormulaData({ + fields, + recordId, + selectedOrg, + selectedUserId, + sobjectName: selectedSObjects[0] || '', + numberNullBehavior: (allValues.formulaTreatBlanksAs.value as any) || 'BLANK', + }); + if (response.type === 'error') { + setFieldErrorMessage(response.message); + return; + } + formulaFields = response.formulaFields; + } + const parsedFormula = formulon.parse(value, formulaFields); + logger.log('results', parsedFormula); + setResults({ + formulaFields, + parsedFormula, + }); + // trackEvent(ANALYTICS_KEYS.formula_execute, { success: true, fieldCount: fields.length, objectPrefix: recordId.substring(0, 3) }); + } catch (ex) { + logger.warn(ex); + setFormulaErrorMessage(ex.message); + // trackEvent(ANALYTICS_KEYS.formula_execute, { success: false, message: ex.message, stack: ex.stack }); + } finally { + setLoading(false); + } + }, []); + + useNonInitialEffect(() => { + if (monaco && editorRef.current) { + editorRef.current.addAction({ + id: 'modifier-enter', + label: 'Submit', + keybindings: [monaco?.KeyMod.CtrlCmd | monaco?.KeyCode.Enter], + run: (currEditor) => { + handleTestFormula(currEditor.getValue()); + }, + }); + } + }, [handleTestFormula, monaco, selectedOrg]); + + function handleEditorChange(value, event) { + setFormulaValue(value); + } + + // FIXME: Error: Can only have one anonymous define call per script file + // const handleFormat = async (value = formulaValue) => { + // try { + // if (!editorRef.current || !formulaValue) { + // return; + // } + // editorRef.current.setValue( + // (await prettier).format(value, { + // parser: 'babel', + // plugins: [await prettierBabelParser], + // bracketSpacing: false, + // semi: false, + // singleQuote: true, + // trailingComma: 'none', + // useTabs: false, + // tabWidth: 2, + // }) + // ); + // } catch (ex) { + // logger.warn('failed to format', ex); + // } + // }; + + const handleApexEditorMount: OnMount = (currEditor, monaco) => { + editorRef.current = currEditor; + // this did not run on initial render if used in useEffect + editorRef.current.addAction({ + id: 'modifier-enter', + label: 'Submit', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run: (currEditor) => { + handleTestFormula(currEditor.getValue()); + }, + }); + // editorRef.current.addAction({ + // id: 'format', + // label: 'Format', + // contextMenuGroupId: '9_cutcopypaste', + // run: (currEditor) => { + // handleFormat(currEditor.getValue()); + // }, + // }); + }; + + function handleClose() { + onChange(formulaValue); + setIsOpen(false); + } + + return ( +
+ + + {isOpen && ( + + + + } + onClose={handleClose} + > +
+ {loading && } + + + {/* */} + + +
+ {/* */} + + {/* */} +
+ + + + + + +
+
+
+ )} +
+ ); + } +); + +export default CreateFieldsFormulaEditor; diff --git a/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsRow.tsx b/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsRow.tsx index 85d97f07f..8b0782256 100644 --- a/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsRow.tsx +++ b/apps/jetstream/src/app/components/create-object-and-fields/CreateFieldsRow.tsx @@ -1,6 +1,6 @@ import { SalesforceOrgUi } from '@jetstream/types'; import { Grid, Icon, Tooltip } from '@jetstream/ui'; -import React, { FunctionComponent } from 'react'; +import { FunctionComponent } from 'react'; import { FieldDefinitionType, FieldValue, FieldValues, SalesforceFieldType } from '../shared/create-fields/create-fields-types'; import { baseFields, @@ -17,8 +17,10 @@ export interface CreateFieldsRowProps { rowIdx: number; enableDelete?: boolean; selectedOrg: SalesforceOrgUi; + selectedSObjects: string[]; values: FieldValues; allValid: boolean; + rows: FieldValues[]; onChange: (field: FieldDefinitionType, value: FieldValue) => void; onClone: () => void; onDelete: () => void; @@ -30,8 +32,10 @@ export const CreateFieldsRow: FunctionComponent = ({ rowIdx, enableDelete, selectedOrg, + selectedSObjects, values, allValid, + rows, onChange, onClone, onDelete, @@ -49,6 +53,8 @@ export const CreateFieldsRow: FunctionComponent = ({ = ({ = ({ void; onBlur: () => void; } export const CreateFieldsRowField = forwardRef( - ({ selectedOrg, id, field, allValues, valueState, disabled: _disabled, onChange, onBlur }, ref) => { + ({ selectedOrg, id, selectedSObjects, field, allValues, valueState, disabled: _disabled, rows, onChange, onBlur }, ref) => { const { value, touched, errorMessage } = valueState; const disabled = _disabled || field?.disabled?.(allValues); const [values, setValues] = useState([]); @@ -196,6 +199,29 @@ export const CreateFieldsRowField = forwardRef( ); + case 'textarea-with-formula': + return ( +
+ {loadingValues && } + +
+ ); case 'text': default: return ( diff --git a/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluator.tsx b/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluator.tsx index 6898f8449..969fcead2 100644 --- a/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluator.tsx +++ b/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluator.tsx @@ -17,7 +17,6 @@ import { Radio, RadioButton, RadioGroup, - ScopedNotification, SobjectCombobox, SobjectComboboxRef, SobjectFieldCombobox, @@ -33,12 +32,13 @@ import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'rea import { useRecoilState, useRecoilValue } from 'recoil'; import { applicationCookieState, selectedOrgState } from '../../app-state'; import { useAmplitude } from '../core/analytics'; -import FormulaEvaluatorRecordSearch from './FormulaEvaluatorRecordSearch'; -import FormulaEvaluatorUserSearch from './FormulaEvaluatorUserSearch'; +import FormulaEvaluatorRecordSearch from '../shared/formula-evaluator/FormulaEvaluatorRecordSearch'; +import FormulaEvaluatorResults from '../shared/formula-evaluator/FormulaEvaluatorResults'; +import FormulaEvaluatorUserSearch from '../shared/formula-evaluator/FormulaEvaluatorUserSearch'; +import { getFormulaData } from '../shared/formula-evaluator/formula-evaluator.utils'; import FormulaEvaluatorDeployModal from './deploy/FormulaEvaluatorDeployModal'; import { registerCompletions } from './formula-evaluator.editor-utils'; import * as fromFormulaState from './formula-evaluator.state'; -import { getFormulaData } from './formula-evaluator.utils'; // Lazy import const prettier = import('prettier/standalone'); @@ -439,7 +439,6 @@ export const FormulaEvaluator: FunctionComponent = () => } > {loading && } - = () => fieldErrorMessage={fieldErrorMessage} onSelectedRecord={setRecordId} /> - {errorMessage && ( -
- - {errorMessage} - -
- )} - {results && ( - - {!!Object.keys(results.formulaFields).length && ( - <> -
Record Fields
-
- {Object.keys(results.formulaFields).map((field) => { - const { value } = results.formulaFields[field]; - return ( -
- {field}: {String(value) || ''} -
- ); - })} -
- - )} -
Formula Results
-
- {results.parsedFormula.type === 'error' ? ( - -
{results.parsedFormula.errorType}
-
{results.parsedFormula.message}
- {results.parsedFormula.errorType === 'NotImplementedError' && results.parsedFormula.name === 'isnull' && ( -
Use ISBLANK instead
- )} -
- ) : ( -
{String(results.parsedFormula.value)}
- )} -
-
- )} + {!errorMessage && !results && ( [] +) { if (priorCompletion) { priorCompletion.dispose(); } @@ -38,7 +43,7 @@ export async function registerCompletions(monaco: Monaco, selectedOrg: Salesforc provideCompletionItems: async (model, position, context, token) => { const characterInfo = getCharacterInfo(model, position); return { - suggestions: await fetchCompletions(monaco, characterInfo, selectedOrg, sobject), + suggestions: await fetchCompletions(monaco, characterInfo, selectedOrg, sobject, additionalFields), }; }, }); @@ -90,7 +95,8 @@ async function fetchCompletions( monaco: Monaco, characterInfo: CharacterInfo, selectedOrg: SalesforceOrgUi, - sobject?: string + sobject?: string, + additionalFields?: Partial[] ): Promise { const { textUntilPosition: textUntilPositionAll, mostRecentCharacter, range } = characterInfo; // if spaces, ignore prior words - e.x. "Log__r.ApiVersion__c != LoggedBy__r" we only care about LoggedBy__r @@ -106,7 +112,7 @@ async function fetchCompletions( } } - let currentSObjectMeta: DescribeSObjectResult; + let currentSObjectMeta: Omit & { fields: Partial[] }; /** * Handle all special words (starts with `$`) @@ -221,7 +227,10 @@ async function fetchCompletions( */ const { data } = await describeSObject(selectedOrg, sobject); // Current object metadata - will be changed when fetching related object metadata - currentSObjectMeta = data; + currentSObjectMeta = { + ...data, + fields: [...data.fields, ...(additionalFields || [])], + }; // If true, then special keywords will be included in results let isRootObject = true; // Completions are added to the list @@ -265,7 +274,7 @@ async function fetchCompletions( detail: field.type, filterText: field.name, kind: monaco.languages.CompletionItemKind.Class, - insertText: field.name, + insertText: field.name!, range, }); if (!!field.relationshipName && !!field.referenceTo?.length) { diff --git a/apps/jetstream/src/app/components/shared/create-fields/create-fields-types.ts b/apps/jetstream/src/app/components/shared/create-fields/create-fields-types.ts index 8ea06414b..6c7258358 100644 --- a/apps/jetstream/src/app/components/shared/create-fields/create-fields-types.ts +++ b/apps/jetstream/src/app/components/shared/create-fields/create-fields-types.ts @@ -65,7 +65,7 @@ export type SalesforceFieldType = | 'LongTextArea' | 'Html'; -export type FieldDefinitionUiType = 'picklist' | 'text' | 'textarea' | 'radio' | 'checkbox'; +export type FieldDefinitionUiType = 'picklist' | 'text' | 'textarea' | 'textarea-with-formula' | 'radio' | 'checkbox'; export interface FieldValueState { value: FieldValue; diff --git a/apps/jetstream/src/app/components/shared/create-fields/create-fields-utils.tsx b/apps/jetstream/src/app/components/shared/create-fields/create-fields-utils.tsx index 3e6fb6cc1..86fec41b0 100644 --- a/apps/jetstream/src/app/components/shared/create-fields/create-fields-utils.tsx +++ b/apps/jetstream/src/app/components/shared/create-fields/create-fields-utils.tsx @@ -301,7 +301,7 @@ export const fieldDefinitions: FieldDefinitions = { }, formula: { label: 'Formula', - type: 'textarea', + type: 'textarea-with-formula', required: true, // TODO: would be cool to have syntax highlighting }, diff --git a/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluatorRecordSearch.tsx b/apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorRecordSearch.tsx similarity index 97% rename from apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluatorRecordSearch.tsx rename to apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorRecordSearch.tsx index 185d9578b..83d302879 100644 --- a/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluatorRecordSearch.tsx +++ b/apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorRecordSearch.tsx @@ -4,8 +4,8 @@ import { CloneEditView, ListItem, Maybe, SalesforceOrgUi } from '@jetstream/type import { ComboboxWithItemsTypeAhead, Grid, Icon, Tooltip } from '@jetstream/ui'; import { FunctionComponent, useCallback, useRef, useState } from 'react'; import { useRecoilState } from 'recoil'; -import { applicationCookieState } from '../../app-state'; -import ViewEditCloneRecord from '../core/ViewEditCloneRecord'; +import { applicationCookieState } from '../../../app-state'; +import ViewEditCloneRecord from '../../core/ViewEditCloneRecord'; export interface FormulaEvaluatorRecordSearchProps { selectedOrg: SalesforceOrgUi; diff --git a/apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorResults.tsx b/apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorResults.tsx new file mode 100644 index 000000000..66bbd0372 --- /dev/null +++ b/apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorResults.tsx @@ -0,0 +1,61 @@ +import { Maybe } from '@jetstream/types'; +import { Grid, ScopedNotification } from '@jetstream/ui'; +import * as formulon from 'formulon'; +import { FunctionComponent } from 'react'; + +export interface FormulaEvaluatorResultsProps { + errorMessage?: Maybe; + results: { + formulaFields: formulon.FormulaData; + parsedFormula: formulon.FormulaResult; + } | null; +} + +export const FormulaEvaluatorResults: FunctionComponent = ({ errorMessage, results }) => { + return ( + <> + {errorMessage && ( +
+ + {errorMessage} + +
+ )} + {results && ( + + {!!Object.keys(results.formulaFields).length && ( + <> +
Record Fields
+
+ {Object.keys(results.formulaFields).map((field) => { + const { value } = results.formulaFields[field]; + return ( +
+ {field}: {String(value) || ''} +
+ ); + })} +
+ + )} +
Formula Results
+
+ {results.parsedFormula.type === 'error' ? ( + +
{results.parsedFormula.errorType}
+
{results.parsedFormula.message}
+ {results.parsedFormula.errorType === 'NotImplementedError' && results.parsedFormula.name === 'isnull' && ( +
Use ISBLANK instead
+ )} +
+ ) : ( +
{String(results.parsedFormula.value)}
+ )} +
+
+ )} + + ); +}; + +export default FormulaEvaluatorResults; diff --git a/apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluatorUserSearch.tsx b/apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorUserSearch.tsx similarity index 100% rename from apps/jetstream/src/app/components/formula-evaluator/FormulaEvaluatorUserSearch.tsx rename to apps/jetstream/src/app/components/shared/formula-evaluator/FormulaEvaluatorUserSearch.tsx diff --git a/apps/jetstream/src/app/components/formula-evaluator/formula-evaluator.utils.ts b/apps/jetstream/src/app/components/shared/formula-evaluator/formula-evaluator.utils.ts similarity index 93% rename from apps/jetstream/src/app/components/formula-evaluator/formula-evaluator.utils.ts rename to apps/jetstream/src/app/components/shared/formula-evaluator/formula-evaluator.utils.ts index 11bdb03cf..0b27ab4c5 100644 --- a/apps/jetstream/src/app/components/formula-evaluator/formula-evaluator.utils.ts +++ b/apps/jetstream/src/app/components/shared/formula-evaluator/formula-evaluator.utils.ts @@ -11,9 +11,9 @@ import type { Field } from 'jsforce'; import lodashGet from 'lodash/get'; import isNil from 'lodash/isNil'; import { composeQuery, getField } from 'soql-parser-js'; -import { fetchMetadataFromSoql } from '../query/utils/query-soql-utils'; -import { NullNumberBehavior } from './formula-evaluator.state'; -import { FormulaFieldsByType } from './formula-evaluator.types'; +import { NullNumberBehavior } from '../../formula-evaluator/formula-evaluator.state'; +import { FormulaFieldsByType } from '../../formula-evaluator/formula-evaluator.types'; +import { fetchMetadataFromSoql } from '../../query/utils/query-soql-utils'; const MATCH_FORMULA_SPECIAL_LABEL = /^\$[a-zA-Z]+\./; @@ -149,21 +149,37 @@ function getPrecision(a) { return p; } +interface FormulaDataProps { + selectedOrg: SalesforceOrgUi; + selectedUserId: string; + /** + * Fields used in formula, used to query records + */ + fields: string[]; + /** + * If the formula is testing fields the do not yet exist, they can be included here + */ + syntheticFields?: Record< + string, + { + name: string; + value?: any; + } + >; + recordId: string; + sobjectName: string; + numberNullBehavior: NullNumberBehavior; +} + export async function getFormulaData({ selectedOrg, selectedUserId, fields, + syntheticFields, recordId, sobjectName, numberNullBehavior = 'ZERO', -}: { - selectedOrg: SalesforceOrgUi; - selectedUserId: string; - fields: string[]; - recordId: string; - sobjectName: string; - numberNullBehavior: NullNumberBehavior; -}): Promise< +}: FormulaDataProps): Promise< | { type: 'error'; message: string } | { type: 'success'; formulaFields: formulon.FormulaData; warnings: { type: string; message: string }[] } > { @@ -269,6 +285,7 @@ export async function getFormulaData({ async function collectBaseRecordFields({ selectedOrg, fields, + syntheticFields, recordId, sobjectName, formulaFields, @@ -276,6 +293,13 @@ async function collectBaseRecordFields({ }: { selectedOrg: SalesforceOrgUi; fields: string[]; + syntheticFields?: Record< + string, + { + name: string; + value?: any; + } + >; recordId: string; sobjectName: string; formulaFields: formulon.FormulaData; @@ -284,6 +308,7 @@ async function collectBaseRecordFields({ if (!fields.length) { return; } + const { queryResults, columns, parsedQuery } = await query( selectedOrg, composeQuery({ @@ -325,6 +350,39 @@ async function collectBaseRecordFields({ }); } +// TODO: Figure out how to handle synthetic fields +// async function collectSyntheticRecordFields({ +// fields, +// recordId, +// sobjectName, +// formulaFields, +// numberNullBehavior, +// }: { +// fields: Record; +// recordId: string; +// sobjectName: string; +// formulaFields: formulon.FormulaData; +// numberNullBehavior: NullNumberBehavior; +// }) { + +// Object.entries(fields).forEach(([field, value]) => { +// // Prefer to get field from actual metadata, otherwise picklist is not handled and can cause formula errors +// const column = lowercaseFieldMap[field.toLowerCase()] || fieldsByName[field.toLowerCase()]; +// if (!column || !fieldsByName[field.toLowerCase()]) { +// throw new Error(`Field ${field} does not exist on ${sobjectName}.`); +// } +// formulaFields['Id'] = { type: 'literal', dataType: 'text', value: recordId, options: { length: 18 } }; +// formulaFields[field] = getFormulonData( +// column, +// lodashGet(queryResults.records[0], fieldsByName[field.toLowerCase()].columnFullPath), +// numberNullBehavior +// ); +// }); +// } + function collectApiFields({ selectedOrg, fields,