Revert "Revert "Fix queue operations stale state + refactor to reuse existing…" (#42291)

TBD

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

* **New Features**
* OS-aware modifier-key label shown in UI (⌘ on macOS, Ctrl+ on others)
  * Tooltips added to operation queue actions for improved usability

* **Bug Fixes**
  * Warns before applying non-primary-key sorts on large tables
  * Clarified access error messages for organizations and projects

* **Refactor**
  * Redesigned hotkey handling and operation-queue shortcuts
* Operation queue view toggle and save flow refined (panel auto-closes
after save)

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-01-30 10:44:42 +08:00
1 parent 8f08df1600
commit f90f3b4d14
10 files changed
+120 -127

No files matched your search

@@ -1,21 +1,22 @@
import { Eye } from 'lucide-react'
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { AnimatePresence, motion } from 'framer-motion'
import { Eye } from 'lucide-react'
import { createPortal } from 'react-dom'
import { useTableEditorStateSnapshot } from 'state/table-editor'
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'
import { getModKeyLabel } from '@/lib/helpers'
export const SaveQueueActionBar = () => {
const modKey = getModKeyLabel()
const snap = useTableEditorStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { handleSave } = useOperationQueueActions()
useOperationQueueShortcuts()
const operationCount = snap.operationQueue.operations.length
const isSaving = snap.operationQueue.status === 'saving'
const isOperationQueuePanelOpen = snap.sidePanel?.type === 'operation-queue'
@@ -23,16 +24,6 @@ export const SaveQueueActionBar = () => {
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 && (
@@ -49,7 +40,7 @@ export const SaveQueueActionBar = () => {
</span>
<div className="flex items-center gap-3">
<button
onClick={() => snap.onViewOperationQueue()}
onClick={() => snap.toggleViewOperationQueue()}
className="text-foreground-light hover:text-foreground transition-colors flex items-center"
aria-label="View Details"
>
@@ -73,6 +64,6 @@ export const SaveQueueActionBar = () => {
</AnimatePresence>
)
if (typeof document === 'undefined') return null
if (typeof document === 'undefined' || !document.body) return null
return createPortal(content, document.body)
}
@@ -1,15 +1,14 @@
import { THRESHOLD_COUNT } from '@supabase/pg-meta/src/query/table-row-query'
import { keepPreviousData } from '@tanstack/react-query'
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import type { Sort } from 'components/grid/types'
import { InlineLink } from 'components/ui/InlineLink'
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
type RoleImpersonationState,
useRoleImpersonationStateSnapshot,
@@ -23,6 +22,7 @@ import {
Popover_Shadcn_,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { DropdownControl } from '../../common/DropdownControl'
import SortRow from './SortRow'
@@ -278,8 +278,10 @@ export const SortPopoverPrimitive = ({
const hasSortNotPK = localSorts.some(
(x) => !snap.table.columns.find((y) => x.column === y.name)?.isPrimaryKey
)
if (hasSortNotPK) setShowWarning(true)
} else onSelectApplySorts()
if (hasSortNotPK) return setShowWarning(true)
}
onSelectApplySorts()
}}
>
Apply sorting
@@ -1,10 +1,9 @@
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 { useCallback } from 'react'
import { toast } from 'sonner'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
@@ -30,6 +29,7 @@ export function useOperationQueueActions(options: UseOperationQueueActionsOption
useOperationQueueSaveMutation({
onSuccess: () => {
snap.clearQueue()
snap.closeSidePanel()
toast.success('Changes saved successfully')
onSaveSuccess?.()
},
@@ -40,12 +40,10 @@ export function useOperationQueueActions(options: UseOperationQueueActionsOption
})
const isSaving = snap.operationQueue.status === 'saving' || isMutationPending
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
const handleSave = useCallback(() => {
if (!project) return
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
if (operations.length === 0) return
if (!project || operations.length === 0) return
snap.setQueueStatus('saving')
@@ -55,7 +53,7 @@ export function useOperationQueueActions(options: UseOperationQueueActionsOption
operations,
roleImpersonationState: getImpersonatedRoleState(),
})
}, [snap, project, saveOperationQueue, getImpersonatedRoleState])
}, [snap, project, operations, saveOperationQueue, getImpersonatedRoleState])
const handleCancel = useCallback(() => {
// Get unique table IDs from the queue before clearing
@@ -1,19 +1,7 @@
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
}
import { useOperationQueueActions } from './useOperationQueueActions'
import { useIsQueueOperationsEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useHotKey } from '@/hooks/ui/useHotKey'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
/**
* Hook that provides keyboard shortcuts for the operation queue.
@@ -25,44 +13,34 @@ interface UseOperationQueueShortcutsOptions {
* 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+'
export function useOperationQueueShortcuts() {
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const snap = useTableEditorStateSnapshot()
const { handleSave } = useOperationQueueActions()
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isMod = os === 'macos' ? event.metaKey : event.ctrlKey
const isSaving = snap.operationQueue.status === 'saving'
const hasOperations = snap.hasPendingOperations
const isEnabled = isQueueOperationsEnabled && hasOperations
if (isMod && event.key === 's') {
event.preventDefault()
event.stopPropagation()
if (!isSaving && hasOperations) {
onSave()
}
} else if (isMod && event.key === '.') {
event.preventDefault()
event.stopPropagation()
onTogglePanel()
useHotKey(
(event) => {
event.preventDefault()
event.stopPropagation()
if (!isSaving && hasOperations) {
handleSave()
}
},
[os, isSaving, hasOperations, onSave, onTogglePanel]
's',
{ enabled: isEnabled }
)
// 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 }
useHotKey(
(event) => {
event.preventDefault()
event.stopPropagation()
snap.toggleViewOperationQueue()
},
'.',
{ enabled: isEnabled }
)
}
@@ -1,7 +1,3 @@
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsMFAEnabled, useParams } from 'common'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
@@ -10,6 +6,9 @@ import useLatest from 'hooks/misc/useLatest'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
import { toast } from 'sonner'
// Ideally these could all be within a _middleware when we use Next 12
export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
@@ -70,7 +69,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
const isValidOrg = organizations.some((org) => org.slug === slug)
if (!isValidOrg) {
toast.error("We couldn't find that organization")
toast.error('You do not have access to this organization')
router.push(`${DEFAULT_HOME}?error=org_not_found&org=${slug}`)
return
}
@@ -83,7 +82,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
// A successful request to project details will validate access to both project and branches
if (!!ref && isErrorProject) {
toast.error('This project does not exist')
toast.error('You do not have access to this project')
router.push(DEFAULT_HOME)
return
}
@@ -1,12 +1,12 @@
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 { X } from 'lucide-react'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
interface OperationItemProps {
operationId: string
@@ -50,17 +50,18 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
<div className="text-sm text-foreground-muted mt-0.5">
<span className="font-medium text-foreground">{columnName}</span>
<span className="text-foreground-muted mx-2">·</span>
<span className="text-foreground-muted mx-2">•</span>
<span className="text-foreground text-xs">where {whereClause}</span>
</div>
</div>
<Button
<ButtonTooltip
type="text"
size="tiny"
icon={<X size={14} />}
onClick={handleDelete}
className="shrink-0"
className="shrink-0 w-7"
aria-label="Remove operation"
tooltip={{ content: { side: 'bottom', text: 'Remove operation' } }}
/>
</div>
@@ -1,9 +1,9 @@
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 { getModKeyLabel } from '@/lib/helpers'
import { QueuedOperation } from '@/state/table-editor-operation-queue.types'
interface OperationQueueSidePanelProps {
@@ -12,6 +12,7 @@ interface OperationQueueSidePanelProps {
}
export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueSidePanelProps) => {
const modKey = getModKeyLabel()
const snap = useTableEditorStateSnapshot()
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
@@ -21,14 +22,6 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
onCancelSuccess: closePanel,
})
const { modKey } = useOperationQueueShortcuts({
enabled: visible,
onSave: handleSave,
onTogglePanel: closePanel,
isSaving,
hasOperations: operations.length > 0,
})
return (
<SidePanel
size="large"
@@ -37,7 +30,7 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
header={
<div className="flex items-center justify-between w-full">
<div className="flex flex-col gap-1">
<span>Pending Changes</span>
<span>Pending changes</span>
<span className="text-xs text-foreground-light">
{operations.length} operation{operations.length !== 1 ? 's' : ''}
</span>
+34 -10
View File
@@ -1,25 +1,49 @@
import { useEffect } from 'react'
import { useLatest } from 'react-use'
export function useHotKey(
// [Joshen] Refactor: Remove dependencies, and just make this into a single definition
function useHotKey(
callback: (e: KeyboardEvent) => void,
key: string,
dependencies: any[] = [],
options?: { enabled?: boolean }
): void
/**
* @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead.
*/
function useHotKey(
callback: (e: KeyboardEvent) => void,
key: string,
dependencies: unknown[],
options?: { enabled?: boolean }
): void
function useHotKey(
callback: (e: KeyboardEvent) => void,
key: string,
dependenciesOrOptions?: unknown[] | { enabled?: boolean },
options?: { enabled?: boolean }
): void {
const enabled = options?.enabled ?? true
// Determine which overload was called
const isDepsArray = Array.isArray(dependenciesOrOptions)
const resolvedOptions = isDepsArray ? options : dependenciesOrOptions
const enabled = resolvedOptions?.enabled ?? true
const enabledRef = useLatest(enabled)
const callbackRef = useLatest(callback)
const keyRef = useLatest(key)
useEffect(() => {
if (!enabled) return
function handler(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === key && !e.altKey && !e.shiftKey) {
callback(e)
if (!enabledRef.current) return
if ((e.metaKey || e.ctrlKey) && e.key === keyRef.current && !e.altKey && !e.shiftKey) {
callbackRef.current(e)
}
}
window.addEventListener('keydown', handler)
window.addEventListener('keydown', handler, true)
return () => {
window.removeEventListener('keydown', handler)
window.removeEventListener('keydown', handler, true)
}
}, [key, enabled, ...dependencies])
}, [callbackRef, enabledRef, keyRef])
}
export { useHotKey }
+5
View File
@@ -182,6 +182,11 @@ export const detectOS = () => {
}
}
export const getModKeyLabel = () => {
const os = detectOS()
return os === 'macos' ? '⌘' : 'Ctrl+'
}
/**
* Convert a list of tables to SQL
* @param t - The list of tables
+12 -10
View File
@@ -1,7 +1,4 @@
import type { PostgresColumn } from '@supabase/postgres-meta'
import { PropsWithChildren, createContext, useContext } from 'react'
import { proxy, useSnapshot } from 'valtio'
import { useConstant } from 'common'
import type { SupaRow } from 'components/grid/types'
import {
@@ -11,15 +8,16 @@ import {
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 { PropsWithChildren, createContext, useContext } from 'react'
import type { Dictionary } from 'types'
import { proxy, useSnapshot } from 'valtio'
import {
NewQueuedOperation,
QueuedOperationType,
type EditCellContentPayload,
type OperationQueueState,
type QueuedOperation,
type QueueStatus,
type QueuedOperation,
QueuedOperationType,
} from './table-editor-operation-queue.types'
export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100
@@ -203,10 +201,14 @@ export const createTableEditorState = () => {
sidePanel: { type: 'csv-import', file },
}
},
onViewOperationQueue: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'operation-queue' },
toggleViewOperationQueue: () => {
if (state.ui.open === 'side-panel' && state.ui.sidePanel.type === 'operation-queue') {
state.closeSidePanel()
} else {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'operation-queue' },
}
}
},