diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e00e75bddc..b2a196ec30 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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)" diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6e3e4f6e72..3c6708da34 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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) { 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]; 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("all"); const gridRef = ref(); const dataGridTopbarRef = ref(); @@ -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 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 { }); } -async function applyOrderBySearch() { - if (!props.onExecuteSql) return; - const orderByClause = orderByInput.value.trim() || undefined; +async function applyOrderBySearch(_value?: string, structuredOrderByOverride?: string): Promise { + 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" /> + +
{{ t(truncationHintKey, { count: result.rows.length }) }} diff --git a/apps/desktop/src/components/grid/DataGridQueryControls.vue b/apps/desktop/src/components/grid/DataGridQueryControls.vue index 1d9d04bfbb..57a18522df 100644 --- a/apps/desktop/src/components/grid/DataGridQueryControls.vue +++ b/apps/desktop/src/components/grid/DataGridQueryControls.vue @@ -1,13 +1,15 @@ + + + + diff --git a/apps/desktop/src/components/grid/DataGridSortWorkbench.vue b/apps/desktop/src/components/grid/DataGridSortWorkbench.vue new file mode 100644 index 0000000000..8ebc80bbdc --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridSortWorkbench.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/apps/desktop/src/components/grid/DataGridTextSortWorkbench.vue b/apps/desktop/src/components/grid/DataGridTextSortWorkbench.vue new file mode 100644 index 0000000000..a0cdcfac96 --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridTextSortWorkbench.vue @@ -0,0 +1,181 @@ + + + diff --git a/apps/desktop/src/components/grid/__tests__/DataGridFilterEditorState.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridFilterEditorState.spec.ts index a216f40e2e..c9dba148fe 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridFilterEditorState.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridFilterEditorState.spec.ts @@ -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('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("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", () => { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index b5f570539e..80e2aa734e 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -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; @@ -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); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridTransposeSort.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridTransposeSort.spec.ts index 1e8a9eed1b..3b9b7003cc 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridTransposeSort.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridTransposeSort.spec.ts @@ -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); diff --git a/apps/desktop/src/components/grid/__tests__/dataGridSortBuilderPointerDrag.spec.ts b/apps/desktop/src/components/grid/__tests__/dataGridSortBuilderPointerDrag.spec.ts new file mode 100644 index 0000000000..aac49c990b --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/dataGridSortBuilderPointerDrag.spec.ts @@ -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("[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(".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("[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(); + 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("[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(); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 9dd29fc9ca..c8fe2eb22f 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2397,7 +2397,7 @@ defineExpose({ @local-column-filters-change="(filters: Record) => 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')" >