diff --git a/packages/cli/src/commands/generate/types.ts b/packages/cli/src/commands/generate/types.ts index df5953e491..69e347101b 100644 --- a/packages/cli/src/commands/generate/types.ts +++ b/packages/cli/src/commands/generate/types.ts @@ -142,8 +142,7 @@ export default class GenerateTypes extends Command { if (action.hooks) { const hooks: ActionHookDefinition = action.hooks - let hookBundle = '' - const hookFields: Record = {} + for (const [hookName, hook] of Object.entries(hooks)) { if (!hookTypeStrings.includes(hookName as ActionHookType)) { throw new Error(`Hook name ${hookName} is not a valid ActionHookType`) @@ -155,30 +154,16 @@ export default class GenerateTypes extends Command { continue } - const hookSchema = { - type: 'object', - required: true, - properties: { - inputs: { - label: `${hookName} hook inputs`, - type: 'object', - properties: inputs - }, - outputs: { - label: `${hookName} hook outputs`, - type: 'object', - properties: outputs - } - } + if (inputs) { + const inputTypes = await generateTypes(inputs, `${hookName}Inputs`) + types += inputTypes + } + + if (outputs) { + const outputTypes = await generateTypes(outputs, `${hookName}Outputs`) + types += outputTypes } - hookFields[hookName] = hookSchema } - hookBundle = await generateTypes( - hookFields, - 'HookBundle', - `// Generated bundle for hooks. DO NOT MODIFY IT BY HAND.` - ) - types += hookBundle } if (fs.pathExistsSync(path.join(parentDir, `${slug}`))) { diff --git a/packages/cli/src/lib/server.ts b/packages/cli/src/lib/server.ts index 5d469a0609..5faebf11df 100644 --- a/packages/cli/src/lib/server.ts +++ b/packages/cli/src/lib/server.ts @@ -259,7 +259,7 @@ function setupRoutes(def: DestinationDefinition | null): void { '/refreshAccessToken', asyncHandler(async (req: express.Request, res: express.Response) => { try { - const settings = {} + const settings = req.body.settings || {} const data = await destination.refreshAccessToken(settings, req.body) res.status(200).json({ ok: true, data }) } catch (e) { @@ -334,8 +334,10 @@ function setupRoutes(def: DestinationDefinition | null): void { payload: req.body.payload || {}, page: req.body.page || 1, auth: req.body.auth || {}, - audienceSettings: req.body.audienceSettings || {} + audienceSettings: req.body.audienceSettings || {}, + dynamicFieldContext: req.body.dynamicFieldContext || {} } + const action = destination.actions[actionSlug] const result = await action.executeDynamicField(field, data) @@ -404,8 +406,10 @@ function setupRoutes(def: DestinationDefinition | null): void { page: req.body.page || 1, auth: req.body.auth || {}, audienceSettings: req.body.audienceSettings || {}, - hookInputs: req.body.hookInputs || {} + hookInputs: req.body.hookInputs || {}, + dynamicFieldContext: req.body.dynamicFieldContext || {} } + const action = destination.actions[actionSlug] const dynamicFn = dynamicInputs[fieldKey] as RequestFn const result = await action.executeDynamicField(fieldKey, data, dynamicFn) diff --git a/packages/core/src/destination-kit/action.ts b/packages/core/src/destination-kit/action.ts index cc641efa44..d205c640b5 100644 --- a/packages/core/src/destination-kit/action.ts +++ b/packages/core/src/destination-kit/action.ts @@ -73,13 +73,6 @@ export interface BaseActionDefinition { type HookValueTypes = string | boolean | number | Array type GenericActionHookValues = Record -type GenericActionHookBundle = { - [K in ActionHookType]?: { - inputs?: GenericActionHookValues - outputs?: GenericActionHookValues - } -} - // Utility type to check if T is an array type IsArray = T extends (infer U)[] ? U : never @@ -94,7 +87,9 @@ export interface ActionDefinition< // eslint-disable-next-line @typescript-eslint/no-explicit-any AudienceSettings = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - GeneratedActionHookBundle extends GenericActionHookBundle = any + GeneratedActionHookInputs = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + GeneratedActionHookOutputs = any > extends BaseActionDefinition { /** * A way to "register" dynamic fields. @@ -139,8 +134,8 @@ export interface ActionDefinition< Settings, Payload, AudienceSettings, - NonNullable['outputs'], - NonNullable['inputs'] + NonNullable, + NonNullable > } @@ -170,8 +165,8 @@ export interface ActionHookDefinition< Settings, Payload, AudienceSettings, - GeneratedActionHookOutputs, - GeneratedActionHookTypesInputs + GeneratedActionHookTypesInputs, + GeneratedActionHookOutputs > { /** The display title for this hook. */ label: string diff --git a/packages/core/src/destination-kit/types.ts b/packages/core/src/destination-kit/types.ts index dc84cc9169..3c44e55a6e 100644 --- a/packages/core/src/destination-kit/types.ts +++ b/packages/core/src/destination-kit/types.ts @@ -36,6 +36,8 @@ export interface DynamicFieldContext { selectedArrayIndex?: number /** The key within a dynamic object for which we are requesting values */ selectedKey?: string + /** The RichInput dropdown search query the user has entered */ + query?: string } export interface ExecuteInput< diff --git a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/generated-types.ts b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/generated-types.ts index 3796c95181..5ec277c539 100644 --- a/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/generated-types.ts +++ b/packages/destination-actions/src/destinations/facebook-custom-audiences/sync/generated-types.ts @@ -70,33 +70,31 @@ export interface Payload { */ batch_size: number } -// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. +// Generated file. DO NOT MODIFY IT BY HAND. -export interface HookBundle { - retlOnMappingSave: { - inputs?: { - /** - * Choose to either create a new custom audience or use an existing one. If you opt to create a new audience, we will display the required fields for audience creation. If you opt to use an existing audience, a drop-down menu will appear, allowing you to select from all the custom audiences in your ad account. - */ - operation?: string - /** - * The name of the audience in Facebook. - */ - audienceName?: string - /** - * The ID of the audience in Facebook. - */ - existingAudienceId?: string - } - outputs?: { - /** - * The name of the audience in Facebook this mapping is connected to. - */ - audienceName: string - /** - * The ID of the audience in Facebook. - */ - audienceId: string - } - } +export interface RetlOnMappingSaveInputs { + /** + * Choose to either create a new custom audience or use an existing one. If you opt to create a new audience, we will display the required fields for audience creation. If you opt to use an existing audience, a drop-down menu will appear, allowing you to select from all the custom audiences in your ad account. + */ + operation?: string + /** + * The name of the audience in Facebook. + */ + audienceName?: string + /** + * The ID of the audience in Facebook. + */ + existingAudienceId?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveOutputs { + /** + * The name of the audience in Facebook this mapping is connected to. + */ + audienceName: string + /** + * The ID of the audience in Facebook. + */ + audienceId: string } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts index 482dfabc02..a4f5719215 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/userList/generated-types.ts @@ -62,41 +62,39 @@ export interface Payload { */ event_name?: string } -// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveInputs { + /** + * The ID of an existing Google list that you would like to sync users to. If you provide this, we will not create a new list. + */ + list_id?: string + /** + * The name of the Google list that you would like to create. + */ + list_name?: string + /** + * Customer match upload key types. + */ + external_id_type: string + /** + * A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID + */ + app_id?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. -export interface HookBundle { - retlOnMappingSave: { - inputs?: { - /** - * The ID of an existing Google list that you would like to sync users to. If you provide this, we will not create a new list. - */ - list_id?: string - /** - * The name of the Google list that you would like to create. - */ - list_name?: string - /** - * Customer match upload key types. - */ - external_id_type: string - /** - * A string that uniquely identifies a mobile application from which the data was collected. Required if external ID type is mobile advertising ID - */ - app_id?: string - } - outputs?: { - /** - * The ID of the Google Customer Match User list that users will be synced to. - */ - id?: string - /** - * The name of the Google Customer Match User list that users will be synced to. - */ - name?: string - /** - * Customer match upload key types. - */ - external_id_type?: string - } - } +export interface RetlOnMappingSaveOutputs { + /** + * The ID of the Google Customer Match User list that users will be synced to. + */ + id?: string + /** + * The name of the Google Customer Match User list that users will be synced to. + */ + name?: string + /** + * Customer match upload key types. + */ + external_id_type?: string } diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts index a10b44461f..5aca4ebe55 100644 --- a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts @@ -73,29 +73,27 @@ export interface Payload { */ override_list_id?: string } -// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. +// Generated file. DO NOT MODIFY IT BY HAND. -export interface HookBundle { - retlOnMappingSave: { - inputs?: { - /** - * The ID of the list in Klaviyo that users will be synced to. If defined, we will not create a new list. - */ - list_identifier?: string - /** - * The name of the list that you would like to create in Klaviyo. - */ - list_name?: string - } - outputs?: { - /** - * The ID of the created Klaviyo list that users will be synced to. - */ - id?: string - /** - * The name of the created Klaviyo list that users will be synced to. - */ - name?: string - } - } +export interface RetlOnMappingSaveInputs { + /** + * The ID of the list in Klaviyo that users will be synced to. If defined, we will not create a new list. + */ + list_identifier?: string + /** + * The name of the list that you would like to create in Klaviyo. + */ + list_name?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveOutputs { + /** + * The ID of the created Klaviyo list that users will be synced to. + */ + id?: string + /** + * The name of the created Klaviyo list that users will be synced to. + */ + name?: string } diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts b/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts index 114d384fdc..10888a7ed2 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts @@ -2,7 +2,7 @@ import nock from 'nock' import createRequestClient from '../../../../../core/src/create-request-client' import { LinkedInConversions } from '../api' import { BASE_URL } from '../constants' -import { HookBundle } from '../streamConversion/generated-types' +import { OnMappingSaveInputs, OnMappingSaveOutputs } from '../streamConversion/generated-types' const requestClient = createRequestClient() @@ -10,7 +10,7 @@ describe('LinkedIn Conversions', () => { describe('conversionRule methods', () => { const linkedIn: LinkedInConversions = new LinkedInConversions(requestClient) const adAccountId = 'urn:li:sponsoredAccount:123456' - const hookInputs: HookBundle['onMappingSave']['inputs'] = { + const hookInputs: OnMappingSaveInputs = { adAccountId, name: 'A different name that should trigger an update', conversionType: 'PURCHASE', @@ -19,7 +19,7 @@ describe('LinkedIn Conversions', () => { view_through_attribution_window_size: 7 } - const hookOutputs: HookBundle['onMappingSave']['outputs'] = { + const hookOutputs: OnMappingSaveOutputs = { id: '56789', name: 'The original name', conversionType: 'LEAD', diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts index 87be5a56af..c4351eb100 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts @@ -18,7 +18,7 @@ import type { GetConversionRuleResponse, ConversionRuleUpdateResponse } from '../types' -import type { Payload, HookBundle } from '../streamConversion/generated-types' +import type { Payload, OnMappingSaveInputs, OnMappingSaveOutputs } from '../streamConversion/generated-types' import { createHash } from 'crypto' interface ConversionRuleUpdateValues { @@ -84,7 +84,7 @@ export class LinkedInConversions { getConversionRule = async ( adAccount: string, conversionRuleId: string - ): Promise> => { + ): Promise> => { try { const { data } = await this.request(`${BASE_URL}/conversions/${conversionRuleId}`, { method: 'get', @@ -116,8 +116,8 @@ export class LinkedInConversions { } createConversionRule = async ( - hookInputs: HookBundle['onMappingSave']['inputs'] - ): Promise> => { + hookInputs: OnMappingSaveInputs | undefined + ): Promise> => { if (!hookInputs?.adAccountId) { return { error: { @@ -169,9 +169,9 @@ export class LinkedInConversions { } updateConversionRule = async ( - hookInputs: HookBundle['onMappingSave']['inputs'], - hookOutputs: HookBundle['onMappingSave']['outputs'] - ): Promise> => { + hookInputs: OnMappingSaveInputs | undefined, + hookOutputs: OnMappingSaveOutputs + ): Promise> => { if (!hookOutputs) { return { error: { @@ -576,8 +576,8 @@ export class LinkedInConversions { } private conversionRuleValuesUpdated = ( - hookInputs: HookBundle['onMappingSave']['inputs'], - hookOutputs: Partial + hookInputs: OnMappingSaveInputs, + hookOutputs: Partial ): ConversionRuleUpdateValues => { const valuesChanged: ConversionRuleUpdateValues = {} diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts index 4569faf232..332c6014bc 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts @@ -57,69 +57,67 @@ export interface Payload { */ batch_size?: number } -// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. +// Generated file. DO NOT MODIFY IT BY HAND. -export interface HookBundle { - onMappingSave: { - inputs?: { - /** - * The ad account to use when creating the conversion event. (When updating a conversion rule after initially creating it, changes to this field will be ignored. LinkedIn does not allow Ad Account IDs to be updated for a conversion rule.) - */ - adAccountId: string - /** - * Select one or more advertising campaigns from your ad account to associate with the configured conversion rule. Segment will only add the selected campaigns to the conversion rule. Deselecting a campaign will not disassociate it from the conversion rule. - */ - campaignId?: string[] - /** - * The ID of an existing conversion rule to stream events to. If defined, we will not create a new conversion rule. - */ - conversionRuleId?: string - /** - * The name of the conversion rule. - */ - name?: string - /** - * The type of conversion rule. - */ - conversionType?: string - /** - * The attribution type for the conversion rule. - */ - attribution_type?: string - /** - * Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 30. - */ - post_click_attribution_window_size?: number - /** - * Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7. - */ - view_through_attribution_window_size?: number - } - outputs?: { - /** - * The ID of the conversion rule. - */ - id: string - /** - * The name of the conversion rule. - */ - name: string - /** - * The type of conversion rule. - */ - conversionType: string - /** - * The attribution type for the conversion rule. - */ - attribution_type: string - /** - * Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. - */ - post_click_attribution_window_size: number - /** - * Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7. - */ - view_through_attribution_window_size: number - } - } +export interface OnMappingSaveInputs { + /** + * The ad account to use when creating the conversion event. (When updating a conversion rule after initially creating it, changes to this field will be ignored. LinkedIn does not allow Ad Account IDs to be updated for a conversion rule.) + */ + adAccountId: string + /** + * Select one or more advertising campaigns from your ad account to associate with the configured conversion rule. Segment will only add the selected campaigns to the conversion rule. Deselecting a campaign will not disassociate it from the conversion rule. + */ + campaignId?: string[] + /** + * The ID of an existing conversion rule to stream events to. If defined, we will not create a new conversion rule. + */ + conversionRuleId?: string + /** + * The name of the conversion rule. + */ + name?: string + /** + * The type of conversion rule. + */ + conversionType?: string + /** + * The attribution type for the conversion rule. + */ + attribution_type?: string + /** + * Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 30. + */ + post_click_attribution_window_size?: number + /** + * Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7. + */ + view_through_attribution_window_size?: number +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveOutputs { + /** + * The ID of the conversion rule. + */ + id: string + /** + * The name of the conversion rule. + */ + name: string + /** + * The type of conversion rule. + */ + conversionType: string + /** + * The attribution type for the conversion rule. + */ + attribution_type: string + /** + * Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. + */ + post_click_attribution_window_size: number + /** + * Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7. + */ + view_through_attribution_window_size: number } diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts index 15ba458a82..5cce0bb855 100644 --- a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts @@ -3,10 +3,10 @@ import { ErrorCodes, IntegrationError, PayloadValidationError, InvalidAuthentica import type { Settings } from '../generated-types' import { LinkedInConversions } from '../api' import { CONVERSION_TYPE_OPTIONS, SUPPORTED_LOOKBACK_WINDOW_CHOICES, DEPENDS_ON_CONVERSION_RULE_ID } from '../constants' -import type { Payload, HookBundle } from './generated-types' +import type { Payload, OnMappingSaveInputs, OnMappingSaveOutputs } from './generated-types' import { LinkedInError } from '../types' -const action: ActionDefinition = { +const action: ActionDefinition = { title: 'Stream Conversion Event', description: 'Directly streams conversion events to a specific conversion rule.', defaultSubscription: 'type = "track"', @@ -140,13 +140,13 @@ const action: ActionDefinition = { performHook: async (request, { hookInputs, hookOutputs }) => { const linkedIn = new LinkedInConversions(request) - let hookReturn: ActionHookResponse + let hookReturn: ActionHookResponse if (hookOutputs?.onMappingSave?.outputs) { linkedIn.setConversionRuleId(hookOutputs.onMappingSave.outputs.id) hookReturn = await linkedIn.updateConversionRule( hookInputs, - hookOutputs.onMappingSave.outputs as HookBundle['onMappingSave']['outputs'] + hookOutputs.onMappingSave.outputs as OnMappingSaveOutputs ) } else { hookReturn = await linkedIn.createConversionRule(hookInputs) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts index eae8037e6d..cf03471730 100644 --- a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts +++ b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts @@ -32,29 +32,27 @@ export interface Payload { */ event_name: string } -// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. +// Generated file. DO NOT MODIFY IT BY HAND. -export interface HookBundle { - retlOnMappingSave: { - inputs?: { - /** - * The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list. - */ - list_id?: string - /** - * The name of the Marketo Static List that you would like to create. - */ - list_name?: string - } - outputs?: { - /** - * The ID of the created Marketo Static List that users will be synced to. - */ - id?: string - /** - * The name of the created Marketo Static List that users will be synced to. - */ - name?: string - } - } +export interface RetlOnMappingSaveInputs { + /** + * The ID of the Marketo Static List that users will be synced to. If defined, we will not create a new list. + */ + list_id?: string + /** + * The name of the Marketo Static List that you would like to create. + */ + list_name?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface RetlOnMappingSaveOutputs { + /** + * The ID of the created Marketo Static List that users will be synced to. + */ + id?: string + /** + * The name of the created Marketo Static List that users will be synced to. + */ + name?: string } diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/dataExtension.test.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/dataExtension.test.ts index 47d153ebb4..64b0e51385 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/dataExtension.test.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/_tests_/dataExtension.test.ts @@ -61,5 +61,70 @@ describe('Salesforce Marketing Cloud', () => { `In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.` ) }) + + it.only('should prioritize using the data extension ID created or selected from the hook', async () => { + const expectedUrl = `https://${settings.subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/hook_output123/rowset` + + nock(expectedUrl).post('').reply(200, {}) + const responses = await testDestination.testAction('dataExtension', { + event, + settings, + mapping: { + keys: { '@path': '$.properties.keys' }, + values: { '@path': '$.properties.values' }, + onMappingSave: { + inputs: {}, + outputs: { + id: 'hook_output123' + } + } + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"[{\\"keys\\":{\\"id\\":\\"HS1\\"},\\"values\\":{\\"name\\":\\"Harry Styles\\"}}]"` + ) + }) + + it('should fallback to using an existing deprecated data extension ID if no hook ID exists', async () => { + const expectedUrl = `https://${settings.subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/deprecated_123/rowset` + + nock(expectedUrl).post('').reply(200, {}) + const responses = await testDestination.testAction('dataExtension', { + event, + settings, + mapping: { + keys: { '@path': '$.properties.keys' }, + values: { '@path': '$.properties.values' }, + id: 'deprecated_123' + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"[{\\"keys\\":{\\"id\\":\\"HS1\\"},\\"values\\":{\\"name\\":\\"Harry Styles\\"}}]"` + ) + }) + + it('should fallback to using an existing deprecated data extension key if no hook ID exists and no deprecated ID exists', async () => { + const expectedUrl = `https://${settings.subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/key:deprecated_123/rowset` + + nock(expectedUrl).post('').reply(200, {}) + const responses = await testDestination.testAction('dataExtension', { + event, + settings, + mapping: { + keys: { '@path': '$.properties.keys' }, + values: { '@path': '$.properties.values' }, + key: 'deprecated_123' + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"[{\\"keys\\":{\\"id\\":\\"HS1\\"},\\"values\\":{\\"name\\":\\"Harry Styles\\"}}]"` + ) + }) }) }) diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/generated-types.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/generated-types.ts index 3b8cfa1b47..e4378670f8 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/generated-types.ts @@ -2,11 +2,11 @@ export interface Payload { /** - * The external key of the data extension that you want to store information in. The data extension must be predefined in SFMC. The external key is required if a Data Extension ID is not provided. + * Note: This field should be considered deprecated in favor of the hook input field "Data Extension ID". For backwards compatibility the field will not be deleted, and is instead hidden. The external key of the data extension that you want to store information in. The data extension must be predefined in SFMC. The external key is required if a Data Extension ID is not provided. */ key?: string /** - * The ID of the data extension that you want to store information in. The data extension must be predefined in SFMC. The ID is required if a Data Extension Key is not provided. + * Note: This field should be considered deprecated in favor of the hook input field "Data Extension ID". For backwards compatibility the field will not be deleted, and is instead hidden. The ID of the data extension that you want to store information in. The data extension must be predefined in SFMC. The ID is required if a Data Extension Key is not provided. */ id?: string /** @@ -34,3 +34,73 @@ export interface Payload { */ batch_size?: number } +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveInputs { + /** + * Whether to create a new data extension or select an existing one for data delivery. + */ + operation: string + /** + * The external key of the data extension. + */ + dataExtensionKey?: string + /** + * The identifier for the data extension. + */ + dataExtensionId?: string + /** + * The identifier for the folder that contains the data extension. + */ + categoryId?: string + /** + * The name of the data extension. + */ + name?: string + /** + * The description of the data extension. + */ + description?: string + /** + * A list of fields to create in the data extension. + */ + columns?: { + /** + * The name of the field. + */ + name: string + /** + * The data type of the field. + */ + type: string + /** + * Whether the field can be null. + */ + isNullable: boolean + /** + * Whether the field is a primary key. + */ + isPrimaryKey: boolean + /** + * The length of the field. + */ + length: number + /** + * The description of the field. + */ + description?: string + [k: string]: unknown + }[] +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveOutputs { + /** + * The identifier for the data extension. + */ + id: string + /** + * The name of the data extension. + */ + name: string +} diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts index 4607abd1e7..4ad328950d 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/contactDataExtension/index.ts @@ -1,8 +1,27 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { key, id, keys, enable_batching, batch_size, values_contactFields } from '../sfmc-properties' -import { executeUpsertWithMultiStatus, upsertRows } from '../sfmc-operations' +import { + key, + id, + keys, + enable_batching, + batch_size, + values_contactFields, + categoryId, + name, + description, + columns, + operation, + dataExtensionKey, + dataExtensionId +} from '../sfmc-properties' +import { + executeUpsertWithMultiStatus, + upsertRows, + selectOrCreateDataExtension, + getDataExtensions +} from '../sfmc-operations' const action: ActionDefinition = { title: 'Send Contact to Data Extension', @@ -30,11 +49,61 @@ const action: ActionDefinition = { enable_batching: enable_batching, batch_size: batch_size }, - perform: async (request, { settings, payload }) => { - return upsertRows(request, settings.subdomain, [payload]) + hooks: { + onMappingSave: { + label: 'Create Data Extension', + description: 'Create a new data extension in Salesforce Marketing Cloud.', + inputFields: { + operation, + dataExtensionKey, + dataExtensionId: { + ...dataExtensionId, + dynamic: async (request, { dynamicFieldContext, settings }) => { + const query = dynamicFieldContext?.query + return await getDataExtensions(request, settings.subdomain, settings, query) + } + }, + categoryId, + name, + description, + columns + }, + outputTypes: { + id: { + label: 'Data Extension ID', + description: 'The identifier for the data extension.', + type: 'string', + required: true + }, + name: { + label: 'Data Extension Name', + description: 'The name of the data extension.', + type: 'string', + required: true + } + }, + performHook: async (request, { settings, hookInputs }) => { + return await selectOrCreateDataExtension(request, settings.subdomain, hookInputs, settings) + } + } }, - performBatch: async (request, { settings, payload }) => { - return executeUpsertWithMultiStatus(request, settings.subdomain, payload) + perform: async (request, { settings, payload, hookOutputs }) => { + const dataExtensionId = hookOutputs?.onMappingSave?.outputs.id || payload.id + const deprecated_dataExtensionKey = payload.key + + return upsertRows(request, settings.subdomain, [payload], dataExtensionId, deprecated_dataExtensionKey) + }, + performBatch: async (request, { settings, payload, hookOutputs }) => { + const dataExtensionId = hookOutputs?.onMappingSave?.outputs.id || payload[0].id + const deprecated_dataExtensionKey = payload[0].key + + return executeUpsertWithMultiStatus( + request, + settings.subdomain, + payload, + dataExtensionId, + deprecated_dataExtensionKey + ) } } diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/generated-types.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/generated-types.ts index 01757a6ad3..42f7cda1f6 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/generated-types.ts @@ -2,11 +2,11 @@ export interface Payload { /** - * The external key of the data extension that you want to store information in. The data extension must be predefined in SFMC. The external key is required if a Data Extension ID is not provided. + * Note: This field should be considered deprecated in favor of the hook input field "Data Extension ID". For backwards compatibility the field will not be deleted, and is instead hidden. The external key of the data extension that you want to store information in. The data extension must be predefined in SFMC. The external key is required if a Data Extension ID is not provided. */ key?: string /** - * The ID of the data extension that you want to store information in. The data extension must be predefined in SFMC. The ID is required if a Data Extension Key is not provided. + * Note: This field should be considered deprecated in favor of the hook input field "Data Extension ID". For backwards compatibility the field will not be deleted, and is instead hidden. The ID of the data extension that you want to store information in. The data extension must be predefined in SFMC. The ID is required if a Data Extension Key is not provided. */ id?: string /** @@ -30,3 +30,73 @@ export interface Payload { */ batch_size?: number } +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveInputs { + /** + * Whether to create a new data extension or select an existing one for data delivery. + */ + operation: string + /** + * The external key of the data extension. + */ + dataExtensionKey?: string + /** + * The identifier for the data extension. + */ + dataExtensionId?: string + /** + * The identifier for the folder that contains the data extension. + */ + categoryId?: string + /** + * The name of the data extension. + */ + name?: string + /** + * The description of the data extension. + */ + description?: string + /** + * A list of fields to create in the data extension. + */ + columns?: { + /** + * The name of the field. + */ + name: string + /** + * The data type of the field. + */ + type: string + /** + * Whether the field can be null. + */ + isNullable: boolean + /** + * Whether the field is a primary key. + */ + isPrimaryKey: boolean + /** + * The length of the field. + */ + length: number + /** + * The description of the field. + */ + description?: string + [k: string]: unknown + }[] +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface OnMappingSaveOutputs { + /** + * The identifier for the data extension. + */ + id: string + /** + * The name of the data extension. + */ + name: string +} diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts index 6635ac64ba..a41f03825d 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/dataExtension/index.ts @@ -1,8 +1,28 @@ import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { key, id, keys, enable_batching, batch_size, values_dataExtensionFields } from '../sfmc-properties' -import { executeUpsertWithMultiStatus, upsertRows } from '../sfmc-operations' +import { + key, + id, + keys, + enable_batching, + batch_size, + values_dataExtensionFields, + categoryId, + name, + description, + columns, + operation, + dataExtensionKey, + dataExtensionId +} from '../sfmc-properties' +import { + executeUpsertWithMultiStatus, + upsertRows, + selectOrCreateDataExtension, + getDataExtensions, + getDataExtensionFields +} from '../sfmc-operations' const action: ActionDefinition = { title: 'Send Event to Data Extension', @@ -15,11 +35,70 @@ const action: ActionDefinition = { enable_batching: enable_batching, batch_size: batch_size }, - perform: async (request, { settings, payload }) => { - return upsertRows(request, settings.subdomain, [payload]) + dynamicFields: { + keys: { + __keys__: async (request, { settings, hookOutputs }) => { + const dataExtensionId = hookOutputs?.onMappingSave?.id + console.log('dataExtensionId', dataExtensionId) + return await getDataExtensionFields(request, settings.subdomain, settings, dataExtensionId) + } + } }, - performBatch: async (request, { settings, payload }) => { - return executeUpsertWithMultiStatus(request, settings.subdomain, payload) + hooks: { + onMappingSave: { + label: 'Create Data Extension', + description: 'Create a new data extension in Salesforce Marketing Cloud.', + inputFields: { + operation, + dataExtensionKey, + dataExtensionId: { + ...dataExtensionId, + dynamic: async (request, { dynamicFieldContext, settings }) => { + const query = dynamicFieldContext?.query + return await getDataExtensions(request, settings.subdomain, settings, query) + } + }, + categoryId, + name, + description, + columns + }, + outputTypes: { + id: { + label: 'Data Extension ID', + description: 'The identifier for the data extension.', + type: 'string', + required: true + }, + name: { + label: 'Data Extension Name', + description: 'The name of the data extension.', + type: 'string', + required: true + } + }, + performHook: async (request, { settings, hookInputs }) => { + return await selectOrCreateDataExtension(request, settings.subdomain, hookInputs, settings) + } + } + }, + perform: async (request, { settings, payload, hookOutputs }) => { + const dataExtensionId = hookOutputs?.onMappingSave?.outputs.id || payload.id + const deprecated_dataExtensionKey = payload.key + + return upsertRows(request, settings.subdomain, [payload], dataExtensionId, deprecated_dataExtensionKey) + }, + performBatch: async (request, { settings, payload, hookOutputs }) => { + const dataExtensionId = hookOutputs?.onMappingSave?.outputs.id || payload[0].id + const deprecated_dataExtensionKey = payload[0].key + + return executeUpsertWithMultiStatus( + request, + settings.subdomain, + payload, + dataExtensionId, + deprecated_dataExtensionKey + ) } } diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts index 194c906724..633056dd9c 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-operations.ts @@ -3,11 +3,17 @@ import { MultiStatusResponse, JSONLikeObject, ModifiedResponse, - IntegrationError + IntegrationError, + ActionHookResponse, + DynamicFieldResponse, + DynamicFieldError, + DynamicFieldItem } from '@segment/actions-core' import { Payload as payload_dataExtension } from './dataExtension/generated-types' import { Payload as payload_contactDataExtension } from './contactDataExtension/generated-types' import { ErrorResponse } from './types' +import { OnMappingSaveInputs } from './dataExtension/generated-types' +import { Settings } from './generated-types' function generateRows(payloads: payload_dataExtension[] | payload_contactDataExtension[]): Record[] { const rows: Record[] = [] @@ -23,10 +29,11 @@ function generateRows(payloads: payload_dataExtension[] | payload_contactDataExt export function upsertRows( request: RequestClient, subdomain: String, - payloads: payload_dataExtension[] | payload_contactDataExtension[] + payloads: payload_dataExtension[] | payload_contactDataExtension[], + dataExtensionId?: string, + dataExtensionKey?: string ) { - const { key, id } = payloads[0] - if (!key && !id) { + if (!dataExtensionKey && !dataExtensionId) { throw new IntegrationError( `In order to send an event to a data extension either Data Extension ID or Data Extension Key must be defined.`, 'Misconfigured required field', @@ -34,13 +41,16 @@ export function upsertRows( ) } const rows = generateRows(payloads) - if (key) { - return request(`https://${subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/key:${key}/rowset`, { - method: 'POST', - json: rows - }) + if (dataExtensionKey) { + return request( + `https://${subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/key:${dataExtensionKey}/rowset`, + { + method: 'POST', + json: rows + } + ) } else { - return request(`https://${subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/${id}/rowset`, { + return request(`https://${subdomain}.rest.marketingcloudapis.com/hub/v1/dataevents/${dataExtensionId}/rowset`, { method: 'POST', json: rows }) @@ -50,13 +60,15 @@ export function upsertRows( export async function executeUpsertWithMultiStatus( request: RequestClient, subdomain: String, - payloads: payload_dataExtension[] | payload_contactDataExtension[] + payloads: payload_dataExtension[] | payload_contactDataExtension[], + dataExtensionId?: string, + dataExtensionKey?: string ): Promise { const multiStatusResponse = new MultiStatusResponse() let response: ModifiedResponse | undefined const rows = generateRows(payloads) try { - response = await upsertRows(request, subdomain, payloads) + response = await upsertRows(request, subdomain, payloads, dataExtensionId, dataExtensionKey) if (response) { const responseData = response.data as JSONLikeObject[] payloads.forEach((_, index) => { @@ -114,3 +126,372 @@ export async function executeUpsertWithMultiStatus( } return multiStatusResponse } + +interface DataExtensionField {} + +interface DataExtensionCreationResponse { + data: { + id?: string + name?: string + key?: string + message?: string + errorcode?: number + } +} + +interface DataExtensionSelectionResponse { + data: { + id?: string + name?: string + key?: string + } +} + +interface DataExtensionSearchResponse { + count: number + items: { + id: string + name: string + key: string + }[] +} + +interface DataExtensionFieldsResponse { + id: string + fields: { + name: string + type: string + }[] +} + +interface RefreshTokenResponse { + access_token: string + instance_url: string +} + +const getAccessToken = async (request: RequestClient, settings: Settings): Promise => { + const baseUrl = `https://${settings.subdomain}.auth.marketingcloudapis.com/v2/token` + const res = await request(`${baseUrl}`, { + method: 'POST', + body: new URLSearchParams({ + account_id: settings.account_id, + client_id: settings.client_id, + client_secret: settings.client_secret, + grant_type: 'client_credentials' + }) + }) + + return res.data.access_token +} + +const dataExtensionRequest = async ( + request: RequestClient, + hookInputs: OnMappingSaveInputs, + auth: { subdomain: string; accessToken: string } +): Promise<{ id?: string; key?: string; error?: string }> => { + if (!hookInputs) { + return { id: '', key: '', error: 'No inputs provided' } + } + + if (!hookInputs.columns) { + return { id: '', key: '', error: 'No columns provided' } + } + + const fields: DataExtensionField[] = hookInputs.columns.map((column, i) => { + return { + name: column.name, + type: column.type, + isNullable: column.isNullable, + isPrimaryKey: column.isPrimaryKey, + length: column.length, + description: column.description || '', + // these are required but we don't give the user an option + ordinal: i, + isTemplateField: false, + isHidden: false, + isReadOnly: false, + isInheritable: false, + isOverridable: false, + mustOverride: false + } + }) + + try { + const response = await request( + `https://${auth.subdomain}.rest.marketingcloudapis.com/data/v1/customobjects`, + { + method: 'POST', + json: { + name: hookInputs.name, + description: hookInputs.description, + categoryId: hookInputs.categoryId, + fields + }, + headers: { + authorization: `Bearer ${auth.accessToken}` + } + } + ) + + if (response.status !== 201) { + return { id: '', key: '', error: `Failed to create Data Extension` } + } + + return { + id: (response as DataExtensionCreationResponse).data.id, + key: (response as DataExtensionCreationResponse).data.key + } + } catch (error) { + return { id: '', key: '', error: error.response.data.message } + } +} + +async function createDataExtension( + request: RequestClient, + subdomain: string, + hookInputs: OnMappingSaveInputs, + settings: Settings +): Promise> { + if (!hookInputs) { + return { + error: { message: 'No inputs provided', code: 'ERROR' } + } + } + + const accessToken = await getAccessToken(request, settings) + + const { id, key, error } = await dataExtensionRequest(request, hookInputs, { subdomain, accessToken }) + + if (error || !id || !key) { + return { + error: { message: error || 'Unknown Error', code: 'ERROR' } + } + } + + return { + successMessage: `Data Extension ${hookInputs.name} created successfully with External Key ${key}`, + savedData: { + id, + name: hookInputs.name! + } + } +} + +const selectDataExtensionRequest = async ( + request: RequestClient, + hookInputs: OnMappingSaveInputs, + auth: { subdomain: string; accessToken: string } +): Promise<{ id?: string; key?: string; name?: string; error?: string }> => { + if (!hookInputs) { + return { id: '', key: '', name: '', error: 'No inputs provided' } + } + + if (!hookInputs.dataExtensionKey && !hookInputs.dataExtensionId) { + return { id: '', key: '', name: '', error: 'No Data Extension Key or Data Extension Id provided' } + } + + try { + const response = await request( + `https://${auth.subdomain}.rest.marketingcloudapis.com/data/v1/customobjects/${hookInputs.dataExtensionId}`, + { + method: 'GET', + headers: { + authorization: `Bearer ${auth.accessToken}` + } + } + ) + console.log('res', response) + if (response.status !== 200) { + return { id: '', key: '', name: '', error: `Failed to select Data Extension` } + } + + return { + id: (response as DataExtensionSelectionResponse).data.id, + key: (response as DataExtensionSelectionResponse).data.key, + name: (response as DataExtensionSelectionResponse).data.name + } + } catch (err) { + return { id: '', key: '', name: '', error: err.response.data.message } + } +} + +async function selectDataExtension( + request: RequestClient, + subdomain: string, + hookInputs: OnMappingSaveInputs, + settings: Settings +): Promise> { + if (!hookInputs) { + return { + error: { message: 'No inputs provided', code: 'ERROR' } + } + } + + const accessToken = await getAccessToken(request, settings) + + const { id, key, name, error } = await selectDataExtensionRequest(request, hookInputs, { subdomain, accessToken }) + + if (error || !id || !key) { + return { + error: { message: error || 'Unknown Error', code: 'ERROR' } + } + } + + return { + successMessage: `Data Extension ${name} selected successfully with External Key ${key}`, + savedData: { + id, + name: name! + } + } +} + +export const selectOrCreateDataExtension = async ( + request: RequestClient, + subdomain: string, + hookInputs: OnMappingSaveInputs, + settings: Settings +): Promise> => { + if (hookInputs.operation === 'create') { + return await createDataExtension(request, subdomain, hookInputs, settings) + } else if (hookInputs.operation === 'select') { + return await selectDataExtension(request, subdomain, hookInputs, settings) + } + + return { + error: { message: 'Invalid operation', code: 'ERROR' } + } +} + +const getDataExtensionsRequest = async ( + request: RequestClient, + searchQuery: string, + auth: { subdomain: string; accessToken: string } +): Promise<{ results?: DynamicFieldItem[]; error?: DynamicFieldError }> => { + try { + const response = await request( + `https://${auth.subdomain}.rest.marketingcloudapis.com/data/v1/customobjects`, + { + method: 'get', + searchParams: { + $search: searchQuery + }, + headers: { + authorization: `Bearer ${auth.accessToken}` + } + } + ) + + if (response.status !== 200) { + return { error: { message: 'Failed to fetch Data Extensions', code: 'BAD_REQUEST' } } + } + + const choices = response.data.items + + return { + results: choices.map((choice) => { + return { + value: choice.id, + label: choice.name + } + }) + } + } catch (err) { + return { error: { message: err.response.data.message, code: 'BAD_REQUEST' } } + } +} + +export const getDataExtensions = async ( + request: RequestClient, + subdomain: string, + settings: Settings, + query?: string +): Promise => { + let searchQuery = '_' + if (query && query !== '') { + searchQuery = query + } + + const accessToken = await getAccessToken(request, settings) + + const { results, error } = await getDataExtensionsRequest(request, searchQuery, { subdomain, accessToken }) + + if (error) { + return { + choices: [], + error: error + } + } + + if (!results || (Array.isArray(results) && results.length === 0)) { + return { + choices: [], + error: { message: 'No Data Extensions found', code: 'NOT_FOUND' } + } + } + + return { + choices: results + } +} + +const getDataExtensionFieldsRequest = async ( + request: RequestClient, + dataExtensionId: string, + auth: { subdomain: string; accessToken: string } +): Promise<{ results?: DynamicFieldItem[]; error?: DynamicFieldError }> => { + try { + const response = await request( + `https://${auth.subdomain}.rest.marketingcloudapis.com/data/v1/customobjects/${dataExtensionId}/fields`, + { + method: 'GET', + headers: { + authorization: `Bearer ${auth.accessToken}` + } + } + ) + + if (response.status !== 200) { + return { error: { message: 'Failed to fetch Data Extension fields', code: 'BAD_REQUEST' } } + } + + const choices = response.data.fields.map((field) => { + return { value: field.name, label: field.name } + }) + + return { results: choices } + } catch (err) { + return { error: { message: err.response.data.message, code: 'BAD_REQUEST' } } + } +} + +export const getDataExtensionFields = async ( + request: RequestClient, + subdomain: string, + settings: Settings, + _dataExtensionID: string +): Promise => { + const HARDCODED_TEMP_ID = 'ae46bb93-5ae2-ef11-ba65-d4f5ef42f41e' // todo reference the ID stored by the extension creation hook + + const accessToken = await getAccessToken(request, settings) + + const { results, error } = await getDataExtensionFieldsRequest(request, HARDCODED_TEMP_ID, { subdomain, accessToken }) + + if (error) { + return { + choices: [], + error: error + } + } + + if (!results || (Array.isArray(results) && results.length === 0)) { + return { + choices: [], + error: { message: 'No Data Extension fields found', code: 'NOT_FOUND' } + } + } + + return { + choices: results + } +} diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts index 5dc3de26eb..5cd09111f2 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts @@ -1,4 +1,4 @@ -import { InputField } from '@segment/actions-core/destination-kit/types' +import { InputField, FieldTypeName, DependsOnConditions } from '@segment/actions-core/destination-kit/types' export const contactKey: InputField = { label: 'Contact Key', @@ -17,17 +17,19 @@ export const contactKeyAPIEvent: InputField = { } export const key: InputField = { - label: 'Data Extension Key', + label: 'DEPRECATED: Data Extension Key', description: - 'The external key of the data extension that you want to store information in. The data extension must be predefined in SFMC. The external key is required if a Data Extension ID is not provided.', - type: 'string' + 'Note: This field should be considered deprecated in favor of the hook input field "Data Extension ID". For backwards compatibility the field will not be deleted, and is instead hidden. The external key of the data extension that you want to store information in. The data extension must be predefined in SFMC. The external key is required if a Data Extension ID is not provided.', + type: 'string', + unsafe_hidden: true } export const id: InputField = { - label: 'Data Extension ID', + label: 'DEPRECATED: Data Extension ID', description: - 'The ID of the data extension that you want to store information in. The data extension must be predefined in SFMC. The ID is required if a Data Extension Key is not provided.', - type: 'string' + 'Note: This field should be considered deprecated in favor of the hook input field "Data Extension ID". For backwards compatibility the field will not be deleted, and is instead hidden. The ID of the data extension that you want to store information in. The data extension must be predefined in SFMC. The ID is required if a Data Extension Key is not provided.', + type: 'string', + unsafe_hidden: true } export const keys: InputField = { @@ -37,7 +39,8 @@ export const keys: InputField = { type: 'object', required: true, defaultObjectUI: 'keyvalue:only', - additionalProperties: true + additionalProperties: true, + dynamic: true } export const values_contactFields: InputField = { @@ -94,3 +97,116 @@ export const batch_size: InputField = { * */ default: 10 } + +// Scripting for the create/select existing data extension flow +const CREATE_OPERATION: DependsOnConditions = { + match: 'all', + conditions: [{ fieldKey: 'operation', operator: 'is', value: 'create' }] +} + +const SELECT_OPERATION: DependsOnConditions = { + match: 'all', + conditions: [{ fieldKey: 'operation', operator: 'is', value: 'select' }] +} + +// The following properties represent hook inputs for the create data extension hook +export const categoryId = { + label: 'Category ID (Folder ID)', + description: 'The identifier for the folder that contains the data extension.', + type: 'string' as FieldTypeName, + required: CREATE_OPERATION, + depends_on: CREATE_OPERATION +} + +export const operation = { + label: 'Operation', + description: 'Whether to create a new data extension or select an existing one for data delivery.', + type: 'string' as FieldTypeName, + choices: [ + { label: 'Create a new Data Extension', value: 'create' }, + { label: 'Select an existing Data Extension', value: 'select' } + ], + required: true +} + +export const dataExtensionKey = { + label: 'Data Extension Key', + description: 'The external key of the data extension.', + type: 'string' as FieldTypeName, + depends_on: SELECT_OPERATION +} + +export const dataExtensionId = { + label: 'Data Extension ID', + description: 'The identifier for the data extension.', + type: 'string' as FieldTypeName, + depends_on: SELECT_OPERATION +} + +export const name = { + label: 'Data Extension Name', + description: 'The name of the data extension.', + type: 'string' as FieldTypeName, + required: CREATE_OPERATION, + depends_on: CREATE_OPERATION +} +export const description = { + label: 'Data Extension Description', + description: 'The description of the data extension.', + type: 'string' as FieldTypeName, + depends_on: CREATE_OPERATION +} + +export const columns: Omit & { + dynamic?: undefined // Typescript hack, this field will not be dynamic +} = { + label: 'Data Extension Fields', + description: 'A list of fields to create in the data extension.', + type: 'object' as FieldTypeName, + multiple: true, + defaultObjectUI: 'arrayeditor', + additionalProperties: true, + required: CREATE_OPERATION, + depends_on: CREATE_OPERATION, + properties: { + name: { + label: 'Field Name', + description: 'The name of the field.', + type: 'string', + required: true + }, + type: { + label: 'Field Type', + description: 'The data type of the field.', + type: 'string', + required: true, + choices: [ + { label: 'Text', value: 'Text' } + // todo add more + ] + }, + isNullable: { + label: 'Is Nullable', + description: 'Whether the field can be null.', + type: 'boolean', + required: true + }, + isPrimaryKey: { + label: 'Is Primary Key', + description: 'Whether the field is a primary key.', + type: 'boolean', + required: true + }, + length: { + label: 'Field Length', + description: 'The length of the field.', + type: 'integer', + required: true + }, + description: { + label: 'Field Description', + description: 'The description of the field.', + type: 'string' + } + } +}