mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: refactor queue operations with write operations into a single hook (#44097)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Since queue operations is a feature users can opt-out of, we need to make it cleaner to toggle between queuing vs straight edits. To do this, refactor all the operations into a single hook and reference it in places where we mutate the rows. ## Testing - Test edit cells, rows, and deletes for non queue operations - Test edit cells, rows, and deletes for queue operations, also double check modifying the same rows that are not yet added --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
1 parent
c0e60adf3d
commit
917a686fee
14 files changed
+641
-282
No files matched your search
@@ -1,17 +1,17 @@
|
||||
import { Maximize } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { RenderEditCellProps } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
|
||||
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'
|
||||
import { useGetCellValueMutation } from 'data/table-rows/get-cell-value-mutation'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Button, Popover, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui'
|
||||
import { Maximize } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { RenderEditCellProps } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn, Popover, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
|
||||
import { BlockKeys } from '../common/BlockKeys'
|
||||
import { EmptyValue } from '../common/EmptyValue'
|
||||
import { MonacoEditor } from '../common/MonacoEditor'
|
||||
@@ -45,7 +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 { isQueueEnabled } = useTableRowOperations()
|
||||
|
||||
const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation()
|
||||
|
||||
@@ -58,7 +58,7 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
|
||||
}
|
||||
|
||||
const pkMatch = selectedTable.primary_keys.reduce((a, b) => {
|
||||
return { ...a, [b.name]: (row as any)[b.name] }
|
||||
return { ...a, [b.name]: row[b.name as keyof typeof row] }
|
||||
}, {})
|
||||
|
||||
getCellValue(
|
||||
@@ -172,8 +172,8 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={() => {
|
||||
if (isQueueOperationsEnabled) {
|
||||
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
|
||||
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
|
||||
if (isQueueEnabled) {
|
||||
saveChanges(null)
|
||||
} else {
|
||||
setIsConfirmNextModalOpen(true)
|
||||
|
||||
@@ -1,91 +1,22 @@
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query'
|
||||
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'
|
||||
import { tableRowKeys } from 'data/table-rows/keys'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import type { TableRowsData } from 'data/table-rows/table-rows-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from 'lib/constants'
|
||||
import { useCallback } from 'react'
|
||||
import { RowsChangeData } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
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'
|
||||
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
|
||||
|
||||
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({
|
||||
async onMutate({ projectRef, table, configuration, payload }) {
|
||||
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
|
||||
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
|
||||
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
|
||||
const previousRowsQueries = queryClient.getQueriesData<TableRowsData>({ queryKey })
|
||||
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
|
||||
return {
|
||||
rows: old.rows.map((row) => {
|
||||
// match primary keys
|
||||
if (
|
||||
Object.entries(row)
|
||||
.filter(([key]) => primaryKeyColumns.has(key))
|
||||
.every(([key, value]) => value === configuration.identifiers[key])
|
||||
) {
|
||||
return { ...row, ...payload }
|
||||
}
|
||||
|
||||
return row
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
return { previousRowsQueries }
|
||||
},
|
||||
onError(error, _variables, context) {
|
||||
const { previousRowsQueries } = context as {
|
||||
previousRowsQueries: [
|
||||
QueryKey,
|
||||
(
|
||||
| {
|
||||
result: any[]
|
||||
}
|
||||
| undefined
|
||||
),
|
||||
][]
|
||||
}
|
||||
|
||||
previousRowsQueries.forEach(([queryKey, previousRows]) => {
|
||||
if (previousRows) {
|
||||
queryClient.setQueriesData({ queryKey }, previousRows)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
})
|
||||
|
||||
toast.error(error?.message ?? error)
|
||||
},
|
||||
})
|
||||
const { editCell } = useTableRowOperations()
|
||||
|
||||
return useCallback(
|
||||
(_rows: SupaRow[], data: RowsChangeData<SupaRow, unknown>) => {
|
||||
if (!project) return
|
||||
|
||||
const rowData = _rows[data.indexes[0]]
|
||||
const previousRow = rows.find((x) => x.idx == rowData.idx)
|
||||
const changedColumn = Object.keys(rowData).find(
|
||||
@@ -127,44 +58,17 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
})
|
||||
}
|
||||
|
||||
const configuration = { identifiers }
|
||||
|
||||
if (isQueueOperationsEnabled) {
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
tableId: snap.table.id,
|
||||
table: snap.originalTable,
|
||||
row: previousRow,
|
||||
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(),
|
||||
})
|
||||
}
|
||||
editCell({
|
||||
tableId: snap.table.id,
|
||||
table: snap.originalTable,
|
||||
row: previousRow,
|
||||
rowIdentifiers: identifiers,
|
||||
columnName: changedColumn,
|
||||
oldValue: previousRow[changedColumn],
|
||||
newValue: rowData[changedColumn],
|
||||
enumArrayColumns,
|
||||
})
|
||||
},
|
||||
[
|
||||
getImpersonatedRoleState,
|
||||
isQueueOperationsEnabled,
|
||||
mutateUpdateTableRow,
|
||||
project,
|
||||
rows,
|
||||
snap.originalTable,
|
||||
snap.table.id,
|
||||
tableEditorSnap,
|
||||
]
|
||||
[editCell, rows, snap.originalTable, snap.table.id]
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
|
||||
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
|
||||
import { useTableSort } from 'components/grid/hooks/useTableSort'
|
||||
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
|
||||
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
import {
|
||||
@@ -225,11 +224,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const { id: _id } = useParams()
|
||||
const tableId = _id ? Number(_id) : undefined
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const { deleteRows } = useTableRowOperations()
|
||||
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
const isImpersonatingRole = roleImpersonationState.role !== undefined
|
||||
@@ -278,22 +276,11 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const rowIdxs = Array.from(snap.selectedRows) as number[]
|
||||
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
|
||||
|
||||
// Queue delete operations directly if queue mode is enabled (and not all rows selected)
|
||||
if (isQueueOperationsEnabled && !snap.allRowsSelected) {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows,
|
||||
table: snap.originalTable,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
snap.resetSelectedRows()
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to confirmation dialog
|
||||
tableEditorSnap.onDeleteRows(rows, {
|
||||
deleteRows({
|
||||
rows,
|
||||
table: snap.originalTable,
|
||||
allRowsSelected: snap.allRowsSelected,
|
||||
numRows: snap.allRowsSelected ? totalRows : rows.length,
|
||||
totalRows,
|
||||
callback: () => {
|
||||
snap.resetSelectedRows()
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
|
||||
import { useTableSort } from 'components/grid/hooks/useTableSort'
|
||||
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
|
||||
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
import {
|
||||
@@ -219,11 +218,10 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const { id: _id } = useParams()
|
||||
const tableId = _id ? Number(_id) : undefined
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const { deleteRows } = useTableRowOperations()
|
||||
|
||||
const roleImpersonationState = useRoleImpersonationStateSnapshot()
|
||||
const isImpersonatingRole = roleImpersonationState.role !== undefined
|
||||
@@ -272,22 +270,11 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const rowIdxs = Array.from(snap.selectedRows) as number[]
|
||||
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
|
||||
|
||||
// Queue delete operations directly if queue mode is enabled (and not all rows selected)
|
||||
if (isQueueOperationsEnabled && !snap.allRowsSelected) {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows,
|
||||
table: snap.originalTable,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
snap.resetSelectedRows()
|
||||
return
|
||||
}
|
||||
|
||||
const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size
|
||||
tableEditorSnap.onDeleteRows(rows, {
|
||||
deleteRows({
|
||||
rows,
|
||||
table: snap.originalTable,
|
||||
allRowsSelected: snap.allRowsSelected,
|
||||
numRows,
|
||||
totalRows,
|
||||
callback: () => {
|
||||
snap.resetSelectedRows()
|
||||
},
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
|
||||
import {
|
||||
useIsQueueOperationsEnabled,
|
||||
useIsTableFilterBarEnabled,
|
||||
} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsTableFilterBarEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { Copy, Edit, ListFilter, Trash } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { Item, ItemParams, Menu } from 'react-contexify'
|
||||
@@ -15,6 +9,7 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
|
||||
import { copyToClipboard, DialogSectionSeparator } from 'ui'
|
||||
|
||||
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
|
||||
import { formatClipboardValue } from '../../utils/common'
|
||||
import { buildFilterFromCellValue, isComplexValue } from '../header/filter/FilterPopoverNew.utils'
|
||||
|
||||
@@ -25,11 +20,10 @@ type RowContextMenuProps = {
|
||||
type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string>
|
||||
|
||||
export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const snap = useTableEditorTableStateSnapshot()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const isTableFilterBarEnabled = useIsTableFilterBarEnabled()
|
||||
const { deleteRows } = useTableRowOperations()
|
||||
|
||||
function onDeleteRow(p: RowContextMenuItemProps) {
|
||||
const rowIdx = p.props?.rowIdx
|
||||
@@ -41,17 +35,7 @@ export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isQueueOperationsEnabled) {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows: [row],
|
||||
table: snap.originalTable,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tableEditorSnap.onDeleteRows([row])
|
||||
deleteRows({ rows: [row], table: snap.originalTable })
|
||||
}
|
||||
|
||||
function onEditRowClick(p: RowContextMenuItemProps) {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SupaRow } from 'components/grid/types'
|
||||
import {
|
||||
queueCellEditWithOptimisticUpdate,
|
||||
queueRowAddWithOptimisticUpdate,
|
||||
queueRowDeletesWithOptimisticUpdate,
|
||||
} from 'components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
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'
|
||||
import type { TableRowsData } from 'data/table-rows/table-rows-query'
|
||||
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 type { Dictionary } from 'types'
|
||||
|
||||
import type { PendingAddRow } from '../types'
|
||||
|
||||
export interface EditCellParams {
|
||||
table: Entity
|
||||
tableId: number
|
||||
row: SupaRow
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
columnName: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
enumArrayColumns?: string[]
|
||||
/** When true, shows a success toast on non-queue save (used by side panel, not grid inline edits) */
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export interface AddRowParams {
|
||||
table: Entity
|
||||
tableId: number
|
||||
rowData: PendingAddRow
|
||||
enumArrayColumns?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateRowParams {
|
||||
table: Entity
|
||||
tableId: number
|
||||
row: SupaRow
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
payload: Dictionary<unknown>
|
||||
enumArrayColumns?: string[]
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export interface DeleteRowsParams {
|
||||
rows: SupaRow[]
|
||||
table: Entity
|
||||
allRowsSelected?: boolean
|
||||
totalRows?: number
|
||||
callback?: () => void
|
||||
}
|
||||
|
||||
export function useTableRowOperations() {
|
||||
const isQueueEnabled = useIsQueueOperationsEnabled()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const tableEditorSnap = useTableEditorStateSnapshot()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
|
||||
// Non-queue mutation for cell edits with optimistic updates
|
||||
const { mutateAsync: mutateUpdateTableRow, isPending: isEditPending } = useTableRowUpdateMutation(
|
||||
{
|
||||
async onMutate({ projectRef, table, configuration, payload }) {
|
||||
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
|
||||
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
|
||||
|
||||
await queryClient.cancelQueries({ queryKey })
|
||||
|
||||
const previousRowsQueries = queryClient.getQueriesData<TableRowsData>({ queryKey })
|
||||
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
|
||||
if (!old) return old
|
||||
return {
|
||||
rows: old.rows.map((row) => {
|
||||
if (
|
||||
Object.entries(row)
|
||||
.filter(([key]) => primaryKeyColumns.has(key))
|
||||
.every(([key, value]) => value === configuration.identifiers[key])
|
||||
) {
|
||||
return { ...row, ...payload }
|
||||
}
|
||||
return row
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
return { previousRowsQueries }
|
||||
},
|
||||
onError(error, _variables, context) {
|
||||
const { previousRowsQueries } = (context ?? { previousRowsQueries: [] }) as {
|
||||
previousRowsQueries: [QueryKey, TableRowsData | undefined][]
|
||||
}
|
||||
|
||||
previousRowsQueries.forEach(([queryKey, previousRows]) => {
|
||||
if (previousRows) {
|
||||
queryClient.setQueriesData<TableRowsData>({ queryKey }, previousRows)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey })
|
||||
})
|
||||
|
||||
toast.error(error?.message ?? error)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Non-queue mutation for row creation
|
||||
const { mutateAsync: mutateCreateTableRow } = useTableRowCreateMutation({
|
||||
onSuccess() {
|
||||
toast.success('Successfully created row')
|
||||
},
|
||||
})
|
||||
|
||||
const editCell = useCallback(
|
||||
async (params: EditCellParams) => {
|
||||
if (isQueueEnabled) {
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
tableId: params.tableId,
|
||||
table: params.table,
|
||||
row: params.row,
|
||||
rowIdentifiers: params.rowIdentifiers,
|
||||
columnName: params.columnName,
|
||||
oldValue: params.oldValue,
|
||||
newValue: params.newValue,
|
||||
enumArrayColumns: params.enumArrayColumns,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!project) return
|
||||
|
||||
const updatedData = { [params.columnName]: params.newValue }
|
||||
await mutateUpdateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: params.table,
|
||||
configuration: { identifiers: params.rowIdentifiers },
|
||||
payload: updatedData,
|
||||
enumArrayColumns: params.enumArrayColumns ?? [],
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
params.onSuccess?.()
|
||||
},
|
||||
[isQueueEnabled, project, tableEditorSnap, mutateUpdateTableRow, getImpersonatedRoleState]
|
||||
)
|
||||
|
||||
const updateRow = useCallback(
|
||||
async (params: UpdateRowParams) => {
|
||||
if (isQueueEnabled) {
|
||||
// Queue individual cell edits per changed column
|
||||
for (const columnName of Object.keys(params.payload)) {
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
tableId: params.tableId,
|
||||
table: params.table,
|
||||
row: params.row,
|
||||
rowIdentifiers: params.rowIdentifiers,
|
||||
columnName,
|
||||
oldValue: params.row[columnName],
|
||||
newValue: params.payload[columnName],
|
||||
enumArrayColumns: params.enumArrayColumns,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!project) return
|
||||
|
||||
await mutateUpdateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: params.table,
|
||||
configuration: { identifiers: params.rowIdentifiers },
|
||||
payload: params.payload,
|
||||
enumArrayColumns: params.enumArrayColumns ?? [],
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
params.onSuccess?.()
|
||||
},
|
||||
[isQueueEnabled, project, tableEditorSnap, mutateUpdateTableRow, getImpersonatedRoleState]
|
||||
)
|
||||
|
||||
const addRow = useCallback(
|
||||
async (params: AddRowParams) => {
|
||||
// Only queue if the table has primary keys (required for queue conflict resolution)
|
||||
const hasPrimaryKeys = isTableLike(params.table) && params.table.primary_keys.length > 0
|
||||
|
||||
if (isQueueEnabled && hasPrimaryKeys) {
|
||||
queueRowAddWithOptimisticUpdate({
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
tableId: params.tableId,
|
||||
table: params.table,
|
||||
rowData: params.rowData,
|
||||
enumArrayColumns: params.enumArrayColumns,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!project) return
|
||||
|
||||
await mutateCreateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: params.table,
|
||||
payload: params.rowData,
|
||||
enumArrayColumns: params.enumArrayColumns ?? [],
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
},
|
||||
[isQueueEnabled, project, tableEditorSnap, mutateCreateTableRow, getImpersonatedRoleState]
|
||||
)
|
||||
|
||||
const deleteRows = useCallback(
|
||||
(params: DeleteRowsParams) => {
|
||||
// When queue is enabled and not all rows are selected, queue the deletes
|
||||
if (isQueueEnabled && !params.allRowsSelected) {
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows: params.rows,
|
||||
table: params.table,
|
||||
queueOperation: tableEditorSnap.queueOperation,
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
params.callback?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, open the confirmation dialog
|
||||
tableEditorSnap.onDeleteRows(params.rows, {
|
||||
allRowsSelected: params.allRowsSelected ?? false,
|
||||
numRows: params.allRowsSelected ? params.totalRows : params.rows.length,
|
||||
callback: params.callback,
|
||||
})
|
||||
},
|
||||
[isQueueEnabled, project, tableEditorSnap]
|
||||
)
|
||||
|
||||
return {
|
||||
editCell,
|
||||
updateRow,
|
||||
addRow,
|
||||
deleteRows,
|
||||
isQueueEnabled,
|
||||
isEditPending,
|
||||
}
|
||||
}
|
||||
@@ -265,8 +265,7 @@ describe('formatGridDataWithOperationValues', () => {
|
||||
expect(result[1].name).toBe('Updated Bob')
|
||||
})
|
||||
|
||||
test('last edit wins when multiple operations target the same row', () => {
|
||||
// Each operation spreads from the original row, so only the last op's column change is preserved
|
||||
test('multiple operations targeting the same row preserve all column changes', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })]
|
||||
const op1 = makeEditOp({
|
||||
id: 'op-1',
|
||||
@@ -290,8 +289,8 @@ describe('formatGridDataWithOperationValues', () => {
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows })
|
||||
// The second op overwrites the first since both spread from the original row
|
||||
expect(result[0].name).toBe('Alice')
|
||||
// Both column edits should be preserved
|
||||
expect(result[0].name).toBe('Updated')
|
||||
expect(result[0].email).toBe('updated@test.com')
|
||||
})
|
||||
|
||||
|
||||
@@ -152,9 +152,9 @@ export const formatGridDataWithOperationValues = ({
|
||||
operations.forEach((op) => {
|
||||
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
||||
const { rowIdentifiers, columnName, newValue } = op.payload
|
||||
const rowMatches = rows.find((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
if (rowMatches) {
|
||||
formattedRows[rowMatches.idx] = { ...rowMatches, [columnName]: newValue }
|
||||
const rowIdx = rows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
if (rowIdx !== -1) {
|
||||
formattedRows[rowIdx] = { ...formattedRows[rowIdx], [columnName]: newValue }
|
||||
}
|
||||
} else if (op.type === QueuedOperationType.ADD_ROW) {
|
||||
const { tempId, rowData } = op.payload
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ import {
|
||||
} from './ColumnEditor.utils'
|
||||
import ColumnForeignKey from './ColumnForeignKey'
|
||||
import ColumnType from './ColumnType'
|
||||
import HeaderTitle from './HeaderTitle'
|
||||
import { HeaderTitle } from './HeaderTitle'
|
||||
|
||||
export interface ColumnEditorProps {
|
||||
column?: Readonly<PostgresColumn>
|
||||
|
||||
+5
-7
@@ -1,25 +1,23 @@
|
||||
import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
|
||||
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
interface Props {
|
||||
table: PostgresTable
|
||||
column: PostgresColumn
|
||||
}
|
||||
|
||||
// Need to fix for new column later
|
||||
const HeaderTitle: React.FC<Props> = ({ table, column }) => {
|
||||
export const HeaderTitle = ({ table, column }: Props) => {
|
||||
if (!column) {
|
||||
return (
|
||||
<>
|
||||
<span>Add new column to</span>
|
||||
<code className="ml-1">{table.name}</code>
|
||||
<code className="text-code-inline !text-sm ml-1">{table.name}</code>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Update column <code>{column.name}</code> from <code>{column.table}</code>
|
||||
Update column <code className="text-code-inline !text-sm">{column.name}</code> from{' '}
|
||||
<code className="text-code-inline !text-sm">{column.table}</code>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderTitle
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => {
|
||||
return (
|
||||
<span>
|
||||
{header}
|
||||
{tableName && <span className="text-code font-mono">{tableName}</span>}
|
||||
{tableName && <code className="text-code-inline !text-sm">{tableName}</code>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
+16
-86
@@ -2,11 +2,7 @@ import * as Sentry from '@sentry/nextjs'
|
||||
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import {
|
||||
queueCellEditWithOptimisticUpdate,
|
||||
queueRowAddWithOptimisticUpdate,
|
||||
} from 'components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
|
||||
import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
|
||||
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { databasePoliciesKeys } from 'data/database-policies/keys'
|
||||
@@ -23,8 +19,6 @@ import { privilegeKeys } from 'data/privileges/keys'
|
||||
import { tableEditorKeys } from 'data/table-editor/keys'
|
||||
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'
|
||||
import { tableKeys } from 'data/tables/keys'
|
||||
import { RetrieveTableResult } from 'data/tables/table-retrieve-query'
|
||||
import { getTables } from 'data/tables/tables-query'
|
||||
@@ -36,7 +30,6 @@ import { useTrack } from 'lib/telemetry/track'
|
||||
import { isEmpty, isUndefined, noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
|
||||
import { useTableEditorStateSnapshot, type TableEditorState } from 'state/table-editor'
|
||||
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
|
||||
import type { Dictionary } from 'types'
|
||||
@@ -202,10 +195,8 @@ export const SidePanelEditor = ({
|
||||
const queryClient = useQueryClient()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
|
||||
const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
|
||||
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
|
||||
const { updateRow, addRow, isEditPending } = useTableRowOperations()
|
||||
|
||||
const [isEdited, setIsEdited] = useState<boolean>(false)
|
||||
|
||||
@@ -241,16 +232,6 @@ export const SidePanelEditor = ({
|
||||
})
|
||||
.map((column) => column.name)
|
||||
|
||||
const { mutateAsync: createTableRows } = useTableRowCreateMutation({
|
||||
onSuccess() {
|
||||
toast.success('Successfully created row')
|
||||
},
|
||||
})
|
||||
const { mutateAsync: updateTableRow, isPending: isUpdatingRow } = useTableRowUpdateMutation({
|
||||
onSuccess() {
|
||||
toast.success('Successfully updated row')
|
||||
},
|
||||
})
|
||||
const { mutateAsync: createPublication } = useDatabasePublicationCreateMutation()
|
||||
const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({
|
||||
onError: () => {},
|
||||
@@ -273,32 +254,13 @@ export const SidePanelEditor = ({
|
||||
|
||||
let saveRowError: Error | undefined
|
||||
if (isNewRecord) {
|
||||
// Queue the ADD_ROW operation if queue operations feature is enabled
|
||||
if (isQueueOperationsEnabled && selectedTable.primary_keys.length > 0) {
|
||||
queueRowAddWithOptimisticUpdate({
|
||||
queueOperation: snap.queueOperation,
|
||||
try {
|
||||
await addRow({
|
||||
tableId: selectedTable.id,
|
||||
table: selectedTable as unknown as Entity,
|
||||
rowData: payload,
|
||||
enumArrayColumns,
|
||||
})
|
||||
|
||||
// Close panel immediately without error
|
||||
onComplete()
|
||||
setIsEdited(false)
|
||||
if (!configuration.createMore) snap.closeSidePanel()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createTableRows({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: selectedTable,
|
||||
payload,
|
||||
enumArrayColumns,
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
})
|
||||
} catch (error: any) {
|
||||
saveRowError = error
|
||||
}
|
||||
@@ -306,56 +268,24 @@ 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 = getRowFromSidePanel(snap.sidePanel)
|
||||
|
||||
const row = getRowFromSidePanel(snap.sidePanel)
|
||||
|
||||
if (!row) {
|
||||
saveRowError = new Error('No row found')
|
||||
toast.error('No row found')
|
||||
onComplete(saveRowError)
|
||||
return
|
||||
}
|
||||
|
||||
const oldValue = row[changedColumn]
|
||||
|
||||
queueCellEditWithOptimisticUpdate({
|
||||
queueOperation: snap.queueOperation,
|
||||
tableId: selectedTable.id,
|
||||
// Cast to Entity - the queue save mutation only uses id, name, schema
|
||||
table: selectedTable as unknown as Entity,
|
||||
row,
|
||||
rowIdentifiers: configuration.identifiers,
|
||||
columnName: changedColumn,
|
||||
oldValue: oldValue,
|
||||
newValue: payload[changedColumn],
|
||||
enumArrayColumns,
|
||||
})
|
||||
|
||||
// Close panel immediately without error
|
||||
onComplete()
|
||||
setIsEdited(false)
|
||||
snap.closeSidePanel()
|
||||
if (!row) {
|
||||
saveRowError = new Error('No row found')
|
||||
toast.error('No row found')
|
||||
onComplete(saveRowError)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: selectedTable,
|
||||
configuration,
|
||||
await updateRow({
|
||||
tableId: selectedTable.id,
|
||||
table: selectedTable as unknown as Entity,
|
||||
row,
|
||||
rowIdentifiers: configuration.identifiers,
|
||||
payload,
|
||||
enumArrayColumns,
|
||||
roleImpersonationState: getImpersonatedRoleState(),
|
||||
onSuccess: () => toast.success('Successfully updated row'),
|
||||
})
|
||||
} catch (error: any) {
|
||||
saveRowError = error
|
||||
@@ -1053,7 +983,7 @@ export const SidePanelEditor = ({
|
||||
? snap.sidePanel.foreignKey.foreignKey
|
||||
: undefined
|
||||
}
|
||||
isSaving={isUpdatingRow}
|
||||
isSaving={isEditPending}
|
||||
closePanel={onClosePanel}
|
||||
onSelect={onSaveForeignRow}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, Page } from '@playwright/test'
|
||||
|
||||
import { createTable, dropTable } from '../utils/db/index.js'
|
||||
import { createTable, dropTable, query } from '../utils/db/index.js'
|
||||
import { test, withSetupCleanup } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
import { waitForTableToLoad } from '../utils/wait-for-response.js'
|
||||
@@ -680,4 +680,79 @@ test.describe('Queue Table Operations', () => {
|
||||
await page.getByRole('button', { name: `View ${tableName2}`, exact: true }).click()
|
||||
await expect(page.getByRole('gridcell', { name: 'pending in table 2' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('editing multiple columns via side panel queues all changes', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_multi_col`
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
created_at timestamp with time zone null default now(),
|
||||
first_name text,
|
||||
last_name text
|
||||
)`
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`,
|
||||
['Alice', 'Smith']
|
||||
)
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
await page.reload()
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible()
|
||||
|
||||
// Right-click to open context menu and edit the row
|
||||
const cell = page.getByRole('gridcell', { name: 'Alice' })
|
||||
await cell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
// Update both columns in the side panel
|
||||
const firstNameInput = page.getByTestId('first_name-input')
|
||||
await expect(firstNameInput).toBeVisible()
|
||||
await firstNameInput.clear()
|
||||
await firstNameInput.fill('Bob')
|
||||
|
||||
const lastNameInput = page.getByTestId('last_name-input')
|
||||
await lastNameInput.clear()
|
||||
await lastNameInput.fill('Jones')
|
||||
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
// Should queue 2 cell edits (one per changed column)
|
||||
await expect(page.getByText('2 pending changes')).toBeVisible()
|
||||
|
||||
// Both values should be optimistically updated in the grid
|
||||
await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'Jones' })).toBeVisible()
|
||||
|
||||
// Review the queued operations
|
||||
await page.getByRole('button', { name: /Review/ }).click()
|
||||
|
||||
const sidePanel = page.getByRole('dialog')
|
||||
await expect(sidePanel.getByText('2 cell edits')).toBeVisible()
|
||||
|
||||
// Save all changes
|
||||
await sidePanel.getByRole('button', { name: /^Save/ }).click()
|
||||
await expect(page.getByText('Changes saved successfully')).toBeVisible()
|
||||
|
||||
// Both columns should reflect the saved values
|
||||
await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'Jones' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'Alice' })).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'Smith' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1234,6 +1234,248 @@ testRunner('table editor', () => {
|
||||
await expect(page.getByRole('gridcell', { name: 'drag drop value 1' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('row insert via side panel saves immediately', async ({ page, ref }) => {
|
||||
const tableName = 'pw_table_row_insert'
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName)
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
// Open side panel to insert a new row
|
||||
await page.getByTestId('table-editor-insert-new-row').click()
|
||||
await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click()
|
||||
await page.getByTestId(`${columnName}-input`).fill('immediate insert')
|
||||
|
||||
// Wait for the POST mutation to complete when saving
|
||||
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
||||
method: 'POST',
|
||||
})
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
await insertPromise
|
||||
|
||||
// Should show success toast
|
||||
await expect(
|
||||
page.getByText('Successfully created row'),
|
||||
'Success toast should appear after immediate row creation'
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Row should be visible in the grid
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'immediate insert' }),
|
||||
'Newly inserted row should be visible in the grid'
|
||||
).toBeVisible()
|
||||
|
||||
// Should NOT show pending changes (queue is off)
|
||||
await expect(
|
||||
page.getByText('pending change'),
|
||||
'No pending changes should appear when queue is disabled'
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('row edit via side panel saves immediately', async ({ page, ref }) => {
|
||||
const tableName = 'pw_table_row_edit'
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName, [{ name: 'original value' }])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'original value' })).toBeVisible()
|
||||
|
||||
// Right-click to open context menu and edit the row
|
||||
const cell = page.getByRole('gridcell', { name: 'original value' })
|
||||
await cell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
// Update the value in the side panel
|
||||
const input = page.getByTestId(`${columnName}-input`)
|
||||
await expect(input).toBeVisible()
|
||||
await input.clear()
|
||||
await input.fill('updated value')
|
||||
|
||||
// Wait for the POST mutation to complete when saving
|
||||
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
||||
method: 'POST',
|
||||
})
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
await updatePromise
|
||||
|
||||
// Updated value should be visible in the grid after immediate save
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'updated value' }),
|
||||
'Updated value should be visible in the grid'
|
||||
).toBeVisible()
|
||||
|
||||
// Original value should be gone
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'original value' }),
|
||||
'Original value should no longer be visible'
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should NOT show pending changes (queue is off)
|
||||
await expect(
|
||||
page.getByText('pending change'),
|
||||
'No pending changes should appear when queue is disabled'
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('editing multiple columns via side panel saves all changes', async ({ page, ref }) => {
|
||||
const tableName = 'pw_table_multi_col_edit'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
created_at timestamp with time zone null default now(),
|
||||
first_name text,
|
||||
last_name text
|
||||
)`
|
||||
)
|
||||
await query(
|
||||
`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`,
|
||||
['Alice', 'Smith']
|
||||
)
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'Smith' })).toBeVisible()
|
||||
|
||||
// Right-click to open context menu and edit the row
|
||||
const cell = page.getByRole('gridcell', { name: 'Alice' })
|
||||
await cell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
// Update both columns in the side panel
|
||||
const firstNameInput = page.getByTestId('first_name-input')
|
||||
await expect(firstNameInput).toBeVisible()
|
||||
await firstNameInput.clear()
|
||||
await firstNameInput.fill('Bob')
|
||||
|
||||
const lastNameInput = page.getByTestId('last_name-input')
|
||||
await lastNameInput.clear()
|
||||
await lastNameInput.fill('Jones')
|
||||
|
||||
// Wait for the POST mutation to complete when saving
|
||||
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
||||
method: 'POST',
|
||||
})
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
await updatePromise
|
||||
|
||||
// Both columns should reflect the updated values
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'Bob' }),
|
||||
'First name should be updated to Bob'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'Jones' }),
|
||||
'Last name should be updated to Jones'
|
||||
).toBeVisible()
|
||||
|
||||
// Original values should be gone
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'Alice' }),
|
||||
'Original first name should no longer be visible'
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'Smith' }),
|
||||
'Original last name should no longer be visible'
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('row delete via context menu shows confirmation dialog', async ({ page, ref }) => {
|
||||
const tableName = 'pw_table_row_delete'
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName, [{ name: 'row to delete' }])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await waitForTableToLoad(page, ref)
|
||||
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'row to delete' })).toBeVisible()
|
||||
|
||||
// Right-click to open context menu and delete the row
|
||||
const cell = page.getByRole('gridcell', { name: 'row to delete' })
|
||||
await cell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete row' }).click()
|
||||
|
||||
// In non-queue mode, a confirmation dialog should appear
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Confirm to delete the selected row' })
|
||||
await expect(
|
||||
confirmDialog,
|
||||
'Confirmation dialog should appear for non-queue row deletion'
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Confirm the deletion
|
||||
const deletePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
|
||||
method: 'POST',
|
||||
})
|
||||
await confirmDialog.getByRole('button', { name: 'Delete' }).click()
|
||||
await deletePromise
|
||||
|
||||
// Row should be gone
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'row to delete' }),
|
||||
'Deleted row should no longer be visible'
|
||||
).not.toBeVisible()
|
||||
|
||||
// Should show 0 records
|
||||
await expect(
|
||||
page.getByText('0 records'),
|
||||
'Table should show 0 records after deletion'
|
||||
).toBeVisible()
|
||||
|
||||
// Should NOT show pending changes (queue is off)
|
||||
await expect(
|
||||
page.getByText('pending change'),
|
||||
'No pending changes should appear when queue is disabled'
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('create a table in a single transaction', async ({ page, ref }) => {
|
||||
const tableName = 'pw_table_create_transaction'
|
||||
|
||||
|
||||
Reference in new issue
Block a user