mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(studio): batched table edits issues (#47319)
## 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? Bug fix ## What is the current behavior? Fixes #47318 Supabase Studio's batched table edit queue has a few related row identity issues: - Editing a row's primary key can make later queued edits or deletes lose track of the original row. - Editing a primary key and another column in the same row before saving can save only the primary key change, because later updates still use the old primary key in the `WHERE` clause. - Adding a row in batched edit mode and then deleting it before saving may not remove the pending row correctly. ## What is the new behavior? - Preserves the original row identity for queued operations after primary key edits. - Applies multiple queued edits for the same row as a single update when saving. - Correctly deletes newly added pending rows before they are saved. - Adds regression coverage for these batched table edit cases. ## Additional context https://github.com/user-attachments/assets/75672361-d781-4fe5-a542-071574ad57bd <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved row identity handling for grid edits, optimistic updates, and queued operations so changes stay correctly attached when primary keys are edited, reverted, or “taken” by another row. * Updated header row deletion to delete from the currently visible/targeted rows rather than relying on the full dataset. * Reduced retry noise for missing tables by clearing conflicting sorts and preventing repeated retries for the same “does not exist” error. * More reliably consolidated queued edits for the same row into fewer combined save statements. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
c6fc456910
commit
719434a7fd
12 files changed
+723
-27
No files matched your search
@@ -91,6 +91,7 @@ export const SupabaseGrid = ({
|
||||
customHeader={customHeader}
|
||||
isRefetching={isRefetching}
|
||||
tableQueriesEnabled={tableQueriesEnabled}
|
||||
rows={rows}
|
||||
/>
|
||||
|
||||
{msSqlWarning.warning !== null && <msSqlWarning.Component />}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useOnRowsChange } from './Grid.utils'
|
||||
import { GridError } from './GridError'
|
||||
import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
|
||||
import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils'
|
||||
import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils'
|
||||
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'
|
||||
@@ -166,7 +167,7 @@ export const Grid = memo(
|
||||
// Check if this cell has pending changes
|
||||
const isDirty = tableEditorSnap.hasPendingCellChange(
|
||||
snap.table.id,
|
||||
rowIdentifiers,
|
||||
getStableRowIdentifiers(row, rowIdentifiers),
|
||||
col.key
|
||||
)
|
||||
return isDirty ? 'rdg-cell--dirty' : undefined
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RowsChangeData } from 'react-data-grid'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useTableRowOperations } from '../../hooks/useTableRowOperations'
|
||||
import { getStableRowIdentifiers } from '../../utils/queueOperationUtils'
|
||||
import { SupaRow } from '@/components/grid/types'
|
||||
import { convertByteaToHex } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
@@ -33,7 +34,7 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
|
||||
const identifiers = {} as Dictionary<any>
|
||||
|
||||
isTableLike(snap.originalTable) &&
|
||||
if (isTableLike(snap.originalTable)) {
|
||||
snap.originalTable.primary_keys.forEach((column) => {
|
||||
const col = snap.originalTable.columns.find((c) => c.name === column.name)
|
||||
identifiers[column.name] =
|
||||
@@ -41,8 +42,10 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
? convertByteaToHex(previousRow[column.name])
|
||||
: previousRow[column.name]
|
||||
})
|
||||
}
|
||||
const stableIdentifiers = getStableRowIdentifiers(previousRow, identifiers)
|
||||
|
||||
if (Object.keys(identifiers).length === 0) {
|
||||
if (Object.keys(stableIdentifiers).length === 0) {
|
||||
return toast('Unable to update row as table has no primary keys', {
|
||||
description: (
|
||||
<div>
|
||||
@@ -62,7 +65,7 @@ export function useOnRowsChange(rows: SupaRow[]) {
|
||||
tableId: snap.table.id,
|
||||
table: snap.originalTable,
|
||||
row: previousRow,
|
||||
rowIdentifiers: identifiers,
|
||||
rowIdentifiers: stableIdentifiers,
|
||||
columnName: changedColumn,
|
||||
oldValue: previousRow[changedColumn],
|
||||
newValue: rowData[changedColumn],
|
||||
|
||||
@@ -21,6 +21,7 @@ import { formatRowsForCSV, hydrateTruncatedRows } from './Header.utils'
|
||||
import { SortPopover } from './sort/SortPopover'
|
||||
import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations'
|
||||
import { useTableSort } from '@/components/grid/hooks/useTableSort'
|
||||
import type { SupaRow } from '@/components/grid/types'
|
||||
import { GridHeaderActions } from '@/components/interfaces/TableGridEditor/GridHeaderActions'
|
||||
import { isValueTruncated } from '@/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
|
||||
import { formatTableRowsToSQL } from '@/components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
@@ -46,10 +47,16 @@ import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
|
||||
export type HeaderProps = {
|
||||
customHeader: ReactNode
|
||||
isRefetching: boolean
|
||||
rows?: SupaRow[]
|
||||
tableQueriesEnabled?: boolean
|
||||
}
|
||||
|
||||
export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true }: HeaderProps) => {
|
||||
export const Header = ({
|
||||
customHeader,
|
||||
isRefetching,
|
||||
rows,
|
||||
tableQueriesEnabled = true,
|
||||
}: HeaderProps) => {
|
||||
useInitializeFiltersFromUrl()
|
||||
useSyncFiltersToUrl()
|
||||
|
||||
@@ -80,7 +87,7 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true
|
||||
<div className="flex-1 px-1.5">{customHeader}</div>
|
||||
) : snap.selectedRows.size > 0 ? (
|
||||
<div className="flex-1 px-1.5">
|
||||
<RowHeader tableQueriesEnabled={tableQueriesEnabled} />
|
||||
<RowHeader rows={rows} tableQueriesEnabled={tableQueriesEnabled} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -128,10 +135,11 @@ export const Header = ({ customHeader, isRefetching, tableQueriesEnabled = true
|
||||
}
|
||||
|
||||
type RowHeaderProps = {
|
||||
rows?: SupaRow[]
|
||||
tableQueriesEnabled?: boolean
|
||||
}
|
||||
|
||||
const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const RowHeader = ({ rows: visibleRows, tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
const { id: _id } = useParams()
|
||||
const tableId = _id ? Number(_id) : undefined
|
||||
|
||||
@@ -194,7 +202,7 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
|
||||
|
||||
const onRowsDelete = () => {
|
||||
const rowIdxs = Array.from(snap.selectedRows) as number[]
|
||||
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
|
||||
const rows = (visibleRows ?? allRows).filter((x) => rowIdxs.includes(x.idx))
|
||||
|
||||
deleteRows({
|
||||
rows,
|
||||
|
||||
@@ -539,6 +539,39 @@ describe('upsertOperation', () => {
|
||||
expect(result.operations[0]).toEqual(otherOp)
|
||||
})
|
||||
|
||||
test('should remove primary key edit when reverted using original row identifiers', () => {
|
||||
const existingOp: QueuedOperation = {
|
||||
id: 'edit_cell_content:1:id:id:1',
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId: 1,
|
||||
timestamp: Date.now() - 1000,
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'id',
|
||||
oldValue: 1,
|
||||
newValue: 3,
|
||||
table: mockTable,
|
||||
},
|
||||
}
|
||||
|
||||
const operations = [existingOp]
|
||||
const newOperation: NewEditCellContentOperation = {
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
tableId: 1,
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'id',
|
||||
oldValue: 3,
|
||||
newValue: 1,
|
||||
table: mockTable,
|
||||
},
|
||||
}
|
||||
|
||||
const result = upsertOperation(operations, newOperation)
|
||||
|
||||
expect(result.operations).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should remove operation when number oldValue matches string newValue', () => {
|
||||
const existingOp: QueuedOperation = {
|
||||
id: 'edit_cell_content:1:age:id:1',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { SupaRow } from '../types'
|
||||
import {
|
||||
formatGridDataWithOperationValues,
|
||||
generateTableChangeKey,
|
||||
getStableRowIdentifiers,
|
||||
queueRowDeletesWithOptimisticUpdate,
|
||||
rowMatchesIdentifiers,
|
||||
} from './queueOperationUtils'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import {
|
||||
QueuedOperationType,
|
||||
type NewAddRowOperation,
|
||||
@@ -152,6 +155,20 @@ describe('rowMatchesIdentifiers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stable queued row identifiers', () => {
|
||||
test('should use original row identifiers when present', () => {
|
||||
const row = { idx: 0, id: 3, __originalRowIdentifiers: { id: 1 } }
|
||||
|
||||
expect(getStableRowIdentifiers(row, { id: 3 })).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
test('should fall back to current identifiers when original row identifiers are not present', () => {
|
||||
const row = { idx: 0, id: 3 }
|
||||
|
||||
expect(getStableRowIdentifiers(row, { id: 3 })).toEqual({ id: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGridDataWithOperationValues', () => {
|
||||
const makeRow = (idx: number, data: Record<string, unknown> = {}): SupaRow => ({
|
||||
idx,
|
||||
@@ -217,7 +234,7 @@ describe('formatGridDataWithOperationValues', () => {
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' })
|
||||
expect(result[0]).toMatchObject({ idx: 0, id: 1, name: 'Updated' })
|
||||
expect(result[1]).toEqual(rows[1])
|
||||
})
|
||||
|
||||
@@ -448,4 +465,106 @@ describe('formatGridDataWithOperationValues', () => {
|
||||
const result = formatGridDataWithOperationValues({ operations: [op], rows })
|
||||
expect(result[0].name).toBe('Updated')
|
||||
})
|
||||
|
||||
test('should preserve row identity after a primary key edit', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' })]
|
||||
const idEdit = makeEditOp({
|
||||
id: 'edit_cell_content:1:id:id:1',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'id',
|
||||
oldValue: 1,
|
||||
newValue: 3,
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
const nameEdit = makeEditOp({
|
||||
id: 'edit_cell_content:1:name:id:1',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Alice',
|
||||
newValue: 'Updated Alice',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({ operations: [idEdit, nameEdit], rows })
|
||||
|
||||
expect(result[0]).toMatchObject({ idx: 0, id: 3, name: 'Updated Alice' })
|
||||
expect(getStableRowIdentifiers(result[0], { id: result[0].id })).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
test('should keep two rows distinct when one row takes another row primary key value', () => {
|
||||
const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })]
|
||||
const aliceIdEdit = makeEditOp({
|
||||
id: 'edit_cell_content:1:id:id:1',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 1 },
|
||||
columnName: 'id',
|
||||
oldValue: 1,
|
||||
newValue: 3,
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
const bobIdEdit = makeEditOp({
|
||||
id: 'edit_cell_content:1:id:id:2',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 2 },
|
||||
columnName: 'id',
|
||||
oldValue: 2,
|
||||
newValue: 1,
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
const bobNameEdit = makeEditOp({
|
||||
id: 'edit_cell_content:1:name:id:2',
|
||||
payload: {
|
||||
rowIdentifiers: { id: 2 },
|
||||
columnName: 'name',
|
||||
oldValue: 'Bob',
|
||||
newValue: 'Updated Bob',
|
||||
table: {} as any,
|
||||
},
|
||||
})
|
||||
|
||||
const result = formatGridDataWithOperationValues({
|
||||
operations: [aliceIdEdit, bobIdEdit, bobNameEdit],
|
||||
rows,
|
||||
})
|
||||
|
||||
expect(result[0]).toMatchObject({ idx: 0, id: 3, name: 'Alice' })
|
||||
expect(getStableRowIdentifiers(result[0], { id: result[0].id })).toEqual({ id: 1 })
|
||||
expect(result[1]).toMatchObject({ idx: 1, id: 1, name: 'Updated Bob' })
|
||||
expect(getStableRowIdentifiers(result[1], { id: result[1].id })).toEqual({ id: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('queueRowDeletesWithOptimisticUpdate', () => {
|
||||
test('should queue pending add row deletes with the temp row as original row', () => {
|
||||
const queueOperation = vi.fn()
|
||||
const row = { idx: -100, __tempId: '-100', name: 'New Row' } as SupaRow
|
||||
|
||||
queueRowDeletesWithOptimisticUpdate({
|
||||
rows: [row],
|
||||
table: {
|
||||
id: 1,
|
||||
schema: 'public',
|
||||
name: 'users',
|
||||
entity_type: ENTITY_TYPE.TABLE,
|
||||
primary_keys: [{ name: 'id' }],
|
||||
} as Parameters<typeof queueRowDeletesWithOptimisticUpdate>[0]['table'],
|
||||
queueOperation,
|
||||
projectRef: 'project-ref',
|
||||
})
|
||||
|
||||
expect(queueOperation).toHaveBeenCalledWith({
|
||||
type: QueuedOperationType.DELETE_ROW,
|
||||
tableId: 1,
|
||||
payload: expect.objectContaining({
|
||||
rowIdentifiers: { id: undefined, __tempId: '-100' },
|
||||
originalRow: row,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isPendingAddRow, PendingAddRow, SupaRow } from '../types'
|
||||
import { isTableLike, type Entity } from '@/data/table-editor/table-editor-types'
|
||||
import { isObject } from '@/lib/helpers'
|
||||
import {
|
||||
EditCellContentOperation,
|
||||
NewQueuedOperation,
|
||||
@@ -8,6 +9,24 @@ import {
|
||||
} from '@/state/table-editor-operation-queue.types'
|
||||
import type { Dictionary } from '@/types'
|
||||
|
||||
// Client-only marker that preserves the original SQL WHERE identifiers after PK edits.
|
||||
const ORIGINAL_ROW_IDENTIFIERS_KEY = '__originalRowIdentifiers'
|
||||
|
||||
export function getStableRowIdentifiers(
|
||||
row: Dictionary<unknown>,
|
||||
fallbackIdentifiers: Dictionary<unknown>
|
||||
): Dictionary<unknown> {
|
||||
const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY]
|
||||
return { ...(isObject(identifiers) ? identifiers : fallbackIdentifiers) }
|
||||
}
|
||||
|
||||
function withOriginalRowIdentifiers<T extends SupaRow>(
|
||||
row: T,
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
): T {
|
||||
return { ...row, [ORIGINAL_ROW_IDENTIFIERS_KEY]: { ...rowIdentifiers } }
|
||||
}
|
||||
|
||||
interface EditCellKeyOperation extends Omit<
|
||||
EditCellContentOperation,
|
||||
'payload' | 'id' | 'timestamp'
|
||||
@@ -59,6 +78,14 @@ export function rowMatchesIdentifiers(
|
||||
return identifierEntries.every(([key, value]) => row[key] === value)
|
||||
}
|
||||
|
||||
function rowMatchesOperationIdentifiers(
|
||||
row: Dictionary<unknown>,
|
||||
rowIdentifiers: Dictionary<unknown>
|
||||
): boolean {
|
||||
const identifiers = row[ORIGINAL_ROW_IDENTIFIERS_KEY]
|
||||
return rowMatchesIdentifiers(isObject(identifiers) ? identifiers : row, rowIdentifiers)
|
||||
}
|
||||
|
||||
export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] {
|
||||
return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
}
|
||||
@@ -86,8 +113,8 @@ export function queueCellEditWithOptimisticUpdate({
|
||||
newValue,
|
||||
enumArrayColumns,
|
||||
}: QueueCellEditParams) {
|
||||
// Updated row identifiers to include __tempId for pending add rows so edits merge into ADD_ROW operation
|
||||
const rowIdentifiers: Dictionary<unknown> = { ...callerRowIdentifiers }
|
||||
// Pending add rows use __tempId so edits merge into the ADD_ROW operation.
|
||||
const rowIdentifiers = getStableRowIdentifiers(row, callerRowIdentifiers)
|
||||
if (isPendingAddRow(row)) {
|
||||
rowIdentifiers.__tempId = row.__tempId
|
||||
}
|
||||
@@ -151,9 +178,14 @@ export const formatGridDataWithOperationValues = ({
|
||||
operations.forEach((op) => {
|
||||
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
|
||||
const { rowIdentifiers, columnName, newValue } = op.payload
|
||||
const rowIdx = formattedRows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
const rowIdx = formattedRows.findIndex((row) =>
|
||||
rowMatchesOperationIdentifiers(row, rowIdentifiers)
|
||||
)
|
||||
if (rowIdx !== -1) {
|
||||
formattedRows[rowIdx] = { ...formattedRows[rowIdx], [columnName]: newValue }
|
||||
formattedRows[rowIdx] = withOriginalRowIdentifiers(
|
||||
{ ...formattedRows[rowIdx], [columnName]: newValue },
|
||||
rowIdentifiers
|
||||
)
|
||||
}
|
||||
} else if (op.type === QueuedOperationType.ADD_ROW) {
|
||||
const { tempId, rowData } = op.payload
|
||||
@@ -176,9 +208,14 @@ export const formatGridDataWithOperationValues = ({
|
||||
}
|
||||
} else if (op.type === QueuedOperationType.DELETE_ROW) {
|
||||
const { rowIdentifiers } = op.payload
|
||||
const rowIdx = formattedRows.findIndex((row) => rowMatchesIdentifiers(row, rowIdentifiers))
|
||||
const rowIdx = formattedRows.findIndex((row) =>
|
||||
rowMatchesOperationIdentifiers(row, rowIdentifiers)
|
||||
)
|
||||
if (rowIdx !== -1) {
|
||||
formattedRows[rowIdx] = { ...formattedRows[rowIdx], __isDeleted: true }
|
||||
formattedRows[rowIdx] = withOriginalRowIdentifiers(
|
||||
{ ...formattedRows[rowIdx], __isDeleted: true },
|
||||
rowIdentifiers
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -225,12 +262,16 @@ export function queueRowDeletesWithOptimisticUpdate({
|
||||
table.primary_keys.forEach((pk) => {
|
||||
rowIdentifiers[pk.name] = row[pk.name]
|
||||
})
|
||||
const stableRowIdentifiers = getStableRowIdentifiers(row, rowIdentifiers)
|
||||
if (isPendingAddRow(row)) {
|
||||
stableRowIdentifiers.__tempId = row.__tempId
|
||||
}
|
||||
|
||||
queueOperation({
|
||||
type: QueuedOperationType.DELETE_ROW,
|
||||
tableId: table.id,
|
||||
payload: {
|
||||
rowIdentifiers,
|
||||
rowIdentifiers: stableRowIdentifiers,
|
||||
originalRow: row,
|
||||
table,
|
||||
},
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ import {
|
||||
validateFields,
|
||||
} from './RowEditor.utils'
|
||||
import { TextEditor } from './TextEditor'
|
||||
import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
@@ -144,7 +145,7 @@ export const RowEditor = ({
|
||||
identifiers[column.name] =
|
||||
column.format === 'bytea' ? convertByteaToHex(row![column.name]) : row![column.name]
|
||||
})
|
||||
configuration.identifiers = identifiers
|
||||
configuration.identifiers = getStableRowIdentifiers(row!, identifiers)
|
||||
configuration.rowIdx = row!.idx
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -35,6 +35,7 @@ import {
|
||||
import { TableEditor } from './TableEditor/TableEditor'
|
||||
import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations'
|
||||
import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
import {
|
||||
acceptGeneratedPolicy,
|
||||
@@ -318,7 +319,7 @@ export const SidePanelEditor = ({
|
||||
const { row, column } = selectedValueForJsonEdit
|
||||
payload = { [column]: value === null ? null : JSON.parse(value as any) }
|
||||
selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name]))
|
||||
configuration = { identifiers, rowIdx: row.idx }
|
||||
configuration = { identifiers: getStableRowIdentifiers(row!, identifiers), rowIdx: row.idx }
|
||||
} else if (snap.sidePanel?.type === 'cell') {
|
||||
const column = snap.sidePanel.value?.column
|
||||
const row = snap.sidePanel.value?.row
|
||||
@@ -326,7 +327,7 @@ export const SidePanelEditor = ({
|
||||
if (!column || !row) return
|
||||
payload = { [column]: value === null ? null : value }
|
||||
selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name]))
|
||||
configuration = { identifiers, rowIdx: row.idx }
|
||||
configuration = { identifiers: getStableRowIdentifiers(row!, identifiers), rowIdx: row.idx }
|
||||
}
|
||||
|
||||
if (payload !== undefined && configuration !== undefined) {
|
||||
@@ -354,7 +355,10 @@ export const SidePanelEditor = ({
|
||||
})
|
||||
|
||||
const isNewRecord = false
|
||||
const configuration = { identifiers, rowIdx: row.idx }
|
||||
const configuration = {
|
||||
identifiers: getStableRowIdentifiers(row, identifiers),
|
||||
rowIdx: row.idx,
|
||||
}
|
||||
|
||||
await saveRow(value, isNewRecord, configuration, (error) => {
|
||||
if (error) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOperationSqlStatements } from './operation-queue-save-mutation'
|
||||
import type { Entity } from '@/data/table-editor/table-editor-types'
|
||||
import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types'
|
||||
|
||||
const usersTable = { id: 1, name: 'users', schema: 'public' } as Entity
|
||||
|
||||
function createEditOperation(
|
||||
columnName: string,
|
||||
newValue: unknown,
|
||||
rowIdentifiers: Record<string, unknown> = { id: 1 }
|
||||
): QueuedOperation {
|
||||
return {
|
||||
id: `${columnName}-${String(newValue)}`,
|
||||
tableId: 1,
|
||||
timestamp: 1,
|
||||
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
||||
payload: {
|
||||
rowIdentifiers,
|
||||
columnName,
|
||||
oldValue: undefined,
|
||||
newValue,
|
||||
table: usersTable,
|
||||
enumArrayColumns: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('getOperationSqlStatements', () => {
|
||||
it('merges edits for the same row into one update statement', () => {
|
||||
const statements = getOperationSqlStatements([
|
||||
createEditOperation('id', '4'),
|
||||
createEditOperation('name', 'Ram 1'),
|
||||
])
|
||||
|
||||
expect(statements).toHaveLength(1)
|
||||
|
||||
const sql = String(statements[0])
|
||||
expect(sql).toContain('update public.users set (id,name)')
|
||||
expect(sql).toContain('"id":"4"')
|
||||
expect(sql).toContain('"name":"Ram 1"')
|
||||
expect(sql.match(/where id = 1/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps edits for different rows as separate update statements', () => {
|
||||
const statements = getOperationSqlStatements([
|
||||
createEditOperation('name', 'Ram 1', { id: 1 }),
|
||||
createEditOperation('name', 'Shyam 1', { id: 2 }),
|
||||
])
|
||||
|
||||
expect(statements).toHaveLength(2)
|
||||
expect(String(statements[0])).toContain('where id = 1')
|
||||
expect(String(statements[1])).toContain('where id = 2')
|
||||
})
|
||||
|
||||
it('does not merge rows whose identifier values would collide with delimiter keys', () => {
|
||||
const statements = getOperationSqlStatements([
|
||||
createEditOperation('name', 'Row 1', { a: 'x', b: 'y|b:z' }),
|
||||
createEditOperation('name', 'Row 2', { a: 'x|b:y', b: 'z' }),
|
||||
])
|
||||
|
||||
expect(statements).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -11,9 +11,22 @@ import type { PendingAddRow } from '@/components/grid/types'
|
||||
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
||||
import { RoleImpersonationState, wrapWithRoleImpersonation } from '@/lib/role-impersonation'
|
||||
import { isRoleImpersonationEnabled } from '@/state/role-impersonation-state'
|
||||
import { QueuedOperation, QueuedOperationType } from '@/state/table-editor-operation-queue.types'
|
||||
import {
|
||||
isEditCellContentOperation,
|
||||
QueuedOperation,
|
||||
QueuedOperationType,
|
||||
type EditCellContentOperation,
|
||||
} from '@/state/table-editor-operation-queue.types'
|
||||
import type { ResponseError, UseCustomMutationOptions } from '@/types'
|
||||
|
||||
function getEditCellOperationRowKey(operation: EditCellContentOperation): string {
|
||||
const rowIdentifiersKey = JSON.stringify(
|
||||
Object.entries(operation.payload.rowIdentifiers).sort(([a], [b]) => a.localeCompare(b))
|
||||
)
|
||||
|
||||
return `${operation.tableId}:${rowIdentifiersKey}`
|
||||
}
|
||||
|
||||
export type OperationQueueSaveVariables = {
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
@@ -81,6 +94,55 @@ function sortOperations(operations: readonly QueuedOperation[]): QueuedOperation
|
||||
})
|
||||
}
|
||||
|
||||
function stripTrailingSemicolon(sql: SafeSqlFragment): SafeSqlFragment {
|
||||
return (sql.endsWith(';') ? sql.slice(0, -1) : sql) as SafeSqlFragment
|
||||
}
|
||||
|
||||
export function getOperationSqlStatements(
|
||||
operations: readonly QueuedOperation[]
|
||||
): Array<SafeSqlFragment> {
|
||||
const statements: Array<SafeSqlFragment> = []
|
||||
const editCellOperationsByRow = new Map<string, EditCellContentOperation[]>()
|
||||
|
||||
for (const operation of sortOperations(operations)) {
|
||||
if (!isEditCellContentOperation(operation)) {
|
||||
statements.push(stripTrailingSemicolon(getOperationSql(operation)))
|
||||
continue
|
||||
}
|
||||
|
||||
const key = getEditCellOperationRowKey(operation)
|
||||
const existing = editCellOperationsByRow.get(key)
|
||||
if (existing) {
|
||||
existing.push(operation)
|
||||
continue
|
||||
}
|
||||
editCellOperationsByRow.set(key, [operation])
|
||||
}
|
||||
|
||||
for (const editCellOperations of editCellOperationsByRow.values()) {
|
||||
const firstOperation = editCellOperations[0]
|
||||
const { table, rowIdentifiers } = firstOperation.payload
|
||||
|
||||
statements.push(
|
||||
stripTrailingSemicolon(
|
||||
getTableRowUpdateSql({
|
||||
table: { id: table.id, name: table.name, schema: table.schema },
|
||||
configuration: { identifiers: rowIdentifiers },
|
||||
payload: Object.fromEntries(
|
||||
editCellOperations.map(({ payload }) => [payload.columnName, payload.newValue])
|
||||
),
|
||||
enumArrayColumns: [
|
||||
...new Set(editCellOperations.flatMap(({ payload }) => payload.enumArrayColumns ?? [])),
|
||||
],
|
||||
returning: false,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves all queued operations in a single database transaction.
|
||||
* If any operation fails, the entire transaction is rolled back.
|
||||
@@ -95,11 +157,7 @@ export async function saveOperationQueue({
|
||||
return { result: [] }
|
||||
}
|
||||
|
||||
const sortedOperations = sortOperations(operations)
|
||||
const statements: Array<SafeSqlFragment> = sortedOperations.map((op) => {
|
||||
const sql = getOperationSql(op)
|
||||
return (sql.endsWith(';') ? sql.slice(0, -1) : sql) as SafeSqlFragment
|
||||
})
|
||||
const statements = getOperationSqlStatements(operations)
|
||||
|
||||
const transactionSql = wrapWithTransaction(safeSql`${joinSqlFragments(statements, ';\n')};`)
|
||||
|
||||
|
||||
@@ -22,6 +22,24 @@ const openQueueDropdownAndClick = async (page: Page, itemName: string) => {
|
||||
const clickReview = async (page: Page) => openQueueDropdownAndClick(page, 'Review')
|
||||
const clickDiscard = async (page: Page) => openQueueDropdownAndClick(page, 'Discard')
|
||||
|
||||
const openTableWithQueueOperations = async (page: Page, ref: string, tableName: string) => {
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await enableQueueOperations(page)
|
||||
const tableLoadWait = createApiResponseWaiter(
|
||||
page,
|
||||
'pg-meta',
|
||||
ref,
|
||||
'query?key=entity-types-public-'
|
||||
)
|
||||
await page.reload()
|
||||
await tableLoadWait
|
||||
|
||||
const gridLoadWait = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-rows-')
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
await gridLoadWait
|
||||
}
|
||||
|
||||
test.describe('Queue Table Operations', () => {
|
||||
test.beforeEach(async ({ page, ref }) => {
|
||||
const loadPromise = waitForTableToLoad(page, ref)
|
||||
@@ -1028,3 +1046,347 @@ test.describe('Queue Table Operations', () => {
|
||||
await expect(page.getByRole('gridcell', { name: 'Smith' })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Queue Table Operations - queue identity fixes', () => {
|
||||
test('primary-key and column edits to the same row are saved together', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_pk_col_edit`
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
await query(
|
||||
`CREATE TABLE ${tableName} (
|
||||
id bigint primary key,
|
||||
name text
|
||||
)`
|
||||
)
|
||||
await query(`INSERT INTO ${tableName} (id, name) VALUES ($1, $2)`, [
|
||||
13001,
|
||||
'original pk row',
|
||||
])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await openTableWithQueueOperations(page, ref, tableName)
|
||||
|
||||
const originalNameCell = page.getByRole('gridcell', { name: 'original pk row' })
|
||||
await expect(
|
||||
originalNameCell,
|
||||
'Original row should be visible before editing through the row editor'
|
||||
).toBeVisible()
|
||||
|
||||
await originalNameCell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
const rowEditor = page.getByTestId('side-panel-row-editor')
|
||||
await expect(rowEditor, 'Row editor should open for the selected row').toBeVisible()
|
||||
|
||||
await rowEditor.getByTestId('id-input').fill('13002')
|
||||
await rowEditor.getByTestId('name-input').fill('updated pk row')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(
|
||||
page.getByText('2 pending changes'),
|
||||
'Editing the primary key and name should queue two cell edits'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: '13002' }),
|
||||
'Optimistic grid state should show the edited primary key'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'updated pk row' }),
|
||||
'Optimistic grid state should show the edited name'
|
||||
).toBeVisible()
|
||||
|
||||
await clickReview(page)
|
||||
|
||||
const sidePanel = page.getByRole('dialog')
|
||||
await expect(sidePanel.getByText('Pending changes')).toBeVisible()
|
||||
await expect(
|
||||
sidePanel.getByText('2 cell edits'),
|
||||
'Both queued edits should still be reviewed as cell edits'
|
||||
).toBeVisible()
|
||||
|
||||
const saveWait = createApiResponseWaiter(
|
||||
page,
|
||||
'pg-meta',
|
||||
ref,
|
||||
'query?key=operation-queue-save',
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
await sidePanel.getByRole('button', { name: /^Save/ }).click()
|
||||
await saveWait
|
||||
await expect(
|
||||
page.getByText('Changes saved successfully'),
|
||||
'Queue save should complete successfully'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
const rows = await query<{ id: string; name: string }>(
|
||||
`SELECT id::text AS id, name FROM ${tableName} ORDER BY id`
|
||||
)
|
||||
expect(rows).toEqual([{ id: '13002', name: 'updated pk row' }])
|
||||
|
||||
await expect(page.getByRole('gridcell', { name: 'updated pk row' })).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'original pk row' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('primary-key edits can be reverted before saving', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_pk_revert`
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
await query(
|
||||
`CREATE TABLE ${tableName} (
|
||||
id bigint primary key,
|
||||
name text
|
||||
)`
|
||||
)
|
||||
await query(`INSERT INTO ${tableName} (id, name) VALUES ($1, $2)`, [14001, 'pk revert row'])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await openTableWithQueueOperations(page, ref, tableName)
|
||||
|
||||
const originalIdCell = page.getByRole('gridcell', { name: '14001', exact: true })
|
||||
await expect(
|
||||
originalIdCell,
|
||||
'Original primary key should be visible before editing'
|
||||
).toBeVisible()
|
||||
|
||||
await originalIdCell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
const rowEditor = page.getByTestId('side-panel-row-editor')
|
||||
await expect(rowEditor, 'Row editor should open for the selected row').toBeVisible()
|
||||
await rowEditor.getByTestId('id-input').fill('14002')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(
|
||||
page.getByText('1 pending change'),
|
||||
'Changing a primary-key cell should queue one pending edit'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: '14002', exact: true }),
|
||||
'Optimistic grid state should show the changed primary key'
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('gridcell', { name: '14002', exact: true }).click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
const revertedRowEditor = page.getByTestId('side-panel-row-editor')
|
||||
await expect(
|
||||
revertedRowEditor,
|
||||
'Row editor should reopen after the primary key changed'
|
||||
).toBeVisible()
|
||||
await revertedRowEditor.getByTestId('id-input').fill('14001')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(
|
||||
page.getByText('pending change'),
|
||||
'Reverting the primary key to its original value should clear the queued edit'
|
||||
).not.toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: '14001', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary-key edits keep rows distinct when another row takes the old key', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = `${tableNamePrefix}_pk_take_old`
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
await query(
|
||||
`CREATE TABLE ${tableName} (
|
||||
id bigint primary key,
|
||||
name text
|
||||
)`
|
||||
)
|
||||
await query(`INSERT INTO ${tableName} (id, name) VALUES ($1, $2), ($3, $4)`, [
|
||||
15001,
|
||||
'Alice keeps identity',
|
||||
15002,
|
||||
'Bob takes old key',
|
||||
])
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await openTableWithQueueOperations(page, ref, tableName)
|
||||
|
||||
const aliceCell = page.getByRole('gridcell', { name: 'Alice keeps identity' })
|
||||
await expect(aliceCell, 'Alice row should be visible before editing').toBeVisible()
|
||||
await aliceCell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
const aliceEditor = page.getByTestId('side-panel-row-editor')
|
||||
await expect(aliceEditor, 'Row editor should open for Alice').toBeVisible()
|
||||
await aliceEditor.getByTestId('id-input').fill('15003')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(
|
||||
page.getByText('1 pending change'),
|
||||
'Alice primary-key edit should queue one pending change'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: '15003', exact: true }),
|
||||
'Alice row should show the updated primary key in the grid'
|
||||
).toBeVisible()
|
||||
|
||||
const bobCell = page.getByRole('gridcell', { name: 'Bob takes old key' })
|
||||
await expect(
|
||||
bobCell,
|
||||
'Bob row should remain visible after Alice primary-key edit'
|
||||
).toBeVisible()
|
||||
await bobCell.click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Edit row' }).click()
|
||||
|
||||
const bobEditor = page.getByTestId('side-panel-row-editor')
|
||||
await expect(bobEditor, 'Row editor should open for Bob').toBeVisible()
|
||||
await bobEditor.getByTestId('id-input').fill('15001')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(
|
||||
page.getByText('2 pending changes'),
|
||||
'Both primary-key edits should stay queued against their original rows'
|
||||
).toBeVisible()
|
||||
|
||||
await clickReview(page)
|
||||
|
||||
const sidePanel = page.getByRole('dialog')
|
||||
await expect(
|
||||
sidePanel.getByText('Pending changes'),
|
||||
'Review panel should show pending changes before saving primary-key edits'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
sidePanel.getByText('2 cell edits'),
|
||||
'Review panel should show both primary-key edits as cell edits'
|
||||
).toBeVisible()
|
||||
|
||||
const saveWait = createApiResponseWaiter(
|
||||
page,
|
||||
'pg-meta',
|
||||
ref,
|
||||
'query?key=operation-queue-save',
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
await sidePanel.getByRole('button', { name: /^Save/ }).click()
|
||||
await saveWait
|
||||
await expect(
|
||||
page.getByText('Changes saved successfully'),
|
||||
'Saving swapped primary-key edits should show a success toast'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
const rows = await query<{ id: string; name: string }>(
|
||||
`SELECT id::text AS id, name FROM ${tableName} ORDER BY id`
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ id: '15001', name: 'Bob takes old key' },
|
||||
{ id: '15003', name: 'Alice keeps identity' },
|
||||
])
|
||||
})
|
||||
|
||||
test('deleting a newly inserted row removes it from pending changes', async ({ page, ref }) => {
|
||||
const tableName = `${tableNamePrefix}_pending_del`
|
||||
const columnName = 'name'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
await createTable(tableName, columnName)
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
|
||||
await openTableWithQueueOperations(page, ref, tableName)
|
||||
|
||||
await page.getByTestId('table-editor-insert-new-row').click()
|
||||
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
||||
await page.getByTestId(`${columnName}-input`).fill('keep pending row')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await page.getByTestId('table-editor-insert-new-row').click()
|
||||
await page.getByRole('menuitem', { name: 'Insert row' }).click()
|
||||
await page.getByTestId(`${columnName}-input`).fill('delete pending row')
|
||||
await page.getByTestId('action-bar-save-row').click()
|
||||
|
||||
await expect(
|
||||
page.getByText('2 pending changes'),
|
||||
'Adding two pending rows should queue two pending changes'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'keep pending row' }),
|
||||
'First pending row should be visible before deleting the second row'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'delete pending row' }),
|
||||
'Second pending row should be visible before deletion'
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('gridcell', { name: 'delete pending row' }).click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete row' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('1 pending change'),
|
||||
'Deleting one pending insert should remove only that row from the queue'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'keep pending row' }),
|
||||
'Remaining pending row should stay visible after deleting the other pending row'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('gridcell', { name: 'delete pending row' }),
|
||||
'Deleted pending row should be removed from the grid'
|
||||
).not.toBeVisible()
|
||||
|
||||
await clickReview(page)
|
||||
|
||||
const sidePanel = page.getByRole('dialog')
|
||||
await expect(
|
||||
sidePanel.getByText('Pending changes'),
|
||||
'Review panel should show pending changes after deleting one pending row'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
sidePanel.getByText('1 row addition'),
|
||||
'Review panel should only show the remaining pending row addition'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
sidePanel.getByText('delete pending row'),
|
||||
'Review panel should not include the deleted pending row'
|
||||
).not.toBeVisible()
|
||||
|
||||
const saveWait = createApiResponseWaiter(
|
||||
page,
|
||||
'pg-meta',
|
||||
ref,
|
||||
'query?key=operation-queue-save',
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
await sidePanel.getByRole('button', { name: /^Save/ }).click()
|
||||
await saveWait
|
||||
await expect(
|
||||
page.getByText('Changes saved successfully'),
|
||||
'Saving the remaining pending row should show a success toast'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
const rows = await query<{ name: string }>(`SELECT name FROM ${tableName} ORDER BY name`)
|
||||
expect(rows).toEqual([{ name: 'keep pending row' }])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user