feat(mongodb): improve document quick filters
This commit is contained in:
1 parent
c417e54698
commit
681da22c33
32 files changed
+1639
-116
No files matched your search
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
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, 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, Columns3, Check, Search, Wrench, Filter } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,12 +17,17 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||
import {
|
||||
arrayObjectAncestorPathForDocumentField,
|
||||
buildDocumentFilterCondition,
|
||||
buildElasticsearchQueryFromRules,
|
||||
combineDocumentFilterConditions,
|
||||
currentDocumentFilterJson,
|
||||
currentDocumentSortJson,
|
||||
defaultDocumentFilterRule,
|
||||
documentFieldPathOptionsFromDocuments,
|
||||
documentFieldPathTreeFromDocuments,
|
||||
flattenDocumentFieldPathTree,
|
||||
searchDocumentFieldPathTree,
|
||||
documentFilterModeNeedsValue,
|
||||
documentFilterModeOptions,
|
||||
documentStoreProviderFor,
|
||||
@@ -30,6 +35,8 @@ import {
|
||||
elasticsearchQueryTypeNeedsValue,
|
||||
elasticsearchQueryTypeOptions,
|
||||
elasticsearchStructuredFilter,
|
||||
formatDocumentQueryInput,
|
||||
type DocumentFieldPathNode,
|
||||
type DocumentFilterMode,
|
||||
type DocumentFilterRule,
|
||||
type ElasticsearchBoolClause,
|
||||
@@ -49,8 +56,9 @@ import {
|
||||
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
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 { useSettingsStore } from "@/stores/settingsStore";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
@@ -98,7 +106,16 @@ const viewMode = computed<ViewMode>({
|
||||
});
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterInputRef = ref<HTMLTextAreaElement>();
|
||||
const sortInputRef = ref<HTMLTextAreaElement>();
|
||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||
const documentViewerRef = ref<HTMLElement>();
|
||||
const documentSearchInputRef = ref<HTMLInputElement>();
|
||||
const documentSearchOpen = ref(false);
|
||||
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) ?? []);
|
||||
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
|
||||
@@ -125,6 +142,7 @@ type LocalFilterSummary = {
|
||||
values: string[];
|
||||
hiddenValueCount: number;
|
||||
};
|
||||
type DocumentFilterFieldTreeRow = DocumentFieldPathNode & { depth: number };
|
||||
type DocumentGridChanges = {
|
||||
dirtyRows: Map<number, Map<number, MongoInputValue>>;
|
||||
deletedRows: Set<number>;
|
||||
@@ -133,6 +151,8 @@ type DocumentGridChanges = {
|
||||
rows: MongoInputValue[][];
|
||||
};
|
||||
const documentFilterBuilderOpen = ref(false);
|
||||
const documentFilterFieldPopoverOpen = ref<Record<string, boolean>>({});
|
||||
const documentFilterFieldSearch = ref<Record<string, string>>({});
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
||||
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
||||
@@ -143,6 +163,16 @@ const selectedDoc = computed(() => {
|
||||
if (selectedIdx.value === null) return null;
|
||||
return documents.value[selectedIdx.value] ?? null;
|
||||
});
|
||||
const documentSearchText = computed(() => editJson.value);
|
||||
const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value));
|
||||
const documentSearchActiveIndex = computed(() => {
|
||||
if (documentSearchMatches.value.length === 0) return 0;
|
||||
return Math.min(documentSearchMatchIndex.value, documentSearchMatches.value.length - 1);
|
||||
});
|
||||
const documentSearchStatus = computed(() => {
|
||||
const count = documentSearchMatches.value.length;
|
||||
return count > 0 ? `${documentSearchActiveIndex.value + 1}/${count}` : "0/0";
|
||||
});
|
||||
|
||||
const editKeyWidth = computed(() => {
|
||||
const longest = editFields.value.reduce((max, field) => {
|
||||
@@ -186,7 +216,7 @@ const gridResult = computed<QueryResult>(() => {
|
||||
|
||||
const rows = docs.map((doc) =>
|
||||
columns.map((col) => {
|
||||
const val = doc[col];
|
||||
const val = mongoDocumentDisplayValue(doc[col]);
|
||||
if (val === undefined || val === null) return null;
|
||||
if (col === "_id") return documentStoreProvider.value.kind === "elasticsearch" ? documentStoreValueForGrid(val, "elasticsearch") : mongoDocumentIdForGrid(val);
|
||||
if (typeof val === "object") return documentStoreValueForGrid(val, documentStoreProvider.value.kind);
|
||||
@@ -197,11 +227,30 @@ const gridResult = computed<QueryResult>(() => {
|
||||
|
||||
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
||||
});
|
||||
const documentFilterFieldOptions = computed(() => {
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch") return gridResult.value.columns;
|
||||
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
|
||||
const documentFilterFieldTree = computed<DocumentFieldPathNode[]>(() => {
|
||||
const tree = documentFieldPathTreeFromDocuments(documents.value);
|
||||
if (tree.length > 0) return tree;
|
||||
return gridResult.value.columns.map((column) => ({
|
||||
key: column,
|
||||
path: column,
|
||||
label: column,
|
||||
displayPath: column,
|
||||
kind: "scalar",
|
||||
selectable: true,
|
||||
children: [],
|
||||
}));
|
||||
});
|
||||
const documentFilterFieldOptions = computed(() => {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
}
|
||||
const nestedFields = documentFieldPathOptionsFromDocuments(documents.value);
|
||||
return nestedFields.length > 0 ? nestedFields : gridResult.value.columns;
|
||||
});
|
||||
const documentFilterFieldRows = computed<DocumentFilterFieldTreeRow[]>(() => visibleDocumentFilterFieldRows(documentFilterFieldTree.value));
|
||||
const documentFilterFieldByPath = computed(() => new Map(flattenDocumentFieldPathTree(documentFilterFieldTree.value).map((node) => [node.path, node])));
|
||||
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
|
||||
const documentStructuredFilterCount = computed(() => {
|
||||
if (!appliedDocumentFilter.value) return 0;
|
||||
@@ -238,8 +287,63 @@ function addDocumentFilterRule() {
|
||||
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
|
||||
}
|
||||
|
||||
function visibleDocumentFilterFieldRows(nodes: readonly DocumentFieldPathNode[], depth = 0): DocumentFilterFieldTreeRow[] {
|
||||
const rows: DocumentFilterFieldTreeRow[] = [];
|
||||
for (const node of nodes) {
|
||||
rows.push({ ...node, depth });
|
||||
if (node.children.length > 0 && expandedDocumentFilterFieldPaths.value.has(node.path)) {
|
||||
rows.push(...visibleDocumentFilterFieldRows(node.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toggleDocumentFilterFieldExpanded(path: string) {
|
||||
const next = new Set(expandedDocumentFilterFieldPaths.value);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
expandedDocumentFilterFieldPaths.value = next;
|
||||
}
|
||||
|
||||
function setDocumentFilterFieldPopoverOpen(ruleId: string, open: boolean) {
|
||||
const next = { ...documentFilterFieldPopoverOpen.value };
|
||||
if (open) next[ruleId] = true;
|
||||
else delete next[ruleId];
|
||||
documentFilterFieldPopoverOpen.value = next;
|
||||
if (!open) {
|
||||
const search = { ...documentFilterFieldSearch.value };
|
||||
delete search[ruleId];
|
||||
documentFilterFieldSearch.value = search;
|
||||
}
|
||||
}
|
||||
|
||||
function documentFilterFieldSearchActive(ruleId: string): boolean {
|
||||
return !!documentFilterFieldSearch.value[ruleId]?.trim();
|
||||
}
|
||||
|
||||
function documentFilterFieldRowsForRule(ruleId: string): DocumentFilterFieldTreeRow[] {
|
||||
const query = documentFilterFieldSearch.value[ruleId] ?? "";
|
||||
if (!query.trim()) return documentFilterFieldRows.value;
|
||||
return searchDocumentFieldPathTree(documentFilterFieldTree.value, query).map((node) => ({ ...node, depth: 0 }));
|
||||
}
|
||||
|
||||
function selectDocumentFilterField(ruleId: string, fieldName: string) {
|
||||
updateDocumentFilterRule(ruleId, { fieldName });
|
||||
setDocumentFilterFieldPopoverOpen(ruleId, false);
|
||||
}
|
||||
|
||||
function documentFilterFieldLabel(path: string): string {
|
||||
return documentFilterFieldByPath.value.get(path)?.displayPath ?? path;
|
||||
}
|
||||
|
||||
function documentFilterFieldKindLabel(kind: DocumentFieldPathNode["kind"]): string {
|
||||
if (kind === "array-object") return "array object";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function removeDocumentFilterRule(ruleId: string) {
|
||||
documentFilterRules.value = documentFilterRules.value.filter((rule) => rule.id !== ruleId);
|
||||
setDocumentFilterFieldPopoverOpen(ruleId, false);
|
||||
if (documentFilterRules.value.length === 0) appliedDocumentFilter.value = null;
|
||||
}
|
||||
|
||||
@@ -276,6 +380,8 @@ function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string
|
||||
|
||||
function resetDocumentFilterBuilder() {
|
||||
appliedDocumentFilter.value = null;
|
||||
documentFilterFieldPopoverOpen.value = {};
|
||||
documentFilterFieldSearch.value = {};
|
||||
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
||||
}
|
||||
|
||||
@@ -283,6 +389,41 @@ function currentDocumentFilter(): string | undefined {
|
||||
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
|
||||
}
|
||||
|
||||
function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(Math.max(el.scrollHeight, 20), 120)}px`;
|
||||
}
|
||||
|
||||
function resizeDocumentQueryInputs() {
|
||||
resizeDocumentQueryInput(filterInputRef.value);
|
||||
resizeDocumentQueryInput(sortInputRef.value);
|
||||
}
|
||||
|
||||
function formatFilterInput() {
|
||||
try {
|
||||
filterInput.value = formatDocumentQueryInput(filterInput.value, documentStoreProvider.value.kind);
|
||||
error.value = "";
|
||||
void nextTick(resizeDocumentQueryInputs);
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSortInput() {
|
||||
try {
|
||||
sortInput.value = formatDocumentQueryInput(sortInput.value);
|
||||
error.value = "";
|
||||
void nextTick(resizeDocumentQueryInputs);
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
watch([filterInput, sortInput], () => {
|
||||
void nextTick(resizeDocumentQueryInputs);
|
||||
});
|
||||
|
||||
const documentQueryPreview = computed(() => {
|
||||
let filter = "{}";
|
||||
try {
|
||||
@@ -309,12 +450,16 @@ async function applyDocumentStructuredFilters() {
|
||||
const items = documentFilterRules.value
|
||||
.map((rule) => ({
|
||||
rule,
|
||||
condition: buildDocumentFilterCondition(rule, { kind: documentStoreProvider.value.kind }),
|
||||
condition: buildDocumentFilterCondition(rule, {
|
||||
kind: documentStoreProvider.value.kind,
|
||||
sampleValue: documentFilterFieldByPath.value.get(rule.fieldName)?.sampleValue,
|
||||
}),
|
||||
}))
|
||||
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||
const structured = combineDocumentFilterConditions(
|
||||
items.map((item) => item.condition),
|
||||
items.map((item) => item.rule),
|
||||
items.map((item) => arrayObjectAncestorPathForDocumentField(documentFilterFieldTree.value, item.rule.fieldName)),
|
||||
);
|
||||
appliedDocumentFilter.value = structured;
|
||||
documentFilterBuilderOpen.value = false;
|
||||
@@ -780,6 +925,7 @@ function cancelEdit() {
|
||||
editJson.value = stringifyDocumentStoreValue(selectedDoc.value, documentStoreProvider.value.kind, 2);
|
||||
}
|
||||
editFields.value = [];
|
||||
documentEditMode.value = "fields";
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
@@ -847,6 +993,7 @@ function requestRemoveField(idx: number) {
|
||||
}
|
||||
|
||||
function formatForEdit(value: unknown): string {
|
||||
value = mongoDocumentDisplayValue(value);
|
||||
if (value === undefined) return "";
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
@@ -1048,18 +1195,71 @@ function docPreview(doc: JsonRecord): string {
|
||||
}
|
||||
|
||||
function highlightedJson(json: string): string {
|
||||
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value);
|
||||
}
|
||||
|
||||
return escaped.replace(/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => {
|
||||
let cls = "json-number";
|
||||
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
||||
else if (match === "true" || match === "false") cls = "json-boolean";
|
||||
else if (match === "null") cls = "json-null";
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
function handleDocumentBrowserPointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
documentViewerSearchActive.value = target instanceof Element && !!target.closest("[data-document-json-viewer], [data-document-search]");
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
if (viewMode.value !== "document" || !documentViewerSearchActive.value) return false;
|
||||
if (isEditing.value) return false;
|
||||
if (!isNew.value && selectedIdx.value === null) return false;
|
||||
documentSearchOpen.value = true;
|
||||
documentSearchHasNavigated.value = false;
|
||||
void nextTick(() => {
|
||||
documentSearchInputRef.value?.focus();
|
||||
documentSearchInputRef.value?.select();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeDocumentSearch() {
|
||||
documentSearchOpen.value = false;
|
||||
void nextTick(() => {
|
||||
documentViewerRef.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function moveDocumentSearchMatch(delta: -1 | 1) {
|
||||
const count = documentSearchMatches.value.length;
|
||||
if (count === 0) return;
|
||||
documentSearchMatchIndex.value = (documentSearchActiveIndex.value + delta + count) % count;
|
||||
documentSearchHasNavigated.value = true;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
}
|
||||
|
||||
function activateDocumentSearchMatch(delta: -1 | 1) {
|
||||
if (documentSearchMatches.value.length === 0) return;
|
||||
if (!documentSearchHasNavigated.value) {
|
||||
documentSearchHasNavigated.value = true;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
return;
|
||||
}
|
||||
moveDocumentSearchMatch(delta);
|
||||
}
|
||||
|
||||
async function scrollDocumentSearchMatchIntoView() {
|
||||
await nextTick();
|
||||
documentViewerRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
}
|
||||
|
||||
watch([documentSearchQuery, documentSearchText], () => {
|
||||
documentSearchMatchIndex.value = 0;
|
||||
documentSearchHasNavigated.value = false;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
});
|
||||
|
||||
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||
if (mode === "document" && !editing && index !== null) return;
|
||||
documentViewerSearchActive.value = false;
|
||||
documentSearchOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
} catch (e) {
|
||||
@@ -1069,8 +1269,10 @@ onMounted(async () => {
|
||||
// first page of documents when the mapping endpoint is slow.
|
||||
void loadElasticsearchMappingFields();
|
||||
void load();
|
||||
void nextTick(resizeDocumentQueryInputs);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||
stopDocumentLoadingTimer();
|
||||
endTableSearchSplitResize();
|
||||
@@ -1117,6 +1319,8 @@ function resetTableSearchSplitWidth() {
|
||||
const containerWidth = tableSearchSplitContainerWidth();
|
||||
tableFindPaneWidth.value = containerWidth > 0 ? clampSearchSplitWidth({ containerWidth }) : null;
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1248,7 +1452,7 @@ function resetTableSearchSplitWidth() {
|
||||
>
|
||||
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
|
||||
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
|
||||
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
|
||||
<Popover v-model:open="documentFilterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
@@ -1331,7 +1535,44 @@ function resetTableSearchSplitWidth() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<Popover v-if="documentStoreProvider.kind !== 'elasticsearch'" :open="!!documentFilterFieldPopoverOpen[rule.id]" @update:open="(open) => setDocumentFilterFieldPopoverOpen(rule.id, open)">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="flex h-8 w-full min-w-0 items-center justify-between gap-1 rounded-md border bg-background px-2 text-left text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
|
||||
<span class="min-w-0 truncate font-mono" :title="documentFilterFieldLabel(rule.fieldName)">{{ documentFilterFieldLabel(rule.fieldName) || t("grid.filterBuilderColumn") }}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-72 max-w-[calc(100vw-32px)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-lg" @click.stop @keydown.stop>
|
||||
<div class="border-b bg-muted/40 px-2 py-1.5 text-xs font-medium text-foreground">{{ t("grid.filterBuilderColumn") }}</div>
|
||||
<div class="relative border-b p-2">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="documentFilterFieldSearch[rule.id]" autofocus class="h-7 pl-7 text-xs" :placeholder="t('grid.filterBuilderSearchColumns')" />
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-1">
|
||||
<div v-for="field in documentFilterFieldRowsForRule(rule.id)" :key="field.path" class="flex items-center gap-1 px-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:class="{ invisible: field.children.length === 0 || documentFilterFieldSearchActive(rule.id) }"
|
||||
:style="{ marginLeft: `${field.depth * 14}px` }"
|
||||
@click.stop="toggleDocumentFilterFieldExpanded(field.path)"
|
||||
>
|
||||
<ChevronRight v-if="!expandedDocumentFilterFieldPaths.has(field.path)" class="h-3.5 w-3.5" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 text-left text-xs hover:bg-accent" :class="rule.fieldName === field.path ? 'bg-accent text-foreground' : ''" @click="selectDocumentFilterField(rule.id, field.path)">
|
||||
<span class="min-w-0 flex-1 truncate font-mono" :title="field.displayPath">{{ documentFilterFieldSearchActive(rule.id) ? field.displayPath : field.label }}</span>
|
||||
<span v-if="field.kind !== 'scalar'" class="shrink-0 rounded border px-1 py-0 text-[10px] leading-4 text-muted-foreground">{{ documentFilterFieldKindLabel(field.kind) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="documentFilterFieldRowsForRule(rule.id).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.fieldName" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { fieldName: String(value) })">
|
||||
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
|
||||
<SelectValue :placeholder="t('grid.filterBuilderColumn')"> {{ rule.fieldName }}{{ elasticsearchFieldTypes.get(rule.fieldName) ? ` (${elasticsearchFieldTypes.get(rule.fieldName)})` : "" }} </SelectValue>
|
||||
</SelectTrigger>
|
||||
@@ -1404,11 +1645,26 @@ function resetTableSearchSplitWidth() {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
|
||||
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.filterInputLabel }}</span>
|
||||
<textarea
|
||||
ref="filterInputRef"
|
||||
v-model="filterInput"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
|
||||
placeholder="{}"
|
||||
@keydown.ctrl.enter.prevent="applyFilter"
|
||||
@keydown.meta.enter.prevent="applyFilter"
|
||||
/>
|
||||
<button v-if="filterInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatFilterInput">
|
||||
<Braces class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
v-if="filterInput.trim()"
|
||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
|
||||
@click="
|
||||
filterInput = '';
|
||||
applyFilter();
|
||||
@@ -1426,12 +1682,27 @@ function resetTableSearchSplitWidth() {
|
||||
>
|
||||
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
|
||||
</button>
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
|
||||
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono" placeholder="{}" @keydown.enter="applyFilter" />
|
||||
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0">
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.sortInputLabel }}</span>
|
||||
<textarea
|
||||
ref="sortInputRef"
|
||||
v-model="sortInput"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
|
||||
placeholder="{}"
|
||||
@keydown.ctrl.enter.prevent="applyFilter"
|
||||
@keydown.meta.enter.prevent="applyFilter"
|
||||
/>
|
||||
<button v-if="sortInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatSortInput">
|
||||
<Braces class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
v-if="sortInput.trim()"
|
||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
|
||||
@click="
|
||||
sortInput = '';
|
||||
applyFilter();
|
||||
@@ -1482,6 +1753,23 @@ function resetTableSearchSplitWidth() {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="documentSearchOpen && (!isEditing || documentEditMode === 'json')" data-document-search class="flex h-9 shrink-0 items-center justify-end gap-1 border-b bg-background px-2">
|
||||
<div class="relative w-56 max-w-[45%] min-w-32">
|
||||
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
|
||||
</div>
|
||||
<span class="w-12 text-center text-[11px] tabular-nums text-muted-foreground">{{ documentSearchStatus }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.prevMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(-1)">
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.nextMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(1)">
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDocumentSearch">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
|
||||
{{ t("mongo.jsonReplaceHint") }}
|
||||
</div>
|
||||
@@ -1503,7 +1791,7 @@ function resetTableSearchSplitWidth() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
||||
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none">
|
||||
<pre class="json-viewer min-w-fit p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1520,6 +1808,15 @@ function resetTableSearchSplitWidth() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.document-query-input {
|
||||
min-height: 20px;
|
||||
max-height: 120px;
|
||||
line-height: 1.25rem;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.json-viewer {
|
||||
font-family: var(--dbx-editor-font-family);
|
||||
font-size: var(--dbx-editor-font-size);
|
||||
@@ -1548,6 +1845,27 @@ function resetTableSearchSplitWidth() {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.native-document-editor {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
padding: 14px 16px;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
outline: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.native-document-editor:focus {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 28%, transparent);
|
||||
}
|
||||
|
||||
:deep(.json-key) {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
@@ -1590,4 +1908,26 @@ function resetTableSearchSplitWidth() {
|
||||
:global(.dark) :deep(.json-null) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:deep(.document-search-match) {
|
||||
border-radius: 2px;
|
||||
background: #fde68a;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.document-search-match-active) {
|
||||
background: #f59e0b;
|
||||
color: #111827;
|
||||
outline: 1px solid #d97706;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.document-search-match) {
|
||||
background: #854d0e;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.document-search-match-active) {
|
||||
background: #fbbf24;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Plus, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import type { EditNode, EditNodeKind } from "@/types/editor";
|
||||
|
||||
@@ -30,6 +31,7 @@ const { t } = useI18n();
|
||||
const isContainer = computed(() => props.node.kind !== "value");
|
||||
const showChildDeleteConfirm = ref(false);
|
||||
const pendingChildDeleteIdx = ref<number | null>(null);
|
||||
const emptyArrayItemType = ref<"object" | "string" | "number" | "boolean" | "null" | "array">("object");
|
||||
|
||||
const childKeyWidth = computed(() => {
|
||||
const longest = props.node.children.reduce((max, child) => {
|
||||
@@ -66,11 +68,11 @@ function fieldValueTone(value: string): string {
|
||||
return "is-string";
|
||||
}
|
||||
|
||||
function createBlankNode(keyName: string, readonlyKey: boolean): EditNode {
|
||||
function createBlankNode(keyName: string, readonlyKey: boolean, kind: EditNodeKind = "value"): EditNode {
|
||||
return {
|
||||
key: uuid(),
|
||||
keyName,
|
||||
kind: "value",
|
||||
kind,
|
||||
valueText: "",
|
||||
readonlyKey,
|
||||
readonlyValue: false,
|
||||
@@ -78,9 +80,32 @@ function createBlankNode(keyName: string, readonlyKey: boolean): EditNode {
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyArrayItem(keyName: string): EditNode {
|
||||
switch (emptyArrayItemType.value) {
|
||||
case "object":
|
||||
case "array":
|
||||
return createBlankNode(keyName, true, emptyArrayItemType.value);
|
||||
case "string":
|
||||
return { ...createBlankNode(keyName, true), valueText: '""' };
|
||||
case "number":
|
||||
return { ...createBlankNode(keyName, true), valueText: "0" };
|
||||
case "boolean":
|
||||
return { ...createBlankNode(keyName, true), valueText: "false" };
|
||||
case "null":
|
||||
return { ...createBlankNode(keyName, true), valueText: "null" };
|
||||
}
|
||||
}
|
||||
|
||||
function addChild() {
|
||||
if (props.node.kind === "array") {
|
||||
props.node.children.push(createBlankNode(String(props.node.children.length), true));
|
||||
const keyName = String(props.node.children.length);
|
||||
if (props.node.children.some((child) => child.kind === "object")) {
|
||||
props.node.children.push(createBlankNode(keyName, true, "object"));
|
||||
} else if (props.node.children.length === 0) {
|
||||
props.node.children.push(createEmptyArrayItem(keyName));
|
||||
} else {
|
||||
props.node.children.push(createBlankNode(keyName, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
props.node.children.push(createBlankNode("", false));
|
||||
@@ -139,7 +164,23 @@ function confirmRemoveChild() {
|
||||
<div v-if="isContainer" class="json-edit-children" :style="{ '--mongo-key-width': childKeyWidth }">
|
||||
<JsonEditNode v-for="(child, idx) in node.children" :key="child.key" :node="child" :parent-kind="node.kind" :removable="!child.readonlyValue || node.kind === 'array'" @remove="requestRemoveChild(idx)" />
|
||||
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
<div v-if="node.kind === 'array' && node.children.length === 0" class="json-edit-add-row">
|
||||
<Select v-model="emptyArrayItemType">
|
||||
<SelectTrigger class="h-8 w-28 text-xs" :aria-label="t('mongo.addField')">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="object">Object</SelectItem>
|
||||
<SelectItem value="string">{{ t("sqlParameters.kind.string") }}</SelectItem>
|
||||
<SelectItem value="number">{{ t("sqlParameters.kind.number") }}</SelectItem>
|
||||
<SelectItem value="boolean">{{ t("sqlParameters.kind.boolean") }}</SelectItem>
|
||||
<SelectItem value="null">{{ t("sqlParameters.kind.null") }}</SelectItem>
|
||||
<SelectItem value="array">Array</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
</div>
|
||||
<Button v-else variant="ghost" size="sm" class="json-edit-add" @click="addChild"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
||||
|
||||
<div class="json-edit-close">{{ node.kind === "array" ? "]" : "}" }}<span class="json-edit-comma">,</span></div>
|
||||
</div>
|
||||
@@ -307,6 +348,17 @@ function confirmRemoveChild() {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.json-edit-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 6px 0 6px 2ch;
|
||||
}
|
||||
|
||||
.json-edit-add-row .json-edit-add {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .json-edit-key {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
defineOptions({ name: "JsonValueTable" });
|
||||
|
||||
const props = defineProps<{ value: unknown }>();
|
||||
const expanded = ref(true);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const isContainer = computed(() => isRecord(props.value) || Array.isArray(props.value));
|
||||
const containerLabel = computed(() => (Array.isArray(props.value) ? `Array (${props.value.length})` : `Object (${Object.keys(props.value as Record<string, unknown>).length})`));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isContainer" class="json-value-container">
|
||||
<button type="button" class="json-value-toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<ChevronDown v-if="expanded" class="h-3.5 w-3.5" />
|
||||
<ChevronRight v-else class="h-3.5 w-3.5" />
|
||||
<span>{{ containerLabel }}</span>
|
||||
</button>
|
||||
<table v-show="expanded && isRecord(value)" class="json-value-table">
|
||||
<tbody>
|
||||
<tr v-for="(item, key) in value as Record<string, unknown>" :key="key">
|
||||
<th>{{ key }}</th>
|
||||
<td><JsonValueTable :value="item" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table v-show="expanded && Array.isArray(value)" class="json-value-table">
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in value as unknown[]" :key="index">
|
||||
<th>{{ index }}</th>
|
||||
<td><JsonValueTable :value="item" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span v-else class="break-words">{{ value === null ? "null" : String(value) }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-value-table {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--dbx-editor-font-family);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.json-value-table th,
|
||||
.json-value-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.json-value-table th {
|
||||
width: 1%;
|
||||
max-width: 20rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.json-value-table td {
|
||||
min-width: 0;
|
||||
}
|
||||
.json-value-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.4rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-align: left;
|
||||
}
|
||||
.json-value-toggle:hover {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
</style>
|
||||
@@ -207,6 +207,7 @@ const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridS
|
||||
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
|
||||
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
||||
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const documentBrowserRef = ref<SearchableBrowserHandle>();
|
||||
|
||||
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||||
@@ -680,6 +681,7 @@ function onHandleCloseColumnPanel() {
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
if (props.activeTab.mode === "mongo") return documentBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
@@ -1547,7 +1549,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in new issue
Block a user