From 8418420305e4d7e9783c658820c2f14afd70a4b2 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:45:53 +0000 Subject: [PATCH] feat(workflows): offer a switch from the sandbox sender Co-Authored-By: Claude Opus 5.5 --- .../WorkflowSandboxSwitchBanner.stories.tsx | 159 ++++++++ .../Workflows/WorkflowSandboxSwitchBanner.tsx | 36 ++ .../WorkflowSandboxSwitchSenderButton.tsx | 43 ++ .../frontend/Workflows/WorkflowScene.tsx | 2 + .../workflowSandboxSwitchBannerLogic.test.ts | 375 ++++++++++++++++++ .../workflowSandboxSwitchBannerLogic.ts | 236 +++++++++++ 6 files changed, 851 insertions(+) create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx create mode 100644 products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx new file mode 100644 index 000000000000..697302b5664c --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryFn } from '@storybook/react' +import { BindLogic } from 'kea' + +import { FEATURE_FLAGS } from 'lib/constants' + +import { useStorybookMocks } from '~/mocks/browser' +import { AccessControlLevel } from '~/types' + +import type { HogFlow } from './hogflows/types' +import { NEW_WORKFLOW, WorkflowLogicProps, workflowLogic } from './workflowLogic' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' + +const LOGIC_PROPS: WorkflowLogicProps = { id: 'storybook-sandbox-switch-banner' } + +const SANDBOX_SENDER = { + id: 7, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, + created_at: '2026-08-18T00:00:00Z', +} + +const OWN_SENDER = { + id: 8, + kind: 'email', + display_name: 'Acme ', + config: { + provider: 'ses', + name: 'Acme', + email: 'hello@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SECOND_OWN_SENDER = { + id: 9, + kind: 'email', + display_name: 'Acme support ', + config: { + provider: 'ses', + name: 'Acme support', + email: 'support@acme.example.com', + domain: 'acme.example.com', + verified: true, + }, + created_at: '2026-08-18T00:00:00Z', +} + +const SANDBOX_WORKFLOW: HogFlow = { + ...NEW_WORKFLOW, + id: LOGIC_PROPS.id!, + name: 'Welcome email', + user_access_level: AccessControlLevel.Editor, + actions: [ + { + id: 'trigger', + type: 'trigger', + name: 'New account created', + description: '', + config: { type: 'event', filters: {} }, + }, + { + id: 'email', + type: 'function_email', + name: 'Send welcome email', + description: '', + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: '{{ person.properties.email }}', name: '' }, + from: { integrationId: SANDBOX_SENDER.id }, + subject: 'Welcome aboard', + text: 'Your account is ready.', + html: '

Your account is ready.

', + }, + templating: 'liquid', + }, + }, + }, + }, + { id: 'exit', type: 'exit', name: 'Exit', description: '', config: { reason: 'Completed' } }, + ], + edges: [ + { from: 'trigger', to: 'email', type: 'continue' }, + { from: 'email', to: 'exit', type: 'continue' }, + ], +} + +type StoryArgs = { + ownSenders: (typeof OWN_SENDER)[] + narrow?: boolean + constrained?: boolean +} + +const meta: Meta = { + title: 'Products/Workflows/Sandbox switch banner', + parameters: { + layout: 'padded', + viewMode: 'story', + featureFlags: [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER], + testOptions: { waitForSelector: '[data-attr="workflow-sandbox-switch-sender"]' }, + }, +} +export default meta + +const Template: StoryFn = ({ ownSenders, narrow, constrained }): JSX.Element => { + useStorybookMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': SANDBOX_WORKFLOW, + '/api/projects/:team_id/integrations/': { results: [SANDBOX_SENDER, ...ownSenders] }, + '/api/projects/:team_id/hog_function_templates': { count: 0, results: [] }, + '/api/environments/:team_id/messaging_categories': { count: 0, results: [] }, + }, + }) + return ( + +
+
+ + {constrained &&
} +
+
+ + ) +} + +export const OneOwnSender = Template.bind({}) +OneOwnSender.args = { ownSenders: [OWN_SENDER] } + +export const OneOwnSenderNarrow = Template.bind({}) +OneOwnSenderNarrow.args = { ownSenders: [OWN_SENDER], narrow: true } + +export const ConstrainedScene = Template.bind({}) +ConstrainedScene.args = { ownSenders: [OWN_SENDER], constrained: true } + +export const ConstrainedSceneNarrow = Template.bind({}) +ConstrainedSceneNarrow.args = { ownSenders: [OWN_SENDER], constrained: true, narrow: true } + +export const OneOwnSenderLongAddressNarrow = Template.bind({}) +OneOwnSenderLongAddressNarrow.args = { + ownSenders: [ + { + ...OWN_SENDER, + display_name: 'Acme notifications ', + config: { ...OWN_SENDER.config, email: 'notifications-and-billing@mail.acme.example.com' }, + }, + ], + narrow: true, +} + +export const SeveralOwnSenders = Template.bind({}) +SeveralOwnSenders.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER] } + +export const SeveralOwnSendersNarrow = Template.bind({}) +SeveralOwnSendersNarrow.args = { ownSenders: [OWN_SENDER, SECOND_OWN_SENDER], narrow: true } diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx new file mode 100644 index 000000000000..33e260f8b2a3 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchBanner.tsx @@ -0,0 +1,36 @@ +import { useActions, useValues } from 'kea' + +import { LemonBanner } from '@posthog/lemon-ui' + +import { WorkflowLogicProps } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' +import { WorkflowSandboxSwitchSenderButton } from './WorkflowSandboxSwitchSenderButton' + +export function WorkflowSandboxSwitchBanner(props: WorkflowLogicProps): JSX.Element | null { + const logic = workflowSandboxSwitchBannerLogic(props) + const { bannerVisible, verifiedOwnSenders, switchDisabledReason } = useValues(logic) + const { switchToOwnSender } = useActions(logic) + + if (!bannerVisible) { + return null + } + + return ( + +
+ + Your project has a verified email sender. This workflow still sends from the sandbox sender, which + delivers only to verified members of your organization. + + +
+
+ ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx new file mode 100644 index 000000000000..c311249d50e5 --- /dev/null +++ b/products/workflows/frontend/Workflows/WorkflowSandboxSwitchSenderButton.tsx @@ -0,0 +1,43 @@ +import { LemonButton, LemonMenu } from '@posthog/lemon-ui' + +import { IntegrationType } from '~/types' + +export function WorkflowSandboxSwitchSenderButton({ + senders, + disabledReason, + onSwitch, +}: { + senders: IntegrationType[] + disabledReason: string | null + onSwitch: (integrationId: number) => void +}): JSX.Element { + const buttonProps = { + type: 'secondary' as const, + size: 'small' as const, + disabledReason, + 'data-attr': 'workflow-sandbox-switch-sender', + className: 'max-w-full self-start @xl:self-auto', + } + + if (senders.length === 1) { + const label = `Switch to ${senders[0].config.email}` + return ( + onSwitch(senders[0].id)}> + {label} + + ) + } + + return ( + ({ + label: sender.display_name, + onClick: () => onSwitch(sender.id), + 'data-attr': 'workflow-sandbox-switch-sender-option', + }))} + placement="bottom-end" + > + Switch sender + + ) +} diff --git a/products/workflows/frontend/Workflows/WorkflowScene.tsx b/products/workflows/frontend/Workflows/WorkflowScene.tsx index 1bc06a9410ab..eac0622a0c5c 100644 --- a/products/workflows/frontend/Workflows/WorkflowScene.tsx +++ b/products/workflows/frontend/Workflows/WorkflowScene.tsx @@ -51,6 +51,7 @@ import { WorkflowInvocations } from './WorkflowInvocations' import { WorkflowLogicProps, workflowLogic } from './workflowLogic' import { WorkflowMetrics } from './WorkflowMetrics' import { WorkflowRevisions } from './WorkflowRevisions' +import { WorkflowSandboxSwitchBanner } from './WorkflowSandboxSwitchBanner' import { WorkflowSceneHeader } from './WorkflowSceneHeader' import { WorkflowSceneLogicProps, WorkflowTab, workflowSceneLogic } from './workflowSceneLogic' import { TRIGGER_PREFILL_PARAM } from './workflowTriggerPrefill' @@ -255,6 +256,7 @@ export function WorkflowScene(props: WorkflowSceneLogicProps): JSX.Element { {isSavedWorkflow && } {!isSavedWorkflow && } + {suggestionsAvailable && isSavedWorkflow && } {/* Only show Logs and Metrics tabs if the workflow has already been created */} {!props.id || props.id === 'new' ? ( diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts new file mode 100644 index 000000000000..d51eed316862 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.test.ts @@ -0,0 +1,375 @@ +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { FEATURE_FLAGS } from 'lib/constants' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { AccessControlLevel } from '~/types' + +import { resourceEditedLogic } from 'products/notifications/frontend/resourceEditedLogic' + +import { HogFlow, HogFlowAction } from './hogflows/types' +import { workflowLogic } from './workflowLogic' +import { workflowSandboxSwitchBannerLogic } from './workflowSandboxSwitchBannerLogic' + +const WORKFLOW_ID = 'wf-sandbox-switch-1' +const SANDBOX_SENDER_ID = 7 +const OWN_SENDER_ID = 8 +const SECOND_OWN_SENDER_ID = 9 + +const SANDBOX_SENDER = { + id: SANDBOX_SENDER_ID, + kind: 'email', + display_name: 'Acme via PostHog ', + config: { provider: 'sandbox', name: 'Acme via PostHog', email: 'sandbox@example.com', verified: true }, +} +const OWN_SENDER = { + id: OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme ', + config: { provider: 'ses', domain: 'acme.example.com', email: 'hello@acme.example.com', verified: true }, +} +const SECOND_OWN_SENDER = { + id: SECOND_OWN_SENDER_ID, + kind: 'email', + display_name: 'Acme support ', + config: { provider: 'ses', domain: 'acme.example.com', email: 'support@acme.example.com', verified: true }, +} +const UNVERIFIED_OWN_SENDER = { ...OWN_SENDER, id: 10, config: { ...OWN_SENDER.config, verified: false } } + +const emailStep = (id: string, from: Record): HogFlowAction => ({ + id, + type: 'function_email', + name: 'Send email', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: 'template-email', + inputs: { + email: { + value: { + to: { email: 'recipient@example.com' }, + from, + subject: 'Hello', + html: '

Hi

', + text: 'Hi', + }, + templating: 'liquid', + }, + }, + }, +}) + +const makeWorkflow = (steps: HogFlowAction[]): HogFlow => ({ + id: WORKFLOW_ID, + name: 'Sandbox switch test', + actions: [ + { + id: 'trigger_node', + type: 'trigger', + name: 'Trigger', + description: '', + created_at: 0, + updated_at: 0, + config: { type: 'event', filters: {} }, + }, + ...steps, + { + id: 'exit_node', + type: 'exit', + name: 'Exit', + description: '', + created_at: 0, + updated_at: 0, + config: { reason: 'Default exit' }, + }, + ], + edges: [], + conversion: { filters: [] }, + exit_condition: 'exit_only_at_end', + version: 1, + status: 'draft', + team_id: 1, + trigger: { type: 'event', filters: {} } as HogFlow['trigger'], + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', +}) + +const senderOf = (workflow: HogFlow, stepId: string): unknown => { + const action = workflow.actions.find((candidate) => candidate.id === stepId) + return action?.type === 'function_email' ? action.config.inputs.email.value.from : undefined +} + +describe('workflowSandboxSwitchBannerLogic', () => { + let logic: ReturnType + let capture: jest.SpyInstance + let successToast: jest.SpyInstance + let savedWorkflow: HogFlow + let integrationsPayload: unknown[] + + const setSandboxFlag = (enabled: boolean): void => { + featureFlagLogic.actions.setFeatureFlags( + enabled ? [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER] : [], + enabled ? { [FEATURE_FLAGS.WORKFLOWS_SANDBOX_SENDER]: true } : {} + ) + } + + const mountWith = async ({ + steps, + integrations, + flag = true, + accessLevel = AccessControlLevel.Editor, + status = 'draft', + }: { + steps: HogFlowAction[] + integrations: unknown[] + flag?: boolean + accessLevel?: AccessControlLevel + status?: HogFlow['status'] + }): Promise => { + savedWorkflow = { ...makeWorkflow(steps), user_access_level: accessLevel, status } + integrationsPayload = integrations + initKeaTests() + featureFlagLogic.mount() + setSandboxFlag(flag) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + workflow.mount() + integrationsLogic.mount() + await expectLogic(workflow).toDispatchActions(['loadWorkflowSuccess']) + await expectLogic(integrationsLogic).toDispatchActions(['loadIntegrationsSuccess']) + logic = workflowSandboxSwitchBannerLogic({ id: WORKFLOW_ID }) + logic.mount() + } + + beforeEach(() => { + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => [200, savedWorkflow], + '/api/projects/:team_id/integrations/': () => [200, { results: integrationsPayload }], + '/api/projects/:team_id/hog_function_templates/': () => new Promise(() => {}), + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => [ + 200, + { ...savedWorkflow, ...((await request.json()) as Partial) }, + ], + }, + }) + capture = jest.spyOn(posthog, 'capture').mockImplementation() + successToast = jest.spyOn(lemonToast, 'success').mockImplementation() + }) + + afterEach(() => { + logic?.unmount() + featureFlagLogic.actions.setFeatureFlags([], {}) + capture.mockRestore() + successToast.mockRestore() + }) + + it.each([ + { + case: 'a sandbox email step and a verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: true, + }, + { + case: 'no verified own sender', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, UNVERIFIED_OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'no email step on the sandbox sender', + steps: [emailStep('email_1', { integrationId: OWN_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: true, + visible: false, + }, + { + case: 'the sandbox sender flag off', + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + visible: false, + }, + ])('shows the banner only with $case', async ({ steps, integrations, flag, visible }) => { + await mountWith({ steps, integrations, flag }) + + expect(logic.values.bannerVisible).toBe(visible) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + visible ? 1 : 0 + ) + }) + + it('reports the banner once the flag arrives after the workflow and integrations loaded', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + flag: false, + }) + expect(logic.values.bannerVisible).toBe(false) + + setSandboxFlag(true) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('reports the banner once per workflow view even when it hides and shows again', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + expect(logic.values.bannerVisible).toBe(false) + workflow.actions.setWorkflowValues({ actions: savedWorkflow.actions }) + + expect(logic.values.bannerVisible).toBe(true) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox switch banner shown')).toHaveLength( + 1 + ) + }) + + it('switches every sandbox email step to the own sender as an unsaved change', async () => { + await mountWith({ + steps: [ + emailStep('email_1', { integrationId: SANDBOX_SENDER_ID }), + emailStep('email_2', { integrationId: OWN_SENDER_ID, name: 'Acme', email: 'hello@acme.example.com' }), + emailStep('email_3', { integrationId: SANDBOX_SENDER_ID }), + ], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: OWN_SENDER_ID }) + expect(senderOf(workflow.values.workflow, 'email_2')).toEqual({ + integrationId: OWN_SENDER_ID, + name: 'Acme', + email: 'hello@acme.example.com', + }) + expect(senderOf(workflow.values.workflow, 'email_3')).toEqual({ integrationId: OWN_SENDER_ID }) + const switchedEmail = workflow.values.workflow.actions.find((step) => step.id === 'email_1') + expect(switchedEmail?.type === 'function_email' && switchedEmail.config.inputs.email).toEqual({ + value: { + from: { integrationId: OWN_SENDER_ID }, + to: { email: 'recipient@example.com' }, + subject: 'Hello', + html: '

Hi

', + text: 'Hi', + }, + templating: 'liquid', + }) + expect(senderOf(workflow.values.originalWorkflow!, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(workflow.values.hasUnsavedChanges).toBe(true) + expect(logic.values.bannerVisible).toBe(false) + expect(capture).toHaveBeenCalledWith('workflows sandbox sender switched', { email_step_count: 2 }) + expect(capture.mock.calls.filter(([event]) => event === 'workflows sandbox sender switched')).toHaveLength(1) + }) + + it.each([ + ['active', 'Updated 1\u00a0email step to hello@acme.example.com. Publish the changes to use this sender.'], + ['draft', 'Updated 1\u00a0email step to hello@acme.example.com.'], + ] as const)('explains what remains after switching a %s workflow', async (status, confirmation) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + status, + }) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(successToast).toHaveBeenCalledWith(confirmation) + }) + + it('lets the user pick the own sender when several are verified', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, SECOND_OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + + expect(logic.values.verifiedOwnSenders.map((sender) => sender.id)).toEqual([ + OWN_SENDER_ID, + SECOND_OWN_SENDER_ID, + ]) + + await expectLogic(logic, () => { + logic.actions.switchToOwnSender(SECOND_OWN_SENDER_ID) + }).toDispatchActions(['setWorkflowValues']) + + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SECOND_OWN_SENDER_ID, + }) + }) + + it('prevents switching while an external edit reloads the workflow', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + }) + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => new Promise(() => {}), + }, + }) + resourceEditedLogic.actions.resourceEdited({ + notification_type: 'resource_edited', + team_id: 1, + resource_type: 'HogFlow', + resource_id: WORKFLOW_ID, + updated_at: '2026-06-01T00:00:00.000Z', + actor_user_id: 99, + }) + const workflow = workflowLogic({ id: WORKFLOW_ID }) + expect(workflow.values.isSyncingExternalEdit).toBe(true) + + logic.actions.switchToOwnSender(OWN_SENDER_ID) + + expect(senderOf(workflow.values.workflow, 'email_1')).toEqual({ integrationId: SANDBOX_SENDER_ID }) + expect(logic.values.switchDisabledReason).toBe('Wait for the workflow to finish syncing') + expect(successToast).not.toHaveBeenCalled() + }) + + it('leaves the workflow intact for a viewer', async () => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER], + accessLevel: AccessControlLevel.Viewer, + }) + logic.actions.switchToOwnSender(OWN_SENDER_ID) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + }) + + it.each([UNVERIFIED_OWN_SENDER.id, SANDBOX_SENDER_ID, 999])( + 'rejects ineligible switch target %s', + async (targetId) => { + await mountWith({ + steps: [emailStep('email_1', { integrationId: SANDBOX_SENDER_ID })], + integrations: [SANDBOX_SENDER, OWN_SENDER, UNVERIFIED_OWN_SENDER], + }) + logic.actions.switchToOwnSender(targetId) + expect(senderOf(workflowLogic({ id: WORKFLOW_ID }).values.workflow, 'email_1')).toEqual({ + integrationId: SANDBOX_SENDER_ID, + }) + } + ) +}) diff --git a/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts new file mode 100644 index 000000000000..a1a8dc4032bf --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowSandboxSwitchBannerLogic.ts @@ -0,0 +1,236 @@ +import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { subscriptions } from 'kea-subscriptions' +import posthog from 'posthog-js' + +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' +import { pluralize } from 'lib/utils/strings' +import { selectedSenderIds } from 'scenes/hog-functions/email-templater/selectSenders' + +import { AccessControlLevel, AccessControlResourceType, IntegrationType } from '~/types' + +import { isEmailAction } from './hogflows/steps/types' +import { HogFlow, HogFlowAction } from './hogflows/types' +import { WorkflowLogicProps, workflowLogic } from './workflowLogic' + +export type EmailStep = Extract + +const usesSender = (step: EmailStep, senderId: number): boolean => + selectedSenderIds(step.config.inputs?.email?.value?.from).includes(senderId) + +const withSender = (step: EmailStep, integrationId: number): EmailStep => { + const email = step.config.inputs.email + return { + ...step, + config: { + ...step.config, + inputs: { + ...step.config.inputs, + email: { ...email, value: { ...email.value, from: { integrationId } } }, + }, + }, + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicValues { + ownEmailIntegrations: IntegrationType[] // integrationsLogic + sandboxEmailSender: IntegrationType | null // integrationsLogic + isSyncingExternalEdit: boolean // workflowLogic + workflow: HogFlow // workflowLogic + workflowLoading: boolean // workflowLogic + workflowUserAccessLevel: AccessControlLevel | null // workflowLogic + bannerShownReported: boolean + bannerVisible: boolean + sandboxEmailSteps: EmailStep[] + switchDisabledReason: string | null + verifiedOwnSenders: IntegrationType[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicActions { + autoSaveWorkflow: () => { + value: true + } // workflowLogic + setWorkflowValues: (values: import('node_modules/kea-forms/lib').DeepPartial) => { + values: import('node_modules/kea-forms/lib').DeepPartial + } // workflowLogic + reportBannerShown: () => { + value: true + } + switchToOwnSender: (integrationId: number) => { + integrationId: number + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowSandboxSwitchBannerLogicMeta { + key: string + __keaTypeGenInternalSelectorTypes: { + switchDisabledReason: ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ) => string | null + verifiedOwnSenders: (ownEmailIntegrations: IntegrationType[]) => IntegrationType[] + sandboxEmailSteps: (workflow: HogFlow, sandboxEmailSender: IntegrationType | null) => EmailStep[] + bannerVisible: ( + sandboxEmailSteps: ({ + config: { + inputs: Record< + string, + { + bytecode?: any + order?: number | undefined + secret?: boolean | undefined + templating?: 'hog' | 'liquid' | undefined + value: any + } + > + message_category_id?: string | undefined + message_category_type?: 'marketing' | 'transactional' | undefined + template_id: 'template-email' + template_uuid?: string | undefined + tracking_enabled?: boolean | undefined + utm_params?: + | { + utm_campaign?: string | undefined + utm_content?: string | undefined + utm_medium?: string | undefined + utm_source?: string | undefined + } + | undefined + utm_params_from_default?: + | ('utm_campaign' | 'utm_content' | 'utm_medium' | 'utm_source')[] + | undefined + utm_tags_enabled?: boolean | undefined + } + created_at?: number | undefined + description: string + filters?: + | { + actions?: any[] | undefined + events?: any[] | undefined + properties?: any[] | undefined + } + | null + | undefined + id: string + name: string + on_error?: 'abort' | 'continue' | null | undefined + output_variable?: + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + } + | { + key: string + label?: string | null | undefined + result_path?: string | null | undefined + spread?: boolean | null | undefined + }[] + | null + | undefined + type: 'function_email' + updated_at?: number | undefined + } & Record)[], + verifiedOwnSenders: IntegrationType[] + ) => boolean + } +} + +export type workflowSandboxSwitchBannerLogicType = MakeLogicType< + workflowSandboxSwitchBannerLogicValues, + workflowSandboxSwitchBannerLogicActions, + WorkflowLogicProps, + workflowSandboxSwitchBannerLogicMeta +> + +export const workflowSandboxSwitchBannerLogic = kea([ + props({} as WorkflowLogicProps), + key((props) => `${props.id ?? 'new'}-${props.templateId ?? 'default'}-${props.editTemplateId ?? 'default'}`), + path((key) => ['products', 'workflows', 'frontend', 'Workflows', 'workflowSandboxSwitchBannerLogic', key]), + connect((props: WorkflowLogicProps) => ({ + values: [ + workflowLogic(props), + ['workflow', 'workflowUserAccessLevel', 'workflowLoading', 'isSyncingExternalEdit'], + integrationsLogic, + ['ownEmailIntegrations', 'sandboxEmailSender'], + ], + actions: [workflowLogic(props), ['setWorkflowValues', 'autoSaveWorkflow']], + })), + actions({ + switchToOwnSender: (integrationId: number) => ({ integrationId }), + reportBannerShown: true, + }), + reducers({ + bannerShownReported: [false, { reportBannerShown: () => true }], + }), + selectors({ + switchDisabledReason: [ + (s) => [s.workflowUserAccessLevel, s.workflowLoading, s.isSyncingExternalEdit], + ( + workflowUserAccessLevel: AccessControlLevel | null, + workflowLoading: boolean, + isSyncingExternalEdit: boolean + ): string | null => + getAccessControlDisabledReason( + AccessControlResourceType.Workflow, + AccessControlLevel.Editor, + workflowUserAccessLevel ?? undefined + ) || (workflowLoading || isSyncingExternalEdit ? 'Wait for the workflow to finish syncing' : null), + ], + verifiedOwnSenders: [ + (s) => [s.ownEmailIntegrations], + (ownEmailIntegrations: IntegrationType[]): IntegrationType[] => + ownEmailIntegrations.filter((integration) => integration.config?.verified === true), + ], + sandboxEmailSteps: [ + (s) => [s.workflow, s.sandboxEmailSender], + (workflow: HogFlow, sandboxEmailSender: IntegrationType | null): EmailStep[] => + sandboxEmailSender + ? workflow.actions.filter(isEmailAction).filter((step) => usesSender(step, sandboxEmailSender.id)) + : [], + ], + bannerVisible: [ + (s) => [s.sandboxEmailSteps, s.verifiedOwnSenders], + (sandboxEmailSteps: EmailStep[], verifiedOwnSenders: IntegrationType[]): boolean => + sandboxEmailSteps.length > 0 && verifiedOwnSenders.length > 0, + ], + }), + listeners(({ actions, values }) => ({ + reportBannerShown: () => { + posthog.capture('workflows sandbox switch banner shown') + }, + switchToOwnSender: ({ integrationId }) => { + const { sandboxEmailSteps } = values + const sender = values.verifiedOwnSenders.find((candidate) => candidate.id === integrationId) + if (sandboxEmailSteps.length === 0 || !sender) { + return + } + if (values.switchDisabledReason) { + return + } + const switched = new Map(sandboxEmailSteps.map((step) => [step.id, withSender(step, integrationId)])) + actions.setWorkflowValues({ + actions: values.workflow.actions.map((action) => switched.get(action.id) ?? action), + }) + actions.autoSaveWorkflow() + posthog.capture('workflows sandbox sender switched', { email_step_count: sandboxEmailSteps.length }) + const nextStep = values.workflow.status === 'active' ? ' Publish the changes to use this sender.' : '' + lemonToast.success( + `Updated ${pluralize(sandboxEmailSteps.length, 'email step')} to ${sender.config.email ?? 'your own sender'}.${nextStep}` + ) + }, + })), + subscriptions(({ actions, values }) => ({ + bannerVisible: (bannerVisible: boolean) => { + if (bannerVisible && !values.bannerShownReported) { + actions.reportBannerShown() + } + }, + })), +])