1145 lines
47 KiB
Vue
1145 lines
47 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
|
import { uuid } from "@/lib/utils";
|
|
import { useI18n } from "vue-i18n";
|
|
import { RefreshCw, RefreshCcw, Loader2, Trash2, Plus, Save, 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";
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
|
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
|
import DataGrid from "@/components/grid/DataGrid.vue";
|
|
import * as api from "@/lib/api";
|
|
import { clampSearchSplitWidth } from "@/lib/dataGridSearchSplit";
|
|
import { buildDocumentFilterCondition, combineDocumentFilterConditions, currentDocumentFilterJson, defaultDocumentFilterRule, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/documentStoreProvider";
|
|
import { normalizeResultPageSize } from "@/lib/paginationPageSize";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import JsonEditNode from "./JsonEditNode.vue";
|
|
import type { EditNode } from "@/types/editor";
|
|
import type { DatabaseType, QueryResult } from "@/types/database";
|
|
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
|
import { Splitpanes, Pane } from "splitpanes";
|
|
import "splitpanes/dist/splitpanes.css";
|
|
|
|
const { t } = useI18n();
|
|
const settingsStore = useSettingsStore();
|
|
|
|
const props = defineProps<{
|
|
connectionId: string;
|
|
database: string;
|
|
collection: string;
|
|
databaseType?: DatabaseType;
|
|
}>();
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type ViewMode = "document" | "table";
|
|
|
|
const documents = ref<JsonRecord[]>([]);
|
|
const lastGridColumns = ref<string[]>([]);
|
|
const total = ref(0);
|
|
const loading = ref(false);
|
|
const page = ref(0);
|
|
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
|
|
const selectedIdx = ref<number | null>(null);
|
|
const editJson = ref("");
|
|
const isEditing = ref(false);
|
|
const isNew = ref(false);
|
|
const error = ref("");
|
|
const editFields = ref<EditNode[]>([]);
|
|
const showDeleteConfirm = ref(false);
|
|
const viewMode = computed<ViewMode>({
|
|
get: () => settingsStore.editorSettings.mongoViewMode,
|
|
set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }),
|
|
});
|
|
const filterInput = ref("");
|
|
const sortInput = ref("");
|
|
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
|
const columnVisibilitySearch = ref("");
|
|
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
|
|
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
|
|
const tableFindPaneWidth = ref<number | null>(null);
|
|
const isResizingTableSearchSplit = ref(false);
|
|
let tableSearchSplitStartX = 0;
|
|
let tableSearchSplitStartWidth = 0;
|
|
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
|
|
|
|
const tableFindPaneStyle = computed(() => {
|
|
if (tableFindPaneWidth.value == null) return {};
|
|
return { flex: `0 0 ${tableFindPaneWidth.value}px` };
|
|
});
|
|
const documentStoreLabels = computed(() => ({
|
|
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value, t }),
|
|
queryPreview: documentQueryPreview.value,
|
|
}));
|
|
|
|
type PendingDelete = { kind: "document"; index: number } | { kind: "field"; index: number; name: string };
|
|
type LocalFilterSummary = {
|
|
columnIndex: number;
|
|
columnName: string;
|
|
values: string[];
|
|
hiddenValueCount: number;
|
|
};
|
|
const documentFilterBuilderOpen = ref(false);
|
|
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
|
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
|
|
|
const pendingDelete = ref<PendingDelete | null>(null);
|
|
|
|
const selectedDoc = computed(() => {
|
|
if (selectedIdx.value === null) return null;
|
|
return documents.value[selectedIdx.value] ?? null;
|
|
});
|
|
|
|
const editKeyWidth = computed(() => {
|
|
const longest = editFields.value.reduce((max, field) => {
|
|
return Math.max(max, Array.from(field.keyName || "").length);
|
|
}, 0);
|
|
return `${Math.min(Math.max(longest + 4, 8), 36)}ch`;
|
|
});
|
|
|
|
const deleteDetails = computed(() => {
|
|
const pending = pendingDelete.value;
|
|
if (!pending) return "";
|
|
if (pending.kind === "document") {
|
|
const id = documents.value[pending.index]?._id ?? "";
|
|
if (props.databaseType === "elasticsearch") return `Elasticsearch index: ${props.collection}\nDocument _id: ${String(id)}`;
|
|
return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(id) });
|
|
}
|
|
return t("dangerDialog.mongoFieldDetails", { field: pending.name || t("mongo.field") });
|
|
});
|
|
|
|
const gridResult = computed<QueryResult>(() => {
|
|
const docs = documents.value;
|
|
if (!docs.length) {
|
|
return {
|
|
columns: lastGridColumns.value,
|
|
rows: [],
|
|
affected_rows: 0,
|
|
execution_time_ms: 0,
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
const keySet = new Set<string>();
|
|
keySet.add("_id");
|
|
for (const doc of docs) {
|
|
for (const key of Object.keys(doc)) {
|
|
if (key !== "_id") keySet.add(key);
|
|
}
|
|
}
|
|
const columns = [...keySet];
|
|
|
|
const rows = docs.map((doc) =>
|
|
columns.map((col) => {
|
|
const val = doc[col];
|
|
if (val === undefined || val === null) return null;
|
|
if (typeof val === "object") return JSON.stringify(val);
|
|
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") return val;
|
|
return String(val);
|
|
}),
|
|
);
|
|
|
|
return { columns, rows, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
|
});
|
|
const documentFilterFieldOptions = computed(() => gridResult.value.columns);
|
|
const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0));
|
|
|
|
function createDocumentFilterRule(): DocumentFilterRule {
|
|
return defaultDocumentFilterRule(uuid(), documentFilterFieldOptions.value[0] ?? "");
|
|
}
|
|
|
|
function ensureDocumentFilterRule() {
|
|
if (documentFilterRules.value.length === 0 && documentFilterFieldOptions.value.length > 0) {
|
|
documentFilterRules.value = [createDocumentFilterRule()];
|
|
}
|
|
}
|
|
|
|
function addDocumentFilterRule() {
|
|
ensureDocumentFilterRule();
|
|
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
|
|
}
|
|
|
|
function removeDocumentFilterRule(ruleId: string) {
|
|
documentFilterRules.value = documentFilterRules.value.filter((rule) => rule.id !== ruleId);
|
|
if (documentFilterRules.value.length === 0) appliedDocumentFilter.value = null;
|
|
}
|
|
|
|
function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterRule>) {
|
|
documentFilterRules.value = documentFilterRules.value.map((rule) => {
|
|
if (rule.id !== ruleId) return rule;
|
|
const next = { ...rule, ...patch };
|
|
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function resetDocumentFilterBuilder() {
|
|
appliedDocumentFilter.value = null;
|
|
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
|
}
|
|
|
|
function currentDocumentFilter(): string | undefined {
|
|
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value);
|
|
}
|
|
|
|
const documentQueryPreview = computed(() => {
|
|
let filter = "{}";
|
|
try {
|
|
filter = currentDocumentFilter() ?? "{}";
|
|
} catch {
|
|
filter = filterInput.value.trim() || "{}";
|
|
}
|
|
return documentStoreProvider.value.queryPreview({
|
|
collection: props.collection,
|
|
filterJson: filter,
|
|
sortJson: sortInput.value.trim(),
|
|
skip: page.value * pageSize.value,
|
|
limit: pageSize.value,
|
|
});
|
|
});
|
|
|
|
async function applyDocumentStructuredFilters() {
|
|
const items = documentFilterRules.value.map((rule) => ({ rule, condition: buildDocumentFilterCondition(rule) })).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),
|
|
);
|
|
appliedDocumentFilter.value = structured;
|
|
documentFilterBuilderOpen.value = false;
|
|
applyFilter();
|
|
}
|
|
|
|
function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) {
|
|
appliedDocumentFilter.value = null;
|
|
resetDocumentFilterBuilder();
|
|
clearLocalFilter?.();
|
|
applyFilter();
|
|
}
|
|
|
|
async function gridSave(changes: { dirtyRows: Map<number, Map<number, string | number | boolean | null>>; deletedRows: Set<number>; columns: string[]; rows: (string | number | boolean | null)[][] }) {
|
|
const cols = changes.columns;
|
|
const idColIdx = cols.indexOf("_id");
|
|
if (idColIdx < 0) throw new Error("No _id column");
|
|
|
|
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
|
|
const row = changes.rows[rowIdx];
|
|
const id = row?.[idColIdx];
|
|
if (id == null) continue;
|
|
const doc = documents.value[rowIdx];
|
|
if (!doc) continue;
|
|
const updated = { ...doc };
|
|
for (const [colIdx, newVal] of dirtyCols) {
|
|
const col = cols[colIdx];
|
|
if (col === "_id") continue;
|
|
if (newVal === null) {
|
|
delete updated[col];
|
|
} else if (typeof newVal === "string") {
|
|
try {
|
|
updated[col] = JSON.parse(newVal);
|
|
} catch {
|
|
updated[col] = newVal;
|
|
}
|
|
} else {
|
|
updated[col] = newVal;
|
|
}
|
|
}
|
|
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(updated));
|
|
}
|
|
|
|
for (const rowIdx of changes.deletedRows) {
|
|
const row = changes.rows[rowIdx];
|
|
const id = row?.[idColIdx];
|
|
if (id == null) continue;
|
|
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, String(id));
|
|
}
|
|
|
|
await load();
|
|
}
|
|
|
|
function formatMongoValue(val: unknown): string {
|
|
if (val === null || val === undefined) return "null";
|
|
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
|
if (typeof val === "string") return JSON.stringify(val);
|
|
return JSON.stringify(val);
|
|
}
|
|
|
|
function mongoIdPreview(val: unknown): string {
|
|
if (val === null || val === undefined) return "null";
|
|
if (typeof val === "string" && /^[a-fA-F0-9]{24}$/.test(val)) return `ObjectId("${val}")`;
|
|
return formatMongoValue(val);
|
|
}
|
|
|
|
function buildUpdateDoc(changes: Map<number, string | number | boolean | null>, columns: string[]): Record<string, unknown> {
|
|
const setFields: Record<string, unknown> = {};
|
|
const unsetFields: Record<string, unknown> = {};
|
|
for (const [colIdx, newVal] of changes) {
|
|
const col = columns[colIdx];
|
|
if (!col || col === "_id") continue;
|
|
if (newVal === null) {
|
|
unsetFields[col] = "";
|
|
} else if (typeof newVal === "string") {
|
|
try {
|
|
setFields[col] = JSON.parse(newVal);
|
|
} catch {
|
|
setFields[col] = newVal;
|
|
}
|
|
} else {
|
|
setFields[col] = newVal;
|
|
}
|
|
}
|
|
const doc: Record<string, unknown> = {};
|
|
if (Object.keys(setFields).length > 0) doc.$set = setFields;
|
|
if (Object.keys(unsetFields).length > 0) doc.$unset = unsetFields;
|
|
return doc;
|
|
}
|
|
|
|
function buildNewDoc(newRow: (string | number | boolean | null)[], columns: string[]): Record<string, unknown> {
|
|
const doc: Record<string, unknown> = {};
|
|
for (let ci = 0; ci < columns.length; ci++) {
|
|
const col = columns[ci];
|
|
if (!col || col === "_id") continue;
|
|
const val = newRow[ci];
|
|
if (val === null) continue;
|
|
if (typeof val === "string") {
|
|
try {
|
|
doc[col] = JSON.parse(val);
|
|
} catch {
|
|
doc[col] = val;
|
|
}
|
|
} else {
|
|
doc[col] = val;
|
|
}
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
async function previewDocumentChanges(changes: { dirtyRows: Map<number, Map<number, string | number | boolean | null>>; deletedRows: Set<number>; newRows: (string | number | boolean | null)[][]; columns: string[]; rows: (string | number | boolean | null)[][] }): Promise<string[]> {
|
|
const { dirtyRows, deletedRows, newRows, columns, rows } = changes;
|
|
const idColIdx = columns.indexOf("_id");
|
|
const stmts: string[] = [];
|
|
const coll = props.collection;
|
|
const isEs = documentStoreProvider.value.kind === "elasticsearch";
|
|
|
|
for (const [rowIdx, dirtyCols] of dirtyRows) {
|
|
const row = rows[rowIdx];
|
|
const id = row?.[idColIdx];
|
|
if (id == null) continue;
|
|
const updateDoc = buildUpdateDoc(dirtyCols, columns);
|
|
if (isEs) {
|
|
stmts.push(`POST /${coll}/_update/${JSON.stringify(String(id))}\n${JSON.stringify({ doc: updateDoc.$set ?? updateDoc }, null, 2)}`);
|
|
} else {
|
|
stmts.push(`db.${coll}.updateOne({_id: ${mongoIdPreview(id)}}, ${JSON.stringify(updateDoc)})`);
|
|
}
|
|
}
|
|
|
|
for (const rowIdx of deletedRows) {
|
|
const row = rows[rowIdx];
|
|
const id = row?.[idColIdx];
|
|
if (id == null) continue;
|
|
if (isEs) {
|
|
stmts.push(`DELETE /${coll}/_doc/${JSON.stringify(String(id))}`);
|
|
} else {
|
|
stmts.push(`db.${coll}.deleteOne({_id: ${mongoIdPreview(id)}})`);
|
|
}
|
|
}
|
|
|
|
for (const newRow of newRows) {
|
|
const doc = buildNewDoc(newRow, columns);
|
|
if (isEs) {
|
|
stmts.push(`POST /${coll}/_doc\n${JSON.stringify(doc, null, 2)}`);
|
|
} else {
|
|
stmts.push(`db.${coll}.insertOne(${JSON.stringify(doc)})`);
|
|
}
|
|
}
|
|
|
|
return stmts;
|
|
}
|
|
|
|
const customSaveHandler = computed<CustomSaveHandler>(() => ({
|
|
save: gridSave,
|
|
preview: previewDocumentChanges,
|
|
}));
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = "";
|
|
const previousSelectedIdx = selectedIdx.value;
|
|
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
|
|
try {
|
|
const filter = currentDocumentFilter();
|
|
const sort = sortInput.value.trim() || undefined;
|
|
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, page.value * pageSize.value, pageSize.value, filter, sort);
|
|
const nextDocuments = result.documents.map(asRecord);
|
|
documents.value = nextDocuments;
|
|
if (nextDocuments.length > 0) {
|
|
const keySet = new Set<string>();
|
|
keySet.add("_id");
|
|
for (const doc of nextDocuments) {
|
|
for (const key of Object.keys(doc)) {
|
|
if (key !== "_id") keySet.add(key);
|
|
}
|
|
}
|
|
lastGridColumns.value = [...keySet];
|
|
}
|
|
total.value = result.total;
|
|
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
|
|
} catch (e: unknown) {
|
|
error.value = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function applyFilter() {
|
|
page.value = 0;
|
|
load();
|
|
}
|
|
|
|
function paginate(offset: number, limit: number) {
|
|
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
|
|
pageSize.value = normalizedLimit;
|
|
page.value = Math.floor(Math.max(0, offset) / normalizedLimit);
|
|
load();
|
|
}
|
|
|
|
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
|
|
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
|
|
page.value = 0;
|
|
load();
|
|
}
|
|
|
|
function asRecord(value: unknown): JsonRecord {
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
return value as JsonRecord;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function documentIdentity(doc: JsonRecord | undefined): string | null {
|
|
const id = doc?._id;
|
|
if (id === null || id === undefined) return null;
|
|
return typeof id === "object" ? JSON.stringify(id) : String(id);
|
|
}
|
|
|
|
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
|
|
if (isNew.value || previousSelectedIdx === null) return;
|
|
if (!documents.value.length) {
|
|
selectedIdx.value = null;
|
|
if (!isEditing.value) editJson.value = "";
|
|
return;
|
|
}
|
|
|
|
const nextIdx = previousSelectedId ? documents.value.findIndex((doc) => documentIdentity(doc) === previousSelectedId) : previousSelectedIdx < documents.value.length ? previousSelectedIdx : -1;
|
|
if (nextIdx < 0) {
|
|
selectedIdx.value = null;
|
|
if (!isEditing.value) editJson.value = "";
|
|
return;
|
|
}
|
|
|
|
selectedIdx.value = nextIdx;
|
|
if (!isEditing.value) {
|
|
editJson.value = JSON.stringify(documents.value[nextIdx], null, 2);
|
|
}
|
|
}
|
|
|
|
function selectDoc(idx: number) {
|
|
selectedIdx.value = idx;
|
|
editJson.value = JSON.stringify(documents.value[idx], null, 2);
|
|
isEditing.value = false;
|
|
isNew.value = false;
|
|
editFields.value = [];
|
|
}
|
|
|
|
function startNew() {
|
|
selectedIdx.value = null;
|
|
editJson.value = "";
|
|
editFields.value = [createEditNode("", "", false, false)];
|
|
isEditing.value = true;
|
|
isNew.value = true;
|
|
}
|
|
|
|
function startEdit() {
|
|
const doc = selectedDoc.value;
|
|
if (!doc) return;
|
|
editFields.value = Object.entries(doc).map(([name, value]) => createEditNode(name, value, name === "_id", name === "_id"));
|
|
isEditing.value = true;
|
|
isNew.value = false;
|
|
}
|
|
|
|
function cancelEdit() {
|
|
isEditing.value = false;
|
|
if (isNew.value) {
|
|
isNew.value = false;
|
|
editFields.value = [];
|
|
return;
|
|
}
|
|
if (selectedDoc.value) {
|
|
editJson.value = JSON.stringify(selectedDoc.value, null, 2);
|
|
}
|
|
editFields.value = [];
|
|
error.value = "";
|
|
}
|
|
|
|
function createEditNode(keyName: string, value: unknown, readonlyKey: boolean, readonlyValue: boolean): EditNode {
|
|
if (Array.isArray(value)) {
|
|
return {
|
|
key: uuid(),
|
|
keyName,
|
|
kind: "array",
|
|
valueText: "",
|
|
readonlyKey,
|
|
readonlyValue,
|
|
children: value.map((child, idx) => createEditNode(String(idx), child, true, readonlyValue)),
|
|
};
|
|
}
|
|
|
|
if (value && typeof value === "object") {
|
|
return {
|
|
key: uuid(),
|
|
keyName,
|
|
kind: "object",
|
|
valueText: "",
|
|
readonlyKey,
|
|
readonlyValue,
|
|
children: Object.entries(value as JsonRecord).map(([childName, child]) => createEditNode(childName, child, readonlyValue, readonlyValue)),
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: uuid(),
|
|
keyName,
|
|
kind: "value",
|
|
valueText: formatForEdit(value),
|
|
readonlyKey,
|
|
readonlyValue,
|
|
children: [],
|
|
};
|
|
}
|
|
|
|
function addField() {
|
|
editFields.value.push(createEditNode("", "", false, false));
|
|
}
|
|
|
|
function applyRemoveField(idx: number) {
|
|
if (editFields.value[idx]?.readonlyValue) return;
|
|
editFields.value.splice(idx, 1);
|
|
}
|
|
|
|
function requestRemoveField(idx: number) {
|
|
const field = editFields.value[idx];
|
|
if (!field || field.readonlyValue) return;
|
|
pendingDelete.value = { kind: "field", index: idx, name: field.keyName };
|
|
showDeleteConfirm.value = true;
|
|
}
|
|
|
|
function formatForEdit(value: unknown): string {
|
|
if (value === undefined) return "";
|
|
if (value === null) return "null";
|
|
if (typeof value === "string") return JSON.stringify(value);
|
|
if (typeof value === "object") return JSON.stringify(value, null, 2);
|
|
return String(value);
|
|
}
|
|
|
|
function parseFieldValue(raw: string): unknown {
|
|
const trimmed = raw.trim();
|
|
if (trimmed === "NULL") return null;
|
|
if (/^(true|false|null)$/i.test(trimmed)) return JSON.parse(trimmed.toLowerCase());
|
|
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) {
|
|
return JSON.parse(trimmed);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
function buildObjectFromNodes(nodes: EditNode[], path: string): JsonRecord {
|
|
const doc: JsonRecord = {};
|
|
const seen = new Set<string>();
|
|
|
|
for (const field of nodes) {
|
|
const name = field.keyName.trim();
|
|
if (!name || (!path && name === "_id")) continue;
|
|
if (seen.has(name)) throw new Error(t("mongo.duplicateField", { field: name }));
|
|
seen.add(name);
|
|
doc[name] = buildValueFromNode(field, path ? `${path}.${name}` : name);
|
|
}
|
|
|
|
return doc;
|
|
}
|
|
|
|
function buildValueFromNode(node: EditNode, path: string): unknown {
|
|
if (node.kind === "value") return parseFieldValue(node.valueText);
|
|
if (node.kind === "array") {
|
|
return node.children.map((child, idx) => buildValueFromNode(child, `${path}[${idx}]`));
|
|
}
|
|
return buildObjectFromNodes(node.children, path);
|
|
}
|
|
|
|
function buildDocumentFromFields(): JsonRecord {
|
|
return buildObjectFromNodes(editFields.value, "");
|
|
}
|
|
|
|
async function saveDoc() {
|
|
error.value = "";
|
|
try {
|
|
const doc = buildDocumentFromFields();
|
|
if (isNew.value) {
|
|
await api.mongoInsertDocument(props.connectionId, props.database, props.collection, JSON.stringify(doc));
|
|
} else if (selectedIdx.value !== null) {
|
|
const current = documents.value[selectedIdx.value];
|
|
const id = current?._id;
|
|
if (!id) {
|
|
error.value = "No _id field";
|
|
return;
|
|
}
|
|
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, String(id), JSON.stringify(doc));
|
|
}
|
|
isEditing.value = false;
|
|
isNew.value = false;
|
|
editFields.value = [];
|
|
await load();
|
|
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
|
|
editJson.value = JSON.stringify(documents.value[selectedIdx.value], null, 2);
|
|
}
|
|
} catch (e: unknown) {
|
|
error.value = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
|
|
async function applyDeleteDoc(idx: number) {
|
|
const doc = documents.value[idx];
|
|
const id = doc._id;
|
|
if (!id) return;
|
|
error.value = "";
|
|
try {
|
|
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, String(id));
|
|
if (selectedIdx.value === idx) {
|
|
selectedIdx.value = null;
|
|
editJson.value = "";
|
|
}
|
|
await load();
|
|
} catch (e: unknown) {
|
|
error.value = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
|
|
function requestDeleteDoc(idx: number) {
|
|
pendingDelete.value = { kind: "document", index: idx };
|
|
showDeleteConfirm.value = true;
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
const pending = pendingDelete.value;
|
|
if (!pending) return;
|
|
if (pending.kind === "document") {
|
|
await applyDeleteDoc(pending.index);
|
|
} else {
|
|
applyRemoveField(pending.index);
|
|
}
|
|
pendingDelete.value = null;
|
|
}
|
|
|
|
function prevPage() {
|
|
if (page.value <= 0) return;
|
|
page.value--;
|
|
load();
|
|
}
|
|
|
|
function nextPage() {
|
|
if ((page.value + 1) * pageSize.value >= total.value) return;
|
|
page.value++;
|
|
load();
|
|
}
|
|
|
|
function docPreview(doc: JsonRecord): string {
|
|
const id = doc._id || "";
|
|
const keys = Object.keys(doc)
|
|
.filter((k) => k !== "_id")
|
|
.slice(0, 3);
|
|
const preview = keys.map((k) => `${k}: ${JSON.stringify(doc[k]).substring(0, 30)}`).join(", ");
|
|
return `${id} - ${preview}`;
|
|
}
|
|
|
|
function highlightedJson(json: string): string {
|
|
const escaped = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
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>`;
|
|
});
|
|
}
|
|
|
|
onMounted(load);
|
|
onBeforeUnmount(() => {
|
|
endTableSearchSplitResize();
|
|
});
|
|
|
|
function tableSearchSplitContainerWidth(): number {
|
|
return tableSearchSplitContainerRef.value?.getBoundingClientRect().width ?? 0;
|
|
}
|
|
|
|
function startTableSearchSplitResize(event: MouseEvent) {
|
|
const containerWidth = tableSearchSplitContainerWidth();
|
|
if (containerWidth <= 0) return;
|
|
event.preventDefault();
|
|
isResizingTableSearchSplit.value = true;
|
|
tableSearchSplitStartX = event.clientX;
|
|
tableSearchSplitStartWidth = clampSearchSplitWidth({
|
|
containerWidth,
|
|
desiredWidth: tableFindPaneWidth.value ?? undefined,
|
|
});
|
|
tableFindPaneWidth.value = tableSearchSplitStartWidth;
|
|
document.body.classList.add("select-none", "cursor-col-resize");
|
|
window.addEventListener("mousemove", moveTableSearchSplitResize);
|
|
window.addEventListener("mouseup", endTableSearchSplitResize);
|
|
}
|
|
|
|
function moveTableSearchSplitResize(event: MouseEvent) {
|
|
if (!isResizingTableSearchSplit.value) return;
|
|
const containerWidth = tableSearchSplitContainerWidth();
|
|
if (containerWidth <= 0) return;
|
|
tableFindPaneWidth.value = clampSearchSplitWidth({
|
|
containerWidth,
|
|
desiredWidth: tableSearchSplitStartWidth + event.clientX - tableSearchSplitStartX,
|
|
});
|
|
}
|
|
|
|
function endTableSearchSplitResize() {
|
|
isResizingTableSearchSplit.value = false;
|
|
document.body.classList.remove("select-none", "cursor-col-resize");
|
|
window.removeEventListener("mousemove", moveTableSearchSplitResize);
|
|
window.removeEventListener("mouseup", endTableSearchSplitResize);
|
|
}
|
|
|
|
function resetTableSearchSplitWidth() {
|
|
const containerWidth = tableSearchSplitContainerWidth();
|
|
tableFindPaneWidth.value = containerWidth > 0 ? clampSearchSplitWidth({ containerWidth }) : null;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-full flex flex-col overflow-hidden">
|
|
<!-- Top toolbar: view toggle + document count + pagination + actions -->
|
|
<div class="h-9 flex items-center gap-1 px-3 border-b shrink-0 text-xs text-muted-foreground">
|
|
<div class="flex items-center border rounded-md overflow-hidden mr-2">
|
|
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none" :class="{ 'bg-accent': viewMode === 'document' }" :title="t('mongo.documentView')" @click="viewMode = 'document'">
|
|
<Braces class="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none" :class="{ 'bg-accent': viewMode === 'table' }" :title="t('mongo.tableView')" @click="viewMode = 'table'">
|
|
<Table2 class="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<span class="shrink-0 ml-1">{{ documentStoreLabels.documentsLabel }}</span>
|
|
|
|
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
|
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
|
|
|
|
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
|
|
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
|
|
<ChevronLeft class="h-3 w-3" />
|
|
</Button>
|
|
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}</span>
|
|
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= total" @click="nextPage">
|
|
<ChevronRight class="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<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-xl 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 items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
|
|
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
|
|
<div class="flex items-center 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?.hiddenColumnCount ?? 0) === 0" @click="dataGridRef?.showAllColumns()">
|
|
{{ t("grid.showAllColumns") }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Popover v-if="viewMode === 'table' && gridResult.columns.length">
|
|
<PopoverTrigger as-child>
|
|
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
|
<Wrench class="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
|
<div class="border-b bg-muted/40 px-3 py-2">
|
|
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
|
|
</div>
|
|
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
|
|
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
|
|
<span class="min-w-0 flex items-center gap-1 font-medium">
|
|
{{ t("grid.hideNullColumns") }}
|
|
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
|
|
</span>
|
|
</label>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<!-- Table view -->
|
|
<DataGrid
|
|
v-if="viewMode === 'table'"
|
|
ref="dataGridRef"
|
|
class="flex-1 min-h-0"
|
|
:result="gridResult"
|
|
context="results"
|
|
editable
|
|
:custom-save-handler="customSaveHandler"
|
|
:loading="loading"
|
|
:sql="documentStoreLabels.queryPreview"
|
|
:page-offset="page * pageSize"
|
|
:page-limit="pageSize"
|
|
:total-row-count="total"
|
|
@sort="onSort"
|
|
@reload="load"
|
|
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
|
|
>
|
|
<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">
|
|
<Popover v-model:open="documentFilterBuilderOpen">
|
|
<PopoverTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="relative flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
|
|
:class="hasLocalColumnFilters || appliedDocumentFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
|
@click="ensureDocumentFilterRule"
|
|
>
|
|
<Filter class="h-3 w-3" />
|
|
<span v-if="localFilterCount + documentStructuredFilterCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">
|
|
{{ localFilterCount + documentStructuredFilterCount }}
|
|
</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" class="w-[360px] max-w-[calc(100vw-24px)] gap-3 p-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
|
|
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
|
|
<Plus class="mr-1 h-3.5 w-3.5" />
|
|
{{ t("grid.filterBuilderAddRule") }}
|
|
</Button>
|
|
</div>
|
|
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
|
|
<Filter class="h-3.5 w-3.5 shrink-0" />
|
|
<span class="truncate">{{ t("grid.localFiltersActive", { count: localFilterCount }) }}</span>
|
|
</div>
|
|
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs" @click="clearLocalFilter()">
|
|
<X class="mr-1 h-3.5 w-3.5" />
|
|
{{ t("grid.clearLocalFiltersShort") }}
|
|
</Button>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-2 rounded border border-primary/10 bg-background/70 px-2 py-1 text-xs">
|
|
<span class="truncate font-medium text-foreground" :title="summary.columnName">
|
|
{{ summary.columnName }}
|
|
</span>
|
|
<span class="min-w-0 truncate font-mono text-muted-foreground">
|
|
<template v-for="(value, valueIndex) in summary.values" :key="valueIndex">
|
|
<span v-if="valueIndex > 0">, </span>
|
|
<span>{{ value }}</span>
|
|
</template>
|
|
<span v-if="summary.hiddenValueCount">
|
|
{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}
|
|
</span>
|
|
</span>
|
|
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="clearLocalFilter(summary.columnIndex)">
|
|
<X class="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="documentFilterRules.length" class="space-y-2">
|
|
<template v-for="(rule, index) in documentFilterRules" :key="rule.id">
|
|
<div v-if="index > 0" class="flex justify-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
|
@click="
|
|
updateDocumentFilterRule(rule.id, {
|
|
conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND',
|
|
})
|
|
"
|
|
>
|
|
{{ rule.conjunction }}
|
|
</Button>
|
|
</div>
|
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto] items-center gap-1.5">
|
|
<Select :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')" />
|
|
</SelectTrigger>
|
|
<SelectContent position="popper">
|
|
<SelectItem v-for="fieldName in documentFilterFieldOptions" :key="fieldName" :value="fieldName">
|
|
{{ fieldName }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
|
|
<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 />
|
|
</SelectTrigger>
|
|
<SelectContent position="popper">
|
|
<SelectItem v-for="option in documentFilterModeOptions" :key="option.value" :value="option.value">
|
|
{{ t(option.labelKey) }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Input
|
|
v-if="documentFilterModeNeedsValue(rule.mode)"
|
|
:model-value="rule.rawValue"
|
|
class="h-8 min-w-0 text-xs"
|
|
:placeholder="t('grid.filterBuilderValue')"
|
|
@update:model-value="(value) => updateDocumentFilterRule(rule.id, { rawValue: String(value ?? '') })"
|
|
@keydown.enter.prevent="applyDocumentStructuredFilters"
|
|
/>
|
|
<div v-else class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground">
|
|
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="documentFilterRules.length === 1" @click="removeDocumentFilterRule(rule.id)">
|
|
<Trash2 class="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div v-else class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
|
{{ t("grid.filterBuilderEmpty") }}
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between gap-2 pt-1">
|
|
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearDocumentFilters(clearLocalFilter)">
|
|
{{ t("grid.clearFilter") }}
|
|
</Button>
|
|
<div class="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetDocumentFilterBuilder">
|
|
{{ t("grid.resetFilterBuilder") }}
|
|
</Button>
|
|
<Button size="sm" class="h-8 px-3 text-xs" @click="applyDocumentStructuredFilters">
|
|
{{ t("grid.applyFilter") }}
|
|
</Button>
|
|
</div>
|
|
</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" />
|
|
<button
|
|
v-if="filterInput.trim()"
|
|
class="text-muted-foreground hover:text-foreground shrink-0"
|
|
@click="
|
|
filterInput = '';
|
|
applyFilter();
|
|
"
|
|
>
|
|
<X class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="group relative flex w-2 shrink-0 cursor-col-resize items-center justify-center border-l border-r border-border/80 bg-muted/15 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
|
aria-label="Resize document filter and sort"
|
|
@mousedown="startTableSearchSplitResize"
|
|
@dblclick.stop="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" />
|
|
<button
|
|
v-if="sortInput.trim()"
|
|
class="text-muted-foreground hover:text-foreground shrink-0"
|
|
@click="
|
|
sortInput = '';
|
|
applyFilter();
|
|
"
|
|
>
|
|
<X class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex shrink-0 items-center gap-1 border-l px-1">
|
|
<Button variant="ghost" size="sm" class="h-5 shrink-0 gap-1 px-1.5 text-xs" :disabled="loading" @click="load">
|
|
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
|
|
<RefreshCcw v-else class="h-3 w-3" />
|
|
{{ t("grid.refresh") }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</DataGrid>
|
|
|
|
<!-- Document view (split pane) -->
|
|
<Splitpanes v-else class="flex-1 min-h-0">
|
|
<!-- Document list (left) -->
|
|
<Pane :size="30" :min-size="15" :max-size="50">
|
|
<div class="h-full flex flex-col overflow-hidden">
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div v-for="(doc, idx) in documents" :key="idx" class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group" :class="{ 'bg-accent': selectedIdx === idx }" @click="selectDoc(idx)">
|
|
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
|
|
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
|
|
<Trash2 class="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
|
{{ t("mongo.emptyCollection") }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Pane>
|
|
|
|
<!-- Document viewer/editor (right) -->
|
|
<Pane :size="70">
|
|
<div class="h-full flex flex-col min-w-0 overflow-hidden">
|
|
<template v-if="selectedIdx !== null || isNew">
|
|
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
|
|
<Badge variant="secondary" class="text-xs">{{ isNew ? "New" : selectedDoc?._id }}</Badge>
|
|
<span class="flex-1" />
|
|
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
|
|
<template v-if="isEditing">
|
|
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
|
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="cancelEdit">{{ t("grid.discard") }}</Button>
|
|
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="isEditing" class="flex-1 overflow-auto bg-muted/10">
|
|
<div class="json-edit min-w-fit p-5 font-mono text-[13px] leading-6" :style="{ '--mongo-key-width': editKeyWidth }">
|
|
<div class="json-edit-brace">{</div>
|
|
|
|
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!field.readonlyValue" @remove="requestRemoveField(idx)" />
|
|
|
|
<Button variant="ghost" size="sm" class="json-edit-add" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
|
|
|
|
<div class="json-edit-brace">}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
|
<pre class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6" v-html="highlightedJson(editJson)" />
|
|
</div>
|
|
</template>
|
|
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
|
{{ t("mongo.selectDocument") }}
|
|
</div>
|
|
|
|
<ErrorBanner v-if="error" :message="error" />
|
|
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
|
|
</div>
|
|
</Pane>
|
|
</Splitpanes>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.json-viewer {
|
|
tab-size: 2;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.json-edit {
|
|
tab-size: 2;
|
|
color: var(--foreground);
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.json-edit-brace {
|
|
color: var(--muted-foreground);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.json-edit-add {
|
|
margin: 6px 0 6px 2ch;
|
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
}
|
|
|
|
:deep(.json-key) {
|
|
color: #7c3aed;
|
|
font-weight: 600;
|
|
}
|
|
|
|
:deep(.json-string) {
|
|
color: #15803d;
|
|
}
|
|
|
|
:deep(.json-number) {
|
|
color: #b45309;
|
|
}
|
|
|
|
:deep(.json-boolean) {
|
|
color: #2563eb;
|
|
font-weight: 600;
|
|
}
|
|
|
|
:deep(.json-null) {
|
|
color: #64748b;
|
|
font-style: italic;
|
|
}
|
|
|
|
:global(.dark) :deep(.json-key) {
|
|
color: #c4b5fd;
|
|
}
|
|
|
|
:global(.dark) :deep(.json-string) {
|
|
color: #86efac;
|
|
}
|
|
|
|
:global(.dark) :deep(.json-number) {
|
|
color: #fbbf24;
|
|
}
|
|
|
|
:global(.dark) :deep(.json-boolean) {
|
|
color: #93c5fd;
|
|
}
|
|
|
|
:global(.dark) :deep(.json-null) {
|
|
color: #94a3b8;
|
|
}
|
|
</style>
|