Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SALTO-6375: Missing reference from FlowAssignmentItem (Flows) to CustomField #6998

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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
3 changes: 3 additions & 0 deletions packages/salesforce-adapter/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -259,6 +260,8 @@ export const allFilters: Array<FilterCreator> = [
// should run after convertListsFilter
replaceFieldValuesFilter,
valueToStaticFileFilter,
// addParentToRecordTriggeredFlows should run before fieldReferenceFilter
addParentToRecordTriggeredFlows,
fieldReferencesFilter,
// should run after customObjectsInstancesFilter for now
referenceAnnotationsFilter,
Expand Down
14 changes: 14 additions & 0 deletions packages/salesforce-adapter/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const optionalFeaturesDefaultValues: OptionalFeaturesDefaultValues = {
networkReferences: false,
extendFetchTargets: false,
addParentToInstancesWithinFolder: false,
addParentToRecordTriggeredFlows: false,
}

export const isFeatureEnabled = (name: keyof OptionalFeatures, optionalFeatures?: OptionalFeatures): boolean =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions packages/salesforce-adapter/src/transformers/reference_mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -104,6 +105,7 @@ type ReferenceSerializationStrategyName =
| 'customLabel'
| 'fromDataInstance'
| 'recordField'
| 'assignToReferenceField'
export const ReferenceSerializationStrategyLookup: Record<
ReferenceSerializationStrategyName,
ReferenceSerializationStrategy
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/salesforce-adapter/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const OPTIONAL_FEATURES = [
'networkReferences',
'extendFetchTargets',
'addParentToInstancesWithinFolder',
'addParentToRecordTriggeredFlows',
] as const
const DEPRECATED_OPTIONAL_FEATURES = [
'addMissingIds',
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
})
})
})
Loading