mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## Context Related to this previous PR [here](https://github.com/supabase/supabase/pull/42321) Table Editor: Adding a CTA to the `HighQueryCost` UI to allow users to proceed with fetching data despite the high query cost warning, to prevent completely blocking the users from their workflows (realised that certain heavy queries are required and this safeguard shouldn't be creating dead-ends for users) <img width="1159" height="264" alt="image" src="https://github.com/user-attachments/assets/5fa01f7f-4442-4349-91f2-f4275e177f89" /> Clicking "Load more" will open a confirmation dialog, in which proceeding to load the data will thereafter suppress this preflight check for the table, for the rest of the browser session <img width="450" height="305" alt="image" src="https://github.com/user-attachments/assets/d3197a5d-a861-47a8-95da-e157972ce092" /> ## Other changes - Also bumped the query cost threshold from 100,000 to 200,000 - the former might have been too aggressive 😓 - (Unrelated) Added query cost tooltip for cron jobs high query cost warning <img width="450" height="230" alt="image" src="https://github.com/user-attachments/assets/d2c66972-7c4c-4f99-818c-e90a0991c2f5" />
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import type { PostgresColumn } from '@supabase/postgres-meta'
|
|
import { useConstant } from 'common'
|
|
import type { SupaRow } from 'components/grid/types'
|
|
import {
|
|
resolveDeleteRowConflicts,
|
|
resolveEditCellConflicts,
|
|
upsertOperation,
|
|
} from 'components/grid/utils/queueConflictResolution'
|
|
import { generateTableChangeKey } from 'components/grid/utils/queueOperationUtils'
|
|
import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types'
|
|
import type { EditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types'
|
|
import type { TableField } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types'
|
|
import { createContext, PropsWithChildren, useContext } from 'react'
|
|
import type { Dictionary } from 'types'
|
|
import { proxy, useSnapshot } from 'valtio'
|
|
|
|
import {
|
|
NewQueuedOperation,
|
|
QueuedOperationType,
|
|
type OperationQueueState,
|
|
type QueueStatus,
|
|
} from './table-editor-operation-queue.types'
|
|
|
|
export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100
|
|
|
|
type ForeignKeyState = {
|
|
foreignKey: ForeignKey
|
|
row: Dictionary<any>
|
|
column: PostgresColumn
|
|
}
|
|
|
|
export type SidePanel =
|
|
| { type: 'cell'; value?: { column: string; row: Dictionary<any> } }
|
|
| { type: 'row'; row?: Dictionary<any> }
|
|
| { type: 'column'; column?: PostgresColumn }
|
|
| { type: 'table'; mode: 'new' | 'edit' | 'duplicate'; templateData?: Partial<TableField> }
|
|
| { type: 'schema'; mode: 'new' | 'edit' }
|
|
| { type: 'json'; jsonValue: EditValue }
|
|
| {
|
|
type: 'foreign-row-selector'
|
|
foreignKey: ForeignKeyState
|
|
}
|
|
| { type: 'csv-import'; file?: File }
|
|
| { type: 'operation-queue' }
|
|
|
|
export type ConfirmationDialog =
|
|
| { type: 'table'; isDeleteWithCascade: boolean }
|
|
| { type: 'column'; column: PostgresColumn; isDeleteWithCascade: boolean }
|
|
// [Joshen] Just FYI callback, numRows, allRowsSelected is a temp workaround so that
|
|
// DeleteConfirmationDialog can trigger dispatch methods after the successful deletion of rows.
|
|
// Once we deprecate react tracked and move things to valtio, we can remove this.
|
|
| {
|
|
type: 'row'
|
|
rows: SupaRow[]
|
|
numRows?: number
|
|
allRowsSelected?: boolean
|
|
callback?: () => void
|
|
}
|
|
|
|
export type UIState =
|
|
| {
|
|
open: 'none'
|
|
}
|
|
| {
|
|
open: 'side-panel'
|
|
sidePanel: SidePanel
|
|
}
|
|
| {
|
|
open: 'confirmation-dialog'
|
|
confirmationDialog: ConfirmationDialog
|
|
}
|
|
|
|
/**
|
|
* Global table editor state for the table editor across multiple tables.
|
|
* See ./table-editor-table.tsx for table specific state.
|
|
*/
|
|
export const createTableEditorState = () => {
|
|
const state = proxy({
|
|
rowsPerPage: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE,
|
|
setRowsPerPage: (rowsPerPage: number) => {
|
|
state.rowsPerPage = rowsPerPage
|
|
},
|
|
|
|
ui: { open: 'none' } as UIState,
|
|
get sidePanel() {
|
|
return state.ui.open === 'side-panel' ? state.ui.sidePanel : undefined
|
|
},
|
|
get confirmationDialog() {
|
|
return state.ui.open === 'confirmation-dialog' ? state.ui.confirmationDialog : undefined
|
|
},
|
|
|
|
closeSidePanel: () => {
|
|
state.ui = { open: 'none' }
|
|
},
|
|
closeConfirmationDialog: () => {
|
|
state.ui = { open: 'none' }
|
|
},
|
|
|
|
onAddSchema: () => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'schema', mode: 'new' },
|
|
}
|
|
},
|
|
|
|
/* Tables */
|
|
onAddTable: (templateData?: Partial<TableField>) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'table', mode: 'new', templateData },
|
|
}
|
|
},
|
|
onEditTable: () => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'table', mode: 'edit' },
|
|
}
|
|
},
|
|
onDuplicateTable: () => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'table', mode: 'duplicate' },
|
|
}
|
|
},
|
|
onDeleteTable: () => {
|
|
state.ui = {
|
|
open: 'confirmation-dialog',
|
|
confirmationDialog: { type: 'table', isDeleteWithCascade: false },
|
|
}
|
|
},
|
|
|
|
/* Columns */
|
|
onAddColumn: () => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'column' },
|
|
}
|
|
},
|
|
onEditColumn: (column: PostgresColumn) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'column', column },
|
|
}
|
|
},
|
|
onDeleteColumn: (column: PostgresColumn) => {
|
|
state.ui = {
|
|
open: 'confirmation-dialog',
|
|
confirmationDialog: { type: 'column', column, isDeleteWithCascade: false },
|
|
}
|
|
},
|
|
|
|
/* Rows */
|
|
onAddRow: () => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'row' },
|
|
}
|
|
},
|
|
onEditRow: (row: Dictionary<any>) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'row', row },
|
|
}
|
|
},
|
|
onDeleteRows: (
|
|
rows: SupaRow[],
|
|
meta: { numRows?: number; allRowsSelected: boolean; callback?: () => void } = {
|
|
numRows: 0,
|
|
allRowsSelected: false,
|
|
callback: () => {},
|
|
}
|
|
) => {
|
|
const { numRows, allRowsSelected, callback } = meta
|
|
state.ui = {
|
|
open: 'confirmation-dialog',
|
|
confirmationDialog: { type: 'row', rows, numRows, allRowsSelected, callback },
|
|
}
|
|
},
|
|
|
|
/* Misc */
|
|
onExpandJSONEditor: (jsonValue: EditValue) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'json', jsonValue },
|
|
}
|
|
},
|
|
onExpandTextEditor: (column: string, row: Dictionary<any>) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'cell', value: { column, row } },
|
|
}
|
|
},
|
|
onEditForeignKeyColumnValue: (foreignKey: ForeignKeyState) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'foreign-row-selector', foreignKey },
|
|
}
|
|
},
|
|
onImportData: (file?: File) => {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'csv-import', file },
|
|
}
|
|
},
|
|
toggleViewOperationQueue: () => {
|
|
if (state.ui.open === 'side-panel' && state.ui.sidePanel.type === 'operation-queue') {
|
|
state.closeSidePanel()
|
|
} else {
|
|
state.ui = {
|
|
open: 'side-panel',
|
|
sidePanel: { type: 'operation-queue' },
|
|
}
|
|
}
|
|
},
|
|
|
|
/* Utils */
|
|
toggleConfirmationIsWithCascade: (overrideIsDeleteWithCascade?: boolean) => {
|
|
if (
|
|
state.ui.open === 'confirmation-dialog' &&
|
|
(state.ui.confirmationDialog.type === 'column' ||
|
|
state.ui.confirmationDialog.type === 'table')
|
|
) {
|
|
state.ui.confirmationDialog.isDeleteWithCascade =
|
|
overrideIsDeleteWithCascade ?? !state.ui.confirmationDialog.isDeleteWithCascade
|
|
}
|
|
},
|
|
|
|
// ========================================================================
|
|
// Operation Queue
|
|
// ========================================================================
|
|
|
|
operationQueue: {
|
|
operations: [],
|
|
status: 'idle',
|
|
} as OperationQueueState,
|
|
|
|
/**
|
|
* Queue a new operation for later processing.
|
|
* If an operation with the same key already exists, it will be overwritten.
|
|
* Handles conflict resolution:
|
|
* - DELETE_ROW on a row: remove any pending EDIT_CELL ops for that row
|
|
* - EDIT_CELL on a row pending deletion: reject (console.warn)
|
|
* - EDIT_CELL on a newly added row: merge edit into ADD_ROW's rowData
|
|
* - DELETE_ROW on a newly added row: cancel both operations
|
|
*/
|
|
queueOperation: (operation: NewQueuedOperation) => {
|
|
const updateQueueStatus = () => {
|
|
if (state.operationQueue.operations.length === 0) {
|
|
state.operationQueue.status = 'idle'
|
|
} else if (state.operationQueue.status === 'idle') {
|
|
state.operationQueue.status = 'pending'
|
|
}
|
|
}
|
|
|
|
// Handle DELETE_ROW conflicts
|
|
if (operation.type === QueuedOperationType.DELETE_ROW) {
|
|
const result = resolveDeleteRowConflicts(state.operationQueue.operations, operation)
|
|
state.operationQueue.operations = result.filteredOperations
|
|
if (result.action === 'skip') {
|
|
updateQueueStatus()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Handle EDIT_CELL_CONTENT conflicts
|
|
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
|
const result = resolveEditCellConflicts(state.operationQueue.operations, operation)
|
|
if (result.action === 'reject') {
|
|
console.warn(result.reason)
|
|
return
|
|
}
|
|
if (result.action === 'merge') {
|
|
state.operationQueue.operations = result.updatedOperations
|
|
updateQueueStatus()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Normal upsert
|
|
const { operations } = upsertOperation(state.operationQueue.operations, operation)
|
|
state.operationQueue.operations = operations
|
|
updateQueueStatus()
|
|
},
|
|
|
|
/**
|
|
* Clear all operations from the queue
|
|
*/
|
|
clearQueue: () => {
|
|
state.operationQueue.operations = []
|
|
state.operationQueue.status = 'idle'
|
|
},
|
|
|
|
/**
|
|
* Remove a specific operation from the queue
|
|
*/
|
|
removeOperation: (operationId: string) => {
|
|
state.operationQueue.operations = state.operationQueue.operations.filter(
|
|
(op) => op.id !== operationId
|
|
)
|
|
if (state.operationQueue.operations.length === 0) {
|
|
state.operationQueue.status = 'idle'
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update the queue status
|
|
*/
|
|
setQueueStatus: (status: QueueStatus) => {
|
|
state.operationQueue.status = status
|
|
},
|
|
|
|
/**
|
|
* Check if there are any pending operations in the queue
|
|
*/
|
|
get hasPendingOperations(): boolean {
|
|
return state.operationQueue.operations.length > 0
|
|
},
|
|
|
|
hasPendingCellChange: (
|
|
tableId: number,
|
|
rowIdentifiers: Dictionary<unknown>,
|
|
columnName: string
|
|
): boolean => {
|
|
const key = generateTableChangeKey({
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId,
|
|
payload: {
|
|
columnName,
|
|
rowIdentifiers,
|
|
},
|
|
})
|
|
return state.operationQueue.operations.some((op) => op.id === key)
|
|
},
|
|
|
|
/**
|
|
* Toggle the preflight check behaviour for each table
|
|
*/
|
|
tablesToIgnorePreflightCheck: [] as number[],
|
|
setTableToIgnorePreflightCheck: (id: number) => {
|
|
const set = new Set<number>(state.tablesToIgnorePreflightCheck)
|
|
set.add(id)
|
|
state.tablesToIgnorePreflightCheck = [...set]
|
|
},
|
|
})
|
|
|
|
return state
|
|
}
|
|
|
|
export type TableEditorState = ReturnType<typeof createTableEditorState>
|
|
|
|
export const TableEditorStateContext = createContext<TableEditorState>(createTableEditorState())
|
|
|
|
export const TableEditorStateContextProvider = ({ children }: PropsWithChildren<{}>) => {
|
|
const state = useConstant(createTableEditorState)
|
|
|
|
return (
|
|
<TableEditorStateContext.Provider value={state}>{children}</TableEditorStateContext.Provider>
|
|
)
|
|
}
|
|
|
|
export const useTableEditorStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
|
|
const state = useContext(TableEditorStateContext)
|
|
return useSnapshot(state, options)
|
|
}
|