mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
}
|
||||
|
||||
+8
-7
@@ -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>
|
||||
|
||||
|
||||
+3
-10
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user