mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
* 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 commit1b0a985fcb. * 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 commit86c3973ffa. * Revert "try new sentry wrapper for api" This reverts commitf67623ebad. * Revert "upgrade to next 14" This reverts commita24dd6131e. * 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>
555 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|