From 2070919b644bc4a5c63ed7f1591a82d54809bed5 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 29 Nov 2022 16:01:08 +0000 Subject: [PATCH 01/42] feat: add base ui for fdw --- .../Database/Wrappers/WrapperCard.tsx | 102 ++++++++++ .../Database/Wrappers/WrapperEditor.tsx | 138 +++++++++++++ .../Database/Wrappers/WrapperTableEditor.tsx | 157 +++++++++++++++ .../interfaces/Database/Wrappers/Wrappers.tsx | 181 ++++++++++++++++++ .../interfaces/Database/Wrappers/types.ts | 40 ++++ .../components/interfaces/Database/index.ts | 3 + .../SidePanelEditor/ActionBar.tsx | 12 +- .../DatabaseLayout/DatabaseMenu.utils.ts | 6 + .../pages/project/[ref]/database/wrappers.tsx | 21 ++ 9 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 studio/components/interfaces/Database/Wrappers/WrapperCard.tsx create mode 100644 studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx create mode 100644 studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx create mode 100644 studio/components/interfaces/Database/Wrappers/Wrappers.tsx create mode 100644 studio/components/interfaces/Database/Wrappers/types.ts create mode 100644 studio/pages/project/[ref]/database/wrappers.tsx diff --git a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx new file mode 100644 index 00000000000..243ba078e1e --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx @@ -0,0 +1,102 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useState } from 'react' +import { IconLoader, Toggle } from 'ui' + +import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' +import { checkPermissions, useStore } from 'hooks' +import { Wrapper } from './types' +import WrapperEditor from './WrapperEditor' + +export type WrapperCardProps = { + wrapper: Wrapper +} + +const WrapperCard = ({ wrapper }: WrapperCardProps) => { + const { ui } = useStore() + + const isOn = false + const [loading, setLoading] = useState(false) + + const canUpdateWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const [isEditorVisible, setIsEditorVisible] = useState(false) + + async function enableWrapper() { + setIsEditorVisible(true) + } + + async function disableWrapper() { + confirmAlert({ + title: 'Confirm to disable wrapper', + message: `Are you sure you want to turn OFF "${wrapper.name}" wrapper?`, + onAsyncConfirm: async () => { + try { + setLoading(true) + // const response: any = await meta.wrappers.del(wrapper.name) + // if (response.error) { + // throw response.error + // } else { + // ui.setNotification({ + // category: 'success', + // message: `${wrapper.name.toUpperCase()} is off.`, + // }) + // } + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Toggle ${wrapper.name.toUpperCase()} failed: ${error.message}`, + }) + } finally { + // Need to reload them because the delete function + // removes the wrapper from the store + // meta.wrappers.load() + setLoading(false) + } + }, + }) + } + + return ( + <> +
+
+

+ {wrapper.label} +

+ {loading ? ( + + ) : ( + (isOn ? disableWrapper() : enableWrapper())} + /> + )} +
+
+ + setIsEditorVisible(false)} + /> + + ) +} + +export default observer(WrapperCard) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx new file mode 100644 index 00000000000..80cd41394d8 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -0,0 +1,138 @@ +import { FC, useState } from 'react' +import { Button, Checkbox, Form, Input, SidePanel } from 'ui' + +import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' +import { useStore } from 'hooks' +import { Wrapper } from './types' +import WrapperTableEditor from './WrapperTableEditor' + +export type WrapperEditorProps = { + visible: boolean + wrapper: Wrapper + onCancel: () => void +} + +const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { + const { ui } = useStore() + + const validate = (values: any) => { + const errors: any = {} + if (values.schema === 'custom' && !values.name) errors.name = 'Required field' + return errors + } + + const onSubmit = async (values: any, { setSubmitting }: any) => { + setSubmitting(true) + + console.log('values:', values) + // const { error } = await meta.wrappers.create({ + // schema, + // name: wrapper.name, + // version: wrapper.default_version, + // cascade: true, + // }) + // if (error) { + // ui.setNotification({ + // error, + // category: 'error', + // message: `Failed to toggle ${wrapper.name.toUpperCase()}: ${error.message}`, + // }) + // } else { + // ui.setNotification({ + // category: 'success', + // message: `${wrapper.name.toUpperCase()} is on.`, + // }) + // } + + setSubmitting(false) + onCancel() + } + + const initialValues = Object.fromEntries( + wrapper.server.options.map((option) => [option.name, option.defaultValue ?? '']) + ) + + const [newTables, setNewTables] = useState([]) + + const [isAddTableOpen, setIsAddTableOpen] = useState(false) + + const onTableAdd = (values: any) => { + setNewTables((prev) => [...prev, values]) + setIsAddTableOpen(false) + } + + const onSaveChanges = console.log + + return ( + <> + +
{wrapper.label} Foreign Data Wrapper
+ + } + className={`transition-all duration-100 ease-in`} + onCancel={onCancel} + onConfirm={() => (resolve: () => void) => onSaveChanges(resolve)} + customFooter={ + void) => onSaveChanges(resolve)} + /> + } + onInteractOutside={(event) => { + const isToast = (event.target as Element)?.closest('#toast') + if (isToast) { + event.preventDefault() + } + }} + > +
+ {({ isSubmitting, values }: any) => { + return ( +
+ +
+ {wrapper.server.options.map((option) => ( + + ))} + + {newTables.map((table, i) => ( +
{table.table_name}
+ ))} + + +
+
+
+ ) + }} +
+
+ + setIsAddTableOpen(false)} + onSave={onTableAdd} + /> + + ) +} + +export default WrapperEditor diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx new file mode 100644 index 00000000000..377c32a80e1 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -0,0 +1,157 @@ +import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' +import { useState } from 'react' +import { Checkbox, Input, Listbox, SidePanel, Accordion, Form } from 'ui' +import { TableOption, Table } from './types' + +export type WrapperTableEditorProps = { + visible: boolean + onCancel: () => void + onSave: (values: any) => void + + tables: Table[] +} + +const WrapperTableEditor = ({ visible, onCancel, onSave, tables }: WrapperTableEditorProps) => { + const [selectedTableIndex, setSelectedTableIndex] = useState('') + + const selectedTable = selectedTableIndex === '' ? undefined : tables[parseInt(selectedTableIndex)] + + const onSubmit = (values: any) => { + onSave(values) + } + + return ( + Edit foreign table} + customFooter={ + + } + > + +
+ setSelectedTableIndex(value)} + > + + --- + + + {tables.map((table, i) => { + return ( + +
{table.label}
+
+ ) + })} +
+ + {selectedTable && } +
+
+
+ ) +} + +export default WrapperTableEditor + +const Option = ({ option }: { option: TableOption }) => { + return ( + + ) +} + +const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: (values: any) => void }) => { + const requiredOptions = + table.options.filter((option) => option.editable && option.required && !option.defaultValue) ?? + [] + const optionalOptions = + table.options.filter( + (option) => option.editable && (!option.required || option.defaultValue) + ) ?? [] + + const initialValues = Object.fromEntries( + [ + ['table_name', ''], + ['columns', []], + ].concat(table.options.map((option) => [option.name, option.defaultValue ?? ''])) + ) + + return ( +
+ {() => ( +
+ + + {requiredOptions.map((option) => ( +
+ )} +
+ ) +} diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx new file mode 100644 index 00000000000..b7346b72333 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -0,0 +1,181 @@ +import { Wrapper } from './types' +import WrapperCard from './WrapperCard' + +const wrappers: Wrapper[] = [ + { + name: 'stripe_wrapper', + extensionName: 'StripeFdw', + label: 'Stripe', + server: { + name: 'stripe_server', + options: [ + { + name: 'api_key_id', + label: 'Stripe Secret Key', + required: true, + encrypted: true, + }, + { + name: 'api_url', + label: 'Stripe API URL', + defaultValue: 'https://api.stripe.com/v1', + required: false, + encrypted: false, + }, + ], + }, + tables: [ + { + label: 'Balance', + availableColumns: [ + { + name: 'amount', + type: 'bigint', + }, + { + name: 'currency', + type: 'text', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'balance', + editable: false, + required: true, + }, + ], + }, + ], + }, + { + name: 'firebase_wrapper', + extensionName: 'FirebaseFdw', + label: 'Firebase', + server: { + name: 'firebase_server', + options: [ + { + name: 'project_id', + label: 'Project ID', + required: true, + encrypted: false, + }, + { + name: 'sa_key_id', + label: 'Service Account Key', + required: true, + encrypted: true, + }, + ], + }, + tables: [ + { + label: 'Users', + availableColumns: [ + { + name: 'local_id', + type: 'text', + }, + { + name: 'email', + type: 'text', + }, + { + name: 'fields', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'auth/users', + editable: false, + required: true, + }, + { + name: 'base_url', + label: 'Base URL', + defaultValue: 'https://identitytoolkit.googleapis.com/v1/projects', + editable: true, + required: true, + }, + ], + }, + { + label: 'Firestore Collection', + availableColumns: [ + { + name: 'name', + type: 'text', + }, + { + name: 'fields', + type: 'jsonb', + }, + { + name: 'create_time', + type: 'timestamp', + }, + { + name: 'update_time', + type: 'timestamp', + }, + ], + options: [ + { + name: 'object', + label: 'Object', + placeholder: 'firestore/[collection_id]', + editable: true, + required: true, + }, + { + name: 'base_url', + label: 'Base URL', + defaultValue: 'https://firestore.googleapis.com/v1beta1/projects', + editable: true, + required: true, + }, + ], + }, + ], + }, +] + +const Wrappers = () => { + const enabledWrappers: any[] = [] + const disabledWrappers = wrappers + + return ( + <> +
+
+ {enabledWrappers.length > 0 && ( +
+

Enabled wrappers

+
+ {enabledWrappers.map((wrapper) => ( + + ))} +
+
+ )} + + {disabledWrappers.length > 0 && ( +
+

Available wrappers

+
+ {disabledWrappers.map((wrapper) => ( + + ))} +
+
+ )} +
+
+ + ) +} + +export default Wrappers diff --git a/studio/components/interfaces/Database/Wrappers/types.ts b/studio/components/interfaces/Database/Wrappers/types.ts new file mode 100644 index 00000000000..66a2f4f2ea3 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/types.ts @@ -0,0 +1,40 @@ +export type Wrapper = { + name: string + extensionName: string + label: string + server: Server + tables: Table[] +} + +export type ServerOption = { + name: string + label: string + required: boolean + encrypted: boolean + defaultValue?: string +} + +export type Server = { + name: string + options: ServerOption[] +} + +export type TableOption = { + name: string + defaultValue?: string + editable: boolean + required: boolean + label?: string + placeholder?: string +} + +export type Table = { + label: string + availableColumns: AvailableColumn[] + options: TableOption[] +} + +export type AvailableColumn = { + name: string + type: string +} diff --git a/studio/components/interfaces/Database/index.ts b/studio/components/interfaces/Database/index.ts index 60141982fab..b294434c18e 100644 --- a/studio/components/interfaces/Database/index.ts +++ b/studio/components/interfaces/Database/index.ts @@ -6,6 +6,8 @@ import RolesSettings from './Roles/RolesSettings' import Extensions from './Extensions/Extensions' +import Wrappers from './Wrappers/Wrappers' + import PublicationsList from './Publications/PublicationsList' import PublicationsTables from './Publications/PublicationsTables' @@ -25,6 +27,7 @@ export { RolesList, RolesSettings, Extensions, + Wrappers, PublicationsList, PublicationsTables, BackupsList, diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx index 03f8b01dc8d..4f4cb0c4595 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx @@ -10,6 +10,7 @@ interface ActionBarProps { backButtonLabel?: string applyFunction?: (resolve: any) => void closePanel: () => void + formId?: string } const ActionBar: FC = ({ loading = false, @@ -20,6 +21,7 @@ const ActionBar: FC = ({ backButtonLabel = 'Back', applyFunction = undefined, closePanel = () => {}, + formId, }) => { const [isRunning, setIsRunning] = useState(false) @@ -33,7 +35,7 @@ const ActionBar: FC = ({ } return ( -
+
@@ -53,7 +55,13 @@ const ActionBar: FC = ({ ) : !hideApply ? ( // New solution, when using the Form component, loading is handled by the Form itself // Does not require applyFunction() callback - ) : ( diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts index b22828ad94b..0ba8e86a251 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts +++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts @@ -32,6 +32,12 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { url: `/project/${ref}/database/extensions`, items: [], }, + { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/database/wrappers`, + items: [], + }, { name: 'Roles', key: 'roles', url: `/project/${ref}/database/roles`, items: [] }, { name: 'Replication', diff --git a/studio/pages/project/[ref]/database/wrappers.tsx b/studio/pages/project/[ref]/database/wrappers.tsx new file mode 100644 index 00000000000..f28e918c273 --- /dev/null +++ b/studio/pages/project/[ref]/database/wrappers.tsx @@ -0,0 +1,21 @@ +import { observer } from 'mobx-react-lite' +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { NextPageWithLayout } from 'types' +import { checkPermissions } from 'hooks' +import { DatabaseLayout } from 'components/layouts' +import { Wrappers } from 'components/interfaces/Database' +import NoPermission from 'components/ui/NoPermission' + +const DatabaseWrappers: NextPageWithLayout = () => { + const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers') + if (!canReadWrappers) { + return + } + + return +} + +DatabaseWrappers.getLayout = (page) => {page} + +export default observer(DatabaseWrappers) From 3d4fb09696d87413e0f0026314ce720c56bce216 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 1 Dec 2022 12:30:42 +0000 Subject: [PATCH 02/42] remove wrapper editor form --- .../Database/Wrappers/WrapperEditor.tsx | 52 ++++++++----------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 80cd41394d8..5cfe1bc88c0 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -92,37 +92,31 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { } }} > -
- {({ isSubmitting, values }: any) => { - return ( -
- -
- {wrapper.server.options.map((option) => ( - - ))} +
+ +
+ {wrapper.server.options.map((option) => ( + + ))} - {newTables.map((table, i) => ( -
{table.table_name}
- ))} + {newTables.map((table, i) => ( +
{table.table_name}
+ ))} - -
-
-
- ) - }} - + +
+
+
Date: Fri, 2 Dec 2022 14:12:03 +0000 Subject: [PATCH 03/42] add create wrapper sql snippet --- .../Database/Wrappers/WrapperEditor.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 5cfe1bc88c0..9bcb57be2d9 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -48,10 +48,11 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { onCancel() } - const initialValues = Object.fromEntries( - wrapper.server.options.map((option) => [option.name, option.defaultValue ?? '']) + const [formState, setFormState] = useState(() => + Object.fromEntries( + wrapper.server.options.map((option) => [option.name, option.defaultValue ?? '']) + ) ) - const [newTables, setNewTables] = useState([]) const [isAddTableOpen, setIsAddTableOpen] = useState(false) @@ -61,7 +62,21 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { setIsAddTableOpen(false) } - const onSaveChanges = console.log + const onSaveChanges = (done: () => void) => { + console.log('newTables:', formState, newTables) + + const createWrapperSql = /* SQL */ ` + create foreign data wrapper stripe_wrapper + handler wrappers_handler + validator wrappers_validator + options ( + wrapper 'StripeFdw' + ); + ` + + done() + onCancel() + } return ( <> @@ -104,6 +119,10 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { defaultValue={option.defaultValue ?? ''} required={option.required ?? false} layout="horizontal" + value={formState[option.name]} + onChange={(e) => + setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) + } /> ))} From 25a493c4f3566b86f66312dcc09fa4a397ea6d44 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Sun, 4 Dec 2022 20:17:56 +0000 Subject: [PATCH 04/42] add SQL generation for creating FDWs --- package-lock.json | 26 ++++ .../Database/Wrappers/WrapperEditor.tsx | 81 +++++----- .../Database/Wrappers/WrapperTableEditor.tsx | 29 ++-- .../interfaces/Database/Wrappers/Wrappers.tsx | 144 +---------------- .../interfaces/Database/Wrappers/data.ts | 143 +++++++++++++++++ .../layouts/ProjectLayout/ProjectContext.tsx | 46 ++++++ .../layouts/ProjectLayout/ProjectLayout.tsx | 10 +- studio/data/fdw/fdw-create-mutation.ts | 146 ++++++++++++++++++ studio/data/sql/execute-sql-mutation.ts | 16 ++ studio/data/sql/execute-sql-query.ts | 69 +++++++++ studio/data/sql/keys.ts | 6 + studio/package.json | 2 + 12 files changed, 518 insertions(+), 200 deletions(-) create mode 100644 studio/components/interfaces/Database/Wrappers/data.ts create mode 100644 studio/components/layouts/ProjectLayout/ProjectContext.tsx create mode 100644 studio/data/fdw/fdw-create-mutation.ts create mode 100644 studio/data/sql/execute-sql-mutation.ts create mode 100644 studio/data/sql/execute-sql-query.ts create mode 100644 studio/data/sql/keys.ts diff --git a/package-lock.json b/package-lock.json index 66be52941a9..02ad5988d2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9760,6 +9760,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/blueimp-md5": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz", + "integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==", + "dev": true + }, "node_modules/@types/btoa-lite": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz", @@ -12393,6 +12399,11 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "devOptional": true }, + "node_modules/blueimp-md5": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==" + }, "node_modules/bn.js": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", @@ -37028,6 +37039,7 @@ "ajv": "^8.6.3", "analytics-node": "^3.5.0", "awesome-debounce-promise": "^2.1.0", + "blueimp-md5": "^2.19.0", "clipboard": "^2.0.8", "common": "*", "config": "*", @@ -37097,6 +37109,7 @@ "@testing-library/dom": "^8.11.1", "@testing-library/react": "^12.1.2", "@testing-library/user-event": "^13.5.0", + "@types/blueimp-md5": "^2.18.0", "@types/crypto-js": "^4.0.2", "@types/file-saver": "^2.0.2", "@types/json-logic-js": "^1.2.1", @@ -44670,6 +44683,12 @@ "@babel/types": "^7.3.0" } }, + "@types/blueimp-md5": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@types/blueimp-md5/-/blueimp-md5-2.18.0.tgz", + "integrity": "sha512-f4A+++lGZGJvVSgeyMkqA7BEf2BVQli6F+qEykKb49c5ieWQBkfpn6CP5c1IZr2Yi2Ofl6Fj+v0e1fN18Z8Cnw==", + "dev": true + }, "@types/btoa-lite": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.0.tgz", @@ -46800,6 +46819,11 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "devOptional": true }, + "blueimp-md5": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==" + }, "bn.js": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", @@ -62678,6 +62702,7 @@ "@testing-library/dom": "^8.11.1", "@testing-library/react": "^12.1.2", "@testing-library/user-event": "^13.5.0", + "@types/blueimp-md5": "^2.18.0", "@types/crypto-js": "^4.0.2", "@types/file-saver": "^2.0.2", "@types/json-logic-js": "^1.2.1", @@ -62703,6 +62728,7 @@ "autoprefixer": "10.4.2", "awesome-debounce-promise": "^2.1.0", "babel-loader": "^8.2.3", + "blueimp-md5": "^2.19.0", "clipboard": "^2.0.8", "common": "*", "config": "*", diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 9bcb57be2d9..10c29dd410b 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -1,7 +1,9 @@ -import { FC, useState } from 'react' -import { Button, Checkbox, Form, Input, SidePanel } from 'ui' +import { useState } from 'react' +import { Button, Input, SidePanel } from 'ui' import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' import { useStore } from 'hooks' import { Wrapper } from './types' import WrapperTableEditor from './WrapperTableEditor' @@ -21,38 +23,12 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { return errors } - const onSubmit = async (values: any, { setSubmitting }: any) => { - setSubmitting(true) - - console.log('values:', values) - // const { error } = await meta.wrappers.create({ - // schema, - // name: wrapper.name, - // version: wrapper.default_version, - // cascade: true, - // }) - // if (error) { - // ui.setNotification({ - // error, - // category: 'error', - // message: `Failed to toggle ${wrapper.name.toUpperCase()}: ${error.message}`, - // }) - // } else { - // ui.setNotification({ - // category: 'success', - // message: `${wrapper.name.toUpperCase()} is on.`, - // }) - // } - - setSubmitting(false) - onCancel() - } - - const [formState, setFormState] = useState(() => + const getInitialFormState = () => Object.fromEntries( wrapper.server.options.map((option) => [option.name, option.defaultValue ?? '']) ) - ) + + const [formState, setFormState] = useState(getInitialFormState) const [newTables, setNewTables] = useState([]) const [isAddTableOpen, setIsAddTableOpen] = useState(false) @@ -62,17 +38,40 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { setIsAddTableOpen(false) } - const onSaveChanges = (done: () => void) => { - console.log('newTables:', formState, newTables) + const { project } = useProjectContext() + const { mutateAsync: createFDW } = useFDWCreateMutation() - const createWrapperSql = /* SQL */ ` - create foreign data wrapper stripe_wrapper - handler wrappers_handler - validator wrappers_validator - options ( - wrapper 'StripeFdw' - ); - ` + const onSaveChanges = async (done: () => void) => { + const toastId = ui.setNotification({ + category: 'loading', + message: `Creating foreign data wrapper...`, + }) + + try { + await createFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper, + formState, + newTables, + }) + + // Reset state + setFormState(getInitialFormState()) + setNewTables([]) + + ui.setNotification({ + id: toastId, + category: 'success', + message: `Created foreign data wrapper successfully!`, + }) + } catch (error: any) { + ui.setNotification({ + id: toastId, + category: 'error', + message: error.message, + }) + } done() onCancel() diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index 377c32a80e1..f3e65756af4 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -1,7 +1,7 @@ import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' import { useState } from 'react' -import { Checkbox, Input, Listbox, SidePanel, Accordion, Form } from 'ui' -import { TableOption, Table } from './types' +import { Accordion, Checkbox, Form, Input, Listbox, SidePanel } from 'ui' +import { Table, TableOption } from './types' export type WrapperTableEditorProps = { visible: boolean @@ -11,13 +11,16 @@ export type WrapperTableEditorProps = { tables: Table[] } +type OnSubmitFn = (values: any, { resetForm }: { resetForm: () => void }) => void + const WrapperTableEditor = ({ visible, onCancel, onSave, tables }: WrapperTableEditorProps) => { const [selectedTableIndex, setSelectedTableIndex] = useState('') const selectedTable = selectedTableIndex === '' ? undefined : tables[parseInt(selectedTableIndex)] - const onSubmit = (values: any) => { - onSave(values) + const onSubmit: OnSubmitFn = (values, { resetForm }) => { + onSave({ ...values, index: parseInt(selectedTableIndex) }) + resetForm() } return ( @@ -70,7 +73,7 @@ const Option = ({ option }: { option: TableOption }) => { { ) } -const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: (values: any) => void }) => { +const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) => { const requiredOptions = table.options.filter((option) => option.editable && option.required && !option.defaultValue) ?? [] @@ -88,12 +91,13 @@ const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: (values: any) (option) => option.editable && (!option.required || option.defaultValue) ) ?? [] - const initialValues = Object.fromEntries( - [ - ['table_name', ''], - ['columns', []], - ].concat(table.options.map((option) => [option.name, option.defaultValue ?? ''])) - ) + const initialValues = { + table_name: '', + columns: [], + options: Object.fromEntries( + table.options.map((option) => [option.name, option.defaultValue ?? '']) + ), + } return (
{() => (
diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index b7346b72333..ea66fcea2f4 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -1,148 +1,6 @@ -import { Wrapper } from './types' +import { wrappers } from './data' import WrapperCard from './WrapperCard' -const wrappers: Wrapper[] = [ - { - name: 'stripe_wrapper', - extensionName: 'StripeFdw', - label: 'Stripe', - server: { - name: 'stripe_server', - options: [ - { - name: 'api_key_id', - label: 'Stripe Secret Key', - required: true, - encrypted: true, - }, - { - name: 'api_url', - label: 'Stripe API URL', - defaultValue: 'https://api.stripe.com/v1', - required: false, - encrypted: false, - }, - ], - }, - tables: [ - { - label: 'Balance', - availableColumns: [ - { - name: 'amount', - type: 'bigint', - }, - { - name: 'currency', - type: 'text', - }, - ], - options: [ - { - name: 'object', - defaultValue: 'balance', - editable: false, - required: true, - }, - ], - }, - ], - }, - { - name: 'firebase_wrapper', - extensionName: 'FirebaseFdw', - label: 'Firebase', - server: { - name: 'firebase_server', - options: [ - { - name: 'project_id', - label: 'Project ID', - required: true, - encrypted: false, - }, - { - name: 'sa_key_id', - label: 'Service Account Key', - required: true, - encrypted: true, - }, - ], - }, - tables: [ - { - label: 'Users', - availableColumns: [ - { - name: 'local_id', - type: 'text', - }, - { - name: 'email', - type: 'text', - }, - { - name: 'fields', - type: 'jsonb', - }, - ], - options: [ - { - name: 'object', - defaultValue: 'auth/users', - editable: false, - required: true, - }, - { - name: 'base_url', - label: 'Base URL', - defaultValue: 'https://identitytoolkit.googleapis.com/v1/projects', - editable: true, - required: true, - }, - ], - }, - { - label: 'Firestore Collection', - availableColumns: [ - { - name: 'name', - type: 'text', - }, - { - name: 'fields', - type: 'jsonb', - }, - { - name: 'create_time', - type: 'timestamp', - }, - { - name: 'update_time', - type: 'timestamp', - }, - ], - options: [ - { - name: 'object', - label: 'Object', - placeholder: 'firestore/[collection_id]', - editable: true, - required: true, - }, - { - name: 'base_url', - label: 'Base URL', - defaultValue: 'https://firestore.googleapis.com/v1beta1/projects', - editable: true, - required: true, - }, - ], - }, - ], - }, -] - const Wrappers = () => { const enabledWrappers: any[] = [] const disabledWrappers = wrappers diff --git a/studio/components/interfaces/Database/Wrappers/data.ts b/studio/components/interfaces/Database/Wrappers/data.ts new file mode 100644 index 00000000000..5d577325cef --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/data.ts @@ -0,0 +1,143 @@ +import { Wrapper } from './types' + +export const wrappers: Wrapper[] = [ + { + name: 'stripe_wrapper', + extensionName: 'StripeFdw', + label: 'Stripe', + server: { + name: 'stripe_server', + options: [ + { + name: 'api_key_id', + label: 'Stripe Secret Key', + required: true, + encrypted: true, + }, + { + name: 'api_url', + label: 'Stripe API URL', + defaultValue: 'https://api.stripe.com/v1', + required: false, + encrypted: false, + }, + ], + }, + tables: [ + { + label: 'Balance', + availableColumns: [ + { + name: 'amount', + type: 'bigint', + }, + { + name: 'currency', + type: 'text', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'balance', + editable: false, + required: true, + }, + ], + }, + ], + }, + { + name: 'firebase_wrapper', + extensionName: 'FirebaseFdw', + label: 'Firebase', + server: { + name: 'firebase_server', + options: [ + { + name: 'project_id', + label: 'Project ID', + required: true, + encrypted: false, + }, + { + name: 'sa_key_id', + label: 'Service Account Key', + required: true, + encrypted: true, + }, + ], + }, + tables: [ + { + label: 'Users', + availableColumns: [ + { + name: 'local_id', + type: 'text', + }, + { + name: 'email', + type: 'text', + }, + { + name: 'fields', + type: 'jsonb', + }, + ], + options: [ + { + name: 'object', + defaultValue: 'auth/users', + editable: false, + required: true, + }, + { + name: 'base_url', + label: 'Base URL', + defaultValue: 'https://identitytoolkit.googleapis.com/v1/projects', + editable: true, + required: true, + }, + ], + }, + { + label: 'Firestore Collection', + availableColumns: [ + { + name: 'name', + type: 'text', + }, + { + name: 'fields', + type: 'jsonb', + }, + { + name: 'create_time', + type: 'timestamp', + }, + { + name: 'update_time', + type: 'timestamp', + }, + ], + options: [ + { + name: 'object', + label: 'Object', + placeholder: 'firestore/[collection_id]', + editable: true, + required: true, + }, + { + name: 'base_url', + label: 'Base URL', + defaultValue: 'https://firestore.googleapis.com/v1beta1/projects', + editable: true, + required: true, + }, + ], + }, + ], + }, +] diff --git a/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/studio/components/layouts/ProjectLayout/ProjectContext.tsx new file mode 100644 index 00000000000..7be2501528c --- /dev/null +++ b/studio/components/layouts/ProjectLayout/ProjectContext.tsx @@ -0,0 +1,46 @@ +import { useStore } from 'hooks' +import { observer } from 'mobx-react-lite' +import { createContext, PropsWithChildren, useContext, useMemo } from 'react' +import { Project } from 'types' + +type ProjectContextType = { + project: Project | undefined + isLoading: boolean +} + +const ProjectContext = createContext({ + project: undefined, + isLoading: true, +}) + +export default ProjectContext + +export const useProjectContext = () => useContext(ProjectContext) + +type ProjectContextProviderProps = { + projectRef: string | undefined +} + +export const ProjectContextProvider = observer( + ({ projectRef, children }: PropsWithChildren) => { + // TODO(alaister): This will eventually be replaced with a react-query query + // once we remove mobx + const { ui } = useStore() + + const value = useMemo(() => { + if (ui.selectedProject?.ref === projectRef) { + return { + project: ui.selectedProject, + isLoading: false, + } + } + + return { + project: undefined, + isLoading: true, + } + }, [ui.selectedProject?.ref, projectRef]) + + return {children} + } +) diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 0c37dfe0717..bc86ead7c13 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -2,7 +2,7 @@ import Head from 'next/head' import { FC, ReactNode, PropsWithChildren, Fragment } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useStore, withAuth, useFlag } from 'hooks' +import { useStore, withAuth, useFlag, useParams } from 'hooks' import { PROJECT_STATUS } from 'lib/constants' import Connecting from 'components/ui/Loading' @@ -12,6 +12,7 @@ import LayoutHeader from './LayoutHeader' import ConnectingState from './ConnectingState' import PausingState from './PausingState' import BuildingState from './BuildingState' +import { ProjectContextProvider } from './ProjectContext' import RestoringState from './RestoringState' interface Props { @@ -32,12 +33,13 @@ const ProjectLayout = ({ hideHeader = false, hideIconBar = false, }: PropsWithChildren) => { + const { ref: projectRef } = useParams() const { ui } = useStore() const ongoingIncident = useFlag('ongoingIncident') const projectName = ui.selectedProject?.name return ( - <> + {title ? `${title} | Supabase` : projectName ? `${projectName} | Supabase` : 'Supabase'} @@ -55,14 +57,14 @@ const ProjectLayout = ({ </MenuBarWrapper> <main - className="flex w-full flex-1 flex-col overflow-x-hidden" + className="flex flex-col flex-1 w-full overflow-x-hidden" style={{ height: ongoingIncident ? 'calc(100vh - 44px)' : '100vh' }} > {!hideHeader && <LayoutHeader />} <ContentWrapper isLoading={isLoading}>{children}</ContentWrapper> </main> </div> - </> + </ProjectContextProvider> ) } diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts new file mode 100644 index 00000000000..3772f446b9b --- /dev/null +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -0,0 +1,146 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/types' +import { executeSql } from 'data/sql/execute-sql-query' + +export type FDWCreateVariables = { + projectRef?: string + connectionString?: string + wrapper: Wrapper + formState: { + [k: string]: string + } + newTables: any[] +} + +export function getFDWCreateSql({ + wrapper, + formState, + newTables, +}: Pick<FDWCreateVariables, 'wrapper' | 'formState' | 'newTables'>) { + const createWrapperSql = /* SQL */ ` + create foreign data wrapper ${wrapper.name} + handler wrappers_handler + validator wrappers_validator + options ( + wrapper '${wrapper.extensionName}' + ); + ` + + const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted) + const unencryptedOptions = wrapper.server.options.filter((option) => !option.encrypted) + + const createEncryptedKeysSqlArray = encryptedOptions.map((option) => { + const key = `${wrapper.name}_${option.name}` + const value = formState[option.name] // TODO(alaister): escape ' + + return /* SQL */ ` + select pgsodium.create_key(name := '${key}'); + + insert into vault.secrets (secret, key_id) values ( + '${value}', + (select id from pgsodium.valid_key where name = '${key}') + ); + ` + }) + + const createEncryptedKeysSql = createEncryptedKeysSqlArray.join('\n') + + const encryptedOptionsSqlArray = encryptedOptions.map((option) => `${option.name} ''%s''`) + const unencryptedOptionsSqlArray = unencryptedOptions.map( + (option) => `${option.name} ''${formState[option.name]}''` + ) + const optionsSqlArray = [...encryptedOptionsSqlArray, ...unencryptedOptionsSqlArray].join(',') + + const createServerSql = /* SQL */ ` + do $$ + declare + ${encryptedOptions.map((option) => `v_${option.name} text;`).join('\n')} + begin + ${encryptedOptions + .map( + (option) => + /* SQL */ `select id into v_${option.name} from pgsodium.valid_key where name = '${wrapper.name}_${option.name}' limit 1;` + ) + .join('\n')} + + execute format( + E'create server ${wrapper.server.name}\\n' + ' foreign data wrapper ${wrapper.name}\\n' + ' options (\\n' + ' ${optionsSqlArray}\\n' + ' );', + ${encryptedOptions.map((option) => `v_${option.name}`).join(',\n')} + ); + end $$; + ` + + const createTablesSql = newTables + .map((newTable) => { + const table = wrapper.tables[newTable.index] + + const columns: AvailableColumn[] = newTable.columns + .map((name: string) => table.availableColumns.find((c) => c.name === name)) + .filter(Boolean) + + return /* SQL */ ` + create foreign table ${newTable.table_name} ( + ${columns.map((column) => `${column.name} ${column.type}`).join(',\n ')} + ) + server ${wrapper.server.name} + options ( + ${Object.entries(newTable.options) + .map(([key, value]) => `${key} '${value}'`) + .join(',\n ')} + ); + ` + }) + .join('\n\n') + + const sql = /* SQL */ ` + begin; + + ${createWrapperSql} + + ${createEncryptedKeysSql} + + ${createServerSql} + + ${createTablesSql} + + commit; + ` + + return sql +} + +export async function createFDW({ + projectRef, + connectionString, + wrapper, + formState, + newTables, +}: FDWCreateVariables) { + const sql = getFDWCreateSql({ wrapper, formState, newTables }) + + const { result } = await executeSql({ projectRef, connectionString, sql }) + + return result +} + +type FDWCreateData = Awaited<ReturnType<typeof createFDW>> + +export const useFDWCreateMutation = ({ + onSuccess, + ...options +}: Omit<UseMutationOptions<FDWCreateData, unknown, FDWCreateVariables>, 'mutationFn'> = {}) => { + const queryClient = useQueryClient() + + return useMutation<FDWCreateData, unknown, FDWCreateVariables>((vars) => createFDW(vars), { + async onSuccess(data, variables, context) { + // await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name])) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/sql/execute-sql-mutation.ts b/studio/data/sql/execute-sql-mutation.ts new file mode 100644 index 00000000000..5bf0c92b2ae --- /dev/null +++ b/studio/data/sql/execute-sql-mutation.ts @@ -0,0 +1,16 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query' + +/* Execute Query */ + +export const useExecuteSqlMutation = ({ + onSuccess, + ...options +}: Omit<UseMutationOptions<ExecuteSqlData, unknown, ExecuteSqlVariables>, 'mutationFn'> = {}) => { + return useMutation<ExecuteSqlData, unknown, ExecuteSqlVariables>((args) => executeSql(args), { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/studio/data/sql/execute-sql-query.ts b/studio/data/sql/execute-sql-query.ts new file mode 100644 index 00000000000..8fc75f28b45 --- /dev/null +++ b/studio/data/sql/execute-sql-query.ts @@ -0,0 +1,69 @@ +import { QueryKey, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import md5 from 'blueimp-md5' +import { post } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { sqlKeys } from './keys' + +export type ExecuteSqlVariables = { + projectRef?: string + connectionString?: string + sql: string + queryKey?: QueryKey +} + +export async function executeSql( + { projectRef, connectionString, sql }: ExecuteSqlVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + let headers = new Headers() + + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const response = await post( + `${API_URL}/pg-meta/${projectRef}/query`, + { query: sql }, + { headers: Object.fromEntries(headers), signal } + ) + if (response.error) { + throw response.error + } + + return { result: response } +} + +export type ExecuteSqlData = Awaited<ReturnType<typeof executeSql>> +export type ExecuteSqlError = unknown + +export const useExecuteSqlQuery = <TData = ExecuteSqlData>( + { projectRef, connectionString, sql, queryKey }: ExecuteSqlVariables, + { enabled = true, ...options }: UseQueryOptions<ExecuteSqlData, ExecuteSqlError, TData> = {} +) => + useQuery<ExecuteSqlData, ExecuteSqlError, TData>( + sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), + ({ signal }) => executeSql({ projectRef, connectionString, sql }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) + +export const useExecuteSqlPrefetch = ({ + projectRef, + connectionString, + sql, + queryKey, +}: ExecuteSqlVariables) => { + const client = useQueryClient() + + return useCallback(() => { + if (projectRef) { + client.prefetchQuery(sqlKeys.query(projectRef, queryKey ?? [md5(sql)]), ({ signal }) => + executeSql({ projectRef, connectionString, sql }, signal) + ) + } + }, [projectRef]) +} diff --git a/studio/data/sql/keys.ts b/studio/data/sql/keys.ts new file mode 100644 index 00000000000..b651bbfe087 --- /dev/null +++ b/studio/data/sql/keys.ts @@ -0,0 +1,6 @@ +import { QueryKey } from '@tanstack/react-query' + +export const sqlKeys = { + query: (projectRef: string | undefined, queryKey: QueryKey) => + ['projects', projectRef, 'query', ...queryKey] as const, +} diff --git a/studio/package.json b/studio/package.json index f127a053018..f25fde73c25 100644 --- a/studio/package.json +++ b/studio/package.json @@ -34,6 +34,7 @@ "ajv": "^8.6.3", "analytics-node": "^3.5.0", "awesome-debounce-promise": "^2.1.0", + "blueimp-md5": "^2.19.0", "clipboard": "^2.0.8", "common": "*", "config": "*", @@ -103,6 +104,7 @@ "@testing-library/dom": "^8.11.1", "@testing-library/react": "^12.1.2", "@testing-library/user-event": "^13.5.0", + "@types/blueimp-md5": "^2.18.0", "@types/crypto-js": "^4.0.2", "@types/file-saver": "^2.0.2", "@types/json-logic-js": "^1.2.1", From 8d0a4c8bb9952bccb9db53d0c620f21b2f2e3a7c Mon Sep 17 00:00:00 2001 From: Alaister Young <a@alaisteryoung.com> Date: Tue, 6 Dec 2022 15:50:48 +0000 Subject: [PATCH 05/42] adds flag for wrappers --- .../DatabaseLayout/DatabaseMenu.utils.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts index 0ba8e86a251..c7e19c412ae 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts +++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts @@ -1,5 +1,6 @@ import { Project } from 'types' import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import { useFlag } from 'hooks' export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { const ref = project?.ref ?? 'default' @@ -7,6 +8,8 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { const HOOKS_RELEASED = '2021-07-30T15:33:54.383Z' const showHooksRoute = project?.inserted_at ? project.inserted_at > HOOKS_RELEASED : false + const foreignDataWrappersEnabled = useFlag('foreignDataWrappers') + return [ { title: 'Database', @@ -32,12 +35,16 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { url: `/project/${ref}/database/extensions`, items: [], }, - { - name: 'Wrappers', - key: 'wrappers', - url: `/project/${ref}/database/wrappers`, - items: [], - }, + ...(foreignDataWrappersEnabled + ? [ + { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/database/wrappers`, + items: [], + }, + ] + : []), { name: 'Roles', key: 'roles', url: `/project/${ref}/database/roles`, items: [] }, { name: 'Replication', From f35e21b190f8ebbd1862b8983cf0ea1b23f44341 Mon Sep 17 00:00:00 2001 From: Alaister Young <a@alaisteryoung.com> Date: Tue, 6 Dec 2022 19:15:50 +0000 Subject: [PATCH 06/42] detect if wrappers are enabled --- .../interfaces/Database/Wrappers/Wrappers.tsx | 18 ++++++- studio/data/fdw/fdw-create-mutation.ts | 3 +- studio/data/fdw/fdws-query.ts | 53 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 studio/data/fdw/fdws-query.ts diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index ea66fcea2f4..79a975b6a13 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -1,9 +1,23 @@ +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useFDWsQuery } from 'data/fdw/fdws-query' import { wrappers } from './data' import WrapperCard from './WrapperCard' const Wrappers = () => { - const enabledWrappers: any[] = [] - const disabledWrappers = wrappers + const { project } = useProjectContext() + const { data, isLoading } = useFDWsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) + + const enabledWrappers = wrappers.filter((wrapper) => + enabledWrapperNamesSet.has(wrapper.server.name) + ) + const disabledWrappers = wrappers.filter( + (wrapper) => !enabledWrapperNamesSet.has(wrapper.server.name) + ) return ( <> diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts index 3772f446b9b..65fb878e20c 100644 --- a/studio/data/fdw/fdw-create-mutation.ts +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -1,6 +1,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/types' import { executeSql } from 'data/sql/execute-sql-query' +import { sqlKeys } from 'data/sql/keys' export type FDWCreateVariables = { projectRef?: string @@ -137,7 +138,7 @@ export const useFDWCreateMutation = ({ return useMutation<FDWCreateData, unknown, FDWCreateVariables>((vars) => createFDW(vars), { async onSuccess(data, variables, context) { - // await queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name])) + await queryClient.invalidateQueries(sqlKeys.query(variables.projectRef, ['fdws'])) await onSuccess?.(data, variables, context) }, diff --git a/studio/data/fdw/fdws-query.ts b/studio/data/fdw/fdws-query.ts new file mode 100644 index 00000000000..f5b95f2904c --- /dev/null +++ b/studio/data/fdw/fdws-query.ts @@ -0,0 +1,53 @@ +import { UseQueryOptions } from '@tanstack/react-query' +import { ExecuteSqlData, useExecuteSqlPrefetch, useExecuteSqlQuery } from '../sql/execute-sql-query' + +export const getFDWsSql = () => { + const sql = /* SQL */ ` + select + oid as id, + srvname as name + from pg_catalog.pg_foreign_server; + ` + + return sql +} + +export type FDW = { + id: string + name: string +} + +export type FDWsResponse = { + result: FDW[] +} + +export type FDWsVariables = { + projectRef?: string + connectionString?: string +} + +export type FDWsData = FDWsResponse +export type FDWsError = unknown + +export const useFDWsQuery = <TData extends FDWsData = FDWsData>( + { projectRef, connectionString }: FDWsVariables, + options: UseQueryOptions<ExecuteSqlData, FDWsError, TData> = {} +) => + useExecuteSqlQuery( + { + projectRef, + connectionString, + sql: getFDWsSql(), + queryKey: ['fdws'], + }, + options + ) + +export const useFDWsPrefetch = ({ projectRef, connectionString }: FDWsVariables) => { + return useExecuteSqlPrefetch({ + projectRef, + connectionString, + sql: getFDWsSql(), + queryKey: ['fdws'], + }) +} From 8ec82cbad7f84f2613c136fdb9717b2b08b08d68 Mon Sep 17 00:00:00 2001 From: Alaister Young <a@alaisteryoung.com> Date: Tue, 6 Dec 2022 20:39:50 +0000 Subject: [PATCH 07/42] delete fdw --- .../Database/Wrappers/WrapperCard.tsx | 41 +++++++--------- .../interfaces/Database/Wrappers/Wrappers.tsx | 4 +- .../custom-domains-delete-mutation.ts | 1 - studio/data/fdw/fdw-create-mutation.ts | 4 +- studio/data/fdw/fdw-delete-mutation.ts | 49 +++++++++++++++++++ 5 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 studio/data/fdw/fdw-delete-mutation.ts diff --git a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx index 243ba078e1e..f59ea1bfb3d 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx @@ -7,21 +7,24 @@ import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmM import { checkPermissions, useStore } from 'hooks' import { Wrapper } from './types' import WrapperEditor from './WrapperEditor' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' export type WrapperCardProps = { wrapper: Wrapper + enabled?: boolean } -const WrapperCard = ({ wrapper }: WrapperCardProps) => { +const WrapperCard = ({ wrapper, enabled = false }: WrapperCardProps) => { + const { project } = useProjectContext() const { ui } = useStore() - const isOn = false - const [loading, setLoading] = useState(false) - const canUpdateWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') const [isEditorVisible, setIsEditorVisible] = useState(false) + const { mutateAsync: deleteFDW, isLoading } = useFDWDeleteMutation() + async function enableWrapper() { setIsEditorVisible(true) } @@ -29,29 +32,19 @@ const WrapperCard = ({ wrapper }: WrapperCardProps) => { async function disableWrapper() { confirmAlert({ title: 'Confirm to disable wrapper', - message: `Are you sure you want to turn OFF "${wrapper.name}" wrapper?`, + message: `Are you sure you want to turn OFF "${wrapper.name}" wrapper? This will also delete ALL tables created with this wrapper.`, onAsyncConfirm: async () => { try { - setLoading(true) - // const response: any = await meta.wrappers.del(wrapper.name) - // if (response.error) { - // throw response.error - // } else { - // ui.setNotification({ - // category: 'success', - // message: `${wrapper.name.toUpperCase()} is off.`, - // }) - // } + await deleteFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: wrapper.name, + }) } catch (error: any) { ui.setNotification({ category: 'error', - message: `Toggle ${wrapper.name.toUpperCase()} failed: ${error.message}`, + message: `Disabling ${wrapper.name} failed: ${error.message}`, }) - } finally { - // Need to reload them because the delete function - // removes the wrapper from the store - // meta.wrappers.load() - setLoading(false) } }, }) @@ -77,14 +70,14 @@ const WrapperCard = ({ wrapper }: WrapperCardProps) => { > {wrapper.label} </h3> - {loading ? ( + {isLoading ? ( <IconLoader className="animate-spin" size={16} /> ) : ( <Toggle size="tiny" - checked={isOn} + checked={enabled} disabled={!canUpdateWrappers} - onChange={() => (isOn ? disableWrapper() : enableWrapper())} + onChange={() => (enabled ? disableWrapper() : enableWrapper())} /> )} </div> diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 79a975b6a13..69ef2c83c80 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -28,7 +28,7 @@ const Wrappers = () => { <h4 className="text-lg">Enabled wrappers</h4> <div className="grid grid-cols-1 gap-6 mb-4 md:grid-cols-2 xl:grid-cols-3"> {enabledWrappers.map((wrapper) => ( - <WrapperCard key={wrapper.name} wrapper={wrapper} /> + <WrapperCard key={wrapper.name} wrapper={wrapper} enabled={true} /> ))} </div> </div> @@ -39,7 +39,7 @@ const Wrappers = () => { <h4 className="text-lg">Available wrappers</h4> <div className="grid grid-cols-1 gap-6 mb-4 md:grid-cols-2 xl:grid-cols-3"> {disabledWrappers.map((wrapper) => ( - <WrapperCard key={wrapper.name} wrapper={wrapper} /> + <WrapperCard key={wrapper.name} wrapper={wrapper} enabled={false} /> ))} </div> </div> diff --git a/studio/data/custom-domains/custom-domains-delete-mutation.ts b/studio/data/custom-domains/custom-domains-delete-mutation.ts index 014ce571e4d..50e992f4831 100644 --- a/studio/data/custom-domains/custom-domains-delete-mutation.ts +++ b/studio/data/custom-domains/custom-domains-delete-mutation.ts @@ -1,6 +1,5 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { delete_ } from 'lib/common/fetch' -import { getCustomDomains } from './custom-domains-query' import { customDomainKeys } from './keys' export type CustomDomainDeleteVariables = { diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts index 65fb878e20c..5cd08b6d7c6 100644 --- a/studio/data/fdw/fdw-create-mutation.ts +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -138,7 +138,9 @@ export const useFDWCreateMutation = ({ return useMutation<FDWCreateData, unknown, FDWCreateVariables>((vars) => createFDW(vars), { async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(sqlKeys.query(variables.projectRef, ['fdws'])) + const { projectRef } = variables + + await queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])) await onSuccess?.(data, variables, context) }, diff --git a/studio/data/fdw/fdw-delete-mutation.ts b/studio/data/fdw/fdw-delete-mutation.ts new file mode 100644 index 00000000000..854555343d4 --- /dev/null +++ b/studio/data/fdw/fdw-delete-mutation.ts @@ -0,0 +1,49 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { sqlKeys } from 'data/sql/keys' + +export type FDWDeleteVariables = { + projectRef?: string + connectionString?: string + name: string +} + +export const getDeleteFDWSql = ({ name }: Pick<FDWDeleteVariables, 'name'>) => { + const sql = /* SQL */ ` + drop foreign data wrapper if exists ${name} cascade; + ` + + return sql +} + +export async function deleteFDW({ projectRef, connectionString, name }: FDWDeleteVariables) { + if (!projectRef) { + throw new Error('projectRef is required') + } + + const sql = getDeleteFDWSql({ name }) + + const { result } = await executeSql({ projectRef, connectionString, sql }) + + return result +} + +type FDWDeleteData = Awaited<ReturnType<typeof deleteFDW>> + +export const useFDWDeleteMutation = ({ + onSuccess, + ...options +}: Omit<UseMutationOptions<FDWDeleteData, unknown, FDWDeleteVariables>, 'mutationFn'> = {}) => { + const queryClient = useQueryClient() + + return useMutation<FDWDeleteData, unknown, FDWDeleteVariables>((vars) => deleteFDW(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(sqlKeys.query(projectRef, ['fdws'])) + + await onSuccess?.(data, variables, context) + }, + ...options, + }) +} From 4b23d42c672e113a69f63e655ace50738ed34c64 Mon Sep 17 00:00:00 2001 From: Alaister Young <a@alaisteryoung.com> Date: Tue, 6 Dec 2022 23:03:05 +0000 Subject: [PATCH 08/42] adds some fdw validation --- .../Database/Wrappers/WrapperEditor.tsx | 46 +++++++++++++------ .../Database/Wrappers/WrapperTableEditor.tsx | 27 +++++++---- .../interfaces/Database/Wrappers/utils.ts | 17 +++++++ studio/data/fdw/fdw-create-mutation.ts | 3 +- 4 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 studio/components/interfaces/Database/Wrappers/utils.ts diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 10c29dd410b..50bf744243a 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -1,11 +1,13 @@ import { useState } from 'react' import { Button, Input, SidePanel } from 'ui' +import { isEmpty } from 'lodash' import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' import { useStore } from 'hooks' import { Wrapper } from './types' +import { makeValidateRequired } from './utils' import WrapperTableEditor from './WrapperTableEditor' export type WrapperEditorProps = { @@ -17,11 +19,7 @@ export type WrapperEditorProps = { const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { const { ui } = useStore() - const validate = (values: any) => { - const errors: any = {} - if (values.schema === 'custom' && !values.name) errors.name = 'Required field' - return errors - } + const validate = makeValidateRequired(wrapper.server.options) const getInitialFormState = () => Object.fromEntries( @@ -29,6 +27,7 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { ) const [formState, setFormState] = useState(getInitialFormState) + const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) const [newTables, setNewTables] = useState<any[]>([]) const [isAddTableOpen, setIsAddTableOpen] = useState(false) @@ -38,10 +37,28 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { setIsAddTableOpen(false) } + const resetForm = () => { + setFormState(getInitialFormState()) + setNewTables([]) + } + + const handleCancel = () => { + resetForm() + onCancel() + } + const { project } = useProjectContext() const { mutateAsync: createFDW } = useFDWCreateMutation() const onSaveChanges = async (done: () => void) => { + // Validate form + const errors = validate(formState) + if (!isEmpty(errors)) { + setFormErrors(errors) + done() + return + } + const toastId = ui.setNotification({ category: 'loading', message: `Creating foreign data wrapper...`, @@ -56,15 +73,15 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { newTables, }) - // Reset state - setFormState(getInitialFormState()) - setNewTables([]) + resetForm() ui.setNotification({ id: toastId, category: 'success', message: `Created foreign data wrapper successfully!`, }) + + onCancel() } catch (error: any) { ui.setNotification({ id: toastId, @@ -74,7 +91,6 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { } done() - onCancel() } return ( @@ -88,14 +104,14 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { <h5 className="text-sm text-scale-1200">{wrapper.label} Foreign Data Wrapper</h5> </div> } - className={`transition-all duration-100 ease-in`} - onCancel={onCancel} + className="transition-all duration-100 ease-in" + onCancel={handleCancel} onConfirm={() => (resolve: () => void) => onSaveChanges(resolve)} customFooter={ <ActionBar backButtonLabel="Cancel" applyButtonLabel="Save" - closePanel={onCancel} + closePanel={handleCancel} applyFunction={(resolve: () => void) => onSaveChanges(resolve)} /> } @@ -119,9 +135,11 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { required={option.required ?? false} layout="horizontal" value={formState[option.name]} - onChange={(e) => + onChange={(e) => { setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) - } + setFormErrors((prev) => ({ ...prev, [option.name]: '' })) + }} + error={formErrors[option.name]} /> ))} diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index f3e65756af4..291413239bd 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -2,6 +2,7 @@ import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/Act import { useState } from 'react' import { Accordion, Checkbox, Form, Input, Listbox, SidePanel } from 'ui' import { Table, TableOption } from './types' +import { makeValidateRequired } from './utils' export type WrapperTableEditorProps = { visible: boolean @@ -73,11 +74,10 @@ const Option = ({ option }: { option: TableOption }) => { <Input key={option.name} id={option.name} - name={`options.${option.name}`} + name={option.name} label={option.label} placeholder={option.placeholder ?? ''} defaultValue={option.defaultValue ?? ''} - required={option.required ?? false} /> ) } @@ -94,27 +94,30 @@ const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) const initialValues = { table_name: '', columns: [], - options: Object.fromEntries( - table.options.map((option) => [option.name, option.defaultValue ?? '']) - ), + ...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])), } + const validate = makeValidateRequired([ + ...table.options, + { name: 'table_name', required: true }, + { name: 'columns', required: true }, + ]) + return ( <Form id="wrapper-table-editor-form" initialValues={initialValues} - // validate={validate} + validate={validate} onSubmit={onSubmit} enableReinitialize={true} > - {() => ( + {({ errors }: any) => ( <div className="space-y-4"> <Input - id="table-name" + id="table_name" name="table_name" label="Table name" descriptionText="The name of the local table table you will query after the wrapper is enabled." - required /> {requiredOptions.map((option) => ( @@ -135,6 +138,12 @@ const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) /> ))} </div> + + {errors.columns && ( + <p className="text-sm text-red-900 transition-all data-show:mt-2 data-show:animate-slide-down-normal data-hide:animate-slide-up-normal"> + {errors.columns} + </p> + )} </div> {optionalOptions.length > 0 && ( diff --git a/studio/components/interfaces/Database/Wrappers/utils.ts b/studio/components/interfaces/Database/Wrappers/utils.ts new file mode 100644 index 00000000000..6bf0b68c509 --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/utils.ts @@ -0,0 +1,17 @@ +export const makeValidateRequired = + (options: { name: string; required: boolean }[]) => (values: any) => { + const requiredOptionsSet = new Set( + options.filter((option) => option.required).map((option) => option.name) + ) + + const errors = Object.fromEntries( + Object.entries(values) + .filter( + ([key, value]) => + requiredOptionsSet.has(key) && (Array.isArray(value) ? value.length < 1 : !value) + ) + .map(([key]) => [key, 'Required']) + ) + + return errors + } diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts index 5cd08b6d7c6..e298841df21 100644 --- a/studio/data/fdw/fdw-create-mutation.ts +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -89,7 +89,8 @@ export function getFDWCreateSql({ ) server ${wrapper.server.name} options ( - ${Object.entries(newTable.options) + ${Object.entries(newTable) + .filter(([key]) => key !== 'table_name' && key !== 'columns' && key !== 'index') .map(([key, value]) => `${key} '${value}'`) .join(',\n ')} ); From b0beb4387f3a1c20b34deac2dcf9842e4439c0ea Mon Sep 17 00:00:00 2001 From: Alaister Young <a@alaisteryoung.com> Date: Tue, 6 Dec 2022 23:35:07 +0000 Subject: [PATCH 09/42] adds simple editing on fdw tables --- .../Database/Wrappers/WrapperEditor.tsx | 63 ++++++++++++++++--- .../Database/Wrappers/WrapperTableEditor.tsx | 50 ++++++++++++--- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 50bf744243a..096624e4674 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react' -import { Button, Input, SidePanel } from 'ui' import { isEmpty } from 'lodash' +import { useState } from 'react' +import { Button, IconDelete, IconEdit, IconTrash, IconTrash2, Input, SidePanel } from 'ui' import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' @@ -31,10 +31,23 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { const [newTables, setNewTables] = useState<any[]>([]) const [isAddTableOpen, setIsAddTableOpen] = useState(false) + const [editingTable, setEditingTable] = useState<any>(null) const onTableAdd = (values: any) => { - setNewTables((prev) => [...prev, values]) + setNewTables((prev) => { + // if the new values have tableIndex, we are editing an existing table + if (values.tableIndex !== undefined) { + const tableIndex = values.tableIndex + const newTables = [...prev] + delete values.tableIndex + newTables[tableIndex] = values + return newTables + } + + return [...prev, values] + }) setIsAddTableOpen(false) + setEditingTable(null) } const resetForm = () => { @@ -140,12 +153,44 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { setFormErrors((prev) => ({ ...prev, [option.name]: '' })) }} error={formErrors[option.name]} + className="input-mono" /> ))} - {newTables.map((table, i) => ( - <div key={i}>{table.table_name}</div> - ))} + {newTables.length > 0 && ( + <div className="flex flex-col gap-4"> + <div className="block text-sm break-all text-scale-1100">Tables</div> + + {newTables.map((table, i) => ( + <div + key={i} + className="flex items-center justify-between py-2 pl-4 pr-2 border rounded bg-scale-400 border-scale-600" + > + <div className="font-mono text-lg">{table.table_name}</div> + + <div className="flex items-center gap-2"> + <Button + icon={<IconEdit size="small" />} + size="tiny" + type="outline" + onClick={() => { + setEditingTable({ ...table, tableIndex: i }) + setIsAddTableOpen(true) + }} + /> + <Button + icon={<IconTrash2 size="small" />} + size="tiny" + type="outline" + onClick={() => { + setNewTables((prev) => prev.filter((_, j) => j !== i)) + }} + /> + </div> + </div> + ))} + </div> + )} <Button type="default" onClick={() => setIsAddTableOpen(true)}> Add table @@ -158,8 +203,12 @@ const WrapperEditor = ({ visible, wrapper, onCancel }: WrapperEditorProps) => { <WrapperTableEditor visible={isAddTableOpen} tables={wrapper.tables} - onCancel={() => setIsAddTableOpen(false)} + onCancel={() => { + setEditingTable(null) + setIsAddTableOpen(false) + }} onSave={onTableAdd} + initialData={editingTable} /> </> ) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index 291413239bd..e0c0b501738 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -1,5 +1,5 @@ import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Accordion, Checkbox, Form, Input, Listbox, SidePanel } from 'ui' import { Table, TableOption } from './types' import { makeValidateRequired } from './utils' @@ -10,18 +10,37 @@ export type WrapperTableEditorProps = { onSave: (values: any) => void tables: Table[] + initialData: any } type OnSubmitFn = (values: any, { resetForm }: { resetForm: () => void }) => void -const WrapperTableEditor = ({ visible, onCancel, onSave, tables }: WrapperTableEditorProps) => { +const WrapperTableEditor = ({ + visible, + onCancel, + onSave, + tables, + initialData, +}: WrapperTableEditorProps) => { const [selectedTableIndex, setSelectedTableIndex] = useState<string>('') + useEffect(() => { + if (initialData) { + setSelectedTableIndex(String(initialData.index)) + } + }, [initialData]) + const selectedTable = selectedTableIndex === '' ? undefined : tables[parseInt(selectedTableIndex)] + const handleCancel = () => { + setSelectedTableIndex('') + onCancel() + } + const onSubmit: OnSubmitFn = (values, { resetForm }) => { onSave({ ...values, index: parseInt(selectedTableIndex) }) resetForm() + setSelectedTableIndex('') } return ( @@ -29,14 +48,14 @@ const WrapperTableEditor = ({ visible, onCancel, onSave, tables }: WrapperTableE key="WrapperTableEditor" size="medium" visible={visible} - onCancel={onCancel} + onCancel={handleCancel} header={<span>Edit foreign table</span>} customFooter={ <ActionBar backButtonLabel="Cancel" applyButtonLabel="Save" formId="wrapper-table-editor-form" - closePanel={onCancel} + closePanel={handleCancel} /> } > @@ -60,7 +79,9 @@ const WrapperTableEditor = ({ visible, onCancel, onSave, tables }: WrapperTableE })} </Listbox> - {selectedTable && <TableForm table={selectedTable} onSubmit={onSubmit} />} + {selectedTable && ( + <TableForm table={selectedTable} onSubmit={onSubmit} initialData={initialData} /> + )} </div> </SidePanel.Content> </SidePanel> @@ -78,11 +99,20 @@ const Option = ({ option }: { option: TableOption }) => { label={option.label} placeholder={option.placeholder ?? ''} defaultValue={option.defaultValue ?? ''} + className="input-mono" /> ) } -const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) => { +const TableForm = ({ + table, + onSubmit, + initialData, +}: { + table: Table + onSubmit: OnSubmitFn + initialData: any +}) => { const requiredOptions = table.options.filter((option) => option.editable && option.required && !option.defaultValue) ?? [] @@ -91,7 +121,7 @@ const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) (option) => option.editable && (!option.required || option.defaultValue) ) ?? [] - const initialValues = { + const initialValues = initialData ?? { table_name: '', columns: [], ...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])), @@ -111,13 +141,14 @@ const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) onSubmit={onSubmit} enableReinitialize={true} > - {({ errors }: any) => ( + {({ errors, touched, values }: any) => ( <div className="space-y-4"> <Input id="table_name" name="table_name" label="Table name" descriptionText="The name of the local table table you will query after the wrapper is enabled." + className="input-mono" /> {requiredOptions.map((option) => ( @@ -134,12 +165,13 @@ const TableForm = ({ table, onSubmit }: { table: Table; onSubmit: OnSubmitFn }) id={column.name} name="columns" value={column.name} + checked={values.columns.includes(column.name)} label={`${column.name} (${column.type})`} /> ))} </div> - {errors.columns && ( + {touched.columns && errors.columns && ( <p className="text-sm text-red-900 transition-all data-show:mt-2 data-show:animate-slide-down-normal data-hide:animate-slide-up-normal"> {errors.columns} </p> From 09cdf759ab8aa40281116fdc71ca19b2526756ff Mon Sep 17 00:00:00 2001 From: Joshen Lim <joshenlimek@gmail.com> Date: Wed, 7 Dec 2022 17:00:04 +0800 Subject: [PATCH 10/42] Add logos --- .../Database/Wrappers/WrapperCard.tsx | 18 ++++--- .../interfaces/Database/Wrappers/Wrappers.tsx | 7 ++- .../interfaces/Database/Wrappers/data.ts | 2 + .../interfaces/Database/Wrappers/types.ts | 1 + .../pages/project/[ref]/database/wrappers.tsx | 8 ++- studio/public/img/icons/firebase-icon.svg | 51 +++++++++++++++++++ studio/public/img/icons/stripe-icon.svg | 1 + 7 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 studio/public/img/icons/firebase-icon.svg create mode 100644 studio/public/img/icons/stripe-icon.svg diff --git a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx index f59ea1bfb3d..214df2baa34 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx @@ -9,6 +9,7 @@ import { Wrapper } from './types' import WrapperEditor from './WrapperEditor' import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import Image from 'next/image' export type WrapperCardProps = { wrapper: Wrapper @@ -61,15 +62,18 @@ const WrapperCard = ({ wrapper, enabled = false }: WrapperCardProps) => { <div className={[ 'border-panel-border-light bg-panel-header-light dark:bg-panel-header-dark', - 'flex border-b p-4 px-6 dark:border-panel-border-dark', + 'flex justify-between border-b p-4 px-6 dark:border-panel-border-dark', ].join(' ')} > - <h3 - title={wrapper.label} - className="flex-1 h-5 m-0 text-base uppercase truncate text-scale-1200" - > - {wrapper.label} - </h3> + <div className="flex items-center space-x-3"> + <Image src={wrapper.icon} height={20} width={20} /> + <h3 + title={wrapper.label} + className="flex-1 h-5 m-0 text-base uppercase truncate text-scale-1200 capitalize" + > + {wrapper.label} + </h3> + </div> {isLoading ? ( <IconLoader className="animate-spin" size={16} /> ) : ( diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 69ef2c83c80..04cd12e05dd 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -1,4 +1,5 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormHeader } from 'components/ui/Forms' import { useFDWsQuery } from 'data/fdw/fdws-query' import { wrappers } from './data' import WrapperCard from './WrapperCard' @@ -21,7 +22,11 @@ const Wrappers = () => { return ( <> - <div className="p-4 space-y-4"> + <FormHeader + title="Foreign Data Wrappers" + description="Connect your database to external systems. Query your data warehouse directly from your database, or query third-party APIs using SQL." + /> + <div className="space-y-4"> <div className="w-full space-y-12"> {enabledWrappers.length > 0 && ( <div className="space-y-4"> diff --git a/studio/components/interfaces/Database/Wrappers/data.ts b/studio/components/interfaces/Database/Wrappers/data.ts index 5d577325cef..816cac4626d 100644 --- a/studio/components/interfaces/Database/Wrappers/data.ts +++ b/studio/components/interfaces/Database/Wrappers/data.ts @@ -3,6 +3,7 @@ import { Wrapper } from './types' export const wrappers: Wrapper[] = [ { name: 'stripe_wrapper', + icon: '/img/icons/stripe-icon.svg', extensionName: 'StripeFdw', label: 'Stripe', server: { @@ -49,6 +50,7 @@ export const wrappers: Wrapper[] = [ }, { name: 'firebase_wrapper', + icon: '/img/icons/firebase-icon.svg', extensionName: 'FirebaseFdw', label: 'Firebase', server: { diff --git a/studio/components/interfaces/Database/Wrappers/types.ts b/studio/components/interfaces/Database/Wrappers/types.ts index 66a2f4f2ea3..42544c56382 100644 --- a/studio/components/interfaces/Database/Wrappers/types.ts +++ b/studio/components/interfaces/Database/Wrappers/types.ts @@ -1,5 +1,6 @@ export type Wrapper = { name: string + icon: string extensionName: string label: string server: Server diff --git a/studio/pages/project/[ref]/database/wrappers.tsx b/studio/pages/project/[ref]/database/wrappers.tsx index f28e918c273..90f45dd7209 100644 --- a/studio/pages/project/[ref]/database/wrappers.tsx +++ b/studio/pages/project/[ref]/database/wrappers.tsx @@ -16,6 +16,12 @@ const DatabaseWrappers: NextPageWithLayout = () => { return <Wrappers /> } -DatabaseWrappers.getLayout = (page) => <DatabaseLayout title="Wrappers">{page}</DatabaseLayout> +DatabaseWrappers.getLayout = (page) => ( + <DatabaseLayout title="Wrappers"> + <div className="1xl:px-28 mx-auto flex flex-col gap-8 px-5 py-6 lg:px-16 xl:px-24 2xl:px-32 "> + {page} + </div> + </DatabaseLayout> +) export default observer(DatabaseWrappers) diff --git a/studio/public/img/icons/firebase-icon.svg b/studio/public/img/icons/firebase-icon.svg new file mode 100644 index 00000000000..400716b89d4 --- /dev/null +++ b/studio/public/img/icons/firebase-icon.svg @@ -0,0 +1,51 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="111px" height="151px" viewBox="0 0 111 151" version="1.1"> + <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch --> + <title>logo_lockup_firebase_vertical + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/studio/public/img/icons/stripe-icon.svg b/studio/public/img/icons/stripe-icon.svg new file mode 100644 index 00000000000..66dd20f76c0 --- /dev/null +++ b/studio/public/img/icons/stripe-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file From e94f459560d51c667c131c40da2c7c714d1fa356 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 7 Dec 2022 17:20:31 +0800 Subject: [PATCH 11/42] Init FDW rows --- .../Database/Wrappers/WrapperRow.tsx | 86 +++++++++++++++++++ .../interfaces/Database/Wrappers/Wrappers.tsx | 20 +++++ 2 files changed, 106 insertions(+) create mode 100644 studio/components/interfaces/Database/Wrappers/WrapperRow.tsx diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx new file mode 100644 index 00000000000..32b7d4ccd8b --- /dev/null +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -0,0 +1,86 @@ +import Image from 'next/image' +import { FC } from 'react' +import { Collapsible, Form, IconCheck, IconChevronUp } from 'ui' +import { Wrapper } from './types' + +interface Props { + wrapper: Wrapper + isOpen: boolean + onOpen: (wrapper: string) => void +} + +const WrapperRow: FC = ({ wrapper, isOpen, onOpen }) => { + return ( + onOpen(wrapper.name)} + className={[ + 'bg-scale-100 dark:bg-scale-300 ', + 'hover:bg-scale-200 dark:hover:bg-scale-500', + 'data-open:bg-scale-200 dark:data-open:bg-scale-500', + 'border-scale-300', + 'dark:border-scale-500 hover:border-scale-500', + 'dark:hover:border-scale-700 data-open:border-scale-700', + 'data-open:pb-px col-span-12 mx-auto', + '-space-y-px overflow-hidden', + 'transition border shadow hover:z-50', + 'first:rounded-tl first:rounded-tr', + 'last:rounded-bl last:rounded-br', + ].join(' ')} + > + + + + {}} + > + {({ isSubmitting, handleReset, initialValues, values }: any) => { + return ( + +
+ Hello +
+
+ ) + }} + +
+ ) +} + +export default WrapperRow diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 04cd12e05dd..8c0bf08155d 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -1,8 +1,12 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' import { useFDWsQuery } from 'data/fdw/fdws-query' +import { Collapsible, Form, IconCheck, IconLoader, Toggle } from 'ui' import { wrappers } from './data' import WrapperCard from './WrapperCard' +import Image from 'next/image' +import WrapperRow from './WrapperRow' +import { useState } from 'react' const Wrappers = () => { const { project } = useProjectContext() @@ -10,6 +14,7 @@ const Wrappers = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) + const [open, setOpen] = useState('') const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) @@ -26,6 +31,21 @@ const Wrappers = () => { title="Foreign Data Wrappers" description="Connect your database to external systems. Query your data warehouse directly from your database, or query third-party APIs using SQL." /> +
+ {wrappers.map((wrapper) => { + return ( + { + if (open !== wrapperName) setOpen(wrapperName) + else setOpen('') + }} + /> + ) + })} +
+
{enabledWrappers.length > 0 && ( From 0b7064530358a64f63fc3b193df6d4c239219486 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 8 Dec 2022 02:53:38 +0800 Subject: [PATCH 12/42] Update FDW UI --- .../Database/Wrappers/WrapperCard.tsx | 2 + .../Database/Wrappers/WrapperEditor.tsx | 2 + .../Database/Wrappers/WrapperRow.tsx | 328 ++++++++++++++---- .../Database/Wrappers/WrapperTableEditor.tsx | 2 - .../interfaces/Database/Wrappers/Wrappers.tsx | 22 +- .../interfaces/Database/Wrappers/data.ts | 5 + .../interfaces/Database/Wrappers/types.ts | 2 + studio/components/ui/MultiSelect/index.tsx | 2 +- .../pages/project/[ref]/database/wrappers.tsx | 5 +- 9 files changed, 284 insertions(+), 86 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx index 214df2baa34..f1756648276 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx @@ -11,6 +11,8 @@ import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Image from 'next/image' +// [Joshen TODO] No longer used, can remove after WrapperRow is working + export type WrapperCardProps = { wrapper: Wrapper enabled?: boolean diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 096624e4674..65cde64fa25 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -10,6 +10,8 @@ import { Wrapper } from './types' import { makeValidateRequired } from './utils' import WrapperTableEditor from './WrapperTableEditor' +// [Joshen TODO] No longer used, can remove after WrapperRow is working + export type WrapperEditorProps = { visible: boolean wrapper: Wrapper diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 32b7d4ccd8b..6e5bb32d556 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -1,85 +1,275 @@ import Image from 'next/image' -import { FC } from 'react' -import { Collapsible, Form, IconCheck, IconChevronUp } from 'ui' +import { isEmpty } from 'lodash' +import { FC, useState } from 'react' +import { + Collapsible, + Input, + IconCheck, + IconChevronUp, + Button, + IconEdit, + IconTrash, + IconEye, + IconEyeOff, +} from 'ui' + +import { useStore } from 'hooks' import { Wrapper } from './types' +import { makeValidateRequired } from './utils' +import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import WrapperTableEditor from './WrapperTableEditor' interface Props { wrapper: Wrapper + isEnabled: boolean isOpen: boolean onOpen: (wrapper: string) => void } -const WrapperRow: FC = ({ wrapper, isOpen, onOpen }) => { +const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { + const getInitialFormState = () => + Object.fromEntries( + wrapper.server.options.map((option) => [option.name, option.defaultValue ?? '']) + ) + + const { ui } = useStore() + const { project } = useProjectContext() + const { mutateAsync: createFDW } = useFDWCreateMutation() + + const [isSubmitting, setIsSubmitting] = useState(false) + const [isEditingTable, setIsEditingTable] = useState(false) + const [selectedTableToEdit, setSelectedTableToEdit] = useState() + + const [formState, setFormState] = useState(getInitialFormState) + const [newTables, setNewTables] = useState([]) + const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) + + // [Joshen TODO] Fix this logic + const noChanges = false + // const noChanges = JSON.stringify(getInitialFormState()) === JSON.stringify(formState) + + const onUpdateTable = (values: any) => { + setNewTables((prev) => { + // if the new values have tableIndex, we are editing an existing table + if (values.tableIndex !== undefined) { + const tableIndex = values.tableIndex + const newTables = [...prev] + delete values.tableIndex + newTables[tableIndex] = values + return newTables + } + return [...prev, values] + }) + setIsEditingTable(false) + setSelectedTableToEdit(undefined) + } + + const onSubmit = async () => { + const validate = makeValidateRequired(wrapper.server.options) + const errors = validate(formState) + if (!isEmpty(errors)) { + setFormErrors(errors) + return + } + + setIsSubmitting(true) + try { + await createFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper, + formState, + newTables, + }) + ui.setNotification({ + category: 'success', + message: `Successfully created ${wrapper.label} foreign data wrapper`, + }) + onOpen('') + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Failed to create ${wrapper.label} foreign data wrapper: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + return ( - onOpen(wrapper.name)} - className={[ - 'bg-scale-100 dark:bg-scale-300 ', - 'hover:bg-scale-200 dark:hover:bg-scale-500', - 'data-open:bg-scale-200 dark:data-open:bg-scale-500', - 'border-scale-300', - 'dark:border-scale-500 hover:border-scale-500', - 'dark:hover:border-scale-700 data-open:border-scale-700', - 'data-open:pb-px col-span-12 mx-auto', - '-space-y-px overflow-hidden', - 'transition border shadow hover:z-50', - 'first:rounded-tl first:rounded-tr', - 'last:rounded-bl last:rounded-br', - ].join(' ')} - > - - - -
{}} + <> + onOpen(wrapper.name)} + className={[ + 'bg-scale-100 dark:bg-scale-300 ', + 'hover:bg-scale-200 dark:hover:bg-scale-500', + 'data-open:bg-scale-200 dark:data-open:bg-scale-500', + 'border-scale-300', + 'dark:border-scale-500 hover:border-scale-500', + 'dark:hover:border-scale-700 data-open:border-scale-700', + 'data-open:pb-px col-span-12 mx-auto', + '-space-y-px overflow-hidden', + 'transition border shadow hover:z-50', + 'first:rounded-tl first:rounded-tr', + 'last:rounded-bl last:rounded-br', + ].join(' ')} > - {({ isSubmitting, handleReset, initialValues, values }: any) => { - return ( - -
- Hello + + + + +
+
+ {wrapper.server.options.map((option) => { + const [showHidden, setShowHidden] = useState(option.hidden) + return ( + { + setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) + setFormErrors((prev) => ({ ...prev, [option.name]: '' })) + }} + error={formErrors[option.name]} + className="input-mono" + type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'} + actions={ + option.hidden ? ( +
+
+ ) : null + } + /> + ) + })} +
+
+
+

Foreign Tables

+ +
+
+
+ {newTables.length === 0 && ( +
+

+ Add foreign tables to query from after the wrapper is enabled +

+
+ )} + {newTables.map((table, i) => ( +
+
+

{table.table_name}

+

+ {wrapper.tables[table.index].label}: {table.columns.join(', ')} +

+
+
+
+
+ ))} +
+
+ {/* [Joshen] Thinking if we need to add a disclaimer here that users cannot edit wrappers */} +
+ + +
+
- - ) +
+
+
+ + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) }} - - + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + ) } diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index e0c0b501738..0f8a4db53ee 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -99,7 +99,6 @@ const Option = ({ option }: { option: TableOption }) => { label={option.label} placeholder={option.placeholder ?? ''} defaultValue={option.defaultValue ?? ''} - className="input-mono" /> ) } @@ -148,7 +147,6 @@ const TableForm = ({ name="table_name" label="Table name" descriptionText="The name of the local table table you will query after the wrapper is enabled." - className="input-mono" /> {requiredOptions.map((option) => ( diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 8c0bf08155d..bbdcf5f8938 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -1,12 +1,12 @@ +import { partition } from 'lodash' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' import { useFDWsQuery } from 'data/fdw/fdws-query' -import { Collapsible, Form, IconCheck, IconLoader, Toggle } from 'ui' import { wrappers } from './data' import WrapperCard from './WrapperCard' -import Image from 'next/image' import WrapperRow from './WrapperRow' import { useState } from 'react' +import { Wrapper } from './types' const Wrappers = () => { const { project } = useProjectContext() @@ -17,25 +17,24 @@ const Wrappers = () => { const [open, setOpen] = useState('') const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) - - const enabledWrappers = wrappers.filter((wrapper) => + // const enabledWrapperNamesSet = new Set(['stripe_server']) + const [enabledWrappers, disabledWrappers] = partition(wrappers, (wrapper: Wrapper) => enabledWrapperNamesSet.has(wrapper.server.name) ) - const disabledWrappers = wrappers.filter( - (wrapper) => !enabledWrapperNamesSet.has(wrapper.server.name) - ) return ( - <> +
+
{wrappers.map((wrapper) => { return ( { if (open !== wrapperName) setOpen(wrapperName) @@ -46,7 +45,8 @@ const Wrappers = () => { })}
-
+ {/* [Joshen TODO] Once above is working, can remove below */} +
{enabledWrappers.length > 0 && (
@@ -71,7 +71,7 @@ const Wrappers = () => { )}
- +
) } diff --git a/studio/components/interfaces/Database/Wrappers/data.ts b/studio/components/interfaces/Database/Wrappers/data.ts index 816cac4626d..05f7a29374f 100644 --- a/studio/components/interfaces/Database/Wrappers/data.ts +++ b/studio/components/interfaces/Database/Wrappers/data.ts @@ -14,6 +14,7 @@ export const wrappers: Wrapper[] = [ label: 'Stripe Secret Key', required: true, encrypted: true, + hidden: true, }, { name: 'api_url', @@ -21,12 +22,14 @@ export const wrappers: Wrapper[] = [ defaultValue: 'https://api.stripe.com/v1', required: false, encrypted: false, + hidden: false, }, ], }, tables: [ { label: 'Balance', + description: 'Shows the balance currently on your Stripe account', availableColumns: [ { name: 'amount', @@ -61,12 +64,14 @@ export const wrappers: Wrapper[] = [ label: 'Project ID', required: true, encrypted: false, + hidden: false, }, { name: 'sa_key_id', label: 'Service Account Key', required: true, encrypted: true, + hidden: true, }, ], }, diff --git a/studio/components/interfaces/Database/Wrappers/types.ts b/studio/components/interfaces/Database/Wrappers/types.ts index 42544c56382..41d9bd1f690 100644 --- a/studio/components/interfaces/Database/Wrappers/types.ts +++ b/studio/components/interfaces/Database/Wrappers/types.ts @@ -12,6 +12,7 @@ export type ServerOption = { label: string required: boolean encrypted: boolean + hidden: boolean defaultValue?: string } @@ -31,6 +32,7 @@ export type TableOption = { export type Table = { label: string + description?: string availableColumns: AvailableColumn[] options: TableOption[] } diff --git a/studio/components/ui/MultiSelect/index.tsx b/studio/components/ui/MultiSelect/index.tsx index e43ed33aee1..18a49705e77 100644 --- a/studio/components/ui/MultiSelect/index.tsx +++ b/studio/components/ui/MultiSelect/index.tsx @@ -112,7 +112,7 @@ export default function MultiSelect({ return (
- {label && } + {label && }
{ const canReadWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers') @@ -18,9 +19,7 @@ const DatabaseWrappers: NextPageWithLayout = () => { DatabaseWrappers.getLayout = (page) => ( -
- {page} -
+ {page}
) From c455ebad0e3c4c50de0843dc557d4a6c5fa1353c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 8 Dec 2022 15:08:58 +0800 Subject: [PATCH 13/42] Improvements to WrapperTableEditor --- .../Database/Wrappers/WrapperTableEditor.tsx | 105 ++++++++---------- .../interfaces/Database/Wrappers/data.ts | 2 + .../interfaces/Database/Wrappers/utils.ts | 6 +- studio/components/ui/MultiSelect/index.tsx | 8 +- 4 files changed, 63 insertions(+), 58 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index 0f8a4db53ee..22e86236c77 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -1,6 +1,7 @@ import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/ActionBar' +import MultiSelect from 'components/ui/MultiSelect' import { useEffect, useState } from 'react' -import { Accordion, Checkbox, Form, Input, Listbox, SidePanel } from 'ui' +import { Form, Input, Listbox, SidePanel } from 'ui' import { Table, TableOption } from './types' import { makeValidateRequired } from './utils' @@ -72,8 +73,16 @@ const WrapperTableEditor = ({ {tables.map((table, i) => { return ( - -
{table.label}
+ +
+

{table.label}

+

{table.description}

+
) })} @@ -122,7 +131,7 @@ const TableForm = ({ const initialValues = initialData ?? { table_name: '', - columns: [], + columns: table.availableColumns.map((column) => column.name), ...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])), } @@ -140,62 +149,46 @@ const TableForm = ({ onSubmit={onSubmit} enableReinitialize={true} > - {({ errors, touched, values }: any) => ( -
- + {({ errors, values, resetForm }: any) => { + return ( +
+ - {requiredOptions.map((option) => ( -
- )} + ) + }} ) } diff --git a/studio/components/interfaces/Database/Wrappers/data.ts b/studio/components/interfaces/Database/Wrappers/data.ts index 05f7a29374f..e5446326ccf 100644 --- a/studio/components/interfaces/Database/Wrappers/data.ts +++ b/studio/components/interfaces/Database/Wrappers/data.ts @@ -78,6 +78,7 @@ export const wrappers: Wrapper[] = [ tables: [ { label: 'Users', + description: 'Shows your Firebase users', availableColumns: [ { name: 'local_id', @@ -110,6 +111,7 @@ export const wrappers: Wrapper[] = [ }, { label: 'Firestore Collection', + description: 'Map to a Firestore collection', availableColumns: [ { name: 'name', diff --git a/studio/components/interfaces/Database/Wrappers/utils.ts b/studio/components/interfaces/Database/Wrappers/utils.ts index 6bf0b68c509..19b48c207f1 100644 --- a/studio/components/interfaces/Database/Wrappers/utils.ts +++ b/studio/components/interfaces/Database/Wrappers/utils.ts @@ -10,7 +10,11 @@ export const makeValidateRequired = ([key, value]) => requiredOptionsSet.has(key) && (Array.isArray(value) ? value.length < 1 : !value) ) - .map(([key]) => [key, 'Required']) + .map(([key]) => { + if (key === 'table_name') return [key, 'Please provide a name for your table'] + else if (key === 'columns') return [key, 'Please select at least one column'] + else return [key, 'This field is required'] + }) ) return errors diff --git a/studio/components/ui/MultiSelect/index.tsx b/studio/components/ui/MultiSelect/index.tsx index 18a49705e77..a0b91cf0f99 100644 --- a/studio/components/ui/MultiSelect/index.tsx +++ b/studio/components/ui/MultiSelect/index.tsx @@ -8,6 +8,7 @@ export interface MultiSelectOption { id: string | number value: string name: string + description?: string disabled: boolean } @@ -166,7 +167,12 @@ export default function MultiSelect({ `${active ? ' dark:bg-green-600 dark:bg-opacity-25' : ''}`, ].join(' ')} > - {option.name} +
+

{option.name}

+ {option.description !== undefined && ( +

{option.description}

+ )} +
{active && ( Date: Thu, 8 Dec 2022 15:11:32 +0800 Subject: [PATCH 14/42] Rename some files --- .../components/interfaces/Database/Wrappers/WrapperCard.tsx | 2 +- .../components/interfaces/Database/Wrappers/WrapperEditor.tsx | 4 ++-- studio/components/interfaces/Database/Wrappers/WrapperRow.tsx | 4 ++-- .../interfaces/Database/Wrappers/WrapperTableEditor.tsx | 4 ++-- .../Database/Wrappers/{data.ts => Wrappers.constants.ts} | 2 +- studio/components/interfaces/Database/Wrappers/Wrappers.tsx | 4 ++-- .../Database/Wrappers/{types.ts => Wrappers.types.ts} | 0 .../Database/Wrappers/{utils.ts => Wrappers.utils.ts} | 0 studio/data/fdw/fdw-create-mutation.ts | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) rename studio/components/interfaces/Database/Wrappers/{data.ts => Wrappers.constants.ts} (98%) rename studio/components/interfaces/Database/Wrappers/{types.ts => Wrappers.types.ts} (100%) rename studio/components/interfaces/Database/Wrappers/{utils.ts => Wrappers.utils.ts} (100%) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx index f1756648276..533629fee89 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperCard.tsx @@ -5,7 +5,7 @@ import { IconLoader, Toggle } from 'ui' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' import { checkPermissions, useStore } from 'hooks' -import { Wrapper } from './types' +import { Wrapper } from './Wrappers.types' import WrapperEditor from './WrapperEditor' import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' diff --git a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx index 65cde64fa25..94e22632d07 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperEditor.tsx @@ -6,8 +6,8 @@ import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/Act import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' import { useStore } from 'hooks' -import { Wrapper } from './types' -import { makeValidateRequired } from './utils' +import { Wrapper } from './Wrappers.types' +import { makeValidateRequired } from './Wrappers.utils' import WrapperTableEditor from './WrapperTableEditor' // [Joshen TODO] No longer used, can remove after WrapperRow is working diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 6e5bb32d556..f1b39603503 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -14,8 +14,8 @@ import { } from 'ui' import { useStore } from 'hooks' -import { Wrapper } from './types' -import { makeValidateRequired } from './utils' +import { Wrapper } from './Wrappers.types' +import { makeValidateRequired } from './Wrappers.utils' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import WrapperTableEditor from './WrapperTableEditor' diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index 22e86236c77..c3f9a6f7561 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -2,8 +2,8 @@ import ActionBar from 'components/interfaces/TableGridEditor/SidePanelEditor/Act import MultiSelect from 'components/ui/MultiSelect' import { useEffect, useState } from 'react' import { Form, Input, Listbox, SidePanel } from 'ui' -import { Table, TableOption } from './types' -import { makeValidateRequired } from './utils' +import { Table, TableOption } from './Wrappers.types' +import { makeValidateRequired } from './Wrappers.utils' export type WrapperTableEditorProps = { visible: boolean diff --git a/studio/components/interfaces/Database/Wrappers/data.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts similarity index 98% rename from studio/components/interfaces/Database/Wrappers/data.ts rename to studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts index e5446326ccf..82be1944647 100644 --- a/studio/components/interfaces/Database/Wrappers/data.ts +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts @@ -1,4 +1,4 @@ -import { Wrapper } from './types' +import { Wrapper } from './Wrappers.types' export const wrappers: Wrapper[] = [ { diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index bbdcf5f8938..704efdadc11 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -2,11 +2,11 @@ import { partition } from 'lodash' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader } from 'components/ui/Forms' import { useFDWsQuery } from 'data/fdw/fdws-query' -import { wrappers } from './data' +import { wrappers } from './Wrappers.constants' import WrapperCard from './WrapperCard' import WrapperRow from './WrapperRow' import { useState } from 'react' -import { Wrapper } from './types' +import { Wrapper } from './Wrappers.types' const Wrappers = () => { const { project } = useProjectContext() diff --git a/studio/components/interfaces/Database/Wrappers/types.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts similarity index 100% rename from studio/components/interfaces/Database/Wrappers/types.ts rename to studio/components/interfaces/Database/Wrappers/Wrappers.types.ts diff --git a/studio/components/interfaces/Database/Wrappers/utils.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts similarity index 100% rename from studio/components/interfaces/Database/Wrappers/utils.ts rename to studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts index e298841df21..4c6000dccef 100644 --- a/studio/data/fdw/fdw-create-mutation.ts +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -1,5 +1,5 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/types' +import { AvailableColumn, Wrapper } from 'components/interfaces/Database/Wrappers/Wrappers.types' import { executeSql } from 'data/sql/execute-sql-query' import { sqlKeys } from 'data/sql/keys' From 04ad6bbae283ad51fb57ea160e1d3861885fb685 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 8 Dec 2022 15:12:12 +0800 Subject: [PATCH 15/42] Fix TS --- studio/components/interfaces/Database/Wrappers/WrapperRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index f1b39603503..15519e74d76 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -67,7 +67,7 @@ const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { const onSubmit = async () => { const validate = makeValidateRequired(wrapper.server.options) - const errors = validate(formState) + const errors: any = validate(formState) if (!isEmpty(errors)) { setFormErrors(errors) return From 85cc1a964e803fa3c7084a4a6671ed40e29b8596 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 8 Dec 2022 18:50:25 +0800 Subject: [PATCH 16/42] Update UI for disabling FDW --- .../Database/Wrappers/WrapperRow.tsx | 253 +++++++++++------- .../Database/Wrappers/WrapperTableEditor.tsx | 4 +- .../interfaces/Database/Wrappers/Wrappers.tsx | 20 +- studio/data/fdw/fdw-create-mutation.ts | 2 + 4 files changed, 168 insertions(+), 111 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 15519e74d76..0f4304d4fb0 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -11,14 +11,18 @@ import { IconTrash, IconEye, IconEyeOff, + IconCheckCircle, } from 'ui' import { useStore } from 'hooks' import { Wrapper } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import WrapperTableEditor from './WrapperTableEditor' +import InformationBox from 'components/ui/InformationBox' +import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' interface Props { wrapper: Wrapper @@ -36,6 +40,7 @@ const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { const { ui } = useStore() const { project } = useProjectContext() const { mutateAsync: createFDW } = useFDWCreateMutation() + const { mutateAsync: deleteFDW } = useFDWDeleteMutation() const [isSubmitting, setIsSubmitting] = useState(false) const [isEditingTable, setIsEditingTable] = useState(false) @@ -65,7 +70,7 @@ const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { setSelectedTableToEdit(undefined) } - const onSubmit = async () => { + const onSaveWrapper = async () => { const validate = makeValidateRequired(wrapper.server.options) const errors: any = validate(formState) if (!isEmpty(errors)) { @@ -98,6 +103,28 @@ const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { } } + const onDeleteWrapper = () => { + confirmAlert({ + title: `Confirm to disable ${wrapper.label} wrapper`, + message: `Are you sure you want to disable the ${wrapper.label} wrapper? This will also remove all tables created with this wrapper.`, + onAsyncConfirm: async () => { + try { + await deleteFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: wrapper.name, + }) + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Disabling ${wrapper.name} failed: ${error.message}`, + }) + } + }, + }) + } + return ( <> = ({ wrapper, isEnabled, isOpen, onOpen }) => {
- -
-
- {wrapper.server.options.map((option) => { - const [showHidden, setShowHidden] = useState(option.hidden) - return ( - { - setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) - setFormErrors((prev) => ({ ...prev, [option.name]: '' })) - }} - error={formErrors[option.name]} - className="input-mono" - type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'} - actions={ - option.hidden ? ( -
-
- ) : null - } - /> - ) - })} -
-
-
-

Foreign Tables

- -
-
-
- {newTables.length === 0 && ( -
-

- Add foreign tables to query from after the wrapper is enabled -

-
- )} - {newTables.map((table, i) => ( -
-
-

{table.table_name}

-

- {wrapper.tables[table.index].label}: {table.columns.join(', ')} -

-
-
-
-
- ))} -
+ {isEnabled ? ( + +
+
+ } + title={`${wrapper.label} foreign data wrapper is currently enabled`} + description="If you'd like to edit this wrapper, you'll need to disable the wrapper first and create it again with any updated configuration." + />
- {/* [Joshen] Thinking if we need to add a disclaimer here that users cannot edit wrappers */}
-
-
- + + ) : ( + +
+
+ {wrapper.server.options.map((option) => { + const [showHidden, setShowHidden] = useState(option.hidden) + return ( + { + setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) + setFormErrors((prev) => ({ ...prev, [option.name]: '' })) + }} + error={formErrors[option.name]} + className="input-mono" + type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'} + actions={ + option.hidden ? ( +
+
+ ) : null + } + /> + ) + })} +
+
+
+

Foreign Tables

+ +
+
+
+ {newTables.length === 0 && ( +
+

+ Add foreign tables to query from after the wrapper is enabled +

+
+ )} + {newTables.map((table, i) => ( +
+
+

{table.table_name}

+

+ {wrapper.tables[table.index].label}: {table.columns.join(', ')} +

+
+
+
+
+ ))} +
+
+ {/* [Joshen] Thinking if we need to add a disclaimer here that users cannot edit wrappers */} +
+ + +
+
+
+
+
+
+ )} { const { project } = useProjectContext() @@ -16,11 +14,9 @@ const Wrappers = () => { }) const [open, setOpen] = useState('') + console.log('useFDWQuery', { data }) + const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) - // const enabledWrapperNamesSet = new Set(['stripe_server']) - const [enabledWrappers, disabledWrappers] = partition(wrappers, (wrapper: Wrapper) => - enabledWrapperNamesSet.has(wrapper.server.name) - ) return (
@@ -46,7 +42,7 @@ const Wrappers = () => {
{/* [Joshen TODO] Once above is working, can remove below */} -
+ {/*
{enabledWrappers.length > 0 && (
@@ -70,7 +66,7 @@ const Wrappers = () => {
)}
-
+
*/}
) } diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts index 4c6000dccef..e55553cf8c8 100644 --- a/studio/data/fdw/fdw-create-mutation.ts +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -101,6 +101,8 @@ export function getFDWCreateSql({ const sql = /* SQL */ ` begin; + create extension if not exists wrappers; + ${createWrapperSql} ${createEncryptedKeysSql} From b79ea235abcf4507676583e4292cfad4af75fba7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 8 Dec 2022 19:12:53 +0800 Subject: [PATCH 17/42] Add links to docs, fix client side render error, add loading state --- .../Database/Wrappers/WrapperRow.tsx | 307 ++++++++++-------- .../Database/Wrappers/Wrappers.constants.ts | 2 + .../interfaces/Database/Wrappers/Wrappers.tsx | 6 +- .../Database/Wrappers/Wrappers.types.ts | 1 + 4 files changed, 179 insertions(+), 137 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 0f4304d4fb0..1b1d90fe6f3 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -1,3 +1,4 @@ +import Link from 'next/link' import Image from 'next/image' import { isEmpty } from 'lodash' import { FC, useState } from 'react' @@ -12,6 +13,8 @@ import { IconEye, IconEyeOff, IconCheckCircle, + IconExternalLink, + IconLoader, } from 'ui' import { useStore } from 'hooks' @@ -26,12 +29,48 @@ import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmM interface Props { wrapper: Wrapper + isLoading: boolean isEnabled: boolean isOpen: boolean onOpen: (wrapper: string) => void } -const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { +const InputField: FC<{ option: any; value: any; error: any; onChange: any }> = ({ + option, + value, + error, + onChange, +}: any) => { + const [showHidden, setShowHidden] = useState(!option.hidden) + return ( + +
+ ) : null + } + /> + ) +} + +const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen }) => { const getInitialFormState = () => Object.fromEntries( wrapper.server.options.map((option) => [option.name, option.defaultValue ?? '']) @@ -164,7 +203,11 @@ const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => { {wrapper.label}
- {isEnabled ? ( + {isLoading ? ( +
+ +
+ ) : isEnabled ? (
@@ -179,143 +222,139 @@ const WrapperRow: FC = ({ wrapper, isEnabled, isOpen, onOpen }) => {
- {isEnabled ? ( - -
-
- } - title={`${wrapper.label} foreign data wrapper is currently enabled`} - description="If you'd like to edit this wrapper, you'll need to disable the wrapper first and create it again with any updated configuration." - /> -
-
- -
-
-
-
-
- ) : ( - -
-
- {wrapper.server.options.map((option) => { - const [showHidden, setShowHidden] = useState(option.hidden) - return ( - { - setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) - setFormErrors((prev) => ({ ...prev, [option.name]: '' })) - }} - error={formErrors[option.name]} - className="input-mono" - type={!option.hidden ? 'text' : showHidden ? 'text' : 'password'} - actions={ - option.hidden ? ( -
-
- ) : null - } + {!isLoading && ( + <> + {isEnabled ? ( + +
+
+ } + title={`${wrapper.label} foreign data wrapper is currently enabled`} + description="If you'd like to edit this wrapper, you'll need to disable the wrapper first and create it again with any updated configuration." /> - ) - })} -
-
-
-

Foreign Tables

- -
-
-
- {newTables.length === 0 && ( -
-

- Add foreign tables to query from after the wrapper is enabled -

+
+
+ + + + + +
- )} - {newTables.map((table, i) => ( -
-
-

{table.table_name}

-

- {wrapper.tables[table.index].label}: {table.columns.join(', ')} -

-
-
-
-
- ))} -
-
- {/* [Joshen] Thinking if we need to add a disclaimer here that users cannot edit wrappers */} -
- -
-
-
- + + ) : ( + +
+
+ {wrapper.server.options.map((option) => ( + { + setFormState((prev) => ({ ...prev, [option.name]: e.target.value })) + setFormErrors((prev) => ({ ...prev, [option.name]: '' })) + }} + /> + ))} +
+
+
+

Foreign Tables

+ +
+
+
+ {newTables.length === 0 && ( +
+

+ Add foreign tables to query from after the wrapper is enabled +

+
+ )} + {newTables.map((table, i) => ( +
+
+

{table.table_name}

+

+ {wrapper.tables[table.index].label}: {table.columns.join(', ')} +

+
+
+
+
+ ))} +
+
+ + + + + +
+ + +
+
+
+
+
+
+ )} + )} { + const [open, setOpen] = useState('') const { project } = useProjectContext() const { data, isLoading } = useFDWsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const [open, setOpen] = useState('') + const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) console.log('useFDWQuery', { data }) - const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) - return (
{ return ( { diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts index 41d9bd1f690..18a1bdd9d71 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts @@ -5,6 +5,7 @@ export type Wrapper = { label: string server: Server tables: Table[] + docsUrl: string } export type ServerOption = { From eef210f8d1e8cde9bd5f5f61b73dbac517ad31b4 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 8 Dec 2022 22:33:05 +0000 Subject: [PATCH 18/42] update fdw creation sql --- .../interfaces/Database/Wrappers/Wrappers.constants.ts | 4 ++++ .../interfaces/Database/Wrappers/Wrappers.types.ts | 2 ++ studio/data/fdw/fdw-create-mutation.ts | 7 ++----- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts index 7eeec9f9042..0abc0d3ac68 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts @@ -3,6 +3,8 @@ import { Wrapper } from './Wrappers.types' export const wrappers: Wrapper[] = [ { name: 'stripe_wrapper', + handlerName: 'stripe_fdw_handler', + validatorName: 'stripe_fdw_validator', icon: '/img/icons/stripe-icon.svg', extensionName: 'StripeFdw', label: 'Stripe', @@ -54,6 +56,8 @@ export const wrappers: Wrapper[] = [ }, { name: 'firebase_wrapper', + handlerName: 'firebase_fdw_handler', + validatorName: 'firebase_fdw_validator', icon: '/img/icons/firebase-icon.svg', extensionName: 'FirebaseFdw', label: 'Firebase', diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts index 18a1bdd9d71..f805f656937 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts @@ -1,5 +1,7 @@ export type Wrapper = { name: string + handlerName: string + validatorName: string icon: string extensionName: string label: string diff --git a/studio/data/fdw/fdw-create-mutation.ts b/studio/data/fdw/fdw-create-mutation.ts index e55553cf8c8..587d43f69db 100644 --- a/studio/data/fdw/fdw-create-mutation.ts +++ b/studio/data/fdw/fdw-create-mutation.ts @@ -20,11 +20,8 @@ export function getFDWCreateSql({ }: Pick) { const createWrapperSql = /* SQL */ ` create foreign data wrapper ${wrapper.name} - handler wrappers_handler - validator wrappers_validator - options ( - wrapper '${wrapper.extensionName}' - ); + handler ${wrapper.handlerName} + validator ${wrapper.validatorName}; ` const encryptedOptions = wrapper.server.options.filter((option) => option.encrypted) From bea1a4a43311bd1c6f7fa768d204f666ef50a52d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 9 Dec 2022 17:06:09 +0800 Subject: [PATCH 19/42] Add empty state for when wrappers extension is not enabled yet --- .../interfaces/Database/Wrappers/Wrappers.tsx | 141 +++++++++++++++--- 1 file changed, 122 insertions(+), 19 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 9a19ed3d826..353b08ae2c9 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -1,5 +1,12 @@ +import Link from 'next/link' import { useState } from 'react' +import { Button, IconExternalLink } from 'ui' +import { observer } from 'mobx-react-lite' +import * as Tooltip from '@radix-ui/react-tooltip' +import { PostgresExtension } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { checkPermissions, useStore } from 'hooks' import { useFDWsQuery } from 'data/fdw/fdws-query' import { wrappers } from './Wrappers.constants' import WrapperRow from './WrapperRow' @@ -7,7 +14,7 @@ import { FormHeader } from 'components/ui/Forms' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' const Wrappers = () => { - const [open, setOpen] = useState('') + const { ui, meta } = useStore() const { project } = useProjectContext() const { data, isLoading } = useFDWsQuery({ projectRef: project?.ref, @@ -15,31 +22,127 @@ const Wrappers = () => { }) const enabledWrapperNamesSet = new Set(data?.result.map((fdw) => fdw.name)) + const [open, setOpen] = useState('') + const [isEnabling, setIsEnabling] = useState(false) + + const [wrappersExtension] = meta.extensions.list( + (ext: PostgresExtension) => ext.name.toLowerCase() === 'wrappers' + ) + const isWrappersEnabled = wrappersExtension.installed_version !== null + const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') + console.log('useFDWQuery', { data }) + console.log(wrappersExtension) + + const onEnableWrappers = async () => { + if (wrappersExtension === undefined) return + setIsEnabling(true) + + const { error: createExtensionError } = await meta.extensions.create({ + schema: wrappersExtension.schema ?? 'extensions', + name: wrappersExtension.name, + version: wrappersExtension.default_version, + cascade: true, + }) + if (createExtensionError) { + ui.setNotification({ + error: createExtensionError, + category: 'error', + message: `Failed to enable Wrappers for your project: ${createExtensionError.message}`, + }) + } else { + ui.setNotification({ + category: 'success', + message: 'Wrappers is now enabled for your project!', + }) + } + setIsEnabling(false) + } + return (
-
- {wrappers.map((wrapper) => { - return ( - { - if (open !== wrapperName) setOpen(wrapperName) - else setOpen('') - }} - /> - ) - })} -
+ {isWrappersEnabled ? ( +
+ {wrappers.map((wrapper) => { + return ( + { + if (open !== wrapperName) setOpen(wrapperName) + else setOpen('') + }} + /> + ) + })} +
+ ) : ( +
+
+
+
+

Supabase Wrappers

+

+ Supabase Wrappers is a framework for building Postgres Foreign Data Wrappers (FDW) + which connect Postgres to external systems. Query your data warehouse or + third-party APIs directly from your database +

+
+
+ + + + + + + + + + {!canToggleWrappers && ( + + +
+ + You need additional permissions to enable Wrappers for this project + +
+
+ )} +
+
+
+
+
+ )} {/* [Joshen TODO] Once above is working, can remove below */} {/*
@@ -71,4 +174,4 @@ const Wrappers = () => { ) } -export default Wrappers +export default observer(Wrappers) From be2a1062c52cc541589768ace2388f75318bae44 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Sat, 10 Dec 2022 00:11:25 +0800 Subject: [PATCH 20/42] Update check for empty state --- .../Database/Wrappers/WrapperRow.tsx | 9 ++- .../interfaces/Database/Wrappers/Wrappers.tsx | 77 ++++++++----------- 2 files changed, 41 insertions(+), 45 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 1b1d90fe6f3..3b9fababfa3 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -85,8 +85,8 @@ const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen } const [isEditingTable, setIsEditingTable] = useState(false) const [selectedTableToEdit, setSelectedTableToEdit] = useState() - const [formState, setFormState] = useState(getInitialFormState) const [newTables, setNewTables] = useState([]) + const [formState, setFormState] = useState(getInitialFormState) const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) // [Joshen TODO] Fix this logic @@ -112,6 +112,8 @@ const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen } const onSaveWrapper = async () => { const validate = makeValidateRequired(wrapper.server.options) const errors: any = validate(formState) + if (newTables.length === 0) errors.tables = 'Please add at least one table' + if (!isEmpty(errors)) { setFormErrors(errors) return @@ -131,6 +133,8 @@ const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen } message: `Successfully created ${wrapper.label} foreign data wrapper`, }) onOpen('') + setNewTables([]) + setFormState(getInitialFormState) } catch (error: any) { ui.setNotification({ error, @@ -318,6 +322,9 @@ const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen }
))} + {newTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )}
diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx index 353b08ae2c9..6180010f739 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.tsx +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.tsx @@ -25,31 +25,46 @@ const Wrappers = () => { const [open, setOpen] = useState('') const [isEnabling, setIsEnabling] = useState(false) - const [wrappersExtension] = meta.extensions.list( - (ext: PostgresExtension) => ext.name.toLowerCase() === 'wrappers' - ) - const isWrappersEnabled = wrappersExtension.installed_version !== null + const wrappersExtension = meta.extensions.byId('wrappers') + const vaultExtension = meta.extensions.byId('supabase_vault') + + const isWrappersEnabled = + wrappersExtension !== undefined && + wrappersExtension?.installed_version !== null && + vaultExtension !== undefined && + vaultExtension?.installed_version !== null + const canToggleWrappers = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') - console.log('useFDWQuery', { data }) - - console.log(wrappersExtension) - const onEnableWrappers = async () => { if (wrappersExtension === undefined) return setIsEnabling(true) - const { error: createExtensionError } = await meta.extensions.create({ - schema: wrappersExtension.schema ?? 'extensions', - name: wrappersExtension.name, - version: wrappersExtension.default_version, - cascade: true, - }) - if (createExtensionError) { + const requiredExtensions = await Promise.all([ + await meta.extensions.create({ + schema: wrappersExtension.schema ?? 'extensions', + name: wrappersExtension.name, + version: wrappersExtension.default_version, + cascade: true, + }), + await meta.extensions.create({ + schema: vaultExtension.schema ?? 'vault', + name: vaultExtension.name, + version: vaultExtension.default_version, + cascade: true, + }), + ]) + const errors = requiredExtensions.filter( + (res) => res.error && !res.error.message.includes('already exists') + ) + + if (errors.length > 0) { ui.setNotification({ - error: createExtensionError, + error: errors, category: 'error', - message: `Failed to enable Wrappers for your project: ${createExtensionError.message}`, + message: `Failed to enable Wrappers for your project: ${errors + .map((x) => x.message) + .join(', ')}`, }) } else { ui.setNotification({ @@ -57,6 +72,7 @@ const Wrappers = () => { message: 'Wrappers is now enabled for your project!', }) } + setIsEnabling(false) } @@ -143,33 +159,6 @@ const Wrappers = () => {
)} - - {/* [Joshen TODO] Once above is working, can remove below */} - {/*
-
- {enabledWrappers.length > 0 && ( -
-

Enabled wrappers

-
- {enabledWrappers.map((wrapper) => ( - - ))} -
-
- )} - - {disabledWrappers.length > 0 && ( -
-

Available wrappers

-
- {disabledWrappers.map((wrapper) => ( - - ))} -
-
- )} -
-
*/}
) } From 1ebb17efc6811acc8ac520853ac44fef4cc3a243 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Sat, 10 Dec 2022 01:06:29 +0800 Subject: [PATCH 21/42] Allow users to select a schema for foreign table --- .../Database/Wrappers/WrapperRow.tsx | 5 +- .../Database/Wrappers/WrapperTableEditor.tsx | 47 +++++++++++++++-- .../Database/Wrappers/Wrappers.constants.ts | 2 +- .../interfaces/Database/Wrappers/Wrappers.tsx | 51 ++++++++++++++++--- 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 3b9fababfa3..d9bb520cd85 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -132,7 +132,6 @@ const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen } category: 'success', message: `Successfully created ${wrapper.label} foreign data wrapper`, }) - onOpen('') setNewTables([]) setFormState(getInitialFormState) } catch (error: any) { @@ -296,7 +295,9 @@ const WrapperRow: FC = ({ wrapper, isLoading, isEnabled, isOpen, onOpen } {newTables.map((table, i) => (
-

{table.table_name}

+

+ {table.schema_name}.{table.table_name} +

{wrapper.tables[table.index].label}: {table.columns.join(', ')}

diff --git a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx index 6c33cb7a71a..e454ee7db4e 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperTableEditor.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { Form, Input, Listbox, SidePanel } from 'ui' +import { Form, IconDatabase, Input, Listbox, SidePanel, Modal, IconPlus } from 'ui' +import { useStore } from 'hooks' import { Table, TableOption } from './Wrappers.types' import { makeValidateRequired } from './Wrappers.utils' import MultiSelect from 'components/ui/MultiSelect' @@ -39,7 +40,12 @@ const WrapperTableEditor = ({ } const onSubmit: OnSubmitFn = (values, { resetForm }) => { - onSave({ ...values, index: parseInt(selectedTableIndex) }) + onSave({ + ...values, + index: parseInt(selectedTableIndex), + schema_name: values.schema === 'custom' ? values.schema_name : values.schema, + is_new_schema: values.schema === 'custom', + }) resetForm() setSelectedTableIndex('') } @@ -121,6 +127,9 @@ const TableForm = ({ onSubmit: OnSubmitFn initialData: any }) => { + const { meta } = useStore() + const schemas = meta.schemas.list() + const requiredOptions = table.options.filter((option) => option.editable && option.required && !option.defaultValue) ?? [] @@ -133,6 +142,8 @@ const TableForm = ({ table_name: '', columns: table.availableColumns.map((column) => column.name), ...Object.fromEntries(table.options.map((option) => [option.name, option.defaultValue ?? ''])), + schema: 'public', + schema_name: '', } const validate = makeValidateRequired([ @@ -152,17 +163,44 @@ const TableForm = ({ {({ errors, values, resetForm }: any) => { return (
+ + } + > + Create a new schema + + + {/* @ts-ignore */} + {schemas.map((schema: PostgresSchema) => { + return ( + } + > + {schema.name} + + ) + })} + + {values.schema === 'custom' && ( + + )} - {requiredOptions.map((option) => (