diff --git a/docs/GRID_SORT_DESIGN.md b/docs/GRID_SORT_DESIGN.md new file mode 100644 index 00000000..81bfc409 --- /dev/null +++ b/docs/GRID_SORT_DESIGN.md @@ -0,0 +1,117 @@ +# Result Grid Sorting — Client-Side Primitive Sort Design + +> **Status**: Implemented +> **Date**: 2026-10-08 +> **Scope**: `DataGrid` (query results) and `DataTableView` (table data browser) + +## Executive Summary + +The result grids used to sort by **re-executing the user's SQL** on the backend +(`execute_sorted_query` → `SELECT * FROM (…) ORDER BY …`). That path only works +for plain parsable `SELECT`s, re-runs potentially expensive queries on every +header click, and gave the table browser no sort at all. + +This design moves sorting to the **frontend, over the rows already returned**. +It is instant, works for any result set, and is deliberately limited to +**primitive columns** (text / number / boolean / date / NULL). JSON documents, +arrays and BLOBs stay unsortable because they have no meaningful client-side +total ordering. + +``` +header click ──▶ useDataGridSort (SortState) ──▶ sortRowsByState(rows) + │ + primitive-only comparator + ▼ + sortedRows (computed) + ▼ + virtual rows / table rows render +``` + +## 1. What counts as sortable + +A column is sortable when **both** hold: + +1. Its declared SQL type is primitive — + `/^(?:JSONB?|ARRAY|BYTEA|BLOB|BINARY|VARBINARY|IMAGE|GEOMETRY|GEOGRAPHY|HSTORE|XML)/i` + disqualifies it. +2. Every returned cell is primitive (`string | number | boolean | null | undefined`). + A single object/array cell disqualifies the whole column, because a mixed + column cannot be ordered consistently. + +Non-sortable headers render as plain labels with a tooltip; the header context +menu disables its sort entries and explains why. + +## 2. Comparison rules + +`compareSortValues(a, b, columnType)` resolves an ascending order: + +| Kind | Detection | Ordering | +| --- | --- | --- | +| number | numeric SQL type or `typeof value === 'number'` | `Number()` compare; numeric strings (`bigint`) compare numerically | +| boolean | `BOOL`/`BIT` type or boolean value | `false < true`; `t/f/1/0/y/n/yes/no` accepted | +| date | `DATE`/`TIMESTAMP`/`TIME` type | `Date.parse` when both parse, otherwise string order | +| string | fallback | `localeCompare(…, { numeric: true, sensitivity: 'base' })` | + +- **NULL/undefined** sort after real values in ascending order. Descending is a + pure reversal, so NULLs come first — matching PostgreSQL's default. +- Comparisons never inspect non-primitive values; those columns are filtered out + before any comparison happens. + +## 3. Sorting state + +`useDataGridSort` owns an ordered `SortState = { column, direction }[]`: + +- Click (no modifier): ASC → DESC → cleared, replacing any existing rules. +- Shift-click: append, cycling ASC → DESC → removed (multi-column sort). +- Context menu: `setSort(column, direction)` sets one direction while keeping + other rules. +- `pruneSort(validColumns)` drops rules for columns that disappeared or became + unsortable (e.g. after running a different query). + +`sortRowsByState(rows, sortState, columnTypes)`: + +- returns a **new array** (input is never mutated), +- applies every rule in priority order, +- is stable: ties keep their original index, +- short-circuits when there are no rules or no rows. + +## 4. Component wiring + +### `DataGrid` (query results) + +- `sortedRows` is the single source of truth for rendering, row actions, + copy/export and the status bar. +- Header clicks call `handleHeaderSort`; the context menu is told `sortable`. +- Selection is cleared whenever rows, columns or the sort state change, because + selection is positional. +- The status bar shows `id ↑, name ↓` for active rules. +- `DataGrid` no longer emits `sortChange`; sorting is internal. + +### `QueryResultPanel` + +- Sort no longer round-trips. `reExecuteFiltered` only re-executes for filters, + which still change the result set itself. +- `activeSort`, `handleSortChange` and the `@sort-change` binding are gone. + +### `DataTableView` (table browser) + +- Same composable and `sortedRows`, scoped to the page that was fetched + (`get_table_data` paginates server-side). +- Sort resets when the table/connection changes and survives page changes. +- CSV export and batch delete read from `sortedRows`, so they match the view. + +## 5. Non-goals / limitations + +- **No global sort in the table browser.** The current page is sorted; a + cross-page sort would need `ORDER BY` pushed into `get_table_data`. +- **No sorting of JSON/BLOB columns.** Type-aware ordering for those types is a + database concern, not a client one. +- The backend `execute_sorted_query` still accepts `sort` rules; the frontend + simply always sends `[]` now. + +## 6. Testing + +- `src/__tests__/gridSort.test.ts` — sortability detection, per-kind + comparison, NULL placement, multi-column priority, stability, immutability. +- `src/__tests__/useDataGridSort.test.ts` — click/shift cycling, `setSort`, + `pruneSort`. diff --git a/src/__tests__/gridSort.test.ts b/src/__tests__/gridSort.test.ts new file mode 100644 index 00000000..d077bbad --- /dev/null +++ b/src/__tests__/gridSort.test.ts @@ -0,0 +1,160 @@ +import type { SortState } from '@/types/grid' +import { + collectSortableColumns, + compareSortValues, + formatSortState, + isPrimitiveValue, + isSortableColumn, + sortRowsByState, +} from '@/utils/gridSort' + +type Row = Record + +describe('isPrimitiveValue', () => { + it('accepts primitives and null', () => { + expect(isPrimitiveValue('a')).toBe(true) + expect(isPrimitiveValue(1)).toBe(true) + expect(isPrimitiveValue(true)).toBe(true) + expect(isPrimitiveValue(null)).toBe(true) + expect(isPrimitiveValue(undefined)).toBe(true) + }) + + it('rejects objects and arrays', () => { + expect(isPrimitiveValue({ a: 1 })).toBe(false) + expect(isPrimitiveValue([1, 2])).toBe(false) + }) +}) + +describe('isSortableColumn', () => { + it('allows primitive columns', () => { + const rows: Row[] = [{ id: 1 }, { id: 2 }, { id: null }] + expect(isSortableColumn('id', rows, { id: 'integer' })).toBe(true) + }) + + it('rejects declared JSON/BLOB column types', () => { + const rows: Row[] = [{ payload: '{"a":1}' }, { payload: '"x"' }] + expect(isSortableColumn('payload', rows, { payload: 'jsonb' })).toBe(false) + expect(isSortableColumn('payload', rows, { payload: 'BYTEA' })).toBe(false) + expect(isSortableColumn('payload', rows, { payload: 'blob' })).toBe(false) + }) + + it('rejects columns holding object or array cells', () => { + const rows: Row[] = [{ payload: 'ok' }, { payload: { a: 1 } }] + expect(isSortableColumn('payload', rows, { payload: 'text' })).toBe(false) + }) + + it('treats an empty result set as sortable', () => { + expect(isSortableColumn('id', [], { id: 'integer' })).toBe(true) + }) + + it('collects only sortable columns', () => { + const rows: Row[] = [{ id: 1, meta: { a: 1 }, label: 'x' }] + const sortable = collectSortableColumns(['id', 'meta', 'label'], rows, { meta: 'jsonb' }) + expect([...sortable]).toEqual(['id', 'label']) + }) +}) + +describe('compareSortValues', () => { + it('orders numbers numerically', () => { + expect(compareSortValues(2, 10, 'integer')).toBeLessThan(0) + expect(compareSortValues(10, 2, 'integer')).toBeGreaterThan(0) + expect(compareSortValues(2, 2, 'integer')).toBe(0) + }) + + it('compares numeric strings with a numeric column type', () => { + expect(compareSortValues('9', '10', 'bigint')).toBeLessThan(0) + }) + + it('orders booleans false before true', () => { + expect(compareSortValues(false, true, 'boolean')).toBeLessThan(0) + expect(compareSortValues('t', 'f', 'bool')).toBeGreaterThan(0) + }) + + it('orders dates chronologically', () => { + expect(compareSortValues('2024-01-01', '2024-06-01', 'date')).toBeLessThan(0) + expect(compareSortValues('2024-06-01T00:00:00Z', '2024-06-01T00:00:00Z', 'timestamp')).toBe(0) + }) + + it('sorts nulls after real values in ascending order', () => { + expect(compareSortValues(null, 1)).toBeGreaterThan(0) + expect(compareSortValues(1, null)).toBeLessThan(0) + expect(compareSortValues(null, null)).toBe(0) + }) + + it('uses numeric-aware, case-insensitive string comparison', () => { + expect(compareSortValues('item2', 'item10')).toBeLessThan(0) + expect(compareSortValues('Apple', 'apple')).toBe(0) + }) +}) + +describe('sortRowsByState', () => { + const rows: Row[] = [ + { id: 3, name: 'carol' }, + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + ] + + it('returns a copy when no sort rules are active', () => { + const result = sortRowsByState(rows, []) + expect(result).toEqual(rows) + expect(result).not.toBe(rows) + }) + + it('sorts ascending without mutating the input', () => { + const result = sortRowsByState(rows, [{ column: 'id', direction: 'ASC' }]) + expect(result.map(r => r.id)).toEqual([1, 2, 3]) + expect(rows.map(r => r.id)).toEqual([3, 1, 2]) + }) + + it('sorts descending', () => { + const result = sortRowsByState(rows, [{ column: 'id', direction: 'DESC' }]) + expect(result.map(r => r.id)).toEqual([3, 2, 1]) + }) + + it('applies multiple rules in priority order', () => { + const data: Row[] = [ + { dept: 'b', age: 30 }, + { dept: 'a', age: 40 }, + { dept: 'a', age: 20 }, + ] + const state: SortState = [ + { column: 'dept', direction: 'ASC' }, + { column: 'age', direction: 'DESC' }, + ] + const result = sortRowsByState(data, state) + expect(result.map(r => `${r.dept}${r.age}`)).toEqual(['a40', 'a20', 'b30']) + }) + + it('keeps the original order for equal keys', () => { + const data: Row[] = [ + { group: 'x', seq: 1 }, + { group: 'x', seq: 2 }, + { group: 'x', seq: 3 }, + ] + const result = sortRowsByState(data, [{ column: 'group', direction: 'ASC' }]) + expect(result.map(r => r.seq)).toEqual([1, 2, 3]) + }) + + it('puts nulls last ascending and first descending', () => { + const data: Row[] = [{ n: 2 }, { n: null }, { n: 1 }] + expect(sortRowsByState(data, [{ column: 'n', direction: 'ASC' }]).map(r => r.n)).toEqual([1, 2, null]) + expect(sortRowsByState(data, [{ column: 'n', direction: 'DESC' }]).map(r => r.n)).toEqual([null, 2, 1]) + }) + + it('uses the declared column type for ordering', () => { + const data: Row[] = [{ v: '10' }, { v: '9' }] + expect(sortRowsByState(data, [{ column: 'v', direction: 'ASC' }], { v: 'bigint' }).map(r => r.v)) + .toEqual(['9', '10']) + }) +}) + +describe('formatSortState', () => { + it('renders column and direction', () => { + expect(formatSortState([{ column: 'id', direction: 'ASC' }, { column: 'name', direction: 'DESC' }])) + .toBe('id ↑, name ↓') + }) + + it('renders an empty string without rules', () => { + expect(formatSortState([])).toBe('') + }) +}) diff --git a/src/__tests__/useDataGridSort.test.ts b/src/__tests__/useDataGridSort.test.ts new file mode 100644 index 00000000..bef6e4a6 --- /dev/null +++ b/src/__tests__/useDataGridSort.test.ts @@ -0,0 +1,54 @@ +import { useDataGridSort } from '@/composables/useDataGridSort' + +describe('useDataGridSort', () => { + it('cycles a single column ASC → DESC → cleared', () => { + const sort = useDataGridSort() + + sort.toggleSort('id') + expect(sort.sortState.value).toEqual([{ column: 'id', direction: 'ASC' }]) + + sort.toggleSort('id') + expect(sort.sortState.value).toEqual([{ column: 'id', direction: 'DESC' }]) + + sort.toggleSort('id') + expect(sort.sortState.value).toEqual([]) + }) + + it('replaces the previous column when shift is not held', () => { + const sort = useDataGridSort() + sort.toggleSort('id') + sort.toggleSort('name') + expect(sort.sortState.value).toEqual([{ column: 'name', direction: 'ASC' }]) + }) + + it('appends columns for multi-column sort when shift is held', () => { + const sort = useDataGridSort() + sort.toggleSort('id') + sort.toggleSort('name', true) + expect(sort.sortState.value).toEqual([ + { column: 'id', direction: 'ASC' }, + { column: 'name', direction: 'ASC' }, + ]) + expect(sort.getSortPriority('name')).toBe(2) + }) + + it('sets an explicit direction without dropping other rules', () => { + const sort = useDataGridSort() + sort.toggleSort('id') + sort.toggleSort('name', true) + sort.setSort('id', 'DESC') + expect(sort.sortState.value).toEqual([ + { column: 'id', direction: 'DESC' }, + { column: 'name', direction: 'ASC' }, + ]) + }) + + it('prunes rules for columns that are no longer available', () => { + const sort = useDataGridSort() + sort.toggleSort('id') + sort.toggleSort('name', true) + sort.pruneSort(['name']) + expect(sort.sortState.value).toEqual([{ column: 'name', direction: 'ASC' }]) + expect(sort.getSortDirection('id')).toBeNull() + }) +}) diff --git a/src/components/database-browser/DataTableView.vue b/src/components/database-browser/DataTableView.vue index f1b45695..a9e09bd4 100644 --- a/src/components/database-browser/DataTableView.vue +++ b/src/components/database-browser/DataTableView.vue @@ -20,10 +20,12 @@ import { SelectValue, } from '@/components/ui/select' import { Spinner } from '@/components/ui/spinner' +import { useDataGridSort } from '@/composables/useDataGridSort' import { useMinLoadingTime } from '@/composables/useMinLoadingTime' import { toast } from '@/composables/useNotifications' import { useTableSearch } from '@/composables/useTableSearch' import { ConnectionStatus, useConnectionStore } from '@/store' +import { collectSortableColumns, sortRowsByState } from '@/utils/gridSort' import { computeOffset, computeTotalPages, @@ -165,6 +167,35 @@ const columnTypeMap = computed(() => Object.fromEntries(columnInfoList.value.map(c => [c.name, c.data_type])), ) +// ── Client-side sorting (primitive columns only) ── +// Sorting applies to the rows already fetched for the current page; JSON, +// array and BLOB columns stay unsortable because they have no total order. +const sort = useDataGridSort() + +const sortColumnTypes = computed(() => { + const result = data.value + const fromResult = result + ? Object.fromEntries(result.columns.map((col, i) => [col, result.columnTypes?.[i] ?? ''])) + : {} + return { ...fromResult, ...columnTypeMap.value } +}) + +const sortableColumns = computed(() => + collectSortableColumns(visibleColumns.value, data.value?.rows ?? [], sortColumnTypes.value), +) + +const sortedRows = computed(() => + sortRowsByState(data.value?.rows ?? [], sort.sortState.value, sortColumnTypes.value), +) + +const isSortable = (column: string): boolean => sortableColumns.value.has(column) + +function handleHeaderSort(column: string) { + if (!isSortable(column)) + return + sort.toggleSort(column) +} + const columnIsPK = computed(() => Object.fromEntries(columnInfoList.value.map(c => [c.name, c.is_primary_key])), ) @@ -480,7 +511,7 @@ async function exportCSV() { if (!data.value) return - const csv = rowsToCsv(data.value.rows, visibleColumns.value) + const csv = rowsToCsv(sortedRows.value, visibleColumns.value) const conn = connectionStore.getConnectionById(props.connectionId) const connName = conn?.name || props.connectionId const parts = [connName, props.database] @@ -575,7 +606,7 @@ async function confirmBatchDelete() { let failCount = 0 for (const idx of indices) { - const row = data.value.rows[idx] + const row = sortedRows.value[idx] if (!row || pkColumns.value.length === 0) continue @@ -784,6 +815,11 @@ watch(data, () => { selectedRows.value = new Set() }) +// Sorting reorders positional indices, so the current selection no longer maps +watch(sort.sortState, () => { + selectedRows.value = new Set() +}) + const formatValue = formatTableValue const isNullValue = isTableNullValue @@ -814,6 +850,7 @@ watch( searchDebounceTimer = null } hiddenColumns.value = new Set() + sort.clearSort() connectionError.value = null if (newConnId !== oldConnId) { @@ -967,7 +1004,7 @@ watch( variant="ghost" size="icon" class="flex-shrink-0 h-7 w-7" - :disabled="!data || data.rows.length === 0 || isExporting" + :disabled="!data || sortedRows.length === 0 || isExporting" :title="t('components.dataTableView.exportCsv')" @click.stop="exportCSV" > @@ -1067,7 +1104,33 @@ watch( :key="col" class="data-table-header text-left" > -
+ +
{{ col }} {{ columnTypeMap[col] }} @@ -1081,7 +1144,7 @@ watch(
diff --git a/src/components/database-browser/QueryResultPanel.vue b/src/components/database-browser/QueryResultPanel.vue index d4af3e4e..23cd1652 100644 --- a/src/components/database-browser/QueryResultPanel.vue +++ b/src/components/database-browser/QueryResultPanel.vue @@ -3,7 +3,7 @@ import type { ColumnInfo } from '@/composables/sqlCompletion/metadata' import type { QueryResult } from '@/store/tabStore' import type { ApiError, ApiResponse } from '@/types/api' import type { ExplainResult } from '@/types/explainPlan' -import type { ColumnFilter, SortColumn } from '@/types/grid' +import type { ColumnFilter } from '@/types/grid' import { invoke } from '@tauri-apps/api/core' import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -82,12 +82,13 @@ const formattedTime = computed(() => { : `${(props.executionTime / 1000).toFixed(2)}s` }) -// ── Sort/Filter Re-execution (backend-driven) ── +// ── Filter Re-execution (backend-driven) ── +// Sorting is handled client-side by DataGrid over the returned rows; only +// filters still need a round-trip because they change the result set itself. const gridLoading = ref(false) const gridError = ref(null) const gridResults = ref(null) const gridExecutionTimeMs = ref(undefined) -const activeSort = ref([]) const activeFilters = ref([]) // ── Result editability (single-table SELECTs only) ── @@ -168,17 +169,12 @@ watch(() => props.results, (r) => { } }, { immediate: true }) -async function handleSortChange(sort: SortColumn[]) { - activeSort.value = sort - await reExecuteWithSortFilter() -} - async function handleFilterChange(filters: ColumnFilter[]) { activeFilters.value = filters - await reExecuteWithSortFilter() + await reExecuteFiltered() } -async function reExecuteWithSortFilter() { +async function reExecuteFiltered() { if (!props.connectionId || !props.sql) return @@ -193,10 +189,7 @@ async function reExecuteWithSortFilter() { options: { originalSql: props.sql, schemaContext: props.schema ?? null, - sort: activeSort.value.map(s => ({ - column: s.column, - direction: s.direction === 'ASC' ? 'ASC' : 'DESC', - })), + sort: [], filters: activeFilters.value.map(f => ({ column: f.column, operator: f.operator.toUpperCase(), @@ -229,7 +222,6 @@ async function reExecuteWithSortFilter() { } function handleRefresh() { - activeSort.value = [] activeFilters.value = [] gridResults.value = props.results ?? null gridError.value = null @@ -445,7 +437,6 @@ const displayExecutionTime = computed(() => gridExecutionTimeMs.value ?? props.e :hide-toolbar="true" :hide-batch-actions="true" :error="displayErrorMessage" - @sort-change="handleSortChange" @filter-change="handleFilterChange" @refresh="handleRefresh" /> diff --git a/src/components/grid/ColumnHeaderContextMenu.vue b/src/components/grid/ColumnHeaderContextMenu.vue index bd25c6ea..c3d7fcf1 100644 --- a/src/components/grid/ColumnHeaderContextMenu.vue +++ b/src/components/grid/ColumnHeaderContextMenu.vue @@ -19,6 +19,7 @@ const props = defineProps<{ columnType?: string hasActiveFilter: boolean currentSortDirection: SortDirection | null + sortable?: boolean }>() const emit = defineEmits() @@ -69,11 +70,11 @@ function handleFilterWithPrompt(operator: ColumnFilter['operator']) { - + {{ t('components.dataGrid.sort.asc') }} - + {{ t('components.dataGrid.sort.desc') }} @@ -84,6 +85,9 @@ function handleFilterWithPrompt(operator: ColumnFilter['operator']) { {{ t('components.dataGrid.sort.clear') }} +
+ {{ t('components.dataGrid.sort.unsortable') }} +
diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index b6edfc9f..0cf4a76d 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -27,6 +27,7 @@ import { useDataGridFilter } from '@/composables/useDataGridFilter' import { useDataGridSelection } from '@/composables/useDataGridSelection' import { useDataGridSort } from '@/composables/useDataGridSort' import { toast } from '@/composables/useNotifications' +import { collectSortableColumns, formatSortState, sortRowsByState } from '@/utils/gridSort' import BatchActionBar from './BatchActionBar.vue' import CellContextMenu from './CellContextMenu.vue' import ColumnHeaderContextMenu from './ColumnHeaderContextMenu.vue' @@ -77,6 +78,38 @@ const filter = useDataGridFilter() const selection = useDataGridSelection() const copyUtil = useDataGridCopy() +// ── Client-side sorting ── +// Sorting happens on the returned rows, in the frontend. Only primitive +// columns (text/number/boolean/date) can be ordered; JSON, arrays and BLOBs +// are excluded because they have no client-side total ordering. +const sortableColumns = computed(() => + collectSortableColumns(props.columns, props.rows, props.columnTypes ?? {}), +) + +const isSortable = (column: string): boolean => sortableColumns.value.has(column) + +const sortedRows = computed(() => + sortRowsByState(props.rows, sort.sortState.value, props.columnTypes ?? {}), +) + +const sortSummary = computed(() => formatSortState(sort.sortState.value)) + +function handleHeaderSort(column: string, shiftKey: boolean) { + if (!isSortable(column)) + return + sort.toggleSort(column, shiftKey) +} + +function handleSortFromHeader(column: string, direction: import('@/types/grid').SortDirection) { + if (!isSortable(column)) + return + sort.setSort(column, direction) +} + +function handleClearSort() { + sort.clearSort() +} + // ── Virtual Scroller ── const scrollContainer = ref(null) @@ -170,10 +203,10 @@ function openCellContextMenu(e: MouseEvent, rowIndex: number, col: string) { show: true, x: e.clientX, y: e.clientY, - value: props.rows[rowIndex]?.[col] ?? null, + value: sortedRows.value[rowIndex]?.[col] ?? null, column: col, columnType: props.columnTypes?.[col] ?? '', - row: props.rows[rowIndex] ?? null, + row: sortedRows.value[rowIndex] ?? null, rowIndex, } } @@ -191,19 +224,6 @@ function openHeaderContextMenu(e: MouseEvent, col: string) { } // ── Event Handlers ── -function handleSortFromHeader(column: string, direction: import('@/types/grid').SortDirection) { - sort.clearSort() - sort.toggleSort(column) - if (sort.getSortDirection(column) !== direction) - sort.toggleSort(column) - emit('sortChange', sort.sortState.value) -} - -function handleClearSort() { - sort.clearSort() - emit('sortChange', []) -} - function handleAddFilter(f: ColumnFilter) { filter.addFilter(f) emit('filterChange', filter.filters.value) @@ -278,7 +298,7 @@ async function confirmDelete() { isDeleting.value = true try { const { invoke } = await import('@tauri-apps/api/core') - const row = props.rows[deletingRowIndex.value] + const row = sortedRows.value[deletingRowIndex.value] const pkValues = Object.fromEntries( props.primaryKeys.map(col => [col, row?.[col] ?? null]), ) @@ -312,20 +332,20 @@ const currentEditingRow = computed(() => { return null if (isDuplicateRow.value) { // Clone the row but remove PK values - const row = props.rows[editingRowIndex.value] + const row = sortedRows.value[editingRowIndex.value] if (!row) return null const clone = { ...row } for (const pk of props.primaryKeys) delete clone[pk] return clone } - return props.rows[editingRowIndex.value] ?? null + return sortedRows.value[editingRowIndex.value] ?? null }) const currentDetailsRow = computed(() => { if (detailsRowIndex.value === null) return null - return props.rows[detailsRowIndex.value] ?? null + return sortedRows.value[detailsRowIndex.value] ?? null }) // ── Type-aware Cell Formatting ── @@ -433,19 +453,19 @@ function formatCellText(v: unknown): string { // ── Copy/Export ── function copyAllAs(format: import('@/types/grid').CopyFormat) { - copyUtil.copyRowsAs(props.rows, props.columns, format, props.tableName) + copyUtil.copyRowsAs(sortedRows.value, props.columns, format, props.tableName) } function exportAllAs(format: import('@/types/grid').CopyFormat) { - copyUtil.exportToFile(props.rows, props.columns, format, props.tableName) + copyUtil.exportToFile(sortedRows.value, props.columns, format, props.tableName) } // ── Status Bar ── const statusText = computed(() => { - if (props.rows.length === 0) + if (sortedRows.value.length === 0) return '' const start = 1 - const end = props.rows.length + const end = sortedRows.value.length return t('components.dataGrid.status.showing', { start, end, total: props.rowCount.toLocaleString() }) }) @@ -461,17 +481,22 @@ const formattedTime = computed(() => { // ── Load more handling for virtual scroll ── const allRowsLoaded = computed(() => props.rows.length >= props.rowCount) -// ── Clear selection on data change ── -watch(() => [props.rows, props.columns], () => { +// ── Clear selection on data or sort change (indices are positional) ── +watch(() => [props.rows, props.columns, sort.sortState.value], () => { selection.clearSelection() }) + +// ── Drop sort rules for columns that disappeared from the result set ── +watch(sortableColumns, (columns) => { + sort.pruneSort([...columns]) +})