feat(grid): support selecting multiple fields for sorting

This commit is contained in:
HE WENJUN
2026-10-01 03:11:16 +08:00
committed by GitHub
parent c840206b25
commit a1e2d39ed3
37 changed files with 2033 additions and 48 deletions
+1 -1
View File
@@ -4493,7 +4493,7 @@ onUnmounted(() => {
@save-sql="(tabId: string) => void openSaveSqlDialog(tabId)"
@reload="(tabId: string, sql: any, searchText: any, whereInput: any, orderBy: any, limit: any, offset: any, intent: any) => onReloadData(tabId, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => onPaginate(tabId, offset, limit, whereInput, orderBy, appendResult)"
@sort="(tabId: string, column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => onSort(tabId, column, columnIndex, direction, whereInput, mode)"
@sort="(tabId: string, column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string) => onSort(tabId, column, columnIndex, direction, whereInput, mode, effectiveOrderBy)"
@execute-sql="(tabId: string, sql: string) => onExecuteSql(tabId, sql)"
@click-table="(_tabId: string, target: SqlObjectNavigationTarget) => onClickTable(target)"
@view-table-data="(_tabId: string, target: SqlObjectNavigationTarget) => onViewTableData(target)"
+177 -29
View File
@@ -90,6 +90,8 @@ import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
import DataGridSortWorkbench from "@/components/grid/DataGridSortWorkbench.vue";
import DataGridTextSortWorkbench from "@/components/grid/DataGridTextSortWorkbench.vue";
import DataGridTableInfoPanels from "@/components/grid/DataGridTableInfoPanels.vue";
import DataGridColumnFilterPopover from "@/components/grid/DataGridColumnFilterPopover.vue";
import DataGridCellDetailHeader from "@/components/grid/DataGridCellDetailHeader.vue";
@@ -334,6 +336,7 @@ import type { GridSnapshotSource } from "@/lib/gridSnapshot/gridSnapshot";
import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
import { useDataGridEditor } from "@/composables/useDataGridEditor";
import { useDataGridSort } from "@/composables/useDataGridSort";
import { buildDataGridStructuredOrderBy, combineDataGridOrderByInputs, useDataGridSortBuilder } from "@/composables/useDataGridSortBuilder";
import { useDataGridSearch, type DataGridSearchMatch } from "@/composables/useDataGridSearch";
import { findDataGridReplacementMatches, prepareDataGridCellReplacements, type DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace";
import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
@@ -630,9 +633,10 @@ function logDataGridTiming(message: string, payload?: Record<string, unknown>) {
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string];
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
"update:structuredOrderByInput": [value: string];
"local-column-filters-change": [value: Record<string, string[]>];
changeQueryTimeout: [connectionId: string];
cancel: [];
@@ -1226,6 +1230,12 @@ const filterModeOptions = computed(() => allFilterModeOptions.filter((option) =>
const filterBuilderColumnOptions = computed(() => filterBuilderColumns.value.map((column) => column.name));
const structuredFilterCacheKey = computed(() => props.cacheKey || [props.connectionId ?? "", props.database ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? ""].join("\u0001"));
const structuredFilterScopeKey = computed(() => [props.connectionId ?? "", props.database ?? "", props.schema ?? "", props.context ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", props.tableMeta?.columns.map((column) => column.name).join("\0") ?? ""].join("\u0001"));
const sortBuilderScopeKey = computed(() =>
JSON.stringify({
filterScope: structuredFilterScopeKey.value,
columns: filterBuilderColumnOptions.value,
}),
);
function isStructuredFilterRuleComplete(rule: StructuredFilterRule): boolean {
return filterModeIsSupportedForDatabase(rule.mode, resolvedDatabaseType.value) && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue);
}
@@ -2030,8 +2040,7 @@ watch(whereFilterInput, () => {
});
function clearOrderByInput() {
orderByInput.value = "";
void applyOrderBySearch();
void clearStructuredSortRules();
}
watch(orderByInput, (value) => {
@@ -2056,6 +2065,78 @@ watch(
);
const isApplyingWhere = ref(false);
const sortBuilder = useDataGridSortBuilder({
columns: filterBuilderColumnOptions,
cacheKey: structuredFilterCacheKey,
scopeKey: sortBuilderScopeKey,
createId: uuid,
});
const structuredSortRules = sortBuilder.rules;
const sortBuilderOpen = sortBuilder.open;
const appliedStructuredOrderByInput = sortBuilder.appliedOrderByInput;
const sortButtonCount = sortBuilder.activeRuleCount;
const sortButtonActive = computed(() => !!appliedStructuredOrderByInput.value);
const applyingOnlyStructuredSort = ref(false);
const sortSqlPreview = computed(() => {
const draftStructuredOrderBy = buildDataGridStructuredOrderBy(structuredSortRules.value, queryColumnRef) ?? "";
const orderBy = combineDataGridOrderByInputs(orderByInput.value, draftStructuredOrderBy);
return orderBy ? `ORDER BY ${orderBy}` : "";
});
function copySortSqlPreview() {
if (!sortSqlPreview.value) return;
void copyText(sortSqlPreview.value);
toast(t("grid.sortSqlCopied"));
}
function setSortBuilderOpen(value: boolean) {
sortBuilder.setOpen(value);
}
async function applyStructuredSortRules() {
const nextOrderBy = buildDataGridStructuredOrderBy(structuredSortRules.value, queryColumnRef);
if (nextOrderBy === undefined) {
toast(t("grid.sortBuilderCompleteRuleFirst"));
return;
}
const applied = await applyOrderBySearch(undefined, nextOrderBy);
if (!applied) return;
if (filterEditorView.value === "quick") sortBuilder.setOpen(false);
}
async function applyOnlyStructuredSortRule(ruleId: string) {
if (!canUseWhereSearch.value || applyingOnlyStructuredSort.value || isApplyingWhere.value) return;
const rule = structuredSortRules.value.find((item) => item.id === ruleId);
if (!rule?.columnName) {
toast(t("grid.sortBuilderCompleteRuleFirst"));
return;
}
const nextOrderBy = buildDataGridStructuredOrderBy([{ ...rule, disabled: false }], queryColumnRef);
if (!nextOrderBy) {
toast(t("grid.sortBuilderCompleteRuleFirst"));
return;
}
applyingOnlyStructuredSort.value = true;
const scopeKey = sortBuilderScopeKey.value;
const cacheKey = structuredFilterCacheKey.value;
try {
sortBuilder.enableOnlyRule(ruleId);
const applied = await applyOrderBySearch(undefined, nextOrderBy);
if (applied && filterEditorView.value === "quick" && scopeKey === sortBuilderScopeKey.value && cacheKey === structuredFilterCacheKey.value) sortBuilder.setOpen(false);
} finally {
applyingOnlyStructuredSort.value = false;
}
}
async function clearStructuredSortRules() {
if (isApplyingWhere.value) return;
sortBuilder.clear();
orderByInput.value = "";
await applyOrderBySearch(undefined, "");
}
watch(filterEditorView, () => sortBuilder.setOpen(false));
const rowStatusFilter = ref<RowStatusFilter>("all");
const gridRef = ref<HTMLDivElement>();
const dataGridTopbarRef = ref<HTMLDivElement>();
@@ -3220,7 +3301,7 @@ watch(
// Switched between paginated and infinite scroll: reset to first page
if (enabled !== prevEnabled) {
resetInfiniteScrollState();
emit("paginate", 0, pageSize.value, currentWhereInput(), currentOrderBy());
emit("paginate", 0, pageSize.value, currentWhereInput(), effectiveOrderBy());
}
},
);
@@ -3430,7 +3511,7 @@ watch(
if (currentPage.value <= lastPageNum) return;
currentPage.value = lastPageNum;
resetGridVerticalScroll(true);
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), effectiveOrderBy());
}
},
);
@@ -3481,9 +3562,10 @@ function currentWhereInput(): string | undefined {
return combineWhereInputs(whereFilterInput.value, appliedStructuredWhereInput.value);
}
function currentOrderBy(): string | undefined {
const structuredOrderBy = sortMode.value === "database" && sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined;
return orderByInput.value.trim() || structuredOrderBy;
function effectiveOrderBy(): string | undefined {
const columnOrderBy = sortMode.value === "database" && sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined;
const manualOrderBy = orderByInput.value.trim() || columnOrderBy;
return combineDataGridOrderByInputs(manualOrderBy, appliedStructuredOrderByInput.value);
}
function executeServerPageJump(targetPage: number, updateCurrentPage = false) {
@@ -3495,7 +3577,7 @@ function executeServerPageJump(targetPage: number, updateCurrentPage = false) {
currentPage.value = targetPage;
}
resetGridVerticalScroll(true);
emit("paginate", offset, pageSize.value, currentWhereInput(), currentOrderBy());
emit("paginate", offset, pageSize.value, currentWhereInput(), effectiveOrderBy());
}
function requestServerPageJump(targetPage: number, updateCurrentPage = false) {
@@ -3554,7 +3636,7 @@ function firstPage() {
currentPage.value = 1;
lastInfiniteScrollPage = 0;
resetGridVerticalScroll(true);
emit("paginate", 0, pageSize.value, currentWhereInput(), currentOrderBy());
emit("paginate", 0, pageSize.value, currentWhereInput(), effectiveOrderBy());
}
function prevPage() {
if (currentPage.value <= 1) {
@@ -3566,7 +3648,7 @@ function nextPage() {
if (!canGoNextPage.value) return;
currentPage.value++;
resetGridVerticalScroll(true);
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, currentWhereInput(), effectiveOrderBy());
}
function jumpPage(page: number) {
@@ -3605,7 +3687,7 @@ function infiniteScrollNextPage() {
currentPage.value = nextPageNum;
// Fetch only the missing segment. Re-reading offset 0 grows transfer and replaces
// row identities, which would invalidate pending edits while the user scrolls.
emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());
emit("paginate", nextOffset, nextLimit, currentWhereInput(), effectiveOrderBy());
}
function selectAndRevealLastLoadedRow() {
@@ -3670,7 +3752,7 @@ function startLoadAllRows(segment: { offset: number; limit: number }) {
infiniteScrollRequestedOffset = segment.offset;
infiniteScrollRequestedLimit = segment.limit;
currentPage.value++;
emit("paginate", segment.offset, segment.limit, currentWhereInput(), currentOrderBy(), true);
emit("paginate", segment.offset, segment.limit, currentWhereInput(), effectiveOrderBy(), true);
}
// Continues an explicit "load all" run chunk by chunk until the server runs
@@ -3730,7 +3812,7 @@ function changePageSize(size: number) {
loadAllRowsLoopActive = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
emit("paginate", 0, normalizedSize, currentWhereInput(), currentOrderBy());
emit("paginate", 0, normalizedSize, currentWhereInput(), effectiveOrderBy());
}
function setDefaultPageSize() {
@@ -3937,7 +4019,7 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap<number, Readon
context: props.context,
infiniteScroll: infiniteScrollEnabled.value,
filterActive: !!currentWhereInput() || hasLocalColumnFilters.value || (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value),
orderActive: !!orderByInput.value.trim() || !!sortCol.value,
orderActive: !!effectiveOrderBy(),
columns: request.columns,
sourceColumns: props.sourceColumns,
rows: request.rows,
@@ -4086,7 +4168,7 @@ const editor = useDataGridEditor({
searchText,
whereFilterInput,
currentWhereInput: computed(() => currentWhereInput()),
orderByInput,
orderByInput: computed(() => effectiveOrderBy() ?? ""),
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
@@ -4606,7 +4688,7 @@ async function reloadTableData(intent: DataGridReloadIntent) {
}
markConditionInputsApplied();
prepareFullReload();
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value, intent);
emit("reload", props.sql, searchText.value, currentWhereInput(), effectiveOrderBy(), pageSize.value, resetToFirstPage ? 0 : (currentPage.value - 1) * pageSize.value, intent);
}
function setAutoRefreshInterval(seconds: number) {
@@ -4624,7 +4706,7 @@ async function onToolbarCommit() {
function onToolbarRollback() {
discardChanges();
prepareFullReload();
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value);
emit("reload", props.sql, searchText.value, currentWhereInput(), effectiveOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value);
}
function addRow() {
@@ -5567,7 +5649,7 @@ function currentViewProbe(): string {
largeValueCells: props.result.large_value_cells,
navigation: {
whereInput: currentWhereInput(),
orderByInput: orderByInput.value,
orderByInput: effectiveOrderBy() ?? "",
pageOffset: props.pageOffset,
pageLimit: pageSize.value,
sortColumn: props.sortColumn,
@@ -6552,7 +6634,8 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
clearSort();
syncOrderByInputWithSort(null, null);
}
emit("sort", column, columnIndex, direction, currentWhereInput(), mode);
if (mode === "database") emit("sort", column, columnIndex, direction, currentWhereInput(), mode, effectiveOrderBy());
else emit("sort", column, columnIndex, direction, currentWhereInput(), mode);
}
function selectHeaderSort(value: string, column: string, columnIndex: number) {
@@ -6631,18 +6714,26 @@ function waitForTableMeta(timeoutMs = 2500): Promise<DataGridTableMeta | null> {
});
}
async function applyOrderBySearch() {
if (!props.onExecuteSql) return;
const orderByClause = orderByInput.value.trim() || undefined;
async function applyOrderBySearch(_value?: string, structuredOrderByOverride?: string): Promise<boolean> {
if (!props.onExecuteSql) return false;
const structuredOrderBy = structuredOrderByOverride === undefined ? appliedStructuredOrderByInput.value : structuredOrderByOverride;
const orderByClause = combineDataGridOrderByInputs(orderByInput.value, structuredOrderBy);
const sortScopeKey = sortBuilderScopeKey.value;
const sortCacheKey = structuredFilterCacheKey.value;
emit("update:orderByInput", orderByInput.value);
if (orderByClause) rememberDataGridConditionHistory("orderBy", conditionHistoryScope.value, orderByClause);
// Mirror applyWhereFilter's push into tab state: the structured clause lives
// in its own tab field so store-side rebuilds keep the composite ORDER BY
// while the manual input stays free of the structured terms.
emit("update:structuredOrderByInput", structuredOrderBy ?? "");
const manualOrderByClause = orderByInput.value.trim();
if (manualOrderByClause) rememberDataGridConditionHistory("orderBy", conditionHistoryScope.value, manualOrderByClause);
isApplyingWhere.value = true;
queryControlError.value = "";
currentPage.value = 1;
clearSort();
try {
const tableMeta = await waitForTableMeta();
if (!tableMeta) return;
if (!tableMeta) return false;
const sql = await buildTableSelectSql({
databaseType: resolvedDatabaseType.value,
driverProfile: props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined,
@@ -6665,8 +6756,11 @@ async function applyOrderBySearch() {
});
markConditionInputsApplied();
await props.onExecuteSql(sql);
if (structuredOrderByOverride !== undefined && sortScopeKey === sortBuilderScopeKey.value && sortCacheKey === structuredFilterCacheKey.value) sortBuilder.markApplied(structuredOrderByOverride);
return true;
} catch (e: any) {
queryControlError.value = String(e?.message || e);
return false;
} finally {
isApplyingWhere.value = false;
}
@@ -6697,7 +6791,7 @@ async function applyWhereFilter() {
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
primaryKeys: tableMeta.primaryKeys,
...tableDataLargeValuePreviewOptions(resolvedDatabaseType.value, tableMeta.columns, tableMeta.primaryKeys, pageSize.value),
orderBy: orderByInput.value.trim() || (sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined),
orderBy: effectiveOrderBy(),
limit: pageSize.value,
injectDefaultTimeSeriesWhere: true,
whereInput,
@@ -7970,7 +8064,7 @@ async function syncUserFacingSql() {
includeDatabaseName,
injectDefaultTimeSeriesWhere: true,
whereInput: currentWhereInput(),
orderBy: currentOrderBy(),
orderBy: effectiveOrderBy(),
limit: footerPage.limit,
offset: footerPage.offset,
});
@@ -7981,7 +8075,7 @@ async function syncUserFacingSql() {
}
watch(
() => [props.sql, props.context, props.tableMeta, props.pageLimit, props.pageOffset, props.executedPageLimit, props.executedPageOffset, currentWhereInput(), currentOrderBy(), settingsStore.editorSettings.generateSqlIncludeDatabaseName],
() => [props.sql, props.context, props.tableMeta, props.pageLimit, props.pageOffset, props.executedPageLimit, props.executedPageOffset, currentWhereInput(), effectiveOrderBy(), settingsStore.editorSettings.generateSqlIncludeDatabaseName],
() => void syncUserFacingSql(),
{ immediate: true },
);
@@ -8050,7 +8144,7 @@ const {
columnTypes: visibleColumnTypes,
allColumnTypes,
whereInput: computed(() => currentWhereInput()),
orderBy: computed(() => currentOrderBy()),
orderBy: computed(() => effectiveOrderBy()),
exportBatchSize: computed(() => settingsStore.editorSettings.exportBatchSize),
hasCellSelection,
hasColumnSelection,
@@ -12548,8 +12642,10 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
v-model:where-input="whereFilterInput"
v-model:order-by-input="orderByInput"
v-model:filter-builder-open="filterBuilderOpen"
:sort-builder-open="sortBuilderOpen"
:filter-editor-view="filterEditorView"
:columns="props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns"
:comment-by-column="columnCommentMap"
:condition-columns="conditionColumns"
:identifier-quote="conditionIdentifierQuote"
:history-scope="conditionHistoryScope"
@@ -12558,6 +12654,11 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:leading-border="!!(useTransaction && editable && hasDataGridSaveTarget)"
:filter-button-active="filterButtonActive"
:filter-button-count="filterButtonCount"
:sort-button-active="sortButtonActive"
:sort-button-count="sortButtonCount"
:sort-rules="structuredSortRules"
:sort-builder-busy="isApplyingWhere"
:sort-apply-only-busy="applyingOnlyStructuredSort"
:has-local-column-filters="hasLocalColumnFilters"
:local-filter-count="localFilterCount"
:local-filter-summaries="localFilterSummaries"
@@ -12569,6 +12670,15 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:apply-where="applyWhereFilter"
:apply-order-by="applyOrderBySearch"
:clear-order-by="clearOrderByInput"
@update:sort-builder-open="setSortBuilderOpen"
@add-sort-rule="sortBuilder.addRule"
@remove-sort-rule="sortBuilder.removeRule"
@move-sort-rule="sortBuilder.moveRule"
@update-sort-rule="sortBuilder.updateRule"
@apply-only-sort-rule="applyOnlyStructuredSortRule"
@reset-sort-rules="sortBuilder.reset"
@clear-sort-rules="clearStructuredSortRules"
@apply-sort-rules="applyStructuredSortRules"
@update:column-search="filterBuilderColumnSearch = $event"
@ensure-rule="ensureStructuredFilterRule"
@add-rule="addStructuredFilterRule"
@@ -12770,6 +12880,44 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
@apply-value-suggestions="applyFilterValueSuggestions"
/>
<DataGridSortWorkbench
v-if="canUseWhereSearch && filterEditorView === 'conditions' && sortBuilderOpen"
:sql-preview="sortSqlPreview"
:rules="structuredSortRules"
:columns="filterBuilderColumnOptions"
:comment-by-column="columnCommentMap"
:busy="isApplyingWhere"
:apply-only-busy="applyingOnlyStructuredSort"
@add-rule="sortBuilder.addRule"
@remove-rule="sortBuilder.removeRule"
@move-rule="sortBuilder.moveRule"
@update-rule="sortBuilder.updateRule"
@apply-only="applyOnlyStructuredSortRule"
@reset="sortBuilder.reset"
@clear="clearStructuredSortRules"
@copy-sql="copySortSqlPreview"
@apply="applyStructuredSortRules"
/>
<DataGridTextSortWorkbench
v-if="canUseWhereSearch && filterEditorView === 'text' && sortBuilderOpen"
:height="settingsStore.editorSettings.dataGridTextFilterPanelHeight"
:sql-preview="sortSqlPreview"
:rules="structuredSortRules"
:columns="filterBuilderColumnOptions"
:comment-by-column="columnCommentMap"
:busy="isApplyingWhere"
:apply-only-busy="applyingOnlyStructuredSort"
@update:height="updateTextFilterPanelHeight"
@add-rule="sortBuilder.addRule"
@remove-rule="sortBuilder.removeRule"
@move-rule="sortBuilder.moveRule"
@update-rule="sortBuilder.updateRule"
@apply-only="applyOnlyStructuredSortRule"
@reset="sortBuilder.reset"
@clear="clearStructuredSortRules"
@copy-sql="copySortSqlPreview"
@apply="applyStructuredSortRules"
/>
<!-- Truncation warning banner -->
<div v-if="showTruncationWarning" class="shrink-0 px-3 py-1 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
<span>{{ t(truncationHintKey, { count: result.rows.length }) }}</span>
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { Filter, Trash2, X } from "@lucide/vue";
import { ArrowUpDown, Filter, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridSortBuilder from "@/components/grid/DataGridSortBuilder.vue";
import type { DataGridConditionColumnOption } from "@/composables/useDataGridConditionEditor";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
import type { DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
@@ -26,6 +28,7 @@ const props = defineProps<{
whereInput: string;
orderByInput: string;
columns: readonly string[];
commentByColumn?: ReadonlyMap<string, string>;
conditionColumns: readonly DataGridConditionColumnOption[];
identifierQuote?: string;
historyScope: DataGridConditionHistoryScope;
@@ -48,12 +51,19 @@ const props = defineProps<{
applyWhere: (value?: string) => void | boolean | Promise<void | boolean>;
applyOrderBy: (value?: string) => void | boolean | Promise<void | boolean>;
clearOrderBy: () => void | Promise<void>;
sortBuilderOpen?: boolean;
sortButtonActive?: boolean;
sortButtonCount?: number;
sortRules?: readonly DataGridStructuredSortRule[];
sortBuilderBusy?: boolean;
sortApplyOnlyBusy?: boolean;
}>();
const emit = defineEmits<{
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
"update:filterBuilderOpen": [value: boolean];
"update:sortBuilderOpen": [value: boolean];
"update:columnSearch": [value: string];
ensureRule: [];
addRule: [];
@@ -72,6 +82,14 @@ const emit = defineEmits<{
toggleValueSuggestion: [option: DataGridLocalFilterOption];
toggleAllValueSuggestions: [];
applyValueSuggestions: [];
addSortRule: [];
removeSortRule: [id: string];
moveSortRule: [id: string, targetIndex: number];
updateSortRule: [id: string, patch: Partial<DataGridStructuredSortRule>];
applyOnlySortRule: [id: string];
resetSortRules: [];
clearSortRules: [];
applySortRules: [];
}>();
const { t } = useI18n();
@@ -157,6 +175,7 @@ async function openPendingFirstEmptyRuleColumnSearch() {
async function handleFilterButtonClick() {
if (props.filterEditorView !== "quick") {
const nextOpen = !props.filterBuilderOpen;
if (nextOpen && props.sortBuilderOpen) emit("update:sortBuilderOpen", false);
emit("update:filterBuilderOpen", nextOpen);
if (nextOpen) emit("ensureRule");
return;
@@ -175,6 +194,16 @@ function updateQuickFilterBuilderOpen(open: boolean) {
if (!open) emit("closeValueSuggestions");
}
function updateSortBuilderOpen(open: boolean) {
emit("update:sortBuilderOpen", open);
}
function handleSortButtonClick() {
const nextOpen = !props.sortBuilderOpen;
if (nextOpen && props.filterBuilderOpen) emit("update:filterBuilderOpen", false);
emit("update:sortBuilderOpen", nextOpen);
}
watch([() => props.filterBuilderOpen, () => props.rules.map((rule) => `${rule.id}:${rule.columnName}:${rule.disabled ? "1" : "0"}`).join("\u0000"), filterBuilderRef], () => void openPendingFirstEmptyRuleColumnSearch(), { flush: "post" });
onUnmounted(onResizeEnd);
@@ -296,7 +325,53 @@ onUnmounted(onResizeEnd);
>
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
</button>
<div class="flex flex-1 items-center px-2 py-0.5 border-r min-w-0">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 border-r min-w-0">
<template v-if="filterEditorView === 'quick'">
<Popover :open="sortBuilderOpen" @update:open="updateSortBuilderOpen">
<PopoverTrigger as-child>
<button
type="button"
class="relative flex h-5 w-5 -translate-x-1 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
:class="sortButtonActive ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:disabled="!canUseWhereSearch"
:aria-label="t('grid.sortBuilderTitle')"
>
<ArrowUpDown class="h-3 w-3" />
<span v-if="sortButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ sortButtonCount }}</span>
</button>
</PopoverTrigger>
<PopoverContent data-sort-rules-scroll align="start" :collision-padding="8" class="max-h-[var(--reka-popover-content-available-height)] w-fit max-w-[calc(100vw-16px)] overflow-y-auto overscroll-contain p-2.5">
<DataGridSortBuilder
:rules="sortRules ?? []"
:columns="columns"
:comment-by-column="commentByColumn"
:busy="sortBuilderBusy"
:apply-only-busy="sortApplyOnlyBusy"
@add="emit('addSortRule')"
@remove="emit('removeSortRule', $event)"
@move="(id, targetIndex) => emit('moveSortRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateSortRule', id, patch)"
@apply-only="emit('applyOnlySortRule', $event)"
@reset="emit('resetSortRules')"
@clear="emit('clearSortRules')"
@apply="emit('applySortRules')"
/>
</PopoverContent>
</Popover>
</template>
<button
v-else
type="button"
class="relative flex h-5 w-5 -translate-x-1 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
:class="sortButtonActive ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:disabled="!canUseWhereSearch"
:aria-label="t('grid.sortBuilderTitle')"
:aria-expanded="sortBuilderOpen"
@click="handleSortButtonClick"
>
<ArrowUpDown class="h-3 w-3" />
<span v-if="sortButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ sortButtonCount }}</span>
</button>
<DataGridConditionEditor
:model-value="orderByInput"
kind="orderBy"
@@ -307,6 +382,7 @@ onUnmounted(onResizeEnd);
:history-empty-text="t('grid.conditionHistoryEmpty')"
:history-no-matches-text="t('grid.conditionHistoryNoMatches')"
:compact="compact"
:disabled="sortBuilderBusy"
:apply="applyOrderBy"
:clear="clearOrderBy"
@update:model-value="emit('update:orderByInput', $event)"
@@ -0,0 +1,474 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import { ArrowDown, ArrowUp, Check, Eye, EyeOff, Focus, GripVertical, Plus, Search, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
import { resolveDataGridFilterRuleDropPlacement } from "@/lib/dataGrid/dataGridFilterRuleDrag";
import type { DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
import { matchesIdentifierSearch } from "@/lib/sql/identifierSearch";
const RULE_DROP_ZONE_EXTENSION = 36;
const RULE_AUTO_SCROLL_EDGE_SIZE = 40;
const RULE_AUTO_SCROLL_MAX_SPEED = 12;
const IME_COMPOSITION_END_GRACE_MS = 120;
const props = withDefaults(
defineProps<{
rules: readonly DataGridStructuredSortRule[];
columns: readonly string[];
commentByColumn?: ReadonlyMap<string, string>;
busy?: boolean;
applyOnlyBusy?: boolean;
showHeader?: boolean;
showFooter?: boolean;
layout?: "popover" | "panel" | "text";
}>(),
{ showHeader: true, showFooter: true, layout: "popover" },
);
const emit = defineEmits<{
add: [];
remove: [id: string];
move: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredSortRule>];
applyOnly: [id: string];
reset: [];
clear: [];
apply: [];
}>();
const { t } = useI18n();
const rootRef = ref<HTMLElement>();
const ruleElements = new Map<string, HTMLElement>();
const columnSearchInputs = new Map<string, HTMLInputElement>();
const columnSearches = ref<Record<string, string>>({});
const openColumnSelectIds = ref(new Set<string>());
const activeColumnIndexes = ref<Record<string, number>>({});
const composingSearches = new Set<string>();
const compositionEndedAt = new Map<string, number>();
const draggingRuleId = ref<string>();
const dropRuleId = ref<string>();
const dropPosition = ref<"before" | "after">();
const dropTargetIndex = ref<number>();
let dragPointerId: number | undefined;
let dragPointerY: number | undefined;
let dragHandle: HTMLElement | undefined;
let dragContainer: HTMLElement | undefined;
let autoScrollFrame: number | undefined;
let dragBodyStyleActive = false;
let previousBodyUserSelect = "";
let previousBodyCursor = "";
const hasIncompleteActiveRule = computed(() => props.rules.some((rule) => !rule.disabled && !rule.columnName));
function filteredColumns(rule: DataGridStructuredSortRule): string[] {
const query = columnSearches.value[rule.id]?.trim() ?? "";
if (!query) return [...props.columns];
// Match column comments too, mirroring the filter builder's comment search (#10759).
const comments = props.commentByColumn;
return props.columns.filter((column) => matchesIdentifierSearch(column, query) || matchesIdentifierSearch(comments?.get(column) ?? comments?.get(column.toLowerCase()) ?? "", query));
}
function setRuleElement(id: string, element: unknown) {
if (element instanceof HTMLElement) ruleElements.set(id, element);
else ruleElements.delete(id);
}
function setColumnSearchInput(id: string, element: unknown) {
if (!(element instanceof HTMLInputElement)) {
columnSearchInputs.delete(id);
return;
}
columnSearchInputs.set(id, element);
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus({ preventScroll: true }));
}
function setColumnSelectOpen(id: string, open: boolean) {
const next = new Set(openColumnSelectIds.value);
if (open) {
next.clear();
next.add(id);
} else {
next.delete(id);
columnSearches.value = { ...columnSearches.value, [id]: "" };
}
openColumnSelectIds.value = next;
}
function activeColumnIndex(id: string): number {
return activeColumnIndexes.value[id] ?? -1;
}
function scrollColumnIntoView(id: string, index: number, block: ScrollLogicalPosition) {
const listbox = columnSearchInputs.get(id)?.closest('[role="listbox"]');
listbox?.querySelectorAll<HTMLElement>('[role="option"]')[index]?.scrollIntoView?.({ block });
}
function setActiveColumnIndex(rule: DataGridStructuredSortRule, index: number, scrollBlock: ScrollLogicalPosition = "nearest") {
const count = filteredColumns(rule).length;
const nextIndex = count ? ((index % count) + count) % count : -1;
activeColumnIndexes.value = { ...activeColumnIndexes.value, [rule.id]: nextIndex };
window.requestAnimationFrame(() => scrollColumnIntoView(rule.id, nextIndex, scrollBlock));
}
async function handleColumnSelectOpen(rule: DataGridStructuredSortRule, open: boolean) {
setColumnSelectOpen(rule.id, open);
if (!open) return;
const selectedIndex = filteredColumns(rule).indexOf(rule.columnName);
const nextIndex = selectedIndex >= 0 ? selectedIndex : 0;
setActiveColumnIndex(rule, nextIndex, "start");
await nextTick();
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus({ preventScroll: true }));
}
function updateColumnSearch(rule: DataGridStructuredSortRule, event: Event) {
columnSearches.value = { ...columnSearches.value, [rule.id]: (event.target as HTMLInputElement).value };
setActiveColumnIndex(rule, 0);
}
function selectColumn(rule: DataGridStructuredSortRule, column: unknown) {
emit("updateRule", rule.id, { columnName: String(column) });
setColumnSelectOpen(rule.id, false);
}
function startImeComposition(id: string) {
composingSearches.add(id);
compositionEndedAt.delete(id);
}
function endImeComposition(id: string) {
composingSearches.delete(id);
compositionEndedAt.set(id, Date.now());
}
function isImeCompositionKey(event: KeyboardEvent, id: string): boolean {
const endedAt = compositionEndedAt.get(id);
const justEnded = event.key === "Enter" && endedAt !== undefined && Date.now() - endedAt <= IME_COMPOSITION_END_GRACE_MS;
if (justEnded || (endedAt !== undefined && event.key !== "Process")) compositionEndedAt.delete(id);
return event.isComposing || event.key === "Process" || event.keyCode === 229 || composingSearches.has(id) || justEnded;
}
function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructuredSortRule) {
if (isImeCompositionKey(event, rule.id)) {
event.stopPropagation();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
setActiveColumnIndex(rule, activeColumnIndex(rule.id) + (event.key === "ArrowDown" ? 1 : -1));
return;
}
if (event.key === "Enter") {
const column = filteredColumns(rule)[activeColumnIndex(rule.id)];
if (!column) return;
event.preventDefault();
event.stopPropagation();
selectColumn(rule, column);
return;
}
if (!["Escape", "Tab"].includes(event.key)) event.stopPropagation();
}
function removeDragListeners() {
window.removeEventListener("pointermove", handlePointerMove, true);
window.removeEventListener("pointerup", handlePointerUp, true);
window.removeEventListener("pointercancel", handlePointerCancel, true);
window.removeEventListener("keydown", handleDragKeydown, true);
window.removeEventListener("blur", clearDragState);
}
function stopAutoScroll() {
if (autoScrollFrame !== undefined) window.cancelAnimationFrame(autoScrollFrame);
autoScrollFrame = undefined;
}
function clearDropTarget() {
dropRuleId.value = undefined;
dropPosition.value = undefined;
dropTargetIndex.value = undefined;
}
function clearDragState() {
stopAutoScroll();
removeDragListeners();
if (dragHandle && dragPointerId !== undefined && dragHandle.hasPointerCapture?.(dragPointerId)) dragHandle.releasePointerCapture(dragPointerId);
if (dragBodyStyleActive) {
document.body.style.userSelect = previousBodyUserSelect;
document.body.style.cursor = previousBodyCursor;
}
draggingRuleId.value = undefined;
dragPointerId = undefined;
dragPointerY = undefined;
dragHandle = undefined;
dragContainer = undefined;
dragBodyStyleActive = false;
clearDropTarget();
}
function updateDropTarget(clientY: number) {
if (!draggingRuleId.value) return;
const placement = resolveDataGridFilterRuleDropPlacement(
props.rules.map((rule) => rule.id),
draggingRuleId.value,
props.rules.flatMap((rule) => {
const element = ruleElements.get(rule.id);
if (!element) return [];
const bounds = element.getBoundingClientRect();
return [{ id: rule.id, top: bounds.top, bottom: bounds.bottom }];
}),
clientY,
);
if (!placement) {
clearDropTarget();
return;
}
dropRuleId.value = placement.ruleId;
dropPosition.value = placement.position;
dropTargetIndex.value = placement.targetIndex;
}
function pointerWithinDropZone(event: PointerEvent): boolean {
const bounds = dragContainer?.getBoundingClientRect();
return !!bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top - RULE_DROP_ZONE_EXTENSION && event.clientY <= bounds.bottom + RULE_DROP_ZONE_EXTENSION;
}
function autoScrollSpeed(clientY: number, bounds: DOMRect): number {
if (clientY < bounds.top + RULE_AUTO_SCROLL_EDGE_SIZE) return -Math.max(1, Math.ceil(Math.min(1, (bounds.top + RULE_AUTO_SCROLL_EDGE_SIZE - clientY) / RULE_AUTO_SCROLL_EDGE_SIZE) * RULE_AUTO_SCROLL_MAX_SPEED));
if (clientY > bounds.bottom - RULE_AUTO_SCROLL_EDGE_SIZE) return Math.max(1, Math.ceil(Math.min(1, (clientY - (bounds.bottom - RULE_AUTO_SCROLL_EDGE_SIZE)) / RULE_AUTO_SCROLL_EDGE_SIZE) * RULE_AUTO_SCROLL_MAX_SPEED));
return 0;
}
function runAutoScroll() {
autoScrollFrame = undefined;
if (!draggingRuleId.value || !dragContainer || dragPointerY === undefined) return;
const speed = autoScrollSpeed(dragPointerY, dragContainer.getBoundingClientRect());
if (!speed) return;
const previousScrollTop = dragContainer.scrollTop;
dragContainer.scrollTop += speed;
if (dragContainer.scrollTop === previousScrollTop) return;
updateDropTarget(dragPointerY);
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
}
function scheduleAutoScroll() {
if (autoScrollFrame === undefined) autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
}
function handlePointerMove(event: PointerEvent) {
if (!draggingRuleId.value || event.pointerId !== dragPointerId) return;
if (!pointerWithinDropZone(event)) {
stopAutoScroll();
clearDropTarget();
return;
}
event.preventDefault();
dragPointerY = event.clientY;
updateDropTarget(event.clientY);
scheduleAutoScroll();
}
function handlePointerUp(event: PointerEvent) {
if (!draggingRuleId.value || event.pointerId !== dragPointerId) return;
if (pointerWithinDropZone(event)) {
event.preventDefault();
updateDropTarget(event.clientY);
if (dropTargetIndex.value !== undefined) emit("move", draggingRuleId.value, dropTargetIndex.value);
}
clearDragState();
}
function handlePointerCancel(event: PointerEvent) {
if (event.pointerId === dragPointerId) clearDragState();
}
function handleDragKeydown(event: KeyboardEvent) {
if (event.key !== "Escape" || !draggingRuleId.value) return;
event.preventDefault();
clearDragState();
}
function startPointerDrag(event: PointerEvent, id: string) {
if (event.button !== 0 || props.rules.length < 2) return;
const handle = event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined;
dragContainer = handle?.closest<HTMLElement>("[data-sort-rules-scroll]") ?? rootRef.value;
if (!dragContainer || !handle) return;
event.preventDefault();
event.stopPropagation();
draggingRuleId.value = id;
dragPointerId = event.pointerId;
dragPointerY = event.clientY;
dragHandle = handle;
previousBodyUserSelect = document.body.style.userSelect;
previousBodyCursor = document.body.style.cursor;
dragBodyStyleActive = true;
document.body.style.userSelect = "none";
document.body.style.cursor = "grabbing";
handle.setPointerCapture?.(event.pointerId);
updateDropTarget(event.clientY);
window.addEventListener("pointermove", handlePointerMove, true);
window.addEventListener("pointerup", handlePointerUp, true);
window.addEventListener("pointercancel", handlePointerCancel, true);
window.addEventListener("keydown", handleDragKeydown, true);
window.addEventListener("blur", clearDragState);
}
function moveRuleByKeyboard(event: KeyboardEvent, id: string, offset: -1 | 1) {
const sourceIndex = props.rules.findIndex((rule) => rule.id === id);
const targetIndex = sourceIndex + offset;
if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= props.rules.length) return;
event.preventDefault();
emit("move", id, targetIndex);
}
onBeforeUnmount(clearDragState);
</script>
<template>
<div ref="rootRef" class="max-w-full" :class="props.layout === 'text' ? 'w-full space-y-0' : [props.layout === 'panel' ? 'w-full' : 'w-[30rem] max-w-[calc(100vw-16px)]', 'space-y-2']">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-foreground">{{ t("grid.sortBuilderTitle") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="busy" @click="emit('clear')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearSort") }}</Button>
</div>
<div v-if="rules.length" :class="props.layout === 'text' ? 'space-y-0' : 'space-y-1.5'">
<div
v-for="rule in rules"
:key="rule.id"
:ref="(element) => setRuleElement(rule.id, element)"
data-sort-rule-item
class="sort-rule-row relative grid items-center"
:class="[props.layout === 'text' ? 'min-h-7 grid-cols-[18px_22px_minmax(180px,1fr)_6.5rem_auto] gap-0.5 border-b border-border/45 px-1 hover:bg-muted/25' : 'grid-cols-[18px_minmax(180px,1fr)_6.5rem_auto] gap-1.5', rule.disabled ? 'opacity-60' : '']"
:data-dragging="draggingRuleId === rule.id ? '' : undefined"
:data-drop-position="dropRuleId === rule.id ? dropPosition : undefined"
>
<button
type="button"
data-sort-drag-handle
class="flex h-6 w-4 touch-none cursor-grab items-center justify-center justify-self-center text-muted-foreground/70 outline-none hover:text-foreground focus-visible:text-primary disabled:cursor-default disabled:opacity-30 active:cursor-grabbing"
:disabled="rules.length < 2"
:aria-label="t('grid.sortBuilderReorderRule')"
:aria-grabbed="draggingRuleId === rule.id"
@pointerdown="startPointerDrag($event, rule.id)"
@keydown.up="moveRuleByKeyboard($event, rule.id, -1)"
@keydown.down="moveRuleByKeyboard($event, rule.id, 1)"
>
<GripVertical class="h-3.5 w-3.5" />
</button>
<button
v-if="props.layout === 'text'"
type="button"
role="checkbox"
:aria-checked="!rule.disabled"
:aria-label="rule.disabled ? t('grid.sortBuilderEnableRule') : t('grid.sortBuilderDisableRule')"
class="flex h-4 w-4 items-center justify-center justify-self-center border text-primary transition-colors hover:border-primary"
:class="rule.disabled ? 'border-muted-foreground/40 bg-background' : 'border-primary bg-primary/10'"
@click="emit('updateRule', rule.id, { disabled: !rule.disabled })"
>
<Check v-if="!rule.disabled" class="h-3 w-3" />
</button>
<Select :model-value="rule.columnName" :open="openColumnSelectIds.has(rule.id)" :disabled="rule.disabled" @update:model-value="(value: any) => selectColumn(rule, value)" @update:open="(open: boolean) => handleColumnSelectOpen(rule, open)">
<SelectTrigger size="sm" class="w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate" :class="props.layout === 'text' ? 'h-6 rounded-none border-0 bg-transparent px-1 shadow-none focus-visible:ring-0' : 'h-7'">
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>
<SelectValue v-else :placeholder="t('grid.sortBuilderSelectColumn')" />
</SelectTrigger>
<SelectContent position="popper" class="max-h-72" :hide-scroll-buttons="true">
<SelectItem v-for="(column, columnIndex) in filteredColumns(rule)" :key="column" :value="column" class="rounded-none" :class="activeColumnIndex(rule.id) === columnIndex ? 'bg-accent text-accent-foreground' : ''" @pointermove="setActiveColumnIndex(rule, columnIndex)">
{{ column }}
</SelectItem>
<div v-if="!filteredColumns(rule).length" class="px-2 py-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoMatchingColumns") }}</div>
<template #header>
<div data-sort-column-search class="flex items-center gap-1.5 border-b bg-popover px-2 py-1.5">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input
:ref="(element) => setColumnSearchInput(rule.id, element)"
:value="columnSearches[rule.id] ?? ''"
:aria-label="t('grid.filterBuilderSearchColumns')"
class="h-7 min-w-0 flex-1 rounded-sm border border-input bg-background/60 px-2 text-xs outline-none placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary"
:placeholder="t('grid.filterBuilderSearchColumns')"
@input="updateColumnSearch(rule, $event)"
@click.stop
@compositionend="endImeComposition(rule.id)"
@compositionstart="startImeComposition(rule.id)"
@keydown="handleColumnSearchKeydown($event, rule)"
@pointerdown.stop
/>
</div>
</template>
</SelectContent>
</Select>
<Select :model-value="rule.direction" :disabled="rule.disabled" @update:model-value="(value: any) => emit('updateRule', rule.id, { direction: value as DataGridSortDirection })">
<SelectTrigger size="sm" class="text-xs" :class="props.layout === 'text' ? 'h-6 rounded-none border-0 bg-transparent px-1 shadow-none focus-visible:ring-0' : 'h-7'"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="asc"
><span class="flex items-center gap-1.5"><ArrowUp class="h-3.5 w-3.5" />ASC</span></SelectItem
>
<SelectItem value="desc"
><span class="flex items-center gap-1.5"><ArrowDown class="h-3.5 w-3.5" />DESC</span></SelectItem
>
</SelectContent>
</Select>
<div class="flex items-center gap-0.5">
<LightTooltip :text="t('grid.sortBuilderApplyOnly')" side="top" :side-offset="4">
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="busy || applyOnlyBusy" :aria-label="t('grid.sortBuilderApplyOnly')" @click="emit('applyOnly', rule.id)">
<Focus class="h-3.5 w-3.5" />
</Button>
</LightTooltip>
<LightTooltip v-if="props.layout !== 'text'" :text="t(rule.disabled ? 'grid.sortBuilderEnableRule' : 'grid.sortBuilderDisableRule')" side="top" :side-offset="4">
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t(rule.disabled ? 'grid.sortBuilderEnableRule' : 'grid.sortBuilderDisableRule')" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })">
<EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" />
</Button>
</LightTooltip>
<template v-if="props.layout === 'text'">
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="t('grid.sortBuilderAddRule')" @click="emit('add')"><Plus class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="t('common.remove')" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
</template>
<LightTooltip v-else :text="t('common.remove')" side="top" :side-offset="4">
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('common.remove')" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
</LightTooltip>
</div>
</div>
</div>
<div v-else class="rounded-md border border-dashed px-3 py-3 text-center text-xs text-muted-foreground">{{ t("grid.sortBuilderEmpty") }}</div>
<div v-if="props.showFooter !== false" class="flex justify-between gap-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!columns.length" @click="emit('add')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.sortBuilderAddRule") }}</Button>
<div class="flex gap-1.5">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('reset')">{{ t("grid.sortBuilderReset") }}</Button>
<Button size="sm" class="h-7 px-3 text-xs" :disabled="busy || hasIncompleteActiveRule" @click="emit('apply')">{{ t("grid.sortBuilderApply") }}</Button>
</div>
</div>
</div>
</template>
<style scoped>
.sort-rule-row[data-dragging] {
opacity: 0.45;
}
.sort-rule-row[data-drop-position]::before {
position: absolute;
z-index: 10;
right: 0;
left: 0;
height: 2px;
background: var(--primary);
content: "";
pointer-events: none;
}
.sort-rule-row[data-drop-position="before"]::before {
top: -1px;
}
.sort-rule-row[data-drop-position="after"]::before {
bottom: -1px;
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { Copy, Plus, RotateCcw, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import DataGridSortBuilder from "@/components/grid/DataGridSortBuilder.vue";
import type { DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
const props = defineProps<{
sqlPreview: string;
rules: readonly DataGridStructuredSortRule[];
columns: readonly string[];
commentByColumn?: ReadonlyMap<string, string>;
busy?: boolean;
applyOnlyBusy?: boolean;
}>();
const emit = defineEmits<{
addRule: [];
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredSortRule>];
applyOnly: [id: string];
apply: [];
reset: [];
clear: [];
copySql: [];
}>();
const { t } = useI18n();
const rulesScrollerRef = ref<HTMLElement>();
const previewText = computed(() => props.sqlPreview || t("grid.sortSqlPreviewEmpty"));
watch(
() => props.rules.length,
async (ruleCount, previousRuleCount) => {
if (ruleCount <= previousRuleCount) return;
await nextTick();
window.requestAnimationFrame(() => {
const scroller = rulesScrollerRef.value;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
},
);
</script>
<template>
<section data-grid-sort-workbench class="shrink-0 border-b bg-muted/10 text-foreground">
<div ref="rulesScrollerRef" data-sort-rules-scroll class="sort-rules-scroll min-h-11 overflow-auto px-3 py-2">
<DataGridSortBuilder
class="min-w-[460px]"
:rules="rules"
:columns="columns"
:comment-by-column="commentByColumn"
:busy="busy"
:apply-only-busy="applyOnlyBusy"
layout="panel"
:show-header="false"
:show-footer="false"
@add="emit('addRule')"
@remove="emit('removeRule', $event)"
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateRule', id, patch)"
@apply-only="emit('applyOnly', $event)"
@reset="emit('reset')"
@clear="emit('clear')"
@apply="emit('apply')"
/>
</div>
<footer class="flex min-w-0 items-center gap-2 border-t bg-background/35 px-2 py-1">
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs" :disabled="!columns.length" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.sortBuilderAddRule") }}</Button>
<div class="flex min-w-0 flex-1 items-center gap-1.5 border-l pl-2">
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t("grid.sortSqlPreview") }}</span>
<code class="min-w-0 flex-1 truncate rounded bg-muted/35 px-1.5 py-0.5 text-[11px]" :class="sqlPreview ? 'text-foreground' : 'text-muted-foreground'" :title="previewText">{{ previewText }}</code>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="!sqlPreview" :aria-label="t('grid.copySortSql')" @click="emit('copySql')"><Copy class="h-3 w-3" /></Button>
</div>
<Button variant="outline" size="sm" class="h-7 shrink-0 px-2 text-xs" @click="emit('reset')"><RotateCcw class="mr-1 h-3.5 w-3.5" />{{ t("grid.sortBuilderReset") }}</Button>
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs text-muted-foreground hover:text-destructive" :disabled="busy" @click="emit('clear')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearSort") }}</Button>
<Button size="sm" class="h-7 shrink-0 px-3 text-xs" :disabled="busy" @click="emit('apply')">{{ t("grid.sortBuilderApply") }}</Button>
</footer>
</section>
</template>
<style scoped>
.sort-rules-scroll {
max-height: clamp(104px, 27vh, 160px);
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
</style>
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { Copy, Plus, RotateCcw, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import DataGridSortBuilder from "@/components/grid/DataGridSortBuilder.vue";
import type { DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN } from "@/lib/dataGrid/dataGridTextFilterPanel";
const props = defineProps<{
height: number;
sqlPreview: string;
rules: readonly DataGridStructuredSortRule[];
columns: readonly string[];
commentByColumn?: ReadonlyMap<string, string>;
busy?: boolean;
applyOnlyBusy?: boolean;
}>();
const emit = defineEmits<{
"update:height": [height: number];
addRule: [];
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredSortRule>];
applyOnly: [id: string];
apply: [];
reset: [];
clear: [];
copySql: [];
}>();
const { t } = useI18n();
const rulesScrollerRef = ref<HTMLElement>();
const panelHeight = ref(clampHeight(props.height));
const previewText = computed(() => props.sqlPreview || t("grid.sortSqlPreviewEmpty"));
const panelStyle = computed(() => ({ height: `${panelHeight.value}px`, maxHeight: "55vh" }));
let resizeStart: { y: number; height: number } | undefined;
function clampHeight(height: number) {
return Math.min(DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, Math.max(DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN, Math.round(height)));
}
function restoreDocumentInteraction() {
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function stopResizeListeners() {
window.removeEventListener("pointermove", resizePanel);
window.removeEventListener("pointerup", finishResize);
window.removeEventListener("pointercancel", finishResize);
}
function startResize(event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
resizeStart = { y: event.clientY, height: panelHeight.value };
document.body.style.cursor = "row-resize";
document.body.style.userSelect = "none";
window.addEventListener("pointermove", resizePanel);
window.addEventListener("pointerup", finishResize);
window.addEventListener("pointercancel", finishResize);
}
function resizePanel(event: PointerEvent) {
if (!resizeStart) return;
panelHeight.value = clampHeight(resizeStart.height + event.clientY - resizeStart.y);
}
function finishResize() {
if (!resizeStart) return;
resizeStart = undefined;
stopResizeListeners();
restoreDocumentInteraction();
emit("update:height", panelHeight.value);
}
function resizePanelByKeyboard(event: KeyboardEvent) {
const step = event.shiftKey ? 24 : 8;
let nextHeight = panelHeight.value;
if (event.key === "ArrowUp") nextHeight -= step;
else if (event.key === "ArrowDown") nextHeight += step;
else if (event.key === "Home") nextHeight = DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN;
else if (event.key === "End") nextHeight = DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX;
else return;
event.preventDefault();
panelHeight.value = clampHeight(nextHeight);
emit("update:height", panelHeight.value);
}
function addRuleFromBlankArea(event: KeyboardEvent) {
if (event.target !== event.currentTarget || event.key !== "Enter" || !event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
event.stopPropagation();
if (props.columns.length) emit("addRule");
}
onBeforeUnmount(() => {
stopResizeListeners();
restoreDocumentInteraction();
});
watch(
() => props.height,
(height) => {
if (!resizeStart) panelHeight.value = clampHeight(height);
},
);
watch(
() => props.rules.length,
async (ruleCount, previousRuleCount) => {
if (ruleCount <= previousRuleCount) return;
await nextTick();
window.requestAnimationFrame(() => {
const scroller = rulesScrollerRef.value;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
},
);
</script>
<template>
<section data-grid-text-sort-workbench class="relative flex shrink-0 flex-col border-b bg-muted/10 text-foreground" :style="panelStyle">
<div ref="rulesScrollerRef" data-sort-rules-scroll tabindex="0" :aria-label="t('grid.sortTextView')" class="min-h-0 flex-1 overflow-auto px-2 py-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/40" @keydown="addRuleFromBlankArea">
<DataGridSortBuilder
class="min-w-[460px]"
:rules="rules"
:columns="columns"
:comment-by-column="commentByColumn"
:busy="busy"
:apply-only-busy="applyOnlyBusy"
layout="text"
:show-header="false"
:show-footer="false"
@add="emit('addRule')"
@remove="emit('removeRule', $event)"
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateRule', id, patch)"
@apply-only="emit('applyOnly', $event)"
@reset="emit('reset')"
@clear="emit('clear')"
@apply="emit('apply')"
/>
</div>
<footer class="flex h-8 min-w-0 shrink-0 items-center gap-1.5 border-t bg-background/45 px-2">
<Tooltip :delay-duration="800">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="!columns.length" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.sortBuilderAddRule") }}</Button>
</TooltipTrigger>
<TooltipContent side="top">Shift+Enter</TooltipContent>
</Tooltip>
<div class="flex min-w-0 flex-1 items-center gap-1.5 border-l pl-2">
<span class="shrink-0 text-[11px] text-muted-foreground">{{ t("grid.sortSqlPreview") }}</span>
<code class="min-w-0 flex-1 truncate px-1 text-[11px]" :class="sqlPreview ? 'text-foreground' : 'text-muted-foreground'" :title="previewText">{{ previewText }}</code>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="!sqlPreview" :aria-label="t('grid.copySortSql')" @click="emit('copySql')"><Copy class="h-3 w-3" /></Button>
</div>
<Button variant="outline" size="sm" class="h-6 shrink-0 px-1.5 text-xs" @click="emit('reset')"><RotateCcw class="mr-1 h-3 w-3" />{{ t("grid.sortBuilderReset") }}</Button>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs text-muted-foreground hover:text-destructive" :disabled="busy" @click="emit('clear')"><Trash2 class="mr-1 h-3 w-3" />{{ t("grid.clearSort") }}</Button>
<Button size="sm" class="h-6 shrink-0 px-2.5 text-xs" :disabled="busy" @click="emit('apply')">{{ t("grid.sortBuilderApply") }}</Button>
</footer>
<div
role="separator"
tabindex="0"
aria-orientation="horizontal"
:aria-label="t('grid.resizeSortPanel')"
:aria-valuemin="DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN"
:aria-valuemax="DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX"
:aria-valuenow="panelHeight"
class="group absolute inset-x-0 -bottom-0.5 z-10 flex h-1.5 cursor-row-resize items-center justify-center outline-none"
@keydown="resizePanelByKeyboard"
@pointerdown="startResize"
>
<span class="h-px w-10 bg-border transition-colors group-hover:bg-primary group-focus:bg-primary" />
</div>
</section>
</template>
@@ -79,6 +79,16 @@ function filterToggle(host: HTMLElement): HTMLButtonElement {
return toggle;
}
function sortPanelSelector(view: DataGridFilterEditorView): string {
return view === "conditions" ? "[data-grid-sort-workbench]" : "[data-grid-text-sort-workbench]";
}
function sortToggle(host: HTMLElement): HTMLButtonElement {
const toggle = host.querySelector<HTMLButtonElement>('button[aria-label="Sort"][aria-expanded]');
if (!toggle) throw new Error("Sort editor toggle not found");
return toggle;
}
function applyButton(host: HTMLElement, view: DataGridFilterEditorView): HTMLButtonElement {
const panel = host.querySelector(panelSelector(view));
const button = [...(panel?.querySelectorAll<HTMLButtonElement>("button") ?? [])].find((candidate) => candidate.textContent?.trim() === "Apply Filter");
@@ -183,6 +193,25 @@ describe.each(["conditions", "text"] as const)("DataGrid %s filter editor state"
await settle();
expect(host.querySelector(panelSelector(view))).not.toBeNull();
});
it("opens the matching sort workbench and keeps it mutually exclusive with the filter workbench", async () => {
const { host } = mountGrid(view, true);
await settle();
expect(host.querySelector(panelSelector(view))).not.toBeNull();
expect(host.querySelector(sortPanelSelector(view))).toBeNull();
sortToggle(host).click();
await settle();
expect(host.querySelector(panelSelector(view))).toBeNull();
expect(host.querySelector(sortPanelSelector(view))).not.toBeNull();
expect(sortToggle(host).getAttribute("aria-expanded")).toBe("true");
filterToggle(host).click();
await settle();
expect(host.querySelector(sortPanelSelector(view))).toBeNull();
expect(host.querySelector(panelSelector(view))).not.toBeNull();
});
});
describe("DataGrid quick filter editor state", () => {
@@ -110,6 +110,9 @@ import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWor
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
import DataGridSortBuilder from "@/components/grid/DataGridSortBuilder.vue";
import DataGridSortWorkbench from "@/components/grid/DataGridSortWorkbench.vue";
import DataGridTextSortWorkbench from "@/components/grid/DataGridTextSortWorkbench.vue";
const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8");
const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8");
@@ -1022,6 +1025,220 @@ describe("DataGridFilterBuilder", () => {
});
});
describe("DataGridSortBuilder", () => {
const rules = [
{ id: "r1", columnName: "id", direction: "asc" as const },
{ id: "r2", columnName: "created_at", direction: "desc" as const, disabled: true },
];
it("edits, reorders, disables, removes, adds, resets, and applies sort rules", () => {
const add = vi.fn();
const remove = vi.fn();
const move = vi.fn();
const updateRule = vi.fn();
const applyOnly = vi.fn();
const reset = vi.fn();
const clear = vi.fn();
const apply = vi.fn();
const mounted = mountComponent(DataGridSortBuilder, {
rules,
columns: ["id", "created_at", "name"],
onAdd: add,
onRemove: remove,
onMove: move,
onUpdateRule: updateRule,
onApplyOnly: applyOnly,
onReset: reset,
onClear: clear,
onApply: apply,
});
const selects = findAll(mounted.root, (node) => node.props["data-stub"] === "Select");
const updateColumn = selects[0]!.props["onUpdate:modelValue"];
for (const callback of Array.isArray(updateColumn) ? updateColumn : [updateColumn]) callback("name");
expect(updateRule).toHaveBeenCalledWith("r1", { columnName: "name" });
dispatch(
findOne(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "grid.sortBuilderReorderRule"),
"keydown",
{ key: "ArrowDown" },
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "grid.sortBuilderApplyOnly"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "grid.sortBuilderDisableRule"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "common.remove"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderAddRule"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderReset"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.clearSort"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderApply"),
"click",
);
expect(move).toHaveBeenCalledWith("r1", 1);
expect(applyOnly).toHaveBeenCalledWith("r1");
expect(updateRule).toHaveBeenCalledWith("r1", { disabled: true });
expect(remove).toHaveBeenCalledWith("r1");
expect(add).toHaveBeenCalledOnce();
expect(reset).toHaveBeenCalledOnce();
expect(clear).toHaveBeenCalledOnce();
expect(apply).toHaveBeenCalledOnce();
});
it("filters fields from the searchable selector", async () => {
const mounted = mountComponent(DataGridSortBuilder, {
rules: [{ id: "r1", columnName: "", direction: "asc" }],
columns: ["id", "created_at", "display_name"],
});
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
dispatch(searchInput, "input", { target: { value: "created" } });
await nextTick();
const options = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").map(hostText);
expect(options).toContain("created_at");
expect(options).not.toContain("display_name");
});
it("matches fields by column comment from the searchable selector", async () => {
const mounted = mountComponent(DataGridSortBuilder, {
rules: [{ id: "r1", columnName: "", direction: "asc" }],
columns: ["customer_name", "created_at"],
commentByColumn: new Map([["customer_name", "客户名称 / Customer Name"]]),
});
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
dispatch(searchInput, "input", { target: { value: "客户" } });
await nextTick();
const options = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").map(hostText);
expect(options).toContain("customer_name");
expect(options).not.toContain("created_at");
});
it("keeps an already selected field available for another sort rule", () => {
const mounted = mountComponent(DataGridSortBuilder, {
rules: [
{ id: "r1", columnName: "name", direction: "asc" },
{ id: "r2", columnName: "", direction: "desc" },
],
columns: ["name"],
});
const nameOptions = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem" && hostText(node) === "name");
expect(nameOptions).toHaveLength(2);
expect(nameOptions.every((option) => option.props.disabled !== true)).toBe(true);
const addButton = findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderAddRule");
expect(addButton.props.disabled).not.toBe(true);
});
});
describe("DataGrid sort workbenches", () => {
const rules = [
{ id: "r1", columnName: "tenant_id", direction: "asc" as const },
{ id: "r2", columnName: "created_at", direction: "desc" as const },
];
it("renders the conditions layout with SQL preview and explicit actions", () => {
const addRule = vi.fn();
const apply = vi.fn();
const reset = vi.fn();
const clear = vi.fn();
const copySql = vi.fn();
const mounted = mountComponent(DataGridSortWorkbench, {
sqlPreview: "ORDER BY tenant_id ASC, created_at DESC",
rules,
columns: ["id", "tenant_id", "created_at"],
onAddRule: addRule,
onApply: apply,
onReset: reset,
onClear: clear,
onCopySql: copySql,
});
expect(findOne(mounted.root, (node) => node.props["data-grid-sort-workbench"] === "")).toBeTruthy();
expect(hostText(mounted.root)).toContain("ORDER BY tenant_id ASC, created_at DESC");
dispatch(
findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copySortSql"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderAddRule"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderReset"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.clearSort"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.sortBuilderApply"),
"click",
);
expect(copySql).toHaveBeenCalledOnce();
expect(addRule).toHaveBeenCalledOnce();
expect(reset).toHaveBeenCalledOnce();
expect(clear).toHaveBeenCalledOnce();
expect(apply).toHaveBeenCalledOnce();
});
it("renders the compact resizable text layout", async () => {
const addRule = vi.fn();
const updateRule = vi.fn();
const updateHeight = vi.fn();
const mounted = mountComponent(DataGridTextSortWorkbench, {
height: 168,
sqlPreview: "ORDER BY tenant_id ASC, created_at DESC",
rules,
columns: ["id", "tenant_id", "created_at"],
onAddRule: addRule,
onUpdateRule: updateRule,
"onUpdate:height": updateHeight,
});
const panel = findOne(mounted.root, (node) => node.props["data-grid-text-sort-workbench"] === "");
const rulesArea = findOne(mounted.root, (node) => node.props["data-sort-rules-scroll"] === "") as any;
const resizeHandle = findOne(mounted.root, (node) => node.props.role === "separator");
expect(panel.props.style).toEqual({ height: "168px", maxHeight: "55vh" });
expect(findAll(mounted.root, (node) => node.props.role === "checkbox")).toHaveLength(2);
dispatch(
findOne(mounted.root, (node) => node.props.role === "checkbox"),
"click",
);
expect(updateRule).toHaveBeenCalledWith("r1", { disabled: true });
const addShortcut = dispatch(rulesArea, "keydown", { key: "Enter", shiftKey: true });
expect(addShortcut.defaultPrevented).toBe(true);
expect(addRule).toHaveBeenCalledOnce();
dispatch(resizeHandle, "keydown", { key: "ArrowDown" });
await nextTick();
expect(updateHeight).toHaveBeenCalledWith(176);
});
});
describe("DataGridQueryControls", () => {
it("opens column search when the filter button creates the first rule", async () => {
let mounted: ReturnType<typeof mountComponent>;
@@ -1264,6 +1481,7 @@ describe("DataGridQueryControls", () => {
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
sortButtonCount: 2,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
@@ -1280,14 +1498,16 @@ describe("DataGridQueryControls", () => {
expect(hostText(mounted.root)).not.toContain("grid.filterQuickView");
expect(hostText(mounted.root)).not.toContain("grid.filterConditionView");
const quickFilterButtons = findAll(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
const quickFilterButtons = findAll(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "grid.filter");
expect(quickFilterButtons).toHaveLength(1);
expect(quickFilterButtons[0].props["aria-label"]).toBe("grid.filter");
const sortButton = findOne(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "grid.sortBuilderTitle");
expect(hostText(sortButton)).toContain("2");
await mounted.setProps({ filterEditorView: "conditions", filterBuilderOpen: false });
await nextTick();
expect(findOne(mounted.root, (node) => node.type === "textarea" && node.props.placeholder === "WHERE")).toBeTruthy();
const filterButtons = findAll(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"));
const filterButtons = findAll(mounted.root, (node) => node.type === "button" && node.props["aria-label"] === "grid.filter");
expect(filterButtons).toHaveLength(1);
expect(filterButtons[0].props["aria-label"]).toBe("grid.filter");
expect(filterButtons[0].props["aria-expanded"]).toBe(false);
@@ -194,13 +194,13 @@ describe("DataGrid transpose sorting", () => {
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.sortDatabaseDescending");
expect(onSort).toHaveBeenLastCalledWith("score", 1, "desc", undefined, "database");
expect(onSort).toHaveBeenLastCalledWith("score", 1, "desc", undefined, "database", "`score` DESC");
expect(transposeValues(host, 1)).toEqual(["10", "2", "NULL"]);
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.clearSort");
expect(onSort).toHaveBeenLastCalledWith("score", 1, null, undefined, "database");
expect(onSort).toHaveBeenLastCalledWith("score", 1, null, undefined, "database", undefined);
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
@@ -0,0 +1,144 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const icon = defineComponent({ setup: () => () => h("span") });
return { ArrowDown: icon, ArrowUp: icon, Check: icon, Eye: icon, EyeOff: icon, Focus: icon, GripVertical: icon, Plus: icon, Search: icon, Trash2: icon, X: icon };
});
function passthrough(tag = "div") {
return defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h(tag, attrs, [slots.default?.(), slots.header?.()]);
},
});
}
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/select", () => ({ Select: passthrough(), SelectContent: passthrough(), SelectItem: passthrough(), SelectTrigger: passthrough("button"), SelectValue: passthrough("span") }));
vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: passthrough() }));
import DataGridSortBuilder from "../DataGridSortBuilder.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function rules(count: number): DataGridStructuredSortRule[] {
return Array.from({ length: count }, (_, index) => ({ id: `r${index + 1}`, columnName: `column_${index + 1}`, direction: index % 2 ? "desc" : "asc" }));
}
async function mountBuilder(ruleCount = 3) {
const move = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const wrapper: Component = defineComponent({
setup() {
const items = rules(ruleCount);
return () => h("div", { "data-sort-rules-scroll": "" }, [h(DataGridSortBuilder, { rules: items, columns: items.map((rule) => rule.columnName), onMove: move })]);
},
});
const app = createApp(wrapper);
app.mount(host);
mountedApps.push({ app, host });
await nextTick();
return { host, move };
}
function domRect(top: number, bottom: number, width: number): DOMRect {
return { left: 0, right: width, top, bottom, width, height: bottom - top, x: 0, y: top, toJSON: () => ({}) };
}
function configureGeometry(host: HTMLElement, scrollerHeight = 120) {
const scroller = host.querySelector<HTMLElement>("[data-sort-rules-scroll]")!;
let scrollTop = 0;
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: scrollerHeight },
scrollHeight: { configurable: true, value: 400 },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(400 - scrollerHeight, value));
},
},
});
scroller.getBoundingClientRect = () => domRect(0, scrollerHeight, 400);
host.querySelectorAll<HTMLElement>(".sort-rule-row").forEach((row, index) => {
row.getBoundingClientRect = () => domRect(index * 38 - scrollTop, index * 38 + 28 - scrollTop, 400);
});
return scroller;
}
function dispatchPointer(target: EventTarget, type: string, options: { pointerId?: number; clientX?: number; clientY: number }) {
target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, pointerId: options.pointerId ?? 1, clientX: options.clientX ?? 20, clientY: options.clientY }));
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
document.body.innerHTML = "";
document.body.style.userSelect = "";
document.body.style.cursor = "";
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("DataGridSortBuilder pointer dragging", () => {
it("reorders sort fields by dragging the handle", async () => {
const { host, move } = await mountBuilder();
configureGeometry(host);
const handles = host.querySelectorAll<HTMLButtonElement>("[data-sort-drag-handle]");
expect(handles.length).toBeGreaterThan(0);
const handle = handles[0]!;
dispatchPointer(handle, "pointerdown", { clientY: 14 });
dispatchPointer(window, "pointermove", { clientY: 100 });
await nextTick();
expect(document.body.style.userSelect).toBe("none");
expect(host.querySelector('[data-drop-position="after"]')).not.toBeNull();
dispatchPointer(window, "pointerup", { clientY: 100 });
expect(move).toHaveBeenCalledWith("r1", 2);
expect(document.body.style.userSelect).toBe("");
});
it("auto-scrolls a long sort rule list while dragging near the bottom", async () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
const frameId = nextFrameId++;
frames.set(frameId, callback);
return frameId;
}),
);
vi.stubGlobal(
"cancelAnimationFrame",
vi.fn((frameId: number) => frames.delete(frameId)),
);
const { host, move } = await mountBuilder(10);
const scroller = configureGeometry(host, 100);
const handles = host.querySelectorAll<HTMLButtonElement>("[data-sort-drag-handle]");
expect(handles.length).toBeGreaterThan(0);
const handle = handles[0]!;
dispatchPointer(handle, "pointerdown", { pointerId: 7, clientY: 14 });
dispatchPointer(window, "pointermove", { pointerId: 7, clientY: 96 });
for (let frameIndex = 0; frameIndex < 8; frameIndex += 1) {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(frameIndex));
}
expect(scroller.scrollTop).toBeGreaterThan(0);
dispatchPointer(window, "pointercancel", { pointerId: 7, clientY: 96 });
expect(move).not.toHaveBeenCalled();
});
});
@@ -2397,7 +2397,7 @@ defineExpose({
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode, effectiveOrderBy)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
<template #result-toolbar-leading="{ compact }">
@@ -2831,10 +2831,11 @@ defineExpose({
@cancel="emit('cancel', activeTab.id)"
@update:where-input="(v: string) => (activeTab.whereInput = v)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@update:structured-order-by-input="(v: string) => (activeTab.structuredOrderByInput = v || undefined)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode, effectiveOrderBy)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
@@ -93,7 +93,7 @@ export interface ContentAreaSurfaceEmits {
formatError: [tabId: string];
reload: [tabId: string, sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean];
sort: [tabId: string, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
sort: [tabId: string, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode, effectiveOrderBy?: string];
executeSql: [tabId: string, sql: string];
clickTable: [tabId: string, target: SqlObjectNavigationTarget];
viewTableData: [tabId: string, target: SqlObjectNavigationTarget];
@@ -1241,6 +1241,29 @@ describe("useDataGridActions", () => {
expect(tab.resultSortMode).toBe("database");
});
it("executes a column sort through the effective ORDER BY channel without exposing the structured part in the input", async () => {
const tab = tableDataTab({
resultPageLimit: 7,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [
{ name: "sort_value", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "created_at", data_type: "timestamp", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: [],
},
});
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted");
const actions = useDataGridActions(computed(() => tab));
await actions.onSort(tab.id, "sort_value", 0, "asc", undefined, "database", '"sort_value" ASC, "created_at" DESC');
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"sort_value" ASC, "created_at" DESC', limit: 7, offset: 0 }));
expect(tab.orderByInput).toBe('"sort_value" ASC');
});
it("clears paginated table sorting by querying the first page without the generated order", async () => {
const tab = tableDataTab({
resultSortColumn: "sort_value",
@@ -0,0 +1,154 @@
import { nextTick, ref } from "vue";
import { beforeEach, describe, expect, it } from "vitest";
import { buildDataGridStructuredOrderBy, clearDataGridSortBuilderMemoryCache, combineDataGridOrderByInputs, moveDataGridStructuredSortRule, useDataGridSortBuilder, type DataGridStructuredSortRule } from "@/composables/useDataGridSortBuilder";
function idFactory() {
let index = 0;
return () => `sort-${++index}`;
}
beforeEach(clearDataGridSortBuilderMemoryCache);
describe("structured data-grid ORDER BY", () => {
it("combines manual and structured sorting without changing either input", () => {
const manual = "LOWER(name) ASC";
const structured = '"id" DESC';
expect(combineDataGridOrderByInputs(manual, structured)).toBe('LOWER(name) ASC, "id" DESC');
expect(manual).toBe("LOWER(name) ASC");
expect(structured).toBe('"id" DESC');
expect(combineDataGridOrderByInputs("", structured)).toBe(structured);
expect(combineDataGridOrderByInputs("", "")).toBeUndefined();
});
it("builds enabled rules in priority order, allows duplicate fields, and rejects incomplete active rules", () => {
const rules: DataGridStructuredSortRule[] = [
{ id: "a", columnName: "created at", direction: "desc" },
{ id: "b", columnName: "ignored", direction: "asc", disabled: true },
{ id: "c", columnName: "name", direction: "asc" },
];
expect(buildDataGridStructuredOrderBy(rules, (column) => `"${column}"`)).toBe('"created at" DESC, "name" ASC');
expect(buildDataGridStructuredOrderBy([...rules, { id: "d", columnName: "", direction: "asc" }], (column) => column)).toBeUndefined();
expect(buildDataGridStructuredOrderBy([...rules, { id: "d", columnName: "name", direction: "desc" }], (column) => column)).toBe("created at DESC, name ASC, name DESC");
expect(buildDataGridStructuredOrderBy([], (column) => column)).toBe("");
});
it("allows adding more sort rules than the number of available fields", () => {
const builder = useDataGridSortBuilder({ columns: ["name"], cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
builder.setOpen(true);
builder.updateRule(builder.rules.value[0]!.id, { columnName: "name" });
builder.addRule();
builder.updateRule(builder.rules.value[1]!.id, { columnName: "name", direction: "desc" });
expect(builder.rules.value).toEqual([
{ id: "sort-1", columnName: "name", direction: "asc" },
{ id: "sort-2", columnName: "name", direction: "desc" },
]);
});
it("moves rules without mutating the source array", () => {
const rules: DataGridStructuredSortRule[] = [
{ id: "a", columnName: "a", direction: "asc" },
{ id: "b", columnName: "b", direction: "desc" },
];
const moved = moveDataGridStructuredSortRule(rules, "b", 0);
expect(moved.map((rule) => rule.id)).toEqual(["b", "a"]);
expect(rules.map((rule) => rule.id)).toEqual(["a", "b"]);
});
});
describe("useDataGridSortBuilder", () => {
it("keeps disabled draft rules when the same tab is remounted", async () => {
const columns = ref(["id", "name"]);
const first = useDataGridSortBuilder({ columns, cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
first.setOpen(true);
first.updateRule(first.rules.value[0]!.id, { columnName: "id" });
first.addRule();
first.updateRule(first.rules.value[1]!.id, { columnName: "name", direction: "desc", disabled: true });
first.markApplied('"id" ASC');
await nextTick();
const second = useDataGridSortBuilder({ columns, cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
second.setOpen(true);
expect(second.rules.value).toHaveLength(2);
expect(second.rules.value[1]).toMatchObject({ columnName: "name", direction: "desc", disabled: true });
expect(second.appliedOrderByInput.value).toBe('"id" ASC');
});
it("isolates and restores rules for each cache and scope pair", async () => {
const scopeKey = ref("scope-1");
const builder = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "tab-1", scopeKey, createId: idFactory() });
builder.setOpen(true);
builder.updateRule(builder.rules.value[0]!.id, { columnName: "id" });
builder.markApplied('"id" ASC');
await nextTick();
scopeKey.value = "scope-2";
await nextTick();
builder.updateRule(builder.rules.value[0]!.id, { columnName: "name", direction: "desc", disabled: true });
builder.markApplied("");
await nextTick();
scopeKey.value = "scope-1";
await nextTick();
expect(builder.rules.value).toEqual([{ id: "sort-1", columnName: "id", direction: "asc" }]);
expect(builder.appliedOrderByInput.value).toBe('"id" ASC');
scopeKey.value = "scope-2";
await nextTick();
expect(builder.rules.value).toEqual([{ id: "sort-2", columnName: "name", direction: "desc", disabled: true }]);
expect(builder.appliedOrderByInput.value).toBe("");
});
it("resets only the draft without clearing the applied structured sort", () => {
const builder = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
builder.setOpen(true);
builder.updateRule(builder.rules.value[0]!.id, { columnName: "id", direction: "desc" });
builder.addRule();
builder.updateRule(builder.rules.value[1]!.id, { columnName: "name", direction: "desc" });
builder.markApplied('"id" DESC, "name" DESC');
builder.reset();
expect(builder.rules.value).toEqual([{ id: "sort-3", columnName: "", direction: "asc" }]);
expect(builder.appliedOrderByInput.value).toBe('"id" DESC, "name" DESC');
});
it("clears draft and applied structured sorting together", () => {
const builder = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
builder.setOpen(true);
builder.updateRule(builder.rules.value[0]!.id, { columnName: "id" });
builder.markApplied('"id" ASC');
builder.clear();
expect(builder.rules.value).toEqual([{ id: "sort-2", columnName: "", direction: "asc" }]);
expect(builder.activeRuleCount.value).toBe(0);
expect(builder.appliedOrderByInput.value).toBe("");
});
it("updates the badge count from configured active rules before applying", () => {
const builder = useDataGridSortBuilder({ columns: ["id", "name"], cacheKey: "tab-1", scopeKey: "scope-1", createId: idFactory() });
builder.setOpen(true);
expect(builder.activeRuleCount.value).toBe(0);
builder.updateRule(builder.rules.value[0]!.id, { columnName: "id" });
expect(builder.activeRuleCount.value).toBe(1);
builder.addRule();
expect(builder.activeRuleCount.value).toBe(1);
builder.updateRule(builder.rules.value[1]!.id, { columnName: "name", direction: "desc" });
expect(builder.activeRuleCount.value).toBe(2);
builder.updateRule(builder.rules.value[0]!.id, { disabled: true });
expect(builder.activeRuleCount.value).toBe(1);
builder.enableOnlyRule(builder.rules.value[0]!.id);
expect(builder.rules.value.map((rule) => !!rule.disabled)).toEqual([false, true]);
expect(builder.activeRuleCount.value).toBe(1);
});
});
@@ -627,7 +627,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
});
}
async function onSort(tabId: string | undefined, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
async function onSort(tabId: string | undefined, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database", effectiveOrderBy?: string) {
const tab = resolveActionTab(tabId);
if (!tab) return;
tab.resultSortColumn = direction ? column : undefined;
@@ -649,10 +649,11 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
tab.whereInput = whereInput ?? "";
const config = connectionStore.getConfig(tab.connectionId);
const quotedColumn = quoteIdent(tab, column);
const orderBy = direction ? `${config?.db_type === "neo4j" ? `n.${quotedColumn}` : quotedColumn} ${direction.toUpperCase()}` : undefined;
const headerOrderBy = direction ? `${config?.db_type === "neo4j" ? `n.${quotedColumn}` : quotedColumn} ${direction.toUpperCase()}` : undefined;
const orderBy = effectiveOrderBy === undefined ? headerOrderBy : effectiveOrderBy.trim() || undefined;
const limit = tableDataPageLimit(tab);
const pagination = { limit, offset: 0 };
tab.orderByInput = orderBy;
tab.orderByInput = headerOrderBy;
const sql = await buildTableSql(tab, { orderBy, whereInput, limit, offset: pagination.offset });
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, {
@@ -0,0 +1,163 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
import { uuid } from "@/lib/common/utils";
import type { DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
export type DataGridStructuredSortRule = {
id: string;
columnName: string;
direction: DataGridSortDirection;
disabled?: boolean;
};
type DataGridSortBuilderCacheEntry = {
appliedOrderBy: string;
rules: DataGridStructuredSortRule[];
};
type UseDataGridSortBuilderOptions = {
columns: MaybeRefOrGetter<readonly string[]>;
cacheKey: MaybeRefOrGetter<string>;
scopeKey: MaybeRefOrGetter<string>;
createId?: () => string;
};
const SORT_BUILDER_CACHE_MAX_ENTRIES = 128;
const sortBuilderCache = new Map<string, DataGridSortBuilderCacheEntry>();
function cloneRules(rules: readonly DataGridStructuredSortRule[]): DataGridStructuredSortRule[] {
return rules.map((rule) => ({ ...rule }));
}
function normalizedOrderBy(value: string | undefined): string {
return value?.trim() ?? "";
}
export function combineDataGridOrderByInputs(manualOrderBy: string | undefined, structuredOrderBy: string | undefined): string | undefined {
return [normalizedOrderBy(manualOrderBy), normalizedOrderBy(structuredOrderBy)].filter(Boolean).join(", ") || undefined;
}
export function buildDataGridStructuredOrderBy(rules: readonly DataGridStructuredSortRule[], quoteColumn: (columnName: string) => string): string | undefined {
const activeRules = rules.filter((rule) => !rule.disabled);
if (activeRules.some((rule) => !rule.columnName)) return undefined;
return activeRules.map((rule) => `${quoteColumn(rule.columnName)} ${rule.direction.toUpperCase()}`).join(", ");
}
export function moveDataGridStructuredSortRule(rules: readonly DataGridStructuredSortRule[], ruleId: string, targetIndex: number): DataGridStructuredSortRule[] {
const sourceIndex = rules.findIndex((rule) => rule.id === ruleId);
if (sourceIndex < 0 || rules.length < 2) return cloneRules(rules);
const nextIndex = Math.min(rules.length - 1, Math.max(0, Math.trunc(targetIndex)));
if (sourceIndex === nextIndex) return cloneRules(rules);
const nextRules = cloneRules(rules);
const [rule] = nextRules.splice(sourceIndex, 1);
nextRules.splice(nextIndex, 0, rule!);
return nextRules;
}
export function useDataGridSortBuilder(options: UseDataGridSortBuilderOptions) {
const rules = ref<DataGridStructuredSortRule[]>([]);
const open = ref(false);
const appliedOrderByInput = ref("");
const activeRuleCount = computed(() => rules.value.filter((rule) => !rule.disabled && !!rule.columnName).length);
function defaultRule(): DataGridStructuredSortRule {
return { id: options.createId?.() ?? uuid(), columnName: "", direction: "asc" };
}
function entryKey(): string | undefined {
const cacheKey = toValue(options.cacheKey);
if (!cacheKey) return undefined;
return JSON.stringify({ cacheKey, scopeKey: toValue(options.scopeKey) });
}
function persist() {
const key = entryKey();
if (!key) return;
sortBuilderCache.delete(key);
sortBuilderCache.set(key, { appliedOrderBy: appliedOrderByInput.value, rules: cloneRules(rules.value) });
while (sortBuilderCache.size > SORT_BUILDER_CACHE_MAX_ENTRIES) {
const oldest = sortBuilderCache.keys().next().value;
if (oldest === undefined) break;
sortBuilderCache.delete(oldest);
}
}
function restore() {
const key = entryKey();
const cached = key ? sortBuilderCache.get(key) : undefined;
if (cached) {
rules.value = cloneRules(cached.rules);
appliedOrderByInput.value = cached.appliedOrderBy;
if (open.value && !rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
return;
}
rules.value = open.value && toValue(options.columns).length ? [defaultRule()] : [];
appliedOrderByInput.value = "";
persist();
}
function setOpen(value: boolean) {
if (value && !rules.value.length && toValue(options.columns).length) rules.value = [defaultRule()];
open.value = value;
}
function addRule() {
if (!toValue(options.columns).length) return;
rules.value = [...rules.value, defaultRule()];
}
function removeRule(id: string) {
rules.value = rules.value.filter((rule) => rule.id !== id);
}
function updateRule(id: string, patch: Partial<DataGridStructuredSortRule>) {
rules.value = rules.value.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
}
function moveRule(id: string, targetIndex: number) {
rules.value = moveDataGridStructuredSortRule(rules.value, id, targetIndex);
}
function enableOnlyRule(id: string) {
if (!rules.value.some((rule) => rule.id === id)) return;
rules.value = rules.value.map((rule) => ({ ...rule, disabled: rule.id !== id }));
}
function reset() {
rules.value = toValue(options.columns).length ? [defaultRule()] : [];
}
function clear() {
appliedOrderByInput.value = "";
reset();
persist();
}
function markApplied(orderBy: string) {
appliedOrderByInput.value = normalizedOrderBy(orderBy);
persist();
}
watch(rules, persist, { deep: true });
watch([() => toValue(options.cacheKey), () => toValue(options.scopeKey)], restore, { immediate: true });
return {
rules,
open,
appliedOrderByInput,
activeRuleCount,
setOpen,
addRule,
removeRule,
updateRule,
moveRule,
enableOnlyRule,
reset,
clear,
markApplied,
restore,
};
}
export function clearDataGridSortBuilderMemoryCache() {
sortBuilderCache.clear();
}
@@ -169,6 +169,7 @@ export function useSidebarDataOpenRuntime() {
tab.schema = tableSchema;
tab.whereInput = undefined;
tab.orderByInput = undefined;
tab.structuredOrderByInput = undefined;
tab.previewSql = undefined;
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
+23
View File
@@ -71,6 +71,29 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
{ feature: "transfer bulk select", keys: under("transfer", ["bulkSelectObjects", "bulkSelectTitle", "bulkSelectHint", "bulkSelectPlaceholder", "bulkSelectConfirm", "bulkSelectMatched", "bulkSelectUnmatched", "noMatchingObjects"]), translated: true },
{ feature: "transfer background acknowledgement (#8649)", keys: ["transfer.backgroundStarted"], translated: true },
{ feature: "table info pin", keys: ["grid.pinTableInfo", "grid.unpinTableInfo"], translated: true },
{
feature: "data grid structured sorting",
keys: under("grid", [
"sortBuilderTitle",
"sortBuilderAddRule",
"sortBuilderSelectColumn",
"sortBuilderReorderRule",
"sortBuilderApplyOnly",
"sortBuilderEnableRule",
"sortBuilderDisableRule",
"sortBuilderEmpty",
"sortBuilderApply",
"sortBuilderReset",
"sortBuilderCompleteRuleFirst",
"sortSqlPreview",
"sortSqlPreviewEmpty",
"copySortSql",
"sortSqlCopied",
"resizeSortPanel",
"sortTextView",
]),
translated: true,
},
{ feature: "Consul workspace", keys: ["consul.ui.*", "consul.tools.*"] },
{ feature: "MQTT max packet size", keys: ["connection.mqttMaxPacketSize"] },
{ feature: "sidebar search skip hint", keys: ["sidebar.searchConnectionSkipped"] },
+17
View File
@@ -2163,6 +2163,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "Verilənlər bazasını artan sıra ilə sırala",
sortDatabaseDescending: "Verilənlər bazasını azalan sıra ilə sırala",
clearSort: "Sıralamanı təmizlə",
sortBuilderTitle: "Sıralama",
sortBuilderAddRule: "Sahə əlavə et",
sortBuilderSelectColumn: "Sahə seçin",
sortBuilderReorderRule: "Sıralama sahəsinin yerini dəyişmək üçün sürükləyin",
sortBuilderApplyOnly: "Yalnız bu sahəyə görə sırala",
sortBuilderEnableRule: "Sıralama sahəsini aktivləşdir",
sortBuilderDisableRule: "Sıralama sahəsini deaktiv et",
sortBuilderEmpty: "Sıralamanı tənzimləmək üçün sahə əlavə edin.",
sortBuilderApply: "Sıralamanı tətbiq et",
sortBuilderReset: "Sıralamanı sıfırla",
sortBuilderCompleteRuleFirst: "Hər aktiv sıralama qaydası üçün sahə seçin.",
sortSqlPreview: "SQL sıralama önizləməsi",
sortSqlPreviewEmpty: "Hələ etibarlı sıralama sahəsi yoxdur",
copySortSql: "SQL sıralamasını kopyala",
sortSqlCopied: "SQL sıralaması kopyalandı",
resizeSortPanel: "Sıralama panelinin ölçüsünü dəyiş",
sortTextView: "Sıralama mətn görünüşü",
freezeToColumn: "Bu sütuna qədər sabitlə",
freezeSelectedColumns: "Seçilmiş sütunları sabitlə ({count})",
freezeCurrentColumn: "Cari sütunu sabitlə",
+17
View File
@@ -2502,6 +2502,23 @@ export default {
sortDatabaseAscending: "Sort Database Ascending",
sortDatabaseDescending: "Sort Database Descending",
clearSort: "Clear Sort",
sortBuilderTitle: "Sort",
sortBuilderAddRule: "Add field",
sortBuilderSelectColumn: "Select a field",
sortBuilderReorderRule: "Drag to reorder sort field",
sortBuilderApplyOnly: "Sort by only this field",
sortBuilderEnableRule: "Enable sort field",
sortBuilderDisableRule: "Disable sort field",
sortBuilderEmpty: "Add a field to configure sorting.",
sortBuilderApply: "Apply Sort",
sortBuilderReset: "Reset sort",
sortBuilderCompleteRuleFirst: "Select a field for every enabled sort rule.",
sortSqlPreview: "SQL sort preview",
sortSqlPreviewEmpty: "No valid sort fields yet",
copySortSql: "Copy SQL sort",
sortSqlCopied: "SQL sort copied",
resizeSortPanel: "Resize sort panel",
sortTextView: "Sort text view",
freezeToColumn: "Freeze to This Column",
freezeSelectedColumns: "Freeze Selected Columns ({count})",
freezeCurrentColumn: "Freeze Current Column",
+17
View File
@@ -2344,6 +2344,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "Ordenar base de datos ascendente",
sortDatabaseDescending: "Ordenar base de datos descendente",
clearSort: "Limpiar orden",
sortBuilderTitle: "Ordenar",
sortBuilderAddRule: "Añadir campo",
sortBuilderSelectColumn: "Seleccionar un campo",
sortBuilderReorderRule: "Arrastra para reordenar el campo de ordenación",
sortBuilderApplyOnly: "Ordenar solo por este campo",
sortBuilderEnableRule: "Activar campo de ordenación",
sortBuilderDisableRule: "Desactivar campo de ordenación",
sortBuilderEmpty: "Añade un campo para configurar la ordenación.",
sortBuilderApply: "Aplicar ordenación",
sortBuilderReset: "Restablecer ordenación",
sortBuilderCompleteRuleFirst: "Selecciona un campo para cada regla de ordenación activada.",
sortSqlPreview: "Vista previa SQL de ordenación",
sortSqlPreviewEmpty: "Aún no hay campos de ordenación válidos",
copySortSql: "Copiar ordenación SQL",
sortSqlCopied: "Ordenación SQL copiada",
resizeSortPanel: "Cambiar el tamaño del panel de ordenación",
sortTextView: "Vista de texto de ordenación",
freezeToColumn: "Inmovilizar hasta esta columna",
freezeSelectedColumns: "Inmovilizar columnas seleccionadas ({count})",
freezeCurrentColumn: "Inmovilizar columna actual",
+17
View File
@@ -2278,6 +2278,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "Ordina database crescente",
sortDatabaseDescending: "Ordina database decrescente",
clearSort: "Rimuovi Ordinamento",
sortBuilderTitle: "Ordinamento",
sortBuilderAddRule: "Aggiungi campo",
sortBuilderSelectColumn: "Seleziona un campo",
sortBuilderReorderRule: "Trascina per riordinare il campo di ordinamento",
sortBuilderApplyOnly: "Ordina solo per questo campo",
sortBuilderEnableRule: "Abilita campo di ordinamento",
sortBuilderDisableRule: "Disabilita campo di ordinamento",
sortBuilderEmpty: "Aggiungi un campo per configurare l'ordinamento.",
sortBuilderApply: "Applica ordinamento",
sortBuilderReset: "Reimposta ordinamento",
sortBuilderCompleteRuleFirst: "Seleziona un campo per ogni regola di ordinamento abilitata.",
sortSqlPreview: "Anteprima ordinamento SQL",
sortSqlPreviewEmpty: "Nessun campo di ordinamento valido",
copySortSql: "Copia ordinamento SQL",
sortSqlCopied: "Ordinamento SQL copiato",
resizeSortPanel: "Ridimensiona pannello di ordinamento",
sortTextView: "Vista testuale dell'ordinamento",
freezeToColumn: "Blocca fino a questa colonna",
freezeSelectedColumns: "Blocca colonne selezionate ({count})",
freezeCurrentColumn: "Blocca colonna corrente",
+17
View File
@@ -2285,6 +2285,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "データベースで昇順に並び替え",
sortDatabaseDescending: "データベースで降順に並び替え",
clearSort: "並び替えをクリア",
sortBuilderTitle: "並び替え",
sortBuilderAddRule: "フィールドを追加",
sortBuilderSelectColumn: "フィールドを選択",
sortBuilderReorderRule: "ドラッグして並び替えフィールドの順序を変更",
sortBuilderApplyOnly: "このフィールドのみで並び替え",
sortBuilderEnableRule: "並び替えフィールドを有効化",
sortBuilderDisableRule: "並び替えフィールドを無効化",
sortBuilderEmpty: "フィールドを追加して並び替えを設定します。",
sortBuilderApply: "並び替えを適用",
sortBuilderReset: "並び替えをリセット",
sortBuilderCompleteRuleFirst: "有効な各並び替えルールでフィールドを選択してください。",
sortSqlPreview: "SQL 並び替えプレビュー",
sortSqlPreviewEmpty: "有効な並び替えフィールドがありません",
copySortSql: "SQL 並び替えをコピー",
sortSqlCopied: "SQL 並び替えをコピーしました",
resizeSortPanel: "並び替えパネルのサイズを変更",
sortTextView: "並び替えテキストビュー",
freezeToColumn: "この列まで固定",
freezeSelectedColumns: "選択列を固定 ({count})",
freezeCurrentColumn: "現在の列を固定",
+17
View File
@@ -2274,6 +2274,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "데이터베이스 오름차순 정렬",
sortDatabaseDescending: "데이터베이스 내림차순 정렬",
clearSort: "정렬 지우기",
sortBuilderTitle: "정렬",
sortBuilderAddRule: "필드 추가",
sortBuilderSelectColumn: "필드 선택",
sortBuilderReorderRule: "드래그하여 정렬 필드 순서 변경",
sortBuilderApplyOnly: "이 필드만 정렬",
sortBuilderEnableRule: "정렬 필드 활성화",
sortBuilderDisableRule: "정렬 필드 비활성화",
sortBuilderEmpty: "필드를 추가하여 정렬을 설정하세요.",
sortBuilderApply: "정렬 적용",
sortBuilderReset: "정렬 재설정",
sortBuilderCompleteRuleFirst: "활성화된 각 정렬 규칙의 필드를 선택하세요.",
sortSqlPreview: "SQL 정렬 미리보기",
sortSqlPreviewEmpty: "유효한 정렬 필드가 없습니다",
copySortSql: "SQL 정렬 복사",
sortSqlCopied: "SQL 정렬이 복사되었습니다",
resizeSortPanel: "정렬 패널 크기 조정",
sortTextView: "정렬 텍스트 보기",
freezeToColumn: "이 컬럼까지 고정",
freezeSelectedColumns: "선택한 컬럼 고정 ({count})",
freezeCurrentColumn: "현재 열 고정",
+17
View File
@@ -2280,6 +2280,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "Ordenar Banco de Dados Crescente",
sortDatabaseDescending: "Ordenar Banco de Dados Decrescente",
clearSort: "Limpar Ordenação",
sortBuilderTitle: "Ordenação",
sortBuilderAddRule: "Adicionar campo",
sortBuilderSelectColumn: "Selecionar um campo",
sortBuilderReorderRule: "Arraste para reordenar o campo de ordenação",
sortBuilderApplyOnly: "Ordenar somente por este campo",
sortBuilderEnableRule: "Ativar campo de ordenação",
sortBuilderDisableRule: "Desativar campo de ordenação",
sortBuilderEmpty: "Adicione um campo para configurar a ordenação.",
sortBuilderApply: "Aplicar ordenação",
sortBuilderReset: "Redefinir ordenação",
sortBuilderCompleteRuleFirst: "Selecione um campo para cada regra de ordenação ativada.",
sortSqlPreview: "Prévia da ordenação SQL",
sortSqlPreviewEmpty: "Nenhum campo de ordenação válido",
copySortSql: "Copiar ordenação SQL",
sortSqlCopied: "Ordenação SQL copiada",
resizeSortPanel: "Redimensionar painel de ordenação",
sortTextView: "Visualização textual da ordenação",
freezeToColumn: "Congelar até esta coluna",
freezeSelectedColumns: "Congelar colunas selecionadas ({count})",
freezeCurrentColumn: "Congelar coluna atual",
+17
View File
@@ -2377,6 +2377,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "Сортировать базу данных по возрастанию",
sortDatabaseDescending: "Сортировать базу данных по убыванию",
clearSort: "Очистить сортировку",
sortBuilderTitle: "Сортировка",
sortBuilderAddRule: "Добавить поле",
sortBuilderSelectColumn: "Выберите поле",
sortBuilderReorderRule: "Перетащите, чтобы изменить порядок полей сортировки",
sortBuilderApplyOnly: "Сортировать только по этому полю",
sortBuilderEnableRule: "Включить поле сортировки",
sortBuilderDisableRule: "Отключить поле сортировки",
sortBuilderEmpty: "Добавьте поле, чтобы настроить сортировку.",
sortBuilderApply: "Применить сортировку",
sortBuilderReset: "Сбросить сортировку",
sortBuilderCompleteRuleFirst: "Выберите поле для каждого включённого правила сортировки.",
sortSqlPreview: "Предпросмотр сортировки SQL",
sortSqlPreviewEmpty: "Нет допустимых полей сортировки",
copySortSql: "Копировать сортировку SQL",
sortSqlCopied: "Сортировка SQL скопирована",
resizeSortPanel: "Изменить размер панели сортировки",
sortTextView: "Текстовый вид сортировки",
freezeToColumn: "Закрепить до этого столбца",
freezeSelectedColumns: "Закрепить выбранные столбцы ({count})",
freezeCurrentColumn: "Закрепить текущий столбец",
+17
View File
@@ -2158,6 +2158,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "Veritabanında Artan Sırala",
sortDatabaseDescending: "Veritabanında Azalan Sırala",
clearSort: "Sıralamayı Temizle",
sortBuilderTitle: "Sıralama",
sortBuilderAddRule: "Alan ekle",
sortBuilderSelectColumn: "Bir alan seçin",
sortBuilderReorderRule: "Sıralama alanının yerini değiştirmek için sürükleyin",
sortBuilderApplyOnly: "Yalnızca bu alana göre sırala",
sortBuilderEnableRule: "Sıralama alanını etkinleştir",
sortBuilderDisableRule: "Sıralama alanını devre dışı bırak",
sortBuilderEmpty: "Sıralamayı yapılandırmak için bir alan ekleyin.",
sortBuilderApply: "Sıralamayı uygula",
sortBuilderReset: "Sıralamayı sıfırla",
sortBuilderCompleteRuleFirst: "Etkinleştirilmiş her sıralama kuralı için bir alan seçin.",
sortSqlPreview: "SQL sıralama önizlemesi",
sortSqlPreviewEmpty: "Henüz geçerli bir sıralama alanı yok",
copySortSql: "SQL sıralamasını kopyala",
sortSqlCopied: "SQL sıralaması kopyalandı",
resizeSortPanel: "Sıralama panelini yeniden boyutlandır",
sortTextView: "Sıralama metin görünümü",
freezeToColumn: "Bu Sütuna Kadar Dondur",
freezeSelectedColumns: "Seçili Sütunları Dondur ({count})",
freezeCurrentColumn: "Geçerli sütunu sabitle",
+17
View File
@@ -2415,6 +2415,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "数据库升序排序",
sortDatabaseDescending: "数据库降序排序",
clearSort: "清除排序",
sortBuilderTitle: "排序",
sortBuilderAddRule: "新增字段",
sortBuilderSelectColumn: "选择字段",
sortBuilderReorderRule: "拖动调整排序字段顺序",
sortBuilderApplyOnly: "仅排序此项",
sortBuilderEnableRule: "启用排序字段",
sortBuilderDisableRule: "禁用排序字段",
sortBuilderEmpty: "新增字段后即可设置排序。",
sortBuilderApply: "应用排序",
sortBuilderReset: "重置排序",
sortBuilderCompleteRuleFirst: "请为每条已启用的排序规则选择字段。",
sortSqlPreview: "SQL 排序预览",
sortSqlPreviewEmpty: "尚未选择有效排序字段",
copySortSql: "复制 SQL 排序",
sortSqlCopied: "SQL 排序已复制",
resizeSortPanel: "调整排序面板高度",
sortTextView: "排序文本视图",
freezeToColumn: "冻结到此列",
freezeSelectedColumns: "冻结选中列({count} 列)",
freezeCurrentColumn: "冻结当前列",
+17
View File
@@ -2277,6 +2277,23 @@ export default withEnglishFallback({
sortDatabaseAscending: "資料庫遞增排序",
sortDatabaseDescending: "資料庫遞減排序",
clearSort: "清除排序",
sortBuilderTitle: "排序",
sortBuilderAddRule: "新增欄位",
sortBuilderSelectColumn: "選擇欄位",
sortBuilderReorderRule: "拖動調整排序欄位順序",
sortBuilderApplyOnly: "僅排序此項",
sortBuilderEnableRule: "啟用排序欄位",
sortBuilderDisableRule: "停用排序欄位",
sortBuilderEmpty: "新增欄位後即可設定排序。",
sortBuilderApply: "套用排序",
sortBuilderReset: "重設排序",
sortBuilderCompleteRuleFirst: "請為每條已啟用的排序規則選擇欄位。",
sortSqlPreview: "SQL 排序預覽",
sortSqlPreviewEmpty: "尚未選擇有效排序欄位",
copySortSql: "複製 SQL 排序",
sortSqlCopied: "SQL 排序已複製",
resizeSortPanel: "調整排序面板高度",
sortTextView: "排序文字檢視",
freezeToColumn: "凍結到此欄",
freezeSelectedColumns: "凍結選取欄({count} 欄)",
freezeCurrentColumn: "凍結目前欄",
@@ -27,6 +27,13 @@ describe("openTabsPersistence originalSql round-trip", () => {
expect(restored.uiState).toEqual({ activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false });
});
it("preserves the structured sort field across a data tab round-trip", () => {
const [restored] = roundTrip([queryTab({ mode: "data", orderByInput: '"status" ASC', structuredOrderByInput: '"created_at" DESC' })]);
expect(restored.orderByInput).toBe('"status" ASC');
expect(restored.structuredOrderByInput).toBe('"created_at" DESC');
});
it("preserves namespaced special-page state across a round-trip", () => {
const uiState = {
page: {
@@ -50,6 +50,7 @@ export interface SavedOpenTab {
resultSortDirection?: QueryTab["resultSortDirection"];
resultSortMode?: QueryTab["resultSortMode"];
orderByInput?: string;
structuredOrderByInput?: string;
resultPageLimit?: number;
resultPageOffset?: number;
whereInput?: string;
@@ -196,6 +197,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.resultSortDirection !== undefined ? { resultSortDirection: tab.resultSortDirection } : {}),
...(tab.resultSortMode !== undefined ? { resultSortMode: tab.resultSortMode } : {}),
...(tab.orderByInput !== undefined ? { orderByInput: tab.orderByInput } : {}),
...(tab.structuredOrderByInput !== undefined ? { structuredOrderByInput: tab.structuredOrderByInput } : {}),
...(tab.resultPageLimit !== undefined ? { resultPageLimit: tab.resultPageLimit } : {}),
...(tab.resultPageOffset !== undefined ? { resultPageOffset: tab.resultPageOffset } : {}),
...(tab.whereInput !== undefined ? { whereInput: tab.whereInput } : {}),
@@ -30,6 +30,23 @@ describe("restoredDataTabReloadFilters", () => {
});
});
it("recombines the persisted structured ORDER BY with the manual input for restored tabs", () => {
expect(
restoredDataTabReloadFilters({
whereInput: "status = 'active'",
orderByInput: '"name" ASC',
structuredOrderByInput: '"age" DESC, "created_at" ASC',
}),
).toEqual({
whereInput: "status = 'active'",
orderBy: '"name" ASC, "age" DESC, "created_at" ASC',
});
expect(restoredDataTabReloadFilters({ whereInput: undefined, orderByInput: undefined, structuredOrderByInput: '"age" DESC' })).toEqual({
whereInput: undefined,
orderBy: '"age" DESC',
});
});
it("reports blank filters as absent so the reload does not emit an empty WHERE/ORDER BY", () => {
expect(restoredDataTabReloadFilters({ whereInput: "", orderByInput: undefined })).toEqual({ whereInput: undefined, orderBy: undefined });
expect(restoredDataTabReloadFilters({ whereInput: " ", orderByInput: " " })).toEqual({ whereInput: undefined, orderBy: undefined });
@@ -1,3 +1,4 @@
import { combineDataGridOrderByInputs } from "@/composables/useDataGridSortBuilder";
import type { QueryTab } from "@/types/database";
type RefreshableDataTab = Pick<QueryTab, "mode" | "result" | "isExecuting">;
@@ -6,7 +7,7 @@ export function canReloadUnavailableDataTab(tab: RefreshableDataTab): boolean {
return tab.mode === "data" && !tab.result && !tab.isExecuting;
}
type RestoredDataTabFilters = Pick<QueryTab, "whereInput" | "orderByInput">;
type RestoredDataTabFilters = Pick<QueryTab, "whereInput" | "orderByInput" | "structuredOrderByInput">;
// 恢复的数据标签页没有 result,刷新走的是 DataGrid 之外那条不带参数的 reload 入口。
// onReloadData 把缺省的 whereInput/orderBy 当成“用户已清空”(DataGrid 清空筛选时
@@ -15,6 +16,6 @@ type RestoredDataTabFilters = Pick<QueryTab, "whereInput" | "orderByInput">;
export function restoredDataTabReloadFilters(tab: RestoredDataTabFilters): { whereInput?: string; orderBy?: string } {
return {
whereInput: tab.whereInput?.trim() || undefined,
orderBy: tab.orderByInput?.trim() || undefined,
orderBy: combineDataGridOrderByInputs(tab.orderByInput, tab.structuredOrderByInput),
};
}
@@ -583,6 +583,31 @@ describe("queryStore table data refresh", () => {
expect(store.tabs.find((tab) => tab.id === secondTabId)?.result).toBeUndefined();
});
it("rebuilds the structured sort from persisted tab state on refresh", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "users", "data", "public");
store.setTableMeta(tabId, {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [
{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "status", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
{ name: "created_at", data_type: "timestamp", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["id"],
});
const tab = store.tabs.find((tab) => tab.id === tabId)!;
tab.orderByInput = '"status" ASC';
tab.structuredOrderByInput = '"created_at" DESC';
const refreshed = await store.refreshDataTab(tabId);
expect(refreshed).toBe(true);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"status" ASC, "created_at" DESC' }));
});
it("keeps the configured MySQL page size when results contain large-value previews", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "mysql-1",
@@ -679,6 +704,7 @@ describe("queryStore table data refresh", () => {
const tab = store.tabs.find((candidate) => candidate.id === tabId)!;
tab.whereInput = "id > 0";
tab.orderByInput = '"old_name" ASC';
tab.structuredOrderByInput = '"old_name" DESC';
tab.resultSortColumn = "old_name";
tab.resultSortColumnIndex = 1;
tab.resultSortDirection = "asc";
@@ -712,6 +738,7 @@ describe("queryStore table data refresh", () => {
expect(tab.resultLocalSortOriginalMongoDocuments).toBeUndefined();
expect(tab.resultLocalSortOriginalMongoCopyDocuments).toBeUndefined();
expect(tab.orderByInput).toBeUndefined();
expect(tab.structuredOrderByInput).toBeUndefined();
expect(tab.whereInput).toBe("id > 0");
expect(tab.resultPageLimit).toBe(25);
expect(tab.resultPageOffset).toBe(50);
+12 -3
View File
@@ -103,6 +103,7 @@ import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, switchesDat
import { classifySqlRisk } from "@/lib/sql/sqlRisk";
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { combineDataGridOrderByInputs } from "@/composables/useDataGridSortBuilder";
import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache";
import { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache";
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
@@ -1776,6 +1777,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultLocalSortOriginalMongoDocuments = undefined;
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
tab.orderByInput = undefined;
tab.structuredOrderByInput = undefined;
tab.resultPageSql = undefined;
tab.resultPageLimit = undefined;
tab.resultPageOffset = undefined;
@@ -2597,6 +2599,7 @@ export const useQueryStore = defineStore("query", () => {
resultSortDirection: t.resultSortDirection,
resultSortMode: t.resultSortMode,
orderByInput: t.orderByInput,
structuredOrderByInput: t.structuredOrderByInput,
whereInput: t.whereInput,
pinned: t.pinned,
mode: t.mode,
@@ -4660,6 +4663,7 @@ export const useQueryStore = defineStore("query", () => {
resultLocalSortOriginalMongoDocuments: undefined,
resultLocalSortOriginalMongoCopyDocuments: undefined,
orderByInput: original.mode === "data" ? original.orderByInput : undefined,
structuredOrderByInput: original.mode === "data" ? original.structuredOrderByInput : undefined,
resultPageSql: undefined,
resultPageLimit: original.mode === "data" ? original.resultPageLimit : undefined,
resultPageOffset: original.mode === "data" ? original.resultPageOffset : undefined,
@@ -4890,7 +4894,7 @@ export const useQueryStore = defineStore("query", () => {
clearInvalidDataTabSortState(tab, tableMeta.columns);
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
const orderBy = tab.orderByInput?.trim() || sortOrder;
const orderBy = combineDataGridOrderByInputs(tab.orderByInput, tab.structuredOrderByInput) || sortOrder;
const limit = tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
const offset = tab.resultPageOffset ?? 0;
const useDriverRowOffset = jdbcConnectionUsesDriverRowOffset(conn, effectiveDbType);
@@ -5638,7 +5642,11 @@ export const useQueryStore = defineStore("query", () => {
tab.orderByInput,
columns.map((column) => column.name),
);
if (!structuredSortMissing && !simpleOrderMissing) return false;
const structuredOrderMissing = simpleDataGridOrderByReferencesMissingColumn(
tab.structuredOrderByInput,
columns.map((column) => column.name),
);
if (!structuredSortMissing && !simpleOrderMissing && !structuredOrderMissing) return false;
if (structuredSortMissing) {
tab.resultSortColumn = undefined;
tab.resultSortColumnIndex = undefined;
@@ -5651,6 +5659,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultLocalSortOriginalMongoCopyDocuments = undefined;
}
if (simpleOrderMissing) tab.orderByInput = undefined;
if (structuredOrderMissing) tab.structuredOrderByInput = undefined;
return true;
}
@@ -9037,7 +9046,7 @@ export const useQueryStore = defineStore("query", () => {
const identifierQuote = connStore.connectionIdentifierQuote?.(tab.connectionId);
const primaryKeys = tab.tableMeta ? tab.tableMeta.primaryKeys : tableMeta.primaryKeys;
const sortOrder = tab.resultSortColumn && tab.resultSortDirection ? `${quoteTableDataIdentifier(effectiveDbType, tab.resultSortColumn, identifierQuote)} ${tab.resultSortDirection.toUpperCase()}` : undefined;
const orderBy = tab.orderByInput?.trim() || sortOrder;
const orderBy = combineDataGridOrderByInputs(tab.orderByInput, tab.structuredOrderByInput) || sortOrder;
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
const executionDatabase = tab.database;
const rows: QueryResult["rows"] = [];
+6
View File
@@ -1939,6 +1939,12 @@ export interface QueryTab {
resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"];
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
orderByInput?: string;
/**
* Structured (sort builder) ORDER BY applied on top of `orderByInput`. Kept as
* a sibling field so the manual input stays editable while store-side SQL
* rebuilds (refresh/export/restore) still reproduce the composite sort.
*/
structuredOrderByInput?: string;
resultPageSql?: string;
resultPageLimit?: number;
resultPageOffset?: number;
+1 -1
View File
@@ -55,7 +55,7 @@ test("load-all selects the last loaded row only after a valid append completes",
assert.match(loadAllFn, /resolvedDatabaseType\.value === "elasticsearch" \|\| resolvedDatabaseType\.value === "easysearch"/);
assert.match(loadAllFn, /LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD/);
assert.match(loadAllFn, /infiniteScrollLoadAllPending = true/);
assert.match(loadAllFn, /emit\("paginate", segment\.offset, segment\.limit, currentWhereInput\(\), currentOrderBy\(\), true\)/);
assert.match(loadAllFn, /emit\("paginate", segment\.offset, segment\.limit, currentWhereInput\(\), effectiveOrderBy\(\), true\)/);
assert.match(source, /\(\) => \[props\.loading, props\.result\.rows\.length, props\.result\.appended_from_row_count\] as const[\s\S]*?\{ flush: "post" \}/);
assert.match(source, /props\.result\.appended_from_row_count !== requestedOffset[\s\S]*?return;[\s\S]*?if \(shouldSelectLastRow\) selectAndRevealLastLoadedRow\(\)/);
assert.match(source, /function selectAndRevealLastLoadedRow\(\)[\s\S]*?selectRow\(rowIndex\)[\s\S]*?remainingFrames = 12[\s\S]*?scrollCanvasRowIntoView\(rowIndex, "end"\)[\s\S]*?scrollDomRowIntoView\(rowIndex, "end"\)[\s\S]*?requestAnimationFrame\(revealWhenReady\)/);