diff --git a/packages/web/components/elements/Checkbox.tsx b/packages/web/components/elements/Checkbox.tsx index ac7f91d7a..f09997077 100644 --- a/packages/web/components/elements/Checkbox.tsx +++ b/packages/web/components/elements/Checkbox.tsx @@ -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 ( - + diff --git a/packages/web/components/elements/FormElements.tsx b/packages/web/components/elements/FormElements.tsx index 6b9acb5e6..f7896089f 100644 --- a/packages/web/components/elements/FormElements.tsx +++ b/packages/web/components/elements/FormElements.tsx @@ -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(props) + + if (props.type === 'checkbox') { + return ( + + {input.options?.map((label, index) => ( + + {label} + + { + input.value[index] = arg + setInput(input) + props.onChange && + props.onChange( + input.options?.filter((_, i) => input.value[i]) + ) + }} + > + + + ))} + + ) + } else { + return ( + { + 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} + /> + ) + } +} diff --git a/packages/web/components/elements/Table.tsx b/packages/web/components/elements/Table.tsx index 38ba1587f..c4e79ef36 100644 --- a/packages/web/components/elements/Table.tsx +++ b/packages/web/components/elements/Table.tsx @@ -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> 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} ))} + - {props.rows.map((row, index) => ( + {Array.from(props.rows.keys()).map((key, index) => ( @@ -191,7 +197,7 @@ export function Table(props: TableProps): JSX.Element { }, }} > - {row.map((cell, index) => ( + {Object.values(props.rows.get(key) || {}).map((cell, index) => ( ))} + {props.onEdit && ( + { + props.onEdit && + props.onEdit({ ...props.rows.get(key), id: key }) + }} + > + + + )} {props.onDelete && ( { - props.onDelete && props.onDelete(row[0]) + props.onDelete && props.onDelete(key) }} > diff --git a/packages/web/components/patterns/FormModal.tsx b/packages/web/components/patterns/FormModal.tsx new file mode 100644 index 000000000..c6a3592c2 --- /dev/null +++ b/packages/web/components/patterns/FormModal.tsx @@ -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(props.inputs || []) + + return ( + + + { + event.preventDefault() + props.onOpenChange(false) + }} + css={{ overflow: 'auto', p: '0' }} + > + + + + {props.title} + + + + +
{ + event.preventDefault() + props.onSubmit() + props.onOpenChange(false) + }} + > + {inputs.map((input, index) => ( + + + + {input.label} + + + + + + + ))} + + + + +
+
+
+
+
+ ) +} diff --git a/packages/web/lib/networking/mutations/deleteWebhookMutation.ts b/packages/web/lib/networking/mutations/deleteWebhookMutation.ts new file mode 100644 index 000000000..0533c6b77 --- /dev/null +++ b/packages/web/lib/networking/mutations/deleteWebhookMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/mutations/setWebhookMutation.ts b/packages/web/lib/networking/mutations/setWebhookMutation.ts new file mode 100644 index 000000000..e291cdc37 --- /dev/null +++ b/packages/web/lib/networking/mutations/setWebhookMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/queries/useGetWebhookQuery.tsx b/packages/web/lib/networking/queries/useGetWebhookQuery.tsx new file mode 100644 index 000000000..e5e05a6d2 --- /dev/null +++ b/packages/web/lib/networking/queries/useGetWebhookQuery.tsx @@ -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 {} +} diff --git a/packages/web/lib/networking/queries/useGetWebhooksQuery.tsx b/packages/web/lib/networking/queries/useGetWebhooksQuery.tsx new file mode 100644 index 000000000..dedd16ceb --- /dev/null +++ b/packages/web/lib/networking/queries/useGetWebhooksQuery.tsx @@ -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: () => {}, + } +} diff --git a/packages/web/pages/settings/subscriptions.tsx b/packages/web/pages/settings/subscriptions.tsx index fba1a7b43..e09d12a89 100644 --- a/packages/web/pages/settings/subscriptions.tsx +++ b/packages/web/pages/settings/subscriptions.tsx @@ -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() + subscriptions.forEach((subscription) => + rows.set(subscription.name, [ + subscription.name, + subscription.newsletterEmail, + subscription.updatedAt.toString(), + ]) + ) return ( @@ -64,13 +67,18 @@ export default function SubscriptionsPage(): JSX.Element { ) diff --git a/packages/web/pages/settings/webhooks.tsx b/packages/web/pages/settings/webhooks.tsx new file mode 100644 index 000000000..7f976c1b0 --- /dev/null +++ b/packages/web/pages/settings/webhooks.tsx @@ -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(null) + const [addModelOpen, setAddModelOpen] = useState(false) + const [onEditWebhook, setOnEditWebhook] = useState(null) + const [url, setUrl] = useState('') + const eventTypeOptions = ['PAGE_CREATED', 'HIGHLIGHT_CREATED'] + const [eventTypes, setEventTypes] = useState([]) + const [contentType, setContentType] = useState('application/json') + const [method, setMethod] = useState('POST') + const [formInputs, setFormInputs] = useState([]) + + const headers = ['URL', 'Event Types', 'Method', 'Content Type'] + const rows = useMemo(() => { + const rows = new Map() + 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 { + 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 { + 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 { + 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 ( + + + + {addModelOpen && ( + + )} + + {onEditWebhook && ( + setOnEditWebhook(null)} + inputs={formInputs} + acceptButtonLabel={'Update'} + /> + )} + + {onDeleteId && ( + { + await onDelete(onDeleteId) + setOnDeleteId(null) + }} + onOpenChange={() => setOnDeleteId(null)} + /> + )} +
{ + 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) + }} + /> + + ) +}