diff --git a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx index c554f2065dc..8975417d268 100644 --- a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx +++ b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx @@ -2,11 +2,10 @@ import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueu import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts' import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { AnimatePresence, motion } from 'framer-motion' -import { Eye } from 'lucide-react' import { createPortal } from 'react-dom' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' - +import { useEffect, useRef, useState } from 'react' import { getModKeyLabel } from '@/lib/helpers' export const SaveQueueActionBar = () => { @@ -14,6 +13,7 @@ export const SaveQueueActionBar = () => { const snap = useTableEditorStateSnapshot() const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const { handleSave } = useOperationQueueActions() + const [leftPosition, setLeftPosition] = useState('50%') useOperationQueueShortcuts() @@ -24,42 +24,80 @@ export const SaveQueueActionBar = () => { const isVisible = isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen + // Center position relative to grid container (viewport alignment) + useEffect(() => { + if (!isVisible) return + + const gridContainer = document.querySelector('.sb-grid') + const updatePosition = () => { + if (!gridContainer) { + setLeftPosition('50%') + return + } + + const gridRect = gridContainer.getBoundingClientRect() + const gridCenter = gridRect.left + gridRect.width / 2 + setLeftPosition(`${gridCenter}px`) + } + + updatePosition() + + if (!gridContainer) return + + const resizeObserver = new ResizeObserver(updatePosition) + resizeObserver.observe(gridContainer) + + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [isVisible]) + const content = ( {isVisible && ( - -
- - {operationCount} pending change{operationCount !== 1 ? 's' : ''} - -
- - + +
+ + {operationCount} pending change{operationCount !== 1 ? 's' : ''} + +
+ + +
-
- + +
)}
) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx index 6dcd99ce3e0..0b2878cee77 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx @@ -1,12 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' +import { Plus, Undo2 } from 'lucide-react' import { tableRowKeys } from 'data/table-rows/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Plus, X } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { Button } from 'ui' import { formatOperationItemValue } from './OperationQueueSidePanel.utils' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { AddRowPayload } from '@/state/table-editor-operation-queue.types' +import { Card, CardContent, CardHeader } from 'ui' interface AddRowOperationItemProps { operationId: string @@ -47,40 +48,52 @@ export const AddRowOperationItem = ({ } return ( -
-
+ +
- -
-
{fullTableName}
-
- New row +
+ + {fullTableName} + +
+ New row
-
+ -
+ {previewColumns.map(([key, value]) => ( -
- {key}: - +
+ + + {key}: + {formatOperationItemValue(value)}
))} {remainingCount > 0 && ( -
+{remainingCount} more column(s)
+
+ + + +{remainingCount} more column(s) +
)} -
-
+ + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx index 8e641460788..56069c3b2c6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx @@ -1,12 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' -import { Trash2, X } from 'lucide-react' -import { Button } from 'ui' - +import { Undo2 } from 'lucide-react' import { tableRowKeys } from 'data/table-rows/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { DeleteRowPayload } from '@/state/table-editor-operation-queue.types' + import { formatOperationItemValue } from './OperationQueueSidePanel.utils' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { DeleteRowPayload } from '@/state/table-editor-operation-queue.types' +import { Card, CardContent, CardHeader } from 'ui' interface DeleteRowOperationItemProps { operationId: string @@ -45,32 +46,40 @@ export const DeleteRowOperationItem = ({ } return ( -
-
+ +
- -
-
{fullTableName}
-
- Delete row - · - where {whereClause} +
+ + {fullTableName} + +
+ Delete row + · + where {whereClause}
-
+ -
-
Row will be deleted
-
-
+ +
Row will be deleted
+
+
) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx index c9476493de6..4773099a270 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationItem.tsx @@ -1,12 +1,13 @@ import { useQueryClient } from '@tanstack/react-query' +import { Undo2 } from 'lucide-react' import { tableRowKeys } from 'data/table-rows/keys' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { X } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { formatOperationItemValue } from './OperationQueueSidePanel.utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { EditCellContentPayload } from '@/state/table-editor-operation-queue.types' +import { Card, CardContent, CardHeader } from 'ui' interface OperationItemProps { operationId: string @@ -44,42 +45,50 @@ export const OperationItem = ({ operationId, tableId, content }: OperationItemPr } return ( -
-
+ +
-
{fullTableName}
-
- {columnName} - - where {whereClause} + + {fullTableName} + +
+ {columnName} + · + where {whereClause}
} + aria-label="Revert change" + className="px-1.5" + icon={} onClick={handleDelete} - className="shrink-0 w-7" - aria-label="Remove operation" - tooltip={{ content: { side: 'bottom', text: 'Remove operation' } }} + tooltip={{ + content: { + side: 'bottom', + align: 'end', + text: 'Revert change', + }, + }} /> -
+
-
-
- - - + +
+ - + {formattedOldValue}
-
- + - +
+ + + {formattedNewValue}
-
-
+ + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx index 0468be02806..1c3dc3b7559 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx @@ -15,7 +15,11 @@ interface OperationListProps { export const OperationList = ({ operations }: OperationListProps) => { if (operations.length === 0) { - return

No pending changes

+ return ( +
+

No pending changes

+
+ ) } const addOperations = operations.filter(isAddRowOperation) @@ -23,12 +27,13 @@ export const OperationList = ({ operations }: OperationListProps) => { const editOperations = operations.filter(isEditCellContentOperation) return ( -
+
{deleteOperations.length > 0 && (
-

- Rows to Delete ({deleteOperations.length}) +

+ {deleteOperations.length} row deletion{deleteOperations.length !== 1 ? 's' : ''}

+
{deleteOperations.map((op) => ( { {addOperations.length > 0 && (
-

- Rows to Add ({addOperations.length}) +

+ {addOperations.length} row addition{addOperations.length !== 1 ? 's' : ''}

{addOperations.map((op) => ( @@ -62,9 +67,10 @@ export const OperationList = ({ operations }: OperationListProps) => { {editOperations.length > 0 && (
-

- Cell Edits ({editOperations.length}) +

+ {editOperations.length} cell edit{editOperations.length !== 1 ? 's' : ''}

+
{editOperations.map((op) => ( event.preventDefault()} // Prevent focus on first focussable element since it is the revert changes ButtonTooltip header={
Pending changes - + {operations.length} operation{operations.length !== 1 ? 's' : ''}
@@ -41,25 +42,29 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS
-
} > - + diff --git a/apps/studio/styles/grid.scss b/apps/studio/styles/grid.scss index 8664d99a400..faed1baaf7c 100644 --- a/apps/studio/styles/grid.scss +++ b/apps/studio/styles/grid.scss @@ -36,17 +36,13 @@ box-shadow: inset 0 0 0 1px #24b47e; } -// Cell with unsaved changes - yellow/amber border +// Cell with unsaved changes - warning (amber) background/text color .rdg-cell.rdg-cell--dirty { - box-shadow: inset 0 0 0 2px hsl(var(--warning-default)); + background-color: hsl(var(--warning-300) / 0.75); + color: hsl(var(--warning-default)); } -// When a dirty cell is also selected, keep the amber border -.rdg-cell.rdg-cell--dirty[aria-selected='true'] { - box-shadow: inset 0 0 0 2px hsl(var(--warning-default)); -} - -// Row pending addition - green background +// Row pending addition - green background, green text .rdg-row.rdg-row--added { background-color: hsl(var(--brand-200) / 0.3); @@ -56,32 +52,33 @@ .rdg-cell { border-left-color: hsl(var(--brand-default)); + color: hsl(var(--brand-link)); } // First cell gets a stronger left border to indicate new row .rdg-cell:first-child { - box-shadow: inset 3px 0 0 0 hsl(var(--brand-default)); + box-shadow: inset 2px 0 0 0 hsl(var(--brand-default)); } } -// Row pending deletion - red background with strikethrough effect +// Row pending deletion - red background with strikethrough effect, red text .rdg-row.rdg-row--deleted { - background-color: hsl(var(--destructive-200) / 0.3); - opacity: 0.6; + background-color: hsl(var(--destructive-300) / 0.3); &:hover { - background-color: hsl(var(--destructive-200) / 0.5); + background-color: hsl(var(--destructive-300) / 0.5); } .rdg-cell { text-decoration: line-through; text-decoration-color: hsl(var(--destructive-default)); border-left-color: hsl(var(--destructive-default)); + color: hsl(var(--destructive-default)); } // First cell gets a stronger left border to indicate deleted row .rdg-cell:first-child { - box-shadow: inset 3px 0 0 0 hsl(var(--destructive-default)); + box-shadow: inset 2px 0 0 0 hsl(var(--destructive-default)); } } @@ -89,7 +86,6 @@ @apply box-border select-none overflow-x-auto overflow-y-scroll bg-dash-canvas; @apply border-t border-r-0 border-l-0; contain: strict; - -webkit-user-select: none; font-size: 14px; } diff --git a/e2e/studio/features/queue-table-operations.spec.ts b/e2e/studio/features/queue-table-operations.spec.ts index 89d637f4e8a..c3eacb6ba71 100644 --- a/e2e/studio/features/queue-table-operations.spec.ts +++ b/e2e/studio/features/queue-table-operations.spec.ts @@ -86,15 +86,15 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('1 pending change')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') await expect(sidePanel.getByText('Pending changes')).toBeVisible() - await expect(sidePanel.getByText('Cell Edits (1)')).toBeVisible() + await expect(sidePanel.getByText('1 cell edit')).toBeVisible() await expect(sidePanel.getByTitle('original value')).toBeVisible() await expect(sidePanel.getByTitle('edited value')).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await expect(page.getByRole('gridcell', { name: 'edited value' })).toBeVisible() @@ -126,9 +126,9 @@ test.describe('Queue Table Operations', () => { await page.keyboard.press('Enter') await expect(page.getByText('1 pending change')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() - await page.getByRole('button', { name: 'Cancel All' }).click() + await page.getByRole('button', { name: 'Revert', exact: true }).click() await expect(page.getByRole('gridcell', { name: 'keep this value' })).toBeVisible() await expect(page.getByRole('gridcell', { name: 'should be cancelled' })).not.toBeVisible() @@ -161,14 +161,14 @@ test.describe('Queue Table Operations', () => { await expect(page.getByRole('gridcell', { name: 'new row value' })).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') await expect(sidePanel.getByText('Pending changes')).toBeVisible() - await expect(sidePanel.getByText('Rows to Add (1)')).toBeVisible() + await expect(sidePanel.getByText('1 row addition')).toBeVisible() await expect(sidePanel.getByText('New row', { exact: true })).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await expect(page.getByRole('gridcell', { name: 'new row value' })).toBeVisible() @@ -202,13 +202,13 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('2 pending changes')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') await expect(sidePanel.getByText('2 operations')).toBeVisible() - await expect(sidePanel.getByText('Rows to Add (2)')).toBeVisible() + await expect(sidePanel.getByText('2 row additions')).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await expect(page.getByRole('gridcell', { name: 'row one' })).toBeVisible() @@ -243,15 +243,15 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('2 pending changes')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') - const removeButtons = sidePanel.getByRole('button', { name: 'Remove operation' }) + const removeButtons = sidePanel.getByRole('button', { name: 'Revert change' }) await removeButtons.last().click() await expect(sidePanel.getByText('1 operation')).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await expect(page.getByRole('gridcell', { name: 'keep this row' })).toBeVisible() @@ -285,7 +285,7 @@ test.describe('Queue Table Operations', () => { await expect(page.getByRole('dialog').getByText('Pending changes')).toBeVisible() await page.keyboard.press('ControlOrMeta+.') - await expect(page.getByRole('button', { name: 'View Details' })).toBeVisible() + await expect(page.getByRole('button', { name: /Review/ })).toBeVisible() await page.keyboard.press('ControlOrMeta+s') await expect(page.getByText('Changes saved successfully')).toBeVisible() @@ -318,14 +318,14 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('1 pending change')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') await expect(sidePanel.getByText('Pending changes')).toBeVisible() - await expect(sidePanel.getByText('Rows to Delete (1)')).toBeVisible() + await expect(sidePanel.getByText('1 row deletion')).toBeVisible() await expect(sidePanel.getByText('Delete row', { exact: true })).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await expect(page.getByRole('gridcell', { name: 'row to delete' })).not.toBeVisible() @@ -354,8 +354,8 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('1 pending change')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() - await page.getByRole('button', { name: 'Cancel All' }).click() + await page.getByRole('button', { name: /Review/ }).click() + await page.getByRole('button', { name: 'Revert', exact: true }).click() await expect(page.getByRole('gridcell', { name: 'should not be deleted' })).toBeVisible() await expect(page.getByText('pending change')).not.toBeVisible() @@ -397,14 +397,14 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('3 pending changes')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') await expect(sidePanel.getByText('3 operations')).toBeVisible() - await expect(sidePanel.getByText('Rows to Delete (1)')).toBeVisible() - await expect(sidePanel.getByText('Rows to Add (1)')).toBeVisible() - await expect(sidePanel.getByText('Cell Edits (1)')).toBeVisible() + await expect(sidePanel.getByText('1 row deletion')).toBeVisible() + await expect(sidePanel.getByText('1 row addition')).toBeVisible() + await expect(sidePanel.getByText('1 cell edit')).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await expect(page.getByRole('gridcell', { name: 'edited row' })).toBeVisible() @@ -447,11 +447,11 @@ test.describe('Queue Table Operations', () => { await expect(page.getByText('2 pending changes')).toBeVisible() - await page.getByRole('button', { name: 'View Details' }).click() + await page.getByRole('button', { name: /Review/ }).click() const sidePanel = page.getByRole('dialog') await expect(sidePanel.getByText('2 operations')).toBeVisible() - await sidePanel.getByRole('button', { name: /Save All/ }).click() + await sidePanel.getByRole('button', { name: /^Save/ }).click() await expect(page.getByText('Changes saved successfully')).toBeVisible() await page.getByRole('button', { name: `View ${tableName1}`, exact: true }).click()