Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Meta, StoryObj } from '@storybook/react'

import { WorkflowSenderDomainNotice } from './WorkflowSenderDomainNotice'

const meta: Meta<typeof WorkflowSenderDomainNotice> = {
title: 'Products/Workflows/Sender domain notice',
component: WorkflowSenderDomainNotice,
parameters: {
testOptions: { waitForSelector: '[data-attr^="workflow-sender-domain"]' },
},
}
export default meta

type Story = StoryObj<typeof WorkflowSenderDomainNotice>

export const NoDomain: Story = { args: { notice: { stage: 'no_domain', url: '/workflows/channels' } } }

export const UnverifiedDomain: Story = {
args: { notice: { stage: 'unverified', domains: ['mail.example.com'], url: '/workflows/channels' } },
}

export const WaitingForSeveralDomains: Story = {
args: {
notice: { stage: 'waiting', domains: ['mail.example.com', 'news.example.org'], url: '/workflows/channels' },
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Link } from '@posthog/lemon-ui'

import type { SenderDomainNotice } from './senderDomainNotice'

const domainList = new Intl.ListFormat('en', { type: 'conjunction' })

function noticeMessage(notice: SenderDomainNotice): string {
if (notice.stage === 'no_domain') {
return 'No domain configured.'
}
const domains = domainList.format(notice.domains)
const several = notice.domains.length > 1
return notice.stage === 'waiting'
? `Sending starts once ${domains} ${several ? 'are' : 'is'} verified.`
: `${domains} ${several ? "aren't" : "isn't"} verified yet.`
}

export function WorkflowSenderDomainNotice({ notice }: { notice: SenderDomainNotice }): JSX.Element {
const setsUpDomain = notice.stage === 'no_domain'
return (
<span className="flex items-center gap-2 min-w-0 text-xs">
<span className="text-secondary truncate">{noticeMessage(notice)}</span>
<Link
to={notice.url}
target="_blank"
className="shrink-0"
data-attr={setsUpDomain ? 'workflow-sender-domain-set-up' : 'workflow-sender-domain-status'}
>
{setsUpDomain ? 'Set up a domain' : 'Check domain status'}
</Link>
</span>
)
}
13 changes: 10 additions & 3 deletions products/workflows/frontend/Workflows/WorkflowStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type { HogFlowEditorLayout } from './hogflows/hogFlowEditorLogic'
import { WorkflowLogicProps, workflowLogic } from './workflowLogic'
import { WorkflowSenderDomainNotice } from './WorkflowSenderDomainNotice'

type WorkflowStatusBarProps = WorkflowLogicProps & {
editorLayout: HogFlowEditorLayout
Expand All @@ -18,7 +19,7 @@
commands?: JSX.Element
}

export function WorkflowStatusBar({

Check warning on line 22 in products/workflows/frontend/Workflows/WorkflowStatusBar.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`WorkflowStatusBar` has cyclomatic complexity 26 (warn >10)
editorLayout,
showEditorLayoutToggle,
onEditorLayoutChange,
Expand All @@ -34,6 +35,7 @@
isAutoSavePending,
autoSaveEnabled,
lastSavedAt,
senderDomainNotice,
} = useValues(logic)
const { setAutoSaveEnabled } = useActions(logic)
const showSaving = useDebouncedValue(isAutoSavePending || workflowLoading, 1000)
Expand Down Expand Up @@ -94,9 +96,14 @@
) : (
<LemonTag>Draft</LemonTag>
))}
{showWorkflowStatus && statusExplanation && (
<span className="text-xs text-secondary truncate">{statusExplanation}</span>
)}
{showWorkflowStatus &&
(senderDomainNotice ? (
<WorkflowSenderDomainNotice notice={senderDomainNotice} />
) : (
statusExplanation && (
<span className="text-xs text-secondary truncate">{statusExplanation}</span>
)
))}
{commands}
</div>
{/* Interactive controls sit right-anchored with variable-width text leftmost, so the
Expand Down
48 changes: 48 additions & 0 deletions products/workflows/frontend/Workflows/senderDomainNotice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { EmailTemplateFrom } from 'scenes/hog-functions/email-templater/types'
import { urls } from 'scenes/urls'

import type { IntegrationType } from '~/types'

import { isEmailAction } from './hogflows/steps/types'
import type { HogFlow } from './hogflows/types'
import { unverifiedSenderDomains } from './unverifiedSenderDomains'

export type SenderDomainNotice =
| { stage: 'no_domain'; url: string }
| { stage: 'unverified' | 'waiting'; domains: string[]; url: string }

function chosenSenderIds(sender: EmailTemplateFrom | undefined): number[] {
return sender?.integrationIds?.length ? sender.integrationIds : sender?.integrationId ? [sender.integrationId] : []
}

function hasEmailStepWithoutSender(workflow: HogFlow, emailSenders: IntegrationType[]): boolean {
const existingSenderIds = new Set(emailSenders.map(({ id }) => id))
return workflow.actions.filter(isEmailAction).some((action) => {
const senderIds = chosenSenderIds(action.config.inputs.email?.value?.from)
return !senderIds.some((senderId) => existingSenderIds.has(senderId))
})
}

function projectDomainNotice(workflow: HogFlow, emailSenders: IntegrationType[]): SenderDomainNotice | null {
const hasVerifiedSender = emailSenders.some(({ config }) => config.verified === true)
if (hasVerifiedSender || !hasEmailStepWithoutSender(workflow, emailSenders)) {
return null
}
const pendingDomains: string[] = [...new Set(emailSenders.map(({ config }) => config.domain))]
return pendingDomains.length > 0
? { stage: 'unverified', domains: pendingDomains, url: urls.workflows('channels') }
: { stage: 'no_domain', url: urls.workflows('channels') }
}

export function senderDomainNotice(
workflow: HogFlow,
savedWorkflow: HogFlow,
emailSenders: IntegrationType[] | null
): SenderDomainNotice | null {
const domains = unverifiedSenderDomains(savedWorkflow)
if (domains.length > 0) {
const stage = savedWorkflow.status === 'waiting_for_domain' ? 'waiting' : 'unverified'
return { stage, domains, url: urls.workflows('channels') }
}
return emailSenders ? projectDomainNotice(workflow, emailSenders) : null
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
import { expectLogic } from 'kea-test-utils'

import { FEATURE_FLAGS } from 'lib/constants'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'

import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'

import { HogFlow } from './hogflows/types'
import type { SenderDomainNotice } from './senderDomainNotice'
import { workflowLogic } from './workflowLogic'

const WORKFLOW_ID = 'wf-send-once-verified-1'
const UNVERIFIED_SENDER = { id: 7, domain: 'example.com' }
const WEBHOOK_TEMPLATE = { id: 'template-webhook', inputs_schema: [{ key: 'url', type: 'string', templating: true }] }

interface ProjectSender {
id: number
domain: string
verified: boolean
}
const PENDING_PROJECT_SENDER: ProjectSender = { ...UNVERIFIED_SENDER, verified: false }
const VERIFIED_PROJECT_SENDER: ProjectSender = { id: 8, domain: 'mail.example.org', verified: true }

const emailStep = (from: Record<string, unknown>): HogFlow['actions'][number] =>
({
id: 'email_node',
type: 'function_email',
name: 'Welcome email',
description: '',
created_at: 0,
updated_at: 0,
config: { template_id: 'template-email', inputs: { email: { value: { from, to: {}, subject: 'Hi' } } } },
}) as HogFlow['actions'][number]

const withEmailStep = (workflow: HogFlow, from: Record<string, unknown>): HogFlow => ({
...workflow,
actions: [workflow.actions[0], emailStep(from), workflow.actions[1]],
})

const makeWorkflow = (overrides: Partial<HogFlow> = {}): HogFlow => ({
id: WORKFLOW_ID,
name: 'Welcome',
Expand Down Expand Up @@ -62,16 +88,22 @@ describe('workflowLogic send once verified', () => {
let logic: ReturnType<typeof workflowLogic.build>
let patchBodies: Record<string, any>[]
let storedWorkflow: HogFlow
let projectSenders: ProjectSender[]
let retrieveCount: number

const mountWith = async (workflow: HogFlow, { flagOn = true }: { flagOn?: boolean } = {}): Promise<void> => {
const mountWith = async (
workflow: HogFlow,
{ flagOn = true, senders = [PENDING_PROJECT_SENDER] }: { flagOn?: boolean; senders?: ProjectSender[] } = {}
): Promise<void> => {
storedWorkflow = workflow
projectSenders = senders
initKeaTests()
featureFlagLogic.mount()
featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD]: flagOn })
logic = workflowLogic({ id: WORKFLOW_ID })
logic.mount()
await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess'])
await expectLogic(integrationsLogic).toFinishAllListeners()
}

beforeEach(() => {
Expand All @@ -85,6 +117,18 @@ describe('workflowLogic send once verified', () => {
return [200, storedWorkflow]
},
'/api/projects/:team_id/hog_function_templates/': { results: [WEBHOOK_TEMPLATE], count: 1 },
'/api/projects/:team_id/integrations/': () => [
200,
{
results: projectSenders.map(({ id, domain, verified }) => ({
id,
kind: 'email',
config: { domain, verified },
})),
count: projectSenders.length,
next: null,
},
],
},
patch: {
'/api/environments/:team_id/hog_flows/:id/': async ({ request }) => {
Expand Down Expand Up @@ -288,6 +332,95 @@ describe('workflowLogic send once verified', () => {
expect(logic.values.showStartedSendingNotice).toBe(false)
})

it.each<{
name: string
status: HogFlow['status']
from: Record<string, unknown>
unverified: { id: number; domain: string }[]
senders: ProjectSender[]
flagOn?: boolean
notice: SenderDomainNotice | null
}>([
{
name: 'a step has no sender and the project has a verified sender',
status: 'draft',
from: {},
unverified: [],
senders: [PENDING_PROJECT_SENDER, VERIFIED_PROJECT_SENDER],
notice: null,
},
{
name: 'a step has no sender and the project has no sender',
status: 'draft',
from: {},
unverified: [],
senders: [],
notice: { stage: 'no_domain', url: '/workflows/channels' },
},
{
name: 'a step has no sender and the project only has a pending domain',
status: 'draft',
from: {},
unverified: [],
senders: [PENDING_PROJECT_SENDER],
notice: { stage: 'unverified', domains: ['example.com'], url: '/workflows/channels' },
},
{
name: 'the sender of a step was deleted and the project has no other sender',
status: 'draft',
from: { integrationId: 99 },
unverified: [],
senders: [],
notice: { stage: 'no_domain', url: '/workflows/channels' },
},
{
name: 'a draft sends from an unverified domain',
status: 'draft',
from: { integrationId: 7 },
unverified: [UNVERIFIED_SENDER],
senders: [PENDING_PROJECT_SENDER, VERIFIED_PROJECT_SENDER],
notice: { stage: 'unverified', domains: ['example.com'], url: '/workflows/channels' },
},
{
name: 'an enabled workflow waits for its domain',
status: 'waiting_for_domain',
from: { integrationId: 7 },
unverified: [UNVERIFIED_SENDER],
senders: [PENDING_PROJECT_SENDER],
notice: { stage: 'waiting', domains: ['example.com'], url: '/workflows/channels' },
},
{
name: 'every sender is verified',
status: 'active',
from: { integrationId: 8 },
unverified: [],
senders: [VERIFIED_PROJECT_SENDER],
notice: null,
},
{
name: 'the flag is off',
status: 'draft',
from: {},
unverified: [],
senders: [],
flagOn: false,
notice: null,
},
])('shows the sender domain notice when $name', async ({ status, from, unverified, senders, flagOn, notice }) => {
await mountWith(withEmailStep(makeWorkflow({ status, unverified_email_senders: unverified }), from), {
flagOn,
senders,
})

expect(logic.values.senderDomainNotice).toEqual(notice)
})

it('shows no sender domain notice for a workflow without an email step', async () => {
await mountWith(makeWorkflow())

expect(logic.values.senderDomainNotice).toBeNull()
})

describe('while the workflow waits for its domain', () => {
const waiting = makeWorkflow({
status: 'waiting_for_domain',
Expand Down Expand Up @@ -339,6 +472,7 @@ describe('workflowLogic send once verified', () => {

expect(logic.values.originalWorkflow?.status).toEqual('active')
expect(logic.values.showStartedSendingNotice).toBe(true)
expect(logic.values.senderDomainNotice).toBeNull()
expect(logic.values.workflow.name).toEqual('Renamed while waiting')
expect(logic.values.hasUnsavedChanges).toBe(true)

Expand Down
23 changes: 23 additions & 0 deletions products/workflows/frontend/Workflows/workflowLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
} from './hogflows/types'
import { openPublishConfirmDialog } from './PublishImpactDialog'
import { ResourceSaveQueue } from './resourceSaveQueue'
import { SenderDomainNotice, senderDomainNotice } from './senderDomainNotice'
import { unverifiedSenderDomains } from './unverifiedSenderDomains'
import { checkWaitingStatusWhileVisible } from './waitingStatusCheck'
import { prepareWorkflowDuplicate } from './workflowDuplication'
Expand Down Expand Up @@ -351,6 +352,7 @@
dismissedStartedSendingNotices: Record<string, true>
enablePromptDomains: string[] | null
enablePromptOpen: boolean
senderDomainNotice: SenderDomainNotice | null
showStartedSendingNotice: boolean
isWaitingForDomain: boolean
startedSendingNoticeKey: string | null
Expand Down Expand Up @@ -3105,6 +3107,12 @@
logicProps: (arg: WorkflowLogicProps) => WorkflowLogicProps
unverifiedSenderDomains: (originalWorkflow: HogFlow | null, featureFlags: FeatureFlagsSet) => string[]
enablePromptDomains: (enablePromptOpen: boolean, unverifiedSenderDomains: string[]) => string[] | null
senderDomainNotice: (
workflow: HogFlow,
originalWorkflow: HogFlow | null,
integrations: IntegrationType[] | null,
featureFlags: FeatureFlagsSet
) => SenderDomainNotice | null
startedSendingNoticeKey: (originalWorkflow: HogFlow | null) => string | null
isWaitingForDomain: (originalWorkflow: HogFlow | null) => boolean
showStartedSendingNotice: (
Expand Down Expand Up @@ -3518,7 +3526,7 @@
cache.nextSaveKeepsFormEdits = false

let reconciledGoLive = false
const runSave = async (): Promise<HogFlow> => {

Check warning on line 3529 in products/workflows/frontend/Workflows/workflowLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`runSave` has cyclomatic complexity 47 (warn >10)
updates = sanitizeWorkflow(updates, values.hogFunctionTemplatesById)

if (!props.id || props.id === 'new') {
Expand Down Expand Up @@ -3729,7 +3737,7 @@

return errors
},
submit: async (workflow) => {

Check warning on line 3740 in products/workflows/frontend/Workflows/workflowLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`submit` has cyclomatic complexity 11 (warn >10)
if (!workflow) {
return
}
Expand Down Expand Up @@ -3973,6 +3981,21 @@
(enablePromptOpen: boolean, unverifiedSenderDomains: string[]): string[] | null =>
enablePromptOpen ? unverifiedSenderDomains : null,
],
senderDomainNotice: [
(s) => [s.workflow, s.originalWorkflow, s.integrations, s.featureFlags],
(
workflow: HogFlow,
originalWorkflow: HogFlow | null,
integrations: IntegrationType[] | null,
featureFlags: FeatureFlagsSet
): SenderDomainNotice | null => {
if (!featureFlags[FEATURE_FLAGS.WORKFLOWS_EMAIL_DOMAIN_WIZARD] || !originalWorkflow) {
return null
}
const emailSenders = integrations?.filter(({ kind }) => kind === 'email') ?? null
return senderDomainNotice(workflow, originalWorkflow, emailSenders)
},
],
isWaitingForDomain: [
(s) => [s.originalWorkflow],
(originalWorkflow: HogFlow | null): boolean => originalWorkflow?.status === 'waiting_for_domain',
Expand Down Expand Up @@ -4132,7 +4155,7 @@
triggerAction?.type === 'trigger' && ROW_SCOPED_TRIGGER_TYPES.has(triggerAction.config?.type)

return workflow.actions.reduce(
(acc, action) => {

Check warning on line 4158 in products/workflows/frontend/Workflows/workflowLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`<anonymous>` has cyclomatic complexity 39 (warn >10)
const result: HogFlowActionValidationResult = {
valid: true,
schema: null,
Expand Down Expand Up @@ -4612,7 +4635,7 @@
cache.nextSaveKeepsFormEdits = keepsFormEdits
actions.saveWorkflow(merged)
},
loadWorkflowSuccess: async ({ originalWorkflow }) => {

Check warning on line 4638 in products/workflows/frontend/Workflows/workflowLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`loadWorkflowSuccess` has cyclomatic complexity 11 (warn >10)
// This response is now the save baseline. Discarding a draft moves its stamp backwards,
// so a copy kept from an earlier save would fence later saves too loosely.
cache.lastSavedWorkflow = undefined
Expand Down Expand Up @@ -4662,7 +4685,7 @@
confirmEnablePrompt: () => {
actions.saveWorkflowPartial({ status: 'active' })
},
checkWaitingStatus: async () => {

Check warning on line 4688 in products/workflows/frontend/Workflows/workflowLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`checkWaitingStatus` has cyclomatic complexity 13 (warn >10)
const latest = (cache.lastSavedWorkflow as HogFlow | undefined) ?? values.originalWorkflow
if (!props.id || !latest || !values.isWaitingForDomain || values.workflowLoading) {
return
Expand Down Expand Up @@ -4705,7 +4728,7 @@
actions.resourceEdited(deferred)
}
},
saveWorkflowSuccess: async ({ originalWorkflow }) => {

Check warning on line 4731 in products/workflows/frontend/Workflows/workflowLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`saveWorkflowSuccess` has cyclomatic complexity 42 (warn >10)
// What this save was dispatched with. A manual save queued behind an auto-save used to
// relabel the auto-save here, so the auto-save also ran the manual-only work below: a
// second toast, and a second schedule write that can leave a duplicate schedule.
Expand Down
Loading