feat(grid): persist column layouts and improve drag-to-reorder
This commit is contained in:
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"
|
||||
|
||||
+77
-8
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user