import { filter, has, isEmpty, isNull, isUndefined, keyBy, mapValues, partition } from 'lodash' import { makeAutoObservable } from 'mobx' import { observer, useLocalObservable } from 'mobx-react-lite' import { FormEvent, createContext, useContext, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { Button, IconPlus, IconTrash, Input, Listbox, Modal, Radio, SidePanel, Toggle } from 'ui' import { POSTGRES_DATA_TYPES } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ConfirmationModal from 'components/ui/ConfirmationModal' import Panel from 'components/ui/Panel' import SqlEditor from 'components/ui/SqlEditor' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useDatabaseFunctionCreateMutation } from 'data/database-functions/database-functions-create-mutation' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' import { useSchemasQuery } from 'data/database/schemas-query' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { Dictionary } from 'types' import { convertArgumentTypes, convertConfigParams, hasWhitespace } from './Functions.utils' // [Refactor] Remove local state, just use the Form component class CreateFunctionFormState { id: number | undefined originalName: string | undefined // @ts-ignore args: { value: { name: string; type: string; error?: string }[] } // @ts-ignore behavior: { value: string; error?: string } // @ts-ignore configParams: { value: { name: string; value: string; error?: { name?: string; value?: string } }[] } // @ts-ignore definition: { value: string; error?: string } // @ts-ignore language: { value: string; error?: string } // @ts-ignore name: { value: string; error?: string } // @ts-ignore returnType: { value: string; error?: string } // @ts-ignore schema: { value: string; error?: string } // @ts-ignore securityDefiner: { value: boolean; error?: string } constructor() { makeAutoObservable(this) this.reset() } get requestBody() { return { id: this.id, name: this.name.value, schema: this.schema.value, definition: this.definition.value, return_type: this.returnType.value, language: this.language.value, behavior: this.behavior.value as 'VOLATILE' | 'STABLE' | 'IMMUTABLE', security_definer: this.securityDefiner.value, args: this.args.value.map((x: any) => `${x.name} ${x.type}`), config_params: mapValues(keyBy(this.configParams.value, 'name'), 'value'), } } reset(func?: Dictionary) { this.id = func?.id this.originalName = func?.name this.args = convertArgumentTypes(func?.argument_types) this.behavior = { value: func?.behavior ?? 'VOLATILE' } this.configParams = convertConfigParams(func?.config_params) this.definition = { value: func?.definition ?? '' } this.language = { value: func?.language ?? 'plpgsql' } this.name = { value: func?.name ?? '' } this.returnType = { value: func?.return_type ?? 'void' } this.schema = { value: func?.schema ?? 'public' } this.securityDefiner = { value: func?.security_definer ?? false } } update(state: Dictionary) { this.args = state.args this.behavior = state.behavior this.configParams = state.configParams this.definition = state.definition this.language = state.language this.name = state.name this.returnType = state.returnType this.schema = state.schema this.securityDefiner = state.securityDefiner } } interface ICreateFunctionStore { formState: CreateFunctionFormState meta: any schemas: Dictionary[] isEditing: boolean onFormChange: (value: { key: string; value: any }) => void onFormArrayChange: (value: { operation: 'add' | 'delete' | 'update' key: string idx?: number value?: any }) => void setSchemas: (value: Dictionary[]) => void validateForm: () => boolean } class CreateFunctionStore implements ICreateFunctionStore { formState = new CreateFunctionFormState() meta = null schemas = [] advancedVisible = false isDirty = false constructor() { makeAutoObservable(this) } get title() { return this.formState.id ? `Edit '${this.formState.originalName}' function` : 'Add a new function' } get isEditing() { return this.formState.id != undefined } toggleAdvancedVisible = () => { this.advancedVisible = !this.advancedVisible } setSchemas = (value: Dictionary[]) => { this.schemas = value as any } setIsDirty = (value: boolean) => { this.isDirty = value } onFormChange = ({ key, value }: { key: string; value: any }) => { this.isDirty = true if (has(this.formState, key)) { const temp = (this.formState as any)[key] as any ;(this.formState as any)[key] = { ...temp, value, error: undefined } } else { ;(this.formState as any)[key] = { value } } } onFormArrayChange = ({ operation, key, idx, value, }: { operation: 'add' | 'delete' | 'update' key: string idx?: number value?: any }) => { switch (operation) { case 'add': { if (has(this.formState, key)) { // @ts-ignore this.formState[key].value.push(value) } else { const values = [value] // @ts-ignore this.formState[key] = { value: [value] } } break } case 'delete': { if (has(this.formState, key)) { const temp = filter( // @ts-ignore this.formState[key].value, (_: any, index: number) => index != idx ) as any // @ts-ignore this.formState[key].value = temp } break } default: { if (has(this.formState, key) && !isUndefined(idx)) { // @ts-ignore this.formState[key].value[idx] = value } else { // @ts-ignore this.formState[key] = { value: [value] } } } } } 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 function name' } } else { return x } } case 'args': { const temp = x.value?.map((i: Dictionary) => { if (isEmpty(i.name) || hasWhitespace(i.name)) { isValidated = false return { ...i, error: 'Invalid argument name' } } else { return i } }) x.value = temp return x } case 'configParams': { const temp = x.value?.map((i: Dictionary) => { const error: any = { name: undefined, value: undefined } if (isEmpty(i.name) || hasWhitespace(i.name)) { isValidated = false error.name = 'Invalid config name' } if (isEmpty(i.value)) { isValidated = false error.value = 'Missing config value' } return { ...i, error } }) x.value = temp return x } default: return x } }) if (!isValidated) { this.formState.update(_state) } return isValidated } } const CreateFunctionContext = createContext(null) interface CreateFunctionProps { func: any visible: boolean setVisible: (value: boolean) => void } const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { const { project } = useProjectContext() const _localState = useLocalObservable(() => new CreateFunctionStore()) const [isClosingPanel, setIsClosingPanel] = useState(false) useSchemasQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, { onSuccess(schemas) { _localState.setSchemas(schemas) }, } ) const { mutate: createDatabaseFunction, isLoading: isCreating } = useDatabaseFunctionCreateMutation() const { mutate: updateDatabaseFunction, isLoading: isUpdating } = useDatabaseFunctionUpdateMutation() useEffect(() => { _localState.formState.reset(func) _localState.setIsDirty(false) }, [visible, func]) async function handleSubmit() { if (!project) return console.error('Project is required') if (_localState.validateForm()) { const body = _localState.formState.requestBody if (body.id) { updateDatabaseFunction( { id: body.id, projectRef: project.ref, connectionString: project.connectionString, payload: body as any, }, { onSuccess: () => { toast.success(`Successfully updated function ${body.name}`) setVisible(!visible) }, } ) } else { createDatabaseFunction( { projectRef: project.ref, connectionString: project.connectionString, payload: body, }, { onSuccess: () => { toast.success(`Successfully created function ${body.name}`) setVisible(!visible) }, } ) } } } function isClosingSidePanel() { _localState.isDirty ? setIsClosingPanel(true) : setVisible(!visible) } return ( <>
{_localState.isEditing ? ( <>
) : (
_localState.toggleAdvancedVisible()} label="Show advanced settings" checked={_localState.advancedVisible} labelOptional="These are settings that might be familiar for postgres heavy users " />
{/* advanced selections */}
{_localState.advancedVisible && ( <>
)}
)}
setIsClosingPanel(false)} onSelectConfirm={() => { setIsClosingPanel(false) setVisible(!visible) }} >

There are unsaved changes. Are you sure you want to close the panel? Your changes will be lost.

) } export default observer(CreateFunction) const InputName = observer(({}) => { const _localState = useContext(CreateFunctionContext) return ( _localState!.onFormChange({ key: 'name', value: e.target.value, }) } size="small" error={_localState!.formState.name.error} descriptionText="Name will also be used for the function name in postgres" /> ) }) interface InputMultiArgumentsProps { readonly?: boolean } const InputMultiArguments = observer(({ readonly }: InputMultiArgumentsProps) => { const _localState = useContext(CreateFunctionContext) function onAddArgument() { _localState!.onFormArrayChange({ key: 'args', value: { name: '', type: 'bool' }, operation: 'add', }) } return (
Arguments

Arguments can be referenced in the function body using either names or numbers.

{readonly && isEmpty(_localState!.formState.args.value) && ( No argument for this function )} {_localState!.formState.args.value.map( (x: { name: string; type: string; error?: string }, idx: number) => ( ) )} {!readonly && (
)}
) }) interface InputArgumentProps { idx: number name: string type: string error?: string readonly?: boolean } const InputArgument = observer(({ idx, name, type, error, readonly }: InputArgumentProps) => { const _localState = useContext(CreateFunctionContext) function onNameChange(e: FormEvent) { const _value = e.currentTarget.value _localState!.onFormArrayChange({ key: 'args', value: { name: _value, type }, idx, operation: 'update', }) } function onTypeChange(_value: string) { _localState!.onFormArrayChange({ key: 'args', value: { name, type: _value }, idx, operation: 'update', }) } function onDelete() { _localState!.onFormArrayChange({ key: 'args', idx, operation: 'delete', }) } return (
{readonly ? ( ) : ( integer {POSTGRES_DATA_TYPES.map((x: string) => ( {x} ))} )} {!readonly && (
) }) const InputMultiConfigParams = observer(({}) => { const _localState = useContext(CreateFunctionContext) function onAddArgument() { _localState!.onFormArrayChange({ key: 'configParams', value: { name: '', value: '' }, operation: 'add', }) } return (
Config Params
{_localState!.formState.configParams.value.map( ( x: { name: string; value: string; error?: { name?: string; value?: string } }, idx: number ) => ( ) )}
) }) interface InputConfigParamProps { idx: number name: string value: string error?: { name?: string; value?: string } } const InputConfigParam = observer(({ idx, name, value, error }: InputConfigParamProps) => { const _localState = useContext(CreateFunctionContext) function onNameChange(e: FormEvent) { const _value = e.currentTarget.value _localState!.onFormArrayChange({ key: 'configParams', value: { name: _value, value }, idx, operation: 'update', }) } function onValueChange(e: FormEvent) { const _value = e.currentTarget.value _localState!.onFormArrayChange({ key: 'configParams', value: { name, value: _value }, idx, operation: 'update', }) } function onDelete() { _localState!.onFormArrayChange({ key: 'configParams', idx, operation: 'delete', }) } return (
) }) const InputDefinition = observer(({}) => { const _localState = useContext(CreateFunctionContext) return (
Definition

The language below should be written in `{_localState!.formState.language.value}`.

{!_localState?.isEditing && (

Change the language in the Advanced Settings below.

)}
{ _localState!.onFormChange({ key: 'definition', value: value, }) }} contextmenu={false} />
) }) const SelectSchema = observer(({}) => { const _localState = useContext(CreateFunctionContext) const { project } = useProjectContext() const { data } = useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) const schemas = data?.filter((schema) => !EXCLUDED_SCHEMAS.includes(schema.name)) ?? [] return ( _localState!.onFormChange({ key: 'schema', value })} > {schemas.map((x) => ( {x.name} ))} ) }) const SelectLanguage = observer(({}) => { const _localState = useContext(CreateFunctionContext) const { project } = useProjectContext() const { data } = useDatabaseExtensionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) const [enabledExtensions] = partition(data ?? [], (ext: any) => !isNull(ext.installed_version)) return ( _localState!.onFormChange({ key: 'language', value })} > sql { //map through all selected extensions that start with pl enabledExtensions .filter((ex: any) => { return ex.name.startsWith('pl') }) .map((ex) => ( {ex.name} )) } ) }) const SelectReturnType = observer(({}) => { const _localState = useContext(CreateFunctionContext) return ( _localState!.onFormChange({ key: 'returnType', value })} > void record trigger integer {POSTGRES_DATA_TYPES.map((x: string) => ( {x} ))} ) }) const SelectBehavior = observer(({}) => { const _localState = useContext(CreateFunctionContext) return ( _localState!.onFormChange({ key: 'behavior', value })} > immutable stable volatile ) }) const RadioSecurity = observer(({}) => { const _localState = useContext(CreateFunctionContext) return ( <>
{ _localState!.onFormChange({ key: 'securityDefiner', value: event.target.value == 'SECURITY_DEFINER', }) }} value={ _localState!.formState.securityDefiner.value ? 'SECURITY_DEFINER' : 'SECURITY_INVOKER' } error={_localState!.formState.securityDefiner.error} >
) })