chore: refactor queue operations with write operations into a single hook (#44097)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Since queue operations is a feature users can opt-out of, we need to
make it cleaner to toggle between queuing vs straight edits. To do this,
refactor all the operations into a single hook and reference it in
places where we mutate the rows.

## Testing
- Test edit cells, rows, and deletes for non queue operations
- Test edit cells, rows, and deletes for queue operations, also double
check modifying the same rows that are not yet added

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ali WaseemandJoshen Lim authored and GitHub committed 2026-03-24 14:11:22 +00:00
1 parent c0e60adf3d
commit 917a686fee
14 files changed
+641 -282

No files matched your search

@@ -1,17 +1,17 @@
import { Maximize } from 'lucide-react'
import { useCallback, useState } from 'react'
import type { RenderEditCellProps } from 'react-data-grid'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
import { isValueTruncated } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { useGetCellValueMutation } from 'data/table-rows/get-cell-value-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Button, Popover, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui'
import { Maximize } from 'lucide-react'
import { useCallback, useState } from 'react'
import type { RenderEditCellProps } from 'react-data-grid'
import { toast } from 'sonner'
import { Button, cn, Popover, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { BlockKeys } from '../common/BlockKeys'
import { EmptyValue } from '../common/EmptyValue'
import { MonacoEditor } from '../common/MonacoEditor'
@@ -45,7 +45,7 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
const [isPopoverOpen, setIsPopoverOpen] = useState(true)
const [value, setValue] = useState<string | null>(initialValue)
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { isQueueEnabled } = useTableRowOperations()
const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation()
@@ -58,7 +58,7 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
}
const pkMatch = selectedTable.primary_keys.reduce((a, b) => {
return { ...a, [b.name]: (row as any)[b.name] }
return { ...a, [b.name]: row[b.name as keyof typeof row] }
}, {})
getCellValue(
@@ -172,8 +172,8 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
type="default"
htmlType="button"
onClick={() => {
if (isQueueOperationsEnabled) {
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
if (isQueueEnabled) {
saveChanges(null)
} else {
setIsConfirmNextModalOpen(true)
@@ -1,91 +1,22 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query'
import { SupaRow } from 'components/grid/types'
import { queueCellEditWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { DocsButton } from 'components/ui/DocsButton'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
import type { TableRowsData } from 'data/table-rows/table-rows-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { useCallback } from 'react'
import { RowsChangeData } from 'react-data-grid'
import { toast } from 'sonner'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import type { Dictionary } from 'types'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
export function useOnRowsChange(rows: SupaRow[]) {
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const snap = useTableEditorTableStateSnapshot()
const tableEditorSnap = useTableEditorStateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const { mutate: mutateUpdateTableRow } = useTableRowUpdateMutation({
async onMutate({ projectRef, table, configuration, payload }) {
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
await queryClient.cancelQueries({ queryKey })
const previousRowsQueries = queryClient.getQueriesData<TableRowsData>({ queryKey })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
return {
rows: old.rows.map((row) => {
// match primary keys
if (
Object.entries(row)
.filter(([key]) => primaryKeyColumns.has(key))
.every(([key, value]) => value === configuration.identifiers[key])
) {
return { ...row, ...payload }
}
return row
}),
}
})
return { previousRowsQueries }
},
onError(error, _variables, context) {
const { previousRowsQueries } = context as {
previousRowsQueries: [
QueryKey,
(
| {
result: any[]
}
| undefined
),
][]
}
previousRowsQueries.forEach(([queryKey, previousRows]) => {
if (previousRows) {
queryClient.setQueriesData({ queryKey }, previousRows)
}
queryClient.invalidateQueries({ queryKey })
})
toast.error(error?.message ?? error)
},
})
const { editCell } = useTableRowOperations()
return useCallback(
(_rows: SupaRow[], data: RowsChangeData<SupaRow, unknown>) => {
if (!project) return
const rowData = _rows[data.indexes[0]]
const previousRow = rows.find((x) => x.idx == rowData.idx)
const changedColumn = Object.keys(rowData).find(
@@ -127,44 +58,17 @@ export function useOnRowsChange(rows: SupaRow[]) {
})
}
const configuration = { identifiers }
if (isQueueOperationsEnabled) {
queueCellEditWithOptimisticUpdate({
queueOperation: tableEditorSnap.queueOperation,
tableId: snap.table.id,
table: snap.originalTable,
row: previousRow,
rowIdentifiers: identifiers,
columnName: changedColumn,
oldValue: previousRow[changedColumn],
newValue: rowData[changedColumn],
enumArrayColumns,
})
} else {
// Default behavior: immediately save the change
const updatedData = { [changedColumn]: rowData[changedColumn] }
mutateUpdateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: snap.originalTable,
configuration,
payload: updatedData,
enumArrayColumns,
roleImpersonationState: getImpersonatedRoleState(),
})
}
editCell({
tableId: snap.table.id,
table: snap.originalTable,
row: previousRow,
rowIdentifiers: identifiers,
columnName: changedColumn,
oldValue: previousRow[changedColumn],
newValue: rowData[changedColumn],
enumArrayColumns,
})
},
[
getImpersonatedRoleState,
isQueueOperationsEnabled,
mutateUpdateTableRow,
project,
rows,
snap.originalTable,
snap.table.id,
tableEditorSnap,
]
[editCell, rows, snap.originalTable, snap.table.id]
)
}
@@ -2,9 +2,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import {
@@ -225,11 +224,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
const { id: _id } = useParams()
const tableId = _id ? Number(_id) : undefined
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { deleteRows } = useTableRowOperations()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
@@ -278,22 +276,11 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
const rowIdxs = Array.from(snap.selectedRows) as number[]
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
// Queue delete operations directly if queue mode is enabled (and not all rows selected)
if (isQueueOperationsEnabled && !snap.allRowsSelected) {
queueRowDeletesWithOptimisticUpdate({
rows,
table: snap.originalTable,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
snap.resetSelectedRows()
return
}
// Fall back to confirmation dialog
tableEditorSnap.onDeleteRows(rows, {
deleteRows({
rows,
table: snap.originalTable,
allRowsSelected: snap.allRowsSelected,
numRows: snap.allRowsSelected ? totalRows : rows.length,
totalRows,
callback: () => {
snap.resetSelectedRows()
},
@@ -1,9 +1,8 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import {
@@ -219,11 +218,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
const { id: _id } = useParams()
const tableId = _id ? Number(_id) : undefined
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { deleteRows } = useTableRowOperations()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
@@ -272,22 +270,11 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
const rowIdxs = Array.from(snap.selectedRows) as number[]
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
// Queue delete operations directly if queue mode is enabled (and not all rows selected)
if (isQueueOperationsEnabled && !snap.allRowsSelected) {
queueRowDeletesWithOptimisticUpdate({
rows,
table: snap.originalTable,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
snap.resetSelectedRows()
return
}
const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size
tableEditorSnap.onDeleteRows(rows, {
deleteRows({
rows,
table: snap.originalTable,
allRowsSelected: snap.allRowsSelected,
numRows,
totalRows,
callback: () => {
snap.resetSelectedRows()
},
@@ -1,12 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
import type { SupaRow } from 'components/grid/types'
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import {
useIsQueueOperationsEnabled,
useIsTableFilterBarEnabled,
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsTableFilterBarEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { Copy, Edit, ListFilter, Trash } from 'lucide-react'
import { useCallback } from 'react'
import { Item, ItemParams, Menu } from 'react-contexify'
@@ -15,6 +9,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { copyToClipboard, DialogSectionSeparator } from 'ui'
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
import { formatClipboardValue } from '../../utils/common'
import { buildFilterFromCellValue, isComplexValue } from '../header/filter/FilterPopoverNew.utils'
@@ -25,11 +20,10 @@ type RowContextMenuProps = {
type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string>
export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
const { deleteRows } = useTableRowOperations()
function onDeleteRow(p: RowContextMenuItemProps) {
const rowIdx = p.props?.rowIdx
@@ -41,17 +35,7 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
return
}
if (isQueueOperationsEnabled) {
queueRowDeletesWithOptimisticUpdate({
rows: [row],
table: snap.originalTable,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
return
}
tableEditorSnap.onDeleteRows([row])
deleteRows({ rows: [row], table: snap.originalTable })
}
function onEditRowClick(p: RowContextMenuItemProps) {
@@ -0,0 +1,253 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query'
import type { SupaRow } from 'components/grid/types'
import {
queueCellEditWithOptimisticUpdate,
queueRowAddWithOptimisticUpdate,
queueRowDeletesWithOptimisticUpdate,
} from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { isTableLike, type Entity } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
import type { TableRowsData } from 'data/table-rows/table-rows-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import type { Dictionary } from 'types'
import type { PendingAddRow } from '../types'
export interface EditCellParams {
table: Entity
tableId: number
row: SupaRow
rowIdentifiers: Dictionary<unknown>
columnName: string
oldValue: unknown
newValue: unknown
enumArrayColumns?: string[]
/** When true, shows a success toast on non-queue save (used by side panel, not grid inline edits) */
onSuccess?: () => void
}
export interface AddRowParams {
table: Entity
tableId: number
rowData: PendingAddRow
enumArrayColumns?: string[]
}
export interface UpdateRowParams {
table: Entity
tableId: number
row: SupaRow
rowIdentifiers: Dictionary<unknown>
payload: Dictionary<unknown>
enumArrayColumns?: string[]
onSuccess?: () => void
}
export interface DeleteRowsParams {
rows: SupaRow[]
table: Entity
allRowsSelected?: boolean
totalRows?: number
callback?: () => void
}
export function useTableRowOperations() {
const isQueueEnabled = useIsQueueOperationsEnabled()
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
// Non-queue mutation for cell edits with optimistic updates
const { mutateAsync: mutateUpdateTableRow, isPending: isEditPending } = useTableRowUpdateMutation(
{
async onMutate({ projectRef, table, configuration, payload }) {
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
await queryClient.cancelQueries({ queryKey })
const previousRowsQueries = queryClient.getQueriesData<TableRowsData>({ queryKey })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
return {
rows: old.rows.map((row) => {
if (
Object.entries(row)
.filter(([key]) => primaryKeyColumns.has(key))
.every(([key, value]) => value === configuration.identifiers[key])
) {
return { ...row, ...payload }
}
return row
}),
}
})
return { previousRowsQueries }
},
onError(error, _variables, context) {
const { previousRowsQueries } = (context ?? { previousRowsQueries: [] }) as {
previousRowsQueries: [QueryKey, TableRowsData | undefined][]
}
previousRowsQueries.forEach(([queryKey, previousRows]) => {
if (previousRows) {
queryClient.setQueriesData<TableRowsData>({ queryKey }, previousRows)
}
queryClient.invalidateQueries({ queryKey })
})
toast.error(error?.message ?? error)
},
}
)
// Non-queue mutation for row creation
const { mutateAsync: mutateCreateTableRow } = useTableRowCreateMutation({
onSuccess() {
toast.success('Successfully created row')
},
})
const editCell = useCallback(
async (params: EditCellParams) => {
if (isQueueEnabled) {
queueCellEditWithOptimisticUpdate({
queueOperation: tableEditorSnap.queueOperation,
tableId: params.tableId,
table: params.table,
row: params.row,
rowIdentifiers: params.rowIdentifiers,
columnName: params.columnName,
oldValue: params.oldValue,
newValue: params.newValue,
enumArrayColumns: params.enumArrayColumns,
})
return
}
if (!project) return
const updatedData = { [params.columnName]: params.newValue }
await mutateUpdateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: params.table,
configuration: { identifiers: params.rowIdentifiers },
payload: updatedData,
enumArrayColumns: params.enumArrayColumns ?? [],
roleImpersonationState: getImpersonatedRoleState(),
})
params.onSuccess?.()
},
[isQueueEnabled, project, tableEditorSnap, mutateUpdateTableRow, getImpersonatedRoleState]
)
const updateRow = useCallback(
async (params: UpdateRowParams) => {
if (isQueueEnabled) {
// Queue individual cell edits per changed column
for (const columnName of Object.keys(params.payload)) {
queueCellEditWithOptimisticUpdate({
queueOperation: tableEditorSnap.queueOperation,
tableId: params.tableId,
table: params.table,
row: params.row,
rowIdentifiers: params.rowIdentifiers,
columnName,
oldValue: params.row[columnName],
newValue: params.payload[columnName],
enumArrayColumns: params.enumArrayColumns,
})
}
return
}
if (!project) return
await mutateUpdateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: params.table,
configuration: { identifiers: params.rowIdentifiers },
payload: params.payload,
enumArrayColumns: params.enumArrayColumns ?? [],
roleImpersonationState: getImpersonatedRoleState(),
})
params.onSuccess?.()
},
[isQueueEnabled, project, tableEditorSnap, mutateUpdateTableRow, getImpersonatedRoleState]
)
const addRow = useCallback(
async (params: AddRowParams) => {
// Only queue if the table has primary keys (required for queue conflict resolution)
const hasPrimaryKeys = isTableLike(params.table) && params.table.primary_keys.length > 0
if (isQueueEnabled && hasPrimaryKeys) {
queueRowAddWithOptimisticUpdate({
queueOperation: tableEditorSnap.queueOperation,
tableId: params.tableId,
table: params.table,
rowData: params.rowData,
enumArrayColumns: params.enumArrayColumns,
})
return
}
if (!project) return
await mutateCreateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: params.table,
payload: params.rowData,
enumArrayColumns: params.enumArrayColumns ?? [],
roleImpersonationState: getImpersonatedRoleState(),
})
},
[isQueueEnabled, project, tableEditorSnap, mutateCreateTableRow, getImpersonatedRoleState]
)
const deleteRows = useCallback(
(params: DeleteRowsParams) => {
// When queue is enabled and not all rows are selected, queue the deletes
if (isQueueEnabled && !params.allRowsSelected) {
queueRowDeletesWithOptimisticUpdate({
rows: params.rows,
table: params.table,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
params.callback?.()
return
}
// Otherwise, open the confirmation dialog
tableEditorSnap.onDeleteRows(params.rows, {
allRowsSelected: params.allRowsSelected ?? false,
numRows: params.allRowsSelected ? params.totalRows : params.rows.length,
callback: params.callback,
})
},
[isQueueEnabled, project, tableEditorSnap]
)
return {
editCell,
updateRow,
addRow,
deleteRows,
isQueueEnabled,
isEditPending,
}
}
@@ -265,8 +265,7 @@ describe('formatGridDataWithOperationValues', () => {
expect(result[1].name).toBe('Updated Bob')
})
test('last edit wins when multiple operations target the same row', () => {
// Each operation spreads from the original row, so only the last op's column change is preserved
test('multiple operations targeting the same row preserve all column changes', () => {
const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })]
const op1 = makeEditOp({
id: 'op-1',
@@ -290,8 +289,8 @@ describe('formatGridDataWithOperationValues', () => {
})
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
// The second op overwrites the first since both spread from the original row
expect(result[0].name).toBe('Alice')
// Both column edits should be preserved
expect(result[0].name).toBe('Updated')
expect(result[0].email).toBe('updated@test.com')
})
@@ -152,9 +152,9 @@ export const formatGridDataWithOperationValues = ({
operations.forEach((op) => {
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
const { rowIdentifiers, columnName, newValue } = op.payload
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
if (rowMatches) {
formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue }
const rowIdx = rows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers))
if (rowIdx !== -1) {
formattedRows[rowIdx] = { ...formattedRows[rowIdx], [columnName]: newValue }
}
} else if (op.type === QueuedOperationType.ADD_ROW) {
const { tempId, rowData } = op.payload
@@ -50,7 +50,7 @@ import {
} from './ColumnEditor.utils'
import ColumnForeignKey from './ColumnForeignKey'
import ColumnType from './ColumnType'
import HeaderTitle from './HeaderTitle'
import { HeaderTitle } from './HeaderTitle'
export interface ColumnEditorProps {
column?: Readonly<PostgresColumn>
@@ -1,25 +1,23 @@
import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
interface Props {
table: PostgresTable
column: PostgresColumn
}
// Need to fix for new column later
const HeaderTitle: React.FC<Props> = ({ table, column }) => {
export const HeaderTitle = ({ table, column }: Props) => {
if (!column) {
return (
<>
<span>Add new column to</span>
<code className="ml-1">{table.name}</code>
<code className="text-code-inline !text-sm ml-1">{table.name}</code>
</>
)
}
return (
<>
Update column <code>{column.name}</code> from <code>{column.table}</code>
Update column <code className="text-code-inline !text-sm">{column.name}</code> from{' '}
<code className="text-code-inline !text-sm">{column.table}</code>
</>
)
}
export default HeaderTitle
@@ -8,7 +8,7 @@ export const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => {
return (
<span>
{header}
{tableName && <span className="text-code font-mono">{tableName}</span>}
{tableName && <code className="text-code-inline !text-sm">{tableName}</code>}
</span>
)
}
@@ -2,11 +2,7 @@ import * as Sentry from '@sentry/nextjs'
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import {
queueCellEditWithOptimisticUpdate,
queueRowAddWithOptimisticUpdate,
} from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { databasePoliciesKeys } from 'data/database-policies/keys'
@@ -23,8 +19,6 @@ import { privilegeKeys } from 'data/privileges/keys'
import { tableEditorKeys } from 'data/table-editor/keys'
import { isTableLike, type Entity } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
import { tableKeys } from 'data/tables/keys'
import { RetrieveTableResult } from 'data/tables/table-retrieve-query'
import { getTables } from 'data/tables/tables-query'
@@ -36,7 +30,6 @@ import { useTrack } from 'lib/telemetry/track'
import { isEmpty, isUndefined, noop } from 'lodash'
import { useState } from 'react'
import { toast } from 'sonner'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot, type TableEditorState } from 'state/table-editor'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import type { Dictionary } from 'types'
@@ -202,10 +195,8 @@ export const SidePanelEditor = ({
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { updateRow, addRow, isEditPending } = useTableRowOperations()
const [isEdited, setIsEdited] = useState<boolean>(false)
@@ -241,16 +232,6 @@ export const SidePanelEditor = ({
})
.map((column) => column.name)
const { mutateAsync: createTableRows } = useTableRowCreateMutation({
onSuccess() {
toast.success('Successfully created row')
},
})
const { mutateAsync: updateTableRow, isPending: isUpdatingRow } = useTableRowUpdateMutation({
onSuccess() {
toast.success('Successfully updated row')
},
})
const { mutateAsync: createPublication } = useDatabasePublicationCreateMutation()
const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({
onError: () => {},
@@ -273,32 +254,13 @@ export const SidePanelEditor = ({
let saveRowError: Error | undefined
if (isNewRecord) {
// Queue the ADD_ROW operation if queue operations feature is enabled
if (isQueueOperationsEnabled && selectedTable.primary_keys.length > 0) {
queueRowAddWithOptimisticUpdate({
queueOperation: snap.queueOperation,
try {
await addRow({
tableId: selectedTable.id,
table: selectedTable as unknown as Entity,
rowData: payload,
enumArrayColumns,
})
// Close panel immediately without error
onComplete()
setIsEdited(false)
if (!configuration.createMore) snap.closeSidePanel()
return
}
try {
await createTableRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable,
payload,
enumArrayColumns,
roleImpersonationState: getImpersonatedRoleState(),
})
} catch (error: any) {
saveRowError = error
}
@@ -306,56 +268,24 @@ export const SidePanelEditor = ({
const hasChanges = !isEmpty(payload)
if (hasChanges) {
if (selectedTable.primary_keys.length > 0) {
// Queue the operation if queue operations feature is enabled
if (isQueueOperationsEnabled) {
const changedColumn = Object.keys(payload)[0]
if (!changedColumn) {
saveRowError = new Error('No changed column')
toast.error('No changed column')
onComplete(saveRowError)
return
}
const row = getRowFromSidePanel(snap.sidePanel)
const row = getRowFromSidePanel(snap.sidePanel)
if (!row) {
saveRowError = new Error('No row found')
toast.error('No row found')
onComplete(saveRowError)
return
}
const oldValue = row[changedColumn]
queueCellEditWithOptimisticUpdate({
queueOperation: snap.queueOperation,
tableId: selectedTable.id,
// Cast to Entity - the queue save mutation only uses id, name, schema
table: selectedTable as unknown as Entity,
row,
rowIdentifiers: configuration.identifiers,
columnName: changedColumn,
oldValue: oldValue,
newValue: payload[changedColumn],
enumArrayColumns,
})
// Close panel immediately without error
onComplete()
setIsEdited(false)
snap.closeSidePanel()
if (!row) {
saveRowError = new Error('No row found')
toast.error('No row found')
onComplete(saveRowError)
return
}
try {
await updateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable,
configuration,
await updateRow({
tableId: selectedTable.id,
table: selectedTable as unknown as Entity,
row,
rowIdentifiers: configuration.identifiers,
payload,
enumArrayColumns,
roleImpersonationState: getImpersonatedRoleState(),
onSuccess: () => toast.success('Successfully updated row'),
})
} catch (error: any) {
saveRowError = error
@@ -1053,7 +983,7 @@ export const SidePanelEditor = ({
? snap.sidePanel.foreignKey.foreignKey
: undefined
}
isSaving={isUpdatingRow}
isSaving={isEditPending}
closePanel={onClosePanel}
onSelect={onSaveForeignRow}
/>
@@ -1,6 +1,6 @@
import { expect, Page } from '@playwright/test'
import { createTable, dropTable } from '../utils/db/index.js'
import { createTable, dropTable, query } from '../utils/db/index.js'
import { test, withSetupCleanup } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
import { waitForTableToLoad } from '../utils/wait-for-response.js'
@@ -680,4 +680,79 @@ test.describe('Queue Table Operations', () => {
await page.getByRole('button', { name: `View ${tableName2}`, exact: true }).click()
await expect(page.getByRole('gridcell', { name: 'pending in table 2' })).toBeVisible()
})
test('editing multiple columns via side panel queues all changes', async ({ page, ref }) => {
const tableName = `${tableNamePrefix}_multi_col`
await using _ = await withSetupCleanup(
async () => {
await query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id bigint generated by default as identity primary key,
created_at timestamp with time zone null default now(),
first_name text,
last_name text
)`
)
await query(
`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`,
['Alice', 'Smith']
)
},
async () => {
await dropTable(tableName)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await enableQueueOperations(page)
await page.reload()
await waitForTableToLoad(page, ref)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible()
// Right-click to open context menu and edit the row
const cell = page.getByRole('gridcell', { name: 'Alice' })
await cell.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Edit row' }).click()
// Update both columns in the side panel
const firstNameInput = page.getByTestId('first_name-input')
await expect(firstNameInput).toBeVisible()
await firstNameInput.clear()
await firstNameInput.fill('Bob')
const lastNameInput = page.getByTestId('last_name-input')
await lastNameInput.clear()
await lastNameInput.fill('Jones')
await page.getByTestId('action-bar-save-row').click()
// Should queue 2 cell edits (one per changed column)
await expect(page.getByText('2 pending changes')).toBeVisible()
// Both values should be optimistically updated in the grid
await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'Jones' })).toBeVisible()
// Review the queued operations
await page.getByRole('button', { name: /Review/ }).click()
const sidePanel = page.getByRole('dialog')
await expect(sidePanel.getByText('2 cell edits')).toBeVisible()
// Save all changes
await sidePanel.getByRole('button', { name: /^Save/ }).click()
await expect(page.getByText('Changes saved successfully')).toBeVisible()
// Both columns should reflect the saved values
await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'Jones' })).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'Alice' })).not.toBeVisible()
await expect(page.getByRole('gridcell', { name: 'Smith' })).not.toBeVisible()
})
})
+242
View File
@@ -1234,6 +1234,248 @@ testRunner('table editor', () => {
await expect(page.getByRole('gridcell', { name: 'drag drop value 1' })).toBeVisible()
})
test('row insert via side panel saves immediately', async ({ page, ref }) => {
const tableName = 'pw_table_row_insert'
const columnName = 'name'
await using _ = await withSetupCleanup(
async () => {
await createTable(tableName, columnName)
},
async () => {
await dropTable(tableName)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await waitForTableToLoad(page, ref)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
// Open side panel to insert a new row
await page.getByTestId('table-editor-insert-new-row').click()
await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click()
await page.getByTestId(`${columnName}-input`).fill('immediate insert')
// Wait for the POST mutation to complete when saving
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByTestId('action-bar-save-row').click()
await insertPromise
// Should show success toast
await expect(
page.getByText('Successfully created row'),
'Success toast should appear after immediate row creation'
).toBeVisible({ timeout: 10000 })
// Row should be visible in the grid
await expect(
page.getByRole('gridcell', { name: 'immediate insert' }),
'Newly inserted row should be visible in the grid'
).toBeVisible()
// Should NOT show pending changes (queue is off)
await expect(
page.getByText('pending change'),
'No pending changes should appear when queue is disabled'
).not.toBeVisible()
})
test('row edit via side panel saves immediately', async ({ page, ref }) => {
const tableName = 'pw_table_row_edit'
const columnName = 'name'
await using _ = await withSetupCleanup(
async () => {
await createTable(tableName, columnName, [{ name: 'original value' }])
},
async () => {
await dropTable(tableName)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await waitForTableToLoad(page, ref)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible()
// Right-click to open context menu and edit the row
const cell = page.getByRole('gridcell', { name: 'original value' })
await cell.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Edit row' }).click()
// Update the value in the side panel
const input = page.getByTestId(`${columnName}-input`)
await expect(input).toBeVisible()
await input.clear()
await input.fill('updated value')
// Wait for the POST mutation to complete when saving
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByTestId('action-bar-save-row').click()
await updatePromise
// Updated value should be visible in the grid after immediate save
await expect(
page.getByRole('gridcell', { name: 'updated value' }),
'Updated value should be visible in the grid'
).toBeVisible()
// Original value should be gone
await expect(
page.getByRole('gridcell', { name: 'original value' }),
'Original value should no longer be visible'
).not.toBeVisible()
// Should NOT show pending changes (queue is off)
await expect(
page.getByText('pending change'),
'No pending changes should appear when queue is disabled'
).not.toBeVisible()
})
test('editing multiple columns via side panel saves all changes', async ({ page, ref }) => {
const tableName = 'pw_table_multi_col_edit'
await using _ = await withSetupCleanup(
async () => {
await query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id bigint generated by default as identity primary key,
created_at timestamp with time zone null default now(),
first_name text,
last_name text
)`
)
await query(
`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`,
['Alice', 'Smith']
)
},
async () => {
await dropTable(tableName)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await waitForTableToLoad(page, ref)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible()
// Right-click to open context menu and edit the row
const cell = page.getByRole('gridcell', { name: 'Alice' })
await cell.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Edit row' }).click()
// Update both columns in the side panel
const firstNameInput = page.getByTestId('first_name-input')
await expect(firstNameInput).toBeVisible()
await firstNameInput.clear()
await firstNameInput.fill('Bob')
const lastNameInput = page.getByTestId('last_name-input')
await lastNameInput.clear()
await lastNameInput.fill('Jones')
// Wait for the POST mutation to complete when saving
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByTestId('action-bar-save-row').click()
await updatePromise
// Both columns should reflect the updated values
await expect(
page.getByRole('gridcell', { name: 'Bob' }),
'First name should be updated to Bob'
).toBeVisible()
await expect(
page.getByRole('gridcell', { name: 'Jones' }),
'Last name should be updated to Jones'
).toBeVisible()
// Original values should be gone
await expect(
page.getByRole('gridcell', { name: 'Alice' }),
'Original first name should no longer be visible'
).not.toBeVisible()
await expect(
page.getByRole('gridcell', { name: 'Smith' }),
'Original last name should no longer be visible'
).not.toBeVisible()
})
test('row delete via context menu shows confirmation dialog', async ({ page, ref }) => {
const tableName = 'pw_table_row_delete'
const columnName = 'name'
await using _ = await withSetupCleanup(
async () => {
await createTable(tableName, columnName, [{ name: 'row to delete' }])
},
async () => {
await dropTable(tableName)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await waitForTableToLoad(page, ref)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await expect(page.getByRole('gridcell', { name: 'row to delete' })).toBeVisible()
// Right-click to open context menu and delete the row
const cell = page.getByRole('gridcell', { name: 'row to delete' })
await cell.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Delete row' }).click()
// In non-queue mode, a confirmation dialog should appear
const confirmDialog = page.getByRole('dialog', { name: 'Confirm to delete the selected row' })
await expect(
confirmDialog,
'Confirmation dialog should appear for non-queue row deletion'
).toBeVisible({ timeout: 10000 })
// Confirm the deletion
const deletePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await confirmDialog.getByRole('button', { name: 'Delete' }).click()
await deletePromise
// Row should be gone
await expect(
page.getByRole('gridcell', { name: 'row to delete' }),
'Deleted row should no longer be visible'
).not.toBeVisible()
// Should show 0 records
await expect(
page.getByText('0 records'),
'Table should show 0 records after deletion'
).toBeVisible()
// Should NOT show pending changes (queue is off)
await expect(
page.getByText('pending change'),
'No pending changes should appear when queue is disabled'
).not.toBeVisible()
})
test('create a table in a single transaction', async ({ page, ref }) => {
const tableName = 'pw_table_create_transaction'