diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--dark.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--dark.png index 08dcde4f1ce..86ea29fa85b 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--dark.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--light.png index 22f82c6a410..c29c5a177d3 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--dark.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--dark.png index 47db1464b14..41a40228dd4 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--dark.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--light.png index 1ab35d99bb0..ba78ee04e44 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-configuration-empty--light.png differ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3b49c3be4f0..306bd61ea94 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1490,6 +1490,9 @@ const api = { async update(id: PluginConfigTypeNew['id'], data: FormData): Promise { return await new ApiRequest().pluginConfig(id).update({ data }) }, + async create(data: FormData): Promise { + return await new ApiRequest().pluginConfigs().create({ data }) + }, async list(): Promise> { return await new ApiRequest().pluginConfigs().get() }, diff --git a/frontend/src/scenes/pipeline/PipelineNode.tsx b/frontend/src/scenes/pipeline/PipelineNode.tsx index f607b9e2742..91316bc4694 100644 --- a/frontend/src/scenes/pipeline/PipelineNode.tsx +++ b/frontend/src/scenes/pipeline/PipelineNode.tsx @@ -1,3 +1,4 @@ +import { Spinner } from '@posthog/lemon-ui' import { useValues } from 'kea' import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' import { NotFound } from 'lib/components/NotFound' @@ -59,7 +60,16 @@ export function PipelineNode(params: { stage?: string; id?: string } = {}): JSX. return } - if (!nodeLoading && !node) { + if (nodeLoading) { + return + } + + if (id === 'new') { + // If it's new we don't want to show any tabs + return + } + + if (!node) { return } diff --git a/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx b/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx index af05827374f..9ca9b3bc5ca 100644 --- a/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx +++ b/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx @@ -1,7 +1,8 @@ import { IconLock } from '@posthog/icons' -import { LemonButton, LemonSkeleton, Tooltip } from '@posthog/lemon-ui' +import { LemonButton, LemonInput, LemonSelect, LemonSkeleton, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { Form } from 'kea-forms' +import { NotFound } from 'lib/components/NotFound' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' import React from 'react' @@ -10,17 +11,57 @@ import { BatchExportConfigurationForm } from 'scenes/batch_exports/batchExportEd import { getConfigSchemaArray, isValidField } from 'scenes/pipeline/configUtils' import { PluginField } from 'scenes/plugins/edit/PluginField' +import { PluginType } from '~/types' + import { pipelineNodeLogic } from './pipelineNodeLogic' -import { PipelineBackend, PipelineNode } from './types' export function PipelineNodeConfiguration(): JSX.Element { - const { node, savedConfiguration, configuration, isConfigurationSubmitting, isConfigurable } = - useValues(pipelineNodeLogic) - const { resetConfiguration, submitConfiguration } = useActions(pipelineNodeLogic) + const { + stage, + node, + savedConfiguration, + configuration, + isConfigurationSubmitting, + isConfigurable, + newConfigurationPlugins, + newConfigurationBatchExports, + newConfigurationServiceOrPluginID, + isNew, + maybeNodePlugin, + } = useValues(pipelineNodeLogic) + const { resetConfiguration, submitConfiguration, setNewConfigurationServiceOrPluginID } = + useActions(pipelineNodeLogic) + + let selector = <> + + if (isNew) { + if (!stage) { + return + } + const pluginsOptions = Object.values(newConfigurationPlugins).map((plugin) => ({ + value: plugin.id, + label: plugin.name, // TODO: Ideally this would show RenderApp or MinimalAppView + })) + const batchExportsOptions = Object.entries(newConfigurationBatchExports).map(([key, name]) => ({ + value: key, + label: name, // TODO: same render with a picture ? + })) + + selector = ( + { + setNewConfigurationServiceOrPluginID(newValue) // TODO: this should change the URL so we can link new specific plugin/batch export + }} + options={[...pluginsOptions, ...batchExportsOptions]} + /> + ) + } return (
- {!node ? ( + {selector} + {!node && !newConfigurationServiceOrPluginID ? ( Array(2) .fill(null) .map((_, index) => ( @@ -29,13 +70,29 @@ export function PipelineNodeConfiguration(): JSX.Element {
)) - ) : isConfigurable ? ( + ) : ( <>
- {node.backend === PipelineBackend.Plugin ? ( - + + + + + + + {!isConfigurable ? ( + This {stage} isn't configurable. + ) : maybeNodePlugin ? ( + ) : ( - + )}
resetConfiguration(savedConfiguration || {})} disabledReason={isConfigurationSubmitting ? 'Saving in progress…' : undefined} > - Cancel + {isNew ? 'Reset' : 'Cancel'} - Save + {isNew ? 'Create' : 'Save'}
- ) : ( - This {node.stage} isn't configurable. )} ) } -function PluginConfigurationFields({ - node, -}: { - node: PipelineNode & { backend: PipelineBackend.Plugin } - formValues: Record -}): JSX.Element { +function PluginConfigurationFields({ plugin }: { plugin: PluginType; formValues: Record }): JSX.Element { const { hiddenFields, requiredFields } = useValues(pipelineNodeLogic) - const configSchemaArray = getConfigSchemaArray(node.plugin.config_schema) + const configSchemaArray = getConfigSchemaArray(plugin.config_schema) const fields = configSchemaArray.map((fieldConfig, index) => ( {fieldConfig.key && @@ -116,14 +166,15 @@ function PluginConfigurationFields({ } function BatchExportConfigurationFields({ + isNew, formValues, }: { - node: PipelineNode & { backend: PipelineBackend.BatchExport } + isNew: boolean formValues: Record }): JSX.Element { return ( diff --git a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx index 74e00828465..e802dc5ebfe 100644 --- a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx +++ b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx @@ -1,4 +1,4 @@ -import { actions, afterMount, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { forms } from 'kea-forms' import { loaders } from 'kea-loaders' import { actionToUrl, urlToAction } from 'kea-router' @@ -6,9 +6,10 @@ import api from 'lib/api' import { capitalizeFirstLetter } from 'lib/utils' import { batchExportFormFields } from 'scenes/batch_exports/batchExportEditLogic' import { Scene } from 'scenes/sceneTypes' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' -import { Breadcrumb, PipelineNodeTab, PipelineStage } from '~/types' +import { Breadcrumb, PipelineNodeTab, PipelineStage, PluginType } from '~/types' import { defaultConfigForPlugin, @@ -17,7 +18,11 @@ import { getConfigSchemaArray, getPluginConfigFormData, } from './configUtils' +import { pipelineDestinationsLogic } from './destinationsLogic' +import { frontendAppsLogic } from './frontendAppsLogic' +import { importAppsLogic } from './importAppsLogic' import type { pipelineNodeLogicType } from './pipelineNodeLogicType' +import { pipelineTransformationsLogic } from './transformationsLogic' import { BatchExportBasedStep, convertToPipelineNode, @@ -42,35 +47,57 @@ export const pipelineNodeLogic = kea([ props({} as PipelineNodeLogicProps), key(({ id }) => id), path((id) => ['scenes', 'pipeline', 'pipelineNodeLogic', id]), + connect(() => ({ + values: [ + teamLogic, + ['currentTeamId'], + pipelineDestinationsLogic, + ['plugins as destinationPlugins'], + pipelineTransformationsLogic, + ['plugins as transformationPlugins'], + frontendAppsLogic, + ['plugins as frontendAppsPlugins'], + importAppsLogic, + ['plugins as importAppsPlugins'], + ], + })), actions({ setCurrentTab: (tab: PipelineNodeTab = PipelineNodeTab.Configuration) => ({ tab }), loadNode: true, updateNode: (payload: PluginUpdatePayload | BatchExportUpdatePayload) => ({ payload, }), + createNode: (payload: PluginUpdatePayload | BatchExportUpdatePayload) => ({ + payload, + }), + setNewConfigurationServiceOrPluginID: (id: number | string | null) => ({ id }), }), - reducers({ + reducers(() => ({ currentTab: [ PipelineNodeTab.Configuration as PipelineNodeTab, { setCurrentTab: (_, { tab }) => tab, }, ], - }), + newConfigurationServiceOrPluginID: [ + // TODO: this doesn't clear properly if I exit out of the page and more importantly switching to a different stage + null as null | number | string, + { + setNewConfigurationServiceOrPluginID: (_, { id }) => id, + }, + ], + })), loaders(({ props, values }) => ({ node: [ null as PipelineNode | null, { loadNode: async (_, breakpoint) => { - if (!props.stage) { + if (!props.stage || props.id === 'new') { return null } let node: PipelineNode | null = null try { if (typeof props.id === 'string') { - if (props.stage !== PipelineStage.Destination) { - return null - } const batchExport = await api.batchExports.get(props.id) node = convertToPipelineNode(batchExport, props.stage) } else { @@ -85,6 +112,33 @@ export const pipelineNodeLogic = kea([ breakpoint() return node }, + createNode: async ({ payload }) => { + if (!values.newConfigurationServiceOrPluginID || !values.stage) { + return null + } + if (values.nodeBackend === PipelineBackend.BatchExport) { + payload = payload as BatchExportUpdatePayload + const batchExport = await api.batchExports.create({ + paused: !payload.enabled, + name: payload.name, + interval: payload.interval, + destination: payload.service, + }) + return convertToPipelineNode(batchExport, values.stage) + } else if (values.maybeNodePlugin) { + payload = payload as PluginUpdatePayload + const formdata = getPluginConfigFormData( + values.maybeNodePlugin.config_schema, + defaultConfigForPlugin(values.maybeNodePlugin), + { ...payload, enabled: true } // Default enable on creation + ) + formdata.append('plugin', values.maybeNodePlugin.id.toString()) + formdata.append('order', '99') // TODO: fix this should be at the end of latest here for transformations + const pluginConfig = await api.pluginConfigs.create(formdata) + return convertToPipelineNode(pluginConfig, values.stage) + } + return null + }, updateNode: async ({ payload }) => { if (!values.node) { return null @@ -112,7 +166,7 @@ export const pipelineNodeLogic = kea([ })), forms(({ props, values, asyncActions }) => ({ configuration: { - defaults: {} as Record, + defaults: { name: '', description: '' } as Record, errors: (form) => { if (values.nodeBackend === PipelineBackend.BatchExport) { return batchExportFormFields(props.id === 'new', form as any, { isPipeline: true }) @@ -126,12 +180,18 @@ export const pipelineNodeLogic = kea([ } }, submit: async (formValues) => { - // @ts-expect-error - Sadly Kea logics can't be generic based on props, so TS complains here - await asyncActions.updateNode(formValues) + if (values.isNew) { + // @ts-expect-error - Sadly Kea logics can't be generic based on props, so TS complains here + return await asyncActions.createNode(formValues) + } else { + // @ts-expect-error - Sadly Kea logics can't be generic based on props, so TS complains here + await asyncActions.updateNode(formValues) + } }, }, })), selectors(() => ({ + isNew: [(_, p) => [p.id], (id): boolean => id === 'new'], breadcrumbs: [ (s, p) => [p.id, p.stage, s.node, s.nodeLoading], (id, stage, node, nodeLoading): Breadcrumb[] => [ @@ -152,12 +212,87 @@ export const pipelineNodeLogic = kea([ ], ], nodeBackend: [ - (_, p) => [p.id], - (id): PipelineBackend => (typeof id === 'string' ? PipelineBackend.BatchExport : PipelineBackend.Plugin), + (s, p) => [s.node, p.id, s.newConfigurationServiceOrPluginID], + (node, id, newConfigurationServiceOrPluginID): PipelineBackend | null => { + if (node) { + return node.backend + } + if (id === 'new') { + if (newConfigurationServiceOrPluginID === null) { + return null + } else if (typeof newConfigurationServiceOrPluginID === 'string') { + return PipelineBackend.BatchExport + } + return PipelineBackend.Plugin + } + if (typeof id === 'string') { + return PipelineBackend.BatchExport + } + return PipelineBackend.Plugin + }, + ], + maybeNodePlugin: [ + (s) => [s.node, s.newConfigurationServiceOrPluginID, s.newConfigurationPlugins], + (node, maybePluginId, plugins): PluginType | null => { + if (node) { + return node.backend === PipelineBackend.Plugin ? node.plugin : null + } + if (typeof maybePluginId === 'number') { + // in case of new config creations + return plugins[maybePluginId] || null + } + return null + }, + ], + newConfigurationBatchExports: [ + (_, p) => [p.stage], + (stage): Record => { + if (stage === PipelineStage.Destination) { + return { + BigQuery: 'BigQuery', + Postgres: 'PostgreSQL', + Redshift: 'Redshift', + S3: 'S3', + Snowflake: 'Snowflake', + } + } + return {} + }, + ], + newConfigurationPlugins: [ + (s, p) => [ + p.stage, + s.destinationPlugins, + s.transformationPlugins, + s.frontendAppsPlugins, + s.importAppsPlugins, + ], + ( + stage, + destinationPlugins, + transformationPlugins, + frontendAppsPlugins, + importAppsPlugins + ): Record => { + if (stage === PipelineStage.Transformation) { + return transformationPlugins + } else if (stage === PipelineStage.Destination) { + return destinationPlugins + } else if (stage === PipelineStage.SiteApp) { + return frontendAppsPlugins + } else if (stage === PipelineStage.ImportApp) { + return importAppsPlugins + } + return {} + }, ], tabs: [ (_, p) => [p.id], (id) => { + if (id === 'new') { + // not used, but just in case + return [PipelineNodeTab.Configuration] + } const tabs = Object.values(PipelineNodeTab) if (typeof id === 'string') { // Batch export @@ -167,36 +302,41 @@ export const pipelineNodeLogic = kea([ }, ], savedConfiguration: [ - (s) => [s.node], - (node): Record | null => - node - ? node.backend === PipelineBackend.Plugin + (s) => [s.node, s.maybeNodePlugin], + (node, maybeNodePlugin): Record | null => { + if (node) { + return node.backend === PipelineBackend.Plugin ? node.config || defaultConfigForPlugin(node.plugin) : { interval: node.interval, destination: node.service.type, ...node.service.config } - : null, + } + if (maybeNodePlugin) { + return defaultConfigForPlugin(maybeNodePlugin) + } + return null + }, ], hiddenFields: [ - (s) => [s.node, s.configuration], - (node, configuration): string[] => { - if (node?.backend === PipelineBackend.Plugin) { - return determineInvisibleFields((fieldName) => configuration[fieldName], node.plugin) + (s) => [s.maybeNodePlugin, s.configuration], + (maybeNodePlugin, configuration): string[] => { + if (maybeNodePlugin) { + return determineInvisibleFields((fieldName) => configuration[fieldName], maybeNodePlugin) } return [] }, ], requiredFields: [ - (s) => [s.node, s.configuration], - (node, configuration): string[] => { - if (node?.backend === PipelineBackend.Plugin) { - return determineRequiredFields((fieldName) => configuration[fieldName], node.plugin) + (s) => [s.maybeNodePlugin, s.configuration], + (maybeNodePlugin, configuration): string[] => { + if (maybeNodePlugin) { + return determineRequiredFields((fieldName) => configuration[fieldName], maybeNodePlugin) } return [] }, ], isConfigurable: [ - (s) => [s.node], - (node): boolean => - node?.backend === PipelineBackend.Plugin && getConfigSchemaArray(node.plugin.config_schema).length > 0, + (s) => [s.maybeNodePlugin], + (maybeNodePlugin): boolean => + !maybeNodePlugin || getConfigSchemaArray(maybeNodePlugin.config_schema).length > 0, ], id: [(_, p) => [p.id], (id) => id], stage: [(_, p) => [p.stage], (stage) => stage], @@ -206,6 +346,9 @@ export const pipelineNodeLogic = kea([ actions.resetConfiguration(values.savedConfiguration || {}) // TODO: Update entry in the relevant list logic }, + setNewSelected: () => { + actions.resetConfiguration({}) // If the user switches to a different plugin/batch export, then clear the form + }, setConfigurationValue: async ({ name, value }) => { if (name[0] === 'json_config_file' && value) { try { @@ -244,7 +387,9 @@ export const pipelineNodeLogic = kea([ } }, })), - afterMount(({ actions }) => { - actions.loadNode() + afterMount(({ values, actions }) => { + if (!values.isNew) { + actions.loadNode() + } }), ]) diff --git a/posthog/api/plugin.py b/posthog/api/plugin.py index 55ab950af29..468da9d5ccf 100644 --- a/posthog/api/plugin.py +++ b/posthog/api/plugin.py @@ -646,11 +646,15 @@ class PluginConfigSerializer(serializers.ModelSerializer): raise ValidationError("Plugin configuration is not available for the current organization!") validated_data["team_id"] = self.context["team_id"] _fix_formdata_config_json(self.context["request"], validated_data) - existing_config = PluginConfig.objects.filter( - team_id=validated_data["team_id"], plugin_id=validated_data["plugin"] - ) - if existing_config.exists(): - return self.update(existing_config.first(), validated_data) # type: ignore + # Legacy pipeline UI doesn't show multiple plugin configs per plugin, so we don't allow it + # pipeline 3000 UI does, but to keep things simple we for now pass this flag to not break old users + # name field is something that only the new UI sends + if "config" not in validated_data or "name" not in validated_data["config"]: + existing_config = PluginConfig.objects.filter( + team_id=validated_data["team_id"], plugin_id=validated_data["plugin"] + ) + if existing_config.exists(): + return self.update(existing_config.first(), validated_data) # type: ignore validated_data["web_token"] = generate_random_token() plugin_config = super().create(validated_data)