Files
supabase/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx
Nick Rayburn 0a0cdc16a5 Merge remote-tracking branch 'upstream/master' into fix/type-cleanup
# Conflicts:
#	studio/components/interfaces/Database/Triggers/ChooseFunctionForm.tsx
#	studio/components/interfaces/Home/ProjectUsage.tsx
#	studio/components/ui/Usage.tsx
#	studio/pages/project/[ref]/auth/settings.tsx
2022-06-02 22:06:03 -05:00

759 lines
22 KiB
TypeScript

import { FC, useEffect, createContext, useContext } from 'react'
import { makeAutoObservable } from 'mobx'
import { observer, useLocalObservable } from 'mobx-react-lite'
import { isEmpty, mapValues, has, without, union } from 'lodash'
import {
Input,
SidePanel,
Checkbox,
Listbox,
Typography,
IconPlayCircle,
IconPauseCircle,
IconTerminal,
Badge,
Button,
} from '@supabase/ui'
import { Dictionary } from 'components/grid'
import { useRouter } from 'next/router'
import SVG from 'react-inlinesvg'
import ChooseFunctionForm from './ChooseFunctionForm'
import FormEmptyBox from 'components/ui/FormBoxEmpty'
import NoTableState from 'components/ui/States/NoTableState'
import { useStore } from 'hooks'
class CreateTriggerFormState {
id: number | undefined
originalName: string | undefined
// @ts-ignore
activation: { value: string; error?: string }
// @ts-ignore
enabledMode: { value: string }
// @ts-ignore
events: { value: string[]; error?: string }
// @ts-ignore
functionName: { value: string; error?: string }
// @ts-ignore
functionSchema: { value: string; error?: string }
// @ts-ignore
orientation: { value: string; error?: string }
// @ts-ignore
name: { value: string; error?: string }
// @ts-ignore
schema: { value: string }
// @ts-ignore
table: { value: string }
// @ts-ignore
tableId: { value: number; error?: string }
constructor() {
makeAutoObservable(this)
this.reset()
}
get requestBody() {
return {
id: this.id,
activation: this.activation.value,
enabled_mode: this.enabledMode.value,
events: this.events.value,
function_name: this.functionName.value,
function_schema: this.functionSchema.value,
orientation: this.orientation.value,
name: this.name.value,
schema: this.schema.value,
table: this.table.value,
}
}
reset(trigger?: Dictionary<any>) {
this.id = trigger?.id
this.originalName = trigger?.name
this.activation = { value: trigger?.activation ?? 'BEFORE' }
this.enabledMode = { value: trigger?.enabled_mode ?? 'ORIGIN' }
this.events = { value: trigger?.events ?? [] }
this.functionName = { value: trigger?.function_name ?? '' }
this.functionSchema = { value: trigger?.function_schema ?? '' }
this.orientation = { value: trigger?.orientation ?? 'STATEMENT' }
this.name = { value: trigger?.name ?? '' }
this.schema = { value: trigger?.schema ?? '' }
this.table = { value: trigger?.table ?? '' }
this.tableId = { value: trigger?.table_id ?? '' }
}
update(state: Dictionary<any>) {
this.activation = state.activation
this.enabledMode = state.enabledMode
this.events = state.events
this.functionName = state.functionName
this.functionSchema = state.functionSchema
this.orientation = state.orientation
this.name = state.name
this.schema = state.schema
this.table = state.table
this.tableId = state.tableId
}
}
interface ICreateTriggerStore {
chooseFunctionFormVisible: boolean
loading: boolean
formState: CreateTriggerFormState
meta: any
selectedFunction?: Dictionary<any>[]
tables: Dictionary<any>[]
triggerFunctions: Dictionary<any>[]
onFormChange: ({ key, value }: { key: string; value: any }) => void
onSelectFunction: (id: number) => void
setChooseFunctionFormVisible: (value: boolean) => void
setDefaultSelectedTable: () => void
setLoading: (value: boolean) => void
setTables: (value: Dictionary<any>[]) => void
setTriggerFunctions: (value: Dictionary<any>[]) => void
validateForm: () => boolean
}
class CreateTriggerStore implements ICreateTriggerStore {
chooseFunctionFormVisible = false
loading = false
formState = new CreateTriggerFormState()
meta = null
tables = []
triggerFunctions = []
constructor() {
makeAutoObservable(this)
}
get selectedFunction() {
const func = this.triggerFunctions.find(
(x: any) =>
x.name == this.formState.functionName.value &&
x.schema == this.formState.functionSchema.value
)
return func
}
get title() {
return this.formState.id ? `Edit '${this.formState.originalName}' trigger` : 'Add a new Trigger'
}
get isEditing() {
return this.formState.id != undefined
}
setChooseFunctionFormVisible = (value: boolean) => {
this.chooseFunctionFormVisible = value
}
// set first table as default selection
setDefaultSelectedTable = () => {
if (this.tables?.length != 0) {
this.formState.table.value = (this.tables[0] as any).name
this.formState.schema.value = (this.tables[0] as any).schema
this.formState.tableId.value = (this.tables[0] as any).id
}
}
setLoading = (value: boolean) => {
this.loading = value
}
setTables = (value: Dictionary<any>[]) => {
this.tables = value as any
this.setDefaultSelectedTable()
}
setTriggerFunctions = (value: Dictionary<any>[]) => {
this.triggerFunctions = value as any
}
onFormChange = ({ key, value }: { key: string; value: any }) => {
if (has(this.formState, key)) {
const temp = (this.formState as any)[key]
// @ts-ignore
this.formState[key] = { ...temp, value, error: undefined }
} else {
// @ts-ignore
this.formState[key] = { value }
}
}
onSelectFunction = (id: number) => {
const func = this.triggerFunctions.find((x: any) => x.id == id)
if (func) {
this.formState.functionName.value = (func as any).name
this.formState.functionSchema.value = (func as any).schema
}
}
validateForm = () => {
let isValidated = true
const _state = mapValues(this.formState, (x: { value: any }, key: string) => {
switch (key) {
case 'name': {
if (isEmpty(x.value) || hasWhitespace(x.value)) {
isValidated = false
return { ...x, error: 'Invalid trigger name' }
} else {
return x
}
}
case 'activation': {
if (isEmpty(x.value)) {
isValidated = false
return { ...x, error: 'you have an error' }
} else {
return x
}
}
case 'events': {
if (isEmpty(x.value)) {
isValidated = false
return { ...x, error: 'Select at least 1 event' }
} else {
return x
}
}
case 'tableId': {
if (isEmpty(`${x.value}`)) {
isValidated = false
return { ...x, error: 'You must choose a table' }
} else {
return x
}
}
default:
return x
}
})
if (!isValidated) {
this.formState.update(_state)
}
return isValidated
}
}
function hasWhitespace(value: string) {
return /\s/.test(value)
}
const CreateTriggerContext = createContext<ICreateTriggerStore | null>(null)
type CreateTriggerProps = {
trigger?: any
visible: boolean
setVisible: (value: boolean) => void
} & any
const CreateTrigger: FC<CreateTriggerProps> = ({ trigger, visible, setVisible }) => {
const { ui, meta } = useStore()
const _localState = useLocalObservable(() => new CreateTriggerStore())
_localState.meta = meta as any
// for the empty 'no tables' state link
const router = useRouter()
const { ref } = router.query
useEffect(() => {
const fetchTables = async () => {
await (_localState!.meta as any)!.tables!.load()
const tables = (_localState!.meta as any)!.tables.list()
_localState.setTables(tables)
}
const fetchFunctions = async () => {
await (_localState.meta as any).functions.load()
const triggerFuncs = (_localState!.meta as any)!.functions.listTriggerFunctions()
_localState.setTriggerFunctions(triggerFuncs)
}
fetchTables()
fetchFunctions()
}, [])
useEffect(() => {
if (trigger) {
_localState.formState.reset(trigger)
} else {
_localState.formState.reset()
_localState.setDefaultSelectedTable()
}
}, [visible, trigger])
async function handleSubmit() {
try {
if (_localState.validateForm()) {
_localState.setLoading(true)
const body = _localState.formState.requestBody
const response: any = _localState.isEditing
? await (_localState.meta as any).triggers.update(body.id, body)
: await (_localState.meta as any).triggers.create(body)
if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to create trigger: ${
response.error?.message ?? 'submit request failed'
}`,
})
_localState.setLoading(false)
} else {
ui.setNotification({
category: 'success',
message: `${_localState.isEditing ? 'Updated' : 'Created new'} trigger called ${
response.name
}`,
})
_localState.setLoading(false)
setVisible(!visible)
}
}
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Filed to create trigger: ${error.message}`,
})
_localState.setLoading(false)
}
}
const hasPublicTables = _localState.tables.length >= 1
return (
<>
<SidePanel
size="large"
visible={visible}
onCancel={() => setVisible(!visible)}
header={_localState.title}
hideFooter={!hasPublicTables}
className={
_localState.chooseFunctionFormVisible
? 'hooks-sidepanel transform transition-all duration-300 ease-in-out mr-16'
: 'hooks-sidepanel transform transition-all duration-300 ease-in-out mr-0'
}
loading={_localState.loading}
onConfirm={handleSubmit}
>
{hasPublicTables ? (
<div className="">
<CreateTriggerContext.Provider value={_localState}>
<div className="space-y-10 my-6">
{_localState.isEditing ? (
<div className="px-6 space-y-6">
<InputName />
<SelectEnabledMode />
</div>
) : (
<>
<div className="px-6">
<InputName />
</div>
<SidePanel.Seperator />
<div className="px-6 space-y-12">
<Typography.Title level={5}>Conditions to fire trigger</Typography.Title>
<ListboxTable />
<CheckboxEvents />
<ListboxActivation />
<SelectOrientation />
</div>
<SidePanel.Seperator />
<FunctionForm />
</>
)}
</div>
<ChooseFunctionForm
triggerFunctions={_localState.triggerFunctions}
visible={_localState.chooseFunctionFormVisible}
setVisible={_localState.setChooseFunctionFormVisible}
onChange={(id: number) => _localState.onSelectFunction(id)}
/>
</CreateTriggerContext.Provider>
</div>
) : (
<NoTableState message="You will need to create a table first before you can make a trigger" />
)}
</SidePanel>
</>
)
}
export default observer(CreateTrigger)
const InputName: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Input
id="name"
label="Name of trigger"
layout="horizontal"
placeholder="Name of trigger"
value={_localState!.formState.name.value}
onChange={(e) =>
_localState!.onFormChange({
key: 'name',
value: e.target.value,
})
}
size="small"
error={_localState!.formState.name.error}
descriptionText="The name is also stored as the actual postgres name of the trigger. Do not use spaces/whitespace."
/>
)
})
const SelectEnabledMode: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="enabled-mode"
label="Enabled mode"
layout="horizontal"
value={_localState!.formState.enabledMode.value}
onChange={(value) =>
_localState!.onFormChange({
key: 'enabledMode',
value: value,
})
}
size="small"
descriptionText="Determines if a trigger should or should not fire. Can also be used to disable a trigger, but not delete it."
>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="bg-green-900 border border-green-700 shadow-sm w-3 h-3 rounded-full"></div>
)
}}
value="ORIGIN"
label="Origin"
>
Origin
<span className="text-scale-900 block">This is a default behaviour</span>
</Listbox.Option>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="bg-green-900 border border-green-700 shadow-sm w-3 h-3 rounded-full"></div>
)
}}
value="REPLICA"
label="Replica"
>
Replica
<span className="text-scale-900 block">
Will only fire if the session is in replica mode
</span>
</Listbox.Option>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="bg-green-900 border border-green-700 shadow-sm w-3 h-3 rounded-full"></div>
)
}}
value="ALWAYS"
label="Always"
>
Always
<span className="text-scale-900 block">
Will fire regardless of the current replication role
</span>
</Listbox.Option>
<Listbox.Option
addOnBefore={({ active, selected }: any) => {
return (
<div className="bg-red-900 border border-red-700 shadow-sm w-3 h-3 rounded-full"></div>
)
}}
value="DISABLED"
label="Disabled"
>
Disabled
<span className="text-scale-900 block">Will not fire</span>
</Listbox.Option>
</Listbox>
)
})
const SelectOrientation: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="orientation"
label="Orientation"
layout="horizontal"
value={_localState!.formState.orientation.value}
onChange={(value) =>
_localState!.onFormChange({
key: 'orientation',
value: value,
})
}
size="small"
descriptionText="Identifies whether the trigger fires once for each processed row or once for each statement"
>
<Listbox.Option value="ROW" label="Row">
Row
<span className="text-scale-900 block">fires once for each processed row</span>
</Listbox.Option>
<Listbox.Option value="STATEMENT" label="Statement">
Statement
<span className="text-scale-900 block">fires once for each statement</span>
</Listbox.Option>
</Listbox>
)
})
const ListboxTable: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="table"
label="Table"
layout="horizontal"
value={_localState!.formState.tableId.value}
onChange={(id) => {
const _table = _localState!.tables.find((x) => x.id === id)
if (_table) {
_localState!.onFormChange({
key: 'table',
value: _table.name,
})
_localState!.onFormChange({
key: 'schema',
value: _table.schema,
})
_localState!.onFormChange({
key: 'tableId',
value: id,
})
}
}}
size="small"
error={_localState!.formState.tableId.error}
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
{_localState!.tables.map((x) => {
return (
<Listbox.Option
id={x.id}
key={x.id}
value={x.id}
label={x.name}
addOnBefore={() => (
<div className="bg-scale-1200 p-1 flex items-center justify-center rounded text-scale-100 ">
<SVG
src={'/img/table-editor.svg'}
style={{ width: `16px`, height: `16px`, strokeWidth: '1px' }}
preProcessor={(code) =>
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
}
/>
</div>
)}
>
<div className="flex flex-row items-center space-x-1">
<Typography.Text>{x.name}</Typography.Text>
<Typography.Text type="secondary" className="text-scale-900" small>
{x.schema}
</Typography.Text>
</div>
</Listbox.Option>
)
})}
</Listbox>
)
})
const CheckboxEvents: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
// @ts-ignore
<Checkbox.Group
name="events"
label="Events"
id="events"
labelOptional="The type of events that will trigger your trigger"
layout="horizontal"
descriptionText="These are the events that are watched by the trigger, only the events selected above will fire the trigger on the table you've selected."
size="small"
onChange={(e) => {
const temp = _localState!.formState.events.value
const value = e.target.checked
? union(temp, [e.target.value])
: without(temp, e.target.value)
_localState!.onFormChange({
key: 'events',
value: value,
})
}}
error={_localState!.formState.events.error}
>
<Checkbox
value="INSERT"
label="Insert"
description={'Any insert operation on the table'}
checked={_localState!.formState.events.value.includes('INSERT')}
/>
<Checkbox
value="UPDATE"
label="Update"
description="Any update operation, of any column in the table"
checked={_localState!.formState.events.value.includes('UPDATE')}
/>
<Checkbox
value="DELETE"
label="Delete"
description="Any deletion of a record"
checked={_localState!.formState.events.value.includes('DELETE')}
/>
</Checkbox.Group>
)
})
const ListboxActivation: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<Listbox
id="activation"
label="Trigger type"
descriptionText="This determines when your Hook fires"
onChange={(_value) => {
_localState!.onFormChange({
key: 'activation',
value: _value,
})
}}
value={_localState!.formState.activation.value}
layout="horizontal"
size="small"
error={_localState!.formState.activation.error}
>
<Listbox.Option
id={'before'}
value={'BEFORE'}
label={'Before the event'}
addOnBefore={() => (
<div className="bg-scale-1200 p-1 flex items-center justify-center rounded text-scale-100 ">
<IconPauseCircle strokeWidth={2} size="small" />
</div>
)}
>
<div className="flex flex-col">
<span>{'before'}</span>
<span className="text-scale-900 block">
Trigger fires before the operation is attempted
</span>
</div>
</Listbox.Option>
<Listbox.Option
id={'after'}
value={'AFTER'}
label={'After the event'}
addOnBefore={() => (
<div className="bg-green-1200 p-1 flex items-center justify-center rounded text-scale-100 ">
<IconPlayCircle strokeWidth={2} size="small" />
</div>
)}
>
<div className="flex flex-col">
<span>{'after'}</span>
<span className="text-scale-900 block">
Trigger fires after the operation has completed
</span>
</div>
</Listbox.Option>
</Listbox>
)
})
const FunctionForm: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<div className="space-y-4">
<div className="px-6 space-y-6">
<Typography.Title level={5}>Function to trigger</Typography.Title>
</div>
<div className="px-6">
{isEmpty(_localState!.formState.functionName.value) ? (
<FunctionEmpty />
) : (
<FunctionWithArguments />
)}
</div>
</div>
)
})
const FunctionEmpty: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<button
type="button"
onClick={() => _localState!.setChooseFunctionFormVisible(true)}
className="w-full relative
transition-all
shadow-sm
border bg-scale-200 dark:bg-scale-400
border-scale-600
rounded
hover:border-scale-700
hover:bg-scale-300 dark:hover:bg-scale-500
px-5 py-1
"
>
<FormEmptyBox
icon={<IconTerminal size={14} strokeWidth={2} />}
text="Choose a function to trigger"
/>
</button>
)
})
const FunctionWithArguments: FC = observer(({}) => {
const _localState = useContext(CreateTriggerContext)
return (
<>
<div
className="
w-full
relative
transition-shadow shadow-sm
border border-scale-200 dark:border-scale-500
rounded
px-5 py-4
flex space-x-3 items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="flex rounded w-6 h-6 bg-scale-1200 text-scale-100 focus-within:bg-opacity-10 items-center justify-center">
<IconTerminal size="small" strokeWidth={2} width={14} />
</div>
<div className="flex items-center gap-2">
<Typography.Text type="secondary">
{_localState!.formState.functionName.value}
</Typography.Text>
<div>
<Badge>{_localState!.formState.functionSchema.value}</Badge>
</div>
</div>
</div>
<Button type="default" onClick={() => _localState!.setChooseFunctionFormVisible(true)}>
Change function
</Button>
</div>
</>
)
})