mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feature: batch and save operations for cell content updates (#42120)
* added initial queue operations and feature flag * updated types * added dirty state tracking on columns * updated queue operations * updated operation types and queue * updated spacing * removed on cancel * updated to support saving * updated to include eye details * updated spacing for orders * updated to support shortcuts * added feature preview * updated to unify queue methods * added key generation * used unique keys rather than random uuid * updated based on code review * operation key * updated handle cancel * updated remove operation button * updated views for toast * updated logic to support optimistic updates * updated types * code cleanup: remove LLM slop * updated PR bug * updated preview for logout * updated based on code review * removed use effect as it was causing problems * fixed toast mounting away from sql editor * removed toast for dedicated action bar * cleaned up logic * updated queue operations * renamed method * updated name for types * updated comment * fixed code rabbit solution * added check for changed column * added tests
This commit is contained in:
1 parent
f6441d8d60
commit
ea1b95d29b
25 files changed
+1369
-26
No files matched your search
@@ -12,6 +12,7 @@ import { EMPTY_ARR } from 'lib/void'
|
||||
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
|
||||
|
||||
import { Shortcuts } from './components/common/Shortcuts'
|
||||
import { Footer } from './components/footer/Footer'
|
||||
@@ -20,12 +21,14 @@ import { Header, HeaderProps } from './components/header/Header'
|
||||
import { HeaderNew } from './components/header/HeaderNew'
|
||||
import { RowContextMenu } from './components/menu/RowContextMenu'
|
||||
import { GridProps } from './types'
|
||||
import { reapplyOptimisticUpdates } from './utils/queueOperationUtils'
|
||||
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useTableFilter } from './hooks/useTableFilter'
|
||||
import { useTableSort } from './hooks/useTableSort'
|
||||
import { validateMsSqlSorting } from './MsSqlValidation'
|
||||
import { useIsQueueOperationsEnabled } from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
|
||||
export const SupabaseGrid = ({
|
||||
customHeader,
|
||||
@@ -39,6 +42,9 @@ export const SupabaseGrid = ({
|
||||
const { id: _id } = useParams()
|
||||
const tableId = _id ? Number(_id) : undefined
|
||||
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
@@ -65,6 +71,7 @@ export const SupabaseGrid = ({
|
||||
isError,
|
||||
isPending: isLoading,
|
||||
isRefetching,
|
||||
dataUpdatedAt,
|
||||
} = useTableRowsQuery(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
@@ -91,6 +98,34 @@ export const SupabaseGrid = ({
|
||||
if (!mounted) setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Re-apply optimistic updates when table data is loaded/refetched
|
||||
// This ensures pending changes remain visible when switching tabs or after data refresh
|
||||
useEffect(() => {
|
||||
if (
|
||||
isSuccess &&
|
||||
project?.ref &&
|
||||
tableId &&
|
||||
isQueueOperationsEnabled &&
|
||||
tableEditorSnap.hasPendingOperations
|
||||
) {
|
||||
reapplyOptimisticUpdates({
|
||||
queryClient,
|
||||
projectRef: project.ref,
|
||||
tableId,
|
||||
operations: tableEditorSnap.operationQueue.operations as readonly QueuedOperation[],
|
||||
})
|
||||
}
|
||||
}, [
|
||||
isSuccess,
|
||||
dataUpdatedAt,
|
||||
project?.ref,
|
||||
tableId,
|
||||
isQueueOperationsEnabled,
|
||||
tableEditorSnap.hasPendingOperations,
|
||||
tableEditorSnap.operationQueue.operations,
|
||||
queryClient,
|
||||
])
|
||||
|
||||
const rows = data?.rows ?? EMPTY_ARR
|
||||
|
||||
const HeaderComponent = newFilterBarEnabled ? HeaderNew : Header
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RenderEditCellProps } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
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'
|
||||
@@ -44,6 +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 { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation()
|
||||
|
||||
@@ -169,7 +171,14 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
|
||||
size="tiny"
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={() => setIsConfirmNextModalOpen(true)}
|
||||
onClick={() => {
|
||||
if (isQueueOperationsEnabled) {
|
||||
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
|
||||
saveChanges(null)
|
||||
} else {
|
||||
setIsConfirmNextModalOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set to NULL
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Eye } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import {
|
||||
useOperationQueueShortcuts,
|
||||
getModKey,
|
||||
} from 'components/grid/hooks/useOperationQueueShortcuts'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
|
||||
|
||||
export const SaveQueueActionBar = () => {
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const { handleSave } = useOperationQueueActions()
|
||||
|
||||
const operationCount = snap.operationQueue.operations.length
|
||||
const isSaving = snap.operationQueue.status === 'saving'
|
||||
const isOperationQueuePanelOpen = snap.sidePanel?.type === 'operation-queue'
|
||||
|
||||
const isVisible =
|
||||
isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen
|
||||
|
||||
useOperationQueueShortcuts({
|
||||
enabled: isQueueOperationsEnabled && snap.hasPendingOperations,
|
||||
onSave: handleSave,
|
||||
onTogglePanel: () => snap.onViewOperationQueue(),
|
||||
isSaving,
|
||||
hasOperations: operationCount > 0,
|
||||
})
|
||||
|
||||
const modKey = getModKey()
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
|
||||
>
|
||||
<div className="flex items-center gap-8 px-4 py-3 bg-surface-100 border rounded-lg shadow-lg">
|
||||
<span className="text-sm text-foreground">
|
||||
{operationCount} pending change{operationCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => snap.onViewOperationQueue()}
|
||||
className="text-foreground-light hover:text-foreground transition-colors flex items-center"
|
||||
aria-label="View Details"
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}.`}</span>
|
||||
</button>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save
|
||||
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}S`}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
if (typeof document === 'undefined') return null
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, memo, Ref, useRef } from 'react'
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import { forwardRef, memo, Ref, useMemo, useRef } from 'react'
|
||||
import DataGrid, { CalculatedColumn, DataGridHandle } from 'react-data-grid'
|
||||
import { ref as valtioRef } from 'valtio'
|
||||
|
||||
@@ -7,6 +8,7 @@ import { handleCopyCell } from 'components/grid/SupabaseGrid.utils'
|
||||
import { formatForeignKeys } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
|
||||
import { isTableLike } from 'data/table-editor/table-editor-types'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
@@ -19,14 +21,15 @@ import type { GridProps, SupaRow } from '../../types'
|
||||
import { useOnRowsChange } from './Grid.utils'
|
||||
import { GridError } from './GridError'
|
||||
import RowRenderer from './RowRenderer'
|
||||
import { QueuedOperationType } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
const rowKeyGetter = (row: SupaRow) => {
|
||||
return row?.idx ?? -1
|
||||
}
|
||||
|
||||
interface IGrid extends GridProps {
|
||||
rows: any[]
|
||||
error: any
|
||||
rows: SupaRow[]
|
||||
error: Error | null
|
||||
isDisabled?: boolean
|
||||
isLoading: boolean
|
||||
isSuccess: boolean
|
||||
@@ -65,9 +68,17 @@ export const Grid = memo(
|
||||
snap.setSelectedRows(selectedRows)
|
||||
}
|
||||
|
||||
const selectedCellRef = useRef<{ rowIdx: number; row: any; column: any } | null>(null)
|
||||
const selectedCellRef = useRef<{
|
||||
rowIdx: number
|
||||
row: SupaRow
|
||||
column: CalculatedColumn<SupaRow, unknown>
|
||||
} | null>(null)
|
||||
|
||||
function onSelectedCellChange(args: { rowIdx: number; row: any; column: any }) {
|
||||
function onSelectedCellChange(args: {
|
||||
rowIdx: number
|
||||
row: SupaRow
|
||||
column: CalculatedColumn<SupaRow, unknown>
|
||||
}) {
|
||||
selectedCellRef.current = args
|
||||
snap.setSelectedCellPosition({ idx: args.column.idx, rowIdx: args.rowIdx })
|
||||
}
|
||||
@@ -122,20 +133,60 @@ export const Grid = memo(
|
||||
return fk !== undefined ? formatForeignKeys([fk])[0] : undefined
|
||||
}
|
||||
|
||||
function onRowDoubleClick(row: any, column: any) {
|
||||
function onRowDoubleClick(row: SupaRow, column: { name: string }) {
|
||||
const foreignKey = getColumnForeignKey(column.name)
|
||||
|
||||
if (foreignKey) {
|
||||
tableEditorSnap.onEditForeignKeyColumnValue({
|
||||
foreignKey,
|
||||
row,
|
||||
column,
|
||||
column: column as unknown as PostgresColumn,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeAllFilters = () => onApplyFilters([])
|
||||
|
||||
// Compute columns with cellClass for dirty cells
|
||||
// This needs to be computed at render time so it reacts to operation queue changes
|
||||
const columnsWithDirtyCellClass = useMemo(() => {
|
||||
const primaryKeys = isTableLike(snap.originalTable) ? snap.originalTable.primary_keys : []
|
||||
const pendingOperations = tableEditorSnap.operationQueue.operations
|
||||
|
||||
// If no pending operations, return columns as-is
|
||||
if (pendingOperations.length === 0) {
|
||||
return snap.gridColumns as CalculatedColumn<SupaRow, unknown>[]
|
||||
}
|
||||
|
||||
return (snap.gridColumns as CalculatedColumn<SupaRow, unknown>[]).map((col) => {
|
||||
// Skip special columns like select column
|
||||
if (col.key === 'select-row' || col.key === 'add-column') {
|
||||
return col
|
||||
}
|
||||
|
||||
return {
|
||||
...col,
|
||||
cellClass: (row: SupaRow) => {
|
||||
// Build row identifiers from primary keys
|
||||
const rowIdentifiers: Record<string, unknown> = {}
|
||||
for (const pk of primaryKeys) {
|
||||
rowIdentifiers[pk.name] = row[pk.name]
|
||||
}
|
||||
|
||||
// Check if this cell has pending changes
|
||||
// Since we are checking for cell changes, we need to use the EDIT_CELL_CONTENT type
|
||||
const isDirty = tableEditorSnap.hasPendingCellChange(
|
||||
QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
snap.table.id,
|
||||
rowIdentifiers,
|
||||
col.key
|
||||
)
|
||||
return isDirty ? 'rdg-cell--dirty' : undefined
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [snap.gridColumns, snap.originalTable, snap.table.id, tableEditorSnap])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col relative transition-colors', containerClass)}
|
||||
@@ -245,7 +296,7 @@ export const Grid = memo(
|
||||
ref={ref}
|
||||
className={`${gridClass} flex-grow`}
|
||||
rowClass={rowClass}
|
||||
columns={snap.gridColumns as CalculatedColumn<any, any>[]}
|
||||
columns={columnsWithDirtyCellClass}
|
||||
rows={rows ?? []}
|
||||
renderers={{ renderRow: RowRenderer }}
|
||||
rowKeyGetter={rowKeyGetter}
|
||||
@@ -254,7 +305,11 @@ export const Grid = memo(
|
||||
onRowsChange={onRowsChange}
|
||||
onSelectedCellChange={onSelectedCellChange}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
onCellDoubleClick={(props) => onRowDoubleClick(props.row, props.column)}
|
||||
onCellDoubleClick={(props) => {
|
||||
if (typeof props.column.name === 'string') {
|
||||
onRowDoubleClick(props.row, { name: props.column.name })
|
||||
}
|
||||
}}
|
||||
onCellKeyDown={handleCopyCell}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { RowsChangeData } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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'
|
||||
@@ -15,11 +17,14 @@ import { DOCS_URL } from 'lib/constants'
|
||||
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'
|
||||
|
||||
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({
|
||||
@@ -88,8 +93,6 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
|
||||
if (!previousRow || !changedColumn) return
|
||||
|
||||
const updatedData = { [changedColumn]: rowData[changedColumn] }
|
||||
|
||||
const enumArrayColumns = snap.originalTable.columns
|
||||
?.filter((column) => {
|
||||
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
|
||||
@@ -106,7 +109,6 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
: previousRow[column.name]
|
||||
})
|
||||
|
||||
const configuration = { identifiers }
|
||||
if (Object.keys(identifiers).length === 0) {
|
||||
return toast('Unable to update row as table has no primary keys', {
|
||||
description: (
|
||||
@@ -123,16 +125,46 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
})
|
||||
}
|
||||
|
||||
mutateUpdateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: snap.originalTable,
|
||||
configuration,
|
||||
payload: updatedData,
|
||||
enumArrayColumns,
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
const configuration = { identifiers }
|
||||
|
||||
if (isQueueOperationsEnabled) {
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project.ref,
|
||||
tableId: snap.table.id,
|
||||
table: snap.originalTable,
|
||||
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(),
|
||||
})
|
||||
}
|
||||
},
|
||||
[getImpersonatedRoleState, mutateUpdateTableRow, project, rows, snap.originalTable]
|
||||
[
|
||||
getImpersonatedRoleState,
|
||||
isQueueOperationsEnabled,
|
||||
mutateUpdateTableRow,
|
||||
project,
|
||||
rows,
|
||||
snap.originalTable,
|
||||
snap.table.id,
|
||||
tableEditorSnap,
|
||||
queryClient,
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useOperationQueueSaveMutation } from 'data/table-rows/operation-queue-save-mutation'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
|
||||
|
||||
interface UseOperationQueueActionsOptions {
|
||||
onSaveSuccess?: () => void
|
||||
onCancelSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides save and cancel actions for the operation queue.
|
||||
* Consolidates the logic used by both the useSaveQueueToast hook and OperationQueueSidePanel.
|
||||
*/
|
||||
export function useOperationQueueActions(options: UseOperationQueueActionsOptions = {}) {
|
||||
const { onSaveSuccess, onCancelSuccess } = options
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
|
||||
const { mutate: saveOperationQueue, isPending: isMutationPending } =
|
||||
useOperationQueueSaveMutation({
|
||||
onSuccess: () => {
|
||||
snap.clearQueue()
|
||||
toast.success('Changes saved successfully')
|
||||
onSaveSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
snap.setQueueStatus('idle')
|
||||
toast.error(`Failed to save changes: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const isSaving = snap.operationQueue.status === 'saving' || isMutationPending
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!project) return
|
||||
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
if (operations.length === 0) return
|
||||
|
||||
snap.setQueueStatus('saving')
|
||||
|
||||
saveOperationQueue({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
operations,
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
}, [snap, project, saveOperationQueue, getImpersonatedRoleState])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
// Get unique table IDs from the queue before clearing
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
const tableIds = [...new Set(operations.map((op) => op.tableId))]
|
||||
|
||||
// Clear the queue and invalidate queries to revert optimistic updates
|
||||
snap.clearQueue()
|
||||
if (project) {
|
||||
// Invalidate queries for each table that had pending operations
|
||||
tableIds.forEach((tableId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableId),
|
||||
})
|
||||
})
|
||||
}
|
||||
onCancelSuccess?.()
|
||||
}, [snap, project, queryClient, onCancelSuccess])
|
||||
|
||||
return {
|
||||
handleSave,
|
||||
handleCancel,
|
||||
isSaving,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
import { detectOS } from 'lib/helpers'
|
||||
|
||||
export function getModKey() {
|
||||
const os = detectOS()
|
||||
return os === 'macos' ? '⌘' : 'Ctrl+'
|
||||
}
|
||||
|
||||
interface UseOperationQueueShortcutsOptions {
|
||||
enabled: boolean
|
||||
onSave: () => void
|
||||
onTogglePanel: () => void
|
||||
isSaving?: boolean
|
||||
hasOperations?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides keyboard shortcuts for the operation queue.
|
||||
*
|
||||
* Shortcuts:
|
||||
* - Cmd/Ctrl + S: Save all pending changes
|
||||
* - Cmd/Ctrl + .: Toggle the operation queue side panel
|
||||
*
|
||||
* These shortcuts are registered on the capture phase to ensure they fire
|
||||
* before the data grid handles the keyboard event.
|
||||
*/
|
||||
export function useOperationQueueShortcuts({
|
||||
enabled,
|
||||
onSave,
|
||||
onTogglePanel,
|
||||
isSaving = false,
|
||||
hasOperations = true,
|
||||
}: UseOperationQueueShortcutsOptions) {
|
||||
const os = detectOS()
|
||||
const modKey = os === 'macos' ? '⌘' : 'Ctrl+'
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const isMod = os === 'macos' ? event.metaKey : event.ctrlKey
|
||||
|
||||
if (isMod && event.key === 's') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!isSaving && hasOperations) {
|
||||
onSave()
|
||||
}
|
||||
} else if (isMod && event.key === '.') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onTogglePanel()
|
||||
}
|
||||
},
|
||||
[os, isSaving, hasOperations, onSave, onTogglePanel]
|
||||
)
|
||||
|
||||
// Use capture phase to intercept events before the grid handles them
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}
|
||||
}, [enabled, handleKeyDown])
|
||||
|
||||
return { modKey }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import {
|
||||
generateTableChangeKey,
|
||||
generateTableChangeKeyFromOperation,
|
||||
rowMatchesIdentifiers,
|
||||
applyCellEdit,
|
||||
} from './queueOperationUtils'
|
||||
import { QueuedOperationType } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
describe('generateTableChangeKey', () => {
|
||||
test('should generate key with row identifiers', () => {
|
||||
const key = generateTableChangeKey({
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId: 1,
|
||||
columnName: 'name',
|
||||
rowIdentifiers: { id: 1 },
|
||||
})
|
||||
expect(key).toBe('edit_cell_content:1:name:id:1')
|
||||
})
|
||||
|
||||
test('should generate key with empty row identifiers', () => {
|
||||
const key = generateTableChangeKey({
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId: 1,
|
||||
columnName: 'name',
|
||||
rowIdentifiers: {},
|
||||
})
|
||||
expect(key).toBe('edit_cell_content:1:name:')
|
||||
})
|
||||
|
||||
test('should generate key with multiple row identifiers sorted alphabetically', () => {
|
||||
const key = generateTableChangeKey({
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId: 1,
|
||||
columnName: 'name',
|
||||
rowIdentifiers: { z_id: 3, a_id: 1 },
|
||||
})
|
||||
expect(key).toBe('edit_cell_content:1:name:a_id:1|z_id:3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateTableChangeKeyFromOperation', () => {
|
||||
test('should generate key from EDIT_CELL_CONTENT operation', () => {
|
||||
const operation = {
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId: 1,
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'old',
|
||||
newValue: 'new',
|
||||
table: {} as any,
|
||||
},
|
||||
}
|
||||
const key = generateTableChangeKeyFromOperation(operation)
|
||||
expect(key).toBe('edit_cell_content:1:name:id:1')
|
||||
})
|
||||
|
||||
test('should throw error for unknown operation type', () => {
|
||||
const operation = {
|
||||
type: 'unknown' as any,
|
||||
tableId: 1,
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'old',
|
||||
newValue: 'new',
|
||||
table: {} as any,
|
||||
},
|
||||
}
|
||||
expect(() => generateTableChangeKeyFromOperation(operation)).toThrow('Unknown operation type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rowMatchesIdentifiers', () => {
|
||||
test('should return false for empty row identifiers', () => {
|
||||
const result = rowMatchesIdentifiers({ id: 1 }, {})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('should match row with single identifier', () => {
|
||||
const result = rowMatchesIdentifiers({ id: 1 }, { id: 1 })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('should match row with multiple identifiers', () => {
|
||||
const result = rowMatchesIdentifiers(
|
||||
{ id: 1, email: 'test@test.com' },
|
||||
{ id: 1, email: 'test@test.com' }
|
||||
)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('should not match row with different values', () => {
|
||||
const result = rowMatchesIdentifiers({ id: 2 }, { id: 1 })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('should not match row with missing identifier keys', () => {
|
||||
const result = rowMatchesIdentifiers({ id: 1 }, { id: 1, email: 'test@test.com' })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('should match row with extra keys', () => {
|
||||
const result = rowMatchesIdentifiers({ id: 1, name: 'John', age: 30 }, { id: 1 })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('should match with null values', () => {
|
||||
const result = rowMatchesIdentifiers({ id: null }, { id: null })
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('should not match with undefined values in row', () => {
|
||||
const result = rowMatchesIdentifiers({ id: undefined, name: 'test' }, { id: 1 })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCellEdit', () => {
|
||||
test('should apply cell edit to matching row', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, name: 'old' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, name: 'new' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
test('should not affect non-matching rows', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, name: 'old' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 3 }, 'new')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, name: 'old' },
|
||||
{ idx: 1, id: 2, name: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
test('should create new row instances for matching row', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'old' }]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(result[0]).not.toBe(rows[0])
|
||||
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'new' })
|
||||
})
|
||||
|
||||
test('should not modify original array', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'old' }]
|
||||
const originalRows = [...rows]
|
||||
applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(rows).toEqual(originalRows)
|
||||
})
|
||||
|
||||
test('should handle multiple matching rows with composite keys', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, org_id: 10, name: 'old1' },
|
||||
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
|
||||
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1, org_id: 10 }, 'new')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, org_id: 10, name: 'new' },
|
||||
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
|
||||
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
|
||||
])
|
||||
})
|
||||
|
||||
test('should handle setting value to null', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'test' }]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, null)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, name: null }])
|
||||
})
|
||||
|
||||
test('should handle setting value to undefined', () => {
|
||||
const rows = [{ idx: 0, id: 1, name: 'test' }]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, undefined)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, name: undefined }])
|
||||
})
|
||||
|
||||
test('should handle numeric values', () => {
|
||||
const rows = [{ idx: 0, id: 1, count: 0 }]
|
||||
const result = applyCellEdit(rows, 'count', { id: 1 }, 42)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, count: 42 }])
|
||||
})
|
||||
|
||||
test('should handle object values', () => {
|
||||
const rows = [{ idx: 0, id: 1, data: null }]
|
||||
const newValue = { nested: { value: 123 } }
|
||||
const result = applyCellEdit(rows, 'data', { id: 1 }, newValue)
|
||||
expect(result).toEqual([{ idx: 0, id: 1, data: newValue }])
|
||||
})
|
||||
|
||||
test('should handle empty rows array', () => {
|
||||
const rows: any[] = []
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test('should update all matching rows with same identifier', () => {
|
||||
const rows = [
|
||||
{ idx: 0, id: 1, name: 'row1' },
|
||||
{ idx: 1, id: 1, name: 'row2' },
|
||||
{ idx: 2, id: 2, name: 'row3' },
|
||||
]
|
||||
const result = applyCellEdit(rows, 'name', { id: 1 }, 'updated')
|
||||
expect(result).toEqual([
|
||||
{ idx: 0, id: 1, name: 'updated' },
|
||||
{ idx: 1, id: 1, name: 'updated' },
|
||||
{ idx: 2, id: 2, name: 'row3' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
import type { Entity } from 'data/table-editor/table-editor-types'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import type { TableRowsData } from 'data/table-rows/table-rows-query'
|
||||
import {
|
||||
NewQueuedOperation,
|
||||
QueuedOperation,
|
||||
QueuedOperationType,
|
||||
type EditCellContentPayload,
|
||||
} from '@/state/table-editor-operation-queue.types'
|
||||
import type { Dictionary } from 'types'
|
||||
import { SupaRow } from '../types'
|
||||
|
||||
interface GenerateTableChangeKeyArgs {
|
||||
type: QueuedOperationType
|
||||
tableId: number
|
||||
columnName?: string
|
||||
rowIdentifiers?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function generateTableChangeKeyFromOperation(operation: NewQueuedOperation): string {
|
||||
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
||||
return generateTableChangeKey({
|
||||
type: operation.type,
|
||||
tableId: operation.tableId,
|
||||
columnName: operation.payload.columnName,
|
||||
rowIdentifiers: operation.payload.rowIdentifiers,
|
||||
})
|
||||
}
|
||||
|
||||
// Need to explicitly handle other operations
|
||||
throw new Error(`Unknown operation type: ${operation.type}`)
|
||||
}
|
||||
|
||||
export function generateTableChangeKey({
|
||||
rowIdentifiers,
|
||||
columnName,
|
||||
tableId,
|
||||
type,
|
||||
}: GenerateTableChangeKeyArgs): string {
|
||||
const rowIdentifiersKey = Object.entries(rowIdentifiers ?? {})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join('|')
|
||||
return `${type}:${tableId}:${columnName}:${rowIdentifiersKey}`
|
||||
}
|
||||
|
||||
export function rowMatchesIdentifiers(
|
||||
row: Dictionary<unknown>,
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
): boolean {
|
||||
const identifierEntries = Object.entries(rowIdentifiers)
|
||||
if (identifierEntries.length === 0) return false
|
||||
return identifierEntries.every(([key, value]) => row[key] === value)
|
||||
}
|
||||
|
||||
export function applyCellEdit(
|
||||
rows: SupaRow[],
|
||||
columnName: string,
|
||||
rowIdentifiers: Dictionary<unknown>,
|
||||
newValue: unknown
|
||||
): SupaRow[] {
|
||||
return rows.map((row) => {
|
||||
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
|
||||
if (rowMatches) {
|
||||
return { ...row, [columnName]: newValue }
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
interface QueueCellEditParams {
|
||||
queryClient: QueryClient
|
||||
queueOperation: (operation: NewQueuedOperation) => void
|
||||
projectRef: string
|
||||
tableId: number
|
||||
table: Entity
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
columnName: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
enumArrayColumns?: string[]
|
||||
}
|
||||
|
||||
export function queueCellEditWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation,
|
||||
projectRef,
|
||||
tableId,
|
||||
table,
|
||||
rowIdentifiers,
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
enumArrayColumns,
|
||||
}: QueueCellEditParams) {
|
||||
// Queue the operation
|
||||
queueOperation({
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId,
|
||||
payload: {
|
||||
rowIdentifiers,
|
||||
columnName,
|
||||
oldValue,
|
||||
newValue,
|
||||
table,
|
||||
enumArrayColumns,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply optimistic update to the UI
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
rows: applyCellEdit(old.rows, columnName, rowIdentifiers, newValue),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ReapplyOptimisticUpdatesParams {
|
||||
queryClient: QueryClient
|
||||
projectRef: string
|
||||
tableId: number
|
||||
operations: readonly QueuedOperation[]
|
||||
}
|
||||
|
||||
export function reapplyOptimisticUpdates({
|
||||
queryClient,
|
||||
projectRef,
|
||||
tableId,
|
||||
operations,
|
||||
}: ReapplyOptimisticUpdatesParams) {
|
||||
const tableOperations = operations.filter((op) => op.tableId === tableId)
|
||||
if (tableOperations.length === 0) return
|
||||
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
|
||||
let rows = [...old.rows]
|
||||
for (const operation of tableOperations) {
|
||||
switch (operation.type) {
|
||||
case QueuedOperationType.EDIT_CELL_CONTENT: {
|
||||
const { rowIdentifiers, columnName, newValue } =
|
||||
operation.payload as EditCellContentPayload
|
||||
rows = applyCellEdit(rows, columnName, rowIdentifiers, newValue)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// Need to explicitly handle other operations
|
||||
throw new Error(`Unknown operation type: ${operation.type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...old, rows }
|
||||
})
|
||||
}
|
||||
@@ -36,4 +36,11 @@ export const FEATURE_PREVIEWS = [
|
||||
isNew: false,
|
||||
isPlatformOnly: false,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS,
|
||||
name: 'Queue table operations',
|
||||
discussionsUrl: undefined,
|
||||
isNew: true,
|
||||
isPlatformOnly: false,
|
||||
},
|
||||
] as const
|
||||
@@ -105,6 +105,11 @@ export const useIsAdvisorRulesEnabled = () => {
|
||||
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES]
|
||||
}
|
||||
|
||||
export const useIsQueueOperationsEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]
|
||||
}
|
||||
|
||||
export const useFeaturePreviewModal = () => {
|
||||
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Branching2Preview } from './Branching2Preview'
|
||||
import { CLSPreview } from './CLSPreview'
|
||||
import { FEATURE_PREVIEWS } from './FeaturePreview.constants'
|
||||
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
|
||||
import { QueueOperationsPreview } from './QueueOperationsPreview'
|
||||
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
|
||||
|
||||
const FEATURE_PREVIEW_KEY_TO_CONTENT: {
|
||||
@@ -23,6 +24,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: <APISidePanelPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: <CLSPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: <UnifiedLogsPreview />,
|
||||
[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: <QueueOperationsPreview />,
|
||||
}
|
||||
|
||||
const FeaturePreviewModal = () => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
|
||||
export const QueueOperationsPreview = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-foreground-light text-sm mb-4">
|
||||
Queue your table edits and review all pending changes before saving them to your database.
|
||||
This gives you more control over when changes are committed, allowing you to batch multiple
|
||||
edits and review them together.
|
||||
</p>
|
||||
<div className="bg-yellow-300/15 border-l-4 border-yellow-500 p-3 text-yellow-1100 text-sm rounded mb-2">
|
||||
<strong>Note:</strong> We are currently working to add all CRUD operations to the queue.
|
||||
Right now, only cell edits are supported.
|
||||
</div>
|
||||
<Image
|
||||
src={`${BASE_PATH}/img/previews/queue-operations-table-preview.png`}
|
||||
width={1296}
|
||||
height={900}
|
||||
alt="queue-operations-preview"
|
||||
className="rounded border"
|
||||
/>
|
||||
<div className="space-y-2 !mt-4">
|
||||
<p className="text-sm">Enabling this preview will:</p>
|
||||
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
|
||||
<li>Queue cell edits in the Table Editor instead of saving them immediately</li>
|
||||
<li>Show a panel to review all pending changes before committing them</li>
|
||||
<li>Allow you to cancel individual changes or save all changes at once</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
|
||||
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
|
||||
|
||||
interface OperationItemProps {
|
||||
operationId: string
|
||||
tableId: number
|
||||
content: EditCellContentPayload
|
||||
}
|
||||
|
||||
export const OperationItem = ({ operationId, tableId, content }: OperationItemProps) => {
|
||||
const { table, columnName, oldValue, newValue, rowIdentifiers } = content
|
||||
const tableSchema = table.schema
|
||||
const tableName = table.name
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
|
||||
const fullTableName = `${tableSchema}.${tableName}`
|
||||
const whereClause = Object.entries(rowIdentifiers)
|
||||
.map(([key, value]) => `${key} = ${formatOperationItemValue(value)}`)
|
||||
.join(', ')
|
||||
|
||||
const formattedOldValue = formatOperationItemValue(oldValue)
|
||||
const formattedNewValue = formatOperationItemValue(newValue)
|
||||
|
||||
const handleDelete = () => {
|
||||
// Remove the operation from the queue
|
||||
snap.removeOperation(operationId)
|
||||
|
||||
// Invalidate the query to revert the optimistic update
|
||||
if (project) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableId),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-surface-100">
|
||||
<div className="px-3 py-2 border-b border-default bg-surface-200 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
|
||||
<div className="text-sm text-foreground-muted mt-0.5">
|
||||
<span className="font-medium text-foreground">{columnName}</span>
|
||||
<span className="text-foreground-muted mx-2">·</span>
|
||||
<span className="text-foreground text-xs">where {whereClause}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
icon={<X size={14} />}
|
||||
onClick={handleDelete}
|
||||
className="shrink-0"
|
||||
aria-label="Remove operation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-xs">
|
||||
<div className="flex items-start gap-2 px-3 py-0.5 bg-red-400/20">
|
||||
<span className="text-red-900 select-none font-bold">-</span>
|
||||
<span className="text-red-900 truncate max-w-full" title={formattedOldValue}>
|
||||
{formattedOldValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 px-3 py-0.5 bg-green-400/20">
|
||||
<span className="text-green-900 select-none font-bold">+</span>
|
||||
<span className="text-green-900 truncate max-w-full" title={formattedNewValue}>
|
||||
{formattedNewValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { QueuedOperation, QueuedOperationType } from 'state/table-editor-operation-queue.types'
|
||||
|
||||
import { OperationItem } from './OperationItem'
|
||||
|
||||
interface OperationListProps {
|
||||
operations: readonly QueuedOperation[]
|
||||
}
|
||||
|
||||
export const OperationList = ({ operations }: OperationListProps) => {
|
||||
if (operations.length === 0) {
|
||||
return <p className="text-sm text-foreground-light">No pending changes</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{operations.map((op) => {
|
||||
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
||||
return (
|
||||
<OperationItem
|
||||
key={op.id}
|
||||
operationId={op.id}
|
||||
tableId={op.tableId}
|
||||
content={op.payload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
|
||||
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { Button, SidePanel } from 'ui'
|
||||
|
||||
import { OperationList } from './OperationList'
|
||||
import { QueuedOperation } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
interface OperationQueueSidePanelProps {
|
||||
visible: boolean
|
||||
closePanel: () => void
|
||||
}
|
||||
|
||||
export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueSidePanelProps) => {
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
|
||||
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
|
||||
|
||||
const { handleSave, handleCancel, isSaving } = useOperationQueueActions({
|
||||
onSaveSuccess: closePanel,
|
||||
onCancelSuccess: closePanel,
|
||||
})
|
||||
|
||||
const { modKey } = useOperationQueueShortcuts({
|
||||
enabled: visible,
|
||||
onSave: handleSave,
|
||||
onTogglePanel: closePanel,
|
||||
isSaving,
|
||||
hasOperations: operations.length > 0,
|
||||
})
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
size="large"
|
||||
visible={visible}
|
||||
onCancel={closePanel}
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>Pending Changes</span>
|
||||
<span className="text-xs text-foreground-light">
|
||||
{operations.length} operation{operations.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
customFooter={
|
||||
<div className="flex w-full justify-between border-t border-default px-3 py-4">
|
||||
<Button type="default" onClick={closePanel}>
|
||||
Close
|
||||
<span className="text-foreground-lighter text-xs ml-1.5">{modKey}.</span>
|
||||
</Button>
|
||||
<div className="flex space-x-3">
|
||||
<Button type="default" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel All
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || operations.length === 0}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save All
|
||||
<span className="text-foreground-lighter text-xs ml-1.5">{modKey}S</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content className="py-4">
|
||||
<OperationList operations={operations} />
|
||||
</SidePanel.Content>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Formats a value for display in the operation queue.
|
||||
* Handles null, undefined, objects, and primitive values.
|
||||
*/
|
||||
export const formatOperationItemValue = (value: unknown): string => {
|
||||
if (value === null) return 'NULL'
|
||||
if (value === undefined) return 'UNDEFINED'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
+48
-1
@@ -4,6 +4,8 @@ import { isEmpty, isUndefined, noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { queueCellEditWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useTableApiAccessPrivilegesMutation } from '@/data/privileges/table-api-access-mutation'
|
||||
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
|
||||
import { type ApiPrivilegesByRole } from '@/lib/data-api-types'
|
||||
@@ -22,7 +24,7 @@ import { entityTypeKeys } from 'data/entity-types/keys'
|
||||
import { lintKeys } from 'data/lint/keys'
|
||||
import { privilegeKeys } from 'data/privileges/keys'
|
||||
import { tableEditorKeys } from 'data/table-editor/keys'
|
||||
import { isTableLike } from 'data/table-editor/table-editor-types'
|
||||
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'
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
} from './TableEditor/ApiAccessToggle'
|
||||
import { TableEditor } from './TableEditor/TableEditor'
|
||||
import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import { OperationQueueSidePanel } from './OperationQueueSidePanel/OperationQueueSidePanel'
|
||||
|
||||
export type SaveTableParams =
|
||||
| SaveTableParamsNew
|
||||
@@ -200,6 +203,7 @@ export const SidePanelEditor = ({
|
||||
|
||||
const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
|
||||
const generatePoliciesFlag = usePHFlag<string>('tableCreateGeneratePolicies')
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
|
||||
const [isEdited, setIsEdited] = useState<boolean>(false)
|
||||
|
||||
@@ -283,6 +287,45 @@ 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 =
|
||||
snap.sidePanel?.type === 'json'
|
||||
? snap.sidePanel.jsonValue.row
|
||||
: snap.sidePanel?.type === 'cell'
|
||||
? snap.sidePanel.value?.row
|
||||
: undefined
|
||||
const oldValue = row?.[changedColumn]
|
||||
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queryClient,
|
||||
queueOperation: snap.queueOperation,
|
||||
projectRef: project.ref,
|
||||
tableId: selectedTable.id,
|
||||
// Cast to Entity - the queue save mutation only uses id, name, schema
|
||||
table: selectedTable as unknown as Entity,
|
||||
rowIdentifiers: configuration.identifiers,
|
||||
columnName: changedColumn,
|
||||
oldValue: oldValue,
|
||||
newValue: payload[changedColumn],
|
||||
enumArrayColumns,
|
||||
})
|
||||
|
||||
// Close panel immediately without error
|
||||
onComplete()
|
||||
setIsEdited(false)
|
||||
snap.closeSidePanel()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTableRow({
|
||||
projectRef: project.ref,
|
||||
@@ -936,6 +979,10 @@ export const SidePanelEditor = ({
|
||||
closePanel={onClosePanel}
|
||||
updateEditorDirty={setIsEdited}
|
||||
/>
|
||||
<OperationQueueSidePanel
|
||||
visible={snap.sidePanel?.type === 'operation-queue'}
|
||||
closePanel={snap.closeSidePanel}
|
||||
/>
|
||||
<CloseConfirmationModal {...closeConfirmationModalProps} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { SaveQueueActionBar } from '@/components/grid/components/footer/operations/SaveQueueActionBar'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { ProjectLayoutWithAuth } from '../ProjectLayout'
|
||||
@@ -19,5 +20,10 @@ export const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<SaveQueueActionBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { wrapWithTransaction } from 'data/sql/utils/transaction'
|
||||
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
|
||||
import {
|
||||
EditCellContentPayload,
|
||||
QueuedOperation,
|
||||
QueuedOperationType,
|
||||
} from 'state/table-editor-operation-queue.types'
|
||||
import type { ResponseError, UseCustomMutationOptions } from 'types'
|
||||
import { tableRowKeys } from './keys'
|
||||
import { getTableRowUpdateSql } from './table-row-update-mutation'
|
||||
|
||||
export type OperationQueueSaveVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
operations: readonly QueuedOperation[]
|
||||
roleImpersonationState?: RoleImpersonationState
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates SQL for a single queued operation.
|
||||
* Extend this function as new operation types are added.
|
||||
*/
|
||||
function getOperationSql(operation: QueuedOperation): string {
|
||||
switch (operation.type) {
|
||||
case QueuedOperationType.EDIT_CELL_CONTENT: {
|
||||
const payload = operation.payload as EditCellContentPayload
|
||||
return getTableRowUpdateSql({
|
||||
table: {
|
||||
id: payload.table.id,
|
||||
name: payload.table.name,
|
||||
schema: payload.table.schema,
|
||||
},
|
||||
configuration: { identifiers: payload.rowIdentifiers },
|
||||
payload: { [payload.columnName]: payload.newValue },
|
||||
enumArrayColumns: payload.enumArrayColumns ?? [],
|
||||
returning: false,
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown operation type: ${(operation as QueuedOperation).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves all queued operations in a single database transaction.
|
||||
* If any operation fails, the entire transaction is rolled back.
|
||||
*/
|
||||
export async function saveOperationQueue({
|
||||
projectRef,
|
||||
connectionString,
|
||||
operations,
|
||||
roleImpersonationState,
|
||||
}: OperationQueueSaveVariables) {
|
||||
if (operations.length === 0) {
|
||||
return { result: [] }
|
||||
}
|
||||
|
||||
// Generate SQL for each operation, stripping trailing semicolons to avoid double semicolons when joining
|
||||
const statements = operations.map((op) => {
|
||||
const sql = getOperationSql(op)
|
||||
return sql.endsWith(';') ? sql.slice(0, -1) : sql
|
||||
})
|
||||
|
||||
// Combine all statements into a single transaction
|
||||
const transactionSql = wrapWithTransaction(statements.join(';\n') + ';')
|
||||
|
||||
// Wrap with role impersonation if enabled
|
||||
const sql = wrapWithRoleImpersonation(transactionSql, roleImpersonationState)
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
|
||||
queryKey: ['operation-queue-save'],
|
||||
})
|
||||
|
||||
return { result }
|
||||
}
|
||||
|
||||
type OperationQueueSaveData = Awaited<ReturnType<typeof saveOperationQueue>>
|
||||
|
||||
export const useOperationQueueSaveMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<OperationQueueSaveData, ResponseError, OperationQueueSaveVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<OperationQueueSaveData, ResponseError, OperationQueueSaveVariables>({
|
||||
mutationFn: (vars) => saveOperationQueue(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef, operations } = variables
|
||||
|
||||
// Collect all unique table IDs that were affected
|
||||
const affectedTableIds = [...new Set(operations.map((op) => op.tableId))]
|
||||
|
||||
// Invalidate queries for all affected tables (both rows and count)
|
||||
await Promise.all(
|
||||
affectedTableIds.map((tableId) =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tableRowKeys.tableRowsAndCount(projectRef, tableId),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to save changes: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,56 @@
|
||||
import type { Entity } from 'data/table-editor/table-editor-types'
|
||||
import type { Dictionary } from 'types'
|
||||
|
||||
/**
|
||||
* Extensible enum for queued operation types.
|
||||
* Add new operation types here as we expand the queuing system.
|
||||
*/
|
||||
export enum QueuedOperationType {
|
||||
EDIT_CELL_CONTENT = 'edit_cell_content',
|
||||
// Future: DELETE_ROW, ADD_ROW, EDIT_COLUMN, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for EDIT_CELL_CONTENT operations
|
||||
*/
|
||||
export interface EditCellContentPayload {
|
||||
rowIdentifiers: Dictionary<unknown> // Primary key values to identify the row
|
||||
columnName: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
// For mutation support
|
||||
table: Entity
|
||||
enumArrayColumns?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all operation payloads.
|
||||
* Extend this as new operation types are added.
|
||||
*/
|
||||
export type QueuedOperationPayload = EditCellContentPayload
|
||||
|
||||
/**
|
||||
* Individual queued operation
|
||||
*/
|
||||
export interface QueuedOperation {
|
||||
id: string
|
||||
type: QueuedOperationType
|
||||
tableId: number // Which table this operation belongs to
|
||||
timestamp: number
|
||||
payload: QueuedOperationPayload
|
||||
}
|
||||
|
||||
export type NewQueuedOperation = Omit<QueuedOperation, 'id' | 'timestamp'>
|
||||
|
||||
/**
|
||||
* Status of the overall operation queue
|
||||
*/
|
||||
export type QueueStatus = 'idle' | 'pending' | 'saving' | 'error'
|
||||
|
||||
/**
|
||||
* Operation queue state structure
|
||||
*/
|
||||
export interface OperationQueueState {
|
||||
operations: QueuedOperation[]
|
||||
status: QueueStatus
|
||||
}
|
||||
@@ -4,11 +4,24 @@ import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
import { useConstant } from 'common'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import {
|
||||
generateTableChangeKey,
|
||||
generateTableChangeKeyFromOperation,
|
||||
} 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 type { Dictionary } from 'types'
|
||||
|
||||
import {
|
||||
NewQueuedOperation,
|
||||
QueuedOperationType,
|
||||
type EditCellContentPayload,
|
||||
type OperationQueueState,
|
||||
type QueuedOperation,
|
||||
type QueueStatus,
|
||||
} from './table-editor-operation-queue.types'
|
||||
|
||||
export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100
|
||||
|
||||
type ForeignKeyState = {
|
||||
@@ -29,6 +42,7 @@ export type SidePanel =
|
||||
foreignKey: ForeignKeyState
|
||||
}
|
||||
| { type: 'csv-import'; file?: File }
|
||||
| { type: 'operation-queue' }
|
||||
|
||||
export type ConfirmationDialog =
|
||||
| { type: 'table'; isDeleteWithCascade: boolean }
|
||||
@@ -189,6 +203,12 @@ export const createTableEditorState = () => {
|
||||
sidePanel: { type: 'csv-import', file },
|
||||
}
|
||||
},
|
||||
onViewOperationQueue: () => {
|
||||
state.ui = {
|
||||
open: 'side-panel',
|
||||
sidePanel: { type: 'operation-queue' },
|
||||
}
|
||||
},
|
||||
|
||||
/* Utils */
|
||||
toggleConfirmationIsWithCascade: (overrideIsDeleteWithCascade?: boolean) => {
|
||||
@@ -201,6 +221,97 @@ export const createTableEditorState = () => {
|
||||
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.
|
||||
*/
|
||||
queueOperation: (operation: NewQueuedOperation) => {
|
||||
const operationKey = generateTableChangeKeyFromOperation(operation)
|
||||
const existingOpIndex = state.operationQueue.operations.findIndex(
|
||||
(op) => op.id === operationKey
|
||||
)
|
||||
|
||||
const newOperation: QueuedOperation = {
|
||||
...operation,
|
||||
id: operationKey,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
if (existingOpIndex >= 0) {
|
||||
// [Ali] Keep the old value of the operation that is being overwritten, in case someone edits the cell again, it should reference the original value.
|
||||
// When a user edits the same cell multiple times before saving, we need to preserve the original "before edit" value, not the intermediate value from the previous queued edit
|
||||
if (newOperation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
||||
newOperation.payload.oldValue =
|
||||
state.operationQueue.operations[existingOpIndex].payload.oldValue
|
||||
}
|
||||
state.operationQueue.operations[existingOpIndex] = newOperation
|
||||
} else {
|
||||
state.operationQueue.operations.push(newOperation)
|
||||
}
|
||||
|
||||
if (state.operationQueue.status === 'idle') {
|
||||
state.operationQueue.status = 'pending'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: (
|
||||
type: QueuedOperationType,
|
||||
tableId: number,
|
||||
rowIdentifiers: Record<string, unknown>,
|
||||
columnName: string
|
||||
): boolean => {
|
||||
const key = generateTableChangeKey({
|
||||
type,
|
||||
tableId,
|
||||
columnName,
|
||||
rowIdentifiers,
|
||||
})
|
||||
return state.operationQueue.operations.some((op) => op.id === key)
|
||||
},
|
||||
})
|
||||
|
||||
return state
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
box-shadow: inset 0 0 0 1px #24b47e;
|
||||
}
|
||||
|
||||
// Cell with unsaved changes - yellow/amber border
|
||||
.rdg-cell.rdg-cell--dirty {
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--warning-default));
|
||||
}
|
||||
|
||||
// When a dirty cell is also selected, keep the amber border
|
||||
.rdg-cell.rdg-cell--dirty[aria-selected='true'] {
|
||||
box-shadow: inset 0 0 0 2px hsl(var(--warning-default));
|
||||
}
|
||||
|
||||
.rdg {
|
||||
@apply box-border select-none overflow-x-auto overflow-y-scroll bg-dash-canvas;
|
||||
@apply border-t border-r-0 border-l-0;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown',
|
||||
UI_PREVIEW_BRANCHING_2_0: 'supabase-ui-branching-2-0',
|
||||
UI_PREVIEW_ADVISOR_RULES: 'supabase-ui-advisor-rules',
|
||||
UI_PREVIEW_QUEUE_OPERATIONS: 'supabase-ui-queue-operations',
|
||||
|
||||
NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge',
|
||||
TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge',
|
||||
@@ -135,6 +136,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR,
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS,
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
|
||||
LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS,
|
||||
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
|
||||
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
|
||||
LOCAL_STORAGE_KEYS.BLOG_VIEW,
|
||||
|
||||
Reference in new issue
Block a user