diff --git a/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--dark.png b/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--dark.png index b54d4facb0bfe..e3e93b3dd9678 100644 Binary files a/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--dark.png and b/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--dark.png differ diff --git a/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--light.png b/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--light.png index 332ea24ce3084..d4377362d9270 100644 Binary files a/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--light.png and b/frontend/__snapshots__/exporter-exporter--trends-line-insight-detailed--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png index 7df6f432cbc78..74ab9873aa914 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png differ diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index 74d9ac5212cad..09fd77527c43e 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -8,6 +8,7 @@ import { IconHome, IconLive, IconLogomark, + IconMegaphone, IconNotebook, IconPeople, IconPieChart, @@ -526,6 +527,15 @@ export const navigation3000Logic = kea([ to: urls.pipeline(), } : null, + featureFlags[FEATURE_FLAGS.MESSAGING] && hasOnboardedAnyProduct + ? { + identifier: Scene.MessagingBroadcasts, + label: 'Messaging', + icon: , + to: urls.messagingBroadcasts(), + tag: 'alpha' as const, + } + : null, ].filter(isNotNil), ] }, diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index acdbae72b8530..3639d06e4b466 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -220,6 +220,7 @@ export const FEATURE_FLAGS = { LEGACY_ACTION_WEBHOOKS: 'legacy-action-webhooks', // owner: @mariusandra #team-cdp SESSION_REPLAY_URL_TRIGGER: 'session-replay-url-trigger', // owner: @richard-better #team-replay REPLAY_TEMPLATES: 'replay-templates', // owner: @raquelmsmith #team-replay + MESSAGING: 'messaging', // owner @mariusandra #team-cdp } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index 0bfb2ae19e362..7d5f1291a5213 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -77,4 +77,6 @@ export const appScenes: Record any> = { [Scene.Heatmaps]: () => import('./heatmaps/HeatmapsScene'), [Scene.SessionAttributionExplorer]: () => import('scenes/web-analytics/SessionAttributionExplorer/SessionAttributionExplorerScene'), + [Scene.MessagingProviders]: () => import('./messaging/Providers'), + [Scene.MessagingBroadcasts]: () => import('./messaging/Broadcasts'), } diff --git a/frontend/src/scenes/messaging/Broadcasts.tsx b/frontend/src/scenes/messaging/Broadcasts.tsx new file mode 100644 index 0000000000000..9d26e53398d17 --- /dev/null +++ b/frontend/src/scenes/messaging/Broadcasts.tsx @@ -0,0 +1,43 @@ +import { IconPlusSmall } from '@posthog/icons' +import { useValues } from 'kea' +import { PageHeader } from 'lib/components/PageHeader' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { broadcastsLogic } from 'scenes/messaging/broadcastsLogic' +import { FunctionsTable } from 'scenes/messaging/FunctionsTable' +import { MessagingTabs } from 'scenes/messaging/MessagingTabs' +import { HogFunctionConfiguration } from 'scenes/pipeline/hogfunctions/HogFunctionConfiguration' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +export function Broadcasts(): JSX.Element { + const { broadcastId } = useValues(broadcastsLogic) + return broadcastId ? ( + + ) : ( + <> + + } + > + New broadcast + + } + /> + + + ) +} + +export const scene: SceneExport = { + component: Broadcasts, + logic: broadcastsLogic, +} diff --git a/frontend/src/scenes/messaging/FunctionsTable.tsx b/frontend/src/scenes/messaging/FunctionsTable.tsx new file mode 100644 index 0000000000000..329106a8ceaca --- /dev/null +++ b/frontend/src/scenes/messaging/FunctionsTable.tsx @@ -0,0 +1,125 @@ +import { LemonInput, LemonTable, LemonTableColumn, Link, Tooltip } from '@posthog/lemon-ui' +import { BindLogic, useActions, useValues } from 'kea' +import { More } from 'lib/lemon-ui/LemonButton/More' +import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { functionsTableLogic } from 'scenes/messaging/functionsTableLogic' +import { hogFunctionUrl } from 'scenes/pipeline/hogfunctions/urls' + +import { HogFunctionType, HogFunctionTypeType } from '~/types' + +import { HogFunctionIcon } from '../pipeline/hogfunctions/HogFunctionIcon' +import { HogFunctionStatusIndicator } from '../pipeline/hogfunctions/HogFunctionStatusIndicator' + +export interface FunctionsTableProps { + type?: HogFunctionTypeType +} + +export function FunctionsTableFilters(): JSX.Element | null { + const { filters } = useValues(functionsTableLogic) + const { setFilters } = useActions(functionsTableLogic) + + return ( +
+
+ setFilters({ search: e })} + /> +
+
+ ) +} + +export function FunctionsTable({ type }: FunctionsTableProps): JSX.Element { + const { hogFunctions, filteredHogFunctions, loading } = useValues(functionsTableLogic({ type })) + const { deleteHogFunction, resetFilters } = useActions(functionsTableLogic({ type })) + + return ( + +
+ + + + }, + }, + { + title: 'Name', + sticky: true, + sorter: true, + key: 'name', + dataIndex: 'name', + render: function RenderPluginName(_, hogFunction) { + return ( + + + {hogFunction.name} + + + } + description={hogFunction.description} + /> + ) + }, + }, + + updatedAtColumn() as LemonTableColumn, + { + title: 'Status', + key: 'enabled', + sorter: (a) => (a.enabled ? 1 : -1), + width: 0, + render: function RenderStatus(_, hogFunction) { + return + }, + }, + { + width: 0, + render: function Render(_, hogFunction) { + return ( + deleteHogFunction(hogFunction), + }, + ]} + /> + } + /> + ) + }, + }, + ]} + emptyState={ + hogFunctions.length === 0 && !loading ? ( + 'Nothing found' + ) : ( + <> + Nothing matches filters. resetFilters()}>Clear filters{' '} + + ) + } + /> +
+
+ ) +} diff --git a/frontend/src/scenes/messaging/MessagingTabs.tsx b/frontend/src/scenes/messaging/MessagingTabs.tsx new file mode 100644 index 0000000000000..dac7146eab023 --- /dev/null +++ b/frontend/src/scenes/messaging/MessagingTabs.tsx @@ -0,0 +1,25 @@ +import { useActions, useValues } from 'kea' +import { LemonTabs } from 'lib/lemon-ui/LemonTabs' +import { SceneExport } from 'scenes/sceneTypes' + +import { MessagingTab, messagingTabsLogic } from './messagingTabsLogic' + +export function MessagingTabs(): JSX.Element { + const { currentTab } = useValues(messagingTabsLogic) + const { setTab } = useActions(messagingTabsLogic) + return ( + setTab(tab as MessagingTab)} + tabs={[ + { key: 'broadcasts', label: 'Broadcasts' }, + { key: 'providers', label: 'Providers' }, + ]} + /> + ) +} + +export const scene: SceneExport = { + component: MessagingTabs, + logic: messagingTabsLogic, +} diff --git a/frontend/src/scenes/messaging/Providers.tsx b/frontend/src/scenes/messaging/Providers.tsx new file mode 100644 index 0000000000000..bab003d9cf71b --- /dev/null +++ b/frontend/src/scenes/messaging/Providers.tsx @@ -0,0 +1,32 @@ +import { useValues } from 'kea' +import { PageHeader } from 'lib/components/PageHeader' +import { FunctionsTable } from 'scenes/messaging/FunctionsTable' +import { MessagingTabs } from 'scenes/messaging/MessagingTabs' +import { providersLogic } from 'scenes/messaging/providersLogic' +import { HogFunctionConfiguration } from 'scenes/pipeline/hogfunctions/HogFunctionConfiguration' +import { HogFunctionTemplateList } from 'scenes/pipeline/hogfunctions/list/HogFunctionTemplateList' +import { SceneExport } from 'scenes/sceneTypes' + +export function Providers(): JSX.Element { + const { providerId, templateId } = useValues(providersLogic) + return providerId ? ( + + ) : ( + <> + + + +
+

Add Provider

+ +
+ Note: to add a provider that's not in the list, select one that's similar and edit its source to point + to the right API URLs +
+ + ) +} +export const scene: SceneExport = { + component: Providers, + logic: providersLogic, +} diff --git a/frontend/src/scenes/messaging/broadcastsLogic.tsx b/frontend/src/scenes/messaging/broadcastsLogic.tsx new file mode 100644 index 0000000000000..635e105a064a6 --- /dev/null +++ b/frontend/src/scenes/messaging/broadcastsLogic.tsx @@ -0,0 +1,65 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import { urlToAction } from 'kea-router' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { Breadcrumb } from '~/types' + +import type { broadcastsLogicType } from './broadcastsLogicType' + +export const broadcastsLogic = kea([ + path(['scenes', 'messaging', 'broadcastsLogic']), + actions({ + editBroadcast: (id: string | null) => ({ id }), + }), + reducers({ + broadcastId: [null as string | null, { editBroadcast: (_, { id }) => id }], + }), + selectors({ + breadcrumbs: [ + (s) => [s.broadcastId], + (broadcastId): Breadcrumb[] => { + return [ + { + key: Scene.MessagingBroadcasts, + name: 'Messaging', + path: urls.messagingBroadcasts(), + }, + { + key: 'broadcasts', + name: 'Broadcasts', + path: urls.messagingBroadcasts(), + }, + ...(broadcastId === 'new' + ? [ + { + key: 'new-broadcast', + name: 'New broadcast', + path: urls.messagingBroadcastNew(), + }, + ] + : broadcastId + ? [ + { + key: 'edit-broadcast', + name: 'Edit broadcast', + path: urls.messagingBroadcast(broadcastId), + }, + ] + : []), + ] + }, + ], + }), + urlToAction(({ actions }) => ({ + '/messaging/broadcasts/new': () => { + actions.editBroadcast('new') + }, + '/messaging/broadcasts/:id': ({ id }) => { + actions.editBroadcast(id ?? null) + }, + '/messaging/broadcasts': () => { + actions.editBroadcast(null) + }, + })), +]) diff --git a/frontend/src/scenes/messaging/functionsTableLogic.tsx b/frontend/src/scenes/messaging/functionsTableLogic.tsx new file mode 100644 index 0000000000000..054b8144d73e8 --- /dev/null +++ b/frontend/src/scenes/messaging/functionsTableLogic.tsx @@ -0,0 +1,94 @@ +import FuseClass from 'fuse.js' +import { actions, afterMount, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { deleteWithUndo } from 'lib/utils/deleteWithUndo' +import { teamLogic } from 'scenes/teamLogic' + +import { HogFunctionType, HogFunctionTypeType } from '~/types' + +import type { functionsTableLogicType } from './functionsTableLogicType' + +// Helping kea-typegen navigate the exported default class for Fuse +export interface Fuse extends FuseClass {} + +export interface FunctionsTableLogicProps { + type?: HogFunctionTypeType +} +export interface HogFunctionsFilter { + search?: string +} +export const functionsTableLogic = kea([ + path(['scenes', 'messaging', 'functionsTableLogic']), + props({} as FunctionsTableLogicProps), + key((props: FunctionsTableLogicProps) => props.type ?? 'destination'), + connect({ + values: [teamLogic, ['currentTeamId']], + }), + actions({ + deleteHogFunction: (hogFunction: HogFunctionType) => ({ hogFunction }), + setFilters: (filters: Partial) => ({ filters }), + resetFilters: true, + }), + reducers({ + filters: [ + {} as HogFunctionsFilter, + { + setFilters: (state, { filters }) => ({ + ...state, + ...filters, + }), + resetFilters: () => ({}), + }, + ], + }), + loaders(({ props, values, actions }) => ({ + hogFunctions: [ + [] as HogFunctionType[], + { + loadHogFunctions: async () => { + // TODO: pagination? + return (await api.hogFunctions.list({ type: props.type ?? 'destination' })).results + }, + deleteHogFunction: async ({ hogFunction }) => { + await deleteWithUndo({ + endpoint: `projects/${teamLogic.values.currentTeamId}/hog_functions`, + object: { + id: hogFunction.id, + name: hogFunction.name, + }, + callback: (undo) => { + if (undo) { + actions.loadHogFunctions() + } + }, + }) + return values.hogFunctions.filter((hf) => hf.id !== hogFunction.id) + }, + }, + ], + })), + selectors({ + loading: [(s) => [s.hogFunctionsLoading], (hogFunctionsLoading) => hogFunctionsLoading], + hogFunctionsFuse: [ + (s) => [s.hogFunctions], + (hogFunctions): Fuse => { + return new FuseClass(hogFunctions || [], { + keys: ['name', 'description'], + threshold: 0.3, + }) + }, + ], + + filteredHogFunctions: [ + (s) => [s.filters, s.hogFunctions, s.hogFunctionsFuse], + (filters, hogFunctions, hogFunctionsFuse): HogFunctionType[] => { + const { search } = filters + return search ? hogFunctionsFuse.search(search).map((x) => x.item) : hogFunctions + }, + ], + }), + afterMount(({ actions }) => { + actions.loadHogFunctions() + }), +]) diff --git a/frontend/src/scenes/messaging/messagingTabsLogic.ts b/frontend/src/scenes/messaging/messagingTabsLogic.ts new file mode 100644 index 0000000000000..577a7f6d1ac5e --- /dev/null +++ b/frontend/src/scenes/messaging/messagingTabsLogic.ts @@ -0,0 +1,44 @@ +import { actions, kea, path, reducers } from 'kea' +import { actionToUrl, urlToAction } from 'kea-router' +import { urls } from 'scenes/urls' + +import type { messagingTabsLogicType } from './messagingTabsLogicType' + +export type MessagingTab = 'broadcasts' | 'providers' + +export const messagingTabsLogic = kea([ + path(['scenes', 'messaging', 'messagingLogic']), + actions({ + setTab: (tab: MessagingTab, fromUrl = false) => ({ tab, fromUrl }), + }), + reducers({ + currentTab: ['broadcasts' as MessagingTab, { setTab: (_, { tab }) => tab }], + }), + + actionToUrl(({ values }) => ({ + setTab: ({ fromUrl }) => { + // do not override deeper urls like /messaging/broadcasts/new + if (!fromUrl) { + return ( + { + broadcasts: urls.messagingBroadcasts(), + providers: urls.messagingProviders(), + }[values.currentTab] ?? urls.messagingBroadcasts() + ) + } + }, + })), + + urlToAction(({ actions, values }) => ({ + '/messaging/:tab': ({ tab }) => { + if (tab !== values.currentTab) { + actions.setTab(tab as MessagingTab, true) + } + }, + '/messaging/:tab/*': ({ tab }) => { + if (tab !== values.currentTab) { + actions.setTab(tab as MessagingTab, true) + } + }, + })), +]) diff --git a/frontend/src/scenes/messaging/providersLogic.ts b/frontend/src/scenes/messaging/providersLogic.ts new file mode 100644 index 0000000000000..12ede195abb60 --- /dev/null +++ b/frontend/src/scenes/messaging/providersLogic.ts @@ -0,0 +1,69 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import { urlToAction } from 'kea-router' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { Breadcrumb } from '~/types' + +import type { providersLogicType } from './providersLogicType' + +export const providersLogic = kea([ + path(['scenes', 'messaging', 'providersLogic']), + actions({ + editProvider: (id: string | null, template: string | null) => ({ id, template }), + }), + reducers({ + providerId: [null as string | null, { editProvider: (_, { id }) => id }], + templateId: [null as string | null, { editProvider: (_, { template }) => template }], + }), + selectors({ + breadcrumbs: [ + (s) => [s.providerId, s.templateId], + (providerId, templateId): Breadcrumb[] => { + return [ + { + key: Scene.MessagingBroadcasts, + name: 'Messaging', + path: urls.messagingBroadcasts(), + }, + { + key: 'providers', + name: 'Providers', + path: urls.messagingProviders(), + }, + ...(providerId === 'new' || templateId + ? [ + { + key: 'new-provider', + name: 'New provider', + path: urls.messagingProviderNew(), + }, + ] + : providerId + ? [ + { + key: 'edit-provider', + name: 'Edit provider', + path: urls.messagingProvider(providerId), + }, + ] + : []), + ] + }, + ], + }), + urlToAction(({ actions }) => ({ + '/messaging/providers/new': () => { + actions.editProvider('new', null) + }, + '/messaging/providers/new/:template': ({ template }) => { + actions.editProvider('new', template ?? null) + }, + '/messaging/providers/:id': ({ id }) => { + actions.editProvider(id ?? null, null) + }, + '/messaging/providers': () => { + actions.editProvider(null, null) + }, + })), +]) diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx index d0e60b03d8944..a27181b3e77c7 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx @@ -16,6 +16,7 @@ import { import clsx from 'clsx' import { BindLogic, useActions, useValues } from 'kea' import { Form } from 'kea-forms' +import { combineUrl } from 'kea-router' import { NotFound } from 'lib/components/NotFound' import { PageHeader } from 'lib/components/PageHeader' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' @@ -23,6 +24,7 @@ import { Sparkline } from 'lib/components/Sparkline' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonField } from 'lib/lemon-ui/LemonField' import { CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable' +import { urls } from 'scenes/urls' import { AvailableFeature } from '~/types' @@ -59,10 +61,14 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur hasAddon, sparkline, sparklineLoading, + personsCount, + personsCountLoading, + personsListQuery, template, subTemplate, templateHasChanged, forcedSubTemplateId, + type, } = useValues(logic) const { submitConfiguration, @@ -145,6 +151,12 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur return } + const showFilters = type === 'destination' || type === 'broadcast' + const showExpectedVolume = type === 'destination' + const showEnabled = type === 'destination' || type === 'email' + const canEditSource = type === 'destination' || type === 'email' + const showPersonsCount = type === 'broadcast' + return (
@@ -157,10 +169,13 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur } /> - - Hog Functions are in beta and are the next generation of our data pipeline destinations. You - can use pre-existing templates or modify the source Hog code to create your own custom functions. - + {type === 'destination' ? ( + + Hog Functions are in beta and are the next generation of our data pipeline destinations. + You can use pre-existing templates or modify the source Hog code to create your own custom + functions. + + ) : null} {hogFunction?.filters?.bytecode_error ? (
@@ -197,17 +212,20 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur - - {({ value, onChange }) => ( - onChange(!value)} - checked={value} - disabled={loading} - bordered - /> - )} - + {showEnabled && } + {showEnabled && ( + + {({ value, onChange }) => ( + onChange(!value)} + checked={value} + disabled={loading} + bordered + /> + )} + + )}
@@ -266,45 +284,77 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur ) : null}
- + {showFilters && } -
- Expected volume - {sparkline && !sparklineLoading ? ( - <> - {sparkline.count > EVENT_THRESHOLD_ALERT_LEVEL ? ( - - Warning: This destination would have triggered{' '} - - {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} - {' '} - in the last 7 days. Consider the impact of this function on your - destination. - - ) : ( -

- This destination would have triggered{' '} - - {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} - {' '} - in the last 7 days. -

- )} - - - ) : sparklineLoading ? ( -
- + {showPersonsCount && ( +
+
+ Matching persons
- ) : ( -

The expected volume could not be calculated

- )} -
+ {personsCount && !personsCountLoading ? ( + <> + Found{' '} + + + {personsCount ?? 0} {personsCount !== 1 ? 'people' : 'person'} + + {' '} + to send to. + + ) : personsCountLoading ? ( +
+ +
+ ) : ( +

The expected volume could not be calculated

+ )} +
+ )} + + {showExpectedVolume && ( +
+ Expected volume + {sparkline && !sparklineLoading ? ( + <> + {sparkline.count > EVENT_THRESHOLD_ALERT_LEVEL ? ( + + Warning: This destination would have triggered{' '} + + {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} + {' '} + in the last 7 days. Consider the impact of this function on your + destination. + + ) : ( +

+ This destination would have triggered{' '} + + {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} + {' '} + in the last 7 days. +

+ )} + + + ) : sparklineLoading ? ( +
+ +
+ ) : ( +

The expected volume could not be calculated

+ )} +
+ )}
@@ -346,7 +396,7 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
- {showSource ? ( + {showSource && canEditSource ? ( } size="small" @@ -370,76 +420,79 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
-
-
-
-

Edit source

- {!showSource ?

Click here to edit the function's source code

: null} -
- - {!showSource ? ( - setShowSource(true)} - disabledReason={ - !hasAddon - ? 'Editing the source code requires the Data Pipelines addon' - : undefined - } - > - Edit source code - - ) : ( - setShowSource(false)} - > - Hide source code - + {canEditSource && ( +
+ > +
+
+

Edit source

+ {!showSource ?

Click here to edit the function's source code

: null} +
- {showSource ? ( - - {({ value, onChange }) => ( - <> - - This is the underlying Hog code that will run whenever the filters - match. See the docs{' '} - for more info - - onChange(v ?? '')} - globals={globalsWithInputs} - options={{ - minimap: { - enabled: false, - }, - wordWrap: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - fixedOverflowWidgets: true, - suggest: { - showInlineDetails: true, - }, - quickSuggestionsDelay: 300, - }} - /> - + {!showSource ? ( + setShowSource(true)} + disabledReason={ + !hasAddon + ? 'Editing the source code requires the Data Pipelines addon' + : undefined + } + > + Edit source code + + ) : ( + setShowSource(false)} + > + Hide source code + )} - - ) : null} -
+
+ + {showSource ? ( + + {({ value, onChange }) => ( + <> + + This is the underlying Hog code that will run whenever the + filters match.{' '} + See the docs for + more info + + onChange(v ?? '')} + globals={globalsWithInputs} + options={{ + minimap: { + enabled: false, + }, + wordWrap: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + fixedOverflowWidgets: true, + suggest: { + showInlineDetails: true, + }, + quickSuggestionsDelay: 300, + }} + /> + + )} + + ) : null} +
+ )} - {id ? : } + {!id || id === 'new' ? : }
{saveButtons}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx index 9ee3c844ab66e..1a08481511f47 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx @@ -46,19 +46,24 @@ const HogFunctionTestEditor = ({ ) } -export function HogFunctionTestPlaceholder(): JSX.Element { +export function HogFunctionTestPlaceholder({ + title, + description, +}: { + title?: string | JSX.Element + description?: string | JSX.Element +}): JSX.Element { return (
-

Testing

-

Save your configuration to enable testing

+

{title || 'Testing'}

+

{description || 'Save your configuration to enable testing'}

) } export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element { - const { isTestInvocationSubmitting, testResult, expanded, sampleGlobalsLoading, sampleGlobalsError } = useValues( - hogFunctionTestLogic(props) - ) + const { isTestInvocationSubmitting, testResult, expanded, sampleGlobalsLoading, sampleGlobalsError, type } = + useValues(hogFunctionTestLogic(props)) const { submitTestInvocation, setTestResult, toggleExpanded, loadSampleGlobals } = useActions( hogFunctionTestLogic(props) ) @@ -71,7 +76,14 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {

Testing

- {!expanded &&

Click here to test your function with an example event

} + {!expanded && + (type === 'email' ? ( +

Click here to test the provider with a sample e-mail

+ ) : type === 'broadcast' ? ( +

Click here to test your broadcast

+ ) : ( +

Click here to test your function with an example event

+ ))}
{!expanded ? ( @@ -90,14 +102,16 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element { ) : ( <> - - Refresh globals - + {type === 'destination' ? ( + + Refresh globals + + ) : null} {({ value, onChange }) => ( ( <>
-
Here are all the global variables you can use in your code:
+
+ {type === 'broadcast' + ? 'The test broadcast will be sent with this sample data:' + : type === 'email' + ? 'The provider will be tested with this sample data:' + : 'Here are all the global variables you can use in your code:'} +
{sampleGlobalsError ? (
{sampleGlobalsError}
) : null} diff --git a/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx b/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx index 872e1beebcaf5..c25ea2ce9ce65 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx @@ -8,19 +8,12 @@ import { import { LemonDropdown } from 'lib/lemon-ui/LemonDropdown' import { Link } from 'lib/lemon-ui/Link' import { initHogLanguage } from 'lib/monaco/languages/hog' -import { urls } from 'scenes/urls' -import { PipelineNodeTab, PipelineStage } from '~/types' +import { hogFunctionUrl } from './urls' const nameOrLinkToHogFunction = (id?: string | null, name?: string | null): string | JSX.Element => { const displayName = name || '(empty string)' - return id ? ( - - {displayName} - - ) : ( - displayName - ) + return id ? {displayName} : displayName } export interface DiffProps { diff --git a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx index c9d0e7a9023b8..50bf6bad4f4df 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx @@ -9,6 +9,7 @@ import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' import { groupsModel } from '~/models/groupsModel' +import { NodeKind } from '~/queries/schema' import { AnyPropertyFilter, EntityTypes, FilterType, HogFunctionFiltersType } from '~/types' import { hogFunctionConfigurationLogic } from '../hogFunctionConfigurationLogic' @@ -44,7 +45,34 @@ function sanitizeActionFilters(filters?: FilterType): Partial + + {({ value, onChange }) => ( + { + onChange({ + ...value, + properties, + }) + }} + pageKey={`HogFunctionPropertyFilters.${id}`} + metadataSource={{ kind: NodeKind.ActorsQuery }} + /> + )} + +
+ ) + } return (
diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx index 08511bfbdc8f9..93d6359d349ca 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx @@ -11,15 +11,16 @@ import { uuid } from 'lib/utils' import { deleteWithUndo } from 'lib/utils/deleteWithUndo' import posthog from 'posthog-js' import { asDisplay } from 'scenes/persons/person-utils' +import { hogFunctionNewUrl, hogFunctionUrl } from 'scenes/pipeline/hogfunctions/urls' import { teamLogic } from 'scenes/teamLogic' -import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' import { groupsModel } from '~/models/groupsModel' import { performQuery } from '~/queries/query' -import { EventsNode, EventsQuery, NodeKind, TrendsQuery } from '~/queries/schema' +import { ActorsQuery, DataTableNode, EventsNode, EventsQuery, NodeKind, TrendsQuery } from '~/queries/schema' import { escapePropertyAsHogQlIdentifier, hogql } from '~/queries/utils' import { + AnyPersonScopeFilter, AnyPropertyFilter, AvailableFeature, BaseMathType, @@ -34,9 +35,6 @@ import { HogFunctionTemplateType, HogFunctionType, PersonType, - PipelineNodeTab, - PipelineStage, - PipelineTab, PropertyFilterType, PropertyGroupFilter, PropertyGroupFilterValue, @@ -127,7 +125,7 @@ const templateToConfiguration = ( hog: template.hog, icon_url: template.icon_url, inputs, - enabled: true, + enabled: template.type !== 'broadcast', } } @@ -183,6 +181,8 @@ export const hogFunctionConfigurationLogic = kea ({ sparklineQuery } as { sparklineQuery: TrendsQuery }), + personsCountQueryChanged: (personsCountQuery: ActorsQuery) => + ({ personsCountQuery } as { personsCountQuery: ActorsQuery }), setSubTemplateId: (subTemplateId: HogFunctionSubTemplateIdType | null) => ({ subTemplateId }), loadSampleGlobals: true, setUnsavedConfiguration: (configuration: HogFunctionConfigurationType | null) => ({ configuration }), @@ -256,7 +256,7 @@ export const hogFunctionConfigurationLogic = kea { - if (!props.id) { + if (!props.id || props.id === 'new') { return null } @@ -264,9 +264,10 @@ export const hogFunctionConfigurationLogic = kea { - const res = props.id - ? await api.hogFunctions.update(props.id, configuration) - : await api.hogFunctions.create(configuration) + const res = + props.id && props.id !== 'new' + ? await api.hogFunctions.update(props.id, configuration) + : await api.hogFunctions.create(configuration) posthog.capture('hog function saved', { id: res.id, @@ -289,6 +290,9 @@ export const hogFunctionConfigurationLogic = kea { + if (values.type !== 'destination') { + return null + } if (values.sparkline === null) { await breakpoint(100) } else { @@ -327,16 +331,38 @@ export const hogFunctionConfigurationLogic = kea { + if (values.type !== 'broadcast') { + return null + } + if (values.personsCount === null) { + await breakpoint(100) + } else { + await breakpoint(1000) + } + const result = await performQuery(personsCountQuery) + breakpoint() + return result?.results?.[0]?.[0] ?? null + }, + }, + ], + sampleGlobals: [ null as HogFunctionInvocationGlobals | null, { loadSampleGlobals: async (_, breakpoint) => { + if (!values.lastEventQuery || values.type !== 'destination') { + return values.sampleGlobals + } const errorMessage = 'No events match these filters in the last 30 days. Showing an example $pageview event instead.' try { await breakpoint(values.sampleGlobals === null ? 10 : 1000) let response = await performQuery(values.lastEventQuery) - if (!response?.results?.[0]) { + if (!response?.results?.[0] && values.lastEventSecondQuery) { response = await performQuery(values.lastEventSecondQuery) } if (!response?.results?.[0]) { @@ -653,9 +679,46 @@ export const hogFunctionConfigurationLogic = kea [s.configuration, s.type], + (configuration, type): ActorsQuery | null => { + if (type !== 'broadcast') { + return null + } + return { + kind: NodeKind.ActorsQuery, + properties: configuration.filters?.properties as AnyPersonScopeFilter[] | undefined, + select: ['count()'], + } + }, + { resultEqualityCheck: equal }, + ], + + personsListQuery: [ + (s) => [s.configuration, s.type], + (configuration, type): DataTableNode | null => { + if (type !== 'broadcast') { + return null + } + return { + kind: NodeKind.DataTableNode, + source: { + kind: NodeKind.ActorsQuery, + properties: configuration.filters?.properties as AnyPersonScopeFilter[] | undefined, + select: ['person', 'properties.email', 'created_at'], + }, + full: true, + } + }, + { resultEqualityCheck: equal }, + ], + lastEventQuery: [ - (s) => [s.configuration, s.matchingFilters, s.groupTypes], - (configuration, matchingFilters, groupTypes): EventsQuery => { + (s) => [s.configuration, s.matchingFilters, s.groupTypes, s.type], + (configuration, matchingFilters, groupTypes, type): EventsQuery | null => { + if (type !== 'destination') { + return null + } const query: EventsQuery = { kind: NodeKind.EventsQuery, filterTestAccounts: configuration.filters?.filter_test_accounts, @@ -677,7 +740,7 @@ export const hogFunctionConfigurationLogic = kea [s.lastEventQuery], - (lastEventQuery): EventsQuery => ({ ...lastEventQuery, after: '-30d' }), + (lastEventQuery): EventsQuery | null => (lastEventQuery ? { ...lastEventQuery, after: '-30d' } : null), ], templateHasChanged: [ (s) => [s.hogFunction, s.configuration], @@ -774,13 +837,9 @@ export const hogFunctionConfigurationLogic = kea { @@ -788,13 +847,9 @@ export const hogFunctionConfigurationLogic = kea { @@ -831,7 +886,7 @@ export const hogFunctionConfigurationLogic = kea { @@ -870,7 +923,7 @@ export const hogFunctionConfigurationLogic = kea { - actions.sparklineQueryChanged(sparklineQuery) + if (sparklineQuery) { + actions.sparklineQueryChanged(sparklineQuery) + } }, - - lastEventQuery: () => { - actions.loadSampleGlobals() + personsCountQuery: async (personsCountQuery) => { + if (personsCountQuery) { + actions.personsCountQueryChanged(personsCountQuery) + } + }, + lastEventQuery: (lastEventQuery) => { + if (lastEventQuery) { + actions.loadSampleGlobals() + } }, })), diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx index 45e0d9a523c3c..c309442268f58 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx @@ -31,7 +31,15 @@ export const hogFunctionTestLogic = kea([ connect((props: HogFunctionTestLogicProps) => ({ values: [ hogFunctionConfigurationLogic({ id: props.id }), - ['configuration', 'configurationHasErrors', 'sampleGlobals', 'sampleGlobalsLoading', 'sampleGlobalsError'], + [ + 'configuration', + 'configurationHasErrors', + 'sampleGlobals', + 'sampleGlobalsLoading', + 'exampleInvocationGlobals', + 'sampleGlobalsError', + 'type', + ], groupsModel, ['groupTypes'], ], @@ -61,7 +69,9 @@ export const hogFunctionTestLogic = kea([ }), listeners(({ values, actions }) => ({ loadSampleGlobalsSuccess: () => { - actions.setTestInvocationValue('globals', JSON.stringify(values.sampleGlobals, null, 2)) + if (values.type === 'destination') { + actions.setTestInvocationValue('globals', JSON.stringify(values.sampleGlobals, null, 2)) + } }, })), forms(({ props, actions, values }) => ({ @@ -103,7 +113,25 @@ export const hogFunctionTestLogic = kea([ }, })), - afterMount(({ actions }) => { - actions.setTestInvocationValue('globals', '{/* Please wait, fetching a real event. */}') + afterMount(({ actions, values }) => { + if (values.type === 'email') { + const email = { + from: 'me@example.com', + to: 'you@example.com', + subject: 'Hello', + html: 'hello world', + } + actions.setTestInvocationValue( + 'globals', + JSON.stringify({ email, person: values.exampleInvocationGlobals.person }, null, 2) + ) + } else if (values.type === 'broadcast') { + actions.setTestInvocationValue( + 'globals', + JSON.stringify({ person: values.exampleInvocationGlobals.person }, null, 2) + ) + } else { + actions.setTestInvocationValue('globals', '{/* Please wait, fetching a real event. */}') + } }), ]) diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx index 25a59059e6586..a7598463d54fa 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx @@ -5,6 +5,7 @@ import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu' import { updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' import { useEffect } from 'react' +import { hogFunctionUrl } from 'scenes/pipeline/hogfunctions/urls' import { AppMetricSparkLineV2 } from 'scenes/pipeline/metrics/AppMetricsV2Sparkline' import { urls } from 'scenes/urls' @@ -72,11 +73,7 @@ export function HogFunctionList({ render: (_, hogFunction) => { return ( @@ -96,6 +93,7 @@ export function HogFunctionList({ return ( ( stage: stage as PipelineStage.Destination, backend: PipelineBackend.HogFunction, interval: 'realtime', - id: `hog-${candidate.id}`, + id: candidate.type === 'destination' ? `hog-${candidate.id}` : candidate.id, name: candidate.name, description: candidate.description, enabled: candidate.enabled, diff --git a/frontend/src/scenes/pipeline/utils.tsx b/frontend/src/scenes/pipeline/utils.tsx index 1b3171ec9a087..530c09b9b2df5 100644 --- a/frontend/src/scenes/pipeline/utils.tsx +++ b/frontend/src/scenes/pipeline/utils.tsx @@ -25,6 +25,7 @@ import { PluginType, } from '~/types' +import { hogFunctionUrl } from './hogfunctions/urls' import { pipelineAccessLogic } from './pipelineAccessLogic' import { PluginImage, PluginImageSize } from './PipelinePluginImage' import { @@ -268,14 +269,19 @@ export function pipelineNodeMenuCommonItems(node: Transformation | SiteApp | Imp const items: LemonMenuItem[] = [ { label: canConfigurePlugins ? 'Edit configuration' : 'View configuration', - to: urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Configuration), + to: + 'hog_function' in node && node.hog_function + ? hogFunctionUrl(node.hog_function.type, node.hog_function.id) + : urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Configuration), }, { label: 'View metrics', + // TODO: metrics URL to: urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Metrics), }, { label: 'View logs', + // TODO: logs URL to: urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Logs), }, ] diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 4f94e984542d5..7ecee067a5439 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -81,6 +81,8 @@ export enum Scene { MoveToPostHogCloud = 'MoveToPostHogCloud', Heatmaps = 'Heatmaps', SessionAttributionExplorer = 'SessionAttributionExplorer', + MessagingProviders = 'MessagingProviders', + MessagingBroadcasts = 'MessagingBroadcasts', } export type SceneProps = Record diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index c90772eef7fa4..b230d3244117e 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -402,6 +402,14 @@ export const sceneConfigurations: Record = { projectBased: true, name: 'Session attribution explorer (beta)', }, + [Scene.MessagingBroadcasts]: { + projectBased: true, + name: 'Broadcasts', + }, + [Scene.MessagingProviders]: { + projectBased: true, + name: 'Providers', + }, } // NOTE: These redirects will fully replace the URL. If you want to keep support for query and hash params then you should use a function (not string) redirect @@ -469,6 +477,7 @@ export const redirects: Record< '/batch_exports': urls.pipeline(PipelineTab.Destinations), '/apps': urls.pipeline(PipelineTab.Overview), '/apps/:id': ({ id }) => urls.pipelineNode(PipelineStage.Transformation, id), + '/messaging': urls.messagingBroadcasts(), } export const routes: Record = { @@ -584,4 +593,11 @@ export const routes: Record = { [urls.moveToPostHogCloud()]: Scene.MoveToPostHogCloud, [urls.heatmaps()]: Scene.Heatmaps, [urls.sessionAttributionExplorer()]: Scene.SessionAttributionExplorer, + [urls.messagingProviders()]: Scene.MessagingProviders, + [urls.messagingProvider(':id')]: Scene.MessagingProviders, + [urls.messagingProviderNew()]: Scene.MessagingProviders, + [urls.messagingProviderNew(':template')]: Scene.MessagingProviders, + [urls.messagingBroadcasts()]: Scene.MessagingBroadcasts, + [urls.messagingBroadcast(':id')]: Scene.MessagingBroadcasts, + [urls.messagingBroadcastNew()]: Scene.MessagingBroadcasts, } diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index a6fe7fcbaca9b..e3c85d33260c7 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -143,6 +143,12 @@ export const urls = { `/pipeline/${!stage.startsWith(':') && !stage?.endsWith('s') ? `${stage}s` : stage}/${id}${ nodeTab ? `/${nodeTab}` : '' }`, + messagingBroadcasts: (): string => '/messaging/broadcasts', + messagingBroadcast: (id?: string): string => `/messaging/broadcasts/${id}`, + messagingBroadcastNew: (): string => '/messaging/broadcasts/new', + messagingProviders: (): string => '/messaging/providers', + messagingProvider: (id?: string): string => `/messaging/providers/${id}`, + messagingProviderNew: (template?: string): string => '/messaging/providers/new' + (template ? `/${template}` : ''), groups: (groupTypeIndex: string | number): string => `/groups/${groupTypeIndex}`, // :TRICKY: Note that groupKey is provided by user. We need to override urlPatternOptions for kea-router. group: (groupTypeIndex: string | number, groupKey: string, encode: boolean = true, tab?: string | null): string => diff --git a/posthog/api/hog_function_template.py b/posthog/api/hog_function_template.py index befb986262ee2..2f68614e50a02 100644 --- a/posthog/api/hog_function_template.py +++ b/posthog/api/hog_function_template.py @@ -28,22 +28,19 @@ class Meta: # NOTE: There is nothing currently private about these values class PublicHogFunctionTemplateViewSet(viewsets.GenericViewSet): filter_backends = [DjangoFilterBackend] - filterset_fields = ["id", "team", "created_by", "enabled"] + filterset_fields = ["id", "team", "created_by", "enabled", "type"] permission_classes = [permissions.AllowAny] serializer_class = HogFunctionTemplateSerializer - def _get_templates(self): - type = self.request.GET.get("type", "destination") - return [item for item in HOG_FUNCTION_TEMPLATES if item.type == type] - def list(self, request: Request, *args, **kwargs): - page = self.paginate_queryset(self._get_templates()) + type = self.request.GET.get("type", "destination") + templates = [item for item in HOG_FUNCTION_TEMPLATES if item.type == type] + page = self.paginate_queryset(templates) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) def retrieve(self, request: Request, *args, **kwargs): - data = self._get_templates() - item = next((item for item in data if item.id == kwargs["pk"]), None) + item = next((item for item in HOG_FUNCTION_TEMPLATES if item.id == kwargs["pk"]), None) if not item: raise NotFound(f"Template with id {kwargs['pk']} not found.") diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py index d8c414f54dd03..1acedd79c25d5 100644 --- a/posthog/cdp/templates/__init__.py +++ b/posthog/cdp/templates/__init__.py @@ -14,6 +14,7 @@ from .mailjet.template_mailjet import ( template_create_contact as mailjet_create_contact, template_update_contact_list as mailjet_update_contact_list, + template_send_email as mailset_send_email, ) from .zapier.template_zapier import template as zapier from .mailgun.template_mailgun import template_mailgun_send_email as mailgun @@ -37,8 +38,10 @@ TemplateGoogleCloudStorageMigrator, ) from .airtable.template_airtable import template as airtable +from ._internal.template_broadcast import template_new_broadcast as _broadcast HOG_FUNCTION_TEMPLATES = [ + _broadcast, slack, webhook, activecampaign, @@ -66,6 +69,7 @@ mailgun, mailjet_create_contact, mailjet_update_contact_list, + mailset_send_email, meta_ads, microsoft_teams, posthog, diff --git a/posthog/cdp/templates/_internal/template_broadcast.py b/posthog/cdp/templates/_internal/template_broadcast.py new file mode 100644 index 0000000000000..fa380de5642a6 --- /dev/null +++ b/posthog/cdp/templates/_internal/template_broadcast.py @@ -0,0 +1,206 @@ +from posthog.cdp.templates.hog_function_template import HogFunctionTemplate + +template_new_broadcast: HogFunctionTemplate = HogFunctionTemplate( + status="beta", + type="broadcast", + id="template-new-broadcast", + name="Hello !", + description="This is a broadcast", + icon_url="/static/hedgehog/explorer-hog.png", + category=["Email Marketing"], + hog="""import('provider/email').sendEmail(inputs.email)""".strip(), + inputs_schema=[ + { + "key": "email", + "type": "email", + "label": "Email", + "default": { + "to": "{person.properties.email}", + "body": "Hello {person.properties.first_name} {person.properties.last_name}!\n\nThis is a broadcast", + "from": "info@posthog.com", + "subject": "Hello {person.properties.email}", + "html": '\n\n \n \n \n \n \n \n \n \n \n\n\n\n\n \n \n \n \n \n \n \n \n
\n \n \n \n \n
\n
\n
\n \n \n\n
\n
\n
\n \n\n \n \n \n \n \n
\n \n
\n

Hello from PostHog!

\n
\n\n
\n\n\n \n \n \n \n \n
\n \n \n
\n \n \n Learn more\n \n \n
\n\n
\n\n
\n
\n
\n\n \n
\n
\n
\n \n\n\n \n
\n \n \n\n\n\n', + "design": { + "body": { + "id": "TlJ2GekAva", + "rows": [ + { + "id": "-sL7LJ6rhD", + "cells": [1], + "values": { + "_meta": {"htmlID": "u_row_1", "htmlClassNames": "u_row"}, + "anchor": "", + "columns": False, + "padding": "0px", + "hideable": True, + "deletable": True, + "draggable": True, + "selectable": True, + "_styleGuide": None, + "hideDesktop": False, + "duplicatable": True, + "backgroundColor": "", + "backgroundImage": { + "url": "", + "size": "custom", + "repeat": "no-repeat", + "position": "center", + "fullWidth": True, + "customPosition": ["50%", "50%"], + }, + "displayCondition": None, + "columnsBackgroundColor": "", + }, + "columns": [ + { + "id": "IT_J8vPWTn", + "values": { + "_meta": {"htmlID": "u_column_1", "htmlClassNames": "u_column"}, + "border": {}, + "padding": "0px", + "borderRadius": "0px", + "backgroundColor": "", + }, + "contents": [ + { + "id": "BSAz3H7CN9", + "type": "text", + "values": { + "text": '

Hello from PostHog!

', + "_meta": { + "htmlID": "u_content_text_1", + "htmlClassNames": "u_content_text", + }, + "anchor": "", + "fontSize": "14px", + "hideable": True, + "deletable": True, + "draggable": True, + "linkStyle": { + "inherit": True, + "linkColor": "#0000ee", + "linkUnderline": True, + "linkHoverColor": "#0000ee", + "linkHoverUnderline": True, + }, + "textAlign": "left", + "_languages": {}, + "lineHeight": "140%", + "selectable": True, + "_styleGuide": None, + "duplicatable": True, + "containerPadding": "10px", + "displayCondition": None, + }, + }, + { + "id": "cAnEDJLdIg", + "type": "button", + "values": { + "href": { + "name": "web", + "attrs": {"href": "{{href}}", "target": "{{target}}"}, + "values": {"href": "https://posthog.com/", "target": "_blank"}, + }, + "size": {"width": "100%", "autoWidth": True}, + "text": "Learn more", + "_meta": { + "htmlID": "u_content_button_1", + "htmlClassNames": "u_content_button", + }, + "anchor": "", + "border": {}, + "padding": "10px 20px", + "fontSize": "14px", + "hideable": True, + "deletable": True, + "draggable": True, + "textAlign": "center", + "_languages": {}, + "lineHeight": "120%", + "selectable": True, + "_styleGuide": None, + "borderRadius": "4px", + "buttonColors": { + "color": "#FFFFFF", + "hoverColor": "#FFFFFF", + "backgroundColor": "#3AAEE0", + "hoverBackgroundColor": "#3AAEE0", + }, + "duplicatable": True, + "calculatedWidth": 112, + "calculatedHeight": 37, + "containerPadding": "10px", + "displayCondition": None, + }, + }, + ], + } + ], + } + ], + "values": { + "_meta": {"htmlID": "u_body", "htmlClassNames": "u_body"}, + "language": {}, + "linkStyle": { + "body": True, + "linkColor": "#0000ee", + "linkUnderline": True, + "linkHoverColor": "#0000ee", + "linkHoverUnderline": True, + }, + "textColor": "#000000", + "fontFamily": {"label": "Arial", "value": "arial,helvetica,sans-serif"}, + "popupWidth": "600px", + "_styleGuide": None, + "popupHeight": "auto", + "borderRadius": "10px", + "contentAlign": "center", + "contentWidth": "500px", + "popupPosition": "center", + "preheaderText": "", + "backgroundColor": "#F7F8F9", + "backgroundImage": { + "url": "", + "size": "custom", + "repeat": "no-repeat", + "position": "center", + "fullWidth": True, + }, + "contentVerticalAlign": "center", + "popupBackgroundColor": "#FFFFFF", + "popupBackgroundImage": { + "url": "", + "size": "cover", + "repeat": "no-repeat", + "position": "center", + "fullWidth": True, + }, + "popupCloseButton_action": { + "name": "close_popup", + "attrs": { + "onClick": "document.querySelector('.u-popup-container').style.display = 'none';" + }, + }, + "popupCloseButton_margin": "0px", + "popupCloseButton_position": "top-right", + "popupCloseButton_iconColor": "#000000", + "popupOverlay_backgroundColor": "rgba(0, 0, 0, 0.1)", + "popupCloseButton_borderRadius": "0px", + "popupCloseButton_backgroundColor": "#DDDDDD", + }, + "footers": [], + "headers": [], + }, + "counters": {"u_row": 1, "u_column": 1, "u_content_text": 1, "u_content_button": 1}, + "schemaVersion": 17, + }, + }, + "secret": False, + "required": True, + }, + ], + filters={ + "properties": [{"key": "email", "value": "is_set", "operator": "is_set", "type": "person"}], + }, +) diff --git a/posthog/cdp/templates/mailjet/template_mailjet.py b/posthog/cdp/templates/mailjet/template_mailjet.py index a089372daefb8..23cf5e07da0f4 100644 --- a/posthog/cdp/templates/mailjet/template_mailjet.py +++ b/posthog/cdp/templates/mailjet/template_mailjet.py @@ -3,24 +3,22 @@ # See https://dev.mailjet.com/email/reference/contacts/contact-list/ -common_inputs_schemas = [ - { - "key": "api_key", - "type": "string", - "label": "Mailjet API Key", - "secret": True, - "required": True, - }, - { - "key": "email", - "type": "string", - "label": "Email of the user", - "description": "Where to find the email for the user to be checked with Mailjet", - "default": "{person.properties.email}", - "secret": False, - "required": True, - }, -] +input_api_key = { + "key": "api_key", + "type": "string", + "label": "Mailjet API Key", + "secret": True, + "required": True, +} +input_email = { + "key": "email", + "type": "string", + "label": "Email of the user", + "description": "Where to find the email for the user to be checked with Mailjet", + "default": "{person.properties.email}", + "secret": False, + "required": True, +} common_filters = { "events": [{"id": "$identify", "name": "$identify", "type": "events", "order": 0}], @@ -56,7 +54,8 @@ }) """.strip(), inputs_schema=[ - *common_inputs_schemas, + input_api_key, + input_email, { "key": "name", "type": "string", @@ -110,7 +109,8 @@ }) """.strip(), inputs_schema=[ - *common_inputs_schemas, + input_api_key, + input_email, { "key": "contact_list_id", "type": "string", @@ -148,3 +148,56 @@ ], filters=common_filters, ) + + +template_send_email: HogFunctionTemplate = HogFunctionTemplate( + status="beta", + type="email", + id="template-mailjet-send-email", + name="Mailjet", + description="Send an email with Mailjet", + icon_url="/static/services/mailjet.png", + category=["Email Provider"], + hog=""" +fun sendEmail(email) { + fetch(f'https://api.mailjet.com/v3.1/send', { + 'method': 'POST', + 'headers': { + 'Authorization': f'Bearer {inputs.api_key}', + 'Content-Type': 'application/json' + }, + 'body': { + 'Messages': [ + { + 'From': { + 'Email': inputs.email.from, + 'Name': '' + }, + 'To': [ + { + 'Email': inputs.email.to, + 'Name': '' + } + ], + 'Subject': inputs.email.subject, + 'HTMLPart': inputs.email.html + } + ] + } + }) +} +// TODO: support the "export" keyword in front of functions +return {'sendEmail': sendEmail} +""".strip(), + inputs_schema=[ + input_api_key, + { + "key": "from_email", + "type": "string", + "label": "Email to send from", + "secret": False, + "required": True, + }, + ], + filters=common_filters, +) diff --git a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr index 6027f7ca7bb42..4ae57feb8cb96 100644 --- a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr +++ b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr @@ -851,14 +851,49 @@ # --- # name: TestTrends.test_dau_with_breakdown_filtering_with_sampling.1 ''' - /* celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; + SELECT groupArray(1)(date)[1] AS date, + arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, + if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', breakdown_value) AS breakdown_value + FROM + (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))))), 1))) AS date, + arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) + and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total, + breakdown_value AS breakdown_value, + rowNumberInAllBlocks() AS row_number + FROM + (SELECT sum(total) AS count, + day_start AS day_start, + breakdown_value AS breakdown_value + FROM + (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, + toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, + ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value + FROM events AS e SAMPLE 1.0 + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 2) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up')) + GROUP BY day_start, + breakdown_value) + GROUP BY day_start, + breakdown_value + ORDER BY day_start ASC, breakdown_value ASC) + GROUP BY breakdown_value + ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) + WHERE isNotNull(breakdown_value) + GROUP BY breakdown_value + ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC + LIMIT 50000 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1, + format_csv_allow_double_quotes=0, + max_ast_elements=4000000, + max_expanded_ast_elements=4000000, + max_bytes_before_external_group_by=0 ''' # --- # name: TestTrends.test_dau_with_breakdown_filtering_with_sampling.10 @@ -1075,38 +1110,143 @@ # --- # name: TestTrends.test_dau_with_breakdown_filtering_with_sampling.2 ''' - /* celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; + SELECT groupArray(1)(date)[1] AS date, + arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, + if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', breakdown_value) AS breakdown_value + FROM + (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))))), 1))) AS date, + arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) + and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total, + breakdown_value AS breakdown_value, + rowNumberInAllBlocks() AS row_number + FROM + (SELECT sum(total) AS count, + day_start AS day_start, + breakdown_value AS breakdown_value + FROM + (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, + toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, + ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value + FROM events AS e SAMPLE 1.0 + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 2) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up')) + GROUP BY day_start, + breakdown_value) + GROUP BY day_start, + breakdown_value + ORDER BY day_start ASC, breakdown_value ASC) + GROUP BY breakdown_value + ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) + WHERE isNotNull(breakdown_value) + GROUP BY breakdown_value + ORDER BY if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_other_$$'), 0), 2, if(ifNull(equals(breakdown_value, '$$_posthog_breakdown_null_$$'), 0), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC + LIMIT 50000 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1, + format_csv_allow_double_quotes=0, + max_ast_elements=4000000, + max_expanded_ast_elements=4000000, + max_bytes_before_external_group_by=0 ''' # --- # name: TestTrends.test_dau_with_breakdown_filtering_with_sampling.3 ''' - /* celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; + SELECT groupArray(1)(date)[1] AS date, + arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, + arrayMap(i -> if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', i), breakdown_value) AS breakdown_value + FROM + (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))))), 1))) AS date, + arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) + and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total, + breakdown_value AS breakdown_value, + rowNumberInAllBlocks() AS row_number + FROM + (SELECT sum(total) AS count, + day_start AS day_start, + [ifNull(toString(breakdown_value_1), '$$_posthog_breakdown_null_$$')] AS breakdown_value + FROM + (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, + toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, + ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value_1 + FROM events AS e SAMPLE 1.0 + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 2) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up')) + GROUP BY day_start, + breakdown_value_1) + GROUP BY day_start, + breakdown_value_1 + ORDER BY day_start ASC, breakdown_value ASC) + GROUP BY breakdown_value + ORDER BY if(has(breakdown_value, '$$_posthog_breakdown_other_$$'), 2, if(has(breakdown_value, '$$_posthog_breakdown_null_$$'), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) + WHERE arrayExists(x -> isNotNull(x), breakdown_value) + GROUP BY breakdown_value + ORDER BY if(has(breakdown_value, '$$_posthog_breakdown_other_$$'), 2, if(has(breakdown_value, '$$_posthog_breakdown_null_$$'), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC + LIMIT 50000 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1, + format_csv_allow_double_quotes=0, + max_ast_elements=4000000, + max_expanded_ast_elements=4000000, + max_bytes_before_external_group_by=0 ''' # --- # name: TestTrends.test_dau_with_breakdown_filtering_with_sampling.4 ''' - /* celery:posthog.tasks.tasks.sync_insight_caching_state */ - SELECT team_id, - date_diff('second', max(timestamp), now()) AS age - FROM events - WHERE timestamp > date_sub(DAY, 3, now()) - AND timestamp < now() - GROUP BY team_id - ORDER BY age; + SELECT groupArray(1)(date)[1] AS date, + arrayFold((acc, x) -> arrayMap(i -> plus(acc[i], x[i]), range(1, plus(length(date), 1))), groupArray(ifNull(total, 0)), arrayWithConstant(length(date), reinterpretAsFloat64(0))) AS total, + arrayMap(i -> if(ifNull(ifNull(greaterOrEquals(row_number, 25), 0), 0), '$$_posthog_breakdown_other_$$', i), breakdown_value) AS breakdown_value + FROM + (SELECT arrayMap(number -> plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toIntervalDay(number)), range(0, plus(coalesce(dateDiff('day', toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC'))), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))))), 1))) AS date, + arrayMap(_match_date -> arraySum(arraySlice(groupArray(ifNull(count, 0)), indexOf(groupArray(day_start) AS _days_for_count, _match_date) AS _index, plus(minus(arrayLastIndex(x -> ifNull(equals(x, _match_date), isNull(x) + and isNull(_match_date)), _days_for_count), _index), 1))), date) AS total, + breakdown_value AS breakdown_value, + rowNumberInAllBlocks() AS row_number + FROM + (SELECT sum(total) AS count, + day_start AS day_start, + [ifNull(toString(breakdown_value_1), '$$_posthog_breakdown_null_$$')] AS breakdown_value + FROM + (SELECT count(DISTINCT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id)) AS total, + toStartOfDay(toTimeZone(e.timestamp, 'UTC')) AS day_start, + ifNull(nullIf(toString(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', '')), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value_1 + FROM events AS e SAMPLE 1.0 + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 2) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) + WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), equals(e.event, 'sign up')) + GROUP BY day_start, + breakdown_value_1) + GROUP BY day_start, + breakdown_value_1 + ORDER BY day_start ASC, breakdown_value ASC) + GROUP BY breakdown_value + ORDER BY if(has(breakdown_value, '$$_posthog_breakdown_other_$$'), 2, if(has(breakdown_value, '$$_posthog_breakdown_null_$$'), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC) + WHERE arrayExists(x -> isNotNull(x), breakdown_value) + GROUP BY breakdown_value + ORDER BY if(has(breakdown_value, '$$_posthog_breakdown_other_$$'), 2, if(has(breakdown_value, '$$_posthog_breakdown_null_$$'), 1, 0)) ASC, arraySum(total) DESC, breakdown_value ASC + LIMIT 50000 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1, + format_csv_allow_double_quotes=0, + max_ast_elements=4000000, + max_expanded_ast_elements=4000000, + max_bytes_before_external_group_by=0 ''' # --- # name: TestTrends.test_dau_with_breakdown_filtering_with_sampling.5