mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(grid): support selecting multiple fields for sorting
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
|
||||
+144
@@ -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;
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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ə",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "現在の列を固定",
|
||||
|
||||
@@ -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: "현재 열 고정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Закрепить текущий столбец",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "冻结当前列",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] = [];
|
||||
|
||||
@@ -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
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user