mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(Facebook Lead Ads Trigger Node): Add Facebook Lead Ads Trigger Node (#7113)
Github issue / Community forum post (link here to close automatically): https://community.n8n.io/t/facebook-lead-ads-integration/4590/19 --------- Co-authored-by: Marcus <marcus@n8n.io>
This commit is contained in:
parent
647372be27
commit
ac814a9c61
|
@ -29,6 +29,21 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addWebhookLifecycle(nodeType) {
|
||||||
|
if (nodeType.description.webhooks) {
|
||||||
|
nodeType.description.webhooks = nodeType.description.webhooks.map((webhook) => {
|
||||||
|
const webhookMethods =
|
||||||
|
nodeType?.webhookMethods?.[webhook.name] ?? nodeType?.webhookMethods?.default;
|
||||||
|
webhook.hasLifecycleMethods = Boolean(
|
||||||
|
webhookMethods?.checkExists && webhookMethods?.create && webhookMethods?.delete,
|
||||||
|
);
|
||||||
|
return webhook;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeType;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const loader = new PackageDirectoryLoader(packageDir);
|
const loader = new PackageDirectoryLoader(packageDir);
|
||||||
await loader.loadAll();
|
await loader.loadAll();
|
||||||
|
@ -60,6 +75,7 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
|
||||||
.map((data) => {
|
.map((data) => {
|
||||||
const nodeType = NodeHelpers.getVersionedNodeType(data.type);
|
const nodeType = NodeHelpers.getVersionedNodeType(data.type);
|
||||||
NodeHelpers.applySpecialNodeParameters(nodeType);
|
NodeHelpers.applySpecialNodeParameters(nodeType);
|
||||||
|
addWebhookLifecycle(nodeType);
|
||||||
return data.type;
|
return data.type;
|
||||||
})
|
})
|
||||||
.flatMap((nodeData) => {
|
.flatMap((nodeData) => {
|
||||||
|
|
|
@ -61,12 +61,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
|
import type { INodeTypeDescription, IWebhookDescription } from 'n8n-workflow';
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
import { useToast } from '@/composables';
|
||||||
import { FORM_TRIGGER_NODE_TYPE, OPEN_URL_PANEL_TRIGGER_NODE_TYPES } from '@/constants';
|
import { FORM_TRIGGER_NODE_TYPE, OPEN_URL_PANEL_TRIGGER_NODE_TYPES } from '@/constants';
|
||||||
import { copyPaste } from '@/mixins/copyPaste';
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import { useToast } from '@/composables';
|
|
||||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
@ -94,7 +94,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (this.nodeType as INodeTypeDescription).webhooks!.filter(
|
return (this.nodeType as INodeTypeDescription).webhooks!.filter(
|
||||||
(webhookData) => webhookData.restartWebhook !== true,
|
(webhookData) => webhookData.restartWebhook !== true && !webhookData.hasLifecycleMethods,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
baseText() {
|
baseText() {
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class FacebookLeadAdsOAuth2Api implements ICredentialType {
|
||||||
|
name = 'facebookLeadAdsOAuth2Api';
|
||||||
|
|
||||||
|
extends = ['oAuth2Api'];
|
||||||
|
|
||||||
|
displayName = 'Facebook Lead Ads OAuth2 API';
|
||||||
|
|
||||||
|
documentationUrl = 'facebookleadads';
|
||||||
|
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Grant Type',
|
||||||
|
name: 'grantType',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'authorizationCode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authorization URL',
|
||||||
|
name: 'authUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://www.facebook.com/v17.0/dialog/oauth',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Access Token URL',
|
||||||
|
name: 'accessTokenUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://graph.facebook.com/v17.0/oauth/access_token',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Scope',
|
||||||
|
name: 'scope',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'leads_retrieval pages_show_list pages_manage_metadata pages_manage_ads',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Auth URI Query Parameters',
|
||||||
|
name: 'authQueryParameters',
|
||||||
|
type: 'hidden',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authentication',
|
||||||
|
name: 'authentication',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'header',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
|
@ -1,15 +1,16 @@
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
import type {
|
import type {
|
||||||
IHookFunctions,
|
|
||||||
IWebhookFunctions,
|
|
||||||
IDataObject,
|
IDataObject,
|
||||||
|
IHookFunctions,
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
INodePropertyOptions,
|
INodePropertyOptions,
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
|
IWebhookFunctions,
|
||||||
IWebhookResponseData,
|
IWebhookResponseData,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeApiError } from 'n8n-workflow';
|
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
@ -17,7 +18,7 @@ import { snakeCase } from 'change-case';
|
||||||
|
|
||||||
import { facebookApiRequest, getAllFields, getFields } from './GenericFunctions';
|
import { facebookApiRequest, getAllFields, getFields } from './GenericFunctions';
|
||||||
|
|
||||||
import { createHmac } from 'crypto';
|
import type { FacebookWebhookSubscription } from './types';
|
||||||
|
|
||||||
export class FacebookTrigger implements INodeType {
|
export class FacebookTrigger implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -177,18 +178,27 @@ export class FacebookTrigger implements INodeType {
|
||||||
const object = this.getNodeParameter('object') as string;
|
const object = this.getNodeParameter('object') as string;
|
||||||
const appId = this.getNodeParameter('appId') as string;
|
const appId = this.getNodeParameter('appId') as string;
|
||||||
|
|
||||||
const { data } = await facebookApiRequest.call(this, 'GET', `/${appId}/subscriptions`, {});
|
const { data } = (await facebookApiRequest.call(
|
||||||
|
this,
|
||||||
|
'GET',
|
||||||
|
`/${appId}/subscriptions`,
|
||||||
|
{},
|
||||||
|
)) as { data: FacebookWebhookSubscription[] };
|
||||||
|
|
||||||
for (const webhook of data) {
|
const subscription = data.find((webhook) => webhook.object === object && webhook.status);
|
||||||
if (
|
|
||||||
webhook.target === webhookUrl &&
|
if (!subscription) {
|
||||||
webhook.object === object &&
|
|
||||||
webhook.status === true
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.callback_url !== webhookUrl) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.getNode(),
|
||||||
|
`The Facebook App ID ${appId} already has a webhook subscription. Delete it or use another App before executing the trigger. Due to Facebook API limitations, you can have just one trigger per App.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
async create(this: IHookFunctions): Promise<boolean> {
|
async create(this: IHookFunctions): Promise<boolean> {
|
||||||
const webhookData = this.getWorkflowStaticData('node');
|
const webhookData = this.getWorkflowStaticData('node');
|
||||||
|
|
29
packages/nodes-base/nodes/Facebook/types.ts
Normal file
29
packages/nodes-base/nodes/Facebook/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
export interface FacebookEvent {
|
||||||
|
object: string;
|
||||||
|
entry: FacebookPageEventEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookPageEventEntry {
|
||||||
|
id: string;
|
||||||
|
time: number;
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
field: 'leadgen';
|
||||||
|
value: {
|
||||||
|
ad_id: string;
|
||||||
|
form_id: string;
|
||||||
|
leadgen_id: string;
|
||||||
|
created_time: number;
|
||||||
|
page_id: string;
|
||||||
|
adgroup_id: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookWebhookSubscription {
|
||||||
|
object: string;
|
||||||
|
callback_url: string;
|
||||||
|
fields: string[];
|
||||||
|
status: boolean;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"node": "n8n-nodes-base.facebookLeadAdsTrigger",
|
||||||
|
"nodeVersion": "1.0",
|
||||||
|
"codexVersion": "1.0",
|
||||||
|
"categories": ["Marketing & Content"],
|
||||||
|
"resources": {
|
||||||
|
"credentialDocumentation": [
|
||||||
|
{
|
||||||
|
"url": "https://docs.n8n.io/integrations/credentials/facebookleadads/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryDocumentation": [
|
||||||
|
{
|
||||||
|
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.facebookleadadstrigger/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
import {
|
||||||
|
NodeOperationError,
|
||||||
|
type IDataObject,
|
||||||
|
type IHookFunctions,
|
||||||
|
type INodeType,
|
||||||
|
type INodeTypeDescription,
|
||||||
|
type IWebhookFunctions,
|
||||||
|
type IWebhookResponseData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
appWebhookSubscriptionCreate,
|
||||||
|
appWebhookSubscriptionDelete,
|
||||||
|
appWebhookSubscriptionList,
|
||||||
|
facebookEntityDetail,
|
||||||
|
installAppOnPage,
|
||||||
|
} from './GenericFunctions';
|
||||||
|
import { listSearch } from './methods';
|
||||||
|
import type { FacebookForm, FacebookFormLeadData, FacebookPageEvent } from './types';
|
||||||
|
|
||||||
|
export class FacebookLeadAdsTrigger implements INodeType {
|
||||||
|
description: INodeTypeDescription = {
|
||||||
|
displayName: 'Facebook Lead Ads Trigger',
|
||||||
|
name: 'facebookLeadAdsTrigger',
|
||||||
|
icon: 'file:facebook.svg',
|
||||||
|
group: ['trigger'],
|
||||||
|
version: 1,
|
||||||
|
subtitle: '={{$parameter["event"]}}',
|
||||||
|
description: 'Handle Facebook Lead Ads events via webhooks',
|
||||||
|
defaults: {
|
||||||
|
name: 'Facebook Lead Ads Trigger',
|
||||||
|
},
|
||||||
|
inputs: [],
|
||||||
|
outputs: ['main'],
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: 'facebookLeadAdsOAuth2Api',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
name: 'setup',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
responseMode: 'onReceived',
|
||||||
|
path: 'webhook',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
httpMethod: 'POST',
|
||||||
|
responseMode: 'onReceived',
|
||||||
|
path: 'webhook',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
'Due to Facebook API limitations, you can use just one Facebook Lead Ads trigger for each Facebook App',
|
||||||
|
name: 'facebookLeadAdsNotice',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Event',
|
||||||
|
name: 'event',
|
||||||
|
type: 'options',
|
||||||
|
required: true,
|
||||||
|
default: 'newLead',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'New Lead',
|
||||||
|
value: 'newLead',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Page',
|
||||||
|
name: 'page',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
default: { mode: 'list', value: '' },
|
||||||
|
required: true,
|
||||||
|
description: 'The page linked to the form for retrieving new leads',
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
displayName: 'From List',
|
||||||
|
name: 'list',
|
||||||
|
type: 'list',
|
||||||
|
typeOptions: {
|
||||||
|
searchListMethod: 'pageList',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'By ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '121637951029080',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Form',
|
||||||
|
name: 'form',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
default: { mode: 'list', value: '' },
|
||||||
|
required: true,
|
||||||
|
description: 'The form to monitor for fetching lead details upon submission',
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
displayName: 'From List',
|
||||||
|
name: 'list',
|
||||||
|
type: 'list',
|
||||||
|
typeOptions: {
|
||||||
|
searchListMethod: 'formList',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'By ID',
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '121637951029080',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Options',
|
||||||
|
name: 'options',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Option',
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Simplify Output',
|
||||||
|
name: 'simplifyOutput',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
description:
|
||||||
|
'Whether to return a simplified version of the webhook event instead of all fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
listSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
webhookMethods = {
|
||||||
|
default: {
|
||||||
|
async checkExists(this: IHookFunctions): Promise<boolean> {
|
||||||
|
const webhookUrl = this.getNodeWebhookUrl('default') as string;
|
||||||
|
const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api');
|
||||||
|
const appId = credentials.clientId as string;
|
||||||
|
|
||||||
|
const webhooks = await appWebhookSubscriptionList.call(this, appId);
|
||||||
|
|
||||||
|
const subscription = webhooks.find(
|
||||||
|
(webhook) =>
|
||||||
|
webhook.object === 'page' &&
|
||||||
|
webhook.fields.find((field) => field.name === 'leadgen') &&
|
||||||
|
webhook.active,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.callback_url !== webhookUrl) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.getNode(),
|
||||||
|
`The Facebook App ID ${appId} already has a webhook subscription. Delete it or use another App before executing the trigger. Due to Facebook API limitations, you can have just one trigger per App.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async create(this: IHookFunctions): Promise<boolean> {
|
||||||
|
const webhookUrl = this.getNodeWebhookUrl('default') as string;
|
||||||
|
const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api');
|
||||||
|
const appId = credentials.clientId as string;
|
||||||
|
const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string;
|
||||||
|
const verifyToken = this.getNode().id;
|
||||||
|
|
||||||
|
await appWebhookSubscriptionCreate.call(this, appId, {
|
||||||
|
object: 'page',
|
||||||
|
callback_url: webhookUrl,
|
||||||
|
verify_token: verifyToken,
|
||||||
|
fields: ['leadgen'],
|
||||||
|
include_values: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await installAppOnPage.call(this, pageId, 'leadgen');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async delete(this: IHookFunctions): Promise<boolean> {
|
||||||
|
const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api');
|
||||||
|
const appId = credentials.clientId as string;
|
||||||
|
|
||||||
|
await appWebhookSubscriptionDelete.call(this, appId, 'page');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||||
|
const bodyData = this.getBodyData() as unknown as FacebookPageEvent;
|
||||||
|
const query = this.getQueryData() as IDataObject;
|
||||||
|
const res = this.getResponseObject();
|
||||||
|
const req = this.getRequestObject();
|
||||||
|
const headerData = this.getHeaderData() as IDataObject;
|
||||||
|
const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api');
|
||||||
|
const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string;
|
||||||
|
const formId = this.getNodeParameter('form', '', { extractValue: true }) as string;
|
||||||
|
|
||||||
|
// Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started)
|
||||||
|
if (this.getWebhookName() === 'setup') {
|
||||||
|
if (query['hub.challenge']) {
|
||||||
|
if (this.getNode().id !== query['hub.verify_token']) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(query['hub.challenge']).end();
|
||||||
|
|
||||||
|
return { noWebhookResponse: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedSignature = createHmac('sha256', credentials.clientSecret as string)
|
||||||
|
.update(req.rawBody)
|
||||||
|
.digest('hex');
|
||||||
|
if (headerData['x-hub-signature-256'] !== `sha256=${computedSignature}`) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyData.object !== 'page') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await Promise.all(
|
||||||
|
bodyData.entry
|
||||||
|
.map((entry) =>
|
||||||
|
entry.changes
|
||||||
|
.filter(
|
||||||
|
(change) =>
|
||||||
|
change.field === 'leadgen' &&
|
||||||
|
change.value.page_id === pageId &&
|
||||||
|
change.value.form_id === formId,
|
||||||
|
)
|
||||||
|
.map((change) => change.value),
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.map(async (event) => {
|
||||||
|
const [lead, form] = await Promise.all([
|
||||||
|
facebookEntityDetail.call(
|
||||||
|
this,
|
||||||
|
event.leadgen_id,
|
||||||
|
'field_data,created_time,ad_id,ad_name,adset_id,adset_name,form_id',
|
||||||
|
) as Promise<FacebookFormLeadData>,
|
||||||
|
facebookEntityDetail.call(
|
||||||
|
this,
|
||||||
|
event.form_id,
|
||||||
|
'id,name,locale,status,page,questions',
|
||||||
|
) as Promise<FacebookForm>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const simplifyOutput = this.getNodeParameter('options.simplifyOutput', true) as boolean;
|
||||||
|
|
||||||
|
if (simplifyOutput) {
|
||||||
|
return {
|
||||||
|
id: lead.id,
|
||||||
|
data: lead.field_data.reduce(
|
||||||
|
(acc, field) => ({ ...acc, [field.name]: field.values[0] }),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
form: {
|
||||||
|
id: form.id,
|
||||||
|
name: form.name,
|
||||||
|
locale: form.locale,
|
||||||
|
status: form.status,
|
||||||
|
},
|
||||||
|
ad: { id: lead.ad_id, name: lead.ad_name },
|
||||||
|
adset: { id: lead.adset_id, name: lead.adset_name },
|
||||||
|
page: form.page,
|
||||||
|
created_time: lead.created_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lead.id,
|
||||||
|
field_data: lead.field_data,
|
||||||
|
form,
|
||||||
|
ad: { id: lead.ad_id, name: lead.ad_name },
|
||||||
|
adset: { id: lead.adset_id, name: lead.adset_name },
|
||||||
|
page: form.page,
|
||||||
|
created_time: lead.created_time,
|
||||||
|
event,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflowData: [this.helpers.returnJsonArray(events)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
222
packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts
Normal file
222
packages/nodes-base/nodes/FacebookLeadAds/GenericFunctions.ts
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
import type { OptionsWithUri } from 'request';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IDataObject,
|
||||||
|
IExecuteFunctions,
|
||||||
|
IHookFunctions,
|
||||||
|
ILoadOptionsFunctions,
|
||||||
|
IWebhookFunctions,
|
||||||
|
JsonObject,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeApiError } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
CreateFacebookAppWebhookSubscription,
|
||||||
|
FacebookAppWebhookSubscription,
|
||||||
|
FacebookAppWebhookSubscriptionsResponse,
|
||||||
|
FacebookFormListResponse,
|
||||||
|
FacebookPage,
|
||||||
|
FacebookPageListResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export async function facebookApiRequest(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
method: string,
|
||||||
|
resource: string,
|
||||||
|
body = {},
|
||||||
|
qs: IDataObject = {},
|
||||||
|
): Promise<any> {
|
||||||
|
const options: OptionsWithUri = {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
qs,
|
||||||
|
body,
|
||||||
|
gzip: true,
|
||||||
|
uri: `https://graph.facebook.com/v17.0${resource}`,
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.helpers.requestOAuth2.call(this, 'facebookLeadAdsOAuth2Api', options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeApiError(this.getNode(), error as JsonObject, {
|
||||||
|
message: error?.error?.error?.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appAccessTokenRead(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
): Promise<{ access_token: string }> {
|
||||||
|
const credentials = await this.getCredentials('facebookLeadAdsOAuth2Api');
|
||||||
|
|
||||||
|
const options: OptionsWithUri = {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
form: {
|
||||||
|
client_id: credentials.clientId,
|
||||||
|
client_secret: credentials.clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
},
|
||||||
|
uri: credentials.accessTokenUrl as string,
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await this.helpers.request(options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function facebookAppApiRequest(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
method: string,
|
||||||
|
resource: string,
|
||||||
|
body?: { type: 'json'; payload: IDataObject } | { type: 'form'; payload: IDataObject },
|
||||||
|
qs: IDataObject = {},
|
||||||
|
): Promise<any> {
|
||||||
|
const tokenResponse = await appAccessTokenRead.call(this);
|
||||||
|
const appAccessToken = tokenResponse.access_token;
|
||||||
|
|
||||||
|
const options: OptionsWithUri = {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${appAccessToken}`,
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
qs,
|
||||||
|
gzip: true,
|
||||||
|
uri: `https://graph.facebook.com/v17.0${resource}`,
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body?.type === 'json') {
|
||||||
|
options.body = body.payload;
|
||||||
|
} else if (body?.type === 'form') {
|
||||||
|
options.form = body.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.helpers.request(options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appWebhookSubscriptionList(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
appId: string,
|
||||||
|
): Promise<FacebookAppWebhookSubscription[]> {
|
||||||
|
const response = (await facebookAppApiRequest.call(
|
||||||
|
this,
|
||||||
|
'GET',
|
||||||
|
`/${appId}/subscriptions`,
|
||||||
|
)) as FacebookAppWebhookSubscriptionsResponse;
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appWebhookSubscriptionCreate(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
appId: string,
|
||||||
|
subscription: CreateFacebookAppWebhookSubscription,
|
||||||
|
) {
|
||||||
|
return facebookAppApiRequest.call(this, 'POST', `/${appId}/subscriptions`, {
|
||||||
|
type: 'form',
|
||||||
|
payload: { ...subscription },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appWebhookSubscriptionDelete(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
appId: string,
|
||||||
|
object: string,
|
||||||
|
) {
|
||||||
|
return facebookAppApiRequest.call(this, 'DELETE', `/${appId}/subscriptions`, {
|
||||||
|
type: 'form',
|
||||||
|
payload: { object },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function facebookPageList(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<FacebookPageListResponse> {
|
||||||
|
const response = (await facebookApiRequest.call(
|
||||||
|
this,
|
||||||
|
'GET',
|
||||||
|
'/me/accounts',
|
||||||
|
{},
|
||||||
|
{ cursor, fields: 'id,name' },
|
||||||
|
)) as FacebookPageListResponse;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function facebookEntityDetail(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
entityId: string,
|
||||||
|
fields = 'id,name,access_token',
|
||||||
|
): Promise<any> {
|
||||||
|
return facebookApiRequest.call(this, 'GET', `/${entityId}`, {}, { fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function facebookPageApiRequest(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
method: string,
|
||||||
|
resource: string,
|
||||||
|
body = {},
|
||||||
|
qs: IDataObject = {},
|
||||||
|
): Promise<any> {
|
||||||
|
const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string;
|
||||||
|
const page = (await facebookEntityDetail.call(this, pageId)) as FacebookPage;
|
||||||
|
const pageAccessToken = page.access_token;
|
||||||
|
const options: OptionsWithUri = {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${pageAccessToken}`,
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
qs,
|
||||||
|
body,
|
||||||
|
gzip: true,
|
||||||
|
uri: `https://graph.facebook.com/v17.0${resource}`,
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.helpers.request.call(this, options);
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installAppOnPage(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
pageId: string,
|
||||||
|
fields: string,
|
||||||
|
) {
|
||||||
|
return facebookPageApiRequest.call(
|
||||||
|
this,
|
||||||
|
'POST',
|
||||||
|
`/${pageId}/subscribed_apps`,
|
||||||
|
{},
|
||||||
|
{ subscribed_fields: fields },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function facebookFormList(
|
||||||
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||||
|
pageId: string,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<FacebookFormListResponse> {
|
||||||
|
const response = (await facebookPageApiRequest.call(
|
||||||
|
this,
|
||||||
|
'GET',
|
||||||
|
`/${pageId}/leadgen_forms`,
|
||||||
|
{},
|
||||||
|
{ cursor, fields: 'id,name' },
|
||||||
|
)) as FacebookFormListResponse;
|
||||||
|
return response;
|
||||||
|
}
|
1
packages/nodes-base/nodes/FacebookLeadAds/facebook.svg
Normal file
1
packages/nodes-base/nodes/FacebookLeadAds/facebook.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60"><path d="M59.5 30C59.5 13.71 46.29.5 30 .5S.5 13.71.5 30c0 14.72 10.79 26.93 24.89 29.14V38.53H17.9V30h7.49v-6.5c0-7.39 4.4-11.48 11.14-11.48 3.23 0 6.6.58 6.6.58v7.26h-3.72c-3.66 0-4.81 2.27-4.81 4.61V30h8.18l-1.31 8.53H34.6v20.61C48.71 56.93 59.5 44.72 59.5 30z" fill="#1877f2"/><path d="M41.48 38.53L42.79 30h-8.18v-5.53c0-2.33 1.14-4.61 4.81-4.61h3.72V12.6s-3.38-.58-6.6-.58c-6.74 0-11.14 4.08-11.14 11.48V30h-7.5v8.53h7.49v20.61c1.5.24 3.04.36 4.61.36s3.11-.12 4.61-.36V38.53h6.87z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 567 B |
|
@ -0,0 +1 @@
|
||||||
|
export * as listSearch from './listSearch';
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||||
|
import { facebookFormList, facebookPageList } from '../GenericFunctions';
|
||||||
|
|
||||||
|
const filterMatches = (name: string, filter?: string): boolean =>
|
||||||
|
!filter || name?.toLowerCase().includes(filter.toLowerCase());
|
||||||
|
|
||||||
|
export async function pageList(
|
||||||
|
this: ILoadOptionsFunctions,
|
||||||
|
filter?: string,
|
||||||
|
paginationToken?: string,
|
||||||
|
): Promise<INodeListSearchResult> {
|
||||||
|
const { data: pages, paging } = await facebookPageList.call(this, paginationToken);
|
||||||
|
return {
|
||||||
|
results: pages
|
||||||
|
.filter((page) => filterMatches(page.name, filter))
|
||||||
|
.map((page) => ({
|
||||||
|
name: page.name,
|
||||||
|
value: page.id,
|
||||||
|
url: `https://facebook.com/${page.id}`,
|
||||||
|
})),
|
||||||
|
paginationToken: paging?.cursors?.after,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formList(
|
||||||
|
this: ILoadOptionsFunctions,
|
||||||
|
filter?: string,
|
||||||
|
paginationToken?: string,
|
||||||
|
): Promise<INodeListSearchResult> {
|
||||||
|
const pageId = this.getNodeParameter('page', '', { extractValue: true }) as string;
|
||||||
|
|
||||||
|
const { data: forms, paging } = await facebookFormList.call(this, pageId, paginationToken);
|
||||||
|
return {
|
||||||
|
results: forms
|
||||||
|
.filter((form) => filterMatches(form.name, filter))
|
||||||
|
.map((form) => ({
|
||||||
|
name: form.name,
|
||||||
|
value: form.id,
|
||||||
|
})),
|
||||||
|
paginationToken: paging?.cursors?.after,
|
||||||
|
};
|
||||||
|
}
|
105
packages/nodes-base/nodes/FacebookLeadAds/types.ts
Normal file
105
packages/nodes-base/nodes/FacebookLeadAds/types.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import type { GenericValue } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export type BaseFacebookResponse<TData> = { data: TData };
|
||||||
|
export type BasePaginatedFacebookResponse<TData> = BaseFacebookResponse<TData> & {
|
||||||
|
paging: { cursors: { before?: string; after?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FacebookAppWebhookSubscriptionsResponse = BaseFacebookResponse<
|
||||||
|
FacebookAppWebhookSubscription[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface FacebookAppWebhookSubscription {
|
||||||
|
object: string;
|
||||||
|
callback_url: string;
|
||||||
|
active: boolean;
|
||||||
|
fields: FacebookAppWebhookSubscriptionField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookAppWebhookSubscriptionField {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFacebookAppWebhookSubscription {
|
||||||
|
object: string;
|
||||||
|
callback_url: string;
|
||||||
|
fields: string[];
|
||||||
|
include_values: boolean;
|
||||||
|
verify_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FacebookPageListResponse = BasePaginatedFacebookResponse<FacebookPage[]>;
|
||||||
|
export type FacebookFormListResponse = BasePaginatedFacebookResponse<FacebookForm[]>;
|
||||||
|
|
||||||
|
export interface FacebookPage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
access_token: string;
|
||||||
|
category: string;
|
||||||
|
category_list: FacebookPageCategory[];
|
||||||
|
tasks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookPageCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookFormQuestion {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookForm {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
locale: string;
|
||||||
|
status: string;
|
||||||
|
page: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
questions: FacebookFormQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookPageEvent {
|
||||||
|
object: 'page';
|
||||||
|
entry: FacebookPageEventEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookPageEventEntry {
|
||||||
|
id: string;
|
||||||
|
time: number;
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
field: 'leadgen';
|
||||||
|
value: {
|
||||||
|
ad_id: string;
|
||||||
|
form_id: string;
|
||||||
|
leadgen_id: string;
|
||||||
|
created_time: number;
|
||||||
|
page_id: string;
|
||||||
|
adgroup_id: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookFormLeadData {
|
||||||
|
id: string;
|
||||||
|
created_time: string;
|
||||||
|
ad_id: string;
|
||||||
|
ad_name: string;
|
||||||
|
adset_id: string;
|
||||||
|
adset_name: string;
|
||||||
|
form_id: string;
|
||||||
|
field_data: [
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
values: GenericValue[];
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
|
@ -106,6 +106,7 @@
|
||||||
"dist/credentials/F5BigIpApi.credentials.js",
|
"dist/credentials/F5BigIpApi.credentials.js",
|
||||||
"dist/credentials/FacebookGraphApi.credentials.js",
|
"dist/credentials/FacebookGraphApi.credentials.js",
|
||||||
"dist/credentials/FacebookGraphAppApi.credentials.js",
|
"dist/credentials/FacebookGraphAppApi.credentials.js",
|
||||||
|
"dist/credentials/FacebookLeadAdsOAuth2Api.credentials.js",
|
||||||
"dist/credentials/FigmaApi.credentials.js",
|
"dist/credentials/FigmaApi.credentials.js",
|
||||||
"dist/credentials/FileMaker.credentials.js",
|
"dist/credentials/FileMaker.credentials.js",
|
||||||
"dist/credentials/FlowApi.credentials.js",
|
"dist/credentials/FlowApi.credentials.js",
|
||||||
|
@ -485,6 +486,7 @@
|
||||||
"dist/nodes/ExecutionData/ExecutionData.node.js",
|
"dist/nodes/ExecutionData/ExecutionData.node.js",
|
||||||
"dist/nodes/Facebook/FacebookGraphApi.node.js",
|
"dist/nodes/Facebook/FacebookGraphApi.node.js",
|
||||||
"dist/nodes/Facebook/FacebookTrigger.node.js",
|
"dist/nodes/Facebook/FacebookTrigger.node.js",
|
||||||
|
"dist/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.js",
|
||||||
"dist/nodes/Figma/FigmaTrigger.node.js",
|
"dist/nodes/Figma/FigmaTrigger.node.js",
|
||||||
"dist/nodes/FileMaker/FileMaker.node.js",
|
"dist/nodes/FileMaker/FileMaker.node.js",
|
||||||
"dist/nodes/Filter/Filter.node.js",
|
"dist/nodes/Filter/Filter.node.js",
|
||||||
|
|
|
@ -2,23 +2,23 @@
|
||||||
|
|
||||||
import type * as express from 'express';
|
import type * as express from 'express';
|
||||||
import type FormData from 'form-data';
|
import type FormData from 'form-data';
|
||||||
|
import type { PathLike } from 'fs';
|
||||||
import type { IncomingHttpHeaders } from 'http';
|
import type { IncomingHttpHeaders } from 'http';
|
||||||
import type { Readable } from 'stream';
|
|
||||||
import type { URLSearchParams } from 'url';
|
|
||||||
import type { OptionsWithUri, OptionsWithUrl } from 'request';
|
import type { OptionsWithUri, OptionsWithUrl } from 'request';
|
||||||
import type { RequestPromiseOptions } from 'request-promise-native';
|
import type { RequestPromiseOptions } from 'request-promise-native';
|
||||||
import type { PathLike } from 'fs';
|
import type { Readable } from 'stream';
|
||||||
|
import type { URLSearchParams } from 'url';
|
||||||
|
|
||||||
|
import type { AuthenticationMethod } from './Authentication';
|
||||||
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES } from './Constants';
|
import type { CODE_EXECUTION_MODES, CODE_LANGUAGES } from './Constants';
|
||||||
import type { IDeferredPromise } from './DeferredPromise';
|
import type { IDeferredPromise } from './DeferredPromise';
|
||||||
|
import type { ExecutionStatus } from './ExecutionStatus';
|
||||||
|
import type { ExpressionError } from './ExpressionError';
|
||||||
|
import type { NodeApiError, NodeOperationError } from './NodeErrors';
|
||||||
import type { Workflow } from './Workflow';
|
import type { Workflow } from './Workflow';
|
||||||
import type { WorkflowHooks } from './WorkflowHooks';
|
|
||||||
import type { WorkflowActivationError } from './WorkflowActivationError';
|
import type { WorkflowActivationError } from './WorkflowActivationError';
|
||||||
import type { WorkflowOperationError } from './WorkflowErrors';
|
import type { WorkflowOperationError } from './WorkflowErrors';
|
||||||
import type { NodeApiError, NodeOperationError } from './NodeErrors';
|
import type { WorkflowHooks } from './WorkflowHooks';
|
||||||
import type { ExpressionError } from './ExpressionError';
|
|
||||||
import type { ExecutionStatus } from './ExecutionStatus';
|
|
||||||
import type { AuthenticationMethod } from './Authentication';
|
|
||||||
|
|
||||||
export interface IAdditionalCredentialOptions {
|
export interface IAdditionalCredentialOptions {
|
||||||
oauth2?: IOAuth2Options;
|
oauth2?: IOAuth2Options;
|
||||||
|
@ -1627,6 +1627,7 @@ export interface IWebhookDescription {
|
||||||
responseMode?: WebhookResponseMode | string;
|
responseMode?: WebhookResponseMode | string;
|
||||||
responseData?: WebhookResponseData | string;
|
responseData?: WebhookResponseData | string;
|
||||||
restartWebhook?: boolean;
|
restartWebhook?: boolean;
|
||||||
|
hasLifecycleMethods?: boolean; // set automatically by generate-ui-types
|
||||||
ndvHideUrl?: boolean; // If true the webhook will not be displayed in the editor
|
ndvHideUrl?: boolean; // If true the webhook will not be displayed in the editor
|
||||||
ndvHideMethod?: boolean; // If true the method will not be displayed in the editor
|
ndvHideMethod?: boolean; // If true the method will not be displayed in the editor
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue