diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index ba7cde6ab5860..e0c82bece5f95 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 9607dda28ad89..3554e02ca17bb 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 0000000000000..65be243d12739 --- /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 31eab0cdcb522..73215f2e232eb 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 1c4bb625968a0..6dcc58a2ec7c0 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 0000000000000..a55f45bd52ea8 --- /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 0000000000000..74dc8d606f8d7 --- /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 0000000000000..a5c00e6842e6e --- /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 0000000000000..394aafa6d6d2c --- /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/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 70bffc48d3e53..1041597198f5e 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,20 +1,16 @@ -import { partition } from 'lodash' -import { PropsWithChildren } from 'react' - -import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' import { IncidentBanner } from 'components/layouts/AppLayout/IncidentBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' +import { PropsWithChildren } from 'react' + import { OrganizationResourceBanner } from '../Organization/HeaderBanner' +import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const { data: allStatusPageEvents } = useIncidentStatusQuery() - const [maintenanceEvents, incidents] = partition( - allStatusPageEvents ?? [], - (event) => event.impact === 'maintenance' - ) + const { maintenanceEvents = [], incidents = [] } = allStatusPageEvents ?? {} const ongoingIncident = useFlag('ongoingIncident') || diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx index 57fecd53176a8..b6de3ce963e8c 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 07d10d00be2ac..91976560092a0 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 e419f197d8dba..e4cbd1e2cb368 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 0000000000000..e17fc79413df6 --- /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/BranchManagement/EmptyStates.tsx b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx index 7166def9ca2c8..460fa467487e6 100644 --- a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx @@ -7,7 +7,7 @@ import { DocsButton } from 'components/ui/DocsButton' import { DOCS_URL } from 'lib/constants' import { Button } from 'ui' -const EMPTY_STATE_CONTAINER = 'flex items-center flex-col justify-center w-full py-10 px-4' +const EMPTY_STATE_CONTAINER = 'flex items-center flex-col gap-0.5 justify-center w-full py-10 px-4' export const PullRequestsEmptyState = ({ url, @@ -29,7 +29,7 @@ export const PullRequestsEmptyState = ({ return (

No merge requests

-

+

Create your first merge request to merge changes back to the main branch

@@ -65,14 +65,14 @@ export const PreviewBranchesEmptyState = ({ return (

Create your first preview branch

-

+

Preview branches are short-lived environments that let you safely experiment with changes to your database schema without affecting your main database.

diff --git a/apps/studio/components/interfaces/BranchManagement/Overview.tsx b/apps/studio/components/interfaces/BranchManagement/Overview.tsx index 3fa46a5e86d1f..4202e209fb574 100644 --- a/apps/studio/components/interfaces/BranchManagement/Overview.tsx +++ b/apps/studio/components/interfaces/BranchManagement/Overview.tsx @@ -118,9 +118,9 @@ export const Overview = ({ IS_PLATFORM && persistentBranches.length === 0 && (
-
+

Upgrade to unlock persistent branches

-

+

Persistent branches are long-lived, cannot be reset, and are ideal for staging environments.

@@ -136,9 +136,9 @@ export const Overview = ({ !isLoadingEntitlement && hasAccessToPersistentBranching && persistentBranches.length === 0 && ( -
+

No persistent branches

-

+

Persistent branches are long-lived, cannot be reset, and are ideal for staging environments.

@@ -196,8 +196,8 @@ export const Overview = ({ {isLoading && } {isSuccess && scheduledForDeletionBranches.length === 0 && ( -
-

No scheduled for deletion branches

+
+

No branches scheduled for deletion

)} {isSuccess && diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index b59b6e0a6e144..0f7db86b186aa 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -1,7 +1,3 @@ -import { BookOpen, ChevronDown, ExternalLink } from 'lucide-react' -import { parseAsString, useQueryState } from 'nuqs' -import { HTMLAttributes, ReactNode, useEffect, useMemo, useState } from 'react' - import { useParams } from 'common' import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' @@ -15,6 +11,9 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { DOCS_URL, IS_PLATFORM } from 'lib/constants' import { pluckObjectFields } from 'lib/helpers' +import { BookOpen, ChevronDown, ExternalLink } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { HTMLAttributes, ReactNode, useEffect, useMemo, useState } from 'react' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, @@ -33,6 +32,7 @@ import { cn, } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { CONNECTION_PARAMETERS, type ConnectionStringMethod, @@ -462,7 +462,12 @@ export const DatabaseConnectionString = () => { }} notice={['Does not support PREPARE statements']} parameters={[ - { ...CONNECTION_PARAMETERS.host, value: poolingConfiguration?.db_host ?? '' }, + { + ...CONNECTION_PARAMETERS.host, + value: isReplicaSelected + ? connectionInfo.db_host + : poolingConfiguration?.db_host ?? '', + }, { ...CONNECTION_PARAMETERS.port, value: poolingConfiguration?.db_port.toString() ?? '6543', diff --git a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx index bc935376c8040..be46698b7f7b9 100644 --- a/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/AdvancedConfiguration.tsx @@ -1,23 +1,27 @@ -import { ChevronRight } from 'lucide-react' -import { UseFormReturn } from 'react-hook-form' - +import { useFlag } from 'common' import { DocsButton } from 'components/ui/DocsButton' import Panel from 'components/ui/Panel' import { DOCS_URL } from 'lib/constants' +import { ChevronRight } from 'lucide-react' +import { UseFormReturn } from 'react-hook-form' import { Badge, - cn, - Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, FormItem_Shadcn_, RadioGroupStacked, RadioGroupStackedItem, + Tooltip, + TooltipContent, + TooltipTrigger, + cn, } from 'ui' import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { CreateProjectForm } from './ProjectCreation.schema' interface AdvancedConfigurationProps { @@ -31,6 +35,8 @@ export const AdvancedConfiguration = ({ layout = 'horizontal', collapsible = true, }: AdvancedConfigurationProps) => { + const disableOrioleProjectCreation = useFlag('disableOrioleProjectCreation') + const content = ( <> - - Postgres with OrioleDB - Alpha - - } - description="Not recommended for production workloads" - className={cn( - '[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>label]:flex [&>div>div>label]:items-center [&>div>div>label]:gap-x-2', - form.getValues('useOrioleDb') ? '!rounded-b-none' : '' + + + + Postgres with OrioleDB + Alpha + + } + description="Not recommended for production workloads" + className={cn( + '[&>div>div>p]:text-left [&>div>div>p]:text-xs [&>div>div>label]:flex [&>div>div>label]:items-center [&>div>div>label]:gap-x-2', + form.getValues('useOrioleDb') ? '!rounded-b-none' : '' + )} + disabled={disableOrioleProjectCreation} + /> + + {disableOrioleProjectCreation && ( + + OrioleDB is temporarily disabled for new projects. Please try again + later. + )} - /> + diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubStatus.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubStatus.tsx index 16f62eb9f5e03..f14fa572b8647 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubStatus.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubStatus.tsx @@ -44,10 +44,10 @@ export const GitHubStatus = () => { className="block w-full transition truncate text-sm text-foreground-light hover:text-foreground" >
-

GitHub Integration

+

GitHub Integration

-

+

{isConnected ? ( <> x.impact !== 'maintenance') + const { data: allStatusPageEvents, isLoading, isError } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} // Don't render anything while loading, on error, or if no incidents if (isLoading || isError || !incidents || incidents.length === 0) { diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index 672103a7a812c..60268f0003528 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -1,20 +1,18 @@ import * as Sentry from '@sentry/nextjs' +import CopyButton from 'components/ui/CopyButton' +import { useIncidentStatusQuery } from 'data/platform/incident-status-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useStateTransition } from 'hooks/misc/useStateTransition' +import { BASE_PATH, DOCS_URL } from 'lib/constants' import { Loader2, Wrench } from 'lucide-react' import Link from 'next/link' import { type Dispatch, type PropsWithChildren, useCallback, useReducer } from 'react' import type { UseFormReturn } from 'react-hook-form' import SVG from 'react-inlinesvg' import { toast } from 'sonner' -// End of third-party imports - -import CopyButton from 'components/ui/CopyButton' -import { useIncidentStatusQuery } from 'data/platform/incident-status-query' -import { usePlatformStatusQuery } from 'data/platform/platform-status-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useStateTransition } from 'hooks/misc/useStateTransition' -import { BASE_PATH, DOCS_URL } from 'lib/constants' -import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui' import { Admonition } from 'ui-patterns/admonition' + import { AIAssistantOption } from './AIAssistantOption' import { DiscordCTACard } from './DiscordCTACard' import { IncidentAdmonition } from './IncidentAdmonition' @@ -22,10 +20,10 @@ import { Success } from './Success' import type { ExtendedSupportCategories } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' import { - createInitialSupportFormState, type SupportFormActions, - supportFormReducer, type SupportFormState, + createInitialSupportFormState, + supportFormReducer, } from './SupportForm.state' import { NO_PROJECT_MARKER } from './SupportForm.utils' import { SupportFormV2 } from './SupportFormV2' @@ -67,10 +65,11 @@ function SupportFormPageContent() { const { form, initialError, projectRef, orgSlug } = useSupportForm(dispatch) const { - data: incidents, + data: allStatusPageEvents, isPending: isIncidentsPending, isError: isIncidentsError, } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} const hasActiveIncidents = !isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0 @@ -129,8 +128,10 @@ function SupportFormWrapper({ children }: PropsWithChildren) { } function SupportFormHeader() { - const { data, isPending: isLoading, isError } = usePlatformStatusQuery() - const isHealthy = data?.isHealthy + const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery() + const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} + const isMaintenance = maintenanceEvents.length > 0 + const isIncident = incidents.length > 0 return (

@@ -157,10 +158,10 @@ function SupportFormHeader() { icon={ isLoading ? ( - ) : isHealthy ? ( -
) : ( -
+
) } > @@ -169,9 +170,11 @@ function SupportFormHeader() { ? 'Checking status' : isError ? 'Failed to check status' - : isHealthy - ? 'All systems operational' - : 'Active incident ongoing'} + : isIncident + ? 'Active incident ongoing' + : isMaintenance + ? 'Scheduled maintenance' + : 'All systems operational'} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index c64257e678da4..4c85b45f3ca92 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -1,15 +1,16 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' // End of third-party imports -import { API_URL } from 'lib/constants' +import { API_URL, BASE_PATH } from 'lib/constants' import { HttpResponse, http } from 'msw' import { createMockOrganization, createMockProject } from 'tests/helpers' import { customRender } from 'tests/lib/custom-render' import { addAPIMock, mswServer } from 'tests/lib/msw' import { createMockProfileContext } from 'tests/lib/profile-helpers' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + import { NO_ORG_MARKER, NO_PROJECT_MARKER } from '../SupportForm.utils' import { SupportFormPage } from '../SupportFormPage' @@ -173,6 +174,14 @@ vi.mock(import('lib/gotrue'), async (importOriginal) => { } }) +vi.mock(import('lib/constants'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + } +}) + const renderSupportFormPage = (options?: Parameters[1]) => customRender(, { profileContext: createMockProfileContext(), @@ -397,11 +406,9 @@ describe('SupportFormPage', () => { }, }) - addAPIMock({ - method: 'get', - path: '/platform/status', - response: { is_healthy: true } as any, - }) + mswServer.use( + http.get(`${BASE_PATH}/api/incident-status`, () => HttpResponse.json([], { status: 200 })) + ) addAPIMock({ method: 'get', @@ -459,11 +466,22 @@ describe('SupportFormPage', () => { }) test('shows system status: not healthy', async () => { - addAPIMock({ - method: 'get', - path: '/platform/status', - response: { is_healthy: false } as any, - }) + mswServer.use( + http.get(`${BASE_PATH}/api/incident-status`, () => + HttpResponse.json( + [ + { + id: 'z3qp8rln72pl', + active_since: '2026-01-26T10:30:00Z', + impact: 'critical', + status: 'in_progress', + name: 'Test incident', + }, + ], + { status: 200 } + ) + ) + ) renderSupportFormPage() @@ -474,7 +492,7 @@ describe('SupportFormPage', () => { test('shows system status: check failed', async () => { mswServer.use( - http.get(`${API_URL}/platform/status`, () => + http.get(`${BASE_PATH}/api/incident-status`, () => HttpResponse.json({ msg: 'Status service unavailable' }, { status: 500 }) ) ) 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 0000000000000..3ee11ebb1bc4f --- /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 0000000000000..d05d45e40a646 --- /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 0000000000000..5a5754ad2cb1f --- /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 0000000000000..6e1565254d8c2 --- /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 e3e491f304f82..6920b06973865 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/AppLayout/MaintenanceBanner.tsx b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx index 388f2ee16a598..4c5610b36b68d 100644 --- a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx @@ -1,10 +1,25 @@ +import { LOCAL_STORAGE_KEYS } from 'common' import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' import { InlineLink } from 'components/ui/InlineLink' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' + /** * Used to display ongoing maintenance */ export function MaintenanceBanner() { + const { data: allStatusPageEvents } = useIncidentStatusQuery() + const { maintenanceEvents = [] } = allStatusPageEvents ?? {} + const currentEventId = maintenanceEvents[0]?.id ?? '' + + const [dismissed, setDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(currentEventId), + false + ) + + if (dismissed) return null + return ( } + onDismiss={() => setDismissed(true)} /> ) } diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index 45d9c382f7609..123146bffb19f 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/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 02744f6c4e517..e0c3c98bd26d6 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -10,6 +10,7 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortabl import { AnimatePresence, motion } from 'framer-motion' import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' +import { useEffect } from 'react' import { useParams } from 'common' import { useDashboardHistory } from 'hooks/misc/useDashboardHistory' @@ -28,6 +29,7 @@ import { useEditorType } from '../editors/EditorsLayout.hooks' import { CollapseButton } from './CollapseButton' import { SortableTab } from './SortableTab' import { TabPreview } from './TabPreview' +import { useTabsScroll } from './Tabs.utils' export const EditorTabs = () => { const { ref, id } = useParams() @@ -132,6 +134,8 @@ export const EditorTabs = () => { tabs.handleTabNavigation(id, router) } + const { tabsListRef } = useTabsScroll({ activeTab: tabs.activeTab, tabCount: editorTabs.length }) + return ( { > (null) + const prevTabCountRef = useRef(tabCount) + const isInitialMount = useRef(true) + + useEffect(() => { + if (tabsListRef.current) { + tabsListRef.current.scrollLeft = tabsListRef.current.scrollWidth + } + }, []) + + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + return + } + + if (!tabsListRef.current) return + + const tabCountIncreased = tabCount > prevTabCountRef.current + + if (tabCountIncreased) { + tabsListRef.current.scrollLeft = tabsListRef.current.scrollWidth + } else if (activeTab) { + const activeTabElement = tabsListRef.current.querySelector( + `[data-state="active"]` + ) as HTMLElement + + if (activeTabElement) { + activeTabElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }) + } + } + + prevTabCountRef.current = tabCount + }, [activeTab, tabCount]) + + return { tabsListRef } +} diff --git a/apps/studio/data/config/project-creation-postgres-versions-query.ts b/apps/studio/data/config/project-creation-postgres-versions-query.ts index 7ffc09281a3a2..f5aab54e98628 100644 --- a/apps/studio/data/config/project-creation-postgres-versions-query.ts +++ b/apps/studio/data/config/project-creation-postgres-versions-query.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query' - import { handleError, post } from 'data/fetchers' import { CloudProvider } from 'shared-data' import type { ResponseError, UseCustomQueryOptions } from 'types' + import { configKeys } from './keys' export type ProjectCreationPostgresVersionsVariables = { @@ -58,7 +58,7 @@ export const useProjectCreationPostgresVersionsQuery = { const { data } = useProjectCreationPostgresVersionsQuery( { @@ -66,7 +66,13 @@ export const useAvailableOrioleImageVersion = ( dbRegion, organizationSlug, }, - { enabled } + { + enabled, + select(data) { + return (data?.available_versions ?? []).find((x) => x.postgres_engine === '17-oriole') + }, + } ) - return (data?.available_versions ?? []).find((x) => x.postgres_engine === '17-oriole') + + return data } diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts index 6efb96c1a09bf..a6db924e09665 100644 --- a/apps/studio/data/platform/incident-status-query.ts +++ b/apps/studio/data/platform/incident-status-query.ts @@ -1,11 +1,14 @@ import { useQuery } from '@tanstack/react-query' - import type { IncidentInfo } from 'lib/api/incident-status' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { partition } from 'lodash' import { UseCustomQueryOptions } from 'types' + import { platformKeys } from './keys' -export async function getIncidentStatus(signal?: AbortSignal): Promise { +export async function getIncidentStatus( + signal?: AbortSignal +): Promise<{ maintenanceEvents: IncidentInfo[]; incidents: IncidentInfo[] }> { const response = await fetch(`${BASE_PATH}/api/incident-status`, { signal, method: 'GET', @@ -21,7 +24,11 @@ export async function getIncidentStatus(signal?: AbortSignal): Promise event.impact === 'maintenance' + ) + return { maintenanceEvents, incidents } } export type IncidentStatusData = Awaited> diff --git a/apps/studio/data/platform/keys.ts b/apps/studio/data/platform/keys.ts index 4b7877df0f061..3052d7b2fdfb9 100644 --- a/apps/studio/data/platform/keys.ts +++ b/apps/studio/data/platform/keys.ts @@ -1,4 +1,3 @@ export const platformKeys = { - status: () => ['platform', 'status'] as const, incidentStatus: () => ['platform', 'incident-status'] as const, } diff --git a/apps/studio/data/platform/platform-status-query.ts b/apps/studio/data/platform/platform-status-query.ts deleted file mode 100644 index 73f944673bfd0..0000000000000 --- a/apps/studio/data/platform/platform-status-query.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import { get, handleError } from 'data/fetchers' -import { platformKeys } from './keys' -import { UseCustomQueryOptions } from 'types' - -export type PlatformStatusResponse = { - isHealthy: boolean -} - -export async function getPlatformStatus(signal?: AbortSignal) { - const { data, error } = await get('/platform/status', { signal }) - if (error) handleError(error) - return { isHealthy: (data as any).is_healthy } as PlatformStatusResponse -} - -export type PlatformStatusData = Awaited> -export type PlatformStatusError = unknown - -export const usePlatformStatusQuery = ( - options: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: platformKeys.status(), - queryFn: ({ signal }) => getPlatformStatus(signal), - ...options, - }) 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 0000000000000..ac48b8bb630ee --- /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/evals/dataset.ts b/apps/studio/evals/dataset.ts index 6e5d73bb273c4..a73e8638ba560 100644 --- a/apps/studio/evals/dataset.ts +++ b/apps/studio/evals/dataset.ts @@ -94,4 +94,16 @@ export const dataset: AssistantEvalCase[] = [ 'Uses quotes around schema/table/columns with capital letters, special characters, and reserved keywords.', }, }, + { + input: { + prompt: 'Generate sample data for a blog with users, posts, and comments tables', + }, + expected: { + requiredTools: ['execute_sql'], + }, + metadata: { + category: ['sql_generation', 'schema_design'], + description: 'Invokes `execute_sql` from default "Generate sample data" prompt', + }, + }, ] diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 27aaaed910b17..9f85f5d08e636 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -1,12 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import { z } from 'zod' - import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { AdvancedConfiguration } from 'components/interfaces/ProjectCreation/AdvancedConfiguration' import { CloudProviderSelector } from 'components/interfaces/ProjectCreation/CloudProviderSelector' @@ -17,8 +10,8 @@ import { DisabledWarningDueToIncident } from 'components/interfaces/ProjectCreat import { FreeProjectLimitWarning } from 'components/interfaces/ProjectCreation/FreeProjectLimitWarning' import { OrganizationSelector } from 'components/interfaces/ProjectCreation/OrganizationSelector' import { - extractPostgresVersionDetails, PostgresVersionSelector, + extractPostgresVersionDetails, } from 'components/interfaces/ProjectCreation/PostgresVersionSelector' import { sizes } from 'components/interfaces/ProjectCreation/ProjectCreation.constants' import { FormSchema } from 'components/interfaces/ProjectCreation/ProjectCreation.schema' @@ -54,11 +47,17 @@ import { withAuth } from 'hooks/misc/withAuth' import { usePHFlag } from 'hooks/ui/useFlag' import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from 'lib/constants' import { useTrack } from 'lib/telemetry/track' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' import { AWS_REGIONS, type CloudProvider } from 'shared-data' +import { toast } from 'sonner' import type { NextPageWithLayout } from 'types' -import { Button, Form_Shadcn_, FormField_Shadcn_, useWatch_Shadcn_ } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Button, FormField_Shadcn_, Form_Shadcn_, useWatch_Shadcn_ } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { Admonition } from 'ui-patterns/admonition' +import { z } from 'zod' const sizesWithNoCostConfirmationRequired: DesiredInstanceSize[] = ['micro', 'small'] diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx index 5e11086f673af..79246462838bd 100644 --- a/apps/studio/pages/project/[ref]/branches/index.tsx +++ b/apps/studio/pages/project/[ref]/branches/index.tsx @@ -230,7 +230,7 @@ BranchesPage.getLayout = (page) => { rel="noreferrer" href="https://github.com/orgs/supabase/discussions/18937" > - Branching Feedback + Branching feedback diff --git a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx index 199677704c205..73facef98f356 100644 --- a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx +++ b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx @@ -390,7 +390,7 @@ const MergeRequestsPageWrapper = ({ children }: PropsWithChildren<{}>) => { rel="noreferrer" href="https://github.com/orgs/supabase/discussions/18937" > - Branching Feedback + Branching feedback 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 0000000000000..1f05edaf58d24 Binary files /dev/null and b/apps/studio/public/img/previews/queue-operations-table-preview.png differ 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 0000000000000..d52898f85f824 --- /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 43623fd2d09d3..a45f9672c4451 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 b370f2b8e9bee..8811784daf389 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 dd824ace1cfca..86475d95ba7ef 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -9,6 +9,7 @@ export const LOCAL_STORAGE_KEYS = { PROJECTS_VIEW: 'projects-view', FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content', FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot', + MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`, UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls', @@ -17,6 +18,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 +137,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,