From ab3468123caa9a39d74d2440b1b6600be4da41c7 Mon Sep 17 00:00:00 2001 From: blankll Date: Thu, 8 Oct 2026 17:59:43 +0800 Subject: [PATCH] feat(grid): client-side sorting for primitive result columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result grids delegated sorting to the backend, which re-ran the user's SQL wrapped in `SELECT * FROM (…) ORDER BY …`. That only worked for plain parsable SELECTs, re-ran expensive queries on every header click, and the table browser had no sort support at all. Sorting now happens in the frontend over the rows already returned, and is limited to primitive columns (text/number/boolean/date/NULL) because JSON, arrays and BLOBs have no meaningful client-side total ordering. - add src/utils/gridSort.ts: sortability detection, type-aware comparator, stable multi-column sort, sort summary - extend useDataGridSort with setSort/pruneSort; drop the now-unused buildOrderByClause and hasActiveSort - DataGrid: sortedRows drives rendering, row actions, copy/export and the status bar; unsortable headers and menu entries explain why - QueryResultPanel: filters still round-trip, sorting no longer re-executes - DataTableView: header sorting with indicators, scoped to the fetched page - cover the comparator and sort state with unit tests Backend `execute_sorted_query` keeps its sort field; the frontend now always sends an empty list, so no Rust change is required. --- docs/GRID_SORT_DESIGN.md | 117 +++++++++++++ src/__tests__/gridSort.test.ts | 160 ++++++++++++++++++ src/__tests__/useDataGridSort.test.ts | 54 ++++++ .../database-browser/DataTableView.vue | 75 +++++++- .../database-browser/QueryResultPanel.vue | 23 +-- .../grid/ColumnHeaderContextMenu.vue | 8 +- src/components/grid/DataGrid.vue | 137 +++++++++------ src/composables/useDataGridSort.ts | 30 ++-- src/lang/enUS.ts | 1 + src/lang/zhCN.ts | 1 + src/types/grid.ts | 1 - src/utils/gridSort.ts | 155 +++++++++++++++++ 12 files changed, 676 insertions(+), 86 deletions(-) create mode 100644 docs/GRID_SORT_DESIGN.md create mode 100644 src/__tests__/gridSort.test.ts create mode 100644 src/__tests__/useDataGridSort.test.ts create mode 100644 src/utils/gridSort.ts 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]) +})