Merge branch 'master' of github.com:supabase/supabase

This commit is contained in:
Terry Sutton committed 2022-06-21 08:29:43 -02:30
commit 990b2ff9f2
31 files changed
+749 -508

No files matched your search

+3 -3
View File
@@ -30,7 +30,7 @@ const data: CommunityItem[] = [
},
{
title: 'GitHub',
stat: '32,000+',
stat: '34,000+',
statLabel: 'GitHub stars',
img: 'github.png',
invertImgDarkMode: true,
@@ -42,7 +42,7 @@ const data: CommunityItem[] = [
},
{
title: 'Twitter',
stat: '30,000+',
stat: '32,000+',
statLabel: 'Followers',
img: 'twitter.png',
detail: () => (
@@ -54,7 +54,7 @@ const data: CommunityItem[] = [
{
title: 'Discord',
stat: '6,000+',
statLabel: 'SuperTroopers',
statLabel: 'SupaTroopers',
img: 'discord.png',
detail: () => (
<div>
+39 -15
View File
@@ -14,39 +14,63 @@ const data: PressItem[] = [
},
{
type: 'article',
href: 'https://techcrunch.com/2021/09/09/supabase-raises-30m-for-its-open-source-insta-backend',
title: 'Open source backend-as-a-service startup Supabase raises $30M',
publisher: 'TechCrunch',
href: 'https://www.prnewswire.com/news-releases/supabase-announces-80-million-series-b-led-by-felicis-301544453.html',
title: 'Supabase Announces $80 Million Series B Led by Felicis',
publisher: 'PR Newswire',
},
{
type: 'article',
href: 'https://techcrunch.com/2020/12/15/supabase-raises-6m-for-its-open-source-firebase-alternative',
title: 'Supabase raises $6M for its open-source Firebase alternative',
publisher: 'TechCrunch',
href: 'https://medium.com/lightspeedindia/our-investment-in-supabase-2f443ff55aad',
title: 'Our investment in Supabase',
publisher: 'Medium',
},
{
type: 'podcast',
href: 'https://softwareengineeringdaily.com/2020/10/15/supabase-open-source-firebase-with-paul-copplestone',
title: 'Software Engineering Daily',
publisher: 'Software Engineering Daily',
href: 'https://podcasts.apple.com/us/podcast/73-elixir-at-supabase-with-paul-copplestone/id1516100616?i=1000542087899',
title: 'Elixir at Supabase with Paul Copplestone',
publisher: 'Thinking Elixir Podcast',
},
{
type: 'podcast',
href: 'https://podcasts.apple.com/us/podcast/73-elixir-at-supabase-with-paul-copplestone/id1516100616?i=1000542087899',
title: 'Ant Wilson on Supabase ',
publisher: 'Software Engineering Radio',
},
{
type: 'podcast',
href: 'https://podcasts.apple.com/us/podcast/78-logflare-with-chase-granberry/id1516100616?i=1000545628124',
title: 'Logflare with Chase Granberry',
publisher: 'Thinking Elixir Podcast',
},
{
type: 'podcast',
href: 'https://stackoverflow.blog/2022/05/17/open-source-is-winning-over-developers-and-investors-ep-442/',
title: 'Open-source is winning over developers and investors',
publisher: 'Stackoverflow',
},
{
type: 'podcast',
href: 'https://changelog.com/podcast/476',
title: 'Supabase is all in on Postgres',
publisher: 'changelog',
},
{
type: 'podcast',
href: 'https://fsjam.org/episodes/episode-33-supabase-with-paul-copplestone',
title: 'FS Jam',
title: 'Supabase with Paul Copplestone',
publisher: 'FS Jam',
},
{
type: 'podcast',
href: 'https://www.heavybit.com/library/podcasts/jamstack-radio/ep-71-open-source-firebase-alternative-with-paul-copplestone-of-supabase',
title: 'Jamstack Radio',
publisher: 'Heavybit',
href: 'https://www.heavybit.com/library/podcasts/jamstack-radio/ep-71-open-source-firebase-alternative-with-paul-copplestone-of-supabase/',
title: 'Open Source Firebase Alternative with Paul Copplestone of Supabase',
publisher: 'Jamstack',
},
{
type: 'podcast',
href: 'https://podrocket.logrocket.com/9',
title: 'PodRocket',
publisher: 'LogRocket',
title: 'Supabase with Paul Copplestone',
publisher: 'Podrocket',
},
]
+29 -20
View File
@@ -15,6 +15,7 @@ import InvestorData from 'data/Investors'
import TeamData from 'data/Team'
import Image from 'next/image'
import Link from 'next/link'
import {
Button,
@@ -313,30 +314,38 @@ const Press = () => {
</div>
<div className="mx-auto mt-5 grid gap-5 lg:max-w-none lg:grid-cols-3">
{PressData.filter((x) => x.type == 'article').map((x) => (
<a href={x.href} key={x.href} target="_blank">
<Card key={`press_${x.href}`} hoverable className="dark:bg-scale-400 bg-white">
<Space className="h-40 justify-between" direction="vertical">
<div>
<h1 className="text-scale-1200 text-xl">{x.type.toUpperCase()}</h1>
<p className="text-scale-1100 text-base">{x.title}</p>
</div>
</Space>
</Card>
</a>
<Link href={x.href} key={x.href}>
<a target="_blank">
<Card key={`press_${x.href}`} hoverable className="dark:bg-scale-400 h-36 bg-white">
<Space className="h-40 justify-between" direction="vertical">
<div>
<h1 className="text-scale-1200 text-xl">{x.type.toUpperCase()}</h1>
<p className="text-scale-1100 line-clamp block h-12 overflow-hidden text-ellipsis text-base">
{x.title}
</p>
</div>
</Space>
</Card>
</a>
</Link>
))}
</div>
<div className="mx-auto mt-5 grid gap-5 sm:grid-cols-2 lg:max-w-none lg:grid-cols-4">
{PressData.filter((x) => x.type == 'podcast').map((x) => (
<a href={x.href} key={x.href} target="_blank">
<Card key={`press_${x.href}`} hoverable className="dark:bg-scale-400 bg-white">
<Space className="h-40 justify-between" direction="vertical">
<div>
<h1 className="text-scale-1200 text-xl">{x.type.toUpperCase()}</h1>
<p className="text-scale-1100 text-base">{x.title}</p>
</div>
</Space>
</Card>
</a>
<Link href={x.href} key={x.href}>
<a target="_blank">
<Card key={`press_${x.href}`} hoverable className="dark:bg-scale-400 h-36 bg-white">
<Space className="h-40 justify-between" direction="vertical">
<div>
<h1 className="text-scale-1200 text-xl">{x.type.toUpperCase()}</h1>
<p className="text-scale-1100 line-clamp block h-12 overflow-hidden text-ellipsis text-base">
{x.title}
</p>
</div>
</Space>
</Card>
</a>
</Link>
))}
</div>
</SectionContainer>
+7
View File
@@ -242,3 +242,10 @@ table {
position: relative !important;
height: unset !important;
}
.line-clamp {
display: -webkit-box;
margin: 0 auto;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
+112 -121
View File
@@ -1,40 +1,33 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useMonaco } from '@monaco-editor/react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Dictionary, SupabaseGridProps, SupabaseGridRef } from './types';
import { DataGridHandle } from '@supabase/react-data-grid';
import { RowContextMenu } from './components/menu';
import { StoreProvider, useDispatch, useTrackedState } from './store';
import { fetchCount, fetchPage, refreshPageDebounced } from './utils';
import { REFRESH_PAGE_IMMEDIATELY, TOTAL_ROWS_RESET } from './constants';
import { Grid } from './components/grid';
import { Shortcuts } from './components/common';
import Header from './components/header';
import Footer from './components/footer';
import {
cleanupProps,
initTable,
saveStorageDebounced,
} from './SupabaseGrid.utils';
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
import { createPortal } from 'react-dom'
import { useMonaco } from '@monaco-editor/react'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { Dictionary, SupabaseGridProps, SupabaseGridRef } from './types'
import { DataGridHandle } from '@supabase/react-data-grid'
import { RowContextMenu } from './components/menu'
import { StoreProvider, useDispatch, useTrackedState } from './store'
import { fetchCount, fetchPage, refreshPageDebounced } from './utils'
import { REFRESH_PAGE_IMMEDIATELY, TOTAL_ROWS_RESET } from './constants'
import { Grid } from './components/grid'
import { Shortcuts } from './components/common'
import Header from './components/header'
import Footer from './components/footer'
import { cleanupProps, initTable, saveStorageDebounced } from './SupabaseGrid.utils'
/**
* Supabase Grid.
*
* React component to render database table.
*/
export const SupabaseGrid = React.forwardRef<
SupabaseGridRef,
SupabaseGridProps
>((props, ref) => {
const monaco = useMonaco();
const _props = cleanupProps(props);
const { theme } = _props;
export const SupabaseGrid = forwardRef<SupabaseGridRef, SupabaseGridProps>((props, ref) => {
const monaco = useMonaco()
const _props = cleanupProps(props)
const { theme } = _props
React.useEffect(() => {
useEffect(() => {
if (monaco) {
const darkTheme = theme && theme === 'dark' ? true : false;
const darkTheme = theme && theme === 'dark' ? true : false
monaco.editor.defineTheme('supabase', {
base: 'vs-dark', // can also be vs-dark or hc-black
@@ -47,9 +40,9 @@ export const SupabaseGrid = React.forwardRef<
colors: {
'editor.background': darkTheme ? '#1f1f1f' : '#30313f',
},
});
})
}
}, [monaco]);
}, [monaco])
return (
<StoreProvider>
@@ -57,103 +50,101 @@ export const SupabaseGrid = React.forwardRef<
<SupabaseGridLayout ref={ref} {..._props} />
</DndProvider>
</StoreProvider>
);
});
)
})
const SupabaseGridLayout = React.forwardRef<SupabaseGridRef, SupabaseGridProps>(
(props, ref) => {
const { editable, storageRef, gridProps, headerActions } = props;
const dispatch = useDispatch();
const state = useTrackedState();
const gridRef = React.useRef<DataGridHandle>(null);
const [mounted, setMount] = React.useState(false);
const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props, ref) => {
const { editable, storageRef, gridProps, headerActions } = props
const dispatch = useDispatch()
const state = useTrackedState()
const gridRef = useRef<DataGridHandle>(null)
const [mounted, setMount] = useState(false)
React.useImperativeHandle(ref, () => ({
rowAdded(row: Dictionary<any>) {
dispatch({
type: 'ADD_NEW_ROW',
payload: row,
});
},
rowEdited(row: Dictionary<any>, idx: number) {
dispatch({
type: 'EDIT_ROW',
payload: { row, idx },
});
},
}));
useImperativeHandle(ref, () => ({
rowAdded(row: Dictionary<any>) {
dispatch({
type: 'ADD_NEW_ROW',
payload: row,
})
},
rowEdited(row: Dictionary<any>, idx: number) {
dispatch({
type: 'EDIT_ROW',
payload: { row, idx },
})
},
}))
React.useEffect(() => {
if (!mounted) setMount(true);
}, []);
useEffect(() => {
if (!mounted) setMount(true)
}, [])
React.useEffect(() => {
if (state.isInitialComplete && storageRef && state.table) {
saveStorageDebounced(state, storageRef);
}
}, [
state.table,
state.isInitialComplete,
state.gridColumns,
state.sorts,
state.filters,
storageRef,
]);
useEffect(() => {
if (state.isInitialComplete && storageRef && state.table) {
saveStorageDebounced(state, storageRef)
}
}, [
state.table,
state.isInitialComplete,
state.gridColumns,
state.sorts,
state.filters,
storageRef,
])
React.useEffect(() => {
if (state.refreshPageFlag == REFRESH_PAGE_IMMEDIATELY) {
fetchPage(state, dispatch);
} else if (state.refreshPageFlag != 0) {
refreshPageDebounced(state, dispatch);
}
}, [state.refreshPageFlag]);
useEffect(() => {
if (state.refreshPageFlag == REFRESH_PAGE_IMMEDIATELY) {
fetchPage(state, dispatch)
} else if (state.refreshPageFlag != 0) {
refreshPageDebounced(state, dispatch)
}
}, [state.refreshPageFlag])
React.useEffect(() => {
if (state.totalRows === TOTAL_ROWS_RESET) {
fetchCount(state, dispatch);
}
}, [state.totalRows]);
useEffect(() => {
if (state.totalRows === TOTAL_ROWS_RESET) {
fetchCount(state, dispatch)
}
}, [state.totalRows])
React.useEffect(() => {
if (!state.metaService) {
dispatch({
type: 'INIT_CLIENT',
payload: { onSqlQuery: props.onSqlQuery },
});
dispatch({
type: 'INIT_CALLBACK',
payload: { ...props },
});
}
}, [state.metaService]);
useEffect(() => {
if (!state.metaService) {
dispatch({
type: 'INIT_CLIENT',
payload: { onSqlQuery: props.onSqlQuery },
})
dispatch({
type: 'INIT_CALLBACK',
payload: { ...props },
})
}
}, [state.metaService])
React.useEffect(() => {
if (!state.metaService) return;
useEffect(() => {
if (!state.metaService) return
if (
!state.table ||
(typeof props.table == 'string' &&
state.table!.name != props.table &&
state.table!.schema != props.schema) ||
(typeof props.table != 'string' &&
JSON.stringify(props.table) !== JSON.stringify(state.table))
) {
initTable(props, state, dispatch);
}
}, [state.metaService, state.table, props.table, props.schema]);
if (
!state.table ||
(typeof props.table == 'string' &&
state.table!.name != props.table &&
state.table!.schema != props.schema) ||
(typeof props.table != 'string' &&
JSON.stringify(props.table) !== JSON.stringify(state.table))
) {
initTable(props, state, dispatch)
}
}, [state.metaService, state.table, props.table, props.schema])
return (
<div className="sb-grid">
<Header
onAddRow={editable ? props.onAddRow : undefined}
onAddColumn={editable ? props.onAddColumn : undefined}
headerActions={headerActions}
/>
<Grid ref={gridRef} {...gridProps} />
<Footer />
<Shortcuts gridRef={gridRef} />
{mounted && createPortal(<RowContextMenu />, document.body)}
</div>
);
}
);
return (
<div className="sb-grid">
<Header
onAddRow={editable ? props.onAddRow : undefined}
onAddColumn={editable ? props.onAddColumn : undefined}
headerActions={headerActions}
/>
<Grid ref={gridRef} {...gridProps} rows={state.rows} />
<Footer />
<Shortcuts gridRef={gridRef} />
{mounted && createPortal(<RowContextMenu />, document.body)}
</div>
)
})
@@ -1,14 +1,14 @@
import * as React from 'react';
import { Button, Modal } from '@supabase/ui';
import { render, unmountComponentAtNode } from 'react-dom';
import * as React from 'react'
import { Button, Modal } from '@supabase/ui'
import { render, unmountComponentAtNode } from 'react-dom'
type ConfirmModalProps = {
title: string;
message: string;
onConfirm?: () => void;
onAsyncConfirm?: () => Promise<void>;
variant?: 'danger' | 'warning' | 'success' | undefined;
};
title: string
message: string
onConfirm?: () => void
onAsyncConfirm?: () => Promise<void>
variant?: 'danger' | 'warning' | 'success' | undefined
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({
title,
message,
@@ -16,26 +16,26 @@ const ConfirmModal: React.FC<ConfirmModalProps> = ({
onAsyncConfirm,
variant = 'danger',
}) => {
const [loading, setLoading] = React.useState(false);
const [loading, setLoading] = React.useState(false)
function onCancelClick() {
if (!loading) onClose();
if (!loading) onClose()
}
async function onConfirmClick() {
if (onAsyncConfirm) {
setLoading(true);
await onAsyncConfirm();
setLoading(true)
await onAsyncConfirm()
onClose();
onClose()
} else if (onConfirm) {
onConfirm();
onClose();
onConfirm()
onClose()
}
}
function onClose() {
removeElement();
removeElement()
}
return (
@@ -55,11 +55,7 @@ const ConfirmModal: React.FC<ConfirmModalProps> = ({
<Button type="default" onClick={() => onCancelClick()}>
Cancel
</Button>
<Button
type="primary"
loading={loading}
onClick={() => onConfirmClick()}
>
<Button type="primary" loading={loading} onClick={() => onConfirmClick()}>
Confirm
</Button>
</div>
@@ -67,32 +63,32 @@ const ConfirmModal: React.FC<ConfirmModalProps> = ({
}
>
<Modal.Content>
<p className="text-sm text-scale-1100">{message}</p>
<p className="text-scale-1100 py-4 text-sm">{message}</p>
</Modal.Content>
</Modal>
);
};
)
}
function removeElement() {
const target = document.getElementById('supabase-ui-confirm-alert');
const target = document.getElementById('supabase-ui-confirm-alert')
if (target) {
unmountComponentAtNode(target);
target?.parentNode?.removeChild(target);
unmountComponentAtNode(target)
target?.parentNode?.removeChild(target)
}
}
function createElement(props: ConfirmModalProps) {
let divTarget = document.getElementById('supabase-ui-confirm-alert');
let divTarget = document.getElementById('supabase-ui-confirm-alert')
if (divTarget) {
render(<ConfirmModal {...props} />, divTarget);
render(<ConfirmModal {...props} />, divTarget)
} else {
divTarget = document.createElement('div');
divTarget.id = 'supabase-ui-confirm-alert';
document.body.appendChild(divTarget);
render(<ConfirmModal {...props} />, divTarget);
divTarget = document.createElement('div')
divTarget.id = 'supabase-ui-confirm-alert'
document.body.appendChild(divTarget)
render(<ConfirmModal {...props} />, divTarget)
}
}
export function showConfirmAlert(props: ConfirmModalProps) {
createElement(props);
createElement(props)
}
@@ -1,83 +1,120 @@
import * as React from 'react';
import AwesomeDebouncePromise from 'awesome-debounce-promise';
import {
Button,
InputNumber,
Typography,
IconArrowRight,
IconArrowLeft,
} from '@supabase/ui';
import { DropdownControl } from '../../common';
import { useDispatch, useTrackedState } from '../../../store';
import { FC, useState, useEffect } from 'react'
import AwesomeDebouncePromise from 'awesome-debounce-promise'
import { Button, InputNumber, IconArrowRight, IconArrowLeft, IconLoader } from '@supabase/ui'
import { DropdownControl, showConfirmAlert } from '../../common'
import { useDispatch, useTrackedState } from '../../../store'
const updatePage = (payload: number, dispatch: (value: unknown) => void) => {
dispatch({
type: 'SET_PAGE',
payload: payload,
});
};
const updatePageDebounced = AwesomeDebouncePromise(updatePage, 550);
})
}
const updatePageDebounced = AwesomeDebouncePromise(updatePage, 550)
const rowsPerPageOptions = [
{ value: 100, label: '100 rows' },
{ value: 500, label: '500 rows' },
{ value: 1000, label: '1000 rows' },
];
]
type PaginationProps = {};
type PaginationProps = {}
const Pagination: React.FC<PaginationProps> = () => {
const state = useTrackedState();
const dispatch = useDispatch();
const [page, setPage] = React.useState(state.page);
const maxPages = Math.ceil(state.totalRows / state.rowsPerPage);
const totalPages = state.totalRows > 0 ? maxPages : 1;
const Pagination: FC<PaginationProps> = () => {
const state = useTrackedState()
const dispatch = useDispatch()
const [page, setPage] = useState(state.page)
const maxPages = Math.ceil(state.totalRows / state.rowsPerPage)
const totalPages = state.totalRows > 0 ? maxPages : 1
React.useEffect(() => {
if (state.page != page) setPage(state.page);
}, [state.page, page]);
useEffect(() => {
if (state.page != page) setPage(state.page)
}, [state.page, page])
function onPreviousPage() {
// [Joshen] Oddly without this, state.selectedRows will be stale
useEffect(() => {}, [state.selectedRows])
// [Joshen] Note: I've made pagination buttons disabled while rows are being fetched for now
// at least until we can send an abort signal to cancel requests if users are mashing the
// pagination buttons to find the data they want
const onPreviousPage = () => {
if (state.page > 1) {
const previousPage = state.page - 1;
setPage(previousPage);
dispatch({ type: 'SET_PAGE', payload: previousPage });
if (state.selectedRows.size >= 1) {
showConfirmAlert({
title: 'Confirm moving to previous page',
message: 'The currently selected lines will be deselected, do you want to proceed?',
onConfirm: () => {
goToPreviousPage()
dispatch({
type: 'SELECTED_ROWS_CHANGE',
payload: { selectedRows: new Set() },
})
},
})
} else {
goToPreviousPage()
}
}
}
function onNextPage() {
const onNextPage = () => {
if (state.page < maxPages) {
const nextPage = state.page + 1;
setPage(nextPage);
dispatch({ type: 'SET_PAGE', payload: nextPage });
if (state.selectedRows.size >= 1) {
showConfirmAlert({
title: 'Confirm moving to next page',
message: 'The currently selected lines will be deselected, do you want to proceed?',
onConfirm: () => {
goToNextPage()
dispatch({
type: 'SELECTED_ROWS_CHANGE',
payload: { selectedRows: new Set() },
})
},
})
} else {
goToNextPage()
}
}
}
const goToPreviousPage = () => {
const previousPage = state.page - 1
setPage(previousPage)
dispatch({ type: 'SET_PAGE', payload: previousPage })
}
const goToNextPage = () => {
const nextPage = state.page + 1
setPage(nextPage)
dispatch({ type: 'SET_PAGE', payload: nextPage })
}
function onPageChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value;
const pageNum = Number(value);
setPage(pageNum);
updatePageDebounced(pageNum, dispatch);
const value = event.target.value
const pageNum = Number(value)
setPage(pageNum)
updatePageDebounced(pageNum, dispatch)
}
function onRowsPerPageChange(value: string | number) {
dispatch({ type: 'SET_ROWS_PER_PAGE', payload: value });
dispatch({ type: 'SET_ROWS_PER_PAGE', payload: value })
}
return (
<div className="sb-grid-pagination">
{state.totalRows < 0 ? (
<Typography.Text>... loading total records</Typography.Text>
<p className="text-scale-1100 text-sm">Loading records...</p>
) : (
<>
<Button
icon={<IconArrowLeft />}
type="outline"
disabled={state.page <= 1}
disabled={state.page <= 1 || state.isLoading}
onClick={onPreviousPage}
style={{ padding: '3px 10px' }}
/>
<Typography.Text>Page</Typography.Text>
<p className="text-scale-1100 text-sm">Page</p>
<div className="sb-grid-pagination-input-container">
<InputNumber
value={page}
@@ -91,11 +128,11 @@ const Pagination: React.FC<PaginationProps> = () => {
min={1}
/>
</div>
<Typography.Text>{`of ${totalPages}`}</Typography.Text>
<p className="text-scale-1100 text-sm">{`of ${totalPages}`}</p>
<Button
icon={<IconArrowRight />}
type="outline"
disabled={state.page >= maxPages}
disabled={state.page >= maxPages || state.isLoading}
onClick={onNextPage}
style={{ padding: '3px 10px' }}
/>
@@ -112,12 +149,11 @@ const Pagination: React.FC<PaginationProps> = () => {
style={{ padding: '3px 10px' }}
>{`${state.rowsPerPage} rows`}</Button>
</DropdownControl>
<Typography.Text>
{`${state.totalRows.toLocaleString()} records`}
</Typography.Text>
<p className="text-scale-1100 text-sm">{`${state.totalRows.toLocaleString()} records`}</p>
{state.isLoading && <IconLoader size={14} className="animate-spin" />}
</>
)}
</div>
);
};
export default Pagination;
)
}
export default Pagination
+45 -54
View File
@@ -1,54 +1,51 @@
import * as React from 'react';
import { memo } from 'react-tracked';
import DataGrid, {
DataGridHandle,
RowsChangeData,
} from '@supabase/react-data-grid';
import { IconLoader } from '@supabase/ui';
import { GridProps, SupaRow } from '../../types';
import { useDispatch, useTrackedState } from '../../store';
import RowRenderer from './RowRenderer';
import AwesomeDebouncePromise from 'awesome-debounce-promise';
import { forwardRef } from 'react'
import { memo } from 'react-tracked'
import DataGrid, { DataGridHandle, RowsChangeData } from '@supabase/react-data-grid'
import { IconLoader } from '@supabase/ui'
import { GridProps, SupaRow } from '../../types'
import { useDispatch, useTrackedState } from '../../store'
import RowRenderer from './RowRenderer'
import AwesomeDebouncePromise from 'awesome-debounce-promise'
function rowKeyGetter(row: SupaRow) {
return row.idx;
return row.idx
}
interface IGrid extends GridProps {
rows: any[]
}
export const Grid = memo(
React.forwardRef<DataGridHandle, GridProps>(
forwardRef<DataGridHandle, IGrid>(
(
{ width, height, containerClass, gridClass, rowClass },
{ width, height, containerClass, gridClass, rowClass, rows },
ref: React.Ref<DataGridHandle> | undefined
) => {
const dispatch = useDispatch();
const state = useTrackedState();
const dispatch = useDispatch()
const state = useTrackedState()
// workaround to force state tracking on state.gridColumns
const columnHeaders = state.gridColumns.map(
(x) => `${x.key}_${x.frozen}`
);
const { gridColumns, rows, onError: onErrorFunc } = state;
const columnHeaders = state.gridColumns.map((x) => `${x.key}_${x.frozen}`)
const { gridColumns, onError: onErrorFunc } = state
function onColumnResize(index: number, width: number) {
updateColumnResizeDebounced(index, width, dispatch);
updateColumnResizeDebounced(index, width, dispatch)
}
function onRowsChange(
rows: SupaRow[],
data: RowsChangeData<SupaRow, unknown>
) {
const rowData = rows[data.indexes[0]];
const originRowData = state.rows.find((x) => x.idx == rowData.idx);
const changedColumn = Object.keys(rowData).find((name) => rowData[name] !== originRowData![name]);
async function onRowsChange(rows: SupaRow[], data: RowsChangeData<SupaRow, unknown>) {
const rowData = rows[data.indexes[0]]
const originRowData = state.rows.find((x) => x.idx == rowData.idx)
const changedColumn = Object.keys(rowData).find(
(name) => rowData[name] !== originRowData![name]
)
if (changedColumn) {
const { error } = state.rowService!.update(rowData, changedColumn);
if (error) {
if (onErrorFunc) onErrorFunc(error);
} else {
const { error } = state.rowService!.update(rowData, changedColumn, (payload) => {
dispatch({
type: 'SET_ROWS',
payload: { rows },
});
}
type: 'EDIT_ROW',
payload,
})
})
if (error && onErrorFunc) onErrorFunc(error)
}
}
@@ -56,14 +53,14 @@ export const Grid = memo(
dispatch({
type: 'SELECTED_ROWS_CHANGE',
payload: { selectedRows },
});
})
}
function onSelectedCellChange(position: { idx: number; rowIdx: number }) {
dispatch({
type: 'SELECTED_CELL_CHANGE',
payload: { position },
});
})
}
if (!columnHeaders || columnHeaders.length == 0) {
@@ -73,14 +70,15 @@ export const Grid = memo(
style={{ width: width || '100%', height: height || '50vh' }}
>
<div className="sb-grid-grid--loading__inner flex items-center gap-2">
<div className="animate-spin text-scale-900">
<div className="text-scale-900 animate-spin">
<IconLoader />
</div>
<div className="text-sm text-scale-1100">Loading...</div>
<div className="text-scale-1100 text-sm">Loading...</div>
</div>
</div>
);
)
}
return (
<div
className={containerClass}
@@ -102,22 +100,15 @@ export const Grid = memo(
style={{ height: '100%' }}
/>
</div>
);
)
}
)
);
)
const updateColumnResize = (
index: number,
width: number,
dispatch: (value: unknown) => void
) => {
const updateColumnResize = (index: number, width: number, dispatch: (value: unknown) => void) => {
dispatch({
type: 'UPDATE_COLUMN_SIZE',
payload: { index, width: Math.round(width) },
});
};
const updateColumnResizeDebounced = AwesomeDebouncePromise(
updateColumnResize,
500
);
})
}
const updateColumnResizeDebounced = AwesomeDebouncePromise(updateColumnResize, 500)
@@ -1,144 +1,140 @@
import { IRowService } from '.';
import { Filter, ServiceError, Sort, SupaRow, SupaTable } from '../../types';
import { ERROR_PRIMARY_KEY_NOTFOUND, SupabaseGridQueue } from '../../constants';
import Query from '../../query';
import { isNumericalColumn } from '../../utils';
import { IRowService } from '.'
import { Filter, ServiceError, Sort, SupaRow, SupaTable } from '../../types'
import { ERROR_PRIMARY_KEY_NOTFOUND, SupabaseGridQueue } from '../../constants'
import Query from '../../query'
import { isNumericalColumn } from '../../utils'
import { useDispatch } from 'components/grid/store'
export class SqlRowService implements IRowService {
protected query = new Query();
protected query = new Query()
protected dispatch = useDispatch()
constructor(
protected table: SupaTable,
protected onSqlQuery: (
query: string
) => Promise<{ data?: any; error?: any }>,
protected onSqlQuery: (query: string) => Promise<{ data?: any; error?: any }>,
protected onError: (error: any) => void
) {}
async count(filters: Filter[]) {
let queryChains = this.query
.from(this.table.name, this.table.schema ?? undefined)
.count();
let queryChains = this.query.from(this.table.name, this.table.schema ?? undefined).count()
filters
.filter((x) => x.value && x.value != '')
.forEach((x) => {
const value = this.formatFilterValue(x);
queryChains = queryChains.filter(x.column, x.operator, value);
});
const value = this.formatFilterValue(x)
queryChains = queryChains.filter(x.column, x.operator, value)
})
const query = queryChains.toSql();
const { data, error } = await this.onSqlQuery(query);
const query = queryChains.toSql()
const { data, error } = await this.onSqlQuery(query)
if (error) {
return { error };
return { error }
} else {
if (data?.length == 1) {
return { data: data[0].count };
return { data: data[0].count }
} else {
return { error: { message: 'fetch rows count failed' } };
return { error: { message: 'fetch rows count failed' } }
}
}
}
async create(row: SupaRow) {
console.log('create: ', row);
return { error: { message: 'not implemented' } };
console.log('create: ', row)
return { error: { message: 'not implemented' } }
}
delete(rows: SupaRow[]) {
const { primaryKeys, error } = this.getPrimaryKeys();
if (error) return { error };
const { primaryKeys, error } = this.getPrimaryKeys()
if (error) return { error }
let queryChains = this.query
.from(this.table.name, this.table.schema ?? undefined)
.delete();
let queryChains = this.query.from(this.table.name, this.table.schema ?? undefined).delete()
primaryKeys!.forEach((key) => {
const primaryKeyValues = rows.map((x) => x[key]);
queryChains = queryChains.filter(key, 'in', primaryKeyValues);
});
const primaryKeyValues = rows.map((x) => x[key])
queryChains = queryChains.filter(key, 'in', primaryKeyValues)
})
const query = queryChains.toSql();
const query = queryChains.toSql()
SupabaseGridQueue.add(async () => {
const { error } = await this.onSqlQuery(query);
if (error) throw error;
const { error } = await this.onSqlQuery(query)
if (error) throw error
}).catch((error) => {
this.onError(error);
});
this.onError(error)
})
return {};
return {}
}
async fetchPage(
page: number,
rowsPerPage: number,
filters: Filter[],
sorts: Sort[]
) {
const pageFromZero = page > 0 ? page - 1 : page;
const from = pageFromZero * rowsPerPage;
const to = (pageFromZero + 1) * rowsPerPage - 1;
let queryChains = this.query
.from(this.table.name, this.table.schema ?? undefined)
.select();
async fetchPage(page: number, rowsPerPage: number, filters: Filter[], sorts: Sort[]) {
const pageFromZero = page > 0 ? page - 1 : page
const from = pageFromZero * rowsPerPage
const to = (pageFromZero + 1) * rowsPerPage - 1
let queryChains = this.query.from(this.table.name, this.table.schema ?? undefined).select()
filters
.filter((x) => x.value && x.value != '')
.forEach((x) => {
const value = this.formatFilterValue(x);
queryChains = queryChains.filter(x.column, x.operator, value);
});
const value = this.formatFilterValue(x)
queryChains = queryChains.filter(x.column, x.operator, value)
})
sorts.forEach((x) => {
queryChains = queryChains.order(x.column, x.ascending, x.nullsFirst);
});
queryChains = queryChains.order(x.column, x.ascending, x.nullsFirst)
})
const query = queryChains.range(from, to).toSql();
const { data, error } = await this.onSqlQuery(query);
const query = queryChains.range(from, to).toSql()
const { data, error } = await this.onSqlQuery(query)
if (error) {
this.onError(error);
return { data: { rows: [] } };
this.onError(error)
return { data: { rows: [] } }
} else {
const rows = data?.map((x: any, index: number) => {
return { idx: index, ...x } as SupaRow;
});
return { data: { rows } };
return { idx: index, ...x } as SupaRow
})
return { data: { rows } }
}
}
update(row: SupaRow, changedColumn?: string) {
const { primaryKeys, error } = this.getPrimaryKeys();
update(row: SupaRow, changedColumn?: string, onRowUpdate?: (value: any) => void) {
const { primaryKeys, error } = this.getPrimaryKeys()
if (error) {
return { error };
return { error }
}
const { idx, ...value } = row;
const matchValues: any = {};
const { idx, ...value } = row
const matchValues: any = {}
primaryKeys!.forEach((key) => {
matchValues[key] = row[key];
matchValues[key] = row[key]
// fix: https://github.com/supabase/grid/issues/94
// remove primary key from updated value object
delete value[key];
});
delete value[key]
})
const query = this.query
.from(this.table.name, this.table.schema ?? undefined)
.update(changedColumn ? {
[changedColumn]: value[changedColumn]
}: value)
.update(
changedColumn
? {
[changedColumn]: value[changedColumn],
}
: value,
{ returning: true }
)
.match(matchValues)
.toSql();
SupabaseGridQueue.add(async () => {
const { error } = await this.onSqlQuery(query);
if (error) throw error;
}).catch((error) => {
this.onError(error);
});
.toSql()
return {};
SupabaseGridQueue.add(async () => {
const { data, error } = await this.onSqlQuery(query)
if (error) throw error
if (onRowUpdate) onRowUpdate({ row: data[0], idx })
}).catch((error) => {
this.onError(error)
})
return {}
}
getPrimaryKeys(): { primaryKeys?: string[]; error?: ServiceError } {
const pkColumns = this.table.columns.filter((x) => x.isPrimaryKey);
const pkColumns = this.table.columns.filter((x) => x.isPrimaryKey)
if (!pkColumns || pkColumns.length == 0) {
return { error: { message: ERROR_PRIMARY_KEY_NOTFOUND } };
return { error: { message: ERROR_PRIMARY_KEY_NOTFOUND } }
}
return { primaryKeys: pkColumns.map((x) => x.name) };
return { primaryKeys: pkColumns.map((x) => x.name) }
}
/**
@@ -146,12 +142,12 @@ export class SqlRowService implements IRowService {
* which validate input value base on the column type
*/
formatFilterValue(filter: Filter) {
const column = this.table.columns.find((x) => x.name == filter.column);
const column = this.table.columns.find((x) => x.name == filter.column)
if (column && isNumericalColumn(column.format)) {
const numberValue = Number(filter.value);
if (Number.isNaN(numberValue)) return filter.value;
else return Number(filter.value);
const numberValue = Number(filter.value)
if (Number.isNaN(numberValue)) return filter.value
else return Number(filter.value)
}
return filter.value;
return filter.value
}
}
+13 -11
View File
@@ -1,11 +1,9 @@
import { Filter, ServiceError, Sort, SupaRow } from '../../types';
import { Filter, ServiceError, Sort, SupaRow } from '../../types'
export interface IRowService {
count: (
filters: Filter[]
) => Promise<{ data?: number; error?: ServiceError }>;
count: (filters: Filter[]) => Promise<{ data?: number; error?: ServiceError }>
create: (row: SupaRow) => Promise<{ data?: SupaRow; error?: ServiceError }>;
create: (row: SupaRow) => Promise<{ data?: SupaRow; error?: ServiceError }>
fetchPage: (
page: number,
@@ -13,9 +11,9 @@ export interface IRowService {
filters: Filter[],
sorts: Sort[]
) => Promise<{
data?: { rows: SupaRow[] };
error?: ServiceError;
}>;
data?: { rows: SupaRow[] }
error?: ServiceError
}>
/**
* TODO: should return a promise.
@@ -25,9 +23,13 @@ export interface IRowService {
* Right now, we remove rows immediately, so it's confused when delete operation fails
* and rows are already removed from the grid
*/
delete: (rows: SupaRow[]) => { error?: ServiceError };
delete: (rows: SupaRow[]) => { error?: ServiceError }
update: (row: SupaRow, changedColumn?: string) => { error?: ServiceError };
update: (
row: SupaRow,
changedColumn?: string,
onRowUpdate?: (value: any) => void
) => { row?: SupaRow; error?: ServiceError }
}
export * from './SqlRowService';
export * from './SqlRowService'
+48 -43
View File
@@ -1,54 +1,53 @@
import update from 'immutability-helper';
import {
REFRESH_PAGE_IMMEDIATELY,
TOTAL_ROWS_INITIAL,
TOTAL_ROWS_RESET,
} from '../../constants';
import { Dictionary, SupaRow } from '../../types';
import { INIT_ACTIONTYPE } from './base';
import update from 'immutability-helper'
import { REFRESH_PAGE_IMMEDIATELY, TOTAL_ROWS_INITIAL, TOTAL_ROWS_RESET } from '../../constants'
import { Dictionary, SupaRow } from '../../types'
import { INIT_ACTIONTYPE } from './base'
export interface RowInitialState {
rows: SupaRow[];
selectedRows: ReadonlySet<number>;
selectedCellPosition: { idx: number; rowIdx: number } | null;
page: number;
rowsPerPage: number;
totalRows: number;
isLoading: boolean
rows: SupaRow[]
selectedRows: ReadonlySet<number>
selectedCellPosition: { idx: number; rowIdx: number } | null
page: number
rowsPerPage: number
totalRows: number
}
export const rowInitialState: RowInitialState = {
isLoading: false,
rows: [],
selectedRows: new Set(),
selectedCellPosition: null,
page: 1,
rowsPerPage: 100,
totalRows: TOTAL_ROWS_INITIAL,
};
}
type ROW_ACTIONTYPE =
| INIT_ACTIONTYPE
| {
type: 'SELECTED_CELL_CHANGE';
payload: { position: { idx: number; rowIdx: number } };
type: 'SELECTED_CELL_CHANGE'
payload: { position: { idx: number; rowIdx: number } }
}
| {
type: 'SELECTED_ROWS_CHANGE';
payload: { selectedRows: ReadonlySet<number> };
type: 'SELECTED_ROWS_CHANGE'
payload: { selectedRows: ReadonlySet<number> }
}
| { type: 'SET_PAGE'; payload: number }
| { type: 'SET_ROWS_PER_PAGE'; payload: number }
| {
type: 'SET_ROWS';
payload: { rows: SupaRow[] };
type: 'SET_ROWS'
payload: { rows: SupaRow[] }
}
| {
type: 'SET_ROWS_COUNT';
payload: number;
type: 'SET_ROWS_COUNT'
payload: number
}
| { type: 'ADD_ROWS'; payload: SupaRow[] }
| { type: 'ADD_NEW_ROW'; payload: Dictionary<any> }
| { type: 'EDIT_ROW'; payload: { row: Dictionary<any>; idx: number } }
| { type: 'REMOVE_ROWS'; payload: { rowIdxs: number[] } };
| { type: 'REMOVE_ROWS'; payload: { rowIdxs: number[] } }
| { type: 'SET_IS_LOADING'; payload: { isLoading: boolean } }
const RowReducer = (state: RowInitialState, action: ROW_ACTIONTYPE) => {
switch (action.type) {
@@ -59,26 +58,26 @@ const RowReducer = (state: RowInitialState, action: ROW_ACTIONTYPE) => {
selectedCellPosition: null,
selectedRows: new Set(),
totalRows: TOTAL_ROWS_RESET,
};
}
}
case 'SELECTED_CELL_CHANGE': {
return {
...state,
selectedCellPosition: action.payload.position,
};
}
}
case 'SELECTED_ROWS_CHANGE': {
return {
...state,
selectedRows: action.payload.selectedRows,
};
}
}
case 'SET_PAGE': {
return {
...state,
page: action.payload,
refreshPageFlag: REFRESH_PAGE_IMMEDIATELY,
};
}
}
case 'SET_ROWS_PER_PAGE': {
return {
@@ -86,58 +85,64 @@ const RowReducer = (state: RowInitialState, action: ROW_ACTIONTYPE) => {
page: 1,
rowsPerPage: action.payload,
refreshPageFlag: REFRESH_PAGE_IMMEDIATELY,
};
}
}
case 'SET_ROWS': {
return {
...state,
rows: action.payload.rows,
refreshPageFlag: 0,
};
}
}
case 'SET_ROWS_COUNT': {
return {
...state,
totalRows: action.payload,
};
}
}
case 'ADD_ROWS': {
const totalRows = state.totalRows + action.payload.length;
const totalRows = state.totalRows + action.payload.length
return {
...state,
rows: update(state.rows, { $push: action.payload }),
totalRows: totalRows,
};
}
}
case 'ADD_NEW_ROW': {
const supaRow = { ...action.payload, idx: state.rows.length };
const totalRows = state.totalRows + 1;
const supaRow = { ...action.payload, idx: state.rows.length }
const totalRows = state.totalRows + 1
return {
...state,
rows: update(state.rows, { $push: [supaRow] }),
totalRows: totalRows,
};
}
}
case 'EDIT_ROW': {
const supaRow = { ...action.payload.row, idx: action.payload.idx };
const supaRow = { ...action.payload.row, idx: action.payload.idx }
return {
...state,
rows: update(state.rows, {
[action.payload.idx]: { $set: supaRow },
}),
};
}
}
case 'REMOVE_ROWS': {
const totalRows = state.totalRows - action.payload.rowIdxs.length;
const totalRows = state.totalRows - action.payload.rowIdxs.length
return {
...state,
rows: state.rows.filter((x) => !action.payload.rowIdxs.includes(x.idx)),
totalRows: totalRows,
};
}
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload.isLoading,
}
}
default:
return state;
return state
}
};
}
export default RowReducer;
export default RowReducer
+13 -17
View File
@@ -1,40 +1,36 @@
import AwesomeDebouncePromise from 'awesome-debounce-promise';
import { InitialStateType } from '../store/reducers';
import AwesomeDebouncePromise from 'awesome-debounce-promise'
import { InitialStateType } from '../store/reducers'
export async function fetchCount(
state: InitialStateType,
dispatch: (value: unknown) => void
) {
if (!state.rowService) return;
const { data, error } = await state.rowService.count(state.filters);
export async function fetchCount(state: InitialStateType, dispatch: (value: unknown) => void) {
if (!state.rowService) return
const { data, error } = await state.rowService.count(state.filters)
if (error) {
// TODO: handle fetch rows count error
} else {
dispatch({
type: 'SET_ROWS_COUNT',
payload: data ?? 0,
});
})
}
}
export async function fetchPage(
state: InitialStateType,
dispatch: (value: unknown) => void
) {
if (!state.rowService) return;
export async function fetchPage(state: InitialStateType, dispatch: (value: unknown) => void) {
if (!state.rowService) return
dispatch({ type: 'SET_IS_LOADING', payload: { isLoading: true } })
const { data, error } = await state.rowService.fetchPage(
state.page,
state.rowsPerPage,
state.filters,
state.sorts
);
)
if (error) {
// TODO: handle fetch rows data error
} else {
dispatch({
type: 'SET_ROWS',
payload: { rows: data?.rows ?? [] },
});
})
}
dispatch({ type: 'SET_IS_LOADING', payload: { isLoading: false } })
}
export const refreshPageDebounced = AwesomeDebouncePromise(fetchPage, 500);
export const refreshPageDebounced = AwesomeDebouncePromise(fetchPage, 500)
@@ -120,18 +120,21 @@ const PolicyEditorModal: FC<Props> = ({
return ui.setNotification({
category: 'error',
message: 'You will need to allow at one operation in your policy',
duration: 4000,
})
}
if (['SELECT', 'DELETE'].includes(command) && !definition) {
return ui.setNotification({
category: 'error',
message: 'Did you forget to provide a USING expression for your policy?',
duration: 4000,
})
}
if (command === 'INSERT' && !check) {
return ui.setNotification({
category: 'error',
message: 'Did you forget to provide a WITH CHECK expression for your policy?',
duration: 4000,
})
}
if (command === 'UPDATE' && !definition && !check) {
@@ -139,6 +142,7 @@ const PolicyEditorModal: FC<Props> = ({
category: 'error',
message:
'You will need to provide either a USING, or WITH CHECK expression, or both for your policy',
duration: 4000,
})
}
const policySQLStatement = createSQLPolicy(policyFormFields, selectedPolicyToEdit)
@@ -54,11 +54,11 @@ const PublicationsTableItem: FC<Props> = ({ table, selectedPublication }) => {
<Table.tr key={table.id}>
<Table.td className="whitespace-nowrap">{table.name}</Table.td>
<Table.td className="whitespace-nowrap">{table.schema}</Table.td>
<Table.td className="whitespace-nowrap truncate max-w-sm hidden lg:table-cell">
<Table.td className="hidden max-w-sm truncate whitespace-nowrap lg:table-cell">
{table.comment}
</Table.td>
<Table.td className="px-4 py-3 pr-2">
<div className="flex gap-2 justify-end">
<div className="flex justify-end gap-2">
{enabledForAllTables ? (
// @ts-ignore
<Badge
@@ -74,7 +74,7 @@ const PublicationsTableItem: FC<Props> = ({ table, selectedPublication }) => {
size="tiny"
align="right"
disabled={loading}
className="m-0 p-0 ml-2 mt-1 -mb-1"
className="m-0 ml-2 mt-1 -mb-1 p-0"
checked={checked}
onChange={() => toggleReplicationForTable(table, publication)}
/>
@@ -33,8 +33,8 @@ const JsonEdit: FC<JsonEditProps> = ({ column, jsonString, visible, closePanel,
} catch (error: any) {
const message = error.message
? `Error: ${error.message}`
: 'Hmm, invalid JSON seems to have an invalid structure.'
ui.setNotification({ category: 'error', message })
: 'JSON seems to have an invalid structure.'
ui.setNotification({ category: 'error', message, duration: 4000 })
} finally {
resolve()
}
@@ -82,6 +82,7 @@ const RowEditor: FC<Props> = ({
ui.setNotification({
category: 'error',
message: `Please enter a value in the ${row.name} field first`,
duration: 4000,
})
}
const foreignKey = row.foreignKey
@@ -108,6 +109,7 @@ const RowEditor: FC<Props> = ({
return ui.setNotification({
category: 'error',
message: `Unable to find the corresponding row in ${foreignKey.target_table_schema}.${foreignKey.target_table_name} where ${foreignKey.target_column_name} equals ${row.value}`,
duration: 4000,
})
}
setReferenceRow({ loading: false, foreignKey, row: res[0] })
@@ -283,8 +283,6 @@ export const generateRowObjectFromFields = (
rowObject[field.name] = tryParseJson(value)
}
}
} else if (NUMERICAL_TYPES.includes(field.format) && value) {
rowObject[field.name] = Number(value)
} else if (field.format === 'bool' && value) {
rowObject[field.name] = value === 'true'
} else if (DATETIME_TYPES.includes(field.format)) {
@@ -148,13 +148,15 @@ const SidePanelEditor: FC<Props> = ({
tableId?: number
importContent?: ImportContent
isRLSEnabled: boolean
isRealtimeEnabled: boolean
isDuplicateRows: boolean
},
resolve: any
) => {
let toastId
let saveTableError = false
const { tableId, importContent, isRLSEnabled, isDuplicateRows } = configuration
const { tableId, importContent, isRLSEnabled, isRealtimeEnabled, isDuplicateRows } =
configuration
try {
if (isDuplicating) {
@@ -165,6 +167,7 @@ const SidePanelEditor: FC<Props> = ({
})
const table: any = await meta.duplicateTable(payload, {
isRLSEnabled,
isRealtimeEnabled,
isDuplicateRows,
duplicateTable,
})
@@ -179,7 +182,14 @@ const SidePanelEditor: FC<Props> = ({
category: 'loading',
message: `Creating new table: ${payload.name}...`,
})
const table = await meta.createTable(toastId, payload, isRLSEnabled, columns, importContent)
const table = await meta.createTable(
toastId,
payload,
columns,
isRLSEnabled,
isRealtimeEnabled,
importContent
)
ui.setNotification({
id: toastId,
category: 'success',
@@ -195,7 +205,8 @@ const SidePanelEditor: FC<Props> = ({
toastId,
selectedTableToEdit,
payload,
columns
columns,
isRealtimeEnabled
)
if (hasError) {
ui.setNotification({
@@ -258,8 +269,6 @@ const SidePanelEditor: FC<Props> = ({
)}
<TableEditor
table={selectedTableToEdit}
tables={tables}
enumTypes={enumTypes}
selectedSchema={selectedSchema}
isDuplicating={isDuplicating}
visible={sidePanelKey === 'table'}
@@ -74,6 +74,7 @@ const SpreadsheetImport: FC<Props> = ({
error: errors,
category: 'error',
message: `Some issues have been detected on ${errors.length} rows. More details below the content preview.`,
duration: 4000,
})
}
setErrors(errors)
@@ -98,6 +99,7 @@ const SpreadsheetImport: FC<Props> = ({
error: errors,
category: 'error',
message: `Some issues have been detected on ${errors.length} rows. More details below the content preview.`,
duration: 4000,
})
}
setErrors(errors)
@@ -160,7 +162,7 @@ const SpreadsheetImport: FC<Props> = ({
</div>
{spreadsheetData.headers.length > 0 && (
<div className="py-5 space-y-5">
<div className="space-y-5 py-5">
<div className="space-y-2">
<div className="flex flex-col space-y-1">
<Typography.Text>Content Preview</Typography.Text>
@@ -188,7 +190,7 @@ const SpreadsheetImport: FC<Props> = ({
<div key={key} className="space-y-2">
<div className="flex items-center space-x-2">
<IconChevronRight
className={`cursor-pointer transform ${isExpanded ? 'rotate-90' : ''}`}
className={`transform cursor-pointer ${isExpanded ? 'rotate-90' : ''}`}
size={14}
onClick={() => onSelectExpandError(key)}
/>
@@ -122,9 +122,10 @@ export const inferColumnType = (column: string, rows: object[]) => {
// Infer boolean type
if (includes(['true', 'false'], columnData.toString().toLowerCase())) {
const isAllBoolean = columnDataAcrossRows.every((item: any) =>
includes(['true', 'false'], item.toString().toLowerCase())
)
const isAllBoolean = columnDataAcrossRows.every((item: any) => {
if (item === null) return true
else return includes(['true', 'false'], item.toString().toLowerCase())
})
if (isAllBoolean) {
return 'boolean'
}
@@ -20,8 +20,6 @@ import {
interface Props {
table?: PostgresTable
tables: PostgresTable[]
enumTypes: PostgresType[]
selectedSchema: string
isDuplicating: boolean
visible: boolean
@@ -34,6 +32,7 @@ interface Props {
tableId?: number
importContent?: ImportContent
isRLSEnabled: boolean
isRealtimeEnabled: boolean
isDuplicateRows: boolean
},
resolve: any
@@ -43,8 +42,6 @@ interface Props {
const TableEditor: FC<Props> = ({
table,
tables = [],
enumTypes = [] as PostgresType[],
selectedSchema,
isDuplicating,
visible = false,
@@ -52,9 +49,23 @@ const TableEditor: FC<Props> = ({
saveChanges = () => {},
updateEditorDirty = () => {},
}) => {
const { ui } = useStore()
const { ui, meta } = useStore()
const isNewRecord = isUndefined(table)
const tables = meta.tables.list()
const enumTypes = meta.types.list(
(type: PostgresType) => !meta.excludedSchemas.includes(type.schema)
)
const publications = meta.publications.list()
const realtimePublication = publications.find(
(publication) => publication.name === 'supabase_realtime'
)
const realtimeEnabledTables = realtimePublication?.tables ?? []
const isRealtimeEnabled = isNewRecord
? false
: realtimeEnabledTables.some((t: any) => t.id === table?.id)
const [errors, setErrors] = useState<any>({})
const [tableFields, setTableFields] = useState<TableField>()
const [isDuplicateRows, setIsDuplicateRows] = useState<boolean>(false)
@@ -70,7 +81,11 @@ const TableEditor: FC<Props> = ({
const tableFields = generateTableField()
setTableFields(tableFields)
} else {
const tableFields = generateTableFieldFromPostgresTable(table!, isDuplicating)
const tableFields = generateTableFieldFromPostgresTable(
table!,
isDuplicating,
isRealtimeEnabled
)
setTableFields(tableFields)
}
}
@@ -99,7 +114,7 @@ const TableEditor: FC<Props> = ({
if (tableFields) {
const errors: any = validateFields(tableFields)
if (errors.columns) {
ui.setNotification({ category: 'error', message: errors.columns })
ui.setNotification({ category: 'error', message: errors.columns, duration: 4000 })
}
setErrors(errors)
@@ -122,6 +137,7 @@ const TableEditor: FC<Props> = ({
tableId: table?.id,
importContent,
isRLSEnabled: tableFields.isRLSEnabled,
isRealtimeEnabled: tableFields.isRealtimeEnabled,
isDuplicateRows: isDuplicateRows,
}
@@ -187,8 +203,16 @@ const TableEditor: FC<Props> = ({
</div>
}
description="Restrict access to your table by enabling RLS and writing Postgres policies"
checked={tableFields?.isRLSEnabled}
onChange={() => onUpdateField({ isRLSEnabled: !tableFields?.isRLSEnabled })}
checked={tableFields.isRLSEnabled}
onChange={() => onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled })}
size="medium"
/>
<Checkbox
id="enable-realtime"
label="Enable Realtime"
description="Broadcast changes on this table to authorized subscribers"
checked={tableFields.isRealtimeEnabled}
onChange={() => onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled })}
size="medium"
/>
</div>
@@ -7,6 +7,7 @@ export interface TableField {
comment?: string
columns: ColumnField[]
isRLSEnabled: boolean
isRealtimeEnabled: boolean
}
export interface ImportContent {
@@ -30,12 +30,14 @@ export const generateTableField = (): TableField => {
comment: '',
columns: DEFAULT_COLUMNS,
isRLSEnabled: false,
isRealtimeEnabled: false,
}
}
export const generateTableFieldFromPostgresTable = (
table: PostgresTable,
isDuplicating = false
isDuplicating = false,
isRealtimeEnabled = false
): TableField => {
return {
id: table.id,
@@ -46,6 +48,7 @@ export const generateTableFieldFromPostgresTable = (
return generateColumnFieldFromPostgresColumn(column, table)
}),
isRLSEnabled: table.rls_enabled,
isRealtimeEnabled,
}
}
@@ -37,6 +37,7 @@ const TableEditorLayout: FC<Props> = ({
meta.schemas.load()
meta.tables.load()
meta.types.load()
meta.publications.load()
}
}, [ui.selectedProject])
+1 -1
View File
@@ -58,7 +58,7 @@ export const StoreProvider: FC<StoreProvider> = ({ children, rootStore }) => {
return toast.success(message, { id, duration: toastDuration })
case 'error':
console.error('Error:', { error, message })
return toast.error(message, { id, duration: toastDuration })
return toast.error(message, { id, duration: duration || Infinity })
case 'loading':
if (progress) {
return toast.loading(
+55 -8
View File
@@ -70,6 +70,7 @@
"swr": "^1.2.1",
"uniforms-bootstrap4": "^3.6.2",
"uniforms-bridge-json-schema": "^3.6.2",
"uuid": "^8.3.2",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
@@ -100,6 +101,7 @@
"@types/react-window-infinite-loader": "^1.0.5",
"@types/semver": "^7.3.9",
"@types/sqlstring": "^2.3.0",
"@types/uuid": "^8.3.4",
"@types/zxcvbn": "^4.4.1",
"autoprefixer": "^10.4.2",
"babel-loader": "^8.2.3",
@@ -9048,6 +9050,12 @@
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
"dev": true
},
"node_modules/@types/uuid": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
"integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
"dev": true
},
"node_modules/@types/webpack": {
"version": "4.41.32",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz",
@@ -9472,6 +9480,15 @@
"node": ">=4"
}
},
"node_modules/analytics-node/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/anser": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.9.tgz",
@@ -27444,12 +27461,11 @@
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "bin/uuid"
"uuid": "dist/bin/uuid"
}
},
"node_modules/uuid-browser": {
@@ -28143,6 +28159,16 @@
"node": ">= 6"
}
},
"node_modules/webpack-log/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"dev": true,
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/webpack-sources": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
@@ -35769,6 +35795,12 @@
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
"dev": true
},
"@types/uuid": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz",
"integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==",
"dev": true
},
"@types/webpack": {
"version": "4.41.32",
"resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz",
@@ -36151,6 +36183,13 @@
"ms": "^2.0.0",
"remove-trailing-slash": "^0.1.0",
"uuid": "^3.2.1"
},
"dependencies": {
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
}
}
},
"anser": {
@@ -49981,9 +50020,9 @@
"dev": true
},
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"uuid-browser": {
"version": "3.1.0",
@@ -50845,6 +50884,14 @@
"requires": {
"ansi-colors": "^3.0.0",
"uuid": "^3.3.2"
},
"dependencies": {
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"dev": true
}
}
},
"webpack-sources": {
+95 -12
View File
@@ -93,16 +93,28 @@ export interface IMetaStore {
) => any
duplicateTable: (
payload: any,
metadata: { duplicateTable: PostgresTable; isRLSEnabled: boolean; isDuplicateRows: boolean }
metadata: {
duplicateTable: PostgresTable
isRLSEnabled: boolean
isRealtimeEnabled: boolean
isDuplicateRows: boolean
}
) => void
createTable: (
toastId: string,
payload: any,
isRLSEnabled: boolean,
columns: ColumnField[],
isRLSEnabled: boolean,
isRealtimeEnabled: boolean,
importContent?: ImportContent
) => any
updateTable: (toastId: string, table: PostgresTable, payload: any, columns: ColumnField[]) => any
updateTable: (
toastId: string,
table: PostgresTable,
payload: any,
columns: ColumnField[],
isRealtimeEnabled: boolean
) => any
}
export default class MetaStore implements IMetaStore {
rootStore: IRootStore
@@ -297,9 +309,15 @@ export default class MetaStore implements IMetaStore {
async duplicateTable(
payload: any,
metadata: { duplicateTable: PostgresTable; isRLSEnabled: boolean; isDuplicateRows: boolean }
metadata: {
duplicateTable: PostgresTable
isRLSEnabled: boolean
isRealtimeEnabled: boolean
isDuplicateRows: boolean
}
) {
const sourceTableName = metadata.duplicateTable.name
const { duplicateTable, isRLSEnabled, isRealtimeEnabled, isDuplicateRows } = metadata
const sourceTableName = duplicateTable.name
const duplicatedTableName = payload.name
// The following query will copy the structure of the table along with indexes, constraints and
@@ -310,7 +328,7 @@ export default class MetaStore implements IMetaStore {
if (table.error) throw table.error
// Duplicate foreign key constraints over
const relationships = metadata.duplicateTable.relationships
const relationships = duplicateTable.relationships
if (relationships.length > 0) {
// @ts-ignore, but might need to investigate, sounds bad:
// Type instantiation is excessively deep and possibly infinite
@@ -324,14 +342,15 @@ export default class MetaStore implements IMetaStore {
}
// Duplicate rows if needed
if (metadata.isDuplicateRows) {
if (isDuplicateRows) {
const rows = await this.rootStore.meta.query(
`INSERT INTO "${duplicatedTableName}" SELECT * FROM ${sourceTableName};`
)
if (rows.error) throw rows.error
// Insert into does not copy over auto increment sequences, so we manually do it next if any
const columns = metadata.duplicateTable.columns
const columns = duplicateTable.columns
// @ts-ignore
const identityColumns = columns.filter((column) => column.identity_generation !== null)
identityColumns.map(async (column) => {
const identity = await this.rootStore.meta.query(
@@ -345,21 +364,37 @@ export default class MetaStore implements IMetaStore {
const tables = this.tables.list()
const duplicatedTable = find(tables, { name: duplicatedTableName })
if (metadata.isRLSEnabled) {
if (isRLSEnabled) {
const updateTable: any = await this.tables.update(duplicatedTable!.id, {
rls_enabled: metadata.isRLSEnabled,
rls_enabled: isRLSEnabled,
})
if (updateTable.error) throw updateTable.error
}
if (isRealtimeEnabled) {
const publications = this.publications.list()
const realtimePublication = publications.find(
(publication) => publication.name === 'supabase_realtime'
)
const id = realtimePublication?.id
const realtimeTables = [`${duplicateTable.schema}.${duplicatedTableName}`].concat(
realtimePublication.tables.map((t: any) => `${t.schema}.${t.name}`)
)
let payload = { id, tables: realtimeTables }
const { error: publicationsUpdateError } = await this.publications.update(id, payload)
if (publicationsUpdateError) throw publicationsUpdateError
}
return duplicatedTable
}
async createTable(
toastId: string,
payload: any,
isRLSEnabled: boolean,
columns: ColumnField[] = [],
isRLSEnabled: boolean,
isRealtimeEnabled: boolean,
importContent?: ImportContent
) {
// Create the table first
@@ -379,6 +414,22 @@ export default class MetaStore implements IMetaStore {
if (updatedTable.error) throw updatedTable.error
}
// Toggle Realtime if configured to be
if (isRealtimeEnabled) {
const publications = this.publications.list()
const realtimePublication = publications.find(
(publication) => publication.name === 'supabase_realtime'
)
const id = realtimePublication?.id
const realtimeTables = [`${table.schema}.${table.name}`].concat(
realtimePublication.tables.map((t: any) => `${t.schema}.${t.name}`)
)
let payload = { id, tables: realtimeTables }
const { error: publicationsUpdateError } = await this.publications.update(id, payload)
if (publicationsUpdateError) throw publicationsUpdateError
}
// Then insert the columns - we don't do Promise.all as we want to keep the integrity
// of the column order during creation. Note that we add primary key constraints separately
// via the query endpoint to support composite primary keys as pg-meta does not support that OOB
@@ -483,11 +534,18 @@ export default class MetaStore implements IMetaStore {
}
}
async updateTable(toastId: string, table: PostgresTable, payload: any, columns: ColumnField[]) {
async updateTable(
toastId: string,
table: PostgresTable,
payload: any,
columns: ColumnField[],
isRealtimeEnabled: boolean
) {
// Prepare a check to see if primary keys to the tables were updated or not
const primaryKeyColumns = columns
.filter((column) => column.isPrimaryKey)
.map((column) => `"${column.name}"`)
// @ts-ignore
const existingPrimaryKeyColumns = table.primary_keys.map(
(pk: PostgresPrimaryKey) => `"${pk.name}"`
)
@@ -512,6 +570,7 @@ export default class MetaStore implements IMetaStore {
const columnIds = columns.map((column) => column.id)
// Delete any removed columns
// @ts-ignore
const columnsToRemove = originalColumns.filter((column) => !columnIds.includes(column.id))
for (const column of columnsToRemove) {
this.rootStore.ui.setNotification({
@@ -583,6 +642,30 @@ export default class MetaStore implements IMetaStore {
if (primaryKeys.error) throw primaryKeys.error
}
// Update realtime configuration
const publications = this.publications.list()
const realtimePublication = publications.find(
(publication) => publication.name === 'supabase_realtime'
)
const id = realtimePublication?.id
const realtimeEnabled = realtimePublication.tables.some((x: any) => x.id == table.id)
if (realtimeEnabled && !isRealtimeEnabled) {
// Toggle realtime off
const realtimeTables = realtimePublication.tables
.filter((x: any) => x.id != table.id)
.map((x: any) => `${x.schema}.${x.name}`)
let payload = { id, tables: realtimeTables }
await this.publications.update(id, payload)
} else if (!realtimeEnabled && isRealtimeEnabled) {
// Toggle realtime on
const realtimeTables = [`${table.schema}.${table.name}`].concat(
realtimePublication.tables.map((t: any) => `${t.schema}.${t.name}`)
)
let payload = { id, tables: realtimeTables }
await this.publications.update(id, payload)
}
return { table: await this.tables.loadById(table.id), hasError }
}
+4
View File
@@ -113,6 +113,10 @@
border-spacing: 0;
}
.table-container table span {
@apply truncate;
}
.table-container thead th {
@apply text-scale-900 font-normal text-sm;
@apply bg-table-header-light dark:bg-table-header-dark;
@@ -35,6 +35,15 @@ describe('SpreadsheedImport.utils: inferColumnType', () => {
const type2 = inferColumnType('isRetired', mockData2)
expect(type2).toBe('boolean')
})
test('should infer boolean type for a supposed boolean column if one of the rows has a null value', () => {
const mockData3 = [
{ name: 'bob', height: '161.72', isRetired: 'false' },
{ name: 'bob', height: '161.72', isRetired: 'true' },
{ name: 'bob', height: '161.72', isRetired: null },
]
const type3 = inferColumnType('isRetired', mockData3)
expect(type3).toBe('boolean')
})
test('should infer objects as jsonb types correctly', () => {
const mockData = [{ name: 'bob', metadata: '{}' }]
const type = inferColumnType('metadata', mockData)
+2 -2
View File
@@ -358,10 +358,10 @@ const handleInserts = (payload) => {
console.log('Change received!', payload)
}
// Listen to unserts
// Listen to inserts
const { data: todos, error } = await supabase
.from('todos')
.on('INSERT', handleUpdates)
.on('INSERT', handleInserts)
.subscribe()
```
+3 -3
View File
@@ -23,7 +23,7 @@ To make sure that everything works correctly, let’s try the connection string
If you already have one, all you need to do is set the `DATABASE_URL` to the connection string (including the password) in your `.env` file, and you’re good to go.
In case you don’t have a Prisma project or this is your first time working with Prisma, you’re going to use with the repo from the [quickstart](https://www.prisma.io/docs/getting-started/quickstart) guide.
In case you don’t have a Prisma project or this is your first time working with Prisma, you’re going to use the repo from the [quickstart](https://www.prisma.io/docs/getting-started/quickstart) guide.
### Cloning the starter project
@@ -140,10 +140,10 @@ alter type "Incompatible" rename to compatible;
## Connection pooling with Supabase
If you’re working in a serverless environment (for example Node.js functions hosted on AWS Lambda, Vercel or Netlify Functions), you need to set up [connection pooling](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#serverless-environments-faas) using a tool like [PgBouncer](https://www.pgbouncer.org/). That’s because every function invocation may result in a [new connection to the database](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#the-serverless-challenge). Supabase [support connection management using PgBouncer](https://supabase.io/blog/2021/04/02/supabase-pgbouncer#what-is-connection-pooling) and are enabled by default.
If you’re working in a serverless environment (for example Node.js functions hosted on AWS Lambda, Vercel or Netlify Functions), you need to set up [connection pooling](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#serverless-environments-faas) using a tool like [PgBouncer](https://www.pgbouncer.org/). That’s because every function invocation may result in a [new connection to the database](https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#the-serverless-challenge). Supabase [supports connection management using PgBouncer](https://supabase.io/blog/2021/04/02/supabase-pgbouncer#what-is-connection-pooling) and are enabled by default.
Go to the **Database** page from the sidebar in the Supabase dashboard and navigate to **connection pool** settings
![Connection pool settings](/img/guides/integrations/prisma/w0oowg8vq435ob5c3gf0.png)
When running migrations you need to use the non pooled connection URL (like the one we used in **step 4**). However, when deploying your app, you’ll use the pooled connection URL and add the `?pgbouncer=true` flag to the PostgreSQL connection URL. To minimize the number of concurrent connections, setting the `connection_limit` to `1` is also recommended. So The URL might look as follows:
When running migrations you need to use the non pooled connection URL (like the one we used in **step 4**). However, when deploying your app, you’ll use the pooled connection URL and add the `?pgbouncer=true` flag to the PostgreSQL connection URL. To minimize the number of concurrent connections, setting the `connection_limit` to `1` is also recommended. So the URL might look as follows:
```env
# prisma/.env