feat(grid): add current page sorting
This commit is contained in:
parent
9621f277bf
commit
829f6dc7eb
|
|
@ -60,6 +60,7 @@ import {
|
|||
PanelBottom,
|
||||
PanelRight,
|
||||
TableProperties,
|
||||
Database,
|
||||
} from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
|
|
@ -139,7 +140,7 @@ import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composabl
|
|||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { DataGridSortDirection } from "@/lib/dataGridSort";
|
||||
import type { DataGridSortDirection, DataGridSortMode } from "@/lib/dataGridSort";
|
||||
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
|
||||
import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory } from "@/lib/dataGridConditionHistory";
|
||||
import { caretPositionInsideInsertedSqlSingleQuotes, insertedSqlSingleQuoteAtCaret } from "@/lib/sqlQuoteCaret";
|
||||
|
|
@ -173,7 +174,7 @@ type ConditionSuggestion = {
|
|||
kind: "column" | "history";
|
||||
};
|
||||
|
||||
type SortMenuValue = "asc" | "desc" | "clear";
|
||||
type SortMenuValue = "local-asc" | "local-desc" | "database-asc" | "database-desc" | "clear";
|
||||
|
||||
const props = defineProps<{
|
||||
result: QueryResult;
|
||||
|
|
@ -190,6 +191,7 @@ const props = defineProps<{
|
|||
sortColumn?: string;
|
||||
sortColumnIndex?: number;
|
||||
sortDirection?: DataGridSortDirection;
|
||||
sortMode?: DataGridSortMode;
|
||||
tableMeta?: {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
|
|
@ -217,7 +219,7 @@ const dataGridElapsed = () => `${Math.round(performance.now() - dataGridCreatedA
|
|||
const emit = defineEmits<{
|
||||
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
|
||||
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
|
||||
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
|
||||
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
|
||||
"update:whereInput": [value: string];
|
||||
"update:orderByInput": [value: string];
|
||||
}>();
|
||||
|
|
@ -354,16 +356,29 @@ function columnIsSorted(column: string, columnIndex: number): boolean {
|
|||
function sortMenuItems(column: string, columnIndex: number) {
|
||||
return [
|
||||
{
|
||||
label: t("grid.sortAscending"),
|
||||
value: "asc",
|
||||
label: t("grid.sortCurrentPageAscending"),
|
||||
value: "local-asc",
|
||||
icon: ArrowUp,
|
||||
checked: columnIsSorted(column, columnIndex) && sortDir.value === "asc",
|
||||
checked: columnIsSorted(column, columnIndex) && sortDir.value === "asc" && sortMode.value === "local",
|
||||
},
|
||||
{
|
||||
label: t("grid.sortDescending"),
|
||||
value: "desc",
|
||||
label: t("grid.sortCurrentPageDescending"),
|
||||
value: "local-desc",
|
||||
icon: ArrowDown,
|
||||
checked: columnIsSorted(column, columnIndex) && sortDir.value === "desc",
|
||||
checked: columnIsSorted(column, columnIndex) && sortDir.value === "desc" && sortMode.value === "local",
|
||||
},
|
||||
{
|
||||
label: t("grid.sortDatabaseAscending"),
|
||||
value: "database-asc",
|
||||
icon: Database,
|
||||
checked: columnIsSorted(column, columnIndex) && sortDir.value === "asc" && sortMode.value === "database",
|
||||
separatorBefore: true,
|
||||
},
|
||||
{
|
||||
label: t("grid.sortDatabaseDescending"),
|
||||
value: "database-desc",
|
||||
icon: Database,
|
||||
checked: columnIsSorted(column, columnIndex) && sortDir.value === "desc" && sortMode.value === "database",
|
||||
},
|
||||
{
|
||||
label: t("grid.clearSort"),
|
||||
|
|
@ -376,7 +391,7 @@ function sortMenuItems(column: string, columnIndex: number) {
|
|||
}
|
||||
|
||||
function selectedSortMenuValue(column: string, columnIndex: number): SortMenuValue | undefined {
|
||||
return columnIsSorted(column, columnIndex) ? sortDir.value : undefined;
|
||||
return columnIsSorted(column, columnIndex) ? (`${sortMode.value}-${sortDir.value}` as SortMenuValue) : undefined;
|
||||
}
|
||||
|
||||
function typeColorClass(t: string): string {
|
||||
|
|
@ -429,6 +444,7 @@ const transposeViewportWidth = ref(0);
|
|||
const sortCol = ref<string | null>(null);
|
||||
const sortColIndex = ref<number | null>(null);
|
||||
const sortDir = ref<DataGridSortDirection>("asc");
|
||||
const sortMode = ref<DataGridSortMode>("database");
|
||||
const searchText = ref("");
|
||||
const deferredClientSearchText = ref("");
|
||||
const searchOverlayVisible = ref(false);
|
||||
|
|
@ -2454,14 +2470,15 @@ function syncOrderByInputWithSort(column: string | null, direction: "asc" | "des
|
|||
}
|
||||
|
||||
watch(
|
||||
() => [props.sortColumn, props.sortColumnIndex, props.sortDirection] as const,
|
||||
([column, columnIndex, direction], previous) => {
|
||||
() => [props.sortColumn, props.sortColumnIndex, props.sortDirection, props.sortMode] as const,
|
||||
([column, columnIndex, direction, mode], previous) => {
|
||||
const wasControlledSort = !!previous?.[0] && !!previous?.[2];
|
||||
const isControlledSort = !!column && !!direction;
|
||||
sortCol.value = column && direction ? column : null;
|
||||
sortColIndex.value = typeof columnIndex === "number" && direction ? columnIndex : null;
|
||||
sortDir.value = direction ?? "asc";
|
||||
if (isControlledSort) {
|
||||
sortMode.value = mode ?? "database";
|
||||
if (isControlledSort && sortMode.value === "database") {
|
||||
syncOrderByInputWithSort(sortCol.value, sortDir.value);
|
||||
} else if (wasControlledSort) {
|
||||
syncOrderByInputWithSort(null, null);
|
||||
|
|
@ -3740,7 +3757,7 @@ function setDetailNull() {
|
|||
detailCell.value = { ...detailCell.value! };
|
||||
}
|
||||
|
||||
function applyColumnSort(column: string, columnIndex: number, direction: "asc" | "desc" | null) {
|
||||
function applyColumnSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, mode: DataGridSortMode = "database") {
|
||||
if (getIsResizing()) return;
|
||||
currentPage.value = 1;
|
||||
resetGridVerticalScroll(true);
|
||||
|
|
@ -3748,23 +3765,34 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
|
|||
sortCol.value = column;
|
||||
sortColIndex.value = columnIndex;
|
||||
sortDir.value = direction;
|
||||
syncOrderByInputWithSort(column, direction);
|
||||
sortMode.value = mode;
|
||||
if (mode === "database") {
|
||||
syncOrderByInputWithSort(column, direction);
|
||||
} else {
|
||||
syncOrderByInputWithSort(null, null);
|
||||
}
|
||||
} else {
|
||||
sortCol.value = null;
|
||||
sortColIndex.value = null;
|
||||
sortDir.value = "asc";
|
||||
sortMode.value = "database";
|
||||
syncOrderByInputWithSort(null, null);
|
||||
}
|
||||
emit("sort", column, columnIndex, direction, currentWhereInput());
|
||||
emit("sort", column, columnIndex, direction, currentWhereInput(), mode);
|
||||
}
|
||||
|
||||
function selectHeaderSort(value: string, column: string, columnIndex: number) {
|
||||
applyColumnSort(column, columnIndex, value === "clear" ? null : (value as DataGridSortDirection));
|
||||
if (value === "clear") {
|
||||
applyColumnSort(column, columnIndex, null, sortMode.value);
|
||||
return;
|
||||
}
|
||||
const [mode, direction] = value.split("-") as [DataGridSortMode, DataGridSortDirection];
|
||||
applyColumnSort(column, columnIndex, direction, mode);
|
||||
}
|
||||
|
||||
function applyContextSort(direction: "asc" | "desc" | null) {
|
||||
function applyContextSort(direction: "asc" | "desc" | null, mode: DataGridSortMode = "database") {
|
||||
if (!contextColumn.value || !contextCell.value) return;
|
||||
applyColumnSort(contextColumn.value, contextCell.value.col, direction);
|
||||
applyColumnSort(contextColumn.value, contextCell.value.col, direction, mode);
|
||||
}
|
||||
|
||||
async function contextFilterCondition(mode: FilterMode): Promise<string | null> {
|
||||
|
|
@ -6366,9 +6394,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
|
||||
// 2. Column sort & filter
|
||||
if (contextColumn.value) {
|
||||
items.push({ label: t("grid.sortAscending"), action: () => applyContextSort("asc"), icon: ArrowUp }, { label: t("grid.sortDescending"), action: () => applyContextSort("desc"), icon: ArrowDown });
|
||||
items.push(
|
||||
{ label: t("grid.sortCurrentPageAscending"), action: () => applyContextSort("asc", "local"), icon: ArrowUp },
|
||||
{ label: t("grid.sortCurrentPageDescending"), action: () => applyContextSort("desc", "local"), icon: ArrowDown },
|
||||
{ label: "", separator: true },
|
||||
{ label: t("grid.sortDatabaseAscending"), action: () => applyContextSort("asc", "database"), icon: Database },
|
||||
{ label: t("grid.sortDatabaseDescending"), action: () => applyContextSort("desc", "database"), icon: Database },
|
||||
);
|
||||
if (sortCol.value) {
|
||||
items.push({ label: t("grid.clearSort"), action: () => applyContextSort(null), icon: ArrowUpDown });
|
||||
items.push({ label: t("grid.clearSort"), action: () => applyContextSort(null, sortMode.value), icon: ArrowUpDown });
|
||||
}
|
||||
if (canUseWhereSearch.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { formatShortcut } from "@/lib/shortcutRegistry";
|
|||
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { chartableColumnIndexes } from "@/lib/chartData";
|
||||
import type { SqlExecutionOverride } from "@/lib/sqlExecutionTarget";
|
||||
import type { DataGridSortMode } from "@/lib/dataGridSort";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import type { QueryTab, ConnectionConfig, TableInfoTab } from "@/types/database";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
|
|
@ -119,7 +120,7 @@ const emit = defineEmits<{
|
|||
formatError: [];
|
||||
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
|
||||
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
|
||||
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
|
||||
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
|
||||
executeSql: [sql: string];
|
||||
clickTable: [tableName: string];
|
||||
viewTableData: [tableName: string];
|
||||
|
|
@ -764,6 +765,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
:sort-column="activeTab.resultSortColumn"
|
||||
:sort-column-index="activeTab.resultSortColumnIndex"
|
||||
:sort-direction="activeTab.resultSortDirection"
|
||||
:sort-mode="activeTab.resultSortMode"
|
||||
:initial-order-by-input="activeTab.orderByInput"
|
||||
:sql="activeTab.lastExecutedSql || activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
|
|
@ -787,7 +789,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, columnIndex, direction, whereInput)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
|
||||
>
|
||||
<template v-if="activeTab.result?.columns.includes('Error')" #error-actions="{ errorMessage }">
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('fixWithAi', String(errorMessage))">
|
||||
|
|
@ -959,6 +961,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
:sort-column="activeTab.resultSortColumn"
|
||||
:sort-column-index="activeTab.resultSortColumnIndex"
|
||||
:sort-direction="activeTab.resultSortDirection"
|
||||
:sort-mode="activeTab.resultSortMode"
|
||||
:initial-order-by-input="activeTab.orderByInput"
|
||||
:sql="activeTab.sql"
|
||||
:loading="activeTab.isExecuting"
|
||||
|
|
@ -978,7 +981,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
|
|||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) => emit('sort', column, columnIndex, direction, whereInput)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
|
||||
/>
|
||||
<QueryLoadingState v-else-if="activeTab.isExecuting" class="h-full" :label-key="queryExecutionLabelKey(activeTab)" :elapsed-seconds="queryRunningElapsedSeconds" show-cancel :cancel-disabled="!canCancelQueryExecution(activeTab)" :cancelling="activeTab.isCancelling" @cancel="emit('cancel')" />
|
||||
<div v-else class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||||
|
|
|
|||
|
|
@ -897,6 +897,8 @@ async function openData() {
|
|||
existing.resultSortColumn = undefined;
|
||||
existing.resultSortColumnIndex = undefined;
|
||||
existing.resultSortDirection = undefined;
|
||||
existing.resultSortMode = undefined;
|
||||
existing.resultLocalSortOriginalRows = undefined;
|
||||
existing.resultSortedSql = undefined;
|
||||
queryStore.activeTabId = existing.id;
|
||||
return existing.id;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { QueryTab } from "@/types/database";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import type { DataGridSortMode } from "@/lib/dataGridSort";
|
||||
|
||||
const DATA_TAB_METADATA_TTL_MS = 30_000;
|
||||
|
||||
|
|
@ -175,12 +176,22 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
|||
});
|
||||
}
|
||||
|
||||
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string) {
|
||||
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
tab.resultSortColumn = direction ? column : undefined;
|
||||
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
|
||||
tab.resultSortDirection = direction ?? undefined;
|
||||
tab.resultSortMode = direction ? mode : undefined;
|
||||
|
||||
if (mode === "local") {
|
||||
if (tab.mode === "data") {
|
||||
tab.whereInput = whereInput ?? "";
|
||||
tab.orderByInput = undefined;
|
||||
}
|
||||
queryStore.sortTabResultLocally(tab.id, column, columnIndex, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab.mode === "data") {
|
||||
if (!tableMetaForDataTab(tab)) return;
|
||||
|
|
|
|||
|
|
@ -665,6 +665,10 @@ export default {
|
|||
sort: "Sort",
|
||||
sortAscending: "Sort Ascending",
|
||||
sortDescending: "Sort Descending",
|
||||
sortCurrentPageAscending: "Sort Current Page Ascending",
|
||||
sortCurrentPageDescending: "Sort Current Page Descending",
|
||||
sortDatabaseAscending: "Sort Database Ascending",
|
||||
sortDatabaseDescending: "Sort Database Descending",
|
||||
clearSort: "Clear Sort",
|
||||
pasted: "Pasted!",
|
||||
search: "Search...",
|
||||
|
|
|
|||
|
|
@ -582,6 +582,10 @@ export default {
|
|||
sort: "Ordenar",
|
||||
sortAscending: "Orden ascendente",
|
||||
sortDescending: "Orden descendente",
|
||||
sortCurrentPageAscending: "Ordenar página actual ascendente",
|
||||
sortCurrentPageDescending: "Ordenar página actual descendente",
|
||||
sortDatabaseAscending: "Ordenar base de datos ascendente",
|
||||
sortDatabaseDescending: "Ordenar base de datos descendente",
|
||||
clearSort: "Limpiar orden",
|
||||
pasted: "¡Pegado!",
|
||||
search: "Buscar...",
|
||||
|
|
|
|||
|
|
@ -611,6 +611,10 @@ export default {
|
|||
sort: "Ordina",
|
||||
sortAscending: "Ordina in modo crescente",
|
||||
sortDescending: "Ordina in modo decrescente",
|
||||
sortCurrentPageAscending: "Ordina pagina corrente crescente",
|
||||
sortCurrentPageDescending: "Ordina pagina corrente decrescente",
|
||||
sortDatabaseAscending: "Ordina database crescente",
|
||||
sortDatabaseDescending: "Ordina database decrescente",
|
||||
clearSort: "Rimuovi Ordinamento",
|
||||
pasted: "Incollato!",
|
||||
search: "Cerca...",
|
||||
|
|
|
|||
|
|
@ -661,6 +661,10 @@ export default {
|
|||
sort: "並び替え",
|
||||
sortAscending: "昇順",
|
||||
sortDescending: "降順",
|
||||
sortCurrentPageAscending: "現在のページを昇順に並び替え",
|
||||
sortCurrentPageDescending: "現在のページを降順に並び替え",
|
||||
sortDatabaseAscending: "データベースで昇順に並び替え",
|
||||
sortDatabaseDescending: "データベースで降順に並び替え",
|
||||
clearSort: "並び替えをクリア",
|
||||
pasted: "貼り付けました!",
|
||||
search: "検索...",
|
||||
|
|
|
|||
|
|
@ -611,6 +611,10 @@ export default {
|
|||
sort: "Ordenar",
|
||||
sortAscending: "Ordenar de Forma Crescente",
|
||||
sortDescending: "Ordenar de Forma Decrescente",
|
||||
sortCurrentPageAscending: "Ordenar Página Atual Crescente",
|
||||
sortCurrentPageDescending: "Ordenar Página Atual Decrescente",
|
||||
sortDatabaseAscending: "Ordenar Banco de Dados Crescente",
|
||||
sortDatabaseDescending: "Ordenar Banco de Dados Decrescente",
|
||||
clearSort: "Limpar Ordenação",
|
||||
pasted: "Colado!",
|
||||
search: "Pesquisar...",
|
||||
|
|
|
|||
|
|
@ -666,6 +666,10 @@ export default {
|
|||
sort: "排序",
|
||||
sortAscending: "升序排序",
|
||||
sortDescending: "降序排序",
|
||||
sortCurrentPageAscending: "当前页升序排序",
|
||||
sortCurrentPageDescending: "当前页降序排序",
|
||||
sortDatabaseAscending: "数据库升序排序",
|
||||
sortDatabaseDescending: "数据库降序排序",
|
||||
clearSort: "清除排序",
|
||||
pasted: "已粘贴!",
|
||||
search: "搜索...",
|
||||
|
|
|
|||
|
|
@ -612,6 +612,10 @@ export default {
|
|||
sort: "排序",
|
||||
sortAscending: "遞增排序",
|
||||
sortDescending: "遞減排序",
|
||||
sortCurrentPageAscending: "目前頁遞增排序",
|
||||
sortCurrentPageDescending: "目前頁遞減排序",
|
||||
sortDatabaseAscending: "資料庫遞增排序",
|
||||
sortDatabaseDescending: "資料庫遞減排序",
|
||||
clearSort: "清除排序",
|
||||
pasted: "已貼上!",
|
||||
search: "搜尋……",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export type DataGridSortDirection = "asc" | "desc";
|
||||
export type DataGridSortMode = "database" | "local";
|
||||
|
||||
export interface DataGridSortState {
|
||||
column: string | null;
|
||||
|
|
@ -15,3 +16,68 @@ export function nextDataGridSortState(current: DataGridSortState, column: string
|
|||
}
|
||||
return { column, columnIndex, direction: "asc" };
|
||||
}
|
||||
|
||||
type DataGridCellValue = string | number | boolean | null | undefined;
|
||||
type DataGridRow = DataGridCellValue[];
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
export function sortDataGridRows<T extends DataGridRow>(rows: readonly T[], columnIndex: number, direction: DataGridSortDirection): T[] {
|
||||
const directionMultiplier = direction === "asc" ? 1 : -1;
|
||||
return rows
|
||||
.map((row, index) => ({ row, index }))
|
||||
.sort((left, right) => {
|
||||
const emptyCompared = compareEmptyValues(left.row[columnIndex], right.row[columnIndex]);
|
||||
if (emptyCompared !== null) return emptyCompared;
|
||||
const compared = compareDataGridValues(left.row[columnIndex], right.row[columnIndex]);
|
||||
if (compared !== 0) return compared * directionMultiplier;
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map((item) => item.row);
|
||||
}
|
||||
|
||||
export function compareDataGridValues(left: DataGridCellValue, right: DataGridCellValue): number {
|
||||
const leftEmpty = left == null;
|
||||
const rightEmpty = right == null;
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return 0;
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
if (typeof left === "number" && typeof right === "number") {
|
||||
return compareNumbers(left, right);
|
||||
}
|
||||
if (typeof left === "boolean" && typeof right === "boolean") {
|
||||
return Number(left) - Number(right);
|
||||
}
|
||||
if (typeof left === "string" && typeof right === "string") {
|
||||
const leftDate = dateSortValue(left);
|
||||
const rightDate = dateSortValue(right);
|
||||
if (leftDate !== null && rightDate !== null) return compareNumbers(leftDate, rightDate);
|
||||
return collator.compare(left, right);
|
||||
}
|
||||
|
||||
return collator.compare(String(left), String(right));
|
||||
}
|
||||
|
||||
function compareEmptyValues(left: DataGridCellValue, right: DataGridCellValue): number | null {
|
||||
const leftEmpty = left == null;
|
||||
const rightEmpty = right == null;
|
||||
if (!leftEmpty && !rightEmpty) return null;
|
||||
if (leftEmpty && rightEmpty) return 0;
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareNumbers(left: number, right: number): number {
|
||||
if (Number.isNaN(left) || Number.isNaN(right)) {
|
||||
if (Number.isNaN(left) && Number.isNaN(right)) return 0;
|
||||
return Number.isNaN(left) ? 1 : -1;
|
||||
}
|
||||
return left - right;
|
||||
}
|
||||
|
||||
function dateSortValue(value: string): number | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}/.test(value)) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface SavedOpenTab {
|
|||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
resultSortDirection?: QueryTab["resultSortDirection"];
|
||||
resultSortMode?: QueryTab["resultSortMode"];
|
||||
orderByInput?: string;
|
||||
resultPageLimit?: number;
|
||||
resultPageOffset?: number;
|
||||
|
|
@ -67,6 +68,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
|||
...(tab.resultSortColumn !== undefined ? { resultSortColumn: tab.resultSortColumn } : {}),
|
||||
...(tab.resultSortColumnIndex !== undefined ? { resultSortColumnIndex: tab.resultSortColumnIndex } : {}),
|
||||
...(tab.resultSortDirection !== undefined ? { resultSortDirection: tab.resultSortDirection } : {}),
|
||||
...(tab.resultSortMode !== undefined ? { resultSortMode: tab.resultSortMode } : {}),
|
||||
...(tab.orderByInput !== undefined ? { orderByInput: tab.orderByInput } : {}),
|
||||
...(tab.resultPageLimit !== undefined ? { resultPageLimit: tab.resultPageLimit } : {}),
|
||||
...(tab.resultPageOffset !== undefined ? { resultPageOffset: tab.resultPageOffset } : {}),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
|
|||
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionContext, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
|
||||
import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort";
|
||||
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
|
||||
import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabResultCache";
|
||||
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/queryResultArchive";
|
||||
|
|
@ -202,6 +203,8 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.result = undefined;
|
||||
tab.results = undefined;
|
||||
tab.activeResultIndex = undefined;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
tab.resultSessionId = undefined;
|
||||
tab.resultAccessedAt = undefined;
|
||||
tab.queryAnalysis = undefined;
|
||||
|
|
@ -233,6 +236,8 @@ export const useQueryStore = defineStore("query", () => {
|
|||
tab.resultSortColumn = run.resultSortColumn;
|
||||
tab.resultSortColumnIndex = run.resultSortColumnIndex;
|
||||
tab.resultSortDirection = run.resultSortDirection;
|
||||
tab.resultSortMode = run.resultSortMode;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.orderByInput = run.orderByInput;
|
||||
tab.resultPageSql = run.resultPageSql;
|
||||
tab.resultPageLimit = run.resultPageLimit;
|
||||
|
|
@ -351,6 +356,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
resultSortColumn: tab.resultSortColumn,
|
||||
resultSortColumnIndex: tab.resultSortColumnIndex,
|
||||
resultSortDirection: tab.resultSortDirection,
|
||||
resultSortMode: tab.resultSortMode,
|
||||
orderByInput: tab.orderByInput,
|
||||
resultPageSql: tab.resultPageSql,
|
||||
resultPageLimit: tab.resultPageLimit,
|
||||
|
|
@ -397,6 +403,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
resultSortColumn: tab.resultSortColumn,
|
||||
resultSortColumnIndex: tab.resultSortColumnIndex,
|
||||
resultSortDirection: tab.resultSortDirection,
|
||||
resultSortMode: tab.resultSortMode,
|
||||
orderByInput: tab.orderByInput,
|
||||
resultPageSql: tab.resultPageSql,
|
||||
resultPageLimit: tab.resultPageLimit,
|
||||
|
|
@ -427,6 +434,38 @@ export const useQueryStore = defineStore("query", () => {
|
|||
}
|
||||
}
|
||||
|
||||
function assignDisplayedResult(tab: QueryTab, result: QueryResult) {
|
||||
tab.result = markQueryResultRowsRaw(result);
|
||||
if (tab.results?.length) {
|
||||
const activeIndex = tab.activeResultIndex ?? 0;
|
||||
if (activeIndex >= 0 && activeIndex < tab.results.length) {
|
||||
tab.results[activeIndex] = tab.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab?.result) return;
|
||||
|
||||
if (!tab.resultLocalSortOriginalRows) {
|
||||
tab.resultLocalSortOriginalRows = tab.result.rows.slice();
|
||||
}
|
||||
|
||||
const rows = direction ? sortDataGridRows(tab.resultLocalSortOriginalRows, columnIndex, direction) : tab.resultLocalSortOriginalRows;
|
||||
assignDisplayedResult(tab, { ...tab.result, rows });
|
||||
|
||||
tab.resultSortColumn = direction ? column : undefined;
|
||||
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
|
||||
tab.resultSortDirection = direction ?? undefined;
|
||||
tab.resultSortMode = direction ? "local" : undefined;
|
||||
tab.resultSortedSql = undefined;
|
||||
if (!direction) tab.resultLocalSortOriginalRows = undefined;
|
||||
|
||||
touchResult(tab);
|
||||
syncDisplayedResultRun(tab, tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql);
|
||||
}
|
||||
|
||||
function resultRunHasPayload(run: NonNullable<QueryTab["resultRuns"]>[number]): boolean {
|
||||
return !!run.result || !!run.results?.length;
|
||||
}
|
||||
|
|
@ -459,6 +498,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
resultSortColumn: t.resultSortColumn,
|
||||
resultSortColumnIndex: t.resultSortColumnIndex,
|
||||
resultSortDirection: t.resultSortDirection,
|
||||
resultSortMode: t.resultSortMode,
|
||||
orderByInput: t.orderByInput,
|
||||
resultPageLimit: t.resultPageLimit,
|
||||
resultPageOffset: t.resultPageOffset,
|
||||
|
|
@ -771,6 +811,8 @@ export const useQueryStore = defineStore("query", () => {
|
|||
resultSortColumn: undefined,
|
||||
resultSortColumnIndex: undefined,
|
||||
resultSortDirection: undefined,
|
||||
resultSortMode: undefined,
|
||||
resultLocalSortOriginalRows: undefined,
|
||||
orderByInput: undefined,
|
||||
resultPageSql: undefined,
|
||||
resultPageLimit: undefined,
|
||||
|
|
@ -1312,6 +1354,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
}
|
||||
tab.executionId = executionId;
|
||||
tab.lastExecutedSql = sql;
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
const updateActiveResultRun = !!tab.activeResultRunId && options?.preserveResultDuringExecution === true;
|
||||
if (!updateActiveResultRun) {
|
||||
tab.activeResultRunId = undefined;
|
||||
|
|
@ -1919,6 +1962,11 @@ export const useQueryStore = defineStore("query", () => {
|
|||
if (!tab?.results || index < 0 || index >= tab.results.length) return;
|
||||
tab.activeResultIndex = index;
|
||||
tab.result = tab.results[index];
|
||||
tab.resultLocalSortOriginalRows = undefined;
|
||||
tab.resultSortColumn = undefined;
|
||||
tab.resultSortColumnIndex = undefined;
|
||||
tab.resultSortDirection = undefined;
|
||||
tab.resultSortMode = undefined;
|
||||
touchResult(tab);
|
||||
tab.queryAnalysis = undefined;
|
||||
tab.querySourceColumns = undefined;
|
||||
|
|
@ -2245,6 +2293,7 @@ export const useQueryStore = defineStore("query", () => {
|
|||
executeCurrentTab,
|
||||
executeCurrentSql,
|
||||
executeTabSql,
|
||||
sortTabResultLocally,
|
||||
explainTabSql,
|
||||
cancelTabExecution,
|
||||
cancelTabExplain,
|
||||
|
|
|
|||
|
|
@ -401,6 +401,8 @@ export interface QueryResultRun {
|
|||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
resultSortDirection?: "asc" | "desc";
|
||||
resultSortMode?: "database" | "local";
|
||||
resultLocalSortOriginalRows?: QueryResult["rows"];
|
||||
orderByInput?: string;
|
||||
resultPageSql?: string;
|
||||
resultPageLimit?: number;
|
||||
|
|
@ -559,6 +561,8 @@ export interface QueryTab {
|
|||
resultSortColumn?: string;
|
||||
resultSortColumnIndex?: number;
|
||||
resultSortDirection?: "asc" | "desc";
|
||||
resultSortMode?: "database" | "local";
|
||||
resultLocalSortOriginalRows?: QueryResult["rows"];
|
||||
orderByInput?: string;
|
||||
resultPageSql?: string;
|
||||
resultPageLimit?: number;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { sortDataGridRows } from "../../apps/desktop/src/lib/dataGridSort.ts";
|
||||
|
||||
test("sortDataGridRows sorts numbers numerically and keeps null values last", () => {
|
||||
const rows = [
|
||||
[10, "ten"],
|
||||
[2, "two"],
|
||||
[null, "none"],
|
||||
[1, "one"],
|
||||
];
|
||||
|
||||
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [
|
||||
[1, "one"],
|
||||
[2, "two"],
|
||||
[10, "ten"],
|
||||
[null, "none"],
|
||||
]);
|
||||
assert.deepEqual(sortDataGridRows(rows, 0, "desc"), [
|
||||
[10, "ten"],
|
||||
[2, "two"],
|
||||
[1, "one"],
|
||||
[null, "none"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("sortDataGridRows uses natural string order and keeps equal values stable", () => {
|
||||
const rows = [
|
||||
["item-10", "first"],
|
||||
["item-2", "second"],
|
||||
["item-2", "third"],
|
||||
];
|
||||
|
||||
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [
|
||||
["item-2", "second"],
|
||||
["item-2", "third"],
|
||||
["item-10", "first"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("sortDataGridRows sorts ISO date strings by time", () => {
|
||||
const rows = [["2026-02-01"], ["2025-12-31"], ["2026-01-01"]];
|
||||
|
||||
assert.deepEqual(sortDataGridRows(rows, 0, "asc"), [["2025-12-31"], ["2026-01-01"], ["2026-02-01"]]);
|
||||
});
|
||||
|
|
@ -47,12 +47,12 @@ function oracleConn(id: string): ConnectionConfig {
|
|||
}
|
||||
|
||||
function withConnectionHealthMock(handler: typeof fetch): typeof fetch {
|
||||
return (async (input, init) => {
|
||||
return async (input, init) => {
|
||||
if (String(input) === "/api/connection/check-health") {
|
||||
return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
return handler(input, init);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 1000) {
|
||||
|
|
@ -112,7 +112,10 @@ test("marked-clean object source tabs close without unsaved confirmation", () =>
|
|||
store.closeTab(tabId);
|
||||
|
||||
assert.equal(store.showCloseConfirm, false);
|
||||
assert.equal(store.tabs.some((item) => item.id === tabId), false);
|
||||
assert.equal(
|
||||
store.tabs.some((item) => item.id === tabId),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("editing query sql preserves the displayed result editability state", () => {
|
||||
|
|
@ -166,6 +169,50 @@ test("editing query sql preserves the displayed result editability state", () =>
|
|||
assert.equal(tab.tableMeta?.tableName, "users");
|
||||
});
|
||||
|
||||
test("sortTabResultLocally sorts current rows and restores original order", () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("conn-1", "db");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
|
||||
tab.resultBaseSql = "select id, name from users";
|
||||
tab.resultSortedSql = "select id, name from users order by name";
|
||||
tab.result = {
|
||||
columns: ["id", "name"],
|
||||
rows: [
|
||||
[2, "Grace"],
|
||||
[1, "Ada"],
|
||||
[3, "Linus"],
|
||||
],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
|
||||
store.sortTabResultLocally(tabId, "name", 1, "asc");
|
||||
|
||||
assert.deepEqual(tab.result?.rows, [
|
||||
[1, "Ada"],
|
||||
[2, "Grace"],
|
||||
[3, "Linus"],
|
||||
]);
|
||||
assert.equal(tab.resultSortColumn, "name");
|
||||
assert.equal(tab.resultSortColumnIndex, 1);
|
||||
assert.equal(tab.resultSortDirection, "asc");
|
||||
assert.equal(tab.resultSortMode, "local");
|
||||
assert.equal(tab.resultSortedSql, undefined);
|
||||
|
||||
store.sortTabResultLocally(tabId, "name", 1, null);
|
||||
|
||||
assert.deepEqual(tab.result?.rows, [
|
||||
[2, "Grace"],
|
||||
[1, "Ada"],
|
||||
[3, "Linus"],
|
||||
]);
|
||||
assert.equal(tab.resultSortColumn, undefined);
|
||||
assert.equal(tab.resultSortMode, undefined);
|
||||
});
|
||||
|
||||
test("selecting a result run restores its displayed result without changing SQL draft", async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
|
|
@ -246,7 +293,10 @@ test("removing the active result run selects an adjacent run", async () => {
|
|||
|
||||
assert.equal(store.removeResultRun(tabId, "run-2"), true);
|
||||
|
||||
assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1", "run-3"]);
|
||||
assert.deepEqual(
|
||||
tab.resultRuns?.map((run) => run.id),
|
||||
["run-1", "run-3"],
|
||||
);
|
||||
assert.equal(tab.activeResultRunId, "run-3");
|
||||
assert.deepEqual(tab.result?.columns, ["three"]);
|
||||
assert.deepEqual(tab.result?.rows, [[3]]);
|
||||
|
|
@ -254,7 +304,10 @@ test("removing the active result run selects an adjacent run", async () => {
|
|||
|
||||
assert.equal(store.removeResultRun(tabId, "run-3"), true);
|
||||
|
||||
assert.deepEqual(tab.resultRuns?.map((run) => run.id), ["run-1"]);
|
||||
assert.deepEqual(
|
||||
tab.resultRuns?.map((run) => run.id),
|
||||
["run-1"],
|
||||
);
|
||||
assert.equal(tab.activeResultRunId, "run-1");
|
||||
assert.deepEqual(tab.result?.columns, ["one"]);
|
||||
});
|
||||
|
|
@ -294,7 +347,10 @@ test("removed result runs are excluded from result archives", async () => {
|
|||
assert.ok(archive);
|
||||
const decoded = await decodeQueryResultArchive(archive);
|
||||
|
||||
assert.deepEqual(decoded?.snapshot.resultRuns?.map((run) => run.id), ["run-2"]);
|
||||
assert.deepEqual(
|
||||
decoded?.snapshot.resultRuns?.map((run) => run.id),
|
||||
["run-2"],
|
||||
);
|
||||
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.columns, ["two"]);
|
||||
assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.rows, [[2]]);
|
||||
});
|
||||
|
|
@ -403,10 +459,7 @@ test("completed query executions append result runs and select the latest run",
|
|||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
executeCount++;
|
||||
return new Response(
|
||||
JSON.stringify([{ columns: [`run_${executeCount}`], rows: [[executeCount]], affected_rows: 0, execution_time_ms: 1 }]),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
return new Response(JSON.stringify([{ columns: [`run_${executeCount}`], rows: [[executeCount]], affected_rows: 0, execution_time_ms: 1 }]), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
if (url === "/api/query/analyze-editability") {
|
||||
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
|
||||
|
|
@ -425,7 +478,10 @@ test("completed query executions append result runs and select the latest run",
|
|||
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.equal(tab?.resultRuns?.length, 2);
|
||||
assert.deepEqual(tab?.resultRuns?.map((run) => run.title), ["Run 1", "Run 2"]);
|
||||
assert.deepEqual(
|
||||
tab?.resultRuns?.map((run) => run.title),
|
||||
["Run 1", "Run 2"],
|
||||
);
|
||||
assert.equal(tab?.resultRuns?.[0]?.sql, "select 1");
|
||||
assert.equal(tab?.resultRuns?.[1]?.sql, "select 2");
|
||||
assert.equal(tab?.activeResultRunId, tab?.resultRuns?.[1]?.id);
|
||||
|
|
@ -1343,9 +1399,7 @@ test("query result export fetches every paginated page", async () => {
|
|||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
executedSqls.push(body.sql);
|
||||
timeoutSecs.push(body.timeoutSecs);
|
||||
const rows = String(body.sql).includes("offset:0")
|
||||
? Array.from({ length: 10_000 }, (_, index) => [index + 1])
|
||||
: [[10_001], [10_002]];
|
||||
const rows = String(body.sql).includes("offset:0") ? Array.from({ length: 10_000 }, (_, index) => [index + 1]) : [[10_001], [10_002]];
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows, affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue