From ea1b95d29b166d22a99eb2406e8bf1292cba97c0 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 28 Jan 2026 06:54:30 -0700 Subject: [PATCH] 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 --- apps/studio/components/grid/SupabaseGrid.tsx | 37 ++- .../grid/components/editor/TextEditor.tsx | 11 +- .../footer/operations/SaveQueueActionBar.tsx | 78 +++++++ .../components/grid/components/grid/Grid.tsx | 73 +++++- .../grid/components/grid/Grid.utils.tsx | 58 +++-- .../grid/hooks/useOperationQueueActions.ts | 83 +++++++ .../grid/hooks/useOperationQueueShortcuts.ts | 68 ++++++ .../grid/utils/queueOperationUtils.test.ts | 217 ++++++++++++++++++ .../grid/utils/queueOperationUtils.ts | 161 +++++++++++++ .../FeaturePreview.constants.tsx | 7 + .../FeaturePreview/FeaturePreviewContext.tsx | 5 + .../FeaturePreview/FeaturePreviewModal.tsx | 2 + .../FeaturePreview/QueueOperationsPreview.tsx | 34 +++ .../OperationQueueSidePanel/OperationItem.tsx | 84 +++++++ .../OperationQueueSidePanel/OperationList.tsx | 31 +++ .../OperationQueueSidePanel.tsx | 74 ++++++ .../OperationQueueSidePanel.utils.ts | 10 + .../SidePanelEditor/SidePanelEditor.tsx | 49 +++- .../TableEditorLayout/TableEditorLayout.tsx | 8 +- .../operation-queue-save-mutation.ts | 126 ++++++++++ .../queue-operations-table-preview.png | Bin 0 -> 20102 bytes .../table-editor-operation-queue.types.ts | 56 +++++ apps/studio/state/table-editor.tsx | 111 +++++++++ apps/studio/styles/grid.scss | 10 + packages/common/constants/local-storage.ts | 2 + 25 files changed, 1369 insertions(+), 26 deletions(-) create mode 100644 apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx create mode 100644 apps/studio/components/grid/hooks/useOperationQueueActions.ts create mode 100644 apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts create mode 100644 apps/studio/components/grid/utils/queueOperationUtils.test.ts create mode 100644 apps/studio/components/grid/utils/queueOperationUtils.ts create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.utils.ts create mode 100644 apps/studio/data/table-rows/operation-queue-save-mutation.ts create mode 100644 apps/studio/public/img/previews/queue-operations-table-preview.png create mode 100644 apps/studio/state/table-editor-operation-queue.types.ts diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index ba7cde6ab58..e0c82bece5f 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -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 diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 9607dda28ad..3554e02ca17 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -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 = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(true) const [value, setValue] = useState(initialValue) const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false) + const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation() @@ -169,7 +171,14 @@ export const TextEditor = ({ 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 diff --git a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx new file mode 100644 index 00000000000..65be243d127 --- /dev/null +++ b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx @@ -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 = ( + + {isVisible && ( + +
+ + {operationCount} pending change{operationCount !== 1 ? 's' : ''} + +
+ + +
+
+
+ )} +
+ ) + + if (typeof document === 'undefined') return null + return createPortal(content, document.body) +} diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 31eab0cdcb5..73215f2e232 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -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 + } | null>(null) - function onSelectedCellChange(args: { rowIdx: number; row: any; column: any }) { + function onSelectedCellChange(args: { + rowIdx: number + row: SupaRow + column: CalculatedColumn + }) { 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[] + } + + return (snap.gridColumns as CalculatedColumn[]).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 = {} + 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 (
[]} + 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} />
diff --git a/apps/studio/components/grid/components/grid/Grid.utils.tsx b/apps/studio/components/grid/components/grid/Grid.utils.tsx index 1c4bb625968..6dcc58a2ec7 100644 --- a/apps/studio/components/grid/components/grid/Grid.utils.tsx +++ b/apps/studio/components/grid/components/grid/Grid.utils.tsx @@ -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, + ] ) } diff --git a/apps/studio/components/grid/hooks/useOperationQueueActions.ts b/apps/studio/components/grid/hooks/useOperationQueueActions.ts new file mode 100644 index 00000000000..a55f45bd52e --- /dev/null +++ b/apps/studio/components/grid/hooks/useOperationQueueActions.ts @@ -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, + } +} diff --git a/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts b/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts new file mode 100644 index 00000000000..74dc8d606f8 --- /dev/null +++ b/apps/studio/components/grid/hooks/useOperationQueueShortcuts.ts @@ -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 } +} diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts new file mode 100644 index 00000000000..a5c00e6842e --- /dev/null +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -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' }, + ]) + }) +}) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts new file mode 100644 index 00000000000..394aafa6d6d --- /dev/null +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -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 +} + +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, + rowIdentifiers: Dictionary +): 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, + 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 + 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({ 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({ 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 } + }) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx index 57fecd53176..b6de3ce963e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx @@ -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 diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 07d10d00be2..91976560092 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -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') diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index e419f197d8d..e4cbd1e2cb3 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -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]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: , } const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx new file mode 100644 index 00000000000..e17fc79413d --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image' + +import { BASE_PATH } from 'lib/constants' + +export const QueueOperationsPreview = () => { + return ( +
+

+ 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. +

+
+ Note: We are currently working to add all CRUD operations to the queue. + Right now, only cell edits are supported. +
+ queue-operations-preview +
+

Enabling this preview will:

+
    +
  • Queue cell edits in the Table Editor instead of saving them immediately
  • +
  • Show a panel to review all pending changes before committing them
  • +
  • Allow you to cancel individual changes or save all changes at once
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx new file mode 100644 index 00000000000..3ee11ebb1bc --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx @@ -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 ( +
+
+
+
{fullTableName}
+
+ {columnName} + · + where {whereClause} +
+
+
+ +
+
+ - + + {formattedOldValue} + +
+ +
+ + + + {formattedNewValue} + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx new file mode 100644 index 00000000000..d05d45e40a6 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx @@ -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

No pending changes

+ } + + return ( +
+ {operations.map((op) => { + if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) { + return ( + + ) + } + return null + })} +
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx new file mode 100644 index 00000000000..5a5754ad2cb --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx @@ -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 ( + +
+ Pending Changes + + {operations.length} operation{operations.length !== 1 ? 's' : ''} + +
+ + } + customFooter={ +
+ +
+ + +
+
+ } + > + + + +
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.utils.ts new file mode 100644 index 00000000000..6e1565254d8 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.utils.ts @@ -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) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index e3e491f304f..6920b069738 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -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('tableCreateGeneratePolicies') + const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const [isEdited, setIsEdited] = useState(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} /> + ) diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index 45d9c382f76..123146bffb1 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -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} + + + ) } diff --git a/apps/studio/data/table-rows/operation-queue-save-mutation.ts b/apps/studio/data/table-rows/operation-queue-save-mutation.ts new file mode 100644 index 00000000000..ac48b8bb630 --- /dev/null +++ b/apps/studio/data/table-rows/operation-queue-save-mutation.ts @@ -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> + +export const useOperationQueueSaveMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + 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, + }) +} diff --git a/apps/studio/public/img/previews/queue-operations-table-preview.png b/apps/studio/public/img/previews/queue-operations-table-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..1f05edaf58d247396e541790d5c6c6bfaa660b70 GIT binary patch literal 20102 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 CTRh_c literal 0 HcmV?d00001 diff --git a/apps/studio/state/table-editor-operation-queue.types.ts b/apps/studio/state/table-editor-operation-queue.types.ts new file mode 100644 index 00000000000..d52898f85f8 --- /dev/null +++ b/apps/studio/state/table-editor-operation-queue.types.ts @@ -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 // 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 + +/** + * Status of the overall operation queue + */ +export type QueueStatus = 'idle' | 'pending' | 'saving' | 'error' + +/** + * Operation queue state structure + */ +export interface OperationQueueState { + operations: QueuedOperation[] + status: QueueStatus +} diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 43623fd2d09..a45f9672c44 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -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, + columnName: string + ): boolean => { + const key = generateTableChangeKey({ + type, + tableId, + columnName, + rowIdentifiers, + }) + return state.operationQueue.operations.some((op) => op.id === key) + }, }) return state diff --git a/apps/studio/styles/grid.scss b/apps/studio/styles/grid.scss index b370f2b8e9b..8811784daf3 100644 --- a/apps/studio/styles/grid.scss +++ b/apps/studio/styles/grid.scss @@ -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; diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index dd824ace1cf..4553bcf8b50 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -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,