feat(grid): persist column layouts and improve drag-to-reorder

This commit is contained in:
Ashton Lin authored and GitHub committed 2026-07-31 02:01:46 +08:00
1 parent f5c29f08f1
commit 44f748d3a1
24 files changed
+1532 -276

No files matched your search

@@ -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();