Skip to content

Commit

Permalink
Adds UI for creating webhook type campaigns (#130)
Browse files Browse the repository at this point in the history
* Adds UI for creating webhook type campaigns

More fixes to dropdowns
Improves locale creation

* Fixes linter
  • Loading branch information
pushchris authored Apr 18, 2023
1 parent 15e559b commit 13c0cfd
Show file tree
Hide file tree
Showing 21 changed files with 274 additions and 142 deletions.
1 change: 1 addition & 0 deletions apps/platform/src/projects/ProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const createProject = async (adminId: number, params: ProjectParams) => {
await createSubscription(project.id, { name: 'Default Email', channel: 'email' })
await createSubscription(project.id, { name: 'Default SMS', channel: 'text' })
await createSubscription(project.id, { name: 'Default Push', channel: 'push' })
await createSubscription(project.id, { name: 'Default Webhook', channel: 'webhook' })

return project
}
Expand Down
16 changes: 14 additions & 2 deletions apps/platform/src/providers/webhook/LocalWebhookProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,23 @@ export default class LocalWebhookProvider extends WebhookProvider {
const { method, endpoint, headers, body } = options
const response = await fetch(endpoint, {
method,
headers,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...headers,
},
body: JSON.stringify(body),
})

const responseBody = await response.json()
let responseBody: any | undefined
try {
responseBody = await response.json()
} catch {
try {
responseBody = await response.text()
} catch {}
}

if (response.ok) {
return {
message: options,
Expand Down
51 changes: 51 additions & 0 deletions apps/platform/src/render/TemplateController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ const templateDataPushParams = {
nullable: true,
}

const templateDataWebhookParams = {
type: 'object',
required: ['method', 'endpoint'],
properties: {
method: { type: 'string' },
endpoint: { type: 'string' },
body: {
type: 'object',
nullable: true,
additionalProperties: true,
},
headers: {
type: 'object',
nullable: true,
additionalProperties: true,
},
},
nullable: true,
}

const templateCreateParams: JSONSchemaType<TemplateParams> = {
$id: 'templateCreateParams',
oneOf: [{
Expand Down Expand Up @@ -147,6 +167,24 @@ const templateCreateParams: JSONSchemaType<TemplateParams> = {
data: templateDataPushParams as any,
},
additionalProperties: false,
},
{
type: 'object',
required: ['type', 'campaign_id', 'locale'],
properties: {
type: {
type: 'string',
enum: ['webhook'],
},
campaign_id: {
type: 'integer',
},
locale: {
type: 'string',
},
data: templateDataWebhookParams as any,
},
additionalProperties: false,
}],
}
router.post('/', async ctx => {
Expand Down Expand Up @@ -204,9 +242,22 @@ const templateUpdateParams: JSONSchemaType<TemplateUpdateParams> = {
data: templateDataPushParams as any,
},
additionalProperties: false,
},
{
type: 'object',
required: ['type', 'data'],
properties: {
type: {
type: 'string',
enum: ['webhook'],
},
data: templateDataWebhookParams as any,
},
additionalProperties: false,
}],
}
router.patch('/:templateId', async ctx => {
console.log(ctx.request.body)
const payload = validate(templateUpdateParams, ctx.request.body)
ctx.body = await updateTemplate(ctx.state.template!.id, payload)
})
Expand Down
8 changes: 8 additions & 0 deletions apps/platform/src/render/TemplateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RequestError } from '../core/errors'
import CampaignError from '../campaigns/CampaignError'
import { loadPushChannel } from '../providers/push'
import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository'
import { loadWebhookChannel } from '../providers/webhook'

export const pagedTemplates = async (params: SearchParams, projectId: number) => {
return await Template.searchParams(
Expand Down Expand Up @@ -95,6 +96,13 @@ export const sendProof = async (template: TemplateType, variables: Variables, re
event,
context,
})
} else if (template.type === 'webhook') {
const channel = await loadWebhookChannel(campaign.provider_id, projectId)
await channel?.send(template, {
user: User.fromJson({ ...variables.user, data: variables.user }),
event,
context,
})
} else {
throw new RequestError('Sending template proofs is only supported for email and text message types as this time.')
}
Expand Down
7 changes: 3 additions & 4 deletions apps/ui/src/contexts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createContext, Dispatch, SetStateAction } from 'react'
import { Admin, Campaign, Journey, List, Project, Template, User, UseStateContext } from './types'
import { FieldOption } from './ui/form/Field'
import { Admin, Campaign, Journey, List, LocaleOption, Project, Template, User, UseStateContext } from './types'

export const AdminContext = createContext<null | Admin>(null)

Expand All @@ -15,8 +14,8 @@ export const JourneyContext = createContext<UseStateContext<Journey>>([
])

export interface LocaleSelection {
currentLocale?: FieldOption
allLocales: FieldOption[]
currentLocale?: LocaleOption
allLocales: LocaleOption[]
}
export const LocaleContext = createContext<UseStateContext<LocaleSelection>>([
{ allLocales: [] },
Expand Down
5 changes: 5 additions & 0 deletions apps/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,8 @@ export interface Metric {
date: string | Date
count: number
}

export interface LocaleOption {
key: string
label: string
}
4 changes: 4 additions & 0 deletions apps/ui/src/ui/Preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,8 @@

.push-notification .notification-body {
grid-area: body;
}

.webhook-frame {
padding: 20px 10px;
}
9 changes: 8 additions & 1 deletion apps/ui/src/ui/Preview.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JsonViewer } from '@textea/json-viewer'
import { format } from 'date-fns'
import { Template } from '../types'
import Iframe from './Iframe'
Expand Down Expand Up @@ -39,14 +40,20 @@ export default function Preview({ template }: PreviewProps) {
</div>
</div>

const WebhookFrame = () => (
<div className="webhook-frame">
<JsonViewer value={data} rootName={false} />
</div>
)

return (
<section className="preview">
{
{
email: <EmailFrame />,
text: <TextFrame />,
push: <PushFrame />,
webhook: <></>,
webhook: <WebhookFrame />,
}[type]
}
</section>
Expand Down
64 changes: 26 additions & 38 deletions apps/ui/src/ui/form/EntityIdPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Combobox, Transition } from '@headlessui/react'
import { useCallback, useState, Fragment, RefCallback, ReactNode } from 'react'
import { Combobox } from '@headlessui/react'
import { useCallback, useState, RefCallback, ReactNode } from 'react'
import { useResolver } from '../../hooks'
import { ControlledInputProps, SearchResult } from '../../types'
import clsx from 'clsx'
Expand Down Expand Up @@ -132,45 +132,33 @@ export function EntityIdPicker<T extends { id: number }>({
)
}
</div>
<div className="select-options-wrapper"
<Combobox.Options className="select-options"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}>
<Transition
as={Fragment}
leave="transition-leave"
leaveFrom="transition-leave-from"
leaveTo="transition-leave-to"
enter="transition-enter"
enterFrom="transition-enter-from"
enterTo="transition-enter-to"
>
<Combobox.Options className="select-options">
{
result?.results.map((option) => (
<Combobox.Option
key={option.id}
value={option}
className={({ active, disabled, selected }) => clsx(
'select-option',
active && 'active',
disabled && 'disabled',
selected && 'selected',
)}
disabled={!optionEnabled(option)}
>
<span>
{displayValue(option)}
</span>
<span className="option-icon">
<CheckIcon aria-hidden="true" />
</span>
</Combobox.Option>
))
}
</Combobox.Options>
</Transition>
</div>
{
result?.results.map((option) => (
<Combobox.Option
key={option.id}
value={option}
className={({ active, disabled, selected }) => clsx(
'select-option',
active && 'active',
disabled && 'disabled',
selected && 'selected',
)}
disabled={!optionEnabled(option)}
>
<span>
{displayValue(option)}
</span>
<span className="option-icon">
<CheckIcon aria-hidden="true" />
</span>
</Combobox.Option>
))
}
</Combobox.Options>
</Combobox>
)
}
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/ui/form/FormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function FormWrapper<T extends FieldValues>({
const handleSubmit = form.handleSubmit(async data => {
setIsLoading(true)
onSubmit(data, navigate).finally(() => {
console.log('saved!', data)
setIsLoading(false)
})
})
Expand All @@ -36,6 +37,8 @@ export default function FormWrapper<T extends FieldValues>({
const keys = Object.keys(errors)
if (keys.length === 0) return undefined

console.log('error 1')

const key = keys[0]
const error = errors[key]
if (error) {
Expand Down
35 changes: 35 additions & 0 deletions apps/ui/src/ui/form/JsonField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import TextInput, { TextInputProps } from './TextInput'
import { FieldProps } from '../../types'
import { FieldPath, FieldValues, useController } from 'react-hook-form'
import { useState } from 'react'

export default function JsonField<X extends FieldValues, P extends FieldPath<X>>({
form,
name,
required,
...rest
}: Omit<TextInputProps<P>, 'onChange' | 'onBlur' | 'value'> & FieldProps<X, P>) {
const { field: { ref, value, ...field }, fieldState } = useController({
control: form.control,
name,
rules: {
required,
},
})
const [jsonValue, setJsonValue] = useState(JSON.stringify(value))

return (
<TextInput
{...rest}
{...field}
value={jsonValue}
inputRef={ref}
onChange={async (value) => {
setJsonValue(value)
await field.onChange?.(JSON.parse(value))
}}
required={required}
error={fieldState.error?.message}
/>
)
}
58 changes: 23 additions & 35 deletions apps/ui/src/ui/form/MultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Listbox, Transition } from '@headlessui/react'
import { Listbox } from '@headlessui/react'
import { Fragment, ReactNode } from 'react'
import { CheckIcon, ChevronUpDownIcon } from '../icons'
import { FieldPath, FieldValues, useController } from 'react-hook-form'
Expand Down Expand Up @@ -131,43 +131,31 @@ export function MultiSelect<T, U = T>({
</span>
)
}
<div className="select-options-wrapper"
<Listbox.Options className="select-options"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}>
<Transition
as={Fragment}
leave="transition-leave"
leaveFrom="transition-leave-from"
leaveTo="transition-leave-to"
enter="transition-enter"
enterFrom="transition-enter-from"
enterTo="transition-enter-to"
>
<Listbox.Options className="select-options">
{options.map((option) => {
const value = toValue(option)
return (
<Listbox.Option
key={getValueKey(value)}
value={value}
className={({ active, selected }) => clsx(
'select-option',
active && 'active',
selected && 'selected',
)}
>
<span>{getOptionDisplay(option)}</span>
<span className="option-icon">
<CheckIcon aria-hidden="true" />
</span>
</Listbox.Option>
)
})}
{optionsFooter}
</Listbox.Options>
</Transition>
</div>
{options.map((option) => {
const value = toValue(option)
return (
<Listbox.Option
key={getValueKey(value)}
value={value}
className={({ active, selected }) => clsx(
'select-option',
active && 'active',
selected && 'selected',
)}
>
<span>{getOptionDisplay(option)}</span>
<span className="option-icon">
<CheckIcon aria-hidden="true" />
</span>
</Listbox.Option>
)
})}
{optionsFooter}
</Listbox.Options>
</Listbox>
)
}
Expand Down
Loading

0 comments on commit 13c0cfd

Please sign in to comment.