feat(grid): persist column layouts and improve drag-to-reorder
This commit is contained in:
parent
f5c29f08f1
commit
44f748d3a1
|
|
@ -2,7 +2,7 @@
|
|||
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Columns3, Check, Search, Wrench, Filter } from "@lucide/vue";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
|
||||
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
|
@ -64,6 +65,8 @@ import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/com
|
|||
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch";
|
||||
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
|
|
@ -126,8 +129,6 @@ const documentSearchQuery = ref("");
|
|||
const documentSearchMatchIndex = ref(0);
|
||||
const documentSearchHasNavigated = ref(false);
|
||||
const documentViewerSearchActive = ref(false);
|
||||
const columnVisibilitySearch = ref("");
|
||||
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
||||
|
||||
function openDataGridExtractorConfiguration() {
|
||||
viewOptionsOpen.value = false;
|
||||
|
|
@ -144,6 +145,30 @@ let elasticsearchPaginationLowerBound: number | undefined;
|
|||
let elasticsearchCountExecutionId = "";
|
||||
let elasticsearchCountGeneration = 0;
|
||||
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
|
||||
const documentColumnLayoutScopeKey = computed(() =>
|
||||
documentDataGridColumnLayoutScopeKey({
|
||||
databaseType: props.databaseType ?? "mongodb",
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
}),
|
||||
);
|
||||
const legacyDocumentColumnVisibilityScopeKey = computed(() =>
|
||||
documentGridColumnVisibilityScopeKey({
|
||||
databaseType: props.databaseType,
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
}),
|
||||
);
|
||||
|
||||
watch(
|
||||
[legacyDocumentColumnVisibilityScopeKey, documentColumnLayoutScopeKey],
|
||||
([legacyScopeKey, layoutScopeKey]) => {
|
||||
migrateDocumentGridColumnVisibilityToLayout(legacyScopeKey, layoutScopeKey);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const pageTotal = computed(() => paginationTotal.value);
|
||||
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
|
||||
|
|
@ -1531,52 +1556,7 @@ defineExpose({ focusSearch });
|
|||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Popover v-if="viewMode === 'table' && gridResult.columns.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }" :title="t('grid.columnVisibility')" :aria-label="t('grid.columnVisibility')">
|
||||
<Columns3 class="h-3.5 w-3.5" />
|
||||
{{ t("grid.columnVisibility") }}
|
||||
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }} </span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-2 py-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="columnVisibilitySearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-0.5">
|
||||
<button v-for="option in columnVisibilityOptions" :key="`${option.index}:${option.column}`" type="button" class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="dataGridRef?.toggleColumnVisibility(option.index)">
|
||||
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dataGridRef?.isColumnVisible(option.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
|
||||
<Check class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
|
||||
</button>
|
||||
<div v-if="columnVisibilityOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
|
||||
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
|
||||
{{ t("grid.invertColumnVisibility") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!dataGridRef?.hasCustomColumnOrder" @click="dataGridRef?.resetColumnOrder()">
|
||||
{{ t("grid.resetColumnOrder") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DataGridColumnLayoutPopover v-if="viewMode === 'table' && gridResult.columns.length" :grid="dataGridRef" />
|
||||
|
||||
<Popover v-if="viewMode === 'table' && gridResult.columns.length" v-model:open="viewOptionsOpen">
|
||||
<PopoverTrigger as-child>
|
||||
|
|
@ -1622,6 +1602,9 @@ defineExpose({ focusSearch });
|
|||
ref="dataGridRef"
|
||||
class="flex-1 min-h-0"
|
||||
:result="gridResult"
|
||||
:connection-id="props.connectionId"
|
||||
:database="props.database"
|
||||
:column-layout-scope-key="documentColumnLayoutScopeKey"
|
||||
context="results"
|
||||
:database-type="props.databaseType"
|
||||
:mongo-update-target="mongoUpdateTarget"
|
||||
|
|
|
|||
|
|
@ -43,8 +43,25 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
|||
default: defineComponent({
|
||||
name: "DataGridStub",
|
||||
inheritAttrs: false,
|
||||
setup(_, { expose, slots }) {
|
||||
props: {
|
||||
result: { type: Object, required: true },
|
||||
connectionId: { type: String, default: "" },
|
||||
database: { type: String, default: "" },
|
||||
columnLayoutScopeKey: { type: String, default: "" },
|
||||
},
|
||||
setup(props, { expose, slots }) {
|
||||
expose({
|
||||
visibleColumnCount: 2,
|
||||
displayableColumnCount: 2,
|
||||
hiddenColumnCount: 0,
|
||||
orderedColumnLayoutOptions: [],
|
||||
filteredColumnLayoutOptions: () => [],
|
||||
toggleColumnVisibility: vi.fn(),
|
||||
showAllColumns: vi.fn(),
|
||||
invertColumnVisibility: vi.fn(),
|
||||
hasCustomColumnOrder: false,
|
||||
moveDisplayableColumn: vi.fn(),
|
||||
resetColumnOrder: vi.fn(),
|
||||
nullColumnsHidden: false,
|
||||
canToggleAllNullColumns: false,
|
||||
allNullColumnCount: 0,
|
||||
|
|
@ -53,13 +70,21 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
|||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{ "data-testid": "data-grid" },
|
||||
slots["search-bar"]?.({
|
||||
localFilterCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterSummaries: [],
|
||||
clearLocalFilter: vi.fn(),
|
||||
}),
|
||||
{
|
||||
"data-testid": "data-grid",
|
||||
"data-connection-id": props.connectionId,
|
||||
"data-database": props.database,
|
||||
"data-column-layout-scope-key": props.columnLayoutScopeKey,
|
||||
"data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
|
||||
},
|
||||
[
|
||||
slots["search-bar"]?.({
|
||||
localFilterCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterSummaries: [],
|
||||
clearLocalFilter: vi.fn(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
}),
|
||||
|
|
@ -145,9 +170,12 @@ vi.mock("@/components/ui/select", async () => {
|
|||
});
|
||||
|
||||
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
|
||||
import { documentDataGridColumnLayoutScopeKey, loadDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { documentGridColumnVisibilityScopeKey } from "@/lib/document/documentGridColumnVisibilityStorage";
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
let storedValues: Map<string, string>;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
|
|
@ -182,6 +210,12 @@ async function setSearchInput(value: string) {
|
|||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
storedValues = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storedValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storedValues.set(key, value),
|
||||
removeItem: (key: string) => storedValues.delete(key),
|
||||
});
|
||||
backend.getColumns.mockReset();
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.cancelQuery.mockReset();
|
||||
|
|
@ -219,9 +253,44 @@ afterEach(() => {
|
|||
root?.remove();
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("DocumentBrowser Elasticsearch field search", () => {
|
||||
it("migrates hidden columns and passes a stable index layout scope without changing the query result", async () => {
|
||||
app?.unmount();
|
||||
const legacyScopeKey = documentGridColumnVisibilityScopeKey({
|
||||
databaseType: "elasticsearch",
|
||||
connectionId: "connection-1",
|
||||
database: "",
|
||||
collection: "orders",
|
||||
});
|
||||
const layoutScopeKey = documentDataGridColumnLayoutScopeKey({
|
||||
databaseType: "elasticsearch",
|
||||
connectionId: "connection-1",
|
||||
database: "",
|
||||
collection: "orders",
|
||||
});
|
||||
storedValues.set(`dbx-document-grid-column-visibility:v1:${legacyScopeKey}`, JSON.stringify(["title"]));
|
||||
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "connection-1",
|
||||
database: "",
|
||||
collection: "orders",
|
||||
databaseType: "elasticsearch",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
const dataGrid = root!.querySelector<HTMLElement>('[data-testid="data-grid"]')!;
|
||||
expect(dataGrid.dataset.connectionId).toBe("connection-1");
|
||||
expect(dataGrid.dataset.database).toBe("");
|
||||
expect(dataGrid.dataset.columnLayoutScopeKey).toBe(layoutScopeKey);
|
||||
expect(dataGrid.dataset.resultHiddenColumnKeys).toBe("[]");
|
||||
expect(loadDataGridColumnLayout(layoutScopeKey)).toEqual({ orderKeys: [], hiddenKeys: ["title"] });
|
||||
});
|
||||
|
||||
it("searches, selects, updates the query type, and clears the search when closed", async () => {
|
||||
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
|
||||
await flushUi();
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ interface DataGridProps {
|
|||
database?: string;
|
||||
executionDatabase?: string;
|
||||
schema?: string;
|
||||
columnLayoutScopeKey?: string;
|
||||
context?: "results" | "table-data";
|
||||
autoTransposeSingleRow?: boolean;
|
||||
sourceColumns?: Array<string | undefined>;
|
||||
|
|
@ -351,7 +352,6 @@ const emit = defineEmits<{
|
|||
"update:whereInput": [value: string];
|
||||
"update:orderByInput": [value: string];
|
||||
"local-column-filters-change": [value: Record<string, string[]>];
|
||||
"hidden-column-keys-change": [value: string[]];
|
||||
}>();
|
||||
|
||||
const autoRefresh = useDataGridAutoRefresh({ canRefresh: computed(() => !isSaving.value && !props.loading), refresh: onToolbarRefresh });
|
||||
|
|
@ -1688,18 +1688,20 @@ let highlightedColumnTimer = 0;
|
|||
const goToColumnOpen = ref(false);
|
||||
const goToColumnSearch = ref("");
|
||||
const columnOrderKeys = computed(() => uniqueDataGridColumnOrderKeys(props.result.columns, props.sourceColumns));
|
||||
const columnLayoutScopeKey = computed(() =>
|
||||
dataGridColumnLayoutScopeKey({
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
schema: props.schema,
|
||||
context: props.context,
|
||||
tableSchema: props.tableMeta?.schema,
|
||||
tableName: props.tableMeta?.tableName,
|
||||
sql: props.sql,
|
||||
columns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
}),
|
||||
const resolvedColumnLayoutScopeKey = computed(
|
||||
() =>
|
||||
props.columnLayoutScopeKey ??
|
||||
dataGridColumnLayoutScopeKey({
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
schema: props.schema,
|
||||
context: props.context,
|
||||
tableSchema: props.tableMeta?.schema,
|
||||
tableName: props.tableMeta?.tableName,
|
||||
sql: props.sql,
|
||||
columns: props.result.columns,
|
||||
sourceColumns: props.sourceColumns,
|
||||
}),
|
||||
);
|
||||
const tableColumnOrderScopeKey = computed(() => {
|
||||
if (props.context !== "table-data" || !props.connectionId || !props.database || !props.tableMeta?.tableName) return "";
|
||||
|
|
@ -1727,13 +1729,15 @@ const {
|
|||
allNullColumnCount,
|
||||
hasCustomColumnOrder,
|
||||
canToggleAllNullColumns,
|
||||
filteredColumnVisibilityOptions,
|
||||
orderedColumnLayoutOptions,
|
||||
filteredColumnLayoutOptions,
|
||||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
showAllColumns,
|
||||
invertColumnVisibility,
|
||||
showColumn,
|
||||
persistColumnOrder,
|
||||
moveDisplayableColumn,
|
||||
resetColumnOrder,
|
||||
toggleAllNullColumns,
|
||||
resetColumnVisibility,
|
||||
|
|
@ -1749,13 +1753,13 @@ const {
|
|||
displayableColumnIndexes,
|
||||
allNullColumnIndexes: allNullColumnIndexesForResult,
|
||||
columnOrderKeys,
|
||||
layoutScopeKey: columnLayoutScopeKey,
|
||||
layoutScopeKey: resolvedColumnLayoutScopeKey,
|
||||
tableScopeKey: tableColumnOrderScopeKey,
|
||||
// Existing tab snapshots may still carry this field; new changes persist in the internal layout store.
|
||||
initialHiddenColumnKeys: computed(() => props.result.local_hidden_column_keys),
|
||||
hideNullColumns,
|
||||
onHiddenColumnKeysChange: (keys) => emit("hidden-column-keys-change", keys),
|
||||
onHideNullColumnsChange: (value) => settingsStore.updateEditorSettings({ dataGridHideNullColumns: value }),
|
||||
onRefreshMetrics: refreshGridScrollerMetrics,
|
||||
onRefreshMetrics: scheduleColumnLayoutRefresh,
|
||||
});
|
||||
const goToColumnItems = computed(() =>
|
||||
buildDataGridColumnLookupItems({
|
||||
|
|
@ -1935,7 +1939,7 @@ const {
|
|||
onResizeStart,
|
||||
onCanvasMouseLeave,
|
||||
onCanvasDrawSchedule: scheduleCanvasDraw,
|
||||
onRefreshMetrics: () => nextTick(refreshGridScrollerMetrics),
|
||||
onRefreshMetrics: scheduleColumnLayoutRefresh,
|
||||
onPersistColumnOrder: persistColumnOrder,
|
||||
frozenColumnCount,
|
||||
});
|
||||
|
|
@ -2249,6 +2253,19 @@ function refreshGridScrollerMetrics() {
|
|||
observeGridHorizontalScrollbarScroller();
|
||||
}
|
||||
|
||||
let columnLayoutRefreshFrame = 0;
|
||||
function scheduleColumnLayoutRefresh() {
|
||||
if (columnLayoutRefreshFrame) return;
|
||||
columnLayoutRefreshFrame = requestAnimationFrame(() => {
|
||||
try {
|
||||
initColumnWidths();
|
||||
refreshGridScrollerMetrics();
|
||||
} finally {
|
||||
columnLayoutRefreshFrame = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncHeaderScroll(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
updateGridScrollbarGutter(target);
|
||||
|
|
@ -2321,16 +2338,7 @@ watch(isScrolling, (scrolling) => {
|
|||
});
|
||||
|
||||
initColumnWidths();
|
||||
watch(
|
||||
() => visibleColumns.value.length,
|
||||
() => initColumnWidths(),
|
||||
);
|
||||
watch(
|
||||
() => [visibleColumnCount.value, renderedColumnWidths.value.length],
|
||||
() => {
|
||||
nextTick(refreshGridScrollerMetrics);
|
||||
},
|
||||
);
|
||||
watch([visibleColumnIndexes, () => renderedColumnWidths.value.length], () => scheduleColumnLayoutRefresh());
|
||||
const localFilterScopeKey = computed(() =>
|
||||
[
|
||||
props.connectionId ?? "",
|
||||
|
|
@ -2348,7 +2356,7 @@ watch(
|
|||
() => localFilterScopeKey.value,
|
||||
() => {
|
||||
localColumnFilters.value = {};
|
||||
resetColumnVisibility(props.result.local_hidden_column_keys);
|
||||
resetColumnVisibility();
|
||||
closeLocalFilter();
|
||||
},
|
||||
);
|
||||
|
|
@ -5140,6 +5148,8 @@ onUnmounted(() => {
|
|||
disconnectCellEditResizeObserver();
|
||||
stopGridHorizontalScrollbarDrag();
|
||||
stopGridVerticalScrollbarDrag();
|
||||
if (columnLayoutRefreshFrame) cancelAnimationFrame(columnLayoutRefreshFrame);
|
||||
columnLayoutRefreshFrame = 0;
|
||||
if (typeof window === "undefined") return;
|
||||
window.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleCanvasPixelRatioRefresh);
|
||||
|
|
@ -7585,12 +7595,14 @@ defineExpose({
|
|||
visibleColumnCount,
|
||||
displayableColumnCount,
|
||||
hiddenColumnCount,
|
||||
filteredColumnVisibilityOptions,
|
||||
orderedColumnLayoutOptions,
|
||||
filteredColumnLayoutOptions,
|
||||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
showAllColumns,
|
||||
invertColumnVisibility,
|
||||
hasCustomColumnOrder,
|
||||
moveDisplayableColumn,
|
||||
resetColumnOrder,
|
||||
nullColumnsHidden,
|
||||
allNullColumnCount,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch, type CSSProperties } from "vue";
|
||||
import { Check, Columns3, GripVertical, Search } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
|
||||
import { dragSortAutoScrollDelta } from "@/composables/useDragSort";
|
||||
import {
|
||||
DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD,
|
||||
DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
|
||||
DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT,
|
||||
DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD,
|
||||
dataGridColumnLayoutDropTarget,
|
||||
dataGridColumnLayoutVirtualWindow,
|
||||
type DataGridColumnLayoutHandle,
|
||||
} from "./dataGridColumnLayoutPopover";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
grid?: DataGridColumnLayoutHandle;
|
||||
triggerClass?: string;
|
||||
}>(),
|
||||
{
|
||||
triggerClass: "",
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const popoverOpen = ref(false);
|
||||
const columnSearch = ref("");
|
||||
const listRef = ref<HTMLElement>();
|
||||
const listScrollTop = ref(0);
|
||||
const draggedDisplayPosition = ref<number | null>(null);
|
||||
const dragTargetDisplayPosition = ref<number | null>(null);
|
||||
const dragInsertionIndex = ref<number | null>(null);
|
||||
const dragPreviewOption = ref<DataGridColumnLayoutOption | null>(null);
|
||||
const dragPreviewStyle = ref<CSSProperties>();
|
||||
let columnDragAutoScrollFrame = 0;
|
||||
|
||||
interface ColumnDragState {
|
||||
pointerId: number;
|
||||
fromDisplayPosition: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
lastClientX: number;
|
||||
lastClientY: number;
|
||||
active: boolean;
|
||||
handle: HTMLElement;
|
||||
option: DataGridColumnLayoutOption;
|
||||
previewOffsetX: number;
|
||||
previewOffsetY: number;
|
||||
previewWidth: number;
|
||||
previousBodyUserSelect: string;
|
||||
}
|
||||
|
||||
let columnDragState: ColumnDragState | null = null;
|
||||
|
||||
const columnLayoutOptions = computed(() => props.grid?.filteredColumnLayoutOptions(columnSearch.value) ?? []);
|
||||
const columnReorderEnabled = computed(() => columnSearch.value.trim() === "");
|
||||
const virtualized = computed(() => columnLayoutOptions.value.length > DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD);
|
||||
const virtualWindow = computed(() =>
|
||||
dataGridColumnLayoutVirtualWindow({
|
||||
itemCount: columnLayoutOptions.value.length,
|
||||
scrollTop: listScrollTop.value,
|
||||
}),
|
||||
);
|
||||
const renderedOptions = computed(() => {
|
||||
if (!virtualized.value) return columnLayoutOptions.value;
|
||||
return columnLayoutOptions.value.slice(virtualWindow.value.start, virtualWindow.value.end);
|
||||
});
|
||||
const listContentStyle = computed<CSSProperties | undefined>(() => {
|
||||
if (!virtualized.value) return undefined;
|
||||
return { height: `${virtualWindow.value.totalHeight}px`, position: "relative" };
|
||||
});
|
||||
const renderedOptionsStyle = computed<CSSProperties | undefined>(() => {
|
||||
if (!virtualized.value) return undefined;
|
||||
return {
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
transform: `translateY(${virtualWindow.value.offsetTop}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
function columnLayoutRowStyle(option: DataGridColumnLayoutOption): CSSProperties {
|
||||
const style: CSSProperties = { height: `${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px` };
|
||||
const sourcePosition = draggedDisplayPosition.value;
|
||||
const targetPosition = dragTargetDisplayPosition.value;
|
||||
if (sourcePosition === null || targetPosition === null || option.displayPosition === sourcePosition) return style;
|
||||
let offset = 0;
|
||||
if (targetPosition < sourcePosition && option.displayPosition >= targetPosition && option.displayPosition < sourcePosition) offset = DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
|
||||
if (targetPosition > sourcePosition && option.displayPosition > sourcePosition && option.displayPosition <= targetPosition) offset = -DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
|
||||
if (!offset) return style;
|
||||
return { ...style, transform: `translateY(${offset}px)`, transition: "transform 120ms ease-out" };
|
||||
}
|
||||
|
||||
function resetListScroll() {
|
||||
listScrollTop.value = 0;
|
||||
void nextTick(() => {
|
||||
if (listRef.value) listRef.value.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
|
||||
function onListScroll(event: Event) {
|
||||
listScrollTop.value = (event.currentTarget as HTMLElement).scrollTop;
|
||||
}
|
||||
|
||||
function removeColumnDragListeners() {
|
||||
window.removeEventListener("pointermove", onWindowPointerMove, true);
|
||||
window.removeEventListener("pointerup", onWindowPointerUp, true);
|
||||
window.removeEventListener("pointercancel", onWindowPointerCancel, true);
|
||||
window.removeEventListener("blur", resetColumnDragState);
|
||||
window.removeEventListener("keydown", onWindowKeyDown, true);
|
||||
}
|
||||
|
||||
function cancelColumnDragAutoScroll() {
|
||||
if (!columnDragAutoScrollFrame) return;
|
||||
window.cancelAnimationFrame(columnDragAutoScrollFrame);
|
||||
columnDragAutoScrollFrame = 0;
|
||||
}
|
||||
|
||||
function resetColumnDragState() {
|
||||
const state = columnDragState;
|
||||
columnDragState = null;
|
||||
cancelColumnDragAutoScroll();
|
||||
removeColumnDragListeners();
|
||||
if (state?.active) document.body.style.userSelect = state.previousBodyUserSelect;
|
||||
if (state?.handle.hasPointerCapture?.(state.pointerId)) state.handle.releasePointerCapture(state.pointerId);
|
||||
draggedDisplayPosition.value = null;
|
||||
dragTargetDisplayPosition.value = null;
|
||||
dragInsertionIndex.value = null;
|
||||
dragPreviewOption.value = null;
|
||||
dragPreviewStyle.value = undefined;
|
||||
}
|
||||
|
||||
function updateColumnDragTarget(clientY: number) {
|
||||
const state = columnDragState;
|
||||
const list = listRef.value;
|
||||
if (!state?.active || !list) return;
|
||||
const target = dataGridColumnLayoutDropTarget({
|
||||
clientY,
|
||||
listTop: list.getBoundingClientRect().top,
|
||||
scrollTop: list.scrollTop,
|
||||
itemCount: columnLayoutOptions.value.length,
|
||||
fromDisplayPosition: state.fromDisplayPosition,
|
||||
});
|
||||
dragInsertionIndex.value = target.insertionIndex;
|
||||
dragTargetDisplayPosition.value = target.toDisplayPosition;
|
||||
}
|
||||
|
||||
function columnDragAutoScrollDelta(): number {
|
||||
const state = columnDragState;
|
||||
const list = listRef.value;
|
||||
if (!state?.active || !list) return 0;
|
||||
return dragSortAutoScrollDelta({
|
||||
pointerX: state.lastClientX,
|
||||
pointerY: state.lastClientY,
|
||||
rect: list.getBoundingClientRect(),
|
||||
scrollTop: list.scrollTop,
|
||||
clientHeight: list.clientHeight,
|
||||
scrollHeight: list.scrollHeight,
|
||||
});
|
||||
}
|
||||
|
||||
function runColumnDragAutoScroll() {
|
||||
columnDragAutoScrollFrame = 0;
|
||||
const state = columnDragState;
|
||||
const list = listRef.value;
|
||||
const delta = columnDragAutoScrollDelta();
|
||||
if (!state?.active || !list || !delta) return;
|
||||
const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight);
|
||||
const previousScrollTop = list.scrollTop;
|
||||
list.scrollTop = Math.min(maxScrollTop, Math.max(0, previousScrollTop + delta));
|
||||
listScrollTop.value = list.scrollTop;
|
||||
if (list.scrollTop !== previousScrollTop) updateColumnDragTarget(state.lastClientY);
|
||||
scheduleColumnDragAutoScroll();
|
||||
}
|
||||
|
||||
function scheduleColumnDragAutoScroll() {
|
||||
if (columnDragAutoScrollFrame || !columnDragAutoScrollDelta()) return;
|
||||
columnDragAutoScrollFrame = window.requestAnimationFrame(runColumnDragAutoScroll);
|
||||
}
|
||||
|
||||
function updateColumnDragPreview(clientX: number, clientY: number) {
|
||||
const state = columnDragState;
|
||||
if (!state?.active) return;
|
||||
dragPreviewStyle.value = {
|
||||
height: `${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px`,
|
||||
transform: `translate3d(${clientX - state.previewOffsetX}px, ${clientY - state.previewOffsetY}px, 0)`,
|
||||
width: `${state.previewWidth}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function activateColumnDrag(event: PointerEvent) {
|
||||
const state = columnDragState;
|
||||
if (!state || state.active) return;
|
||||
state.active = true;
|
||||
state.previousBodyUserSelect = document.body.style.userSelect;
|
||||
document.body.style.userSelect = "none";
|
||||
draggedDisplayPosition.value = state.fromDisplayPosition;
|
||||
dragPreviewOption.value = state.option;
|
||||
updateColumnDragPreview(event.clientX, event.clientY);
|
||||
updateColumnDragTarget(event.clientY);
|
||||
}
|
||||
|
||||
function onWindowPointerMove(event: PointerEvent) {
|
||||
const state = columnDragState;
|
||||
if (!state || event.pointerId !== state.pointerId) return;
|
||||
state.lastClientX = event.clientX;
|
||||
state.lastClientY = event.clientY;
|
||||
const movedPastThreshold = Math.hypot(event.clientX - state.startClientX, event.clientY - state.startClientY) >= DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD;
|
||||
if (!state.active && !movedPastThreshold) return;
|
||||
event.preventDefault();
|
||||
activateColumnDrag(event);
|
||||
updateColumnDragPreview(event.clientX, event.clientY);
|
||||
updateColumnDragTarget(event.clientY);
|
||||
scheduleColumnDragAutoScroll();
|
||||
}
|
||||
|
||||
function onWindowPointerUp(event: PointerEvent) {
|
||||
const state = columnDragState;
|
||||
if (!state || event.pointerId !== state.pointerId) return;
|
||||
const fromDisplayPosition = state.fromDisplayPosition;
|
||||
const toDisplayPosition = dragTargetDisplayPosition.value;
|
||||
const shouldCommit = state.active && toDisplayPosition !== null && toDisplayPosition !== fromDisplayPosition;
|
||||
if (state.active) event.preventDefault();
|
||||
resetColumnDragState();
|
||||
if (shouldCommit) props.grid?.moveDisplayableColumn(fromDisplayPosition, toDisplayPosition);
|
||||
}
|
||||
|
||||
function onWindowPointerCancel(event: PointerEvent) {
|
||||
if (event.pointerId === columnDragState?.pointerId) resetColumnDragState();
|
||||
}
|
||||
|
||||
function onWindowKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape" || !columnDragState) return;
|
||||
event.preventDefault();
|
||||
resetColumnDragState();
|
||||
}
|
||||
|
||||
function columnDragPreviewMetrics(handle: HTMLElement, event: PointerEvent) {
|
||||
const rowRect = handle.closest<HTMLElement>("[data-column-layout-row]")?.getBoundingClientRect();
|
||||
const listRect = listRef.value?.getBoundingClientRect();
|
||||
const previewWidth = rowRect?.width || listRect?.width || 288;
|
||||
const rowLeft = rowRect?.width ? rowRect.left : (listRect?.left ?? event.clientX);
|
||||
const rowTop = rowRect?.height ? rowRect.top : event.clientY - DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT / 2;
|
||||
return {
|
||||
previewOffsetX: Math.min(previewWidth, Math.max(0, event.clientX - rowLeft)),
|
||||
previewOffsetY: Math.min(DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT, Math.max(0, event.clientY - rowTop)),
|
||||
previewWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function startColumnDrag(option: DataGridColumnLayoutOption, event: PointerEvent) {
|
||||
if (!columnReorderEnabled.value || event.button !== 0) return;
|
||||
resetColumnDragState();
|
||||
const handle = event.currentTarget as HTMLElement;
|
||||
columnDragState = {
|
||||
pointerId: event.pointerId,
|
||||
fromDisplayPosition: option.displayPosition,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
lastClientX: event.clientX,
|
||||
lastClientY: event.clientY,
|
||||
active: false,
|
||||
handle,
|
||||
option,
|
||||
...columnDragPreviewMetrics(handle, event),
|
||||
previousBodyUserSelect: "",
|
||||
};
|
||||
window.addEventListener("pointermove", onWindowPointerMove, true);
|
||||
window.addEventListener("pointerup", onWindowPointerUp, true);
|
||||
window.addEventListener("pointercancel", onWindowPointerCancel, true);
|
||||
window.addEventListener("blur", resetColumnDragState);
|
||||
window.addEventListener("keydown", onWindowKeyDown, true);
|
||||
try {
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
} catch (error) {
|
||||
console.warn("[DBX][DataGridColumnLayoutPopover:pointer-capture]", error);
|
||||
}
|
||||
}
|
||||
|
||||
watch(columnSearch, () => {
|
||||
resetColumnDragState();
|
||||
resetListScroll();
|
||||
});
|
||||
watch(popoverOpen, (open) => {
|
||||
if (!open) resetColumnDragState();
|
||||
});
|
||||
onBeforeUnmount(resetColumnDragState);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="popoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 shrink-0 gap-1 px-1.5 text-xs text-foreground hover:bg-accent"
|
||||
:class="[triggerClass, { 'bg-accent text-foreground': (grid?.hiddenColumnCount ?? 0) > 0 }]"
|
||||
:disabled="!grid"
|
||||
:title="t('grid.columnVisibility')"
|
||||
:aria-label="t('grid.columnVisibility')"
|
||||
>
|
||||
<Columns3 class="h-3.5 w-3.5" />
|
||||
{{ t("grid.columnVisibility") }}
|
||||
<span v-if="(grid?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ grid?.visibleColumnCount }}/{{ grid?.displayableColumnCount }} </span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-72 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-2 py-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">{{ grid?.visibleColumnCount ?? 0 }}/{{ grid?.displayableColumnCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="columnSearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
|
||||
</div>
|
||||
<div ref="listRef" data-column-layout-list class="max-h-72 overflow-auto" :style="{ maxHeight: `${DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT}px` }" @scroll="onListScroll">
|
||||
<div class="relative" :style="listContentStyle">
|
||||
<div :style="renderedOptionsStyle">
|
||||
<div
|
||||
v-for="option in renderedOptions"
|
||||
:key="option.key"
|
||||
data-column-layout-row
|
||||
:data-display-position="option.displayPosition"
|
||||
class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-1 text-xs hover:bg-accent"
|
||||
:class="{ 'opacity-25': draggedDisplayPosition === option.displayPosition }"
|
||||
:style="columnLayoutRowStyle(option)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-column-drag-handle
|
||||
class="flex h-5 w-5 touch-none items-center justify-center justify-self-center rounded border border-transparent text-muted-foreground"
|
||||
:class="columnReorderEnabled ? 'cursor-move hover:border-border hover:bg-background' : 'cursor-not-allowed opacity-30'"
|
||||
:disabled="!columnReorderEnabled"
|
||||
:aria-label="`${columnReorderEnabled ? t('grid.columnReorderHint') : t('grid.columnReorderSearchHint')} ${option.column}`"
|
||||
:title="columnReorderEnabled ? t('grid.columnReorderHint') : t('grid.columnReorderSearchHint')"
|
||||
@pointerdown.stop.prevent="startColumnDrag(option, $event)"
|
||||
>
|
||||
<GripVertical class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" data-column-visibility-toggle class="grid h-full min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] items-center text-left" :aria-pressed="option.visible" @click="grid?.toggleColumnVisibility(option.index)">
|
||||
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="option.visible ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
|
||||
<Check class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="flex min-w-0 items-baseline gap-1.5">
|
||||
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
|
||||
<span v-if="option.comment" class="truncate text-[10px] text-muted-foreground" :title="option.comment">{{ option.comment }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="draggedDisplayPosition !== null && dragInsertionIndex !== null"
|
||||
data-column-drop-indicator
|
||||
class="pointer-events-none absolute left-1 right-1 z-10 h-0.5 -translate-y-1/2 rounded-full bg-primary shadow-sm"
|
||||
:style="{ top: `${dragInsertionIndex * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px` }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="columnLayoutOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
|
||||
<span class="text-[11px] leading-4 text-muted-foreground">
|
||||
{{ t("grid.columnVisibilityHint") }}
|
||||
{{ columnReorderEnabled ? t("grid.columnReorderHint") : t("grid.columnReorderSearchHint") }}
|
||||
</span>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(grid?.displayableColumnCount ?? 0) <= 1" @click="grid?.invertColumnVisibility()">
|
||||
{{ t("grid.invertColumnVisibility") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!grid?.hasCustomColumnOrder" @click="grid?.resetColumnOrder()">
|
||||
{{ t("grid.resetColumnOrder") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(grid?.hiddenColumnCount ?? 0) === 0" @click="grid?.showAllColumns()">
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="dragPreviewOption"
|
||||
data-column-drag-preview
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed left-0 top-0 z-[100] grid grid-cols-[1.5rem_1.5rem_minmax(0,1fr)] items-center rounded border bg-background px-1 text-xs shadow-lg ring-1 ring-primary/40 will-change-transform dark:bg-muted"
|
||||
:style="dragPreviewStyle"
|
||||
>
|
||||
<span class="flex h-5 w-5 items-center justify-center text-muted-foreground">
|
||||
<GripVertical class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dragPreviewOption.visible ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
|
||||
<Check class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="flex min-w-0 items-baseline gap-1.5">
|
||||
<span class="truncate font-mono text-xs">{{ dragPreviewOption.column }}</span>
|
||||
<span v-if="dragPreviewOption.comment" class="truncate text-[10px] text-muted-foreground">{{ dragPreviewOption.comment }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
323
apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
vendored
Normal file
323
apps/desktop/src/components/grid/__tests__/dataGridColumnLayoutPopover.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
|
||||
import type { DataGridColumnLayoutHandle } from "../dataGridColumnLayoutPopover";
|
||||
import { DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT, DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT, dataGridColumnLayoutDropTarget, dataGridColumnLayoutVirtualWindow } from "../dataGridColumnLayoutPopover";
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { defineComponent, h, onMounted } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
const popover = defineComponent({
|
||||
props: { open: Boolean },
|
||||
emits: ["update:open"],
|
||||
setup(_props, { emit, slots }) {
|
||||
onMounted(() => emit("update:open", true));
|
||||
return () => h("div", { "data-test-popover-root": "" }, [h("button", { class: "test-close-popover", onClick: () => emit("update:open", false) }), slots.default?.()]);
|
||||
},
|
||||
});
|
||||
return { Popover: popover, PopoverContent: passthrough, PopoverTrigger: passthrough };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h("button", attrs, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import DataGridColumnLayoutPopover from "../DataGridColumnLayoutPopover.vue";
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function columnLayoutOptions(itemCount: number): DataGridColumnLayoutOption[] {
|
||||
return Array.from({ length: itemCount }, (_, index) => ({
|
||||
key: `column-${index}`,
|
||||
column: `column_${index}`,
|
||||
name: `column_${index}`,
|
||||
index,
|
||||
visible: true,
|
||||
displayPosition: index,
|
||||
}));
|
||||
}
|
||||
|
||||
function createGrid(itemCount: number) {
|
||||
const options = columnLayoutOptions(itemCount);
|
||||
const toggleColumnVisibility = vi.fn();
|
||||
const moveDisplayableColumn = vi.fn();
|
||||
const grid: DataGridColumnLayoutHandle = {
|
||||
visibleColumnCount: itemCount,
|
||||
displayableColumnCount: itemCount,
|
||||
hiddenColumnCount: 0,
|
||||
orderedColumnLayoutOptions: options,
|
||||
filteredColumnLayoutOptions: (search) => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
return normalizedSearch ? options.filter((option) => option.column.toLowerCase().includes(normalizedSearch)) : options;
|
||||
},
|
||||
toggleColumnVisibility,
|
||||
showAllColumns: vi.fn(),
|
||||
invertColumnVisibility: vi.fn(),
|
||||
hasCustomColumnOrder: false,
|
||||
moveDisplayableColumn,
|
||||
resetColumnOrder: vi.fn(),
|
||||
};
|
||||
return { grid, moveDisplayableColumn, toggleColumnVisibility };
|
||||
}
|
||||
|
||||
async function mountPopover(itemCount = 4) {
|
||||
const gridState = createGrid(itemCount);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(DataGridColumnLayoutPopover, { grid: gridState.grid });
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
return { ...gridState, host, app };
|
||||
}
|
||||
|
||||
function configureList(host: HTMLElement, itemCount: number) {
|
||||
const list = host.querySelector<HTMLElement>("[data-column-layout-list]")!;
|
||||
Object.defineProperties(list, {
|
||||
clientHeight: { configurable: true, value: 288 },
|
||||
scrollHeight: { configurable: true, value: itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT },
|
||||
});
|
||||
list.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
right: 288,
|
||||
top: 0,
|
||||
bottom: 288,
|
||||
width: 288,
|
||||
height: 288,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = "en";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
document.body.style.userSelect = "";
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("data grid column layout popover", () => {
|
||||
it("renders only the visible field window plus a bounded buffer", () => {
|
||||
const window = dataGridColumnLayoutVirtualWindow({
|
||||
itemCount: 500,
|
||||
scrollTop: 1400,
|
||||
viewportHeight: 280,
|
||||
});
|
||||
|
||||
expect(window).toEqual({
|
||||
start: 44,
|
||||
end: 66,
|
||||
offsetTop: 44 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
|
||||
totalHeight: 500 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the virtual field window at the list boundaries", () => {
|
||||
expect(dataGridColumnLayoutVirtualWindow({ itemCount: 4, scrollTop: 9999 })).toMatchObject({
|
||||
start: 0,
|
||||
end: 4,
|
||||
totalHeight: 4 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps row halves to symmetric insertion positions", () => {
|
||||
expect(dataGridColumnLayoutDropTarget({ clientY: 2, listTop: 0, scrollTop: 0, itemCount: 4, fromDisplayPosition: 2 })).toEqual({
|
||||
insertionIndex: 0,
|
||||
toDisplayPosition: 0,
|
||||
});
|
||||
expect(dataGridColumnLayoutDropTarget({ clientY: 50, listTop: 0, scrollTop: 0, itemCount: 4, fromDisplayPosition: 0 })).toEqual({
|
||||
insertionIndex: 2,
|
||||
toDisplayPosition: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("separates the drag handle from the visibility toggle", async () => {
|
||||
const { host, toggleColumnVisibility } = await mountPopover(3);
|
||||
const handles = host.querySelectorAll<HTMLButtonElement>("[data-column-drag-handle]");
|
||||
const visibilityToggles = host.querySelectorAll<HTMLButtonElement>("[data-column-visibility-toggle]");
|
||||
const rows = host.querySelectorAll<HTMLElement>("[data-column-layout-row]");
|
||||
|
||||
expect(handles[0]?.className).toContain("cursor-move");
|
||||
expect(rows[0]?.className).not.toContain("cursor-grab");
|
||||
handles[1]?.click();
|
||||
expect(toggleColumnVisibility).not.toHaveBeenCalled();
|
||||
|
||||
visibilityToggles[1]?.click();
|
||||
expect(toggleColumnVisibility).toHaveBeenCalledOnce();
|
||||
expect(toggleColumnVisibility).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("starts after five pixels, shows an insertion line, and commits once without toggling visibility", async () => {
|
||||
const { host, moveDisplayableColumn, toggleColumnVisibility } = await mountPopover(4);
|
||||
configureList(host, 4);
|
||||
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { clientY: 18 });
|
||||
await nextTick();
|
||||
expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
|
||||
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
|
||||
|
||||
dispatchPointer(window, "pointermove", { clientY: 50 });
|
||||
await nextTick();
|
||||
const dragPreview = document.body.querySelector<HTMLElement>("[data-column-drag-preview]")!;
|
||||
const initialPreviewTransform = dragPreview.style.transform;
|
||||
expect(host.querySelector<HTMLElement>("[data-column-drop-indicator]")?.style.top).toBe(`${2 * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px`);
|
||||
expect(dragPreview.textContent).toContain("column_0");
|
||||
expect(initialPreviewTransform).toBe("translate3d(0px, 36px, 0)");
|
||||
expect(host.querySelector<HTMLElement>('[data-display-position="1"]')?.style.transform).toBe(`translateY(-${DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT}px)`);
|
||||
expect(document.body.style.userSelect).toBe("none");
|
||||
|
||||
dispatchPointer(window, "pointermove", { clientX: 36, clientY: 52 });
|
||||
await nextTick();
|
||||
expect(dragPreview.style.transform).not.toBe(initialPreviewTransform);
|
||||
expect(dragPreview.style.transform).toBe("translate3d(16px, 38px, 0)");
|
||||
|
||||
dispatchPointer(window, "pointerup", { clientX: 36, clientY: 52 });
|
||||
dispatchPointer(window, "pointerup", { clientX: 36, clientY: 52 });
|
||||
await nextTick();
|
||||
expect(moveDisplayableColumn).toHaveBeenCalledOnce();
|
||||
expect(moveDisplayableColumn).toHaveBeenCalledWith(0, 1);
|
||||
expect(toggleColumnVisibility).not.toHaveBeenCalled();
|
||||
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
});
|
||||
|
||||
it("cancels without committing and disables handles while searching", async () => {
|
||||
const { host, moveDisplayableColumn } = await mountPopover(4);
|
||||
configureList(host, 4);
|
||||
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { clientY: 50 });
|
||||
dispatchPointer(window, "pointercancel", { clientY: 50 });
|
||||
dispatchPointer(window, "pointerup", { clientY: 50 });
|
||||
expect(moveDisplayableColumn).not.toHaveBeenCalled();
|
||||
|
||||
const search = host.querySelector<HTMLInputElement>("input")!;
|
||||
search.value = "column_1";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
const filteredHandle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
|
||||
expect(filteredHandle.disabled).toBe(true);
|
||||
dispatchPointer(filteredHandle, "pointerdown", { clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { clientY: 50 });
|
||||
dispatchPointer(window, "pointerup", { clientY: 50 });
|
||||
expect(moveDisplayableColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears active drag state when the popover closes", async () => {
|
||||
const { host, moveDisplayableColumn } = await mountPopover(4);
|
||||
configureList(host, 4);
|
||||
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { clientY: 50 });
|
||||
await nextTick();
|
||||
expect(host.querySelector("[data-column-drop-indicator]")).not.toBeNull();
|
||||
|
||||
host.querySelector<HTMLButtonElement>(".test-close-popover")?.click();
|
||||
await nextTick();
|
||||
expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
|
||||
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
|
||||
dispatchPointer(window, "pointerup", { clientY: 50 });
|
||||
expect(moveDisplayableColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels active dragging on Escape and window blur", async () => {
|
||||
const { host, moveDisplayableColumn } = await mountPopover(4);
|
||||
configureList(host, 4);
|
||||
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { pointerId: 3, clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { pointerId: 3, clientY: 50 });
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||
dispatchPointer(window, "pointerup", { pointerId: 3, clientY: 50 });
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { pointerId: 4, clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { pointerId: 4, clientY: 50 });
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
dispatchPointer(window, "pointerup", { pointerId: 4, clientY: 50 });
|
||||
|
||||
expect(moveDisplayableColumn).not.toHaveBeenCalled();
|
||||
expect(host.querySelector("[data-column-drop-indicator]")).toBeNull();
|
||||
expect(document.body.querySelector("[data-column-drag-preview]")).toBeNull();
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
});
|
||||
|
||||
it("auto-scrolls virtualized fields and commits beyond the initial viewport", 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, moveDisplayableColumn } = await mountPopover(120);
|
||||
const list = configureList(host, 120);
|
||||
const handle = host.querySelector<HTMLButtonElement>("[data-column-drag-handle]")!;
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { pointerId: 7, clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { pointerId: 7, clientY: 286 });
|
||||
for (let frameIndex = 0; frameIndex < 30; frameIndex += 1) {
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
callbacks.forEach((callback) => callback(frameIndex));
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
expect(list.scrollTop).toBeGreaterThan(DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT);
|
||||
expect(host.querySelector('[data-display-position="0"]')).toBeNull();
|
||||
expect(document.body.querySelector("[data-column-drag-preview]")?.textContent).toContain("column_0");
|
||||
dispatchPointer(window, "pointerup", { pointerId: 7, clientY: 286 });
|
||||
expect(moveDisplayableColumn).toHaveBeenCalledOnce();
|
||||
expect(moveDisplayableColumn.mock.calls[0]?.[1]).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { DataGridColumnLayoutOption } from "@/composables/useDataGridColumnLayout";
|
||||
|
||||
export const DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT = 28;
|
||||
export const DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT = 288;
|
||||
export const DATA_GRID_COLUMN_LAYOUT_VIRTUAL_THRESHOLD = 80;
|
||||
export const DATA_GRID_COLUMN_LAYOUT_DRAG_THRESHOLD = 5;
|
||||
const DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS = 6;
|
||||
|
||||
export interface DataGridColumnLayoutHandle {
|
||||
visibleColumnCount: number;
|
||||
displayableColumnCount: number;
|
||||
hiddenColumnCount: number;
|
||||
orderedColumnLayoutOptions: readonly DataGridColumnLayoutOption[];
|
||||
filteredColumnLayoutOptions: (search: string) => DataGridColumnLayoutOption[];
|
||||
toggleColumnVisibility: (columnIndex: number) => void;
|
||||
showAllColumns: () => void;
|
||||
invertColumnVisibility: () => void;
|
||||
hasCustomColumnOrder: boolean;
|
||||
moveDisplayableColumn: (fromDisplayableIndex: number, toDisplayableIndex: number) => void;
|
||||
resetColumnOrder: () => void;
|
||||
}
|
||||
|
||||
export interface DataGridColumnLayoutVirtualWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
offsetTop: number;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
export interface DataGridColumnLayoutDropTarget {
|
||||
insertionIndex: number;
|
||||
toDisplayPosition: number;
|
||||
}
|
||||
|
||||
export function dataGridColumnLayoutDropTarget(options: { clientY: number; listTop: number; scrollTop: number; itemCount: number; fromDisplayPosition: number }): DataGridColumnLayoutDropTarget {
|
||||
const itemCount = Math.max(0, options.itemCount);
|
||||
const totalHeight = itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
|
||||
const relativeY = Math.min(totalHeight, Math.max(0, options.clientY - options.listTop + options.scrollTop));
|
||||
const rowIndex = Math.min(Math.max(0, itemCount - 1), Math.floor(relativeY / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT));
|
||||
const offsetInRow = relativeY - rowIndex * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT;
|
||||
const insertionIndex = relativeY >= totalHeight || offsetInRow >= DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT / 2 ? Math.min(itemCount, rowIndex + 1) : rowIndex;
|
||||
const targetBeforeRemoval = insertionIndex > options.fromDisplayPosition ? insertionIndex - 1 : insertionIndex;
|
||||
return {
|
||||
insertionIndex,
|
||||
toDisplayPosition: Math.min(Math.max(0, itemCount - 1), Math.max(0, targetBeforeRemoval)),
|
||||
};
|
||||
}
|
||||
|
||||
export function dataGridColumnLayoutVirtualWindow(options: { itemCount: number; scrollTop: number; viewportHeight?: number }): DataGridColumnLayoutVirtualWindow {
|
||||
const itemCount = Math.max(0, options.itemCount);
|
||||
const viewportHeight = options.viewportHeight ?? DATA_GRID_COLUMN_LAYOUT_VIEWPORT_HEIGHT;
|
||||
const requestedFirstVisibleRow = Math.floor(Math.max(0, options.scrollTop) / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT);
|
||||
const firstVisibleRow = Math.min(Math.max(0, itemCount - 1), requestedFirstVisibleRow);
|
||||
const visibleRowCount = Math.ceil(viewportHeight / DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT);
|
||||
const start = Math.max(0, firstVisibleRow - DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS);
|
||||
const end = Math.min(itemCount, firstVisibleRow + visibleRowCount + DATA_GRID_COLUMN_LAYOUT_BUFFER_ROWS);
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
offsetTop: start * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
|
||||
totalHeight: itemCount * DATA_GRID_COLUMN_LAYOUT_ROW_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
|||
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
|
||||
import { Check, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert, AlignLeft, AlignRight, PanelsTopLeft } from "@lucide/vue";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -21,6 +21,8 @@ import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarAct
|
|||
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
|
||||
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
|
||||
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
|
||||
import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
|
||||
import type { DataGridColumnLayoutHandle } from "@/components/grid/dataGridColumnLayoutPopover";
|
||||
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
|
||||
let dataGridComponentPromise: Promise<typeof import("@/components/grid/DataGrid.vue")> | undefined;
|
||||
function loadDataGridComponent() {
|
||||
|
|
@ -96,20 +98,10 @@ import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
|||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
|
||||
type DataGridHandle = {
|
||||
type DataGridHandle = DataGridColumnLayoutHandle & {
|
||||
onToolbarRefresh: () => Promise<void> | void;
|
||||
focusSearch: () => boolean;
|
||||
openCellDetailSearch: () => boolean;
|
||||
visibleColumnCount: number;
|
||||
displayableColumnCount: number;
|
||||
hiddenColumnCount: number;
|
||||
filteredColumnVisibilityOptions: (search: string) => Array<{ index: number; column: string; comment?: string }>;
|
||||
isColumnVisible: (columnIndex: number) => boolean;
|
||||
toggleColumnVisibility: (columnIndex: number) => void;
|
||||
showAllColumns: () => void;
|
||||
invertColumnVisibility: () => void;
|
||||
hasCustomColumnOrder: boolean;
|
||||
resetColumnOrder: () => void;
|
||||
nullColumnsHidden: boolean;
|
||||
allNullColumnCount: number;
|
||||
canToggleAllNullColumns: boolean;
|
||||
|
|
@ -217,9 +209,7 @@ const tableStructureEditorRef = ref<{ applyChanges: () => Promise<boolean> }>();
|
|||
const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
|
||||
const standaloneResultToolbarWidth = ref(0);
|
||||
const resultTabsScrollerRef = ref<HTMLElement | null>(null);
|
||||
const columnVisibilitySearch = ref("");
|
||||
const dataGridViewOptionsOpen = ref(false);
|
||||
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
||||
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
|
||||
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
|
||||
const resultRunDisplayMode = computed(() => settingsStore.editorSettings.resultRunDisplayMode);
|
||||
|
|
@ -1450,7 +1440,6 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||
:export-file-base-name="activeTab.title"
|
||||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
|
||||
@hidden-column-keys-change="(keys: string[]) => queryStore.updateDataGridHiddenColumnKeys(activeTab.id, keys)"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
|
||||
|
|
@ -1526,55 +1515,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||
</span>
|
||||
<span v-if="activeTab.mode === 'data' && activeTab.tableMeta" class="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums"> {{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }} </span>
|
||||
<span class="ml-auto" />
|
||||
<Popover v-if="activeTab.result?.columns.length">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }">
|
||||
<Columns3 class="h-3.5 w-3.5" />
|
||||
{{ t("grid.columnVisibility") }}
|
||||
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums"> {{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }} </span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-2 py-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
|
||||
<div class="text-[10px] text-muted-foreground tabular-nums">{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input v-model="columnVisibilitySearch" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground" :placeholder="t('grid.searchColumns')" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-0.5">
|
||||
<button v-for="option in columnVisibilityOptions" :key="`${option.index}:${option.column}`" type="button" class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="dataGridRef?.toggleColumnVisibility(option.index)">
|
||||
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="dataGridRef?.isColumnVisible(option.index) ? 'border-primary bg-primary text-primary-foreground' : 'border-border bg-background text-transparent'">
|
||||
<Check class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
|
||||
<span v-if="option.comment" class="block truncate text-[11px] leading-4 text-muted-foreground" :title="option.comment">{{ option.comment }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="columnVisibilityOptions.length === 0" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 border-t bg-muted/30 px-2 py-1.5">
|
||||
<span class="text-[11px] leading-4 text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1" @click="dataGridRef?.invertColumnVisibility()">
|
||||
{{ t("grid.invertColumnVisibility") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="!dataGridRef?.hasCustomColumnOrder" @click="dataGridRef?.resetColumnOrder()">
|
||||
{{ t("grid.resetColumnOrder") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
|
||||
{{ t("grid.showAllColumns") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DataGridColumnLayoutPopover v-if="activeTab.result?.columns.length" :grid="dataGridRef" trigger-class="px-1.5" />
|
||||
<Button v-if="activeTab.result && activeTab.tableMeta && activeTab.connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': dataGridRef?.showDdl }" @click="dataGridRef?.toggleDdl()"
|
||||
><TableProperties class="h-3.5 w-3.5" />{{ t("grid.tableInfo") }}</Button
|
||||
>
|
||||
|
|
@ -1822,7 +1763,6 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||
@update:where-input="(v: string) => (activeTab.whereInput = v)"
|
||||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||||
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
|
||||
@hidden-column-keys-change="(keys: string[]) => queryStore.updateDataGridHiddenColumnKeys(activeTab.id, keys)"
|
||||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
|
||||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { effectScope, nextTick, ref } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dataGridColumnOffsets, dataGridHorizontalColumnWindow, useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
|
||||
describe("useDataGridColumnLayout", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it("builds cumulative offsets", () => {
|
||||
expect(dataGridColumnOffsets([80, 120, 60])).toEqual([0, 80, 200, 260]);
|
||||
});
|
||||
|
|
@ -91,7 +101,7 @@ describe("useDataGridColumnLayout", () => {
|
|||
|
||||
allNullColumnIndexes.value = [2];
|
||||
await nextTick();
|
||||
state.resetColumnVisibility();
|
||||
state.resetColumnVisibility([]);
|
||||
expect(state.visibleColumnIndexes.value).toEqual([0, 1]);
|
||||
|
||||
state.toggleAllNullColumns();
|
||||
|
|
@ -101,7 +111,6 @@ describe("useDataGridColumnLayout", () => {
|
|||
});
|
||||
|
||||
it("restores manually hidden columns after the grid scope is recreated", () => {
|
||||
const hiddenColumnKeys = ref<string[]>([]);
|
||||
const options = {
|
||||
columns: ref(["id", "name", "email"]),
|
||||
sourceColumns: ref(undefined),
|
||||
|
|
@ -111,28 +120,49 @@ describe("useDataGridColumnLayout", () => {
|
|||
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
|
||||
layoutScopeKey: ref("visibility-recreated-layout"),
|
||||
tableScopeKey: ref(""),
|
||||
initialHiddenColumnKeys: hiddenColumnKeys,
|
||||
onHiddenColumnKeysChange: (keys: string[]) => {
|
||||
hiddenColumnKeys.value = keys;
|
||||
},
|
||||
};
|
||||
const firstScope = effectScope();
|
||||
const firstState = firstScope.run(() => useDataGridColumnLayoutState(options))!;
|
||||
|
||||
firstState.toggleColumnVisibility(1);
|
||||
expect(hiddenColumnKeys.value).toEqual(["name\0\0"]);
|
||||
firstScope.stop();
|
||||
|
||||
const recreatedScope = effectScope();
|
||||
const recreatedState = recreatedScope.run(() => useDataGridColumnLayoutState(options))!;
|
||||
expect(recreatedState.visibleColumnIndexes.value).toEqual([0, 2]);
|
||||
recreatedState.showAllColumns();
|
||||
expect(hiddenColumnKeys.value).toEqual([]);
|
||||
recreatedScope.stop();
|
||||
|
||||
expect(JSON.parse(localStorage.getItem("dbx-data-grid-column-layout:visibility-recreated-layout")!)).toMatchObject({ hiddenKeys: [] });
|
||||
});
|
||||
|
||||
it("show all clears hidden keys for fields missing from the current page", () => {
|
||||
const layoutScopeKey = "visibility-missing-field-layout";
|
||||
saveDataGridColumnLayout(layoutScopeKey, {
|
||||
orderKeys: [],
|
||||
hiddenKeys: ["goodsList\0\0"],
|
||||
});
|
||||
const scope = effectScope();
|
||||
const state = scope.run(() =>
|
||||
useDataGridColumnLayoutState({
|
||||
columns: ref(["id", "status"]),
|
||||
sourceColumns: ref(undefined),
|
||||
commentByColumn: ref(new Map()),
|
||||
displayableColumnIndexes: ref([0, 1]),
|
||||
allNullColumnIndexes: ref([]),
|
||||
columnOrderKeys: ref(["id\0\0", "status\0\0"]),
|
||||
layoutScopeKey: ref(layoutScopeKey),
|
||||
tableScopeKey: ref(""),
|
||||
}),
|
||||
)!;
|
||||
|
||||
state.showAllColumns();
|
||||
scope.stop();
|
||||
|
||||
expect(loadDataGridColumnLayout(layoutScopeKey)?.hiddenKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists a null column when it is manually hidden after showing all columns", () => {
|
||||
const onHiddenColumnKeysChange = vi.fn();
|
||||
const scope = effectScope();
|
||||
const state = scope.run(() =>
|
||||
useDataGridColumnLayoutState({
|
||||
|
|
@ -145,7 +175,6 @@ describe("useDataGridColumnLayout", () => {
|
|||
layoutScopeKey: ref("visibility-null-column-layout"),
|
||||
tableScopeKey: ref(""),
|
||||
hideNullColumns: ref(true),
|
||||
onHiddenColumnKeysChange,
|
||||
}),
|
||||
)!;
|
||||
|
||||
|
|
@ -153,7 +182,30 @@ describe("useDataGridColumnLayout", () => {
|
|||
state.showAllColumns();
|
||||
state.toggleColumnVisibility(1);
|
||||
|
||||
expect(onHiddenColumnKeysChange).toHaveBeenLastCalledWith(["empty\0\0"]);
|
||||
scope.stop();
|
||||
expect(JSON.parse(localStorage.getItem("dbx-data-grid-column-layout:visibility-null-column-layout")!)).toMatchObject({ hiddenKeys: ["empty\0\0"] });
|
||||
});
|
||||
|
||||
it("returns ordered layout options with visibility state and reorders hidden fields", () => {
|
||||
const scope = effectScope();
|
||||
const state = scope.run(() =>
|
||||
useDataGridColumnLayoutState({
|
||||
columns: ref(["id", "status", "goodsList"]),
|
||||
sourceColumns: ref(undefined),
|
||||
commentByColumn: ref(new Map([["goodsList", "Line items"]])),
|
||||
displayableColumnIndexes: ref([0, 1, 2]),
|
||||
allNullColumnIndexes: ref([]),
|
||||
columnOrderKeys: ref(["id\0\0", "status\0\0", "goodsList\0\0"]),
|
||||
layoutScopeKey: ref("layout-options"),
|
||||
tableScopeKey: ref(""),
|
||||
}),
|
||||
)!;
|
||||
|
||||
state.toggleColumnVisibility(2);
|
||||
state.moveDisplayableColumn(2, 1);
|
||||
|
||||
expect(state.filteredColumnLayoutOptions("line")).toMatchObject([{ column: "goodsList", visible: false, displayPosition: 1 }]);
|
||||
expect(state.orderedDisplayableColumnIndexes.value).toEqual([0, 2, 1]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { computed, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue";
|
||||
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
|
||||
import { columnOrderKeysForIndexes, isDefaultColumnOrder, mergeUnavailableColumnOrderKeys, moveDisplayableColumnIndex, moveVisibleColumnIndex, orderedColumnIndexes } from "@/lib/dataGrid/dataGridColumnOrder";
|
||||
import { columnHeaderCanvasPointerDisabled, columnHeaderClickShouldBeSuppressed, columnHeaderPreviewOffsetForColumn, columnHeaderTooltipDisabled } from "@/lib/dataGrid/dataGridColumnHeaderInteraction";
|
||||
import {
|
||||
loadDataGridColumnOrder,
|
||||
loadDataGridColumnLayout,
|
||||
loadDataGridColumnFrozenState,
|
||||
loadTableDataGridColumnOrder,
|
||||
notifyTableDataGridColumnOrderChanged,
|
||||
removeDataGridColumnOrder,
|
||||
removeDataGridColumnFrozenCount,
|
||||
removeTableDataGridColumnOrder,
|
||||
saveDataGridColumnOrder,
|
||||
saveDataGridColumnLayout,
|
||||
saveDataGridColumnFrozenCount,
|
||||
saveTableDataGridColumnOrder,
|
||||
type TableDataGridColumnOrderChangedDetail,
|
||||
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { filterColumnVisibilityOptions, hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
|
||||
import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems, type DataGridColumnLookupItem } from "@/lib/dataGrid/dataGridColumnLookup";
|
||||
import { hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
|
||||
|
||||
export type RenderedDataGridColumn = {
|
||||
visibleColIdx: number;
|
||||
|
|
@ -29,6 +29,13 @@ export type DataGridHorizontalColumnWindow = {
|
|||
afterWidth: number;
|
||||
};
|
||||
|
||||
export interface DataGridColumnLayoutOption extends DataGridColumnLookupItem {
|
||||
key: string;
|
||||
column: string;
|
||||
visible: boolean;
|
||||
displayPosition: number;
|
||||
}
|
||||
|
||||
type ColumnHeaderDragState = {
|
||||
sourceVisibleIndex: number;
|
||||
targetVisibleIndex: number;
|
||||
|
|
@ -78,7 +85,6 @@ export function useDataGridColumnLayoutState(options: {
|
|||
tableScopeKey: MaybeRefOrGetter<string>;
|
||||
initialHiddenColumnKeys?: MaybeRefOrGetter<readonly string[] | undefined>;
|
||||
hideNullColumns?: MaybeRefOrGetter<boolean>;
|
||||
onHiddenColumnKeysChange?: (keys: string[]) => void;
|
||||
onHideNullColumnsChange?: (value: boolean) => void;
|
||||
onRefreshMetrics?: () => void;
|
||||
}) {
|
||||
|
|
@ -87,9 +93,12 @@ export function useDataGridColumnLayoutState(options: {
|
|||
const nullColumnsHidden = computed(() => (options.hideNullColumns === undefined ? localNullColumnsHidden.value : toValue(options.hideNullColumns)));
|
||||
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
|
||||
const persistedColumnOrderKeys = ref<string[]>([]);
|
||||
const persistedHiddenColumnKeys = ref<string[]>([...(toValue(options.initialHiddenColumnKeys) ?? [])]);
|
||||
const frozenColumnCount = ref(0);
|
||||
// 冻结选中列前的列序快照,取消冻结时还原
|
||||
const columnOrderSnapshotBeforeFreeze = ref<string[] | null>(null);
|
||||
let columnLayoutPersistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let columnLayoutPersistPending = false;
|
||||
let pendingColumnLayoutScopeKey = "";
|
||||
const orderedDisplayableColumnIndexes = computed(() => orderedColumnIndexes({ availableIndexes: toValue(options.displayableColumnIndexes), columnKeys: toValue(options.columnOrderKeys), orderedKeys: persistedColumnOrderKeys.value }));
|
||||
const visibleColumnIndexes = computed(() => visibleColumnIndexesForFilter(orderedDisplayableColumnIndexes.value, hiddenColumnIndexes.value));
|
||||
const displayableColumnCount = computed(() => toValue(options.displayableColumnIndexes).length);
|
||||
|
|
@ -97,17 +106,77 @@ export function useDataGridColumnLayoutState(options: {
|
|||
const allNullColumnCount = computed(() => toValue(options.allNullColumnIndexes).length);
|
||||
const hasCustomColumnOrder = computed(() => !isDefaultColumnOrder(toValue(options.displayableColumnIndexes), orderedDisplayableColumnIndexes.value));
|
||||
const canToggleAllNullColumns = computed(() => nullColumnsHidden.value || (toValue(options.allNullColumnIndexes).length > 0 && displayableColumnCount.value > 1));
|
||||
const columnLookupItems = computed(() =>
|
||||
buildDataGridColumnLookupItems({
|
||||
columns: toValue(options.columns),
|
||||
sourceColumns: toValue(options.sourceColumns),
|
||||
displayableIndexes: toValue(options.displayableColumnIndexes),
|
||||
commentByColumn: toValue(options.commentByColumn),
|
||||
}),
|
||||
);
|
||||
const columnLookupItemByIndex = computed(() => new Map(columnLookupItems.value.map((item) => [item.index, item])));
|
||||
const orderedColumnLayoutOptions = computed<DataGridColumnLayoutOption[]>(() =>
|
||||
orderedDisplayableColumnIndexes.value.flatMap((columnIndex, displayPosition) => {
|
||||
const item = columnLookupItemByIndex.value.get(columnIndex);
|
||||
const key = toValue(options.columnOrderKeys)[columnIndex];
|
||||
if (!item || !key) return [];
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
key,
|
||||
column: item.name,
|
||||
visible: !hiddenColumnIndexes.value.has(columnIndex),
|
||||
displayPosition,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
function filteredColumnVisibilityOptions(query: string) {
|
||||
const displayable = new Set(toValue(options.displayableColumnIndexes));
|
||||
return filterColumnVisibilityOptions(toValue(options.columns), query, { sourceColumns: toValue(options.sourceColumns), commentByColumn: toValue(options.commentByColumn) }).filter((option) => displayable.has(option.index));
|
||||
function filteredColumnLayoutOptions(query: string): DataGridColumnLayoutOption[] {
|
||||
return filterDataGridColumnLookupItems(orderedColumnLayoutOptions.value, query);
|
||||
}
|
||||
function isColumnVisible(columnIndex: number) {
|
||||
return !hiddenColumnIndexes.value.has(columnIndex);
|
||||
}
|
||||
function persistHiddenColumnKeys() {
|
||||
options.onHiddenColumnKeysChange?.(hiddenColumnKeysForIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value, toValue(options.columnOrderKeys), toValue(options.displayableColumnIndexes)));
|
||||
|
||||
function flushPersistColumnLayout() {
|
||||
if (!columnLayoutPersistPending) return;
|
||||
if (columnLayoutPersistTimer !== undefined) clearTimeout(columnLayoutPersistTimer);
|
||||
columnLayoutPersistTimer = undefined;
|
||||
columnLayoutPersistPending = false;
|
||||
saveDataGridColumnLayout(pendingColumnLayoutScopeKey || toValue(options.layoutScopeKey), {
|
||||
orderKeys: persistedColumnOrderKeys.value,
|
||||
hiddenKeys: persistedHiddenColumnKeys.value,
|
||||
});
|
||||
}
|
||||
|
||||
function markColumnLayoutForPersistence() {
|
||||
columnLayoutPersistPending = true;
|
||||
pendingColumnLayoutScopeKey = toValue(options.layoutScopeKey);
|
||||
}
|
||||
|
||||
function schedulePersistColumnLayout() {
|
||||
markColumnLayoutForPersistence();
|
||||
if (columnLayoutPersistTimer !== undefined) clearTimeout(columnLayoutPersistTimer);
|
||||
columnLayoutPersistTimer = setTimeout(flushPersistColumnLayout, 100);
|
||||
}
|
||||
|
||||
function persistColumnLayoutImmediately() {
|
||||
markColumnLayoutForPersistence();
|
||||
flushPersistColumnLayout();
|
||||
}
|
||||
|
||||
function currentManualHiddenColumnKeys() {
|
||||
return hiddenColumnKeysForIndexes(hiddenColumnIndexes.value, autoHiddenNullColumnIndexes.value, toValue(options.columnOrderKeys), toValue(options.displayableColumnIndexes));
|
||||
}
|
||||
|
||||
function persistHiddenColumnKeys() {
|
||||
const currentKeys = new Set(toValue(options.displayableColumnIndexes).flatMap((index) => toValue(options.columnOrderKeys)[index] ?? []));
|
||||
const unavailableHiddenKeys = persistedHiddenColumnKeys.value.filter((key) => !currentKeys.has(key));
|
||||
persistedHiddenColumnKeys.value = [...new Set([...unavailableHiddenKeys, ...currentManualHiddenColumnKeys()])];
|
||||
schedulePersistColumnLayout();
|
||||
}
|
||||
|
||||
function toggleColumnVisibility(columnIndex: number) {
|
||||
hiddenColumnIndexes.value = nextHiddenColumnIndexes({ columnIndex, hiddenIndexes: hiddenColumnIndexes.value, totalColumns: displayableColumnCount.value });
|
||||
if (!hiddenColumnIndexes.value.has(columnIndex) && autoHiddenNullColumnIndexes.value.delete(columnIndex)) {
|
||||
|
|
@ -115,16 +184,20 @@ export function useDataGridColumnLayoutState(options: {
|
|||
}
|
||||
persistHiddenColumnKeys();
|
||||
}
|
||||
|
||||
function showAllColumns() {
|
||||
hiddenColumnIndexes.value = new Set();
|
||||
autoHiddenNullColumnIndexes.value = new Set();
|
||||
persistHiddenColumnKeys();
|
||||
persistedHiddenColumnKeys.value = [];
|
||||
schedulePersistColumnLayout();
|
||||
}
|
||||
|
||||
function invertColumnVisibility() {
|
||||
hiddenColumnIndexes.value = invertedHiddenColumnIndexes([...toValue(options.displayableColumnIndexes)], hiddenColumnIndexes.value);
|
||||
autoHiddenNullColumnIndexes.value = new Set();
|
||||
persistHiddenColumnKeys();
|
||||
}
|
||||
|
||||
function showColumn(columnIndex: number) {
|
||||
if (!hiddenColumnIndexes.value.has(columnIndex)) return;
|
||||
hiddenColumnIndexes.value.delete(columnIndex);
|
||||
|
|
@ -133,11 +206,18 @@ export function useDataGridColumnLayoutState(options: {
|
|||
autoHiddenNullColumnIndexes.value = new Set(autoHiddenNullColumnIndexes.value);
|
||||
persistHiddenColumnKeys();
|
||||
}
|
||||
function loadColumnOrder() {
|
||||
|
||||
function loadColumnLayout() {
|
||||
flushPersistColumnLayout();
|
||||
const storedLayout = loadDataGridColumnLayout(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
const tableOrder = tableScopeKey ? loadTableDataGridColumnOrder(tableScopeKey) : [];
|
||||
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : loadDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys));
|
||||
persistedColumnOrderKeys.value = tableOrder.length ? tableOrder : (storedLayout?.orderKeys ?? []);
|
||||
persistedHiddenColumnKeys.value = storedLayout?.hiddenKeys ?? [...(toValue(options.initialHiddenColumnKeys) ?? [])];
|
||||
resetColumnVisibility();
|
||||
if (!storedLayout && persistedHiddenColumnKeys.value.length > 0) schedulePersistColumnLayout();
|
||||
}
|
||||
|
||||
function loadFrozenColumnCount() {
|
||||
const state = loadDataGridColumnFrozenState(toValue(options.layoutScopeKey));
|
||||
frozenColumnCount.value = Math.min(state.frozenCount, visibleColumnIndexes.value.length);
|
||||
|
|
@ -159,24 +239,21 @@ export function useDataGridColumnLayoutState(options: {
|
|||
if (selectedVisibleColIdxs.length === 0) return;
|
||||
const sorted = [...selectedVisibleColIdxs].sort((a, b) => a - b);
|
||||
const visibleIdxs = visibleColumnIndexes.value;
|
||||
// 获取选中列的 actualColIdx
|
||||
const selectedActualIdxs = sorted.map((vIdx) => visibleIdxs[vIdx]).filter((idx): idx is number => idx !== undefined);
|
||||
if (selectedActualIdxs.length === 0) return;
|
||||
const selectedSet = new Set(selectedActualIdxs);
|
||||
// 非选中列保持原序
|
||||
const currentOrder = orderedDisplayableColumnIndexes.value;
|
||||
const nonSelectedActualIdxs = currentOrder.filter((idx) => !selectedSet.has(idx));
|
||||
// 保存冻结前的列序快照(仅在首次冻结时保存,避免连续冻结覆盖快照)
|
||||
// 首次冻结时保留原序,连续冻结不能覆盖用户真正的起始顺序。
|
||||
if (columnOrderSnapshotBeforeFreeze.value === null) {
|
||||
columnOrderSnapshotBeforeFreeze.value = [...persistedColumnOrderKeys.value];
|
||||
}
|
||||
// 新列序:选中列在前 + 非选中列在后
|
||||
persistColumnOrder([...selectedActualIdxs, ...nonSelectedActualIdxs]);
|
||||
setFrozenColumnCount(selectedActualIdxs.length);
|
||||
}
|
||||
|
||||
function unfreezeAllColumns() {
|
||||
setFrozenColumnCount(0);
|
||||
// 还原冻结前的列序
|
||||
if (columnOrderSnapshotBeforeFreeze.value !== null) {
|
||||
const snapshot = columnOrderSnapshotBeforeFreeze.value;
|
||||
columnOrderSnapshotBeforeFreeze.value = null;
|
||||
|
|
@ -184,7 +261,7 @@ export function useDataGridColumnLayoutState(options: {
|
|||
resetColumnOrder();
|
||||
} else {
|
||||
persistedColumnOrderKeys.value = snapshot;
|
||||
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), snapshot);
|
||||
persistColumnLayoutImmediately();
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
if (tableScopeKey) {
|
||||
saveTableDataGridColumnOrder(tableScopeKey, snapshot);
|
||||
|
|
@ -193,35 +270,48 @@ export function useDataGridColumnLayoutState(options: {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistColumnOrder(indexes: number[]) {
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
if (isDefaultColumnOrder(toValue(options.displayableColumnIndexes), indexes)) {
|
||||
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
|
||||
persistedColumnOrderKeys.value = [];
|
||||
persistColumnLayoutImmediately();
|
||||
if (tableScopeKey) {
|
||||
removeTableDataGridColumnOrder(tableScopeKey);
|
||||
notifyTableDataGridColumnOrderChanged(tableScopeKey);
|
||||
}
|
||||
persistedColumnOrderKeys.value = [];
|
||||
return;
|
||||
}
|
||||
const keys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
|
||||
const currentKeys = columnOrderKeysForIndexes(indexes, toValue(options.columnOrderKeys));
|
||||
const keys = mergeUnavailableColumnOrderKeys(currentKeys, persistedColumnOrderKeys.value);
|
||||
persistedColumnOrderKeys.value = keys;
|
||||
saveDataGridColumnOrder(toValue(options.layoutScopeKey), toValue(options.columnOrderKeys), keys);
|
||||
persistColumnLayoutImmediately();
|
||||
if (tableScopeKey) {
|
||||
saveTableDataGridColumnOrder(tableScopeKey, keys);
|
||||
notifyTableDataGridColumnOrderChanged(tableScopeKey);
|
||||
}
|
||||
}
|
||||
|
||||
function moveDisplayableColumn(fromDisplayableIndex: number, toDisplayableIndex: number) {
|
||||
const next = moveDisplayableColumnIndex({
|
||||
orderedIndexes: orderedDisplayableColumnIndexes.value,
|
||||
fromDisplayableIndex,
|
||||
toDisplayableIndex,
|
||||
});
|
||||
persistColumnOrder(next);
|
||||
}
|
||||
|
||||
function resetColumnOrder() {
|
||||
removeDataGridColumnOrder(toValue(options.layoutScopeKey));
|
||||
persistedColumnOrderKeys.value = [];
|
||||
persistColumnLayoutImmediately();
|
||||
const tableScopeKey = toValue(options.tableScopeKey);
|
||||
if (tableScopeKey) {
|
||||
removeTableDataGridColumnOrder(tableScopeKey);
|
||||
notifyTableDataGridColumnOrderChanged(tableScopeKey);
|
||||
}
|
||||
persistedColumnOrderKeys.value = [];
|
||||
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
|
||||
}
|
||||
|
||||
function setNullColumnsHidden(value: boolean) {
|
||||
if (options.hideNullColumns === undefined) localNullColumnsHidden.value = value;
|
||||
else options.onHideNullColumnsChange?.(value);
|
||||
|
|
@ -250,16 +340,17 @@ export function useDataGridColumnLayoutState(options: {
|
|||
if (!(event instanceof CustomEvent)) return;
|
||||
const detail = event.detail as TableDataGridColumnOrderChangedDetail | undefined;
|
||||
if (!detail || detail.scopeKey !== toValue(options.tableScopeKey)) return;
|
||||
loadColumnOrder();
|
||||
persistedColumnOrderKeys.value = loadTableDataGridColumnOrder(detail.scopeKey);
|
||||
if (options.onRefreshMetrics) nextTick(options.onRefreshMetrics);
|
||||
}
|
||||
|
||||
function resetColumnVisibility(hiddenColumnKeys: readonly string[] = []) {
|
||||
function resetColumnVisibility(hiddenColumnKeys: readonly string[] = persistedHiddenColumnKeys.value) {
|
||||
hiddenColumnIndexes.value = hiddenColumnIndexesForKeys(hiddenColumnKeys, toValue(options.columnOrderKeys), toValue(options.displayableColumnIndexes));
|
||||
autoHiddenNullColumnIndexes.value = new Set();
|
||||
applyNullColumnVisibility(nullColumnsHidden.value);
|
||||
}
|
||||
|
||||
onScopeDispose(flushPersistColumnLayout);
|
||||
watch([() => nullColumnsHidden.value, () => [...toValue(options.allNullColumnIndexes)], () => [...toValue(options.displayableColumnIndexes)]], ([hidden]) => applyNullColumnVisibility(hidden as boolean), { immediate: true });
|
||||
watch(
|
||||
() => visibleColumnIndexes.value.length,
|
||||
|
|
@ -271,11 +362,12 @@ export function useDataGridColumnLayoutState(options: {
|
|||
watch(
|
||||
[() => toValue(options.layoutScopeKey), () => toValue(options.tableScopeKey)],
|
||||
() => {
|
||||
loadColumnOrder();
|
||||
loadColumnLayout();
|
||||
loadFrozenColumnCount();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch([() => [...toValue(options.columnOrderKeys)], () => [...toValue(options.displayableColumnIndexes)]], () => resetColumnVisibility(), { flush: "sync" });
|
||||
|
||||
return {
|
||||
hiddenColumnIndexes,
|
||||
|
|
@ -287,13 +379,15 @@ export function useDataGridColumnLayoutState(options: {
|
|||
allNullColumnCount,
|
||||
hasCustomColumnOrder,
|
||||
canToggleAllNullColumns,
|
||||
filteredColumnVisibilityOptions,
|
||||
orderedColumnLayoutOptions,
|
||||
filteredColumnLayoutOptions,
|
||||
isColumnVisible,
|
||||
toggleColumnVisibility,
|
||||
showAllColumns,
|
||||
invertColumnVisibility,
|
||||
showColumn,
|
||||
persistColumnOrder,
|
||||
moveDisplayableColumn,
|
||||
resetColumnOrder,
|
||||
toggleAllNullColumns,
|
||||
resetColumnVisibility,
|
||||
|
|
|
|||
|
|
@ -1216,6 +1216,8 @@ export default {
|
|||
localFilterMoreValues: " +{count} more",
|
||||
columnVisibility: "Columns",
|
||||
columnVisibilityHint: "At least one column stays visible.",
|
||||
columnReorderHint: "Drag to reorder columns.",
|
||||
columnReorderSearchHint: "Clear the search to reorder columns.",
|
||||
searchColumns: "Search columns...",
|
||||
dragColumnToReorder: "Drag column header to reorder",
|
||||
invertColumnVisibility: "Invert",
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: " +{count} más",
|
||||
columnVisibility: "Columnas",
|
||||
columnVisibilityHint: "Al menos una columna permanece visible.",
|
||||
columnReorderHint: "Arrastra para reordenar las columnas.",
|
||||
columnReorderSearchHint: "Borra la búsqueda para reordenar las columnas.",
|
||||
searchColumns: "Buscar columnas...",
|
||||
dragColumnToReorder: "Arrastra el encabezado para reordenar",
|
||||
invertColumnVisibility: "Invertir",
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: " +{count} altri",
|
||||
columnVisibility: "Colonne",
|
||||
columnVisibilityHint: "Almeno una colonna deve rimanere visibile.",
|
||||
columnReorderHint: "Trascina per riordinare le colonne.",
|
||||
columnReorderSearchHint: "Cancella la ricerca per riordinare le colonne.",
|
||||
searchColumns: "Cerca colonne...",
|
||||
dragColumnToReorder: "Trascina l'intestazione per riordinare",
|
||||
invertColumnVisibility: "Inverti",
|
||||
|
|
|
|||
|
|
@ -1108,6 +1108,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: " +{count}件以上",
|
||||
columnVisibility: "列",
|
||||
columnVisibilityHint: "少なくとも1列は表示されます。",
|
||||
columnReorderHint: "ドラッグして列の順序を変更します。",
|
||||
columnReorderSearchHint: "列の順序を変更するには検索をクリアしてください。",
|
||||
searchColumns: "列を検索...",
|
||||
dragColumnToReorder: "列ヘッダーをドラッグして並べ替え",
|
||||
invertColumnVisibility: "反転",
|
||||
|
|
|
|||
|
|
@ -1200,6 +1200,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: " 외 {count}개",
|
||||
columnVisibility: "컬럼",
|
||||
columnVisibilityHint: "최소 한 개의 컬럼은 표시 상태를 유지합니다.",
|
||||
columnReorderHint: "드래그하여 컬럼 순서를 변경합니다.",
|
||||
columnReorderSearchHint: "컬럼 순서를 변경하려면 검색을 지우세요.",
|
||||
searchColumns: "컬럼 검색...",
|
||||
dragColumnToReorder: "컬럼 머리글을 드래그하여 순서 변경",
|
||||
invertColumnVisibility: "반전",
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: " +{count} mais",
|
||||
columnVisibility: "Colunas",
|
||||
columnVisibilityHint: "Pelo menos uma coluna permanece visível.",
|
||||
columnReorderHint: "Arraste para reordenar as colunas.",
|
||||
columnReorderSearchHint: "Limpe a pesquisa para reordenar as colunas.",
|
||||
searchColumns: "Pesquisar colunas...",
|
||||
dragColumnToReorder: "Arraste o cabeçalho para reordenar",
|
||||
invertColumnVisibility: "Inverter",
|
||||
|
|
|
|||
|
|
@ -1217,6 +1217,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: ",还有 {count} 个",
|
||||
columnVisibility: "字段筛选",
|
||||
columnVisibilityHint: "至少保留一列可见。",
|
||||
columnReorderHint: "拖动可调整字段顺序。",
|
||||
columnReorderSearchHint: "清除搜索后可调整字段顺序。",
|
||||
searchColumns: "搜索字段...",
|
||||
dragColumnToReorder: "拖拽列头调整顺序",
|
||||
invertColumnVisibility: "反选",
|
||||
|
|
|
|||
|
|
@ -1112,6 +1112,8 @@ export default withEnglishFallback({
|
|||
localFilterMoreValues: ",還有 {count} 個",
|
||||
columnVisibility: "欄位篩選",
|
||||
columnVisibilityHint: "至少保留一欄可見。",
|
||||
columnReorderHint: "拖曳可調整欄位順序。",
|
||||
columnReorderSearchHint: "清除搜尋後可調整欄位順序。",
|
||||
searchColumns: "搜尋欄位……",
|
||||
dragColumnToReorder: "拖曳欄位標題調整順序",
|
||||
invertColumnVisibility: "反選",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadTableDataGridColumnOrder, notifyTableDataGridColumnOrderChanged, removeTableDataGridColumnOrder, saveTableDataGridColumnOrder, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import {
|
||||
documentDataGridColumnLayoutScopeKey,
|
||||
loadDataGridColumnLayout,
|
||||
loadDataGridColumnOrder,
|
||||
loadTableDataGridColumnOrder,
|
||||
notifyTableDataGridColumnOrderChanged,
|
||||
removeDataGridColumnOrder,
|
||||
removeTableDataGridColumnOrder,
|
||||
saveDataGridColumnLayout,
|
||||
saveTableDataGridColumnOrder,
|
||||
TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT,
|
||||
tableDataGridColumnOrderScopeKey,
|
||||
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
|
|
@ -57,3 +69,55 @@ describe("table data grid column order storage", () => {
|
|||
expect(event.detail).toEqual({ scopeKey: "table-scope" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("data grid column layout storage", () => {
|
||||
beforeEach(installLocalStorage);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("uses a stable document scope without query or result column signatures", () => {
|
||||
expect(
|
||||
documentDataGridColumnLayoutScopeKey({
|
||||
databaseType: "elasticsearch",
|
||||
connectionId: "connection-1",
|
||||
database: "database-name",
|
||||
collection: "order_index_v1",
|
||||
}),
|
||||
).toBe(["document", "elasticsearch", "connection-1", "database-name", "order_index_v1"].join("\u0001"));
|
||||
});
|
||||
|
||||
it("stores order and hidden keys without dropping fields absent from the current page", () => {
|
||||
const layout = {
|
||||
orderKeys: ["_id", "status", "orderNo", "goodsList"],
|
||||
hiddenKeys: ["goodsList"],
|
||||
};
|
||||
|
||||
saveDataGridColumnLayout("document-layout", layout);
|
||||
|
||||
expect(loadDataGridColumnLayout("document-layout", ["_id", "orderNo", "status", "createTime"])).toEqual(layout);
|
||||
});
|
||||
|
||||
it("keeps hidden keys when only the saved order is reset", () => {
|
||||
saveDataGridColumnLayout("combined-layout", { orderKeys: ["status", "_id"], hiddenKeys: ["goodsList"] });
|
||||
|
||||
removeDataGridColumnOrder("combined-layout");
|
||||
|
||||
expect(loadDataGridColumnOrder("combined-layout", [])).toEqual([]);
|
||||
expect(loadDataGridColumnLayout("combined-layout")).toEqual({ orderKeys: [], hiddenKeys: ["goodsList"] });
|
||||
});
|
||||
|
||||
it("loads the previous order-only payload format", () => {
|
||||
localStorage.setItem(
|
||||
"dbx-data-grid-column-layout:legacy-layout",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
columnSignature: "id\0name",
|
||||
order: ["name", "id"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadDataGridColumnLayout("legacy-layout", ["id", "name"])).toEqual({
|
||||
orderKeys: ["name", "id"],
|
||||
hiddenKeys: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
83
apps/desktop/src/lib/__tests__/document/documentGridColumnVisibilityStorage.spec.ts
vendored
Normal file
83
apps/desktop/src/lib/__tests__/document/documentGridColumnVisibilityStorage.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { documentGridColumnVisibilityScopeKey, loadDocumentGridHiddenColumnKeys, migrateDocumentGridColumnVisibilityToLayout, saveDocumentGridHiddenColumnKeys } from "@/lib/document/documentGridColumnVisibilityStorage";
|
||||
|
||||
const STORAGE_PREFIX = "dbx-document-grid-column-visibility:v1:";
|
||||
let storedValues: Map<string, string>;
|
||||
|
||||
function installLocalStorage() {
|
||||
storedValues = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => storedValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storedValues.set(key, value),
|
||||
removeItem: (key: string) => storedValues.delete(key),
|
||||
});
|
||||
}
|
||||
|
||||
function scopeKey(collection: string) {
|
||||
return documentGridColumnVisibilityScopeKey({
|
||||
databaseType: "elasticsearch",
|
||||
connectionId: "connection-1",
|
||||
database: "",
|
||||
collection,
|
||||
});
|
||||
}
|
||||
|
||||
describe("document grid column visibility storage", () => {
|
||||
beforeEach(installLocalStorage);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("stores normalized hidden field names", () => {
|
||||
const ordersScope = scopeKey("orders");
|
||||
|
||||
saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList", "logisticsInfo", "goodsList"]);
|
||||
|
||||
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual(["goodsList", "logisticsInfo"]);
|
||||
});
|
||||
|
||||
it("isolates indexes and removes the persisted selection when all fields are shown", () => {
|
||||
const ordersScope = scopeKey("orders");
|
||||
const productsScope = scopeKey("products");
|
||||
saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList"]);
|
||||
|
||||
expect(loadDocumentGridHiddenColumnKeys(productsScope)).toEqual([]);
|
||||
|
||||
saveDocumentGridHiddenColumnKeys(ordersScope, []);
|
||||
|
||||
expect(storedValues.has(`${STORAGE_PREFIX}${ordersScope}`)).toBe(false);
|
||||
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores invalid persisted values without hiding unrelated fields", () => {
|
||||
const ordersScope = scopeKey("orders");
|
||||
storedValues.set(`${STORAGE_PREFIX}${ordersScope}`, JSON.stringify(["goodsList", 42, "goodsList", null]));
|
||||
|
||||
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual(["goodsList"]);
|
||||
});
|
||||
|
||||
it("reports malformed persisted JSON and falls back to all fields visible", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const ordersScope = scopeKey("orders");
|
||||
storedValues.set(`${STORAGE_PREFIX}${ordersScope}`, "{");
|
||||
|
||||
expect(loadDocumentGridHiddenColumnKeys(ordersScope)).toEqual([]);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("[DBX][document-grid-column-visibility:parse]"), expect.any(SyntaxError));
|
||||
});
|
||||
|
||||
it("migrates legacy hidden fields into the unified layout once", () => {
|
||||
const ordersScope = scopeKey("orders");
|
||||
saveDocumentGridHiddenColumnKeys(ordersScope, ["goodsList"]);
|
||||
|
||||
migrateDocumentGridColumnVisibilityToLayout(ordersScope, "document-layout");
|
||||
migrateDocumentGridColumnVisibilityToLayout(ordersScope, "document-layout");
|
||||
|
||||
expect(loadDataGridColumnLayout("document-layout")).toEqual({
|
||||
orderKeys: [],
|
||||
hiddenKeys: ["goodsList"],
|
||||
});
|
||||
expect(storedValues.has(`${STORAGE_PREFIX}${ordersScope}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const STORAGE_PREFIX = "dbx-data-grid-column-layout:";
|
||||
const TABLE_STORAGE_PREFIX = "dbx-data-grid-table-column-order:";
|
||||
|
|
@ -22,12 +23,28 @@ export interface DataGridColumnLayoutScope {
|
|||
sourceColumns?: readonly (string | undefined)[];
|
||||
}
|
||||
|
||||
interface StoredDataGridColumnLayout {
|
||||
export interface DataGridColumnLayout {
|
||||
orderKeys: string[];
|
||||
hiddenKeys: string[];
|
||||
}
|
||||
|
||||
interface StoredDataGridColumnLayout extends DataGridColumnLayout {
|
||||
version: number;
|
||||
columnSignature: string;
|
||||
}
|
||||
|
||||
interface LegacyStoredDataGridColumnLayout {
|
||||
version: number;
|
||||
columnSignature?: string;
|
||||
order: string[];
|
||||
}
|
||||
|
||||
export interface DocumentDataGridColumnLayoutScope {
|
||||
databaseType: DatabaseType;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
}
|
||||
|
||||
export interface TableDataGridColumnOrderScope {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
|
|
@ -50,30 +67,61 @@ export function dataGridColumnLayoutScopeKey(scope: DataGridColumnLayoutScope):
|
|||
return [scope.connectionId ?? "", scope.database ?? "", scope.schema ?? "", scope.context ?? "", scope.tableSchema ?? "", scope.tableName ?? "", scope.tableName ? "" : normalizeSql(scope.sql), columnSignature, sourceSignature].join("\u0001");
|
||||
}
|
||||
|
||||
export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
|
||||
export function documentDataGridColumnLayoutScopeKey(scope: DocumentDataGridColumnLayoutScope): string {
|
||||
return ["document", scope.databaseType, scope.connectionId, scope.database, scope.collection].join("\u0001");
|
||||
}
|
||||
|
||||
function normalizedStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.filter((key): key is string => typeof key === "string"))];
|
||||
}
|
||||
|
||||
export function loadDataGridColumnLayout(scopeKey: string, columnKeys: readonly string[] = []): DataGridColumnLayout | null {
|
||||
const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
|
||||
if (!raw) return [];
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<StoredDataGridColumnLayout>;
|
||||
if (parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.order)) return [];
|
||||
if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return [];
|
||||
return parsed.order.filter((key): key is string => typeof key === "string");
|
||||
} catch {
|
||||
return [];
|
||||
const parsed = JSON.parse(raw) as Partial<StoredDataGridColumnLayout & LegacyStoredDataGridColumnLayout>;
|
||||
if (parsed.version !== STORAGE_VERSION) return null;
|
||||
if (Array.isArray(parsed.orderKeys) || Array.isArray(parsed.hiddenKeys)) {
|
||||
return {
|
||||
orderKeys: normalizedStringList(parsed.orderKeys),
|
||||
hiddenKeys: normalizedStringList(parsed.hiddenKeys),
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(parsed.order)) return null;
|
||||
if (parsed.columnSignature && parsed.columnSignature !== columnKeys.join("\0")) return null;
|
||||
return { orderKeys: normalizedStringList(parsed.order), hiddenKeys: [] };
|
||||
} catch (error) {
|
||||
console.warn(`[DBX][data-grid-column-layout:parse] ${scopeKey}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
|
||||
export function saveDataGridColumnLayout(scopeKey: string, layout: DataGridColumnLayout) {
|
||||
const payload: StoredDataGridColumnLayout = {
|
||||
version: STORAGE_VERSION,
|
||||
columnSignature: columnKeys.join("\0"),
|
||||
order: [...order],
|
||||
orderKeys: normalizedStringList(layout.orderKeys),
|
||||
hiddenKeys: normalizedStringList(layout.hiddenKeys),
|
||||
};
|
||||
safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function loadDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[]): string[] {
|
||||
return loadDataGridColumnLayout(scopeKey, columnKeys)?.orderKeys ?? [];
|
||||
}
|
||||
|
||||
export function saveDataGridColumnOrder(scopeKey: string, columnKeys: readonly string[], order: readonly string[]) {
|
||||
const hiddenKeys = loadDataGridColumnLayout(scopeKey, columnKeys)?.hiddenKeys ?? [];
|
||||
saveDataGridColumnLayout(scopeKey, { orderKeys: [...order], hiddenKeys });
|
||||
}
|
||||
|
||||
export function removeDataGridColumnOrder(scopeKey: string) {
|
||||
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
|
||||
const layout = loadDataGridColumnLayout(scopeKey);
|
||||
if (!layout) {
|
||||
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
|
||||
return;
|
||||
}
|
||||
saveDataGridColumnLayout(scopeKey, { orderKeys: [], hiddenKeys: layout.hiddenKeys });
|
||||
}
|
||||
|
||||
export function tableDataGridColumnOrderScopeKey(scope: TableDataGridColumnOrderScope): string {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function buildDataGridColumnLookupItems(options: DataGridColumnLookupOpti
|
|||
});
|
||||
}
|
||||
|
||||
export function filterDataGridColumnLookupItems(items: readonly DataGridColumnLookupItem[], query: string): DataGridColumnLookupItem[] {
|
||||
export function filterDataGridColumnLookupItems<ColumnLookupItem extends DataGridColumnLookupItem>(items: readonly ColumnLookupItem[], query: string): ColumnLookupItem[] {
|
||||
const normalizedQuery = normalizedSearchText(query);
|
||||
if (!normalizedQuery) return [...items];
|
||||
return items.filter((item) => [item.name, item.sourceName, item.comment].filter((value): value is string => !!value).some((value) => value.toLocaleLowerCase().includes(normalizedQuery)));
|
||||
|
|
|
|||
|
|
@ -57,6 +57,48 @@ export function moveVisibleColumnIndex(options: { orderedIndexes: readonly numbe
|
|||
return next;
|
||||
}
|
||||
|
||||
export function moveDisplayableColumnIndex(options: { orderedIndexes: readonly number[]; fromDisplayableIndex: number; toDisplayableIndex: number }): number[] {
|
||||
const next = [...options.orderedIndexes];
|
||||
if (next.length === 0) return next;
|
||||
|
||||
const from = Math.max(0, Math.min(options.fromDisplayableIndex, next.length - 1));
|
||||
const to = Math.max(0, Math.min(options.toDisplayableIndex, next.length - 1));
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved === undefined) return next;
|
||||
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeUnavailableColumnOrderKeys(currentKeys: readonly string[], previousKeys: readonly string[]): string[] {
|
||||
const normalizedCurrentKeys = [...new Set(currentKeys)];
|
||||
const currentKeySet = new Set(normalizedCurrentKeys);
|
||||
const missingBeforeCurrent = new Set<string>();
|
||||
const missingAfterCurrent = new Map<string, Set<string>>();
|
||||
let precedingCurrentKey: string | undefined;
|
||||
|
||||
for (const key of previousKeys) {
|
||||
if (currentKeySet.has(key)) {
|
||||
precedingCurrentKey = key;
|
||||
continue;
|
||||
}
|
||||
if (!precedingCurrentKey) {
|
||||
missingBeforeCurrent.add(key);
|
||||
continue;
|
||||
}
|
||||
const anchoredKeys = missingAfterCurrent.get(precedingCurrentKey) ?? new Set<string>();
|
||||
anchoredKeys.add(key);
|
||||
missingAfterCurrent.set(precedingCurrentKey, anchoredKeys);
|
||||
}
|
||||
|
||||
const mergedKeys = [...missingBeforeCurrent];
|
||||
for (const key of normalizedCurrentKeys) {
|
||||
mergedKeys.push(key);
|
||||
mergedKeys.push(...(missingAfterCurrent.get(key) ?? []));
|
||||
}
|
||||
return mergedKeys;
|
||||
}
|
||||
|
||||
export function columnOrderKeysForIndexes(indexes: readonly number[], columnKeys: readonly string[]): string[] {
|
||||
return indexes.map((index) => columnKeys[index]).filter((key): key is string => !!key);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { loadDataGridColumnLayout, saveDataGridColumnLayout } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
|
||||
const STORAGE_PREFIX = "dbx-document-grid-column-visibility:v1:";
|
||||
|
||||
export interface DocumentGridColumnVisibilityScope {
|
||||
databaseType?: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
}
|
||||
|
||||
export function documentGridColumnVisibilityScopeKey(scope: DocumentGridColumnVisibilityScope): string {
|
||||
return ["document-column-visibility", scope.databaseType ?? "", scope.connectionId, scope.database, scope.collection].join("\u0001");
|
||||
}
|
||||
|
||||
export function loadDocumentGridHiddenColumnKeys(scopeKey: string): string[] {
|
||||
const raw = safeLocalStorageGet(`${STORAGE_PREFIX}${scopeKey}`);
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return [...new Set(parsed.filter((key): key is string => typeof key === "string"))];
|
||||
} catch (error) {
|
||||
console.warn(`[DBX][document-grid-column-visibility:parse] ${scopeKey}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDocumentGridHiddenColumnKeys(scopeKey: string, hiddenColumnKeys: readonly string[]) {
|
||||
const normalizedKeys = [...new Set(hiddenColumnKeys)];
|
||||
if (normalizedKeys.length === 0) {
|
||||
safeLocalStorageRemove(`${STORAGE_PREFIX}${scopeKey}`);
|
||||
return;
|
||||
}
|
||||
safeLocalStorageSet(`${STORAGE_PREFIX}${scopeKey}`, JSON.stringify(normalizedKeys));
|
||||
}
|
||||
|
||||
export function migrateDocumentGridColumnVisibilityToLayout(legacyScopeKey: string, layoutScopeKey: string) {
|
||||
const legacyStorageKey = `${STORAGE_PREFIX}${legacyScopeKey}`;
|
||||
if (loadDataGridColumnLayout(layoutScopeKey)) {
|
||||
safeLocalStorageRemove(legacyStorageKey);
|
||||
return;
|
||||
}
|
||||
const hiddenKeys = loadDocumentGridHiddenColumnKeys(legacyScopeKey);
|
||||
if (hiddenKeys.length === 0) return;
|
||||
saveDataGridColumnLayout(layoutScopeKey, { orderKeys: [], hiddenKeys });
|
||||
if (loadDataGridColumnLayout(layoutScopeKey)) safeLocalStorageRemove(legacyStorageKey);
|
||||
}
|
||||
|
|
@ -1,99 +1,108 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { columnOrderKeysForIndexes, isDefaultColumnOrder, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnOrder";
|
||||
import { columnOrderKeysForIndexes, isDefaultColumnOrder, mergeUnavailableColumnOrderKeys, moveDisplayableColumnIndex, moveVisibleColumnIndex, orderedColumnIndexes, uniqueDataGridColumnOrderKeys } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnOrder";
|
||||
|
||||
test("creates stable keys for duplicate column names", () => {
|
||||
assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "name", "name"]), [`id\u00000`, `name\u00000`, `name\u00001`]);
|
||||
assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "name", "name"]), [`id\u00000`, `name\u00000`, `name\u00001`]);
|
||||
});
|
||||
|
||||
test("uses source columns when available", () => {
|
||||
assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "display_name"], ["id", "name"]), [`id\u00000`, `name\u00000`]);
|
||||
assert.deepEqual(uniqueDataGridColumnOrderKeys(["id", "display_name"], ["id", "name"]), [`id\u00000`, `name\u00000`]);
|
||||
});
|
||||
|
||||
test("orders available indexes from persisted keys and appends new columns", () => {
|
||||
const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email", "created_at"]);
|
||||
assert.deepEqual(
|
||||
orderedColumnIndexes({
|
||||
availableIndexes: [0, 1, 2, 3],
|
||||
columnKeys: keys,
|
||||
orderedKeys: [keys[2], keys[0], "missing"],
|
||||
}),
|
||||
[2, 0, 1, 3],
|
||||
);
|
||||
const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email", "created_at"]);
|
||||
assert.deepEqual(
|
||||
orderedColumnIndexes({
|
||||
availableIndexes: [0, 1, 2, 3],
|
||||
columnKeys: keys,
|
||||
orderedKeys: [keys[2], keys[0], "missing"],
|
||||
}),
|
||||
[2, 0, 1, 3],
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores unavailable indexes while preserving displayable columns", () => {
|
||||
const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
|
||||
assert.deepEqual(
|
||||
orderedColumnIndexes({
|
||||
availableIndexes: [1, 2],
|
||||
columnKeys: keys,
|
||||
orderedKeys: [keys[0], keys[2], keys[1]],
|
||||
}),
|
||||
[2, 1],
|
||||
);
|
||||
const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
|
||||
assert.deepEqual(
|
||||
orderedColumnIndexes({
|
||||
availableIndexes: [1, 2],
|
||||
columnKeys: keys,
|
||||
orderedKeys: [keys[0], keys[2], keys[1]],
|
||||
}),
|
||||
[2, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("moves a visible column forward", () => {
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set(),
|
||||
fromVisibleIndex: 3,
|
||||
toVisibleIndex: 1,
|
||||
}),
|
||||
[0, 3, 1, 2],
|
||||
);
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set(),
|
||||
fromVisibleIndex: 3,
|
||||
toVisibleIndex: 1,
|
||||
}),
|
||||
[0, 3, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test("moves a visible column backward", () => {
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set(),
|
||||
fromVisibleIndex: 1,
|
||||
toVisibleIndex: 3,
|
||||
}),
|
||||
[0, 2, 3, 1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set(),
|
||||
fromVisibleIndex: 1,
|
||||
toVisibleIndex: 3,
|
||||
}),
|
||||
[0, 2, 3, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("moves a visible column to an adjacent later position", () => {
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set(),
|
||||
fromVisibleIndex: 1,
|
||||
toVisibleIndex: 2,
|
||||
}),
|
||||
[0, 2, 1, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set(),
|
||||
fromVisibleIndex: 1,
|
||||
toVisibleIndex: 2,
|
||||
}),
|
||||
[0, 2, 1, 3],
|
||||
);
|
||||
});
|
||||
|
||||
test("moves visible columns without disturbing hidden column identity", () => {
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set([1]),
|
||||
fromVisibleIndex: 2,
|
||||
toVisibleIndex: 0,
|
||||
}),
|
||||
[3, 0, 1, 2],
|
||||
);
|
||||
assert.deepEqual(
|
||||
moveVisibleColumnIndex({
|
||||
orderedIndexes: [0, 1, 2, 3],
|
||||
hiddenIndexes: new Set([1]),
|
||||
fromVisibleIndex: 2,
|
||||
toVisibleIndex: 0,
|
||||
}),
|
||||
[3, 0, 1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test("moves displayable columns including hidden fields", () => {
|
||||
assert.deepEqual(moveDisplayableColumnIndex({ orderedIndexes: [0, 1, 2, 3], fromDisplayableIndex: 3, toDisplayableIndex: 1 }), [0, 3, 1, 2]);
|
||||
assert.deepEqual(moveDisplayableColumnIndex({ orderedIndexes: [0, 1, 2, 3], fromDisplayableIndex: 1, toDisplayableIndex: 3 }), [0, 2, 3, 1]);
|
||||
});
|
||||
|
||||
test("keeps unavailable fields anchored when the current page order changes", () => {
|
||||
assert.deepEqual(mergeUnavailableColumnOrderKeys(["id", "orderNo", "status", "createTime"], ["id", "status", "orderNo", "goodsList"]), ["id", "orderNo", "goodsList", "status", "createTime"]);
|
||||
});
|
||||
|
||||
test("returns no-op for invalid or same visible indexes", () => {
|
||||
const orderedIndexes = [0, 1, 2];
|
||||
assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: 1, toVisibleIndex: 1 }), orderedIndexes);
|
||||
assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: -1, toVisibleIndex: 1 }), orderedIndexes);
|
||||
const orderedIndexes = [0, 1, 2];
|
||||
assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: 1, toVisibleIndex: 1 }), orderedIndexes);
|
||||
assert.deepEqual(moveVisibleColumnIndex({ orderedIndexes, hiddenIndexes: new Set(), fromVisibleIndex: -1, toVisibleIndex: 1 }), orderedIndexes);
|
||||
});
|
||||
|
||||
test("converts indexes back to persisted keys", () => {
|
||||
const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
|
||||
assert.deepEqual(columnOrderKeysForIndexes([2, 0, 1], keys), [keys[2], keys[0], keys[1]]);
|
||||
const keys = uniqueDataGridColumnOrderKeys(["id", "name", "email"]);
|
||||
assert.deepEqual(columnOrderKeysForIndexes([2, 0, 1], keys), [keys[2], keys[0], keys[1]]);
|
||||
});
|
||||
|
||||
test("detects default order", () => {
|
||||
assert.equal(isDefaultColumnOrder([0, 1, 2], [0, 1, 2]), true);
|
||||
assert.equal(isDefaultColumnOrder([0, 1, 2], [1, 0, 2]), false);
|
||||
assert.equal(isDefaultColumnOrder([0, 1, 2], [0, 1, 2]), true);
|
||||
assert.equal(isDefaultColumnOrder([0, 1, 2], [1, 0, 2]), false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue