Files
supabase/apps/studio/components/grid/hooks/useTableRowOperations.ts
Ali Waseem 6be596ea34 feat: add user preference to enable queue operations (#44366)
## 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?

- Remove queue operations from feature preview into settings
- Refactor dashboard settings 
- Resolves DEPR-434

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Dashboard settings panel in Account preferences with toggles for
Inline Editor and Queue Operations; “Dashboard” added to project
Configuration.

* **Removed**
* Old Inline Editor settings UI and the Queue Operations feature-preview
UI removed.

* **Refactor**
* Consolidated dashboard preferences into a single settings surface;
banners and actions now navigate to preferences; account/preferences
layouts and back-navigation behavior adjusted for platform vs
self-hosted.

* **Tests**
* Added tests for settings UI, menu generation, redirects, and
local-storage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-04-06 13:52:53 +00:00

254 lines
8.3 KiB
TypeScript

import { QueryKey, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
import type { PendingAddRow } from '../types'
import type { SupaRow } from '@/components/grid/types'
import {
queueCellEditWithOptimisticUpdate,
queueRowAddWithOptimisticUpdate,
queueRowDeletesWithOptimisticUpdate,
} from '@/components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
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 { useGetImpersonatedRoleState } from '@/state/role-impersonation-state'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
import type { Dictionary } 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,
}
}