Merge pull request #13406 from supabase/feat/import-csv-existing

Feat/import csv existing
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-04-04 14:58:45 +08:00
commit 32ddb291ec
25 files changed
+615 -197

No files matched your search

@@ -18,7 +18,7 @@ interface Props {
export function TicketBrickWallSlider({ users, reverse, speed = 10000 }: Props) {
const ref = useRef(null)
const [swiperInstace, setSwiperInstance] = useState<SwiperCore | any | null>(null)
const [swiperInstance, setSwiperInstance] = useState<SwiperCore | any | null>(null)
const { width } = useWindowSize()
const STORAGE_URL = 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/lw7'
const BUCKET_FOLDER_VERSION = 'v3'
@@ -28,8 +28,8 @@ export function TicketBrickWallSlider({ users, reverse, speed = 10000 }: Props)
}/${BUCKET_FOLDER_VERSION}/${username}.png`
useEffect(() => {
// trigger autplay if viewport resize
if (swiperInstace) swiperInstace?.autoplay?.run()
// trigger autoplay if viewport resize
if (swiperInstance) swiperInstance?.autoplay?.run()
}, [width])
return (
+5 -4
View File
@@ -369,6 +369,7 @@ export default {
transition
shadow-sm
rounded
border
focus:outline-none
focus-visible:ring
focus-visible:ring-scale-700
@@ -376,13 +377,13 @@ export default {
`,
inactive: `
bg-scale-200
border border-scale-700 hover:border-scale-900
border-scale-700 hover:border-scale-900
text-scale-900 hover:text-scale-1200
`,
active: `
bg-scale-1200
text-scale-200
border-scale-1200
bg-scale-600
text-scale-1200
border-scale-800
`,
},
'rounded-pills': {
+15 -11
View File
@@ -66,6 +66,11 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
showCustomChildren,
customHeader,
children,
onAddRow,
onAddColumn,
updateTableRow,
onEditForeignKeyColumnValue,
onImportData,
} = props
const dispatch = useDispatch()
const state = useTrackedState()
@@ -79,15 +84,13 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
const sorts = formatSortURLParams(sort as string[])
const filters = formatFilterURLParams(filter as string[])
const table = props.table
const { project } = useProjectContext()
const { data, isLoading, isRefetching } = useTableRowsQuery(
{
queryKey: [table.schema, table.name],
queryKey: [props.table.schema, props.table.name],
projectRef: project?.ref,
connectionString: project?.connectionString,
table,
table: props.table,
sorts,
filters,
page: state.page,
@@ -194,17 +197,18 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
if (!state.table || refreshView || refreshTable) {
initializeData()
}
}, [state.metaService, state.table, table, props.schema])
}, [state.metaService, state.table, props.table, props.schema])
return (
<div className="sb-grid">
<Header
table={table}
table={props.table}
sorts={sorts}
filters={filters}
isRefetching={isRefetching}
onAddRow={editable ? props.onAddRow : undefined}
onAddColumn={editable ? props.onAddColumn : undefined}
onAddRow={editable ? onAddRow : undefined}
onAddColumn={editable ? onAddColumn : undefined}
onImportData={editable ? onImportData : undefined}
headerActions={headerActions}
customHeader={customHeader}
/>
@@ -216,8 +220,8 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
ref={gridRef}
{...gridProps}
rows={data?.rows ?? []}
updateRow={props.updateTableRow}
onEditForeignKeyColumnValue={props.onEditForeignKeyColumnValue}
updateRow={updateTableRow}
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
/>
<Footer isLoading={isLoading || isRefetching} />
<Shortcuts gridRef={gridRef} />
@@ -225,7 +229,7 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
)}
{mounted &&
createPortal(<RowContextMenu table={table} rows={data?.rows ?? []} />, document.body)}
createPortal(<RowContextMenu table={props.table} rows={data?.rows ?? []} />, document.body)}
</div>
)
})
@@ -1,6 +1,15 @@
import { saveAs } from 'file-saver'
import { useState, ReactNode } from 'react'
import { Button, IconDownload, IconX, IconTrash, Dropdown, IconChevronDown } from 'ui'
import {
Button,
IconDownload,
IconX,
IconTrash,
Dropdown,
IconChevronDown,
IconFileText,
IconArrowUp,
} from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { checkPermissions, useStore, useUrlState } from 'hooks'
@@ -18,6 +27,7 @@ import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
import RLSBannerWarning from './RLSBannerWarning'
import clsx from 'clsx'
// [Joshen] CSV exports require this guard as a fail-safe if the table is
// just too large for a browser to keep all the rows in memory before
@@ -31,6 +41,7 @@ export type HeaderProps = {
isRefetching: boolean
onAddColumn?: () => void
onAddRow?: () => void
onImportData?: () => void
headerActions?: ReactNode
customHeader: ReactNode
}
@@ -41,6 +52,7 @@ const Header = ({
filters,
onAddColumn,
onAddRow,
onImportData,
headerActions,
customHeader,
isRefetching,
@@ -63,6 +75,7 @@ const Header = ({
isRefetching={isRefetching}
onAddColumn={onAddColumn}
onAddRow={onAddRow}
onImportData={onImportData}
/>
)}
</>
@@ -81,8 +94,15 @@ type DefaultHeaderProps = {
isRefetching: boolean
onAddColumn?: () => void
onAddRow?: () => void
onImportData?: () => void
}
const DefaultHeader = ({ table, isRefetching, onAddColumn, onAddRow }: DefaultHeaderProps) => {
const DefaultHeader = ({
table,
isRefetching,
onAddColumn,
onAddRow,
onImportData,
}: DefaultHeaderProps) => {
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
// [Joshen] Using this logic to block both column and row creation/update/delete
@@ -115,7 +135,6 @@ const DefaultHeader = ({ table, isRefetching, onAddColumn, onAddRow }: DefaultHe
key="add-row"
className="group"
onClick={onAddRow}
disabled={onAddRow === undefined}
icon={
<div className="-mt-2 pr-1.5">
<div className="border border-scale-1000 w-[15px] h-[4px]" />
@@ -142,7 +161,6 @@ const DefaultHeader = ({ table, isRefetching, onAddColumn, onAddRow }: DefaultHe
key="add-column"
className="group"
onClick={onAddColumn}
disabled={onAddColumn === undefined}
icon={
<div className="flex -mt-2 pr-1.5">
<div className="border border-scale-1000 w-[4px] h-[15px]" />
@@ -163,6 +181,33 @@ const DefaultHeader = ({ table, isRefetching, onAddColumn, onAddRow }: DefaultHe
</Dropdown.Item>,
]
: []),
...(onImportData !== undefined
? [
<Dropdown.Item
key="import-data"
className="group"
onClick={onImportData}
icon={
<div className="relative -mt-2">
<IconFileText className="-translate-x-[2px]" />
<IconArrowUp
className={clsx(
'transition duration-200 absolute bottom-0 right-0 translate-y-1 opacity-0 bg-brand-700 rounded-full',
'group-hover:translate-y-0 group-hover:text-brand-900 group-hover:opacity-100'
)}
strokeWidth={3}
size={12}
/>
</div>
}
>
<div className="">
<p>Import data from CSV</p>
<p className="text-scale-1000">Insert new rows from a CSV</p>
</div>
</Dropdown.Item>,
]
: []),
]}
>
<Button size="tiny" icon={<IconChevronDown size={14} strokeWidth={1.5} />}>
+5
View File
@@ -96,6 +96,11 @@ export interface SupabaseGridProps {
* Custom component passed as children
*/
children?: ReactNode
/**
* show import csv data button if available
*/
onImportData?: () => void
}
export interface SupabaseGridRef {
@@ -9,7 +9,7 @@ import { useStore, useUrlState } from 'hooks'
import { entityTypeKeys } from 'data/entity-types/keys'
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
import { RowEditor, ColumnEditor, TableEditor } from '.'
import { RowEditor, ColumnEditor, TableEditor, SpreadsheetImport } from '.'
import { ImportContent } from './TableEditor/TableEditor.types'
import {
ColumnField,
@@ -38,7 +38,7 @@ export interface SidePanelEditorProps {
row: any
column: any
}
sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector'
sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import'
isDuplicating?: boolean
closePanel: () => void
onRowCreated?: (row: Dictionary<any>) => void
@@ -74,9 +74,14 @@ const SidePanelEditor = ({
const [isClosingPanel, setIsClosingPanel] = useState<boolean>(false)
const tables = meta.tables.list()
const enumArrayColumns = (selectedTable?.columns ?? [])
.filter((column) => {
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
})
.map((column) => column.name)
const { project } = useProjectContext()
const { mutateAsync: createTableRow } = useTableRowCreateMutation()
const { mutateAsync: createTableRows } = useTableRowCreateMutation()
const { mutateAsync: updateTableRow } = useTableRowUpdateMutation({
async onMutate({ projectRef, table, configuration, payload }) {
closePanel()
@@ -146,16 +151,9 @@ const SidePanelEditor = ({
}
let saveRowError = false
// @ts-ignore
const enumArrayColumns = selectedTable.columns
.filter((column) => {
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
})
.map((column) => column.name)
if (isNewRecord) {
try {
const result = await createTableRow({
const result = await createTableRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
@@ -444,6 +442,54 @@ const SidePanelEditor = ({
resolve()
}
const onImportData = async (importContent: ImportContent) => {
if (!project || selectedTable === undefined) {
return console.error('no project or table selected')
}
const { file, rowCount, selectedHeaders, resolve } = importContent
const toastId = ui.setNotification({
category: 'loading',
message: `Adding ${rowCount.toLocaleString()} rows to ${selectedTable.name}`,
})
const { error }: any = await meta.insertRowsViaSpreadsheet(
file,
selectedTable,
selectedHeaders,
(progress: number) => {
ui.setNotification({
id: toastId,
progress,
category: 'loading',
message: `Adding ${rowCount.toLocaleString()} rows to ${selectedTable.name}`,
})
}
)
if (error) {
ui.setNotification({
error,
id: toastId,
category: 'error',
message: `Failed to import data: ${error.message}`,
})
resolve()
} else {
await Promise.all([
queryClient.invalidateQueries(
sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name])
),
])
ui.setNotification({
id: toastId,
category: 'success',
message: `Successfully imported ${rowCount} rows of data into ${selectedTable.name}`,
})
resolve()
closePanel()
}
}
const onClosePanel = () => {
if (isEdited) {
setIsClosingPanel(true)
@@ -499,6 +545,13 @@ const SidePanelEditor = ({
closePanel={onClosePanel}
onSelect={onSaveForeignRow}
/>
<SpreadsheetImport
visible={sidePanelKey === 'csv-import'}
selectedTable={selectedTableToEdit}
saveContent={onImportData}
closePanel={onClosePanel}
updateEditorDirty={setIsEdited}
/>
<ConfirmationModal
visible={isClosingPanel}
header="Confirm to close"
@@ -34,7 +34,7 @@ const SpreadSheetFileUpload: FC<Props> = ({
}
return (
<div className="space-y-10">
<div className="space-y-4">
<div>
<p className="mb-2 text-sm text-scale-1100">
Upload a CSV or TSV file. The first row should be the headers of the table, and your
@@ -42,13 +42,13 @@ const SpreadSheetFileUpload: FC<Props> = ({
<span className="text-code">-</span>) or underscores (<span className="text-code">_</span>
).
</p>
<p className="text-xs text-scale-900">
<p className="text-sm text-scale-1000">
Tip: Datetime columns should be formatted as YYYY-MM-DD HH:mm:ss
</p>
</div>
{!uploadedFile ? (
<div
className={`flex h-48 cursor-pointer items-center justify-center rounded-md border border-dashed dark:border-gray-500 ${
className={`flex h-48 cursor-pointer items-center justify-center rounded-md border border-dashed dark:border-gray-700 ${
isDraggedOver ? 'bg-gray-500' : ''
}`}
onDragOver={onDragOver}
@@ -56,15 +56,15 @@ const SpreadSheetFileUpload: FC<Props> = ({
onDrop={onDrop}
onClick={() => (uploadButtonRef.current as any)?.click()}
>
<p>
Drag and drop, or <span className="text-green-1000">browse</span> your files
<p className="text-sm">
Drag and drop, or <span className="text-brand-900">browse</span> your files
</p>
</div>
) : (
<div className="flex h-32 flex-col items-center justify-center space-y-2 rounded-md border border-dashed dark:border-gray-500">
<div className="flex h-28 flex-col items-center justify-center space-y-2 rounded-md border border-dashed dark:border-gray-700">
<div className="flex items-center space-x-2">
<IconFileText size={14} strokeWidth={1.5} />
<h3 className="text-base text-scale-1200">{uploadedFile.name}</h3>
<p className="text-sm text-scale-1200">{uploadedFile.name}</p>
</div>
{parseProgress === 100 ? (
<Button type="outline" onClick={removeUploadedFile}>
@@ -76,9 +76,9 @@ const SpreadSheetFileUpload: FC<Props> = ({
<SparkBar
value={parseProgress}
max={100}
type={'horizontal'}
barClass={'bg-green-500'}
labelBottom="Parsing file..."
type="horizontal"
barClass="bg-green-900"
labelBottom="Checking file..."
labelTop={`${parseProgress}%`}
/>
</div>
@@ -0,0 +1,71 @@
import clsx from 'clsx'
import { useState } from 'react'
import { SidePanel, Button, IconChevronDown, Collapsible } from 'ui'
import { SpreadsheetData } from './SpreadsheetImport.types'
interface SpreadSheetImportConfigurationProps {
spreadsheetData: SpreadsheetData
selectedHeaders: string[]
onToggleHeader: (header: string) => void
}
const SpreadsheetImportConfiguration = ({
spreadsheetData,
selectedHeaders,
onToggleHeader,
}: SpreadSheetImportConfigurationProps) => {
const [expandConfiguration, setExpandConfiguration] = useState(false)
return (
<Collapsible open={expandConfiguration} onOpenChange={setExpandConfiguration} className={''}>
<Collapsible.Trigger asChild>
<SidePanel.Content>
<div className="py-1 flex items-center justify-between">
<p className="text-sm">Configure import data</p>
<Button
type="text"
icon={
<IconChevronDown
size={18}
strokeWidth={2}
className={clsx('text-scale-1100', expandConfiguration && 'rotate-180')}
/>
}
className="px-1"
onClick={() => setExpandConfiguration(!expandConfiguration)}
/>
</div>
</SidePanel.Content>
</Collapsible.Trigger>
<Collapsible.Content>
<SidePanel.Content>
<div className="py-2 space-y-3">
<div>
<p className="text-sm text-scale-1100">Select which columns to import</p>
<p className="text-sm text-scale-1000">
By default, all columns are selected to be imported from your CSV
</p>
</div>
<div className="flex items-center flex-wrap gap-2 pl-0.5 pb-0.5">
{spreadsheetData.headers.map((header) => {
const isSelected = selectedHeaders.includes(header)
return (
<Button
key={header}
type={isSelected ? 'primary' : 'default'}
className={clsx('transition', isSelected ? 'opacity-100' : 'opacity-75')}
onClick={() => onToggleHeader(header)}
>
{header}
</Button>
)
})}
</div>
</div>
</SidePanel.Content>
</Collapsible.Content>
</Collapsible>
)
}
export default SpreadsheetImportConfiguration
@@ -15,7 +15,7 @@ const SpreadSheetTextInput: FC<Props> = ({ input, onInputChange }) => (
include any special characters other than hyphens (<code>-</code>) or underscores (
<code>_</code>).
</p>
<p className="text-xs text-scale-900">
<p className="text-sm text-scale-900">
Tip: Datetime columns should be formatted as YYYY-MM-DD HH:mm:ss
</p>
</div>
@@ -7,10 +7,7 @@ export const UPLOAD_FILE_TYPES = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]
export const UPLOAD_FILE_EXTENSIONS = [
'csv',
'tsv',
]
export const UPLOAD_FILE_EXTENSIONS = ['csv', 'tsv']
export const EMPTY_SPREADSHEET_DATA: SpreadsheetData = {
headers: [],
@@ -1,12 +1,12 @@
import { useCallback, useState, FC, useEffect } from 'react'
import { debounce, includes } from 'lodash'
import { SidePanel, Tabs, IconArrowRight, IconChevronRight } from 'ui'
import { debounce, includes, noop } from 'lodash'
import { SidePanel, Tabs } from 'ui'
import { PostgresTable } from '@supabase/postgres-meta'
import { useStore } from 'hooks'
import ActionBar from '../../ActionBar'
import ActionBar from '../ActionBar'
import SpreadSheetTextInput from './SpreadSheetTextInput'
import SpreadSheetFileUpload from './SpreadSheetFileUpload'
import SpreadsheetPreview from './SpreadsheetPreview'
import { SpreadsheetData } from './SpreadsheetImport.types'
import {
acceptedFileExtension,
@@ -14,31 +14,36 @@ import {
parseSpreadsheetText,
} from './SpreadsheetImport.utils'
import { UPLOAD_FILE_TYPES, EMPTY_SPREADSHEET_DATA } from './SpreadsheetImport.constants'
import { ImportContent } from '../TableEditor/TableEditor.types'
import SpreadsheetImportConfiguration from './SpreadSheetImportConfiguration'
import SpreadsheetImportPreview from './SpreadsheetImportPreview'
interface Props {
debounceDuration?: number
headers?: string[]
rows?: any[]
visible: boolean
saveContent: (prefillData: any) => void
selectedTable?: PostgresTable
saveContent: (prefillData: ImportContent) => void
closePanel: () => void
updateEditorDirty?: (value: boolean) => void
}
const SpreadsheetImport: FC<Props> = ({
visible = false,
debounceDuration = 250,
headers = [],
rows = [],
selectedTable,
saveContent,
closePanel,
visible = false,
updateEditorDirty = noop,
}) => {
const { ui } = useStore()
useEffect(() => {
if (visible) {
if (headers.length === 0) {
resetSpreadsheetImport()
}
if (visible && headers.length === 0) {
resetSpreadsheetImport()
}
}, [visible])
@@ -52,7 +57,13 @@ const SpreadsheetImport: FC<Props> = ({
columnTypeMap: {},
})
const [errors, setErrors] = useState<any>([])
const [expandedErrors, setExpandedErrors] = useState<string[]>([])
const [selectedHeaders, setSelectedHeaders] = useState<string[]>([])
const selectedTableColumns = (selectedTable?.columns ?? []).map((column) => column.name)
const incompatibleHeaders = selectedHeaders.filter(
(header) => !selectedTableColumns.includes(header)
)
const isCompatible = selectedTable !== undefined ? incompatibleHeaders.length === 0 : true
const onProgressUpdate = (progress: number) => {
setParseProgress(progress)
@@ -68,6 +79,7 @@ const SpreadsheetImport: FC<Props> = ({
message: 'Sorry! We only accept CSV or TSV file types, please upload another file.',
})
} else {
updateEditorDirty(true)
setUploadedFile(file)
const { headers, rowCount, columnTypeMap, errors, previewRows } = await parseSpreadsheet(
file,
@@ -83,6 +95,7 @@ const SpreadsheetImport: FC<Props> = ({
}
setErrors(errors)
setSelectedHeaders(headers)
setSpreadsheetData({ headers, rows: previewRows, rowCount, columnTypeMap })
}
event.target.value = ''
@@ -93,7 +106,7 @@ const SpreadsheetImport: FC<Props> = ({
setSpreadsheetData(EMPTY_SPREADSHEET_DATA)
setUploadedFile(null)
setErrors([])
setExpandedErrors([])
updateEditorDirty(false)
}
const readSpreadsheetText = async (text: string) => {
@@ -108,6 +121,7 @@ const SpreadsheetImport: FC<Props> = ({
})
}
setErrors(errors)
setSelectedHeaders(headers)
setSpreadsheetData({ headers, rows, rowCount: rows.length, columnTypeMap })
} else {
setSpreadsheetData(EMPTY_SPREADSHEET_DATA)
@@ -120,11 +134,28 @@ const SpreadsheetImport: FC<Props> = ({
handler(event.target.value)
}
const onSelectExpandError = (key: string) => {
if (expandedErrors.includes(key)) {
setExpandedErrors(expandedErrors.filter((error) => error !== key))
const onToggleHeader = (header: string) => {
const updatedSelectedHeaders = selectedHeaders.includes(header)
? selectedHeaders.filter((h) => h !== header)
: selectedHeaders.concat([header])
setSelectedHeaders(updatedSelectedHeaders)
}
const onConfirm = (resolve: () => void) => {
if (selectedHeaders.length === 0) {
ui.setNotification({
category: 'error',
message: 'Please select at least one header from your CSV',
})
resolve()
} else if (!isCompatible) {
ui.setNotification({
category: 'error',
message: 'The data that you are trying to import is incompatible with your table structure',
})
resolve()
} else {
setExpandedErrors(expandedErrors.concat([key]))
saveContent({ file: uploadedFile, ...spreadsheetData, selectedHeaders, resolve })
}
}
@@ -133,24 +164,34 @@ const SpreadsheetImport: FC<Props> = ({
size="large"
visible={visible}
align="right"
header="Add content to new table"
header={
selectedTable !== undefined ? (
<>
Add data to{' '}
<code className="text-sm">
{selectedTable.schema}.{selectedTable.name}
</code>
</>
) : (
'Add content to new table'
)
}
onCancel={() => closePanel()}
customFooter={
<ActionBar
backButtonLabel="Cancel"
applyButtonLabel="Save"
applyButtonLabel={selectedTable === undefined ? 'Save' : 'Import data'}
closePanel={closePanel}
applyFunction={() => {
saveContent({
file: uploadedFile,
...spreadsheetData,
})
}}
applyFunction={onConfirm}
/>
}
onInteractOutside={(event) => {
const isToast = (event.target as Element)?.closest('#toast')
if (isToast) event.preventDefault()
}}
>
<SidePanel.Content>
<div className="flex flex-col">
<div className="pt-6">
<Tabs block type="pills">
<Tabs.Panel id="fileUpload" label="Upload CSV">
<SpreadSheetFileUpload
@@ -165,70 +206,28 @@ const SpreadsheetImport: FC<Props> = ({
</Tabs.Panel>
</Tabs>
</div>
{spreadsheetData.headers.length > 0 && (
<div className="space-y-5 py-5">
<div className="space-y-2">
<div className="flex flex-col space-y-1">
<p>Content Preview</p>
<p className="text-scale-1000">
Your table will have {spreadsheetData.rowCount.toLocaleString()} rows and the
following {spreadsheetData.headers.length} columns.
</p>
<p className="text-scale-1000">
Here is a preview of your table (up to the first 20 columns and first 20 rows).
</p>
</div>
<SpreadsheetPreview headers={spreadsheetData.headers} rows={spreadsheetData.rows} />
</div>
{errors.length > 0 && (
<div className="space-y-2">
<div className="flex flex-col space-y-1">
<p>Issues found in spreadsheet</p>
<p className="text-scale-1000">
Your table can still be created nonetheless despite issues in the following
rows.
</p>
</div>
<div className="space-y-2">
{errors.map((error: any, idx: number) => {
const key = `import-error-${idx}`
const isExpanded = expandedErrors.includes(key)
return (
<div key={key} className="space-y-2">
<div className="flex items-center space-x-2">
<IconChevronRight
className={`transform cursor-pointer ${isExpanded ? 'rotate-90' : ''}`}
size={14}
onClick={() => onSelectExpandError(key)}
/>
<p className="w-14">Row: {error.row}</p>
<p>{error.message}</p>
{error.data?.__parsed_extra && (
<>
<IconArrowRight size={14} />
<p>Extra field(s):</p>
{error.data?.__parsed_extra.map((value: any, i: number) => (
<code key={i} className="text-sm">{value}</code>
))}
</>
)}
</div>
{isExpanded && (
<SpreadsheetPreview
headers={spreadsheetData.headers}
rows={[error.data]}
/>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)}
</SidePanel.Content>
{spreadsheetData.headers.length > 0 && (
<>
<div className="pt-4">
<SidePanel.Separator />
</div>
<SpreadsheetImportConfiguration
spreadsheetData={spreadsheetData}
selectedHeaders={selectedHeaders}
onToggleHeader={onToggleHeader}
/>
<SidePanel.Separator />
<SpreadsheetImportPreview
selectedTable={selectedTable}
spreadsheetData={spreadsheetData}
errors={errors}
selectedHeaders={selectedHeaders}
incompatibleHeaders={incompatibleHeaders}
/>
<SidePanel.Separator />
</>
)}
</SidePanel>
)
}
@@ -0,0 +1,200 @@
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import {
SidePanel,
Badge,
Button,
Collapsible,
IconChevronDown,
IconChevronRight,
IconArrowRight,
IconAlertCircle,
} from 'ui'
import { SpreadsheetData } from './SpreadsheetImport.types'
import SpreadsheetPreviewGrid from './SpreadsheetPreviewGrid'
import { PostgresTable } from '@supabase/postgres-meta'
const MAX_ROWS = 20
const MAX_HEADERS = 20
interface SpreadsheetImportPreviewProps {
selectedTable?: PostgresTable
spreadsheetData: SpreadsheetData
errors?: any[]
selectedHeaders: string[]
incompatibleHeaders: string[]
}
const SpreadsheetImportPreview = ({
selectedTable,
spreadsheetData,
errors = [],
selectedHeaders,
incompatibleHeaders,
}: SpreadsheetImportPreviewProps) => {
const [expandPreview, setExpandPreview] = useState(false)
const [expandedErrors, setExpandedErrors] = useState<string[]>([])
const { headers, rows } = spreadsheetData
const previewHeaders = headers
.filter((header) => selectedHeaders.includes(header))
.slice(0, MAX_HEADERS)
const previewRows = rows.slice(0, MAX_ROWS)
const isCompatible = selectedTable !== undefined ? incompatibleHeaders.length === 0 : true
useEffect(() => {
setExpandPreview(true)
}, [spreadsheetData])
const onSelectExpandError = (key: string) => {
if (expandedErrors.includes(key)) {
setExpandedErrors(expandedErrors.filter((error) => error !== key))
} else {
setExpandedErrors(expandedErrors.concat([key]))
}
}
return (
<Collapsible open={expandPreview} onOpenChange={setExpandPreview} className={''}>
<Collapsible.Trigger asChild>
<SidePanel.Content>
<div className="py-1 flex items-center justify-between">
<div className="flex items-center space-x-2">
<p className="text-sm">Preview data to be imported</p>
{!isCompatible && <Badge color="red">Data incompatible</Badge>}
{errors.length > 0 && <Badge color="yellow">{errors.length} issues found</Badge>}
</div>
<Button
type="text"
icon={
<IconChevronDown
size={18}
strokeWidth={2}
className={clsx('text-scale-1100', expandPreview && 'rotate-180')}
/>
}
className="px-1"
onClick={() => setExpandPreview(!expandPreview)}
/>
</div>
</SidePanel.Content>
</Collapsible.Trigger>
<Collapsible.Content>
<SidePanel.Content>
<div className="mb-4">
<p className="text-sm text-scale-1000">
{selectedTable === undefined
? `Your table will have ${spreadsheetData.rowCount.toLocaleString()} rows and the
following ${spreadsheetData.headers.length} columns.`
: `A total of ${spreadsheetData.rowCount.toLocaleString()} rows will be added to the table "${
selectedTable.name
}"`}
</p>
<p className="text-sm text-scale-1000">
Here is a preview of the data that will be added (up to the first 20 columns and first
20 rows).
</p>
</div>
<div className="mb-4">
{previewHeaders.length > 0 && previewRows.length > 0 ? (
<SpreadsheetPreviewGrid height={350} headers={previewHeaders} rows={previewRows} />
) : (
<div className="flex items-center justify-center py-4 border border-scale-600 rounded-md space-x-2">
<IconAlertCircle size={16} strokeWidth={1.5} className="text-scale-1000" />
<p className="text-sm text-scale-1000">
{previewHeaders.length === 0
? 'No headers have been selected'
: previewRows.length === 0
? 'Your CSV contains no data'
: ''}
</p>
</div>
)}
</div>
{(!isCompatible || errors.length > 0) && (
<div className="space-y-2 my-4">
<div className="flex flex-col space-y-1">
<p className="text-sm">Issues found in spreadsheet</p>
{isCompatible && (
<p className="text-sm text-scale-1000">
{selectedTable !== undefined
? 'This CSV can still be imported into your table despite issues in the following rows.'
: 'Your table can still be created nonetheless despite issues in the following rows.'}
</p>
)}
</div>
<div className="space-y-2">
{!isCompatible && (
<div className="space-y-2">
<div className="flex items-start space-x-2">
<div className="w-[14px] h-[14px] flex items-center justify-center translate-y-[3px]">
<div className="w-[6px] h-[6px] rounded-full bg-scale-1000" />
</div>
<p className="text-sm">
This CSV <span className="text-red-900">cannot</span> be imported into your
table due to incompatible headers:
<br />
The column{incompatibleHeaders.length > 1 ? 's' : ''}{' '}
{incompatibleHeaders.map((x) => `"${x}"`).join(', ')}{' '}
{incompatibleHeaders.length > 1 ? 'are' : 'is'} not present in your table
</p>
</div>
</div>
)}
{errors.map((error: any, idx: number) => {
const key = `import-error-${idx}`
const isExpanded = expandedErrors.includes(key)
return (
<div key={key} className="space-y-2">
<div
className="flex items-center space-x-2 cursor-pointer"
onClick={() => onSelectExpandError(key)}
>
{error.data !== undefined ? (
<IconChevronRight
size={14}
className={`transform ${isExpanded ? 'rotate-90' : ''}`}
/>
) : (
<div className="w-[14px] h-[14px] flex items-center justify-center">
<div className="w-[6px] h-[6px] rounded-full bg-scale-1000" />
</div>
)}
{error.data !== undefined && (
<p className="text-sm w-14">Row: {error.row}</p>
)}
<p className="text-sm">{error.message}</p>
{error.data?.__parsed_extra && (
<>
<IconArrowRight size={14} />
<p className="text-sm">Extra field(s):</p>
{error.data?.__parsed_extra.map((value: any, i: number) => (
<code key={i} className="text-xs">
{value}
</code>
))}
</>
)}
</div>
{error.data !== undefined && isExpanded && (
<SpreadsheetPreviewGrid
headers={spreadsheetData.headers}
rows={[error.data]}
/>
)}
</div>
)
})}
</div>
</div>
)}
</SidePanel.Content>
</Collapsible.Content>
</Collapsible>
)
}
export default SpreadsheetImportPreview
@@ -0,0 +1,55 @@
import clsx from 'clsx'
import DataGrid from '@supabase/react-data-grid'
const MAX_ROWS = 20
const MAX_HEADERS = 20
interface SpreadsheetPreviewGridProps {
headers: string[]
rows?: any[]
height?: number
}
const SpreadsheetPreviewGrid = ({
headers = [],
rows = [],
height,
}: SpreadsheetPreviewGridProps) => {
const previewHeaders = headers.slice(0, MAX_HEADERS)
const previewRows = rows.slice(0, MAX_ROWS)
return (
<DataGrid
columns={previewHeaders.map((header) => {
const columnValues = previewRows.map((row) => row[header]?.toString() ?? '')
const maxLength = Math.max(...columnValues.map((el) => el.length))
const maxWidth = maxLength > 20 ? 200 : maxLength * 10
return {
key: header,
name: header,
width: maxWidth,
resizable: true,
headerRenderer: () => (
<div className="flex items-center justify-center h-full">
<p className="text-xs">{header}</p>
</div>
),
formatter: ({ row }: { row: any }) => {
const isEmpty = !row[header]
return (
<span className={clsx('text-sm flex items-center', isEmpty && 'text-scale-1000')}>
{isEmpty ? 'NULL' : row[header]}
</span>
)
},
}
})}
rows={previewRows}
className="!border-l !border-r"
style={{ height: height || `${34 + 34 * (previewRows.length || 1)}px` }}
/>
)
}
export default SpreadsheetPreviewGrid
@@ -163,12 +163,10 @@ const ColumnManagement: FC<Props> = ({
</div>
{hasImportContent && (
<div className="my-2 opacity-75">
<p>
Your table will be created with {importContent?.rowCount?.toLocaleString()} rows and
the following {columns.length} columns.
</p>
</div>
<p className="text-sm text-scale-1000 my-2">
Your table will be created with {importContent?.rowCount?.toLocaleString()} rows and the
following {columns.length} columns.
</p>
)}
{primaryKeyColumns.length === 0 && (
@@ -1,43 +0,0 @@
import { FC } from 'react'
import { isUndefined } from 'lodash'
import DataGrid from '@supabase/react-data-grid'
interface Props {
headers: string[]
rows?: any[]
}
const MAX_ROWS = 20
const MAX_HEADERS = 20
const SpreadsheetPreview: FC<Props> = ({ headers = [], rows = [] }) => {
const previewHeaders = headers.slice(0, MAX_HEADERS)
const previewRows = rows.slice(0, MAX_ROWS)
return (
<DataGrid
columns={previewHeaders.map((header) => {
return {
key: header,
name: header,
width: header.length * 10,
resizable: true,
headerRenderer: () => (
<div className="flex items-center justify-center font-mono h-full">
<p className="text-sm">{header}</p>
</div>
),
formatter: ({ row }: { row: any }) => (
<span className="font-mono text-xs">
{isUndefined(row[header]) ? 'NULL' : row[header]}
</span>
),
}
})}
rows={previewRows}
className="!border-l !border-r"
style={{ height: `${34 + 34 * (previewRows.length || 1)}px` }}
/>
)
}
export default SpreadsheetPreview
@@ -1,3 +1,4 @@
import Link from 'next/link'
import { FC, useEffect, useState } from 'react'
import { isUndefined, isEmpty } from 'lodash'
import { Badge, Checkbox, SidePanel, Input, Alert, IconBookOpen, Button } from 'ui'
@@ -7,7 +8,7 @@ import { useStore } from 'hooks'
import ActionBar from '../ActionBar'
import HeaderTitle from './HeaderTitle'
import ColumnManagement from './ColumnManagement'
import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport'
import { SpreadsheetImport } from '../'
import { ColumnField, CreateTablePayload, UpdateTablePayload } from '../SidePanelEditor.types'
import { DEFAULT_COLUMNS } from './TableEditor.constants'
import { TableField, ImportContent } from './TableEditor.types'
@@ -19,7 +20,6 @@ import {
} from './TableEditor.utils'
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Link from 'next/link'
interface Props {
table?: PostgresTable
@@ -16,4 +16,6 @@ export interface ImportContent {
rowCount: number
rows: object[]
columnTypeMap: Dictionary<any>
selectedHeaders: string[]
resolve: () => void
}
@@ -55,9 +55,12 @@ export const generateTableFieldFromPostgresTable = (
}
export const formatImportedContentToColumnFields = (importContent: ImportContent) => {
const columnFields = importContent.headers.map((header: string) => {
const columnType = importContent.columnTypeMap[header]
return generateColumnField({ name: header, format: columnType })
})
const { headers, selectedHeaders, columnTypeMap } = importContent
const columnFields = headers
.filter((header) => selectedHeaders.includes(header))
.map((header) => {
const columnType = columnTypeMap[header]
return generateColumnField({ name: header, format: columnType })
})
return columnFields
}
@@ -2,7 +2,8 @@ import SidePanelEditor from './SidePanelEditor'
import RowEditor from './RowEditor/RowEditor'
import ColumnEditor from './ColumnEditor/ColumnEditor'
import TableEditor from './TableEditor/TableEditor'
import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport'
import ForeignKeySelector from './ForeignKeySelector/ForeignKeySelector'
export { RowEditor, ColumnEditor, TableEditor, ForeignKeySelector }
export { RowEditor, ColumnEditor, TableEditor, ForeignKeySelector, SpreadsheetImport }
export default SidePanelEditor
@@ -44,7 +44,7 @@ export interface TableGridEditorProps {
selectedTable: any // PostgresTable | SchemaView
/** Determines what side panel editor to show */
sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector'
sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import'
/** Toggles if we're duplicating a table */
isDuplicating: boolean
/** Selected entities if we're editing a row, column or table */
@@ -70,6 +70,7 @@ export interface TableGridEditorProps {
column: any
}) => void
onClosePanel: () => void
onImportData: () => void
}
const TableGridEditor = ({
@@ -93,6 +94,7 @@ const TableGridEditor = ({
onExpandJSONEditor = noop,
onEditForeignKeyColumnValue = noop,
onClosePanel = noop,
onImportData = noop,
}: TableGridEditorProps) => {
const { meta, ui, vault } = useStore()
const router = useRouter()
@@ -381,6 +383,7 @@ const TableGridEditor = ({
onAddRow={onAddRow}
updateTableRow={updateTableRow}
onEditRow={onEditRow}
onImportData={onImportData}
onError={onError}
onSqlQuery={onSqlQuery}
onExpandJSONEditor={onExpandJSONEditor}
+12 -1
View File
@@ -44,7 +44,7 @@ const TableEditorPage: NextPageWithLayout = () => {
const [selectedTableToDelete, setSelectedTableToDelete] = useState<PostgresTable>()
const [sidePanelKey, setSidePanelKey] = useState<
'row' | 'column' | 'table' | 'json' | 'foreign-row-selector'
'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' | 'csv-import'
>()
const [selectedRowToEdit, setSelectedRowToEdit] = useState<Dictionary<any>>()
const [selectedColumnToEdit, setSelectedColumnToEdit] = useState<PostgresColumn>()
@@ -137,6 +137,16 @@ const TableEditorPage: NextPageWithLayout = () => {
setSelectedValueForJsonEdit({ column, row, jsonString: JSON.stringify(row[column]) || '' })
}
const onImportData = () => {
if (id) {
setSidePanelKey('csv-import')
const table = meta.tables.byId(id)
setSelectedTableToEdit(table)
} else {
console.error('Table ID not found')
}
}
const onEditForeignKeyColumnValue = ({
foreignKey,
row,
@@ -280,6 +290,7 @@ const TableEditorPage: NextPageWithLayout = () => {
onEditForeignKeyColumnValue={onEditForeignKeyColumnValue}
onClosePanel={onClosePanel}
theme={ui.themeOption == 'dark' ? 'dark' : 'light'}
onImportData={onImportData}
/>
<ConfirmationModal
danger
+15 -2
View File
@@ -129,7 +129,12 @@ export interface IMetaStore {
columns: ColumnField[],
isRealtimeEnabled: boolean
) => any
insertRowsViaSpreadsheet: (
file: any,
table: PostgresTable,
selectedHeaders: string[],
onProgressUpdate: (progress: number) => void
) => void
setProjectDetails: (details: { ref: string; connectionString?: string }) => void
}
export default class MetaStore implements IMetaStore {
@@ -652,6 +657,7 @@ export default class MetaStore implements IMetaStore {
const { error }: any = await this.insertRowsViaSpreadsheet(
importContent.file,
table,
importContent.selectedHeaders,
(progress: number) => {
this.rootStore.ui.setNotification({
id: toastId,
@@ -834,6 +840,7 @@ export default class MetaStore implements IMetaStore {
async insertRowsViaSpreadsheet(
file: any,
table: PostgresTable,
selectedHeaders: string[],
onProgressUpdate: (progress: number) => void
) {
let chunkNumber = 0
@@ -849,9 +856,15 @@ export default class MetaStore implements IMetaStore {
chunk: async (results: any, parser: any) => {
parser.pause()
const formattedData = results.data.map((row: any) => {
const formattedRow: any = {}
selectedHeaders.forEach((header) => (formattedRow[header] = row[header]))
return formattedRow
})
const insertQuery = new Query()
.from(table.name, table.schema)
.insert(results.data)
.insert(formattedData)
.toSql()
const res = await this.query(insertQuery)
@@ -1,4 +1,4 @@
import { inferColumnType } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/SpreadsheetImport/SpreadsheetImport.utils'
import { inferColumnType } from 'components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils'
describe('SpreadsheedImport.utils: inferColumnType', () => {
test('should default column type to text if no rows to infer from', () => {