diff --git a/packages/salesforce-adapter/src/adapter.ts b/packages/salesforce-adapter/src/adapter.ts index beb472e3f2b..03911d941f5 100644 --- a/packages/salesforce-adapter/src/adapter.ts +++ b/packages/salesforce-adapter/src/adapter.ts @@ -140,6 +140,7 @@ import flowCoordinatesFilter from './filters/flow_coordinates' import taskAndEventCustomFields from './filters/task_and_event_custom_fields' import picklistReferences from './filters/picklist_references' import addParentToInstancesWithinFolderFilter from './filters/add_parent_to_instances_within_folder' +import addParentToRecordTriggeredFlows from './filters/add_parent_to_record_triggered_flows' import { getConfigFromConfigChanges } from './config_change' import { Filter, FilterContext, FilterCreator, FilterResult } from './filter' import { @@ -259,6 +260,8 @@ export const allFilters: Array = [ // should run after convertListsFilter replaceFieldValuesFilter, valueToStaticFileFilter, + // addParentToRecordTriggeredFlows should run before fieldReferenceFilter + addParentToRecordTriggeredFlows, fieldReferencesFilter, // should run after customObjectsInstancesFilter for now referenceAnnotationsFilter, diff --git a/packages/salesforce-adapter/src/constants.ts b/packages/salesforce-adapter/src/constants.ts index 75a8d030db0..e4ea0857a18 100644 --- a/packages/salesforce-adapter/src/constants.ts +++ b/packages/salesforce-adapter/src/constants.ts @@ -76,6 +76,20 @@ export enum FIELD_TYPE_NAMES { FILE = 'File', } +export enum FLOW_FIELD_TYPE_NAMES { + FLOW_ASSIGNMENT_ITEM = 'FlowAssignmentItem', + FLOW_STAGE_STEP_OUTPUT_PARAMETER = 'FlowStageStepOutputParameter', + FLOW_SUBFLOW_OUTPUT_ASSIGNMENT = 'FlowSubflowOutputAssignment', + FLOW_TRANSFORM_VALUE_ACTION = 'FlowTransformValueAction', + FLOW_SCREEN_FIELD_OUTPUT_PARAMETER = 'FlowScreenFieldOutputParameter', + FLOW_WAIT_EVENT_OUTPUT_PARAMETER = 'FlowWaitEventOutputParameter', + FLOW_STAGE_STEP_EXIT_ACTION_OUTPUT_PARAMETER = 'FlowStageStepExitActionOutputParameter', + FLOW_APEX_PLUGIN_CALL_OUTPUT_PARAMETER = 'FlowApexPluginCallOutputParameter', + FLOW_ACTION_CALL_OUTPUT_PARAMETER = 'FlowActionCallOutputParameter', + FLOW_OUTPUT_FIELD_ASSIGNMENT = 'FlowOutputFieldAssignment', + FLOW_STAGE_STEP_ENTRY_ACTION_OUTPUT_PARAMETER = 'FlowStageStepEntryActionOutputParameter', +} + export enum INTERNAL_FIELD_TYPE_NAMES { UNKNOWN = 'Unknown', // internal-only placeholder for fields whose type is unknown ANY = 'AnyType', diff --git a/packages/salesforce-adapter/src/fetch_profile/optional_features.ts b/packages/salesforce-adapter/src/fetch_profile/optional_features.ts index 81f2003a0d6..c261b6e6de4 100644 --- a/packages/salesforce-adapter/src/fetch_profile/optional_features.ts +++ b/packages/salesforce-adapter/src/fetch_profile/optional_features.ts @@ -21,6 +21,7 @@ const optionalFeaturesDefaultValues: OptionalFeaturesDefaultValues = { networkReferences: false, extendFetchTargets: false, addParentToInstancesWithinFolder: false, + addParentToRecordTriggeredFlows: false, } export const isFeatureEnabled = (name: keyof OptionalFeatures, optionalFeatures?: OptionalFeatures): boolean => diff --git a/packages/salesforce-adapter/src/filters/add_parent_to_record_triggered_flows.ts b/packages/salesforce-adapter/src/filters/add_parent_to_record_triggered_flows.ts new file mode 100644 index 00000000000..ee28eaec334 --- /dev/null +++ b/packages/salesforce-adapter/src/filters/add_parent_to_record_triggered_flows.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Salto Labs Ltd. + * Licensed under the Salto Terms of Use (the "License"); + * You may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.salto.io/terms-of-use + * + * CERTAIN THIRD PARTY SOFTWARE MAY BE CONTAINED IN PORTIONS OF THE SOFTWARE. See NOTICE FILE AT https://github.com/salto-io/salto/blob/main/NOTICES + */ + +import { logger } from '@salto-io/logging' +import _ from 'lodash' +import { collections, values as lowerDashValues } from '@salto-io/lowerdash' +import { Element, InstanceElement } from '@salto-io/adapter-api' +import { FilterCreator } from '../filter' +import { + apiNameSync, + buildElementsSourceForFetch, + isInstanceOfTypeSync, + addElementParentReference, + isCustomObjectSync, +} from './utils' +import { FLOW_METADATA_TYPE } from '../constants' + +const { isDefined } = lowerDashValues +const { toArrayAsync } = collections.asynciterable +const log = logger(module) + +type RecordTriggeredFlowInstance = InstanceElement & { + value: { + start: { + object: string + } + } +} + +const isRecordTriggeredFlowInstance = (element: Element): element is RecordTriggeredFlowInstance => + isInstanceOfTypeSync(FLOW_METADATA_TYPE)(element) && _.isString(_.get(element.value, ['start', 'object'])) + +const filter: FilterCreator = ({ config }) => ({ + name: 'addParentToRecordTriggeredFlows', + onFetch: async (elements: Element[]) => { + if (!config.fetchProfile.isFeatureEnabled('addParentToRecordTriggeredFlows')) { + return + } + const customObjectByName = _.keyBy( + (await toArrayAsync(await buildElementsSourceForFetch(elements, config).getAll())).filter(isCustomObjectSync), + objectType => apiNameSync(objectType) ?? '', + ) + const createdReferencesCount: number = elements.filter(isRecordTriggeredFlowInstance).reduce((acc, flow) => { + const parent = customObjectByName[flow.value.start.object] + if (isDefined(parent)) { + addElementParentReference(flow, parent) + return acc + 1 + } + log.warn( + 'could not add parent reference to instance %s, the object %s is missing from the workspace', + flow, + flow.value.start.object, + ) + return acc + }, 0) + log.debug('filter created %d references in total', createdReferencesCount) + }, +}) + +export default filter diff --git a/packages/salesforce-adapter/src/transformers/reference_mapping.ts b/packages/salesforce-adapter/src/transformers/reference_mapping.ts index 3641c779c89..bab8d15525e 100644 --- a/packages/salesforce-adapter/src/transformers/reference_mapping.ts +++ b/packages/salesforce-adapter/src/transformers/reference_mapping.ts @@ -64,6 +64,7 @@ import { CPQ_QUOTE, CPQ_CONSTRAINT_FIELD, CUSTOM_LABEL_METADATA_TYPE, + FLOW_FIELD_TYPE_NAMES, } from '../constants' import { instanceInternalId } from '../filters/utils' import { FetchProfile } from '../types' @@ -104,6 +105,7 @@ type ReferenceSerializationStrategyName = | 'customLabel' | 'fromDataInstance' | 'recordField' + | 'assignToReferenceField' export const ReferenceSerializationStrategyLookup: Record< ReferenceSerializationStrategyName, ReferenceSerializationStrategy @@ -166,6 +168,16 @@ export const ReferenceSerializationStrategyLookup: Record< return val }, }, + assignToReferenceField: { + serialize: async ({ ref, path }) => + `$Record${API_NAME_SEPARATOR}${await safeApiName({ ref, path, relative: true })}`, + lookup: (val, context) => { + if (context !== undefined && _.isString(val) && val.startsWith('$Record.')) { + return [context, val.split(API_NAME_SEPARATOR)[1]].join(API_NAME_SEPARATOR) + } + return val + }, + }, } export type ReferenceContextStrategyName = @@ -255,6 +267,27 @@ const NETWORK_REFERENCES_DEF: FieldReferenceDefinition[] = [ }, ] +const FLOW_ASSIGNMENT_ITEM_REFERENCE_DEF: FieldReferenceDefinition = { + src: { + field: 'assignToReference', + parentTypes: [ + FLOW_FIELD_TYPE_NAMES.FLOW_ASSIGNMENT_ITEM, + FLOW_FIELD_TYPE_NAMES.FLOW_STAGE_STEP_OUTPUT_PARAMETER, + FLOW_FIELD_TYPE_NAMES.FLOW_SUBFLOW_OUTPUT_ASSIGNMENT, + FLOW_FIELD_TYPE_NAMES.FLOW_TRANSFORM_VALUE_ACTION, + FLOW_FIELD_TYPE_NAMES.FLOW_SCREEN_FIELD_OUTPUT_PARAMETER, + FLOW_FIELD_TYPE_NAMES.FLOW_WAIT_EVENT_OUTPUT_PARAMETER, + FLOW_FIELD_TYPE_NAMES.FLOW_STAGE_STEP_EXIT_ACTION_OUTPUT_PARAMETER, + FLOW_FIELD_TYPE_NAMES.FLOW_APEX_PLUGIN_CALL_OUTPUT_PARAMETER, + FLOW_FIELD_TYPE_NAMES.FLOW_ACTION_CALL_OUTPUT_PARAMETER, + FLOW_FIELD_TYPE_NAMES.FLOW_OUTPUT_FIELD_ASSIGNMENT, + FLOW_FIELD_TYPE_NAMES.FLOW_STAGE_STEP_ENTRY_ACTION_OUTPUT_PARAMETER, + ], + }, + serializationStrategy: 'assignToReferenceField', + target: { parentContext: 'instanceParent', type: CUSTOM_FIELD }, +} + /** * The rules for finding and resolving values into (and back from) reference expressions. * Overlaps between rules are allowed, and the first successful conversion wins. @@ -1118,6 +1151,7 @@ export const getDefsFromFetchProfile = (fetchProfile: FetchProfile): FieldRefere fieldNameToTypeMappingDefs .concat(fetchProfile.isFeatureEnabled('genAiReferences') ? GEN_AI_REFERENCES_DEF : []) .concat(fetchProfile.isFeatureEnabled('networkReferences') ? NETWORK_REFERENCES_DEF : []) + .concat(fetchProfile.isFeatureEnabled('addParentToRecordTriggeredFlows') ? FLOW_ASSIGNMENT_ITEM_REFERENCE_DEF : []) /** * Translate a reference expression back to its original value before deploy. diff --git a/packages/salesforce-adapter/src/types.ts b/packages/salesforce-adapter/src/types.ts index 257c0020ff9..57edea9ff2d 100644 --- a/packages/salesforce-adapter/src/types.ts +++ b/packages/salesforce-adapter/src/types.ts @@ -105,6 +105,7 @@ const OPTIONAL_FEATURES = [ 'networkReferences', 'extendFetchTargets', 'addParentToInstancesWithinFolder', + 'addParentToRecordTriggeredFlows', ] as const const DEPRECATED_OPTIONAL_FEATURES = [ 'addMissingIds', diff --git a/packages/salesforce-adapter/test/filters/add_parent_to_record_triggered_flows.test.ts b/packages/salesforce-adapter/test/filters/add_parent_to_record_triggered_flows.test.ts new file mode 100644 index 00000000000..5f8127434b8 --- /dev/null +++ b/packages/salesforce-adapter/test/filters/add_parent_to_record_triggered_flows.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2024 Salto Labs Ltd. + * Licensed under the Salto Terms of Use (the "License"); + * You may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.salto.io/terms-of-use + * + * CERTAIN THIRD PARTY SOFTWARE MAY BE CONTAINED IN PORTIONS OF THE SOFTWARE. See NOTICE FILE AT https://github.com/salto-io/salto/blob/main/NOTICES + */ + +import { CORE_ANNOTATIONS, Element, ReferenceExpression } from '@salto-io/adapter-api' +import { buildElementsSourceFromElements } from '@salto-io/adapter-utils' +import { mockTypes } from '../mock_elements' +import { createCustomObjectType, defaultFilterContext } from '../utils' +import { createInstanceElement } from '../../src/transformers/transformer' +import { OPPORTUNITY_METADATA_TYPE } from '../../src/constants' +import { FilterWith } from './mocks' +import filterCreator from '../../src/filters/add_parent_to_record_triggered_flows' +import { buildFetchProfile } from '../../src/fetch_profile/fetch_profile' + +const mockLogError = jest.fn() +jest.mock('@salto-io/logging', () => ({ + ...jest.requireActual<{}>('@salto-io/logging'), + logger: jest.fn().mockReturnValue({ + warn: jest.fn((...args) => mockLogError(...args)), + debug: jest.fn((...args) => mockLogError(...args)), + }), +})) + +describe('addParentToRecordTriggeredFlows', () => { + describe('onFetch', () => { + let elements: Element[] + let elementsSource: Element[] + let updateOpportunityFlow: Element + let updateLeadFlow: Element + let opportunity: Element + let lead: Element + + describe('when there are record triggered flows', () => { + describe('when addParentToRecordTriggeredFlows in Enabled', () => { + describe('when all objects that trigger flows exist in the workspace', () => { + beforeEach(async () => { + opportunity = createCustomObjectType(OPPORTUNITY_METADATA_TYPE, {}) + elementsSource = [opportunity] + elements = [ + (updateOpportunityFlow = createInstanceElement( + { fullName: 'UpdateOpportunity', start: { object: OPPORTUNITY_METADATA_TYPE } }, + mockTypes.Flow, + )), + (updateLeadFlow = createInstanceElement( + { fullName: 'UpdateLead', start: { object: 'Lead' } }, + mockTypes.Flow, + )), + (lead = createCustomObjectType('Lead', {})), + ] + const filter: FilterWith<'onFetch'> = filterCreator({ + config: { + ...defaultFilterContext, + fetchProfile: buildFetchProfile({ + fetchParams: { target: [], optionalFeatures: { addParentToRecordTriggeredFlows: true } }, + }), + elementsSource: buildElementsSourceFromElements(elementsSource), + }, + }) as FilterWith<'onFetch'> + await filter.onFetch(elements) + }) + it('should add parent annotation to updateOpportunityFlow', async () => { + expect(updateOpportunityFlow.annotations[CORE_ANNOTATIONS.PARENT][0]).toEqual( + new ReferenceExpression(opportunity.elemID, opportunity), + ) + }) + it('should add parent annotation to updateLeadFlow', async () => { + expect(updateLeadFlow.annotations[CORE_ANNOTATIONS.PARENT][0]).toEqual( + new ReferenceExpression(lead.elemID, lead), + ) + }) + }) + describe('when object that trigger Flow is missing from the workspace', () => { + beforeEach(async () => { + jest.clearAllMocks() + elementsSource = [] + elements = [ + (updateOpportunityFlow = createInstanceElement( + { fullName: 'UpdateOpportunity', start: { object: OPPORTUNITY_METADATA_TYPE } }, + mockTypes.Flow, + )), + (updateLeadFlow = createInstanceElement( + { fullName: 'UpdateLead', start: { object: 'Lead' } }, + mockTypes.Flow, + )), + ] + const filter: FilterWith<'onFetch'> = filterCreator({ + config: { + ...defaultFilterContext, + fetchProfile: buildFetchProfile({ + fetchParams: { target: [], optionalFeatures: { addParentToRecordTriggeredFlows: true } }, + }), + elementsSource: buildElementsSourceFromElements(elementsSource), + }, + }) as FilterWith<'onFetch'> + await filter.onFetch(elements) + }) + it('should return warning that opportunity is missing from the workspace', () => { + expect(mockLogError).toHaveBeenCalledWith( + 'could not add parent reference to instance %s, the object %s is missing from the workspace', + updateOpportunityFlow, + OPPORTUNITY_METADATA_TYPE, + ) + }) + it('should return warning that lead is missing from the workspace', () => { + expect(mockLogError).toHaveBeenCalledWith( + 'could not add parent reference to instance %s, the object %s is missing from the workspace', + updateLeadFlow, + 'Lead', + ) + }) + }) + }) + describe('when addParentToRecordTriggeredFlows in Disabled', () => {}) + }) + }) +}) diff --git a/packages/salesforce-adapter/test/filters/field_references.test.ts b/packages/salesforce-adapter/test/filters/field_references.test.ts index f3e02551226..94baa808d8e 100644 --- a/packages/salesforce-adapter/test/filters/field_references.test.ts +++ b/packages/salesforce-adapter/test/filters/field_references.test.ts @@ -50,9 +50,10 @@ import { } from '../../src/constants' import { metadataType, apiName, createInstanceElement } from '../../src/transformers/transformer' import { CUSTOM_OBJECT_TYPE_ID } from '../../src/filters/custom_objects_to_object_type' -import { defaultFilterContext } from '../utils' +import { createCustomObjectType, defaultFilterContext } from '../utils' import { mockTypes } from '../mock_elements' import { FilterWith } from './mocks' +import { buildFetchProfile } from '../../src/fetch_profile/fetch_profile' const { awu } = collections.asynciterable @@ -806,4 +807,59 @@ describe('Serialization Strategies', () => { ).toEqual(RESOLVED_VALUE) }) }) + describe('assignToReferenceField', () => { + const RESOLVED_VALUE = '$Record.TestCustomField__c' + let filter: FilterWith<'onFetch'> + let targetType: ObjectType + let flowInstance: InstanceElement + let targetInstance: InstanceElement + beforeEach(() => { + targetType = createCustomObjectType('TestType__c', { + fields: { + TestCustomField__c: { + refType: BuiltinTypes.STRING, + annotations: { [API_NAME]: 'TestType__c.TestCustomField__c' }, + }, + }, + }) + targetInstance = createInstanceElement( + { fullName: 'TargetInstance', TestCustomField__c: 'custom field value' }, + targetType, + ) + flowInstance = createInstanceElement( + { + fullName: 'TestFlow', + assignments: [ + { + assignToReference: RESOLVED_VALUE, + }, + ], + }, + mockTypes.Flow, + undefined, + { + [CORE_ANNOTATIONS.PARENT]: new ReferenceExpression(targetType.elemID, targetType), + }, + ) + filter = filterCreator({ + config: { + ...defaultFilterContext, + fetchProfile: buildFetchProfile({ + fetchParams: { target: [], optionalFeatures: { addParentToRecordTriggeredFlows: true } }, + }), + }, + }) as FilterWith<'onFetch'> + }) + it('should create reference to the CustomField and deserialize it to the original value', async () => { + await filter.onFetch([flowInstance, mockTypes.Flow, targetType, targetInstance]) + const createdReference = flowInstance.value.assignments[0].assignToReference as ReferenceExpression + expect(createdReference).toBeInstanceOf(ReferenceExpression) + expect( + await ReferenceSerializationStrategyLookup.assignToReferenceField.serialize({ + ref: createdReference, + element: flowInstance, + }), + ).toEqual(RESOLVED_VALUE) + }) + }) }) diff --git a/packages/salesforce-adapter/test/mock_elements.ts b/packages/salesforce-adapter/test/mock_elements.ts index 58653c37475..2c4bfecf8f8 100644 --- a/packages/salesforce-adapter/test/mock_elements.ts +++ b/packages/salesforce-adapter/test/mock_elements.ts @@ -58,6 +58,7 @@ import { CPQ_TERM_CONDITION, CPQ_INDEX_FIELD, OPPORTUNITY_METADATA_TYPE, + FLOW_FIELD_TYPE_NAMES, } from '../src/constants' import { createInstanceElement, createMetadataObjectType, Types } from '../src/transformers/transformer' import { allMissingSubTypes } from '../src/transformers/salesforce_types' @@ -473,6 +474,18 @@ export const mockTypes = { fields: { status: { refType: BuiltinTypes.STRING }, actionType: { refType: BuiltinTypes.STRING }, + assignments: { + refType: new ListType( + createMetadataObjectType({ + annotations: { metadataType: FLOW_FIELD_TYPE_NAMES.FLOW_ASSIGNMENT_ITEM }, + fields: { + assignToReference: { + refType: BuiltinTypes.STRING, + }, + }, + }), + ), + }, }, }), FlowDefinition: createMetadataObjectType({