From 917a686feedb2acb6ad82c8b40f30a0c14436917 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 24 Mar 2026 08:11:22 -0600 Subject: [PATCH] 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 --- .../grid/components/editor/TextEditor.tsx | 22 +- .../grid/components/grid/Grid.utils.tsx | 122 +-------- .../grid/components/header/Header.tsx | 25 +- .../grid/components/header/HeaderNew.tsx | 27 +- .../grid/components/menu/RowContextMenu.tsx | 24 +- .../grid/hooks/useTableRowOperations.ts | 253 ++++++++++++++++++ .../grid/utils/queueOperationUtils.test.ts | 7 +- .../grid/utils/queueOperationUtils.ts | 6 +- .../ColumnEditor/ColumnEditor.tsx | 2 +- .../ColumnEditor/HeaderTitle.tsx | 12 +- .../SidePanelEditor/RowEditor/HeaderTitle.tsx | 2 +- .../SidePanelEditor/SidePanelEditor.tsx | 102 ++----- .../features/queue-table-operations.spec.ts | 77 +++++- e2e/studio/features/table-editor.spec.ts | 242 +++++++++++++++++ 14 files changed, 641 insertions(+), 282 deletions(-) create mode 100644 apps/studio/components/grid/hooks/useTableRowOperations.ts diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 3554e02ca17..ddfc2229a61 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -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 = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(true) const [value, setValue] = useState(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 = ({ } 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 = ({ 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) diff --git a/apps/studio/components/grid/components/grid/Grid.utils.tsx b/apps/studio/components/grid/components/grid/Grid.utils.tsx index 161bf2bd297..354c39012bf 100644 --- a/apps/studio/components/grid/components/grid/Grid.utils.tsx +++ b/apps/studio/components/grid/components/grid/Grid.utils.tsx @@ -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({ queryKey }) - - queryClient.setQueriesData({ 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) => { - 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] ) } diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 055ac85976c..1507ec69d46 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -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() }, diff --git a/apps/studio/components/grid/components/header/HeaderNew.tsx b/apps/studio/components/grid/components/header/HeaderNew.tsx index f53468eb091..3f8d743c222 100644 --- a/apps/studio/components/grid/components/header/HeaderNew.tsx +++ b/apps/studio/components/grid/components/header/HeaderNew.tsx @@ -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() }, diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index 1873509ca03..864e3616cf2 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -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) { diff --git a/apps/studio/components/grid/hooks/useTableRowOperations.ts b/apps/studio/components/grid/hooks/useTableRowOperations.ts new file mode 100644 index 00000000000..2c3e463e6d6 --- /dev/null +++ b/apps/studio/components/grid/hooks/useTableRowOperations.ts @@ -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 + 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 + payload: Dictionary + 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({ queryKey }) + + queryClient.setQueriesData({ 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({ 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, + } +} diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts index 46ec20de044..07209e500c6 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.test.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -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') }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts index 86b047369c1..ced361f1c84 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -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 diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index ea89ce6b8ae..563580f5475 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -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 diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx index 7a2604fe661..f7631bd5214 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/HeaderTitle.tsx @@ -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 = ({ table, column }) => { +export const HeaderTitle = ({ table, column }: Props) => { if (!column) { return ( <> Add new column to - {table.name} + {table.name} ) } return ( <> - Update column {column.name} from {column.table} + Update column {column.name} from{' '} + {column.table} ) } - -export default HeaderTitle diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx index 24cd1c31fec..c92b0b56c5d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx @@ -8,7 +8,7 @@ export const HeaderTitle = ({ isNewRecord, tableName }: HeaderTitleProps) => { return ( {header} - {tableName && {tableName}} + {tableName && {tableName}} ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 13a81de1bd7..aaf6bc56c32 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -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(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} /> diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index ea835fb77c5..5aaab455bc3 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -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() + }) }) diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index fb8aaf26a8d..b6f7de847ff 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -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'