Skip to content

Commit

Permalink
feat(cdp): Add mailgun destination (#24137)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjackwhite authored Aug 13, 2024
1 parent 5e1dd83 commit 0c3d83d
Show file tree
Hide file tree
Showing 18 changed files with 680 additions and 19 deletions.
Binary file added frontend/public/services/mailgun.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 12 additions & 4 deletions frontend/src/lib/lemon-ui/LemonField/LemonField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type LemonPureFieldProps = {
help?: React.ReactNode
/** Error message to be displayed */
error?: React.ReactNode
renderError?: (error: string) => React.ReactNode
className?: string
children?: React.ReactNode
onClick?: () => void
Expand All @@ -37,6 +38,7 @@ const LemonPureField = ({
children,
inline,
onClick,
renderError,
}: LemonPureFieldProps): JSX.Element => {
return (
<div
Expand Down Expand Up @@ -64,10 +66,14 @@ const LemonPureField = ({
) : null}
{children}
{help ? <div className="text-muted text-xs">{help}</div> : null}
{error ? (
<div className="text-danger flex items-center gap-1 text-sm">
<IconErrorOutline className="text-xl shrink-0" /> {error}
</div>
{typeof error === 'string' ? (
renderError ? (
renderError(error)
) : (
<div className="text-danger flex items-center gap-1 text-sm">
<IconErrorOutline className="text-xl shrink-0" /> {error}
</div>
)
) : null}
</div>
)
Expand All @@ -83,6 +89,7 @@ export const LemonField = ({
showOptional,
inline,
info,
renderError,
...keaFieldProps
}: LemonFieldProps): JSX.Element => {
const template: KeaFieldProps['template'] = ({ label, kids, error }) => {
Expand All @@ -95,6 +102,7 @@ export const LemonField = ({
showOptional={showOptional}
inline={inline}
info={info}
renderError={renderError}
>
{kids}
</LemonPureField>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/monaco/CodeEditor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
.CodeEditorResizeable,
.CodeEditorInline {
.monaco-editor {
/* stylelint-disable custom-property-pattern */
--vscode-textLink-foreground: transparent;
--vscode-focusBorder: transparent;
/* stylelint-enable custom-property-pattern */

border-radius: var(--radius);

.overflow-guard {
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/lib/monaco/CodeEditorInline.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { CodeEditorProps } from 'lib/monaco/CodeEditor'
import { CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable'
import clsx from 'clsx'
import { CodeEditorResizableProps, CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable'

export interface CodeEditorInlineProps extends Omit<CodeEditorProps, 'height'> {
export interface CodeEditorInlineProps extends Omit<CodeEditorResizableProps, 'height'> {
minHeight?: string
}
export function CodeEditorInline(props: CodeEditorInlineProps): JSX.Element {
return (
<CodeEditorResizeable
minHeight="29px"
{...props}
className={clsx('.CodeEditorInline', props.className)}
options={{
// Note: duplicate anything you add here with its default into <CodeEditor />
lineNumbers: 'off',
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/monaco/CodeEditorResizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface CodeEditorResizableProps extends Omit<CodeEditorProps, 'height'
minHeight?: string | number
maxHeight?: string | number
editorClassName?: string
embedded?: boolean
}

export function CodeEditorResizeable({
Expand All @@ -16,6 +17,7 @@ export function CodeEditorResizeable({
maxHeight = '90vh',
className,
editorClassName,
embedded = false,
...props
}: CodeEditorResizableProps): JSX.Element {
const [height, setHeight] = useState(defaultHeight)
Expand All @@ -33,7 +35,7 @@ export function CodeEditorResizeable({
return (
<div
ref={ref}
className={clsx('CodeEditorResizeable relative border rounded w-full', className)}
className={clsx('CodeEditorResizeable relative', !embedded ? 'border rounded w-full' : '', className)}
// eslint-disable-next-line react/forbid-dom-props
style={{
minHeight,
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useEffect, useState } from 'react'

import { HogFunctionInputSchemaType, HogFunctionInputType } from '~/types'

import { EmailTemplater } from './email-templater/EmailTemplater'
import { hogFunctionConfigurationLogic } from './hogFunctionConfigurationLogic'
import { HogFunctionInputIntegration } from './integrations/HogFunctionInputIntegration'
import { HogFunctionInputIntegrationField } from './integrations/HogFunctionInputIntegrationField'
Expand All @@ -38,7 +39,7 @@ export type HogFunctionInputWithSchemaProps = {
schema: HogFunctionInputSchemaType
}

const typeList = ['string', 'boolean', 'dictionary', 'choice', 'json', 'integration'] as const
const typeList = ['string', 'boolean', 'dictionary', 'choice', 'json', 'integration', 'email'] as const

function JsonConfigField(props: {
onChange?: (value: string) => void
Expand Down Expand Up @@ -67,6 +68,22 @@ function JsonConfigField(props: {
)
}

function EmailTemplateField({ schema }: { schema: HogFunctionInputSchemaType }): JSX.Element {
const { exampleInvocationGlobalsWithInputs, logicProps } = useValues(hogFunctionConfigurationLogic)

return (
<>
<EmailTemplater
formLogic={hogFunctionConfigurationLogic}
formLogicProps={logicProps}
formKey="configuration"
formFieldsPrefix={`inputs.${schema.key}.value`}
globals={exampleInvocationGlobalsWithInputs}
/>
</>
)
}

function HogFunctionTemplateInput(props: Omit<CodeEditorInlineProps, 'globals'>): JSX.Element {
const { exampleInvocationGlobalsWithInputs } = useValues(hogFunctionConfigurationLogic)
return <CodeEditorInline {...props} globals={exampleInvocationGlobalsWithInputs} />
Expand Down Expand Up @@ -165,6 +182,8 @@ export function HogFunctionInputRenderer({ value, onChange, schema, disabled }:
return <HogFunctionInputIntegration schema={schema} value={value} onChange={onChange} />
case 'integration_field':
return <HogFunctionInputIntegrationField schema={schema} value={value} onChange={onChange} />
case 'email':
return <EmailTemplateField schema={schema} />
default:
return (
<strong className="text-danger">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { LemonButton, LemonLabel, LemonModal } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { CodeEditorInline } from 'lib/monaco/CodeEditorInline'
import { capitalizeFirstLetter } from 'lib/utils'
import EmailEditor from 'react-email-editor'

import { emailTemplaterLogic, EmailTemplaterLogicProps } from './emailTemplaterLogic'

function EmailTemplaterForm({
mode,
...props
}: EmailTemplaterLogicProps & {
mode: 'full' | 'preview'
}): JSX.Element {
const { setEmailEditorRef, emailEditorReady, setIsModalOpen } = useActions(emailTemplaterLogic(props))

return (
<Form
className="flex flex-col border rounded overflow-hidden flex-1"
logic={props.formLogic}
props={props.formLogicProps}
formKey={props.formKey}
>
{['from', 'to', 'subject'].map((field) => (
<LemonField
key={field}
name={`${props.formFieldsPrefix ? props.formFieldsPrefix + '.' : ''}${field}`}
className="border-b shrink-0 gap-1 pl-2"
// We will handle the error display ourselves
renderError={() => null}
>
{({ value, onChange, error }) => (
<div className="flex items-center">
<LemonLabel className={error ? 'text-danger' : ''}>
{capitalizeFirstLetter(field)}
</LemonLabel>
<CodeEditorInline
embedded
className="flex-1"
globals={props.globals}
value={value}
onChange={onChange}
/>
</div>
)}
</LemonField>
))}

{mode === 'full' ? (
<EmailEditor ref={(r) => setEmailEditorRef(r)} onReady={() => emailEditorReady()} />
) : (
<LemonField
name={`${props.formFieldsPrefix ? props.formFieldsPrefix + '.' : ''}html`}
className="relative flex flex-col"
>
{({ value }) => (
<>
<div className="absolute inset-0 p-2 flex items-end justify-center transition-opacity opacity-0 hover:opacity-100">
<div className="opacity-50 bg-bg-light absolute inset-0" />
<LemonButton type="primary" size="small" onClick={() => setIsModalOpen(true)}>
Click to modify content
</LemonButton>
</div>

<iframe srcDoc={value} className="flex-1" />
</>
)}
</LemonField>
)}
</Form>
)
}

export function EmailTemplaterModal({ ...props }: EmailTemplaterLogicProps): JSX.Element {
const { isModalOpen } = useValues(emailTemplaterLogic(props))
const { setIsModalOpen, onSave } = useActions(emailTemplaterLogic(props))

return (
<LemonModal isOpen={isModalOpen} width="90vw" onClose={() => setIsModalOpen(false)}>
<div className="h-[80vh] flex">
<div className="flex flex-col flex-1">
<div className="shrink-0">
<h2>Editing email template</h2>
</div>
<EmailTemplaterForm {...props} mode="full" />
<div className="flex items-center mt-2 gap-2">
<div className="flex-1" />
<LemonButton onClick={() => setIsModalOpen(false)}>Cancel</LemonButton>
<LemonButton type="primary" onClick={() => onSave()}>
Save
</LemonButton>
</div>
</div>
</div>
</LemonModal>
)
}

export function EmailTemplater(props: EmailTemplaterLogicProps): JSX.Element {
return (
<div className="flex flex-col flex-1">
<EmailTemplaterForm {...props} mode="preview" />
<EmailTemplaterModal {...props} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { actions, connect, kea, listeners, LogicWrapper, path, props, reducers } from 'kea'
import { capitalizeFirstLetter } from 'lib/utils'
import { EditorRef as _EditorRef } from 'react-email-editor'

import type { emailTemplaterLogicType } from './emailTemplaterLogicType'

// Helping kea-typegen navigate the exported type
export interface EditorRef extends _EditorRef {}

export type EmailTemplate = {
design: any
html: string
subject: string
text: string
from: string
to: string
}

export interface EmailTemplaterLogicProps {
formLogic: LogicWrapper
formLogicProps: any
formKey: string
formFieldsPrefix?: string
globals?: Record<string, any>
}

export const emailTemplaterLogic = kea<emailTemplaterLogicType>([
props({} as EmailTemplaterLogicProps),
connect({
// values: [teamLogic, ['currentTeam'], groupsModel, ['groupTypes'], userLogic, ['hasAvailableFeature']],
}),
path(() => ['scenes', 'pipeline', 'hogfunctions', 'emailTemplaterLogic']),
actions({
onSave: true,
setEmailEditorRef: (emailEditorRef: EditorRef | null) => ({ emailEditorRef }),
emailEditorReady: true,
setIsModalOpen: (isModalOpen: boolean) => ({ isModalOpen }),
}),
reducers({
emailEditorRef: [
null as EditorRef | null,
{
setEmailEditorRef: (_, { emailEditorRef }) => emailEditorRef,
},
],
isModalOpen: [
false,
{
setIsModalOpen: (_, { isModalOpen }) => isModalOpen,
},
],
}),

listeners(({ props, values, actions }) => ({
onSave: async () => {
const editor = values.emailEditorRef?.editor
if (!editor) {
return
}
const data = await new Promise<any>((res) => editor.exportHtml(res))

// TRICKY: We have to build the action we need in order to nicely callback to the form field
const setFormValue = props.formLogic.findMounted(props.formLogicProps)?.actions?.[
`set${capitalizeFirstLetter(props.formKey)}Value`
]

const pathParts = props.formFieldsPrefix ? props.formFieldsPrefix.split('.') : []

setFormValue(pathParts.concat('design'), data.design)
setFormValue(pathParts.concat('html'), escapeHTMLStringCurlies(data.html))

// Load the logic and set the property...
actions.setIsModalOpen(false)
},

emailEditorReady: () => {
const pathParts = (props.formFieldsPrefix ? props.formFieldsPrefix.split('.') : []).concat('design')

let value = props.formLogic.findMounted(props.formLogicProps)?.values?.[props.formKey]

// Get the value from the form and set it in the editor
while (pathParts.length && value) {
value = value[pathParts.shift()!]
}

if (value) {
values.emailEditorRef?.editor?.loadDesign(value)
}
},
})),
])

function escapeHTMLStringCurlies(htmlString: string): string {
const parser = new DOMParser()
const doc = parser.parseFromString(htmlString, 'text/html')

function escapeCurlyBraces(text: string): string {
return text.replace(/{/g, '\\{')
}

function processNode(node: Node): void {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement
if (element.tagName === 'STYLE' || element.tagName === 'SCRIPT') {
element.textContent = escapeCurlyBraces(element.textContent || '')
} else {
Array.from(node.childNodes).forEach(processNode)
}
} else if (node.nodeType === Node.COMMENT_NODE) {
const commentContent = (node as Comment).nodeValue || ''
;(node as Comment).nodeValue = escapeCurlyBraces(commentContent)
}
}

processNode(doc.head)
processNode(doc.body)

const serializer = new XMLSerializer()
return serializer.serializeToString(doc)
}
Loading

0 comments on commit 0c3d83d

Please sign in to comment.