diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--light.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--light.png index b0c4487104da4..168f812ecd62d 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--light.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--surveys-list--dark.png b/frontend/__snapshots__/scenes-app-surveys--surveys-list--dark.png index a4ceb3edb03b9..8b38952b36b4e 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--surveys-list--dark.png and b/frontend/__snapshots__/scenes-app-surveys--surveys-list--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--surveys-list--light.png b/frontend/__snapshots__/scenes-app-surveys--surveys-list--light.png index 4315a51e7e8e1..306ddcaab2011 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--surveys-list--light.png and b/frontend/__snapshots__/scenes-app-surveys--surveys-list--light.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index ed731503746dd..39f106016c231 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -196,6 +196,7 @@ export const FEATURE_FLAGS = { SETTINGS_PERSONS_ON_EVENTS_HIDDEN: 'settings-persons-on-events-hidden', // owner: @Twixes HOG: 'hog', // owner: @mariusandra HOG_FUNCTIONS: 'hog-functions', // owner: #team-cdp + HOG_FUNCTIONS_LINKED: 'hog-functions-linked', // owner: #team-cdp PERSONLESS_EVENTS_NOT_SUPPORTED: 'personless-events-not-supported', // owner: @raquelmsmith ALERTS: 'alerts', // owner: github.com/nikitaevg ERROR_TRACKING: 'error-tracking', // owner: #team-replay diff --git a/frontend/src/scenes/actions/ActionHogFunctions.tsx b/frontend/src/scenes/actions/ActionHogFunctions.tsx index 781669d066d1b..0219475291356 100644 --- a/frontend/src/scenes/actions/ActionHogFunctions.tsx +++ b/frontend/src/scenes/actions/ActionHogFunctions.tsx @@ -1,59 +1,34 @@ -import { LemonButton } from '@posthog/lemon-ui' import { useValues } from 'kea' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { actionLogic } from 'scenes/actions/actionLogic' -import { DestinationsTable } from 'scenes/pipeline/destinations/Destinations' -import { PipelineBackend } from 'scenes/pipeline/types' -import { urls } from 'scenes/urls' +import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions' -import { PipelineStage } from '~/types' +import { HogFunctionFiltersType } from '~/types' export function ActionHogFunctions(): JSX.Element | null { const { action } = useValues(actionLogic) - const hogFunctionsEnabled = useFeatureFlag('HOG_FUNCTIONS') if (!action || !hogFunctionsEnabled) { return null } + const filters: HogFunctionFiltersType = { + actions: [ + { + id: `${action?.id}`, + name: action?.name, + type: 'actions', + }, + ], + } + return (
-
-

Connected destinations

- - - New destination - -
+

Connected destinations

Actions can be used a filters for destinations such as Slack or Webhook delivery

- +
) } diff --git a/frontend/src/scenes/data-management/definition/DefinitionView.tsx b/frontend/src/scenes/data-management/definition/DefinitionView.tsx index 534d638f1b95c..57936949c2f22 100644 --- a/frontend/src/scenes/data-management/definition/DefinitionView.tsx +++ b/frontend/src/scenes/data-management/definition/DefinitionView.tsx @@ -7,6 +7,7 @@ import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' import { PageHeader } from 'lib/components/PageHeader' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { IconPlayCircle } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' @@ -14,6 +15,7 @@ import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' import { getFilterLabel } from 'lib/taxonomy' import { definitionLogic, DefinitionLogicProps } from 'scenes/data-management/definition/definitionLogic' import { EventDefinitionProperties } from 'scenes/data-management/events/EventDefinitionProperties' +import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -35,6 +37,7 @@ export function DefinitionView(props: DefinitionLogicProps = {}): JSX.Element { const { definition, definitionLoading, definitionMissing, hasTaxonomyFeatures, singular, isEvent, isProperty } = useValues(logic) const { deleteDefinition } = useActions(logic) + const hogFunctionsEnabled = useFeatureFlag('HOG_FUNCTIONS') if (definitionLoading) { return @@ -196,6 +199,25 @@ export function DefinitionView(props: DefinitionLogicProps = {}): JSX.Element { {isEvent && definition.id !== 'new' && ( <> + + {hogFunctionsEnabled && ( + <> + +

Connected destinations

+

Get notified via Slack, webhooks or more whenever this event is captured.

+ + + + )}

Matching events

This is the list of recent events that match this definition.

diff --git a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx index 50e9809a930e6..cabd132c21809 100644 --- a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx +++ b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx @@ -7,10 +7,12 @@ import { router } from 'kea-router' import { FlagSelector } from 'lib/components/FlagSelector' import { NotFound } from 'lib/components/NotFound' import { PageHeader } from 'lib/components/PageHeader' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { useState } from 'react' +import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -21,6 +23,7 @@ import { EarlyAccessFeatureTabs, EarlyAccessFeatureType, FilterLogicalOperator, + HogFunctionFiltersType, PersonPropertyFilter, PropertyFilterType, PropertyOperator, @@ -58,6 +61,7 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element { } = useActions(earlyAccessFeatureLogic) const isNewEarlyAccessFeature = id === 'new' || id === undefined + const showLinkedHogFunctions = useFeatureFlag('HOG_FUNCTIONS_LINKED') if (earlyAccessFeatureMissing) { return @@ -67,6 +71,26 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element { return } + const destinationFilters: HogFunctionFiltersType | null = + !isEditingFeature && !isNewEarlyAccessFeature && 'id' in earlyAccessFeature && showLinkedHogFunctions + ? { + events: [ + { + id: '$feature_enrollment_update', + type: 'events', + properties: [ + { + key: '$feature_flag', + value: [earlyAccessFeature.feature_flag.key], + operator: PropertyOperator.Exact, + type: PropertyFilterType.Event, + }, + ], + }, + ], + } + : null + return (
+ {destinationFilters && ( + <> + +

Notifications

+

Get notified when people opt in or out of your feature.

+ + + )} {!isEditingFeature && !isNewEarlyAccessFeature && 'id' in earlyAccessFeature && ( <> diff --git a/frontend/src/scenes/pipeline/AppMetricSparkLine.tsx b/frontend/src/scenes/pipeline/AppMetricSparkLine.tsx index d3f7069df99bf..3a3bf07806316 100644 --- a/frontend/src/scenes/pipeline/AppMetricSparkLine.tsx +++ b/frontend/src/scenes/pipeline/AppMetricSparkLine.tsx @@ -1,9 +1,7 @@ -import { useActions, useValues } from 'kea' +import { useValues } from 'kea' import { Sparkline, SparklineTimeSeries } from 'lib/components/Sparkline' -import { useEffect } from 'react' import { pipelineNodeMetricsLogic } from './pipelineNodeMetricsLogic' -import { pipelineNodeMetricsV2Logic } from './pipelineNodeMetricsV2Logic' import { PipelineNode } from './types' export function AppMetricSparkLine({ pipelineNode }: { pipelineNode: PipelineNode }): JSX.Element { @@ -42,36 +40,3 @@ export function AppMetricSparkLine({ pipelineNode }: { pipelineNode: PipelineNod /> ) } - -export function AppMetricSparkLineV2({ pipelineNode }: { pipelineNode: PipelineNode }): JSX.Element { - const logic = pipelineNodeMetricsV2Logic({ id: `${pipelineNode.id}`.replace('hog-', '') }) - const { appMetrics, appMetricsLoading } = useValues(logic) - const { loadMetrics } = useActions(logic) - - useEffect(() => { - loadMetrics() - }, []) - - const displayData: SparklineTimeSeries[] = [ - { - color: 'success', - name: 'Success', - values: appMetrics?.series.find((s) => s.name === 'succeeded')?.values || [], - }, - { - color: 'danger', - name: 'Failures', - values: appMetrics?.series.find((s) => s.name === 'failed')?.values || [], - }, - ] - - return ( - - ) -} diff --git a/frontend/src/scenes/pipeline/PipelineNode.tsx b/frontend/src/scenes/pipeline/PipelineNode.tsx index f0e05a51159bb..44c2340da9298 100644 --- a/frontend/src/scenes/pipeline/PipelineNode.tsx +++ b/frontend/src/scenes/pipeline/PipelineNode.tsx @@ -13,10 +13,10 @@ import { urls } from 'scenes/urls' import { ActivityScope, PipelineNodeTab, PipelineStage, PipelineTab } from '~/types' import { BatchExportRuns } from './BatchExportRuns' +import { AppMetricsV2 } from './metrics/AppMetricsV2' import { PipelineNodeConfiguration } from './PipelineNodeConfiguration' import { pipelineNodeLogic, PipelineNodeLogicProps } from './pipelineNodeLogic' import { PipelineNodeMetrics } from './PipelineNodeMetrics' -import { PipelineNodeMetricsV2 } from './PipelineNodeMetricsV2' import { PipelineBackend } from './types' export const PIPELINE_TAB_TO_NODE_STAGE: Partial> = { @@ -67,7 +67,7 @@ export function PipelineNode(params: { stage?: string; id?: string } = {}): JSX. [PipelineNodeTab.Configuration]: , [PipelineNodeTab.Metrics]: node.backend === PipelineBackend.HogFunction ? ( - + ) : ( ), diff --git a/frontend/src/scenes/pipeline/destinations/Destinations.tsx b/frontend/src/scenes/pipeline/destinations/Destinations.tsx index 6d2ea2b019c72..bcfad528f976f 100644 --- a/frontend/src/scenes/pipeline/destinations/Destinations.tsx +++ b/frontend/src/scenes/pipeline/destinations/Destinations.tsx @@ -21,8 +21,9 @@ import { urls } from 'scenes/urls' import { AvailableFeature, PipelineNodeTab, PipelineStage, ProductKey } from '~/types' -import { AppMetricSparkLine, AppMetricSparkLineV2 } from '../AppMetricSparkLine' +import { AppMetricSparkLine } from '../AppMetricSparkLine' import { HogFunctionIcon } from '../hogfunctions/HogFunctionIcon' +import { AppMetricSparkLineV2 } from '../metrics/AppMetricsV2Sparkline' import { NewButton } from '../NewButton' import { pipelineAccessLogic } from '../pipelineAccessLogic' import { Destination, PipelineBackend } from '../types' @@ -55,8 +56,9 @@ export function Destinations(): JSX.Element { } export function DestinationsTable({ ...props }: PipelineDestinationsLogicProps): JSX.Element { + const { canConfigurePlugins, canEnableDestination } = useValues(pipelineAccessLogic) const { loading, filteredDestinations, filters, destinations } = useValues(pipelineDestinationsLogic(props)) - const { setFilters, resetFilters } = useActions(pipelineDestinationsLogic(props)) + const { setFilters, resetFilters, toggleNode, deleteNode } = useActions(pipelineDestinationsLogic(props)) const hasHogFunctions = !!useFeatureFlag('HOG_FUNCTIONS') @@ -168,7 +170,7 @@ export function DestinationsTable({ ...props }: PipelineDestinationsLogicProps): )} > {destination.backend === PipelineBackend.HogFunction ? ( - + ) : ( )} @@ -201,7 +203,36 @@ export function DestinationsTable({ ...props }: PipelineDestinationsLogicProps): { width: 0, render: function Render(_, destination) { - return } /> + return ( + toggleNode(destination, !destination.enabled), + disabledReason: !canConfigurePlugins + ? 'You do not have permission to toggle destinations.' + : !canEnableDestination(destination) && !destination.enabled + ? 'Data pipelines add-on is required for enabling new destinations' + : undefined, + }, + ...pipelineNodeMenuCommonItems(destination), + { + label: 'Delete destination', + status: 'danger' as const, // for typechecker happiness + onClick: () => deleteNode(destination), + disabledReason: canConfigurePlugins + ? undefined + : 'You do not have permission to delete destinations.', + }, + ]} + /> + } + /> + ) }, }, ]} @@ -220,33 +251,3 @@ export function DestinationsTable({ ...props }: PipelineDestinationsLogicProps): ) } - -const DestinationMoreOverlay = ({ destination }: { destination: Destination }): JSX.Element => { - const { canConfigurePlugins, canEnableDestination } = useValues(pipelineAccessLogic) - const { toggleNode, deleteNode } = useActions(pipelineDestinationsLogic) - - return ( - toggleNode(destination, !destination.enabled), - disabledReason: !canConfigurePlugins - ? 'You do not have permission to toggle destinations.' - : !canEnableDestination(destination) && !destination.enabled - ? 'Data pipelines add-on is required for enabling new destinations' - : undefined, - }, - ...pipelineNodeMenuCommonItems(destination), - { - label: 'Delete destination', - status: 'danger' as const, // for typechecker happiness - onClick: () => deleteNode(destination), - disabledReason: canConfigurePlugins - ? undefined - : 'You do not have permission to delete destinations.', - }, - ]} - /> - ) -} diff --git a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx index 716acee77809e..1809bffac687f 100644 --- a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx +++ b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx @@ -39,16 +39,22 @@ export type NewDestinationItemType = { export type NewDestinationFilters = { search?: string kind?: PipelineBackend + sub_template?: string +} + +export type NewDestinationsLogicProps = { + defaultFilters?: NewDestinationFilters + forceFilters?: NewDestinationFilters } // Helping kea-typegen navigate the exported default class for Fuse export interface Fuse extends FuseClass {} export const newDestinationsLogic = kea([ + path(() => ['scenes', 'pipeline', 'destinations', 'newDestinationsLogic']), connect({ values: [userLogic, ['user'], featureFlagLogic, ['featureFlags']], }), - path(() => ['scenes', 'pipeline', 'destinations', 'newDestinationsLogic']), actions({ setFilters: (filters: Partial) => ({ filters }), resetFilters: true, diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx index 6057a4c81c91e..78f2c52c4c148 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx @@ -6,6 +6,7 @@ import { LemonDropdown, LemonInput, LemonLabel, + LemonSelect, LemonSwitch, LemonTag, LemonTextArea, @@ -52,7 +53,9 @@ export function HogFunctionConfiguration({ templateId, id }: { templateId?: stri sparkline, sparklineLoading, template, + subTemplate, templateHasChanged, + forcedSubTemplateId, } = useValues(logic) const { submitConfiguration, @@ -63,6 +66,7 @@ export function HogFunctionConfiguration({ templateId, id }: { templateId?: stri duplicateFromTemplate, setConfigurationValue, deleteHogFunction, + setSubTemplateId, } = useActions(logic) if (loading && !loaded) { @@ -286,6 +290,41 @@ export function HogFunctionConfiguration({ templateId, id }: { templateId?: stri
+ {!forcedSubTemplateId && template?.sub_templates && ( + <> +
+
+ Choose template + ({ + value: subTemplate.id, + label: subTemplate.name, + labelInMenu: ( +
+
{subTemplate.name}
+
+ {subTemplate.description} +
+
+ ), + })), + ]} + value={subTemplate?.id} + onChange={(value) => { + setSubTemplateId(value) + }} + /> +
+
+ + )} +
diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts new file mode 100644 index 0000000000000..85176c2b4a869 --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts @@ -0,0 +1,216 @@ +import { expectLogic } from 'kea-test-utils' +import api from 'lib/api' + +import { initKeaTests } from '~/test/init' +import { HogFunctionTemplateType, HogFunctionType, PropertyFilterType, PropertyOperator } from '~/types' + +jest.mock('lib/api', () => ({ + ...jest.requireActual('lib/api'), + hogFunctions: { + get: jest.fn(), + getTemplate: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, +})) + +// the mock api object + +const mockApi = api.hogFunctions as jest.Mocked + +import { hogFunctionConfigurationLogic } from './hogFunctionConfigurationLogic' + +const HOG_TEMPLATE: HogFunctionTemplateType = { + sub_templates: [ + { + id: 'early_access_feature_enrollment', + name: 'HTTP Webhook on feature enrollment', + description: null, + filters: { + events: [ + { + id: '$feature_enrollment_update', + type: 'events', + }, + ], + }, + masking: null, + inputs: null, + }, + { + id: 'survey_response', + name: 'HTTP Webhook on survey response', + description: null, + filters: { + events: [ + { + id: 'survey sent', + type: 'events', + properties: [ + { + key: '$survey_response', + type: PropertyFilterType.Event, + value: 'is_set', + operator: PropertyOperator.IsSet, + }, + ], + }, + ], + }, + masking: null, + inputs: null, + }, + ], + status: 'beta', + id: 'template-webhook', + name: 'HTTP Webhook', + description: 'Sends a webhook templated by the incoming event data', + hog: "let res := fetch(inputs.url, {\n 'headers': inputs.headers,\n 'body': inputs.body,\n 'method': inputs.method\n});\n\nif (inputs.debug) {\n print('Response', res.status, res.body);\n}", + inputs_schema: [ + { + key: 'url', + type: 'string', + label: 'Webhook URL', + secret: false, + required: true, + }, + { + key: 'method', + type: 'choice', + label: 'Method', + secret: false, + choices: [ + { + label: 'POST', + value: 'POST', + }, + { + label: 'PUT', + value: 'PUT', + }, + { + label: 'PATCH', + value: 'PATCH', + }, + { + label: 'GET', + value: 'GET', + }, + { + label: 'DELETE', + value: 'DELETE', + }, + ], + default: 'POST', + required: false, + }, + { + key: 'body', + type: 'json', + label: 'JSON Body', + default: { + event: '{event}', + person: '{person}', + }, + secret: false, + required: false, + }, + { + key: 'headers', + type: 'dictionary', + label: 'Headers', + secret: false, + required: false, + }, + { + key: 'debug', + type: 'boolean', + label: 'Log responses', + description: 'Logs the response of http calls for debugging.', + secret: false, + required: false, + default: false, + }, + ], + filters: null, + masking: null, + icon_url: '/static/posthog-icon.svg', +} + +const HOG_FUNCTION: HogFunctionType = { + ...HOG_TEMPLATE, + created_at: '2021-09-29T14:00:00Z', + created_by: {} as any, + id: '123-456-789', + updated_at: '2021-09-29T14:00:00Z', + enabled: true, + status: undefined, +} + +describe('hogFunctionConfigurationLogic', () => { + let logic: ReturnType + + describe('template', () => { + beforeEach(() => { + initKeaTests() + + mockApi.getTemplate.mockReturnValue(Promise.resolve(HOG_TEMPLATE)) + mockApi.create.mockReturnValue(Promise.resolve(HOG_FUNCTION)) + mockApi.update.mockReturnValue(Promise.resolve(HOG_FUNCTION)) + + logic = hogFunctionConfigurationLogic({ + templateId: 'test', + }) + }) + + it('has expected defaults', async () => { + logic.mount() + await expectLogic(logic).toDispatchActions(['loadTemplate', 'loadTemplateSuccess']) + + expect(logic.values.template).toEqual(HOG_TEMPLATE) + expect(logic.values.configuration).toEqual({ + name: HOG_TEMPLATE.name, + description: HOG_TEMPLATE.description, + inputs_schema: HOG_TEMPLATE.inputs_schema, + filters: null, + hog: HOG_TEMPLATE.hog, + icon_url: HOG_TEMPLATE.icon_url, + inputs: { + method: { value: 'POST' }, + body: { + value: { + event: '{event}', + person: '{person}', + }, + }, + }, + enabled: false, + }) + }) + + it('sets rejects submission if missing inputs', async () => { + logic.mount() + await expectLogic(logic).toDispatchActions(['loadTemplate', 'loadTemplateSuccess']) + + await expectLogic(logic, () => { + logic.actions.submitConfiguration() + }).toDispatchActions(['submitConfigurationFailure']) + + expect(logic.values.configurationErrors).toMatchObject({ + inputs: { + url: 'This field is required', + }, + }) + }) + + it('saves if form valid', async () => { + logic.mount() + await expectLogic(logic).toDispatchActions(['loadTemplate', 'loadTemplateSuccess']) + logic.actions.setConfigurationValue('inputs.url', { value: 'https://posthog.com' }) + + await expectLogic(logic, () => { + logic.actions.submitConfiguration() + }).toDispatchActions(['upsertHogFunction', 'submitConfigurationSuccess']) + }) + }) +}) diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx index c67a021e03987..dacfed655a513 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx @@ -27,6 +27,8 @@ import { HogFunctionConfigurationType, HogFunctionInputType, HogFunctionInvocationGlobals, + HogFunctionSubTemplateIdType, + HogFunctionSubTemplateType, HogFunctionTemplateType, HogFunctionType, PipelineNodeTab, @@ -41,6 +43,7 @@ import type { hogFunctionConfigurationLogicType } from './hogFunctionConfigurati export interface HogFunctionConfigurationLogicProps { templateId?: string + subTemplateId?: string id?: string } @@ -96,6 +99,32 @@ export function sanitizeConfiguration(data: HogFunctionConfigurationType): HogFu return payload } +const templateToConfiguration = ( + template: HogFunctionTemplateType, + subTemplate?: HogFunctionSubTemplateType | null +): HogFunctionConfigurationType => { + const inputs: Record = {} + + template.inputs_schema?.forEach((schema) => { + if (typeof subTemplate?.inputs?.[schema.key] !== 'undefined') { + inputs[schema.key] = { value: subTemplate.inputs[schema.key] } + } else if (schema.default) { + inputs[schema.key] = { value: schema.default } + } + }) + + return { + name: subTemplate?.name ?? template.name, + description: subTemplate?.name ?? template.description, + inputs_schema: template.inputs_schema, + filters: subTemplate?.filters ?? template.filters, + hog: template.hog, + icon_url: template.icon_url, + inputs, + enabled: false, + } +} + export const hogFunctionConfigurationLogic = kea([ props({} as HogFunctionConfigurationLogicProps), key(({ id, templateId }: HogFunctionConfigurationLogicProps) => { @@ -107,13 +136,14 @@ export const hogFunctionConfigurationLogic = kea ['scenes', 'pipeline', 'hogFunctionConfigurationLogic', id]), actions({ setShowSource: (showSource: boolean) => ({ showSource }), - resetForm: (configuration?: HogFunctionConfigurationType) => ({ configuration }), + resetForm: true, upsertHogFunction: (configuration: HogFunctionConfigurationType) => ({ configuration }), duplicate: true, duplicateFromTemplate: true, - resetToTemplate: (keepInputs = true) => ({ keepInputs }), + resetToTemplate: true, deleteHogFunction: true, sparklineQueryChanged: (sparklineQuery: TrendsQuery) => ({ sparklineQuery } as { sparklineQuery: TrendsQuery }), + setSubTemplateId: (subTemplateId: HogFunctionSubTemplateIdType | null) => ({ subTemplateId }), }), reducers({ showSource: [ @@ -129,6 +159,12 @@ export const hogFunctionConfigurationLogic = kea true, }, ], + subTemplateId: [ + null as HogFunctionSubTemplateIdType | null, + { + setSubTemplateId: (_, { subTemplateId }) => subTemplateId, + }, + ], }), loaders(({ props, values }) => ({ template: [ @@ -271,27 +307,12 @@ export const hogFunctionConfigurationLogic = kea [s.template, s.hogFunction], - (template, hogFunction): HogFunctionConfigurationType => { + (s) => [s.template, s.hogFunction, s.subTemplate], + (template, hogFunction, subTemplate): HogFunctionConfigurationType | null => { if (template) { - // Fill defaults from template - const inputs: Record = {} - - template.inputs_schema?.forEach((schema) => { - if (schema.default) { - inputs[schema.key] = { value: schema.default } - } - }) - - return { - ...template, - inputs, - enabled: false, - } - } else if (hogFunction) { - return hogFunction + return templateToConfiguration(template, subTemplate) } - return {} as HogFunctionConfigurationType + return hogFunction ?? null }, ], @@ -494,6 +515,20 @@ export const hogFunctionConfigurationLogic = kea [s.template, s.subTemplateId], + (template, subTemplateId) => { + if (!template || !subTemplateId) { + return null + } + + const subTemplate = template.sub_templates?.find((st) => st.id === subTemplateId) + return subTemplate + }, + ], + + forcedSubTemplateId: [() => [router.selectors.searchParams], ({ sub_template }) => !!sub_template], })), listeners(({ actions, values, cache }) => ({ @@ -526,18 +561,26 @@ export const hogFunctionConfigurationLogic = kea { - const config = { - ...values.defaultFormState, - ...(cache.configFromUrl || {}), + const baseConfig = values.defaultFormState + if (!baseConfig) { + return + } + + const config: HogFunctionConfigurationType = { + ...baseConfig, + ...(cache.configFromUrl ?? {}), } const paramsFromUrl = cache.paramsFromUrl ?? {} if (paramsFromUrl.integration_target && paramsFromUrl.integration_id) { + config.inputs = config.inputs ?? {} config.inputs[paramsFromUrl.integration_target] = { value: paramsFromUrl.integration_id, } } + // TODO: Pull out sub template info + actions.resetConfiguration(config) }, @@ -571,25 +614,24 @@ export const hogFunctionConfigurationLogic = kea { - if (values.hogFunction?.template) { - const template = values.hogFunction.template - // Fill defaults from template - const inputs: Record = {} + resetToTemplate: async () => { + const template = values.hogFunction?.template ?? values.template + if (template) { + const config = templateToConfiguration(template, values.subTemplate) - template.inputs_schema?.forEach((schema) => { - inputs[schema.key] = (keepInputs ? values.configuration.inputs?.[schema.key] : undefined) ?? { - value: schema.default, - } + const inputs = config.inputs ?? {} + + // Keep any non-default values + Object.entries(values.configuration.inputs ?? {}).forEach(([key, value]) => { + inputs[key] = inputs[key] ?? value }) actions.setConfigurationValues({ - ...values.hogFunction.template, - filters: values.configuration.filters ?? template.filters, - // Keep some existing things + ...config, + filters: config.filters ?? values.configuration.filters, + // Keep some existing things when manually resetting the template name: values.configuration.name, description: values.configuration.description, - inputs, }) lemonToast.success('Template updates applied but not saved.') @@ -624,6 +666,10 @@ export const hogFunctionConfigurationLogic = kea { + actions.resetToTemplate() + }, })), afterMount(({ props, actions, cache }) => { cache.paramsFromUrl = { @@ -633,14 +679,20 @@ export const hogFunctionConfigurationLogic = kea loadHogFunctionTemplates(), []) + + return ( + <> +
+ {!props.forceFilters?.search && ( + setFilters({ search: e })} + /> + )} +
+ {extraControls} +
+ + + }, + }, + { + title: 'Name', + sticky: true, + sorter: true, + key: 'name', + dataIndex: 'name', + render: (_, template) => { + return ( + + {template.name} + {template.status && } + + } + description={template.description} + /> + ) + }, + }, + + { + width: 0, + render: function Render(_, template) { + return canEnableHogFunction(template) ? ( + } + to={urlForTemplate(template)} + fullWidth + > + Create + + ) : ( + + + + ) + }, + }, + ]} + emptyState={ + templates.length === 0 && !loading ? ( + 'No results found' + ) : ( + <> + Nothing found matching filters. resetFilters()}>Clear filters{' '} + + ) + } + /> + + ) +} diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx new file mode 100644 index 0000000000000..4d17516e02398 --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx @@ -0,0 +1,174 @@ +import { LemonCheckbox, LemonInput, LemonTable, LemonTableColumn, LemonTag, 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 { useEffect } from 'react' +import { AppMetricSparkLineV2 } from 'scenes/pipeline/metrics/AppMetricsV2Sparkline' +import { urls } from 'scenes/urls' + +import { HogFunctionType, PipelineNodeTab, PipelineStage } from '~/types' + +import { HogFunctionIcon } from '../HogFunctionIcon' +import { hogFunctionListLogic, HogFunctionListLogicProps } from './hogFunctionListLogic' + +export function HogFunctionList({ + extraControls, + ...props +}: HogFunctionListLogicProps & { extraControls?: JSX.Element }): JSX.Element { + const { loading, filteredHogFunctions, filters, hogFunctions, canEnableHogFunction } = useValues( + hogFunctionListLogic(props) + ) + const { loadHogFunctions, setFilters, resetFilters, toggleEnabled, deleteHogFunction } = useActions( + hogFunctionListLogic(props) + ) + + useEffect(() => loadHogFunctions(), []) + + return ( + <> +
+ {!props.forceFilters?.search && ( + setFilters({ search: e })} + /> + )} +
+ {typeof props.forceFilters?.onlyActive !== 'boolean' && ( + setFilters({ onlyActive: e ?? undefined })} + /> + )} + {extraControls} +
+ + + + }, + }, + { + title: 'Name', + sticky: true, + sorter: true, + key: 'name', + dataIndex: 'name', + render: (_, hogFunction) => { + return ( + + + {hogFunction.name} + + + } + description={hogFunction.description} + /> + ) + }, + }, + + { + title: 'Weekly volume', + render: (_, hogFunction) => { + return ( + + + + ) + }, + }, + updatedAtColumn() as LemonTableColumn, + { + title: 'Status', + key: 'enabled', + sorter: (a) => (a.enabled ? 1 : -1), + width: 0, + render: function RenderStatus(_, destination) { + return ( + <> + {destination.enabled ? ( + + Active + + ) : ( + + Paused + + )} + + ) + }, + }, + { + width: 0, + render: function Render(_, destination) { + return ( + toggleEnabled(destination, !destination.enabled), + disabledReason: + !canEnableHogFunction(destination) && !destination.enabled + ? 'Data pipelines add-on is required for enabling new destinations' + : undefined, + }, + { + label: 'Delete', + status: 'danger' as const, // for typechecker happiness + onClick: () => deleteHogFunction(destination), + }, + ]} + /> + } + /> + ) + }, + }, + ]} + emptyState={ + hogFunctions.length === 0 && !loading ? ( + 'No destinations found' + ) : ( + <> + No destinations matching filters.{' '} + resetFilters()}>Clear filters{' '} + + ) + } + /> + + + ) +} diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/LinkedHogFunctions.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/LinkedHogFunctions.tsx new file mode 100644 index 0000000000000..772b82a2be945 --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/list/LinkedHogFunctions.tsx @@ -0,0 +1,48 @@ +import { LemonButton } from '@posthog/lemon-ui' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' +import { useState } from 'react' + +import { HogFunctionFiltersType, HogFunctionSubTemplateIdType } from '~/types' + +import { HogFunctionList } from './HogFunctionsList' +import { HogFunctionTemplateList } from './HogFunctionTemplateList' + +export type LinkedHogFunctionsProps = { + filters: HogFunctionFiltersType + subTemplateId?: HogFunctionSubTemplateIdType +} + +export function LinkedHogFunctions({ filters, subTemplateId }: LinkedHogFunctionsProps): JSX.Element | null { + const hogFunctionsEnabled = useFeatureFlag('HOG_FUNCTIONS') + const [showNewDestination, setShowNewDestination] = useState(false) + + if (!hogFunctionsEnabled) { + return null + } + + return showNewDestination ? ( + + setShowNewDestination(false)}> + Cancel + + + } + /> + ) : ( + + setShowNewDestination(true)}> + New notification + + + } + /> + ) +} diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx new file mode 100644 index 0000000000000..25b1559b73f69 --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx @@ -0,0 +1,199 @@ +import { lemonToast } from '@posthog/lemon-ui' +import FuseClass from 'fuse.js' +import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { actionToUrl, router, urlToAction } from 'kea-router' +import api from 'lib/api' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { objectsEqual } from 'lib/utils' +import { deleteWithUndo } from 'lib/utils/deleteWithUndo' +import { pipelineAccessLogic } from 'scenes/pipeline/pipelineAccessLogic' +import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' + +import { HogFunctionType } from '~/types' + +import type { hogFunctionListLogicType } from './hogFunctionListLogicType' + +// Helping kea-typegen navigate the exported default class for Fuse +export interface Fuse extends FuseClass {} + +export type HogFunctionListFilters = { + search?: string + onlyActive?: boolean + filters?: Record +} + +export type HogFunctionListLogicProps = { + defaultFilters?: HogFunctionListFilters + forceFilters?: HogFunctionListFilters + syncFiltersWithUrl?: boolean +} + +export const hogFunctionListLogic = kea([ + props({} as HogFunctionListLogicProps), + key((props) => (props.syncFiltersWithUrl ? 'scene' : 'default')), + path((id) => ['scenes', 'pipeline', 'hogFunctionListLogic', id]), + connect({ + values: [ + teamLogic, + ['currentTeamId'], + userLogic, + ['user', 'hasAvailableFeature'], + pipelineAccessLogic, + ['canEnableNewDestinations'], + featureFlagLogic, + ['featureFlags'], + ], + }), + actions({ + toggleEnabled: (hogFunction: HogFunctionType, enabled: boolean) => ({ hogFunction, enabled }), + deleteHogFunction: (hogFunction: HogFunctionType) => ({ hogFunction }), + setFilters: (filters: Partial) => ({ filters }), + resetFilters: true, + }), + reducers(({ props }) => ({ + filters: [ + { ...(props.defaultFilters || {}), ...(props.forceFilters || {}) } as HogFunctionListFilters, + { + setFilters: (state, { filters }) => ({ + ...state, + ...filters, + ...(props.forceFilters || {}), + }), + resetFilters: () => ({ + ...(props.forceFilters || {}), + }), + }, + ], + })), + loaders(({ values, actions }) => ({ + hogFunctions: [ + [] as HogFunctionType[], + { + loadHogFunctions: async () => { + return ( + await api.hogFunctions.list({ + filters: values.filters?.filters, + }) + ).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((x) => x.id !== hogFunction.id) + }, + toggleEnabled: async ({ hogFunction, enabled }) => { + if (enabled && !values.canEnableNewDestinations) { + lemonToast.error('Data pipelines add-on is required for enabling new destinations.') + return values.hogFunctions + } + + const { hogFunctions } = values + const hogFunctionIndex = hogFunctions.findIndex((hf) => hf.id === hogFunction.id) + const response = await api.hogFunctions.update(hogFunction.id, { + enabled, + }) + return [ + ...hogFunctions.slice(0, hogFunctionIndex), + response, + ...hogFunctions.slice(hogFunctionIndex + 1), + ] + }, + }, + ], + })), + selectors({ + loading: [(s) => [s.hogFunctionsLoading], (hogFunctionsLoading) => hogFunctionsLoading], + sortedHogFunctions: [ + (s) => [s.hogFunctions], + (hogFunctions): HogFunctionType[] => { + const enabledFirst = hogFunctions.sort((a, b) => Number(b.enabled) - Number(a.enabled)) + return enabledFirst + }, + ], + hogFunctionsFuse: [ + (s) => [s.hogFunctions], + (hogFunctions): Fuse => { + return new FuseClass(hogFunctions || [], { + keys: ['name', 'description'], + threshold: 0.3, + }) + }, + ], + + filteredHogFunctions: [ + (s) => [s.filters, s.sortedHogFunctions, s.hogFunctionsFuse], + (filters, hogFunctions, hogFunctionsFuse): HogFunctionType[] => { + const { search, onlyActive } = filters + + return (search ? hogFunctionsFuse.search(search).map((x) => x.item) : hogFunctions).filter((x) => { + if (onlyActive && !x.enabled) { + return false + } + return true + }) + }, + ], + + canEnableHogFunction: [ + (s) => [s.canEnableNewDestinations], + (canEnableNewDestinations): ((hogFunction: HogFunctionType) => boolean) => { + return (hogFunction: HogFunctionType) => { + return hogFunction?.template?.status === 'free' || canEnableNewDestinations + } + }, + ], + }), + + actionToUrl(({ props, values }) => { + if (!props.syncFiltersWithUrl) { + return {} + } + const urlFromFilters = (): [ + string, + Record, + Record, + { + replace: boolean + } + ] => [ + router.values.location.pathname, + + values.filters, + router.values.hashParams, + { + replace: true, + }, + ] + + return { + setFilters: () => urlFromFilters(), + resetFilters: () => urlFromFilters(), + } + }), + + urlToAction(({ props, actions, values }) => ({ + '*': (_, searchParams) => { + if (!props.syncFiltersWithUrl) { + return + } + + if (!objectsEqual(values.filters, searchParams)) { + actions.setFilters(searchParams) + } + }, + })), +]) diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx new file mode 100644 index 0000000000000..079b56d659a7a --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx @@ -0,0 +1,181 @@ +import FuseClass from 'fuse.js' +import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { actionToUrl, combineUrl, router, urlToAction } from 'kea-router' +import api from 'lib/api' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { objectsEqual } from 'lib/utils' +import { pipelineAccessLogic } from 'scenes/pipeline/pipelineAccessLogic' +import { urls } from 'scenes/urls' + +import { HogFunctionTemplateType, PipelineStage } from '~/types' + +import type { hogFunctionTemplateListLogicType } from './hogFunctionTemplateListLogicType' + +// Helping kea-typegen navigate the exported default class for Fuse +export interface Fuse extends FuseClass {} + +export type HogFunctionTemplateListFilters = { + search?: string + filters?: Record + subTemplateId?: string +} + +export type HogFunctionTemplateListLogicProps = { + defaultFilters?: HogFunctionTemplateListFilters + forceFilters?: HogFunctionTemplateListFilters + syncFiltersWithUrl?: boolean +} + +export const hogFunctionTemplateListLogic = kea([ + props({} as HogFunctionTemplateListLogicProps), + key((props) => (props.syncFiltersWithUrl ? 'scene' : 'default')), + path((id) => ['scenes', 'pipeline', 'destinationsLogic', id]), + connect({ + values: [pipelineAccessLogic, ['canEnableNewDestinations'], featureFlagLogic, ['featureFlags']], + }), + actions({ + setFilters: (filters: Partial) => ({ filters }), + resetFilters: true, + }), + reducers(({ props }) => ({ + filters: [ + { ...(props.defaultFilters || {}), ...(props.forceFilters || {}) } as HogFunctionTemplateListFilters, + { + setFilters: (state, { filters }) => ({ + ...state, + ...filters, + ...(props.forceFilters || {}), + }), + resetFilters: () => ({ + ...(props.forceFilters || {}), + }), + }, + ], + })), + loaders(() => ({ + rawTemplates: [ + [] as HogFunctionTemplateType[], + { + loadHogFunctionTemplates: async () => { + return (await api.hogFunctions.listTemplates()).results + }, + }, + ], + })), + selectors({ + loading: [(s) => [s.rawTemplatesLoading], (x) => x], + templates: [ + (s) => [s.rawTemplates, s.filters], + (rawTemplates, { subTemplateId }): HogFunctionTemplateType[] => { + if (!subTemplateId) { + return rawTemplates + } + const templates: HogFunctionTemplateType[] = [] + // We want to pull out the sub templates and return the template but with overrides applied + + rawTemplates.forEach((template) => { + const subTemplate = template.sub_templates?.find((subTemplate) => subTemplate.id === subTemplateId) + + if (subTemplate) { + templates.push({ + ...template, + name: subTemplate.name, + description: subTemplate.description ?? template.description, + }) + } + }) + + return templates + }, + ], + templatesFuse: [ + (s) => [s.templates], + (hogFunctionTemplates): Fuse => { + return new FuseClass(hogFunctionTemplates || [], { + keys: ['name', 'description'], + threshold: 0.3, + }) + }, + ], + + filteredTemplates: [ + (s) => [s.filters, s.templates, s.templatesFuse], + (filters, templates, templatesFuse): HogFunctionTemplateType[] => { + const { search } = filters + + return search ? templatesFuse.search(search).map((x) => x.item) : templates + }, + ], + + canEnableHogFunction: [ + (s) => [s.canEnableNewDestinations], + (canEnableNewDestinations): ((template: HogFunctionTemplateType) => boolean) => { + return (template: HogFunctionTemplateType) => { + return template?.status === 'free' || canEnableNewDestinations + } + }, + ], + + urlForTemplate: [ + (s) => [s.filters], + (filters): ((template: HogFunctionTemplateType) => string) => { + return (template: HogFunctionTemplateType) => { + // Add the filters to the url and the template id + const subTemplateId = filters.subTemplateId + + return combineUrl( + urls.pipelineNodeNew(PipelineStage.Destination, `hog-${template.id}`), + { + sub_template: subTemplateId, + }, + { + configuration: { + filters: filters.filters, + }, + } + ).url + } + }, + ], + }), + + actionToUrl(({ props, values }) => { + if (!props.syncFiltersWithUrl) { + return {} + } + const urlFromFilters = (): [ + string, + Record, + Record, + { + replace: boolean + } + ] => [ + router.values.location.pathname, + + values.filters, + router.values.hashParams, + { + replace: true, + }, + ] + + return { + setFilters: () => urlFromFilters(), + resetFilters: () => urlFromFilters(), + } + }), + + urlToAction(({ props, actions, values }) => ({ + '*': (_, searchParams) => { + if (!props.syncFiltersWithUrl) { + return + } + + if (!objectsEqual(values.filters, searchParams)) { + actions.setFilters(searchParams) + } + }, + })), +]) diff --git a/frontend/src/scenes/pipeline/PipelineNodeMetricsV2.tsx b/frontend/src/scenes/pipeline/metrics/AppMetricsV2.tsx similarity index 93% rename from frontend/src/scenes/pipeline/PipelineNodeMetricsV2.tsx rename to frontend/src/scenes/pipeline/metrics/AppMetricsV2.tsx index 7d16f4ea74329..a300f7241bdf4 100644 --- a/frontend/src/scenes/pipeline/PipelineNodeMetricsV2.tsx +++ b/frontend/src/scenes/pipeline/metrics/AppMetricsV2.tsx @@ -8,9 +8,7 @@ import { humanFriendlyNumber, inStorybookTestRunner } from 'lib/utils' import { useEffect, useRef, useState } from 'react' import { InsightTooltip } from 'scenes/insights/InsightTooltip/InsightTooltip' -import { pipelineNodeLogic } from './pipelineNodeLogic' -import { pipelineNodeMetricsV2Logic } from './pipelineNodeMetricsV2Logic' -import { PipelineBackend } from './types' +import { appMetricsV2Logic, AppMetricsV2LogicProps } from './appMetricsV2Logic' const METRICS_INFO = { succeeded: 'Total number of events processed successfully', @@ -22,10 +20,8 @@ const METRICS_INFO = { 'Total number of events that were skipped due to the destination being permanently disabled (due to prolonged issues with the destination)', } -export function PipelineNodeMetricsV2(): JSX.Element { - const { node } = useValues(pipelineNodeLogic) - - const logic = pipelineNodeMetricsV2Logic({ id: `${node.id}` }) +export function AppMetricsV2({ id }: AppMetricsV2LogicProps): JSX.Element { + const logic = appMetricsV2Logic({ id }) const { filters } = useValues(logic) const { setFilters, loadMetrics, loadMetricsTotals } = useActions(logic) @@ -35,12 +31,8 @@ export function PipelineNodeMetricsV2(): JSX.Element { loadMetricsTotals() }, []) - if (node.backend !== PipelineBackend.HogFunction) { - return
Metrics not available for this node
- } - return ( - +
@@ -96,7 +88,7 @@ function AppMetricBigNumber({ } function AppMetricsTotals(): JSX.Element { - const { appMetricsTotals, appMetricsTotalsLoading } = useValues(pipelineNodeMetricsV2Logic) + const { appMetricsTotals, appMetricsTotalsLoading } = useValues(appMetricsV2Logic) return (
@@ -116,7 +108,7 @@ function AppMetricsTotals(): JSX.Element { } function AppMetricsGraph(): JSX.Element { - const { appMetrics, appMetricsLoading } = useValues(pipelineNodeMetricsV2Logic) + const { appMetrics, appMetricsLoading } = useValues(appMetricsV2Logic) const canvasRef = useRef(null) const [popoverContent, setPopoverContent] = useState(null) const [tooltipState, setTooltipState] = useState({ x: 0, y: 0, visible: false }) diff --git a/frontend/src/scenes/pipeline/metrics/AppMetricsV2Sparkline.tsx b/frontend/src/scenes/pipeline/metrics/AppMetricsV2Sparkline.tsx new file mode 100644 index 0000000000000..50e3466861b8b --- /dev/null +++ b/frontend/src/scenes/pipeline/metrics/AppMetricsV2Sparkline.tsx @@ -0,0 +1,38 @@ +import { useActions, useValues } from 'kea' +import { Sparkline, SparklineTimeSeries } from 'lib/components/Sparkline' +import { useEffect } from 'react' + +import { appMetricsV2Logic, AppMetricsV2LogicProps } from './appMetricsV2Logic' + +export function AppMetricSparkLineV2({ id }: AppMetricsV2LogicProps): JSX.Element { + const logic = appMetricsV2Logic({ id }) + const { appMetrics, appMetricsLoading } = useValues(logic) + const { loadMetrics } = useActions(logic) + + useEffect(() => { + loadMetrics() + }, []) + + const displayData: SparklineTimeSeries[] = [ + { + color: 'success', + name: 'Success', + values: appMetrics?.series.find((s) => s.name === 'succeeded')?.values || [], + }, + { + color: 'danger', + name: 'Failures', + values: appMetrics?.series.find((s) => s.name === 'failed')?.values || [], + }, + ] + + return ( + + ) +} diff --git a/frontend/src/scenes/pipeline/pipelineNodeMetricsV2Logic.tsx b/frontend/src/scenes/pipeline/metrics/appMetricsV2Logic.tsx similarity index 86% rename from frontend/src/scenes/pipeline/pipelineNodeMetricsV2Logic.tsx rename to frontend/src/scenes/pipeline/metrics/appMetricsV2Logic.tsx index 2564a6562ed12..7a9a1a97c7a84 100644 --- a/frontend/src/scenes/pipeline/pipelineNodeMetricsV2Logic.tsx +++ b/frontend/src/scenes/pipeline/metrics/appMetricsV2Logic.tsx @@ -4,9 +4,9 @@ import api from 'lib/api' import { AppMetricsTotalsV2Response, AppMetricsV2RequestParams, AppMetricsV2Response } from '~/types' -import type { pipelineNodeMetricsV2LogicType } from './pipelineNodeMetricsV2LogicType' +import type { appMetricsV2LogicType } from './appMetricsV2LogicType' -export type PipelineNodeMetricsProps = { +export type AppMetricsV2LogicProps = { id: string } @@ -18,9 +18,9 @@ const DEFAULT_FILTERS: MetricsFilters = { interval: 'day', } -export const pipelineNodeMetricsV2Logic = kea([ - props({} as PipelineNodeMetricsProps), - key(({ id }: PipelineNodeMetricsProps) => id), +export const appMetricsV2Logic = kea([ + props({} as AppMetricsV2LogicProps), + key(({ id }: AppMetricsV2LogicProps) => id), path((id) => ['scenes', 'pipeline', 'appMetricsLogic', id]), actions({ setFilters: (filters: Partial) => ({ filters }), diff --git a/frontend/src/scenes/surveys/SurveyView.tsx b/frontend/src/scenes/surveys/SurveyView.tsx index bb5f4e79280c1..d81d23a1688bb 100644 --- a/frontend/src/scenes/surveys/SurveyView.tsx +++ b/frontend/src/scenes/surveys/SurveyView.tsx @@ -8,11 +8,13 @@ import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' import { EditableField } from 'lib/components/EditableField/EditableField' import { PageHeader } from 'lib/components/PageHeader' import { dayjs } from 'lib/dayjs' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { capitalizeFirstLetter, pluralize } from 'lib/utils' import { useEffect, useState } from 'react' +import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions' import { Query } from '~/queries/Query/Query' import { NodeKind } from '~/queries/schema' @@ -48,6 +50,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element { const { deleteSurvey } = useActions(surveysLogic) const [tabKey, setTabKey] = useState(survey.start_date ? 'results' : 'overview') + const showLinkedHogFunctions = useFeatureFlag('HOG_FUNCTIONS_LINKED') useEffect(() => { if (survey.start_date) { @@ -392,6 +395,43 @@ export function SurveyView({ id }: { id: string }): JSX.Element { key: 'overview', label: 'Overview', }, + showLinkedHogFunctions + ? { + key: 'notifications', + label: 'Notifications', + content: ( +
+

Get notified whenever a survey result is submitted

+ +
+ ), + } + : null, { label: 'History', key: 'History', diff --git a/frontend/src/scenes/surveys/Surveys.tsx b/frontend/src/scenes/surveys/Surveys.tsx index e75e49a838d50..0e4091361f91d 100644 --- a/frontend/src/scenes/surveys/Surveys.tsx +++ b/frontend/src/scenes/surveys/Surveys.tsx @@ -19,6 +19,7 @@ import { PageHeader } from 'lib/components/PageHeader' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' import { VersionCheckerBanner } from 'lib/components/VersionChecker/VersionCheckerBanner' import { dayjs } from 'lib/dayjs' +import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonTableColumn } from 'lib/lemon-ui/LemonTable' @@ -26,29 +27,22 @@ import { createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/column import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import stringWithWBR from 'lib/utils/stringWithWBR' -import { useState } from 'react' +import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' -import { ActivityScope, ProductKey, ProgressStatus, Survey } from '~/types' +import { ActivityScope, ProductKey, ProgressStatus, PropertyFilterType, PropertyOperator, Survey } from '~/types' import { SurveyQuestionLabel } from './constants' import { openSurveysSettingsDialog } from './SurveySettings' -import { getSurveyStatus, surveysLogic } from './surveysLogic' +import { getSurveyStatus, surveysLogic, SurveysTabs } from './surveysLogic' export const scene: SceneExport = { component: Surveys, logic: surveysLogic, } -export enum SurveysTabs { - Active = 'active', - Yours = 'yours', - Archived = 'archived', - History = 'history', -} - export function Surveys(): JSX.Element { const { surveys, @@ -59,14 +53,13 @@ export function Surveys(): JSX.Element { searchTerm, filters, showSurveysDisabledBanner, + tab, } = useValues(surveysLogic) - const { deleteSurvey, updateSurvey, setSearchTerm, setSurveysFilters } = useActions(surveysLogic) - + const { deleteSurvey, updateSurvey, setSearchTerm, setSurveysFilters, setTab } = useActions(surveysLogic) const { user } = useValues(userLogic) - - const [tab, setSurveyTab] = useState(filters.archived ? SurveysTabs.Archived : SurveysTabs.Active) const shouldShowEmptyState = !surveysLoading && surveys.length === 0 + const showLinkedHogFunctions = useFeatureFlag('HOG_FUNCTIONS_LINKED') return (
@@ -112,19 +105,41 @@ export function Surveys(): JSX.Element { /> { - setSurveyTab(newTab) - setSurveysFilters({ ...filters, archived: newTab === SurveysTabs.Archived }) - }} + onChange={(newTab) => setTab(newTab as SurveysTabs)} tabs={[ { key: SurveysTabs.Active, label: 'Active' }, { key: SurveysTabs.Archived, label: 'Archived' }, + showLinkedHogFunctions ? { key: SurveysTabs.Notifications, label: 'Notifications' } : null, { key: SurveysTabs.History, label: 'History' }, ]} /> {tab === SurveysTabs.History ? ( + ) : tab === SurveysTabs.Notifications ? ( + <> +

Get notified whenever a survey result is submitted

+ + ) : ( <>
diff --git a/frontend/src/scenes/surveys/surveysLogic.tsx b/frontend/src/scenes/surveys/surveysLogic.tsx index a817fcdc9bb7b..806379d9e441a 100644 --- a/frontend/src/scenes/surveys/surveysLogic.tsx +++ b/frontend/src/scenes/surveys/surveysLogic.tsx @@ -2,7 +2,7 @@ import { lemonToast } from '@posthog/lemon-ui' import Fuse from 'fuse.js' import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' -import { router } from 'kea-router' +import { actionToUrl, router, urlToAction } from 'kea-router' import api from 'lib/api' import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' @@ -13,6 +13,14 @@ import { AvailableFeature, Breadcrumb, ProgressStatus, Survey, SurveyType } from import type { surveysLogicType } from './surveysLogicType' +export enum SurveysTabs { + Active = 'active', + Yours = 'yours', + Archived = 'archived', + Notifications = 'notifications', + History = 'history', +} + export function getSurveyStatus(survey: Survey): ProgressStatus { if (!survey.start_date) { return ProgressStatus.Draft @@ -37,6 +45,7 @@ export const surveysLogic = kea([ actions({ setSearchTerm: (searchTerm: string) => ({ searchTerm }), setSurveysFilters: (filters: Partial, replace?: boolean) => ({ filters, replace }), + setTab: (tab: SurveysTabs) => ({ tab }), }), loaders(({ values }) => ({ surveys: { @@ -63,6 +72,12 @@ export const surveysLogic = kea([ }, })), reducers({ + tab: [ + SurveysTabs.Active as SurveysTabs, + { + setTab: (_, { tab }) => tab, + }, + ], searchTerm: { setSearchTerm: (_, { searchTerm }) => searchTerm, }, @@ -79,7 +94,7 @@ export const surveysLogic = kea([ }, ], }), - listeners(({ actions }) => ({ + listeners(({ actions, values }) => ({ deleteSurveySuccess: () => { lemonToast.success('Survey deleted') router.actions.push(urls.surveys()) @@ -95,6 +110,9 @@ export const surveysLogic = kea([ loadSurveysSuccess: () => { actions.loadCurrentTeam() }, + setTab: ({ tab }) => { + actions.setSurveysFilters({ ...values.filters, archived: tab === SurveysTabs.Archived }) + }, })), selectors({ searchedSurveys: [ @@ -179,6 +197,18 @@ export const surveysLogic = kea([ }, ], }), + actionToUrl(({ values }) => ({ + setTab: () => { + return [router.values.location.pathname, { ...router.values.searchParams, tab: values.tab }] + }, + })), + urlToAction(({ actions }) => ({ + [urls.surveys()]: (_, { tab }) => { + if (tab) { + actions.setTab(tab) + } + }, + })), afterMount(({ actions }) => { actions.loadSurveys() actions.loadResponsesCount() diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index f1b2ad553a24f..f12b5017a9014 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -21,6 +21,7 @@ import { import { OnboardingStepKey } from './onboarding/onboardingLogic' import { SettingId, SettingLevelId, SettingSectionId } from './settings/types' +import { SurveysTabs } from './surveys/surveysLogic' /** * To add a new URL to the front end: @@ -137,7 +138,7 @@ export const urls = { errorTracking: (): string => '/error_tracking', errorTrackingGroup: (fingerprint: string): string => `/error_tracking/${fingerprint === ':fingerprint' ? fingerprint : encodeURIComponent(fingerprint)}`, - surveys: (): string => '/surveys', + surveys: (tab?: SurveysTabs): string => `/surveys${tab ? `?tab=${tab}` : ''}`, /** @param id A UUID or 'new'. ':id' for routing. */ survey: (id: string): string => `/surveys/${id}`, surveyTemplates: (): string => '/survey_templates', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d66b0beb1e788..7a8fddeded785 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4332,9 +4332,9 @@ export type HogFunctionMasking = { // subset of EntityFilter export interface HogFunctionFilterBase { id: string - name: string | null - order: number - properties: (EventPropertyFilter | PersonPropertyFilter | ElementPropertyFilter)[] + name?: string | null + order?: number + properties?: (EventPropertyFilter | PersonPropertyFilter | ElementPropertyFilter)[] } export interface HogFunctionFilterEvents extends HogFunctionFilterBase { @@ -4375,27 +4375,36 @@ export type HogFunctionType = { hog: string inputs_schema?: HogFunctionInputSchemaType[] - inputs?: Record + inputs?: Record | null masking?: HogFunctionMasking | null filters?: HogFunctionFiltersType | null template?: HogFunctionTemplateType status?: HogFunctionStatus } +export type HogFunctionTemplateStatus = 'alpha' | 'beta' | 'stable' | 'free' | 'deprecated' +export type HogFunctionSubTemplateIdType = 'early_access_feature_enrollment' | 'survey_response' + export type HogFunctionConfigurationType = Omit< HogFunctionType, - 'created_at' | 'created_by' | 'updated_at' | 'status' | 'hog' + 'id' | 'created_at' | 'created_by' | 'updated_at' | 'status' | 'hog' > & { hog?: HogFunctionType['hog'] // In the config it can be empty if using a template + sub_template_id?: HogFunctionSubTemplateIdType } -export type HogFunctionTemplateStatus = 'alpha' | 'beta' | 'stable' | 'free' | 'deprecated' +export type HogFunctionSubTemplateType = Pick & { + id: HogFunctionSubTemplateIdType + name: string + description: string | null +} export type HogFunctionTemplateType = Pick< HogFunctionType, - 'id' | 'name' | 'description' | 'hog' | 'inputs_schema' | 'filters' | 'icon_url' + 'id' | 'name' | 'description' | 'hog' | 'inputs_schema' | 'filters' | 'icon_url' | 'masking' > & { status: HogFunctionTemplateStatus + sub_templates?: HogFunctionSubTemplateType[] } export type HogFunctionIconResponse = { diff --git a/posthog/api/hog_function_template.py b/posthog/api/hog_function_template.py index 6d04fa69ced6c..c2becf5ab4afe 100644 --- a/posthog/api/hog_function_template.py +++ b/posthog/api/hog_function_template.py @@ -7,7 +7,7 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.cdp.templates import HOG_FUNCTION_TEMPLATES -from posthog.cdp.templates.hog_function_template import HogFunctionTemplate +from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate from posthog.models.hog_functions.hog_function import HogFunction from posthog.permissions import PostHogFeatureFlagPermission from rest_framework_dataclasses.serializers import DataclassSerializer @@ -16,7 +16,14 @@ logger = structlog.get_logger(__name__) +class HogFunctionSubTemplateSerializer(DataclassSerializer): + class Meta: + dataclass = HogFunctionSubTemplate + + class HogFunctionTemplateSerializer(DataclassSerializer): + sub_templates = HogFunctionSubTemplateSerializer(many=True, required=False) + class Meta: dataclass = HogFunctionTemplate diff --git a/posthog/api/test/test_hog_function.py b/posthog/api/test/test_hog_function.py index f62a96714a311..1fce31f85e6b9 100644 --- a/posthog/api/test/test_hog_function.py +++ b/posthog/api/test/test_hog_function.py @@ -171,6 +171,8 @@ def test_creates_with_template_id(self, *args): "inputs_schema": template_webhook.inputs_schema, "hog": template_webhook.hog, "filters": None, + "masking": None, + "sub_templates": response.json()["template"]["sub_templates"], } def test_deletes_via_update(self, *args): diff --git a/posthog/cdp/templates/hog_function_template.py b/posthog/cdp/templates/hog_function_template.py index 9bfc7faf7cb13..ba7ee16e34027 100644 --- a/posthog/cdp/templates/hog_function_template.py +++ b/posthog/cdp/templates/hog_function_template.py @@ -1,5 +1,6 @@ import dataclasses -from typing import Literal, Optional, TYPE_CHECKING +from typing import Literal, Optional, get_args, TYPE_CHECKING + if TYPE_CHECKING: from posthog.models.plugin import PluginConfig @@ -7,6 +8,21 @@ PluginConfig = None +SubTemplateId = Literal["early_access_feature_enrollment", "survey_response"] + +SUB_TEMPLATE_ID: tuple[SubTemplateId, ...] = get_args(SubTemplateId) + + +@dataclasses.dataclass(frozen=True) +class HogFunctionSubTemplate: + id: SubTemplateId + name: str + description: Optional[str] = None + filters: Optional[dict] = None + masking: Optional[dict] = None + inputs: Optional[dict] = None + + @dataclasses.dataclass(frozen=True) class HogFunctionTemplate: status: Literal["alpha", "beta", "stable", "free"] @@ -15,7 +31,9 @@ class HogFunctionTemplate: description: str hog: str inputs_schema: list[dict] + sub_templates: Optional[list[HogFunctionSubTemplate]] = None filters: Optional[dict] = None + masking: Optional[dict] = None icon_url: Optional[str] = None @@ -26,3 +44,32 @@ class HogFunctionTemplateMigrator: def migrate(cls, obj: PluginConfig) -> dict: # Return a dict for the template of a new HogFunction raise NotImplementedError() + + +SUB_TEMPLATE_COMMON: dict[SubTemplateId, HogFunctionSubTemplate] = { + "survey_response": HogFunctionSubTemplate( + id="survey_response", + name="Survey Response", + filters={ + "events": [ + { + "id": "survey sent", + "type": "events", + "properties": [ + { + "key": "$survey_response", + "type": "event", + "value": "is_set", + "operator": "is_set", + }, + ], + } + ] + }, + ), + "early_access_feature_enrollment": HogFunctionSubTemplate( + id="early_access_feature_enrollment", + name="Early Access Feature Enrollment", + filters={"events": [{"id": "$feature_enrollment_update", "type": "events"}]}, + ), +} diff --git a/posthog/cdp/templates/slack/template_slack.py b/posthog/cdp/templates/slack/template_slack.py index dce9872bc3b89..68d4f93274090 100644 --- a/posthog/cdp/templates/slack/template_slack.py +++ b/posthog/cdp/templates/slack/template_slack.py @@ -1,4 +1,4 @@ -from posthog.cdp.templates.hog_function_template import HogFunctionTemplate +from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON template: HogFunctionTemplate = HogFunctionTemplate( status="free", @@ -102,4 +102,68 @@ "required": False, }, ], + sub_templates=[ + HogFunctionSubTemplate( + id="early_access_feature_enrollment", + name="Post to Slack on feature enrollment", + description="Posts a message to Slack when a user enrolls or un-enrolls in an early access feature", + filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters, + inputs={ + "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", + "blocks": [ + { + "text": { + "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", + "type": "mrkdwn", + }, + "type": "section", + }, + { + "type": "actions", + "elements": [ + { + "url": "{person.url}", + "text": {"text": "View Person in PostHog", "type": "plain_text"}, + "type": "button", + }, + # NOTE: It would be nice to have a link to the EAF but the event needs more info + ], + }, + ], + }, + ), + HogFunctionSubTemplate( + id="survey_response", + name="Post to Slack on survey response", + description="Posts a message to Slack when a user responds to a survey", + filters=SUB_TEMPLATE_COMMON["survey_response"].filters, + inputs={ + "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*", + "blocks": [ + { + "text": { + "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*", + "type": "mrkdwn", + }, + "type": "section", + }, + { + "type": "actions", + "elements": [ + { + "url": "{project.url}/surveys/{event.properties.$survey_id}", + "text": {"text": "View Survey", "type": "plain_text"}, + "type": "button", + }, + { + "url": "{person.url}", + "text": {"text": "View Person", "type": "plain_text"}, + "type": "button", + }, + ], + }, + ], + }, + ), + ], ) diff --git a/posthog/cdp/templates/webhook/template_webhook.py b/posthog/cdp/templates/webhook/template_webhook.py index b99e7f9f31f79..cf2acd665619a 100644 --- a/posthog/cdp/templates/webhook/template_webhook.py +++ b/posthog/cdp/templates/webhook/template_webhook.py @@ -1,4 +1,4 @@ -from posthog.cdp.templates.hog_function_template import HogFunctionTemplate +from posthog.cdp.templates.hog_function_template import SUB_TEMPLATE_COMMON, HogFunctionSubTemplate, HogFunctionTemplate template: HogFunctionTemplate = HogFunctionTemplate( @@ -82,4 +82,16 @@ "default": False, }, ], + sub_templates=[ + HogFunctionSubTemplate( + id="early_access_feature_enrollment", + name="HTTP Webhook on feature enrollment", + filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters, + ), + HogFunctionSubTemplate( + id="survey_response", + name="HTTP Webhook on survey response", + filters=SUB_TEMPLATE_COMMON["survey_response"].filters, + ), + ], )