Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion apps/studio/components/grid/SupabaseGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -65,6 +71,7 @@ export const SupabaseGrid = ({
isError,
isPending: isLoading,
isRefetching,
dataUpdatedAt,
} = useTableRowsQuery(
{
projectRef: project?.ref,
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion apps/studio/components/grid/components/editor/TextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -44,6 +45,7 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
const [isPopoverOpen, setIsPopoverOpen] = useState(true)
const [value, setValue] = useState<string | null>(initialValue)
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()

const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation()

Expand Down Expand Up @@ -169,7 +171,14 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
size="tiny"
type="default"
htmlType="button"
onClick={() => setIsConfirmNextModalOpen(true)}
onClick={() => {
if (isQueueOperationsEnabled) {
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
saveChanges(null)
} else {
setIsConfirmNextModalOpen(true)
}
}}
>
Set to NULL
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Eye } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { createPortal } from 'react-dom'
import { Button } from 'ui'

import {
useOperationQueueShortcuts,
getModKey,
} from 'components/grid/hooks/useOperationQueueShortcuts'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'

export const SaveQueueActionBar = () => {
const snap = useTableEditorStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { handleSave } = useOperationQueueActions()

const operationCount = snap.operationQueue.operations.length
const isSaving = snap.operationQueue.status === 'saving'
const isOperationQueuePanelOpen = snap.sidePanel?.type === 'operation-queue'

const isVisible =
isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen

useOperationQueueShortcuts({
enabled: isQueueOperationsEnabled && snap.hasPendingOperations,
onSave: handleSave,
onTogglePanel: () => snap.onViewOperationQueue(),
isSaving,
hasOperations: operationCount > 0,
})

const modKey = getModKey()

const content = (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
>
<div className="flex items-center gap-8 px-4 py-3 bg-surface-100 border rounded-lg shadow-lg">
<span className="text-sm text-foreground">
{operationCount} pending change{operationCount !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-3">
<button
onClick={() => snap.onViewOperationQueue()}
className="text-foreground-light hover:text-foreground transition-colors flex items-center"
aria-label="View Details"
>
<Eye size={14} />
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}.`}</span>
</button>
<Button
size="tiny"
type="primary"
onClick={handleSave}
disabled={isSaving}
loading={isSaving}
>
Save
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}S`}</span>
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)

if (typeof document === 'undefined') return null
return createPortal(content, document.body)
}
73 changes: 64 additions & 9 deletions apps/studio/components/grid/components/grid/Grid.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -65,9 +68,17 @@ export const Grid = memo(
snap.setSelectedRows(selectedRows)
}

const selectedCellRef = useRef<{ rowIdx: number; row: any; column: any } | null>(null)
const selectedCellRef = useRef<{
rowIdx: number
row: SupaRow
column: CalculatedColumn<SupaRow, unknown>
} | null>(null)

function onSelectedCellChange(args: { rowIdx: number; row: any; column: any }) {
function onSelectedCellChange(args: {
rowIdx: number
row: SupaRow
column: CalculatedColumn<SupaRow, unknown>
}) {
selectedCellRef.current = args
snap.setSelectedCellPosition({ idx: args.column.idx, rowIdx: args.rowIdx })
}
Expand Down Expand Up @@ -122,20 +133,60 @@ export const Grid = memo(
return fk !== undefined ? formatForeignKeys([fk])[0] : undefined
}

function onRowDoubleClick(row: any, column: any) {
function onRowDoubleClick(row: SupaRow, column: { name: string }) {
const foreignKey = getColumnForeignKey(column.name)

if (foreignKey) {
tableEditorSnap.onEditForeignKeyColumnValue({
foreignKey,
row,
column,
column: column as unknown as PostgresColumn,
})
}
}

const removeAllFilters = () => onApplyFilters([])

// Compute columns with cellClass for dirty cells
// This needs to be computed at render time so it reacts to operation queue changes
const columnsWithDirtyCellClass = useMemo(() => {
const primaryKeys = isTableLike(snap.originalTable) ? snap.originalTable.primary_keys : []
const pendingOperations = tableEditorSnap.operationQueue.operations

// If no pending operations, return columns as-is
if (pendingOperations.length === 0) {
return snap.gridColumns as CalculatedColumn<SupaRow, unknown>[]
}

return (snap.gridColumns as CalculatedColumn<SupaRow, unknown>[]).map((col) => {
// Skip special columns like select column
if (col.key === 'select-row' || col.key === 'add-column') {
return col
}

return {
...col,
cellClass: (row: SupaRow) => {
// Build row identifiers from primary keys
const rowIdentifiers: Record<string, unknown> = {}
for (const pk of primaryKeys) {
rowIdentifiers[pk.name] = row[pk.name]
}

// Check if this cell has pending changes
// Since we are checking for cell changes, we need to use the EDIT_CELL_CONTENT type
const isDirty = tableEditorSnap.hasPendingCellChange(
QueuedOperationType.EDIT_CELL_CONTENT,
snap.table.id,
rowIdentifiers,
col.key
)
return isDirty ? 'rdg-cell--dirty' : undefined
},
}
})
}, [snap.gridColumns, snap.originalTable, snap.table.id, tableEditorSnap])

return (
<div
className={cn('flex flex-col relative transition-colors', containerClass)}
Expand Down Expand Up @@ -245,7 +296,7 @@ export const Grid = memo(
ref={ref}
className={`${gridClass} flex-grow`}
rowClass={rowClass}
columns={snap.gridColumns as CalculatedColumn<any, any>[]}
columns={columnsWithDirtyCellClass}
rows={rows ?? []}
renderers={{ renderRow: RowRenderer }}
rowKeyGetter={rowKeyGetter}
Expand All @@ -254,7 +305,11 @@ export const Grid = memo(
onRowsChange={onRowsChange}
onSelectedCellChange={onSelectedCellChange}
onSelectedRowsChange={onSelectedRowsChange}
onCellDoubleClick={(props) => onRowDoubleClick(props.row, props.column)}
onCellDoubleClick={(props) => {
if (typeof props.column.name === 'string') {
onRowDoubleClick(props.row, { name: props.column.name })
}
}}
onCellKeyDown={handleCopyCell}
/>
</div>
Expand Down
Loading
Loading