Merge pull request #728 from omnivore-app/webhook-ui

Create webhook settings ui
This commit is contained in:
Hongbo Wu 2022-06-03 17:14:54 +08:00 committed by GitHub
commit f18542f865
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 692 additions and 29 deletions

View file

@ -19,17 +19,21 @@ const CheckboxIndicator = styled(CheckboxPrimitive.Indicator, {
color: '#FFFFFF',
})
export const CheckboxComponent:React.FC<{
checked: boolean;
setChecked:(arg: boolean) => void;
}> = ({checked, setChecked}) => {
const toggleChecked = () => setChecked(!checked);
export const CheckboxComponent: React.FC<{
checked: boolean
setChecked: (arg: boolean) => void
}> = ({ checked, setChecked }) => {
const toggleChecked = () => setChecked(!checked)
return (
<Checkbox css={{
border: checked ? '2px solid #F9D354': '2px solid #3F3E3C4D',
backgroundColor: checked ? '#F9D354' : '#FFFFFF'
}} checked={checked} onCheckedChange={toggleChecked}>
<Checkbox
css={{
border: checked ? '2px solid #F9D354' : '2px solid #3F3E3C4D',
backgroundColor: checked ? '#F9D354' : '#FFFFFF',
}}
checked={checked}
onCheckedChange={toggleChecked}
>
<CheckboxIndicator>
<CheckIcon />
</CheckboxIndicator>

View file

@ -1,4 +1,22 @@
import { styled } from '../tokens/stitches.config'
import { useState } from 'react'
import Checkbox from './Checkbox'
import { Box, HStack, VStack } from './LayoutPrimitives'
import { StyledText } from './StyledText'
export interface FormInputProps {
name: string
label: string
value?: any
onChange?: (value: any) => void
type?: string
placeholder?: string
disabled?: boolean
hidden?: boolean
required?: boolean
css?: any
options?: string[]
}
export const FormInput = styled('input', {
border: 'none',
@ -19,3 +37,66 @@ export const BorderedFormInput = styled(FormInput, {
border: `1px solid $grayBorder`,
p: '$3',
})
export function GeneralFormInput(props: FormInputProps): JSX.Element {
const [input, setInput] = useState<FormInputProps>(props)
if (props.type === 'checkbox') {
return (
<VStack>
{input.options?.map((label, index) => (
<HStack key={index}>
<StyledText>{label}</StyledText>
<Box css={{ padding: '10px 0 0 10px' }}>
<Checkbox
key={index}
checked={input.value[index]}
setChecked={(arg) => {
input.value[index] = arg
setInput(input)
props.onChange &&
props.onChange(
input.options?.filter((_, i) => input.value[i])
)
}}
></Checkbox>
</Box>
</HStack>
))}
</VStack>
)
} else {
return (
<FormInput
key={input.name}
type={input.type || 'text'}
value={input.value}
placeholder={input.placeholder}
onChange={(event) => {
if (input.onChange) {
setInput({ ...input, value: event.target.value })
input.onChange(event.target.value)
}
}}
disabled={input.disabled}
hidden={input.hidden}
required={input.required}
css={{
border: '1px solid $grayBorder',
borderRadius: '8px',
width: '100%',
bg: 'transparent',
fontSize: '16px',
textIndent: '8px',
marginBottom: '2px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
},
}}
name={input.name}
/>
)
}
}

View file

@ -3,7 +3,7 @@ import { styled } from '../tokens/stitches.config'
import { StyledText } from './StyledText'
import { InfoLink } from './InfoLink'
import { Button } from './Button'
import { Plus, Trash } from 'phosphor-react'
import { PencilSimple, Plus, Trash } from 'phosphor-react'
import { isDarkTheme } from '../../lib/themeUpdater'
interface TableProps {
@ -11,8 +11,9 @@ interface TableProps {
infoLink?: string
onAdd?: () => void
headers: string[]
rows: string[][]
rows: Map<string, Record<string, any>>
onDelete?: (id: string) => void
onEdit?: (obj: any) => void
}
const HeaderWrapper = styled(Box, {
@ -157,14 +158,20 @@ export function Table(props: TableProps): JSX.Element {
color: '$grayTextContrast',
textTransform: 'uppercase',
},
width: '240px',
}}
>
{header}
</StyledText>
</Box>
))}
<Box
css={{
width: '120px',
}}
></Box>
</TableHeading>
{props.rows.map((row, index) => (
{Array.from(props.rows.keys()).map((key, index) => (
<TableCard
key={index}
css={{
@ -175,9 +182,8 @@ export function Table(props: TableProps): JSX.Element {
borderTopLeftRadius: index === 0 ? '5px' : '',
borderTopRightRadius: index === 0 ? '5px' : '',
},
borderBottomLeftRadius: index == props.rows.length - 1 ? '5px' : '',
borderBottomRightRadius:
index == props.rows.length - 1 ? '5px' : '',
borderBottomLeftRadius: index == props.rows.size - 1 ? '5px' : '',
borderBottomRightRadius: index == props.rows.size - 1 ? '5px' : '',
padding: '10px 20px 10px 40px',
}}
>
@ -191,7 +197,7 @@ export function Table(props: TableProps): JSX.Element {
},
}}
>
{row.map((cell, index) => (
{Object.values(props.rows.get(key) || {}).map((cell, index) => (
<HStack
key={index}
css={{
@ -210,12 +216,24 @@ export function Table(props: TableProps): JSX.Element {
></Input>
</HStack>
))}
{props.onEdit && (
<IconButton
style="ctaWhite"
css={{ mr: '$1', background: '$labelButtonsBg' }}
onClick={() => {
props.onEdit &&
props.onEdit({ ...props.rows.get(key), id: key })
}}
>
<PencilSimple size={24} color={iconColor} />
</IconButton>
)}
{props.onDelete && (
<IconButton
style="ctaWhite"
css={{ mr: '$1', background: '$labelButtonsBg' }}
onClick={() => {
props.onDelete && props.onDelete(row[0])
props.onDelete && props.onDelete(key)
}}
>
<Trash size={16} color={iconColor} />

View file

@ -0,0 +1,108 @@
import {
ModalContent,
ModalOverlay,
ModalRoot,
} from '../elements/ModalPrimitives'
import { Box, HStack, VStack } from '../elements/LayoutPrimitives'
import { Button } from '../elements/Button'
import { StyledText } from '../elements/StyledText'
import { useState } from 'react'
import { FormInputProps, GeneralFormInput } from '../elements/FormElements'
import { CrossIcon } from '../elements/images/CrossIcon'
import { theme } from '../tokens/stitches.config'
export interface FormModalProps {
inputs?: FormInputProps[]
title: string
acceptButtonLabel?: string
onSubmit: () => void
onOpenChange: (open: boolean) => void
}
export function FormModal(props: FormModalProps): JSX.Element {
const [inputs, setInputs] = useState<FormInputProps[]>(props.inputs || [])
return (
<ModalRoot defaultOpen onOpenChange={props.onOpenChange}>
<ModalOverlay />
<ModalContent
onPointerDownOutside={(event) => {
event.preventDefault()
props.onOpenChange(false)
}}
css={{ overflow: 'auto', p: '0' }}
>
<VStack>
<HStack
distribution="between"
alignment="center"
css={{ width: '100%' }}
>
<StyledText style="modalHeadline" css={{ p: '16px' }}>
{props.title}
</StyledText>
<Button
css={{ pt: '16px', pr: '16px' }}
style="ghost"
onClick={() => {
props.onOpenChange(false)
}}
>
<CrossIcon
size={20}
strokeColor={theme.colors.grayText.toString()}
/>
</Button>
</HStack>
<Box css={{ width: '100%' }}>
<form
onSubmit={(event) => {
event.preventDefault()
props.onSubmit()
props.onOpenChange(false)
}}
>
{inputs.map((input, index) => (
<HStack key={index} css={{ padding: '10px 0 0 10px' }}>
<Box
css={{
p: '0',
width: '25%',
paddingLeft: '16px',
paddingTop: '5px',
}}
>
<StyledText style={'highlightTitle'}>
{input.label}
</StyledText>
</Box>
<Box css={{ width: '100%' }}>
<GeneralFormInput {...input} />
</Box>
</HStack>
))}
<HStack
alignment={'center'}
distribution="center"
css={{
padding: '20px 0 20px 0',
}}
>
<Button
style={'ctaPill'}
onClick={() => props.onOpenChange(false)}
css={{ marginRight: '20px' }}
>
Cancel
</Button>
<Button style={'ctaDarkYellow'}>
{props.acceptButtonLabel || 'Submit'}
</Button>
</HStack>
</form>
</Box>
</VStack>
</ModalContent>
</ModalRoot>
)
}

View file

@ -0,0 +1,39 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { Webhook } from '../queries/useGetWebhooksQuery'
interface DeleteWebhookResult {
deleteWebhook: DeleteWebhook
errorCodes?: unknown[]
}
type DeleteWebhook = {
webhook: Webhook
}
export async function deleteWebhookMutation(
id: string
): Promise<any | undefined> {
const mutation = gql`
mutation {
deleteWebhook(id: "${id}") {
... on DeleteWebhookSuccess {
webhook {
id
}
}
... on DeleteWebhookError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation)) as DeleteWebhookResult
return data.errorCodes ? undefined : data.deleteWebhook.webhook.id
} catch (error) {
console.log('deleteWebhookMutation error', error)
return undefined
}
}

View file

@ -0,0 +1,50 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { Webhook, WebhookEvent } from '../queries/useGetWebhooksQuery'
export interface SetWebhookInput {
contentType?: string[]
enabled?: boolean
eventTypes: WebhookEvent[]
id?: string
method?: string
url: string
}
interface SetWebhookResult {
setWebhook: SetWebhook
errorCodes?: unknown[]
}
type SetWebhook = {
webhook: Webhook
}
export async function setWebhookMutation(
input: SetWebhookInput
): Promise<string | undefined> {
const mutation = gql`
mutation SetWebhook($input: SetWebhookInput!) {
setWebhook(input: $input) {
... on SetWebhookSuccess {
webhook {
id
}
}
... on SetWebhookError {
errorCodes
}
}
}
`
try {
const data = (await gqlFetcher(mutation, {
input,
})) as SetWebhookResult
return data.errorCodes ? undefined : data.setWebhook.webhook.id
} catch (error) {
console.log('setWebhookMutation error', error)
return undefined
}
}

View file

@ -0,0 +1,62 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { makeGqlFetcher } from '../networkHelpers'
import { Webhook } from './useGetWebhooksQuery'
interface WebhookQueryResponse {
isValidating?: boolean
webhook?: Webhook
revalidate?: () => void
}
interface WebhookQueryResponseData {
webhook: WebhookData
}
interface WebhookData {
webhook: unknown
}
export function useGetWebhookQuery(id: string): WebhookQueryResponse {
const query = gql`
query GetWebhook($id: ID!) {
webhook(id: $id) {
... on WebhookSuccess {
webhook {
id
url
eventTypes
contentType
method
enabled
createdAt
updatedAt
}
}
... on WebhookError {
errorCodes
}
}
}
`
const { data, mutate, isValidating } = useSWR(query, makeGqlFetcher({ id }))
console.log('webhook data', data)
try {
if (data) {
const result = data as WebhookQueryResponseData
const webhook = result.webhook.webhook as Webhook
return {
isValidating,
webhook,
revalidate: () => {
mutate()
},
}
}
} catch (error) {
console.log('error', error)
}
return {}
}

View file

@ -0,0 +1,85 @@
import { gql } from 'graphql-request'
import useSWR from 'swr'
import { publicGqlFetcher } from '../networkHelpers'
export type WebhookEvent =
| 'PAGE_CREATED'
| 'PAGE_UPDATED'
| 'PAGE_DELETED'
| 'HIGHLIGHT_CREATED'
| 'HIGHLIGHT_UPDATED'
| 'HIGHLIGHT_DELETED'
export interface Webhook {
id: string
url: string
eventTypes: WebhookEvent[]
contentType: string
method: string
enabled: boolean
createdAt: Date
updatedAt: Date
}
interface WebhooksQueryResponse {
isValidating: boolean
webhooks: Webhook[]
revalidate: () => void
}
interface WebhooksQueryResponseData {
webhooks: WebhooksData
}
interface WebhooksData {
webhooks: unknown
}
export function useGetWebhooksQuery(): WebhooksQueryResponse {
const query = gql`
query GetWebhooks {
webhooks {
... on WebhooksSuccess {
webhooks {
id
url
eventTypes
contentType
method
enabled
createdAt
updatedAt
}
}
... on WebhooksError {
errorCodes
}
}
}
`
const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher)
console.log('webhooks data', data)
try {
if (data) {
const result = data as WebhooksQueryResponseData
const webhooks = result.webhooks.webhooks as Webhook[]
return {
isValidating,
webhooks,
revalidate: () => {
mutate()
},
}
}
} catch (error) {
console.log('error', error)
}
return {
isValidating: false,
webhooks: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
revalidate: () => {},
}
}

View file

@ -27,11 +27,14 @@ export default function SubscriptionsPage(): JSX.Element {
}
const headers = ['Name', 'Email', 'Updated Time']
const rows = subscriptions.map((subscription) => [
subscription.name,
subscription.newsletterEmail,
subscription.updatedAt.toString(),
])
const rows = new Map<string, string[]>()
subscriptions.forEach((subscription) =>
rows.set(subscription.name, [
subscription.name,
subscription.newsletterEmail,
subscription.updatedAt.toString(),
])
)
return (
<PrimaryLayout pageTestId="settings-subscriptions-tag">
@ -64,13 +67,18 @@ export default function SubscriptionsPage(): JSX.Element {
<Table
heading={'Popular Newsletters'}
headers={['Substack', 'Axios', 'Bloomberg']}
rows={[
[
'https://substack.com/',
'https://www.axios.com/newsletters',
'https://www.bloomberg.com/account/newsletters',
],
]}
rows={
new Map([
[
'0',
[
'https://substack.com/',
'https://www.axios.com/newsletters',
'https://www.bloomberg.com/account/newsletters',
],
],
])
}
/>
</PrimaryLayout>
)

View file

@ -0,0 +1,208 @@
import { PrimaryLayout } from '../../components/templates/PrimaryLayout'
import { Toaster } from 'react-hot-toast'
import { Table } from '../../components/elements/Table'
import { applyStoredTheme } from '../../lib/themeUpdater'
import {
useGetWebhooksQuery,
WebhookEvent,
} from '../../lib/networking/queries/useGetWebhooksQuery'
import { useMemo, useState } from 'react'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
import { deleteWebhookMutation } from '../../lib/networking/mutations/deleteWebhookMutation'
import { FormModal } from '../../components/patterns/FormModal'
import { setWebhookMutation } from '../../lib/networking/mutations/setWebhookMutation'
import { FormInputProps } from '../../components/elements/FormElements'
interface Webhook {
id?: string
url: string
eventTypes: string
contentType?: string
method?: string
enabled?: string
createdAt?: Date
updatedAt?: Date
}
export default function Webhooks(): JSX.Element {
const { webhooks, revalidate } = useGetWebhooksQuery()
const [onDeleteId, setOnDeleteId] = useState<string | null>(null)
const [addModelOpen, setAddModelOpen] = useState(false)
const [onEditWebhook, setOnEditWebhook] = useState<Webhook | null>(null)
const [url, setUrl] = useState('')
const eventTypeOptions = ['PAGE_CREATED', 'HIGHLIGHT_CREATED']
const [eventTypes, setEventTypes] = useState<WebhookEvent[]>([])
const [contentType, setContentType] = useState('application/json')
const [method, setMethod] = useState('POST')
const [formInputs, setFormInputs] = useState<FormInputProps[]>([])
const headers = ['URL', 'Event Types', 'Method', 'Content Type']
const rows = useMemo(() => {
const rows = new Map<string, Webhook>()
webhooks.forEach((webhook) =>
rows.set(webhook.id, {
url: webhook.url,
eventTypes: webhook.eventTypes.join(', '),
method: webhook.method,
contentType: webhook.contentType,
})
)
return rows
}, [webhooks])
applyStoredTheme(false)
async function onDelete(id: string): Promise<void> {
const result = await deleteWebhookMutation(id)
if (result) {
showSuccessToast('Webhook deleted', { position: 'bottom-right' })
} else {
showErrorToast('Failed to delete', { position: 'bottom-right' })
}
revalidate()
}
async function onCreate(): Promise<void> {
const result = await setWebhookMutation({ url, eventTypes })
if (result) {
showSuccessToast('Webhook created', { position: 'bottom-right' })
} else {
showErrorToast('Failed to add', { position: 'bottom-right' })
}
revalidate()
}
async function onUpdate(): Promise<void> {
const result = await setWebhookMutation({
id: onEditWebhook?.id,
url,
eventTypes,
})
if (result) {
showSuccessToast('Webhook updated', { position: 'bottom-right' })
} else {
showErrorToast('Failed to update', { position: 'bottom-right' })
}
revalidate()
}
return (
<PrimaryLayout pageTestId={'webhooks'}>
<Toaster
containerStyle={{
top: '5rem',
}}
/>
{addModelOpen && (
<FormModal
title={'Add webhook'}
onSubmit={onCreate}
onOpenChange={setAddModelOpen}
inputs={formInputs}
acceptButtonLabel={'Add'}
/>
)}
{onEditWebhook && (
<FormModal
title={'Edit webhook'}
onSubmit={onUpdate}
onOpenChange={() => setOnEditWebhook(null)}
inputs={formInputs}
acceptButtonLabel={'Update'}
/>
)}
{onDeleteId && (
<ConfirmationModal
message={
'Future events will no longer be delivered to this webhook. This action cannot be undone.'
}
onAccept={async () => {
await onDelete(onDeleteId)
setOnDeleteId(null)
}}
onOpenChange={() => setOnDeleteId(null)}
/>
)}
<Table
heading={'Webhooks'}
headers={headers}
rows={rows}
onDelete={setOnDeleteId}
onAdd={() => {
setFormInputs([
{
label: 'URL',
onChange: setUrl,
name: 'url',
placeholder: 'https://example.com/webhook',
required: true,
},
{
label: 'Event Types',
name: 'eventTypes',
value: [true, true],
onChange: setEventTypes,
options: eventTypeOptions,
type: 'checkbox',
},
{
label: 'Method',
name: 'method',
value: method,
disabled: true,
},
{
label: 'Content Type',
name: 'contentType',
value: contentType,
disabled: true,
},
])
setUrl('')
setEventTypes(eventTypeOptions as WebhookEvent[])
setAddModelOpen(true)
}}
onEdit={(webhook) => {
setFormInputs([
{
label: 'URL',
onChange: setUrl,
name: 'url',
value: webhook?.url,
required: true,
},
{
label: 'Event Types',
name: 'eventTypes',
value: eventTypeOptions.map((option) =>
webhook?.eventTypes.includes(option)
),
onChange: setEventTypes,
options: eventTypeOptions,
type: 'checkbox',
},
{
label: 'Method',
name: 'method',
value: method,
disabled: true,
},
{
label: 'Content Type',
name: 'contentType',
value: contentType,
disabled: true,
},
])
setUrl(webhook?.url)
setEventTypes(webhook?.eventTypes)
setOnEditWebhook(webhook)
}}
/>
</PrimaryLayout>
)
}