Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(cdp): add linkedin integration #26282

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added frontend/public/services/linkedin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,21 @@
return this.integrations(teamId).addPathComponent(id).addPathComponent('channels')
}

public integrationLinkedInAdsAccounts(id: IntegrationType['id'], teamId?: TeamType['id']): ApiRequest {
return this.integrations(teamId).addPathComponent(id).addPathComponent('linkedin_ads_accessible_accounts')
}

public integrationLinkedInAdsConversionRules(
id: IntegrationType['id'],
accountId: string,
teamId?: TeamType['id']
): ApiRequest {
return this.integrations(teamId)
.addPathComponent(id)
.addPathComponent('linkedin_ads_conversion_rules')
.withQueryString({ accountId })
}

public media(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
}
Expand Down Expand Up @@ -2392,6 +2407,15 @@
async slackChannels(id: IntegrationType['id']): Promise<{ channels: SlackChannelType[] }> {
return await new ApiRequest().integrationSlackChannels(id).get()
},
async linkedInAdsAccounts(id: IntegrationType['id']): Promise<{ accessibleAccounts: { id: string }[] }> {
return await new ApiRequest().integrationLinkedInAdsAccounts(id).get()
},
async linkedInAdsConversionRules(
id: IntegrationType['id'],
accountId: string
): Promise<{ conversionRules: LinkedInAdsConversionRuleType[] }> {

Check failure on line 2416 in frontend/src/lib/api.ts

View workflow job for this annotation

GitHub Actions / Code quality checks

Cannot find name 'LinkedInAdsConversionRuleType'.
return await new ApiRequest().integrationLinkedInAdsConversionRules(id, accountId).get()
},
},

resourcePermissions: {
Expand Down
139 changes: 139 additions & 0 deletions frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { LemonInputSelect, LemonInputSelectOption } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { useMemo } from 'react'

import { IntegrationType, LinkedInAdsConversionRuleType } from '~/types'

Check failure on line 5 in frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx

View workflow job for this annotation

GitHub Actions / Code quality checks

Module '"~/types"' has no exported member 'LinkedInAdsConversionRuleType'.

import { linkedInAdsIntegrationLogic } from './linkedInAdsIntegrationLogic'

const getLinkedInAdsAccountOptions = (
linkedInAdsAccounts?: { id: string }[] | null
): LemonInputSelectOption[] | null => {
return linkedInAdsAccounts
? linkedInAdsAccounts.map((customerId) => ({
key: customerId.id.split('/')[1],
labelComponent: (
<span className="flex items-center">
{customerId.id.split('/')[1].replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3')}
</span>
),
label: `${customerId.id.split('/')[1].replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3')}`,
}))
: null
}

const getLinkedInAdsConversionRuleOptions = (
linkedInAdsConversionRules?: LinkedInAdsConversionRuleType[] | null
): LemonInputSelectOption[] | null => {
return linkedInAdsConversionRules
? linkedInAdsConversionRules.map(({ id, name }) => ({
key: id,
labelComponent: <span className="flex items-center">{name}</span>,
label: name,
}))
: null
}

export type LinkedInAdsPickerProps = {
integration: IntegrationType
value?: string
onChange?: (value: string | null) => void
disabled?: boolean
requiresFieldValue?: string
}

export function LinkedInAdsConversionRulePicker({
onChange,
value,
requiresFieldValue,
integration,
disabled,
}: LinkedInAdsPickerProps): JSX.Element {
const { linkedInAdsConversionRules, linkedInAdsConversionRulesLoading } = useValues(

Check failure on line 52 in frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx

View workflow job for this annotation

GitHub Actions / Code quality checks

Property 'linkedInAdsConversionRules' does not exist on type '{ linkedInAdsConversionActions: LinkedInAdsConversionActionType[] | null; linkedInAdsConversionActionsLoading: boolean; linkedInAdsAccessibleAccounts: { id: string; }[] | null; linkedInAdsAccessibleAccountsLoading: boolean; }'.

Check failure on line 52 in frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx

View workflow job for this annotation

GitHub Actions / Code quality checks

Property 'linkedInAdsConversionRulesLoading' does not exist on type '{ linkedInAdsConversionActions: LinkedInAdsConversionActionType[] | null; linkedInAdsConversionActionsLoading: boolean; linkedInAdsAccessibleAccounts: { id: string; }[] | null; linkedInAdsAccessibleAccountsLoading: boolean; }'.
linkedInAdsIntegrationLogic({ id: integration.id })
)
const { loadLinkedInAdsConversionRules } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))

Check failure on line 55 in frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx

View workflow job for this annotation

GitHub Actions / Code quality checks

Property 'loadLinkedInAdsConversionRules' does not exist on type '{ loadLinkedInAdsConversionActions: (customerId: string) => void; loadLinkedInAdsAccessibleAccounts: () => void; loadLinkedInAdsConversionActionsSuccess: (linkedInAdsConversionActions: LinkedInAdsConversionActionType[] | null, payload?: string | undefined) => void; loadLinkedInAdsConversionActionsFailure: (error: st...'.

const linkedInAdsConversionRuleOptions = useMemo(
() => getLinkedInAdsConversionRuleOptions(linkedInAdsConversionRules),
[linkedInAdsConversionRules]
)

return (
<>
<LemonInputSelect
onChange={(val) => onChange?.(val[0] ?? null)}
value={value ? [value] : []}
onFocus={() =>
!linkedInAdsConversionRules &&
!linkedInAdsConversionRulesLoading &&
requiresFieldValue &&
loadLinkedInAdsConversionRules(requiresFieldValue)
}
disabled={disabled}
mode="single"
data-attr="select-linkedin-ads-conversion-action"
placeholder="Select a Conversion Action..."
options={
linkedInAdsConversionRuleOptions ??
(value
? [
{
key: value,
label: value,
},
]
: [])
}
loading={linkedInAdsConversionRulesLoading}
/>
</>
)
}

export function LinkedInAdsAccountIdPicker({
onChange,
value,
integration,
disabled,
}: LinkedInAdsPickerProps): JSX.Element {
const { linkedInAdsAccessibleAccounts, linkedInAdsAccessibleAccountsLoading } = useValues(
linkedInAdsIntegrationLogic({ id: integration.id })
)
const { loadLinkedInAdsAccessibleAccounts } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))

const linkedInAdsAccountOptions = useMemo(
() => getLinkedInAdsAccountOptions(linkedInAdsAccessibleAccounts),
[linkedInAdsAccessibleAccounts]
)

return (
<>
<LemonInputSelect
onChange={(val) => onChange?.(val[0] ?? null)}
value={value ? [value] : []}
onFocus={() =>
!linkedInAdsAccessibleAccounts &&
!linkedInAdsAccessibleAccountsLoading &&
loadLinkedInAdsAccessibleAccounts()
}
disabled={disabled}
mode="single"
data-attr="select-linkedin-ads-customer-id-channel"
placeholder="Select a Customer ID..."
options={
linkedInAdsAccountOptions ??
(value
? [
{
key: value,
label: value.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3'),
},
]
: [])
}
loading={linkedInAdsAccessibleAccountsLoading}
/>
</>
)
}
2 changes: 2 additions & 0 deletions frontend/src/lib/integrations/integrationsLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import IconGoogleAds from 'public/services/google-ads.png'
import IconGoogleCloud from 'public/services/google-cloud.png'
import IconGoogleCloudStorage from 'public/services/google-cloud-storage.png'
import IconHubspot from 'public/services/hubspot.png'
import IconLinkedIn from 'public/services/linkedin.png'
import IconSalesforce from 'public/services/salesforce.png'
import IconSlack from 'public/services/slack.png'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
Expand All @@ -24,6 +25,7 @@ const ICONS: Record<IntegrationKind, any> = {
'google-pubsub': IconGoogleCloud,
'google-cloud-storage': IconGoogleCloudStorage,
'google-ads': IconGoogleAds,
'linkedin-ads': IconLinkedIn,
}

export const integrationsLogic = kea<integrationsLogicType>([
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { actions, kea, key, path, props } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'

import { LinkedInAdsConversionActionType } from '~/types'

Check failure on line 5 in frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts

View workflow job for this annotation

GitHub Actions / Code quality checks

Module '"~/types"' has no exported member 'LinkedInAdsConversionActionType'.

import type { linkedInAdsIntegrationLogicType } from './linkedInAdsIntegrationLogicType'

export const linkedInAdsIntegrationLogic = kea<linkedInAdsIntegrationLogicType>([
props({} as { id: number }),
key((props) => props.id),
path((key) => ['lib', 'integrations', 'linkedInAdsIntegrationLogic', key]),
actions({
loadLinkedInAdsConversionActions: (customerId: string) => customerId,
loadLinkedInAdsAccessibleAccounts: true,
}),

loaders(({ props }) => ({
linkedInAdsConversionActions: [
null as LinkedInAdsConversionActionType[] | null,
{
loadLinkedInAdsConversionActions: async (customerId: string) => {
const res = await api.integrations.linkedInAdsConversionActions(props.id, customerId)

Check failure on line 23 in frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts

View workflow job for this annotation

GitHub Actions / Code quality checks

Property 'linkedInAdsConversionActions' does not exist on type '{ get(id: number): Promise<IntegrationType>; create(data: FormData | Partial<IntegrationType>): Promise<IntegrationType>; ... 5 more ...; linkedInAdsConversionRules(id: number, accountId: string): Promise<...>; }'. Did you mean 'linkedInAdsConversionRules'?
return res.conversionActions
},
},
],
linkedInAdsAccessibleAccounts: [
null as { id: string }[] | null,
{
loadLinkedInAdsAccessibleAccounts: async () => {
const res = await api.integrations.linkedInAdsAccounts(props.id)
return res.accessibleAccounts
},
},
],
})),
])
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { LemonSkeleton } from '@posthog/lemon-ui'
import { useValues } from 'kea'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import {
LinkedInAdsAccountIdPicker,
LinkedInAdsConversionRulePicker,
} from 'lib/integrations/LinkedInIntegrationHelpers'
import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'

import { HogFunctionInputSchemaType } from '~/types'
Expand Down Expand Up @@ -54,6 +58,25 @@
/>
)
}
if (schema.integration_field === 'linkedin_ads_conversion_rule_id' && requiresFieldValue) {

Check failure on line 61 in frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx

View workflow job for this annotation

GitHub Actions / Code quality checks

Cannot find name 'requiresFieldValue'.
return (
<LinkedInAdsConversionRulePicker
value={value}
requiresFieldValue={requiresFieldValue}

Check failure on line 65 in frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx

View workflow job for this annotation

GitHub Actions / Code quality checks

Cannot find name 'requiresFieldValue'.
onChange={(x) => onChange?.(x?.split('|')[0])}
integration={integration}
/>
)
}
if (schema.integration_field === 'linkedin_ads_account_id') {
return (
<LinkedInAdsAccountIdPicker
value={value}
onChange={(x) => onChange?.(x?.split('|')[0])}
integration={integration}
/>
)
}
return (
<div className="text-danger">
<p>Unsupported integration type: {schema.integration}</p>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3732,6 +3732,7 @@ export type IntegrationKind =
| 'google-pubsub'
| 'google-cloud-storage'
| 'google-ads'
| 'linkedin-ads'

export interface IntegrationType {
id: number
Expand Down
39 changes: 38 additions & 1 deletion posthog/api/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@

from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.models.integration import Integration, OauthIntegration, SlackIntegration, GoogleCloudIntegration
from posthog.models.integration import (
Integration,
OauthIntegration,
SlackIntegration,
GoogleCloudIntegration,
LinkedInAdsIntegration,
)


class IntegrationSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -94,3 +100,34 @@ def channels(self, request: Request, *args: Any, **kwargs: Any) -> Response:
]

return Response({"channels": channels})

@action(methods=["GET"], detail=True, url_path="linkedin_ads_conversion_rules")
def conversion_rules(self, request: Request, *args: Any, **kwargs: Any) -> Response:
instance = self.get_object()
linkedin_ads = LinkedInAdsIntegration(instance)
account_id = request.query_params.get("accountId")

conversion_actions = [
{
"id": conversionAction["conversionAction"]["id"],
"name": conversionAction["conversionAction"]["name"],
"resourceName": conversionAction["conversionAction"]["resourceName"],
}
for conversionAction in linkedin_ads.list_linkedin_ads_conversion_rules(account_id)[0]["results"]
]

return Response({"conversionActions": conversion_actions})

@action(methods=["GET"], detail=True, url_path="linkedin_ads_accessible_accounts")
def accessible_accounts(self, request: Request, *args: Any, **kwargs: Any) -> Response:
instance = self.get_object()
linkedin_ads = LinkedInAdsIntegration(instance)

accessible_accounts = [
{
"id": accountId,
}
for accountId in linkedin_ads.list_linkedin_ads_accessible_accounts()["resourceNames"]
]

return Response({"accessibleAccounts": accessible_accounts})
2 changes: 2 additions & 0 deletions posthog/cdp/templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .attio.template_attio import template as attio
from .mailchimp.template_mailchimp import template as mailchimp
from .microsoft_teams.template_microsoft_teams import template as microsoft_teams
from .linkedin_ads.template_linkedin_ads import template as linkedin_ads
from .klaviyo.template_klaviyo import template_user as klaviyo_user, template_event as klaviyo_event
from .google_cloud_storage.template_google_cloud_storage import (
template as google_cloud_storage,
Expand Down Expand Up @@ -67,6 +68,7 @@
klaviyo_event,
klaviyo_user,
knock,
linkedin_ads,
loops,
mailchimp,
mailgun,
Expand Down
Loading
Loading