Files
supabase/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
T
8057309e51 chore: upgrade next 13 + react 18 (#17839)
* update deps + image codemod (studio)

* update next links (studio)

* update deps

* update links (ui)

* remove next-transpile-modules

* move next-themes dependency

* chore: update ConfirmDialog

* chore: remove old ConfirmModal js file. migrated to TS

* dependency wrangling

* remove empty page

* update next links (www)

* First run bump react-data-grid-v7 beta 4

* fix package-lock.json

* more deps wrangling

* update recharts

* update sentry options

* fix some broken things in www

* studio fixes

* fix graphiql

* fix studio build

* fix menu hydration

* small build error

* update turbo

* fix www typescript errors

* docs image codemod

* links codemod docs

* fix docs typescript errors

* move useConsent to ui to prevent circular deps

* Fix links

* Fix homepage

* Fix links

* move studio/ to apps/

* Revert "move studio/ to apps/"

This reverts commit 1b0a985fcb.

* disable outputFileTracingRoot

* remove outputFileTracingRoot

* fix homepage product cards

* fix PrivacySettings links

* Fix links

* Fix the build for www.

* Minor fixes for JWTGenerator.

* Fix the docs and ui tests.

* Revert codehike back to 0.8.3

* remove ConfirmAlert()

* reenable babel because mobx hates me

* fix blog image and comparison page avatar

* Fix svg errors

* update image synthax

* Fix code hike

* Move the button in a div so that it doesn't inherit its parent height and make the button look weird.

* When components are defined in a component, they get recreated on each render. This makes them unstable in certain cases and causes infinite rerenders.

* Replace the next/head usage with next/script.

* Chore/upgrade next 13 fix table editor (#18431)

* fix table editor styling and fix row deletion logic

* Fix deleting selected rows from header, and fix checkboxes not clearing up

* Fix deleting all rows when filter applied, and fix deleting all rows

* Fix grid size styling issue

* Fix TS error

* Hydration errors

* studio org pages fixes

* fix more studio links

* audit logs fixes

* dropdown icon styling fixes

* fix some images in www

* upgrade to next 14

* try new sentry wrapper for api

* see if this is even invoked

* Revert "see if this is even invoked"

This reverts commit 86c3973ffa.

* Revert "try new sentry wrapper for api"

This reverts commit f67623ebad.

* Revert "upgrade to next 14"

This reverts commit a24dd6131e.

* chore: allow node version 19/20

* Try to fix the LogTable so that it renders with the newer "react-data-grid" version.

* Fix type errors in the log renderer code.

* Fix the replication screen.

* Add the CSS for the GraphiQL.

* Fix SQL editor results rendering

* Lint

* Fix SQL editor results height issue

* Fix auth RLS not invalidating RQ when toggling RLS

* Fix database tables new/edit column regressed

* Fix migrations page empty state if migrations schema not yet created

* Fix API side panel docs temp remove postgrest text for column description PK and FK

* Fix + improve timeout handling in SQL editor

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Francesco Sansalvadore <f.sansalvadore@gmail.com>
Co-authored-by: Terry Sutton <saltcod@gmail.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
Co-authored-by: Kevin Grüneberg <k.grueneberg1994@gmail.com>
2023-10-31 05:51:46 +00:00

555 lines
18 KiB
TypeScript

import { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta'
import Image from 'next/legacy/image'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'common/hooks'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms'
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation'
import {
EdgeFunctionsResponse,
useEdgeFunctionsQuery,
} from 'data/edge-functions/edge-functions-query'
import { getTable } from 'data/tables/table-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useStore } from 'hooks'
import { isValidHttpUrl, tryParseJson, uuidv4 } from 'lib/helpers'
import { Button, Checkbox, Form, Input, Listbox, Modal, Radio, SidePanel } from 'ui'
import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
import HTTPRequestFields from './HTTPRequestFields'
export interface EditHookPanelProps {
visible: boolean
selectedHook?: PostgresTrigger
onClose: () => void
}
export type HTTPArgument = { id: string; name: string; value: string }
const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => {
const { ref } = useParams()
const { ui } = useStore()
const submitRef = useRef<any>(null)
const [isEdited, setIsEdited] = useState(false)
const [isClosingPanel, setIsClosingPanel] = useState(false)
// [Joshen] There seems to be some bug between Checkbox.Group within the Form component
// hence why this external state as a temporary workaround
const [events, setEvents] = useState<string[]>([])
const [eventsError, setEventsError] = useState<string>()
// For HTTP request
const [httpHeaders, setHttpHeaders] = useState<HTTPArgument[]>([])
const [httpParameters, setHttpParameters] = useState<HTTPArgument[]>([])
const { project } = useProjectContext()
const { data } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const [isSubmitting, setIsSubmitting] = useState(false)
const { mutate: createDatabaseTrigger } = useDatabaseTriggerCreateMutation({
onSuccess: (res) => {
ui.setNotification({
category: 'success',
message: `Successfully created new webhook "${res.name}"`,
})
setIsSubmitting(false)
onClose()
},
onError: (error) => {
setIsSubmitting(false)
ui.setNotification({
error,
category: 'error',
message: `Failed to create webhook: ${error.message}`,
})
},
})
const { mutate: updateDatabaseTrigger } = useDatabaseTriggerUpdateMutation({
onSuccess: (res) => {
setIsSubmitting(false)
ui.setNotification({
category: 'success',
message: `Successfully updated webhook "${res.name}"`,
})
onClose()
},
onError: (error) => {
setIsSubmitting(false)
ui.setNotification({
error,
category: 'error',
message: `Failed to update webhook: ${error.message}`,
})
},
})
const tables = useMemo(
() => [...(data ?? [])].sort((a, b) => (a.schema > b.schema ? 0 : -1)),
[data]
)
const restUrl = project?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
const isEdgeFunction = (url: string) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
const initialValues = {
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: selectedHook?.function_args?.[1] ?? 'POST',
function_type: isEdgeFunction(selectedHook?.function_args?.[0] ?? '')
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 1000),
}
useEffect(() => {
if (visible) {
setIsEdited(false)
setIsClosingPanel(false)
// Reset form fields outside of the Form context
if (selectedHook !== undefined) {
setEvents(selectedHook.events)
const [url, method, headers, parameters] = selectedHook.function_args
const formattedHeaders = tryParseJson(headers) || {}
setHttpHeaders(
Object.keys(formattedHeaders).map((key) => {
return { id: uuidv4(), name: key, value: formattedHeaders[key] }
})
)
const formattedParameters = tryParseJson(parameters) || {}
setHttpParameters(
Object.keys(formattedParameters).map((key) => {
return { id: uuidv4(), name: key, value: formattedParameters[key] }
})
)
} else {
setEvents([])
setHttpHeaders([{ id: uuidv4(), name: 'Content-type', value: 'application/json' }])
setHttpParameters([{ id: uuidv4(), name: '', value: '' }])
}
}
}, [visible, selectedHook])
const onClosePanel = () => {
if (isEdited) setIsClosingPanel(true)
else onClose()
}
const onUpdateSelectedEvents = (event: string) => {
if (events.includes(event)) {
setEvents(events.filter((e) => e !== event))
} else {
setEvents(events.concat(event))
}
setEventsError(undefined)
}
const validate = (values: any) => {
const errors: any = {}
if (!values.name) {
errors['name'] = 'Please provide a name for your webhook'
}
if (!values.table_id) {
errors['table_id'] = 'Please select a table for which your webhook will trigger from'
}
if (values.function_type === 'http_request') {
// For HTTP requests
if (!values.http_url) {
errors['http_url'] = 'Please provide a URL'
} else if (!values.http_url.startsWith('http')) {
errors['http_url'] = 'Please include HTTP/HTTPs to your URL'
} else if (!isValidHttpUrl(values.http_url)) {
errors['http_url'] = 'Please provide a valid URL'
}
} else if (values.function_type === 'supabase_function') {
// For Supabase Edge Functions
if (values.http_url.includes('undefined')) {
errors['http_url'] = 'No edge functions available for selection'
}
}
if (values.timeout_ms < 1000 || values.timeout_ms > 5000) {
errors['timeout_ms'] = 'Timeout should be between 1000ms and 5000ms'
}
if (JSON.stringify(values) !== JSON.stringify(initialValues)) setIsEdited(true)
return errors
}
const onSubmit = async (values: any) => {
setIsSubmitting(true)
if (!project?.ref) {
return console.error('Project ref is required')
}
if (events.length === 0) {
return setEventsError('Please select at least one event')
}
const selectedTable = await getTable({
id: values.table_id,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (!selectedTable) {
setIsSubmitting(false)
return ui.setNotification({ category: 'error', message: 'Unable to find selected table' })
}
const headers = httpHeaders
.filter((header) => header.name && header.value)
.reduce((a: any, b: any) => {
a[b.name] = b.value
return a
}, {})
const parameters = httpParameters
.filter((param) => param.name && param.value)
.reduce((a: any, b: any) => {
a[b.name] = b.value
return a
}, {})
const payload: any = {
events,
activation: 'AFTER',
orientation: 'ROW',
enabled_mode: 'ORIGIN',
name: values.name,
table: selectedTable.name,
schema: selectedTable.schema,
table_id: values.table_id,
function_name: 'http_request',
function_schema: 'supabase_functions',
function_args: [
values.http_url,
values.http_method,
JSON.stringify(headers),
JSON.stringify(parameters),
values.timeout_ms.toString(),
],
}
if (selectedHook === undefined) {
createDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
payload,
})
} else {
updateDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
originalTrigger: selectedHook,
updatedTrigger: payload,
})
}
}
return (
<>
<SidePanel
size="xlarge"
visible={visible}
header={
selectedHook === undefined ? (
'Create a new database webhook'
) : (
<>
Update webhook <code className="text-sm">{selectedHook.name}</code>
</>
)
}
className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
onConfirm={() => {}}
onCancel={() => onClosePanel()}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-scale-500 px-3 py-4">
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
htmlType="button"
disabled={isSubmitting}
loading={isSubmitting}
onClick={() => submitRef?.current?.click()}
>
{selectedHook === undefined ? 'Create webhook' : 'Update webhook'}
</Button>
</div>
}
>
<Form validateOnBlur initialValues={initialValues} onSubmit={onSubmit} validate={validate}>
{({ values, resetForm, errors }: any) => {
return (
<FormContents
values={values}
resetForm={resetForm}
errors={errors}
projectRef={ref}
restUrlTld={restUrlTld}
functions={functions}
isEdgeFunction={isEdgeFunction}
tables={tables}
events={events}
eventsError={eventsError}
onUpdateSelectedEvents={onUpdateSelectedEvents}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
setHttpHeaders={setHttpHeaders}
setHttpParameters={setHttpParameters}
submitRef={submitRef}
selectedHook={selectedHook}
/>
)
}}
</Form>
</SidePanel>
<ConfirmationModal
visible={isClosingPanel}
header="Discard changes"
buttonLabel="Discard"
onSelectCancel={() => setIsClosingPanel(false)}
onSelectConfirm={() => {
setIsClosingPanel(false)
setIsEdited(false)
onClose()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
</ConfirmationModal>
</>
)
}
export default EditHookPanel
interface FormContentsProps {
values: any
resetForm: any
errors: any
projectRef?: string
restUrlTld?: string
selectedHook?: PostgresTrigger
functions: EdgeFunctionsResponse[] | undefined
isEdgeFunction: (url: string) => boolean
tables: PostgresTable[]
events: string[]
eventsError?: string
onUpdateSelectedEvents: (event: string) => void
httpHeaders: HTTPArgument[]
httpParameters: HTTPArgument[]
setHttpHeaders: (arr: HTTPArgument[]) => void
setHttpParameters: (arr: HTTPArgument[]) => void
submitRef: MutableRefObject<any>
}
const FormContents = ({
values,
resetForm,
errors,
projectRef,
restUrlTld,
selectedHook,
functions,
isEdgeFunction,
tables,
events,
eventsError,
onUpdateSelectedEvents,
httpHeaders,
httpParameters,
setHttpHeaders,
setHttpParameters,
submitRef,
}: FormContentsProps) => {
useEffect(() => {
if (values.function_type === 'http_request') {
if (selectedHook !== undefined) {
const [url, method] = selectedHook.function_args
const updatedValues = { ...values, http_url: url, http_method: method }
resetForm({ values: updatedValues, initialValues: updatedValues })
} else {
const updatedValues = { ...values, http_url: '' }
resetForm({ values: updatedValues, initialValues: updatedValues })
}
} else if (values.function_type === 'supabase_function') {
const fnSlug = (functions ?? [])[0]?.slug
const defaultFunctionUrl = `https://${projectRef}.supabase.${restUrlTld}/functions/v1/${fnSlug}`
const updatedValues = {
...values,
http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [values.function_type])
return (
<div>
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
name="name"
label="Name"
descriptionText="Do not use spaces/whitespaces"
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<p className="text-sm text-foreground-light">
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire webhook
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Listbox
size="medium"
id="table_id"
name="table_id"
label="Table"
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
<Listbox.Option
key={'table-no-selection'}
id={'table-no-selection'}
label={'---'}
value={'no-selection'}
>
---
</Listbox.Option>
{tables.map((table) => (
<Listbox.Option
key={table.id}
id={table.id.toString()}
value={table.id}
label={table.name}
>
<div className="flex items-center space-x-2">
<p className="text-foreground-light">{table.schema}</p>
<p className="text-foreground">{table.name}</p>
</div>
</Listbox.Option>
))}
</Listbox>
<Checkbox.Group
id="events"
name="events"
label="Events"
error={eventsError}
descriptionText="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
>
{HOOK_EVENTS.map((event) => (
<Checkbox
key={event.value}
value={event.value}
label={event.label}
description={event.description}
checked={events.includes(event.value)}
onChange={() => onUpdateSelectedEvents(event.value)}
/>
))}
</Checkbox.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Webhook configuration</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Radio.Group id="function_type" name="function_type" label="Type of webhook" type="cards">
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<Radio
key={webhook.value}
id={webhook.value}
value={webhook.value}
label=""
beforeLabel={
<div className="flex items-center space-x-5">
<Image src={webhook.icon} layout="fixed" width="32" height="32" />
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-foreground">{webhook.label}</p>
</div>
<p className="text-foreground-light">{webhook.description}</p>
</div>
</div>
}
/>
))}
</Radio.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<HTTPRequestFields
type={values.function_type}
errors={errors}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
onAddHeader={(header?: any) => {
if (header) setHttpHeaders(httpHeaders.concat(header))
else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' }))
}}
onUpdateHeader={(idx, property, value) =>
setHttpHeaders(
httpHeaders.map((header, i) => {
if (idx === i) return { ...header, [property]: value }
else return header
})
)
}
onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))}
onAddParameter={() =>
setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' }))
}
onUpdateParameter={(idx, property, value) =>
setHttpParameters(
httpParameters.map((param, i) => {
if (idx === i) return { ...param, [property]: value }
else return param
})
)
}
onRemoveParameter={(idx) => setHttpParameters(httpParameters.filter((_, i) => idx !== i))}
/>
<button ref={submitRef} type="submit" className="hidden" />
</div>
)
}