feat(mongodb): improve GridFS manager filtering and sorting
* docs: add GridFS manager filter and sort design * feat: improve GridFS manager filtering and sorting
This commit is contained in:
parent
26647ff755
commit
fa5e55a376
|
|
@ -1,13 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Download, RefreshCcw, Trash2, Upload } from "@lucide/vue";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Filter, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildDocumentFilterCondition, currentDocumentFilterJson, currentDocumentSortJson, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
|
||||
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, currentGridFsFileSortDirection, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -27,11 +33,38 @@ const error = ref("");
|
|||
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
|
||||
const selectedFileId = ref("");
|
||||
const showDeleteConfirm = ref(false);
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterBuilderOpen = ref(false);
|
||||
const filterRules = ref<DocumentFilterRule[]>([createGridFsFileFilterRule(uuid())]);
|
||||
const appliedStructuredFilter = ref<Record<string, unknown> | null>(null);
|
||||
|
||||
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
|
||||
const selectedFile = computed(() => files.value.find((file) => file.id === selectedFileId.value) || null);
|
||||
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
|
||||
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
|
||||
const mongoProvider = documentStoreProviderFor("mongodb");
|
||||
const activeStructuredRuleCount = computed(() => {
|
||||
if (!appliedStructuredFilter.value) return 0;
|
||||
return filterRules.value.filter((rule) => !!buildDocumentFilterCondition(rule, { kind: "mongodb" })).length;
|
||||
});
|
||||
const filterBuilderActive = computed(() => !!appliedStructuredFilter.value);
|
||||
const filesQueryPreview = computed(() => {
|
||||
let filter = "{}";
|
||||
let sort: string | undefined;
|
||||
try {
|
||||
filter = currentFilesFilter() || "{}";
|
||||
sort = currentDocumentSortJson(sortInput.value);
|
||||
} catch {
|
||||
filter = filterInput.value.trim() || "{}";
|
||||
sort = sortInput.value.trim() || undefined;
|
||||
}
|
||||
return gridFsFilesQueryPreview({
|
||||
bucket: props.bucket,
|
||||
filterJson: filter,
|
||||
sortJson: sort,
|
||||
});
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
|
@ -44,11 +77,55 @@ function displayName(file: (typeof files.value)[number]): string {
|
|||
return file.filename || file.id;
|
||||
}
|
||||
|
||||
function gridFsFileFieldLabel(fieldName: string): string {
|
||||
const option = gridFsFileFieldDisplayOption(fieldName);
|
||||
if (!option) return fieldName;
|
||||
if (option.label !== undefined) return option.label;
|
||||
if (option.labelKey !== undefined) return t(option.labelKey);
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
function currentFilesFilter(): string | undefined {
|
||||
return currentDocumentFilterJson(filterInput.value, appliedStructuredFilter.value, "mongodb");
|
||||
}
|
||||
|
||||
function ensureFilterRule() {
|
||||
if (filterRules.value.length === 0) {
|
||||
filterRules.value = [createGridFsFileFilterRule(uuid())];
|
||||
}
|
||||
}
|
||||
|
||||
function addFilterRule() {
|
||||
ensureFilterRule();
|
||||
filterRules.value = [...filterRules.value, createGridFsFileFilterRule(uuid())];
|
||||
}
|
||||
|
||||
function removeFilterRule(ruleId: string) {
|
||||
filterRules.value = filterRules.value.filter((rule) => rule.id !== ruleId);
|
||||
if (filterRules.value.length === 0) {
|
||||
appliedStructuredFilter.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilterRule(ruleId: string, patch: Partial<DocumentFilterRule>) {
|
||||
filterRules.value = filterRules.value.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const next = { ...rule, ...patch };
|
||||
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function resetFilterBuilder() {
|
||||
appliedStructuredFilter.value = null;
|
||||
filterRules.value = [createGridFsFileFilterRule(uuid())];
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket);
|
||||
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
|
||||
files.value = nextFiles;
|
||||
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
|
||||
selectedFileId.value = "";
|
||||
|
|
@ -60,6 +137,46 @@ async function loadFiles() {
|
|||
}
|
||||
}
|
||||
|
||||
function applyQuery() {
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
type SortDirection = "asc" | "desc" | null;
|
||||
|
||||
function currentSortDirection(column: string): SortDirection {
|
||||
const direction = currentGridFsFileSortDirection(sortInput.value, column);
|
||||
return direction === "none" ? null : direction;
|
||||
}
|
||||
|
||||
function toggleSortForColumn(column: string) {
|
||||
const current = currentSortDirection(column);
|
||||
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
|
||||
sortInput.value = mongoProvider.sortInputForColumn(column, nextDirection);
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
function sortIconForColumn(column: string) {
|
||||
const direction = currentSortDirection(column);
|
||||
return direction === "asc" ? ArrowUp : direction === "desc" ? ArrowDown : ArrowUpDown;
|
||||
}
|
||||
|
||||
function sortIconClass(column: string): string {
|
||||
return currentSortDirection(column) ? "text-foreground" : "text-muted-foreground/60";
|
||||
}
|
||||
|
||||
function applyStructuredFilters() {
|
||||
appliedStructuredFilter.value = buildGridFsFilesStructuredFilter(filterRules.value);
|
||||
filterBuilderOpen.value = false;
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
filterInput.value = "";
|
||||
appliedStructuredFilter.value = null;
|
||||
filterRules.value = [createGridFsFileFilterRule(uuid())];
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
async function downloadFile(file: (typeof files.value)[number]) {
|
||||
try {
|
||||
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
|
||||
|
|
@ -174,6 +291,143 @@ onMounted(() => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-background/80 shadow-xs">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
|
||||
<Popover v-model:open="filterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-[11px] transition-colors"
|
||||
:class="filterBuilderActive || filterInput.trim() ? '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="ensureFilterRule"
|
||||
>
|
||||
<Filter class="h-3.5 w-3.5" />
|
||||
<span v-if="activeStructuredRuleCount" 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">
|
||||
{{ activeStructuredRuleCount }}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[420px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
|
||||
<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="addFilterRule">
|
||||
<Plus class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("grid.filterBuilderAddRule") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="filterRules.length" class="space-y-2">
|
||||
<template v-for="(rule, index) in filterRules" :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="
|
||||
updateFilterRule(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) => updateFilterRule(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="option in gridFsFileFieldDisplayOptions" :key="option.fieldName" :value="option.fieldName">
|
||||
{{ gridFsFileFieldLabel(option.fieldName) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select :model-value="rule.mode" @update:model-value="(value: any) => updateFilterRule(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) => updateFilterRule(rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown.enter.prevent="applyStructuredFilters"
|
||||
/>
|
||||
<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="filterRules.length === 1" @click="removeFilterRule(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="clearAllFilters">
|
||||
{{ t("grid.clearFilter") }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetFilterBuilder">
|
||||
{{ t("grid.resetFilterBuilder") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-8 px-3 text-xs" @click="applyStructuredFilters">
|
||||
{{ t("grid.applyFilter") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="shrink-0 text-xs font-medium text-blue-600 dark:text-blue-400">{{ mongoProvider.filterInputLabel }}</span>
|
||||
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" placeholder="{}" @keydown.enter="applyQuery" />
|
||||
<button
|
||||
v-if="filterInput.trim()"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="
|
||||
filterInput = '';
|
||||
applyQuery();
|
||||
"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-px bg-border/70 md:h-auto md:w-px" />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
|
||||
<span class="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">{{ mongoProvider.sortInputLabel }}</span>
|
||||
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" placeholder='{"uploadDate":-1}' @keydown.enter="applyQuery" />
|
||||
<button
|
||||
v-if="sortInput.trim()"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="
|
||||
sortInput = '';
|
||||
applyQuery();
|
||||
"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border/60 px-3 py-1.5">
|
||||
<div class="truncate font-mono text-[11px] text-muted-foreground" :title="filesQueryPreview">
|
||||
{{ filesQueryPreview }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
|
||||
|
|
@ -193,13 +447,48 @@ onMounted(() => {
|
|||
<table class="min-w-full border-collapse text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr class="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.name") }}</th>
|
||||
<th class="px-4 py-2 font-medium">ID</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.totalSize") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.chunkSize") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.uploadDate") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.contentType") }}</th>
|
||||
<th class="px-4 py-2 font-medium">MD5</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('filename')">
|
||||
<span>{{ gridFsFileFieldLabel("filename") }}</span>
|
||||
<component :is="sortIconForColumn('filename')" class="h-3.5 w-3.5" :class="sortIconClass('filename')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('_id')">
|
||||
<span>{{ gridFsFileFieldLabel("_id") }}</span>
|
||||
<component :is="sortIconForColumn('_id')" class="h-3.5 w-3.5" :class="sortIconClass('_id')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('length')">
|
||||
<span>{{ gridFsFileFieldLabel("length") }}</span>
|
||||
<component :is="sortIconForColumn('length')" class="h-3.5 w-3.5" :class="sortIconClass('length')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
|
||||
<span>{{ gridFsFileFieldLabel("chunkSize") }}</span>
|
||||
<component :is="sortIconForColumn('chunkSize')" class="h-3.5 w-3.5" :class="sortIconClass('chunkSize')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
|
||||
<span>{{ gridFsFileFieldLabel("uploadDate") }}</span>
|
||||
<component :is="sortIconForColumn('uploadDate')" class="h-3.5 w-3.5" :class="sortIconClass('uploadDate')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('contentType')">
|
||||
<span>{{ gridFsFileFieldLabel("contentType") }}</span>
|
||||
<component :is="sortIconForColumn('contentType')" class="h-3.5 w-3.5" :class="sortIconClass('contentType')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('md5')">
|
||||
<span>{{ gridFsFileFieldLabel("md5") }}</span>
|
||||
<component :is="sortIconForColumn('md5')" class="h-3.5 w-3.5" :class="sortIconClass('md5')" />
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FolderOpen, Plus, RefreshCcw, Trash2 } from "@lucide/vue";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Filter, FolderOpen, Plus, RefreshCcw, Trash2, X } from "@lucide/vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
|
|
@ -30,6 +32,9 @@ const selectedBucketName = ref("");
|
|||
const showCreateDialog = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const newBucketName = ref("");
|
||||
const filterInput = ref("");
|
||||
const sortInput = ref("");
|
||||
const filterBuilderOpen = ref(false);
|
||||
|
||||
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
|
||||
const totalFiles = computed(() => buckets.value.reduce((sum, bucket) => sum + bucket.fileCount, 0));
|
||||
|
|
@ -47,7 +52,7 @@ async function loadBuckets() {
|
|||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database);
|
||||
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database, currentGridFsBucketFilter(filterInput.value), currentGridFsBucketSort(sortInput.value));
|
||||
buckets.value = nextBuckets;
|
||||
if (selectedBucketName.value && !nextBuckets.some((bucket) => bucket.name === selectedBucketName.value)) {
|
||||
selectedBucketName.value = "";
|
||||
|
|
@ -59,6 +64,48 @@ async function loadBuckets() {
|
|||
}
|
||||
}
|
||||
|
||||
function applyQuery() {
|
||||
void loadBuckets();
|
||||
}
|
||||
|
||||
type SortDirection = "asc" | "desc" | null;
|
||||
|
||||
function currentSortDirection(column: "name" | "fileCount" | "totalBytes"): SortDirection {
|
||||
const direction = currentGridFsBucketSortDirection(sortInput.value, column);
|
||||
return direction === "none" ? null : direction;
|
||||
}
|
||||
|
||||
function toggleSortForColumn(column: "name" | "fileCount" | "totalBytes") {
|
||||
const current = currentSortDirection(column);
|
||||
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
|
||||
sortInput.value = gridFsBucketSortInputForColumn(column, nextDirection);
|
||||
void loadBuckets();
|
||||
}
|
||||
|
||||
function sortIconForColumn(column: "name" | "fileCount" | "totalBytes") {
|
||||
const direction = currentSortDirection(column);
|
||||
return direction === "asc" ? ArrowUp : direction === "desc" ? ArrowDown : ArrowUpDown;
|
||||
}
|
||||
|
||||
function sortIconClass(column: "name" | "fileCount" | "totalBytes"): string {
|
||||
return currentSortDirection(column) ? "text-foreground" : "text-muted-foreground/60";
|
||||
}
|
||||
|
||||
function applyBucketNameFilter() {
|
||||
filterBuilderOpen.value = false;
|
||||
void loadBuckets();
|
||||
}
|
||||
|
||||
function resetBucketNameFilter() {
|
||||
filterInput.value = "";
|
||||
}
|
||||
|
||||
function clearBucketNameFilter() {
|
||||
filterInput.value = "";
|
||||
filterBuilderOpen.value = false;
|
||||
void loadBuckets();
|
||||
}
|
||||
|
||||
function openBucket(bucketName = selectedBucketName.value) {
|
||||
if (!bucketName) return;
|
||||
queryStore.openMongoBucket(props.connectionId, props.database, bucketName);
|
||||
|
|
@ -131,6 +178,77 @@ onMounted(() => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 overflow-hidden rounded-xl border border-border/70 bg-background/80 shadow-xs">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
|
||||
<Popover v-model:open="filterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border text-[11px] transition-colors"
|
||||
:class="filterInput.trim() ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||||
>
|
||||
<Filter class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[420px] max-w-[calc(100vw-24px)] gap-3 p-3" @click.stop @keydown.stop>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)] items-center gap-1.5">
|
||||
<div class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border px-2 text-xs font-medium text-foreground">
|
||||
<span class="truncate">{{ t("gridfsBrowser.name") }}</span>
|
||||
</div>
|
||||
<div class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border px-2 text-xs text-muted-foreground">
|
||||
<span class="truncate">{{ t("grid.filterBuilderContains") }}</span>
|
||||
</div>
|
||||
<Input v-model="filterInput" class="h-8 min-w-0 text-xs" :placeholder="t('gridfsBrowser.bucketNamePlaceholder')" @keydown.enter.prevent="applyBucketNameFilter" />
|
||||
</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="clearBucketNameFilter">
|
||||
{{ t("grid.clearFilter") }}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetBucketNameFilter">
|
||||
{{ t("grid.resetFilterBuilder") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-8 px-3 text-xs" @click="applyBucketNameFilter">
|
||||
{{ t("grid.applyFilter") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="shrink-0 text-xs font-medium text-blue-600 dark:text-blue-400">{{ t("grid.filter") }}</span>
|
||||
<input v-model="filterInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/60" :placeholder="t('gridfsBrowser.bucketNamePlaceholder')" @keydown.enter="applyQuery" />
|
||||
<button
|
||||
v-if="filterInput.trim()"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="
|
||||
filterInput = '';
|
||||
applyQuery();
|
||||
"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-px bg-border/70 md:h-auto md:w-px" />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5">
|
||||
<span class="shrink-0 text-xs font-medium text-orange-600 dark:text-orange-400">{{ t("grid.sort") }}</span>
|
||||
<input v-model="sortInput" autocapitalize="off" autocorrect="off" spellcheck="false" class="h-7 min-w-0 flex-1 bg-transparent font-mono text-xs outline-none placeholder:text-muted-foreground/60" placeholder='{"name":1}' @keydown.enter="applyQuery" />
|
||||
<button
|
||||
v-if="sortInput.trim()"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="
|
||||
sortInput = '';
|
||||
applyQuery();
|
||||
"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-4 py-3 text-sm text-destructive">
|
||||
|
|
@ -150,9 +268,24 @@ onMounted(() => {
|
|||
<table class="min-w-full border-collapse text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-background">
|
||||
<tr class="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.name") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.fileCount") }}</th>
|
||||
<th class="px-4 py-2 font-medium">{{ t("gridfsBrowser.totalSize") }}</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('name')">
|
||||
<span>{{ t("gridfsBrowser.name") }}</span>
|
||||
<component :is="sortIconForColumn('name')" class="h-3.5 w-3.5" :class="sortIconClass('name')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('fileCount')">
|
||||
<span>{{ t("gridfsBrowser.fileCount") }}</span>
|
||||
<component :is="sortIconForColumn('fileCount')" class="h-3.5 w-3.5" :class="sortIconClass('fileCount')" />
|
||||
</button>
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium">
|
||||
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('totalBytes')">
|
||||
<span>{{ t("gridfsBrowser.totalSize") }}</span>
|
||||
<component :is="sortIconForColumn('totalBytes')" class="h-3.5 w-3.5" :class="sortIconClass('totalBytes')" />
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -354,9 +354,9 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
|||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "mongo-gridfs":
|
||||
case "mongo-buckets":
|
||||
return { icon: Archive, colorClass: "text-amber-500" };
|
||||
return { icon: Archive, colorClass: "text-cyan-500" };
|
||||
case "mongo-bucket":
|
||||
return { icon: Archive, colorClass: "text-amber-400" };
|
||||
return { icon: Archive, colorClass: "text-cyan-400" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
case "vector-collection":
|
||||
|
|
|
|||
|
|
@ -1893,12 +1893,12 @@ export async function documentFindDocuments(connectionId: string, database: stri
|
|||
return post("/api/document-store/find-documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
|
||||
}
|
||||
|
||||
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string): Promise<MongoGridFsFileInfo[]> {
|
||||
return post("/api/document-store/list-gridfs-files", { connectionId, database, bucket });
|
||||
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string, filter?: string, sort?: string): Promise<MongoGridFsFileInfo[]> {
|
||||
return post("/api/document-store/list-gridfs-files", { connectionId, database, bucket, filter, sort });
|
||||
}
|
||||
|
||||
export async function documentListGridFsBuckets(connectionId: string, database: string): Promise<MongoGridFsBucketInfo[]> {
|
||||
return post("/api/document-store/list-gridfs-buckets", { connectionId, database });
|
||||
export async function documentListGridFsBuckets(connectionId: string, database: string, filter?: string, sort?: string): Promise<MongoGridFsBucketInfo[]> {
|
||||
return post("/api/document-store/list-gridfs-buckets", { connectionId, database, filter, sort });
|
||||
}
|
||||
|
||||
export async function documentCreateGridFsBucket(connectionId: string, database: string, bucket: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1626,12 +1626,12 @@ export async function documentFindDocuments(connectionId: string, database: stri
|
|||
return invoke("document_find_documents", { connectionId, database, collection, skip, limit, filter, projection, sort, executionId });
|
||||
}
|
||||
|
||||
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string): Promise<MongoGridFsFileInfo[]> {
|
||||
return invoke("document_list_gridfs_files", { connectionId, database, bucket });
|
||||
export async function documentListGridFsFiles(connectionId: string, database: string, bucket: string, filter?: string, sort?: string): Promise<MongoGridFsFileInfo[]> {
|
||||
return invoke("document_list_gridfs_files", { connectionId, database, bucket, filter, sort });
|
||||
}
|
||||
|
||||
export async function documentListGridFsBuckets(connectionId: string, database: string): Promise<MongoGridFsBucketInfo[]> {
|
||||
return invoke("document_list_gridfs_buckets", { connectionId, database });
|
||||
export async function documentListGridFsBuckets(connectionId: string, database: string, filter?: string, sort?: string): Promise<MongoGridFsBucketInfo[]> {
|
||||
return invoke("document_list_gridfs_buckets", { connectionId, database, filter, sort });
|
||||
}
|
||||
|
||||
export async function documentCreateGridFsBucket(connectionId: string, database: string, bucket: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { buildDocumentFilterCondition, combineDocumentFilterConditions, defaultDocumentFilterRule, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
|
||||
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
|
||||
|
||||
export type GridFsBucketSortField = "name" | "fileCount" | "totalBytes";
|
||||
export type GridFsBucketSortDirection = "asc" | "desc";
|
||||
export type GridFsSortDirectionState = "none" | "asc" | "desc";
|
||||
export type GridFsFileField = "_id" | "filename" | "contentType" | "length" | "chunkSize" | "uploadDate" | "md5";
|
||||
export type GridFsFileFieldDisplayOption = { fieldName: GridFsFileField; label: string; labelKey?: never } | { fieldName: GridFsFileField; labelKey: string; label?: never };
|
||||
export type GridFsBucketSort = {
|
||||
field: GridFsBucketSortField;
|
||||
direction: GridFsBucketSortDirection;
|
||||
};
|
||||
|
||||
export const gridFsFileFilterFieldOptions = ["_id", "filename", "contentType", "length", "chunkSize", "uploadDate", "md5"] as const satisfies readonly GridFsFileField[];
|
||||
export const gridFsFileFieldDisplayOptions = [
|
||||
{ fieldName: "_id", label: "ID" },
|
||||
{ fieldName: "filename", labelKey: "gridfsBrowser.name" },
|
||||
{ fieldName: "contentType", labelKey: "gridfsBrowser.contentType" },
|
||||
{ fieldName: "length", labelKey: "gridfsBrowser.totalSize" },
|
||||
{ fieldName: "chunkSize", labelKey: "gridfsBrowser.chunkSize" },
|
||||
{ fieldName: "uploadDate", labelKey: "gridfsBrowser.uploadDate" },
|
||||
{ fieldName: "md5", label: "MD5" },
|
||||
] as const satisfies readonly GridFsFileFieldDisplayOption[];
|
||||
|
||||
export function gridFsFilesQueryPreview(options: { bucket: string; filterJson?: string; sortJson?: string }): string {
|
||||
const parts = [`db.getCollection(${JSON.stringify(`${options.bucket}.files`)}).find(${mongoShellPreviewLiteral(options.filterJson || "{}")})`];
|
||||
if (options.sortJson?.trim()) parts.push(`.sort(${mongoShellPreviewLiteral(options.sortJson)})`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function currentGridFsBucketFilter(input: string): string | undefined {
|
||||
const trimmed = input.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function createGridFsFileFilterRule(id: string): DocumentFilterRule {
|
||||
return defaultDocumentFilterRule(id, gridFsFileFilterFieldOptions[0] ?? "");
|
||||
}
|
||||
|
||||
export function gridFsFileFieldDisplayOption(fieldName: string): GridFsFileFieldDisplayOption | null {
|
||||
return gridFsFileFieldDisplayOptions.find((option) => option.fieldName === fieldName) ?? null;
|
||||
}
|
||||
|
||||
export function buildGridFsFilesStructuredFilter(rules: DocumentFilterRule[]): Record<string, unknown> | null {
|
||||
const items = rules
|
||||
.map((rule) => ({
|
||||
rule,
|
||||
condition: buildDocumentFilterCondition(rule, { kind: "mongodb" }),
|
||||
}))
|
||||
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
|
||||
return combineDocumentFilterConditions(
|
||||
items.map((item) => item.condition),
|
||||
items.map((item) => item.rule),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseGridFsBucketSort(input?: string): GridFsBucketSort | null {
|
||||
const trimmed = input?.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = JSON.parse(quoteUnquotedObjectKeys(trimmed)) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("GridFS bucket sort must be a JSON object");
|
||||
}
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error("GridFS bucket sort must contain exactly one field");
|
||||
}
|
||||
const [field, rawDirection] = entries[0]!;
|
||||
if (field !== "name" && field !== "fileCount" && field !== "totalBytes") {
|
||||
throw new Error("Unsupported GridFS bucket sort field");
|
||||
}
|
||||
const direction = normalizeGridFsBucketSortDirection(rawDirection);
|
||||
if (!direction) {
|
||||
throw new Error("GridFS bucket sort direction must be 1, -1, 'asc', or 'desc'");
|
||||
}
|
||||
return { field, direction };
|
||||
}
|
||||
|
||||
export function currentGridFsBucketSort(input: string): string | undefined {
|
||||
const sort = parseGridFsBucketSort(input);
|
||||
if (!sort) return undefined;
|
||||
return JSON.stringify({ [sort.field]: sort.direction === "desc" ? -1 : 1 });
|
||||
}
|
||||
|
||||
export function currentGridFsFileSortDirection(input: string, column: string): GridFsSortDirectionState {
|
||||
const sort = parseSingleFieldSort(input);
|
||||
if (!sort || sort.field !== column) return "none";
|
||||
return sort.direction;
|
||||
}
|
||||
|
||||
export function currentGridFsBucketSortDirection(input: string, column: GridFsBucketSortField): GridFsSortDirectionState {
|
||||
try {
|
||||
const sort = parseGridFsBucketSort(input);
|
||||
if (!sort || sort.field !== column) return "none";
|
||||
return sort.direction;
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function gridFsBucketSortInputForColumn(column: string, direction: GridFsBucketSortDirection | null): string {
|
||||
if (!direction) return "";
|
||||
if (column !== "name" && column !== "fileCount" && column !== "totalBytes") return "";
|
||||
return JSON.stringify({ [column]: direction === "desc" ? -1 : 1 });
|
||||
}
|
||||
|
||||
function normalizeGridFsBucketSortDirection(value: unknown): GridFsBucketSortDirection | null {
|
||||
if (value === -1 || value === "-1" || value === "desc") return "desc";
|
||||
if (value === 1 || value === "1" || value === "asc") return "asc";
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSingleFieldSort(input?: string): { field: string; direction: GridFsBucketSortDirection } | null {
|
||||
const trimmed = input?.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(quoteUnquotedObjectKeys(trimmed)) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length !== 1) return null;
|
||||
const [field, rawDirection] = entries[0]!;
|
||||
const direction = normalizeGridFsBucketSortDirection(rawDirection);
|
||||
if (!direction) return null;
|
||||
return { field, direction };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mongoShellPreviewLiteral(json: string): string {
|
||||
const trimmed = json.trim();
|
||||
if (!trimmed) return "{}";
|
||||
try {
|
||||
return formatMongoShellLiteral(JSON.parse(quoteUnquotedObjectKeys(trimmed)));
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import type { Component } from "vue";
|
||||
import { Archive, Braces, Columns3, Database, Eye, FileCode, FolderClosed, FolderOpen, Key, Link, ListTree, Network, Package, Plus, ScrollText, Server, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
|
||||
import type { ColumnInfo, TreeNode } from "@/types/database";
|
||||
|
||||
export type TreeNodeIconInfo = {
|
||||
icon: Component;
|
||||
colorClass: string;
|
||||
};
|
||||
|
||||
export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null {
|
||||
switch (node.type) {
|
||||
case "schema":
|
||||
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-amber-500" };
|
||||
case "connection":
|
||||
case "database":
|
||||
case "mongo-db":
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "linked-server-root":
|
||||
return { icon: Network, colorClass: "text-blue-500" };
|
||||
case "linked-server":
|
||||
return { icon: Server, colorClass: "text-blue-400" };
|
||||
case "linked-server-catalog":
|
||||
case "linked-server-schema":
|
||||
case "mq-tenant":
|
||||
return { icon: FolderOpen, colorClass: "text-sky-400" };
|
||||
case "nacos-namespace":
|
||||
case "etcd-root":
|
||||
return { icon: FolderOpen, colorClass: "text-sky-500" };
|
||||
case "zookeeper-root":
|
||||
return { icon: Database, colorClass: "text-blue-500" };
|
||||
case "table":
|
||||
return { icon: Table, colorClass: "text-green-500" };
|
||||
case "view":
|
||||
return { icon: Eye, colorClass: "text-purple-500" };
|
||||
case "materialized_view":
|
||||
return { icon: Eye, colorClass: "text-indigo-500" };
|
||||
case "column":
|
||||
return { icon: Columns3, colorClass: (node.meta as ColumnInfo | undefined)?.is_primary_key ? "text-orange-400" : "text-muted-foreground" };
|
||||
case "group-columns":
|
||||
return { icon: ListTree, colorClass: "text-green-400" };
|
||||
case "group-indexes":
|
||||
case "index":
|
||||
return { icon: Key, colorClass: "text-amber-500" };
|
||||
case "group-fkeys":
|
||||
return { icon: Link, colorClass: "text-blue-400" };
|
||||
case "fkey":
|
||||
return { icon: Link, colorClass: "text-blue-300" };
|
||||
case "group-triggers":
|
||||
return { icon: Zap, colorClass: "text-orange-400" };
|
||||
case "trigger":
|
||||
return { icon: Zap, colorClass: "text-orange-300" };
|
||||
case "object-browser":
|
||||
return { icon: TableProperties, colorClass: "text-primary" };
|
||||
case "user-admin":
|
||||
return { icon: UsersRound, colorClass: "text-primary" };
|
||||
case "redis-db":
|
||||
return { icon: Database, colorClass: "text-red-400" };
|
||||
case "mongo-gridfs":
|
||||
case "mongo-buckets":
|
||||
return { icon: Archive, colorClass: "text-cyan-500" };
|
||||
case "mongo-bucket":
|
||||
return { icon: Archive, colorClass: "text-cyan-400" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
case "vector-collection":
|
||||
return { icon: TableProperties, colorClass: "text-cyan-400" };
|
||||
case "elasticsearch-index":
|
||||
return { icon: Table, colorClass: "text-emerald-400" };
|
||||
case "procedure":
|
||||
return { icon: ScrollText, colorClass: "text-blue-500" };
|
||||
case "function":
|
||||
return { icon: Braces, colorClass: "text-amber-500" };
|
||||
case "sequence":
|
||||
return { icon: ListTree, colorClass: "text-emerald-500" };
|
||||
case "package":
|
||||
return { icon: Package, colorClass: "text-cyan-500" };
|
||||
case "package-body":
|
||||
return { icon: FileCode, colorClass: "text-cyan-400" };
|
||||
case "group-tables":
|
||||
return { icon: Table, colorClass: "text-green-500" };
|
||||
case "group-views":
|
||||
return { icon: Eye, colorClass: "text-purple-500" };
|
||||
case "group-materialized-views":
|
||||
return { icon: Eye, colorClass: "text-indigo-500" };
|
||||
case "group-procedures":
|
||||
return { icon: ScrollText, colorClass: "text-blue-500" };
|
||||
case "group-functions":
|
||||
return { icon: Braces, colorClass: "text-amber-500" };
|
||||
case "group-sequences":
|
||||
return { icon: ListTree, colorClass: "text-emerald-500" };
|
||||
case "group-packages":
|
||||
return { icon: Package, colorClass: "text-cyan-500" };
|
||||
case "group-partitions":
|
||||
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" };
|
||||
case "group-extensions":
|
||||
return { icon: Package, colorClass: "text-violet-500" };
|
||||
case "extension":
|
||||
return { icon: Package, colorClass: "text-violet-400" };
|
||||
case "load-more":
|
||||
return { icon: Plus, colorClass: "text-primary" };
|
||||
default:
|
||||
return { icon: Database, colorClass: "text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
|
@ -207,12 +207,15 @@ pub async fn list_gridfs_files(
|
|||
client: &Client,
|
||||
database: &str,
|
||||
bucket: &str,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
) -> Result<Vec<MongoGridFsFileInfo>, String> {
|
||||
let bucket = normalized_gridfs_bucket_name(bucket)?;
|
||||
let collection_name = format!("{bucket}.files");
|
||||
let collection = client.database(database).collection::<Document>(&collection_name);
|
||||
let mut cursor =
|
||||
collection.find(doc! {}).sort(doc! { "uploadDate": -1_i32, "_id": -1_i32 }).await.map_err(|e| e.to_string())?;
|
||||
let filter_doc = gridfs_file_filter_document(filter)?;
|
||||
let sort_doc = gridfs_file_sort_document(sort)?;
|
||||
let mut cursor = collection.find(filter_doc).sort(sort_doc).await.map_err(|e| e.to_string())?;
|
||||
let mut files = Vec::new();
|
||||
while cursor.advance().await.map_err(|e| e.to_string())? {
|
||||
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
|
||||
|
|
@ -221,6 +224,26 @@ pub async fn list_gridfs_files(
|
|||
Ok(files)
|
||||
}
|
||||
|
||||
fn gridfs_file_filter_document(filter: Option<&str>) -> Result<Document, String> {
|
||||
match filter {
|
||||
Some(raw) if !raw.trim().is_empty() => {
|
||||
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid filter JSON: {e}"))?;
|
||||
json_filter_to_document(&json)
|
||||
}
|
||||
_ => Ok(doc! {}),
|
||||
}
|
||||
}
|
||||
|
||||
fn gridfs_file_sort_document(sort: Option<&str>) -> Result<Document, String> {
|
||||
match sort {
|
||||
Some(raw) if !raw.trim().is_empty() => {
|
||||
let json: serde_json::Value = serde_json::from_str(raw).map_err(|e| format!("Invalid sort JSON: {e}"))?;
|
||||
json_object_to_document(&json).map_err(|e| format!("Invalid sort: {e}"))
|
||||
}
|
||||
_ => Ok(doc! { "uploadDate": -1_i32, "_id": -1_i32 }),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn gridfs_bucket_summary(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
|
|
@ -1583,6 +1606,16 @@ mod tests {
|
|||
assert_eq!(info.aliases, Some(vec!["archive".to_string(), "nightly".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gridfs_file_sort_uses_upload_date_desc_by_default() {
|
||||
assert_eq!(gridfs_file_sort_document(None).unwrap(), doc! { "uploadDate": -1_i32, "_id": -1_i32 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gridfs_file_sort_parses_explicit_sort_json() {
|
||||
assert_eq!(gridfs_file_sort_document(Some(r#"{"filename":1}"#)).unwrap(), doc! { "filename": 1_i64 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_object_to_document_parses_find_projection() {
|
||||
let value = serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -122,6 +122,78 @@ fn mongo_bucket_infos(names: &[String]) -> Vec<CollectionInfo> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GridFsBucketSortField {
|
||||
Name,
|
||||
FileCount,
|
||||
TotalBytes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct GridFsBucketSort {
|
||||
field: GridFsBucketSortField,
|
||||
descending: bool,
|
||||
}
|
||||
|
||||
fn parse_gridfs_bucket_sort(sort: Option<&str>) -> Result<GridFsBucketSort, String> {
|
||||
let Some(raw) = sort.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(GridFsBucketSort { field: GridFsBucketSortField::Name, descending: false });
|
||||
};
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(raw).map_err(|e| format!("Invalid GridFS bucket sort JSON: {e}"))?;
|
||||
let object = value.as_object().ok_or_else(|| "GridFS bucket sort must be a JSON object".to_string())?;
|
||||
if object.len() != 1 {
|
||||
return Err("GridFS bucket sort must contain exactly one field".to_string());
|
||||
}
|
||||
|
||||
let (field_name, direction) = object.iter().next().expect("checked len");
|
||||
let field = match field_name.as_str() {
|
||||
"name" => GridFsBucketSortField::Name,
|
||||
"fileCount" => GridFsBucketSortField::FileCount,
|
||||
"totalBytes" => GridFsBucketSortField::TotalBytes,
|
||||
_ => return Err(format!("Unsupported GridFS bucket sort field: {field_name}")),
|
||||
};
|
||||
let descending = match direction {
|
||||
serde_json::Value::Number(value) if value.as_i64() == Some(-1) => true,
|
||||
serde_json::Value::Number(value) if value.as_i64() == Some(1) => false,
|
||||
serde_json::Value::String(value) if value.eq_ignore_ascii_case("desc") || value == "-1" => true,
|
||||
serde_json::Value::String(value) if value.eq_ignore_ascii_case("asc") || value == "1" => false,
|
||||
_ => return Err("GridFS bucket sort direction must be 1, -1, 'asc', or 'desc'".to_string()),
|
||||
};
|
||||
|
||||
Ok(GridFsBucketSort { field, descending })
|
||||
}
|
||||
|
||||
fn filter_and_sort_gridfs_bucket_infos(
|
||||
mut buckets: Vec<MongoGridFsBucketInfo>,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
) -> Result<Vec<MongoGridFsBucketInfo>, String> {
|
||||
if let Some(filter_text) = filter.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let needle = filter_text.to_lowercase();
|
||||
buckets.retain(|bucket| bucket.name.to_lowercase().contains(&needle));
|
||||
}
|
||||
|
||||
let sort = parse_gridfs_bucket_sort(sort)?;
|
||||
buckets.sort_by(|left, right| {
|
||||
let name_cmp =
|
||||
left.name.to_lowercase().cmp(&right.name.to_lowercase()).then_with(|| left.name.cmp(&right.name));
|
||||
let ordering = match sort.field {
|
||||
GridFsBucketSortField::Name => name_cmp,
|
||||
GridFsBucketSortField::FileCount => left.file_count.cmp(&right.file_count).then_with(|| name_cmp),
|
||||
GridFsBucketSortField::TotalBytes => left.total_bytes.cmp(&right.total_bytes).then_with(|| name_cmp),
|
||||
};
|
||||
if sort.descending {
|
||||
ordering.reverse()
|
||||
} else {
|
||||
ordering
|
||||
}
|
||||
});
|
||||
|
||||
Ok(buckets)
|
||||
}
|
||||
|
||||
pub async fn list_collections_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
|
@ -160,11 +232,13 @@ pub async fn list_gridfs_files_core(
|
|||
connection_id: &str,
|
||||
database: &str,
|
||||
bucket: &str,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
) -> Result<Vec<MongoGridFsFileInfo>, String> {
|
||||
ensure_document_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => mongo_driver::list_gridfs_files(client, database, bucket).await,
|
||||
PoolKind::MongoDb(client) => mongo_driver::list_gridfs_files(client, database, bucket, filter, sort).await,
|
||||
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support GridFS file browsing".to_string()),
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
|
|
@ -174,6 +248,8 @@ pub async fn list_gridfs_buckets_core(
|
|||
state: &AppState,
|
||||
connection_id: &str,
|
||||
database: &str,
|
||||
filter: Option<&str>,
|
||||
sort: Option<&str>,
|
||||
) -> Result<Vec<MongoGridFsBucketInfo>, String> {
|
||||
ensure_document_pool(state, connection_id).await?;
|
||||
let connections = state.connections.read().await;
|
||||
|
|
@ -185,7 +261,7 @@ pub async fn list_gridfs_buckets_core(
|
|||
for bucket_name in bucket_names {
|
||||
buckets.push(mongo_driver::gridfs_bucket_summary(client, database, &bucket_name).await?);
|
||||
}
|
||||
Ok(buckets)
|
||||
filter_and_sort_gridfs_bucket_infos(buckets, filter, sort)
|
||||
}
|
||||
PoolKind::Agent(_) => Err("MongoDB legacy agent does not support GridFS bucket browsing".to_string()),
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
|
|
@ -420,7 +496,10 @@ pub async fn delete_document_core(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fallback_mongo_database, mongo_gridfs_bucket_names, mongo_list_databases_unauthorized, sort_names};
|
||||
use super::{
|
||||
fallback_mongo_database, filter_and_sort_gridfs_bucket_infos, mongo_gridfs_bucket_names,
|
||||
mongo_list_databases_unauthorized, parse_gridfs_bucket_sort, sort_names, MongoGridFsBucketInfo,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sorts_names_case_insensitively() {
|
||||
|
|
@ -464,4 +543,49 @@ mod tests {
|
|||
|
||||
assert_eq!(buckets, vec!["orders".to_string(), "reports".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_gridfs_buckets_by_case_insensitive_name_match() {
|
||||
let buckets = filter_and_sort_gridfs_bucket_infos(
|
||||
vec![
|
||||
MongoGridFsBucketInfo { name: "images".to_string(), file_count: 4, total_bytes: 512 },
|
||||
MongoGridFsBucketInfo { name: "nightly-reports".to_string(), file_count: 9, total_bytes: 4096 },
|
||||
MongoGridFsBucketInfo { name: "videos".to_string(), file_count: 2, total_bytes: 8192 },
|
||||
],
|
||||
Some("REPORT"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
buckets.into_iter().map(|bucket| bucket.name).collect::<Vec<_>>(),
|
||||
vec!["nightly-reports".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_gridfs_buckets_by_total_bytes_descending() {
|
||||
let buckets = filter_and_sort_gridfs_bucket_infos(
|
||||
vec![
|
||||
MongoGridFsBucketInfo { name: "images".to_string(), file_count: 4, total_bytes: 512 },
|
||||
MongoGridFsBucketInfo { name: "nightly-reports".to_string(), file_count: 9, total_bytes: 4096 },
|
||||
MongoGridFsBucketInfo { name: "videos".to_string(), file_count: 2, total_bytes: 8192 },
|
||||
],
|
||||
None,
|
||||
Some(r#"{"totalBytes":-1}"#),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
buckets.into_iter().map(|bucket| bucket.name).collect::<Vec<_>>(),
|
||||
vec!["videos".to_string(), "nightly-reports".to_string(), "images".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gridfs_bucket_sort_rejects_unknown_fields() {
|
||||
let error = parse_gridfs_bucket_sort(Some(r#"{"createdAt":-1}"#)).unwrap_err();
|
||||
|
||||
assert!(error.contains("Unsupported GridFS bucket sort field"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,25 @@ pub struct GridFsBucketRequest {
|
|||
pub bucket: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridFsFileListRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub bucket: String,
|
||||
pub filter: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridFsBucketListRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub filter: Option<String>,
|
||||
pub sort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridFsDownloadRequest {
|
||||
|
|
@ -222,22 +241,34 @@ pub async fn delete_document(
|
|||
|
||||
pub async fn list_gridfs_files(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<GridFsBucketRequest>,
|
||||
Json(req): Json<GridFsFileListRequest>,
|
||||
) -> Result<Json<Vec<dbx_core::document_ops::MongoGridFsFileInfo>>, AppError> {
|
||||
let result =
|
||||
dbx_core::document_ops::list_gridfs_files_core(&state.app, &req.connection_id, &req.database, &req.bucket)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
let result = dbx_core::document_ops::list_gridfs_files_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.bucket,
|
||||
req.filter.as_deref(),
|
||||
req.sort.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_gridfs_buckets(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<DocumentListCollectionsRequest>,
|
||||
Json(req): Json<GridFsBucketListRequest>,
|
||||
) -> Result<Json<Vec<dbx_core::document_ops::MongoGridFsBucketInfo>>, AppError> {
|
||||
let result = dbx_core::document_ops::list_gridfs_buckets_core(&state.app, &req.connection_id, &req.database)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
let result = dbx_core::document_ops::list_gridfs_buckets_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
req.filter.as_deref(),
|
||||
req.sort.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
# GridFS Manager Filter And Sort Design
|
||||
|
||||
**Date:** 2026-07-06
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the existing GridFS manager and bucket browser so MongoDB users can filter and sort GridFS data without leaving the dedicated GridFS workflow.
|
||||
|
||||
This work covers three user-facing improvements:
|
||||
|
||||
- add query-style filter and sort controls to the GridFS file list
|
||||
- add filter and sort controls to the GridFS bucket manager
|
||||
- make the GridFS sidebar node more visually distinct from regular Mongo collections
|
||||
|
||||
## Background
|
||||
|
||||
The dedicated GridFS browser shipped on 2026-07-03 and already provides:
|
||||
|
||||
- a single `GridFS` sidebar entry per Mongo database
|
||||
- a database-level manager page for bucket CRUD
|
||||
- a bucket-level file browser for upload, download, and delete
|
||||
|
||||
Issue #2589 reports that the new Mongo manager flow still lacks filtering. The current UI shows static tables only:
|
||||
|
||||
- `MongoGridFsBrowser.vue` lists bucket summaries with no filter or sort controls
|
||||
- `MongoBucketBrowser.vue` lists bucket files with a fixed default ordering
|
||||
- the backend `list_gridfs_files` API always runs an empty `find({})` with a built-in descending upload date sort
|
||||
|
||||
The goal of this iteration is to keep the dedicated GridFS UX, but make it feel closer to the existing Mongo document query experience.
|
||||
|
||||
## Current State
|
||||
|
||||
### File browser
|
||||
|
||||
`MongoBucketBrowser.vue` currently:
|
||||
|
||||
- loads every file in the selected bucket
|
||||
- shows a table with metadata columns
|
||||
- supports upload, download, delete, refresh
|
||||
- has no filter input
|
||||
- has no user-controlled sort
|
||||
- has no query preview
|
||||
|
||||
### Bucket manager
|
||||
|
||||
`MongoGridFsBrowser.vue` currently:
|
||||
|
||||
- loads every GridFS bucket summary for the database
|
||||
- shows bucket name, file count, and total size
|
||||
- supports create, delete, open, refresh
|
||||
- has no filter input
|
||||
- has no user-controlled sort
|
||||
|
||||
### Sidebar
|
||||
|
||||
`TreeItem.vue` currently renders:
|
||||
|
||||
- `mongo-gridfs` and `mongo-buckets` with `Archive` plus amber styling
|
||||
- `mongo-bucket` with a very similar `Archive` plus lighter amber styling
|
||||
|
||||
This makes the GridFS entry readable, but not especially distinct from other MongoDB tree nodes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional
|
||||
|
||||
- users can filter GridFS files within a bucket
|
||||
- users can sort GridFS files within a bucket
|
||||
- users can filter GridFS buckets within a database
|
||||
- users can sort GridFS buckets within a database
|
||||
- file-list sorting works both from explicit sort input and column-header sort actions
|
||||
- the GridFS sidebar entry has a clearer dedicated visual treatment
|
||||
|
||||
### Behavioral
|
||||
|
||||
- existing upload, download, delete, create, and open flows keep working
|
||||
- existing callers that omit new query parameters retain current behavior
|
||||
- invalid filter or sort input produces visible errors rather than silent fallback
|
||||
|
||||
## Chosen Approach
|
||||
|
||||
### Query model
|
||||
|
||||
Use one consistent principle across both GridFS pages:
|
||||
|
||||
- execute filter and sort on the server
|
||||
- keep frontend controls visually aligned with the document query UI
|
||||
- only reuse document-query semantics where the underlying data shape supports it cleanly
|
||||
|
||||
### Bucket file browser
|
||||
|
||||
The file browser should closely mirror Mongo document query behavior.
|
||||
|
||||
Frontend:
|
||||
|
||||
- add `filter` and `sort` text inputs to `MongoBucketBrowser.vue`
|
||||
- show a query preview string for the effective GridFS file query
|
||||
- trigger reload on Enter, refresh click, and table-header sort changes
|
||||
- map header sort actions back into the `sort` input, matching `DocumentBrowser.vue`
|
||||
|
||||
Backend:
|
||||
|
||||
- extend the GridFS file list API to accept optional `filter` and `sort` strings
|
||||
- parse them with the same Mongo document parsing helpers already used by document queries
|
||||
- run the query against `<bucket>.files` as `find(filter).sort(sort)`
|
||||
- preserve the existing default sort of `uploadDate desc, _id desc` when no sort is provided
|
||||
|
||||
Preview format:
|
||||
|
||||
- use `db.getCollection("<bucket>.files").find(...).sort(...).skip(0).limit(...)` style output
|
||||
- the preview exists for transparency and consistency, even if the file browser continues returning a plain row list instead of a paged document result object
|
||||
|
||||
### Bucket manager
|
||||
|
||||
The bucket manager should adopt the same interaction style, but not pretend bucket summaries are raw Mongo documents.
|
||||
|
||||
Frontend:
|
||||
|
||||
- add a lightweight `filter` input for bucket-name matching
|
||||
- add a sort control that behaves like the existing query toolbar and table-header sorting
|
||||
- keep the manager table focused on `name`, `fileCount`, and `totalBytes`
|
||||
|
||||
Backend:
|
||||
|
||||
- extend the bucket list API to accept optional filter and sort parameters
|
||||
- filter bucket summaries by normalized bucket name match
|
||||
- sort bucket summaries by one of:
|
||||
- `name`
|
||||
- `fileCount`
|
||||
- `totalBytes`
|
||||
- default to `name asc` when no explicit sort is provided
|
||||
|
||||
This gives the manager page service-side filtering and sorting while staying honest about the underlying data model, which is aggregated summary data rather than a directly queried collection.
|
||||
|
||||
### Sidebar icon treatment
|
||||
|
||||
Keep the existing icon family but make the top-level GridFS entry visually stand apart.
|
||||
|
||||
- `mongo-gridfs`: keep `Archive` but switch to a more distinctive cool color so it no longer blends with ordinary Mongo tree items
|
||||
- `mongo-bucket`: keep the same icon family with a related but softer shade
|
||||
|
||||
The goal is recognition, not a broad tree redesign.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend layers
|
||||
|
||||
Update the GridFS query path across all transport layers:
|
||||
|
||||
- `crates/dbx-core/src/db/mongo_driver.rs`
|
||||
- `crates/dbx-core/src/document_ops.rs`
|
||||
- `src-tauri/src/commands/document_cmd.rs`
|
||||
- `crates/dbx-web/src/routes/document_store.rs`
|
||||
- `apps/desktop/src/lib/backend/tauri.ts`
|
||||
- `apps/desktop/src/lib/backend/http.ts`
|
||||
|
||||
The backend remains backward compatible by treating all new parameters as optional.
|
||||
|
||||
### Frontend layers
|
||||
|
||||
Update the dedicated GridFS views:
|
||||
|
||||
- `apps/desktop/src/components/document/MongoBucketBrowser.vue`
|
||||
- `apps/desktop/src/components/document/MongoGridFsBrowser.vue`
|
||||
|
||||
Reuse document-query support where it fits:
|
||||
|
||||
- `apps/desktop/src/lib/app/documentStoreProvider.ts`
|
||||
|
||||
Update the sidebar presentation in:
|
||||
|
||||
- `apps/desktop/src/components/sidebar/TreeItem.vue`
|
||||
|
||||
## Data Flow
|
||||
|
||||
### File browser
|
||||
|
||||
1. User opens a GridFS bucket tab.
|
||||
2. `MongoBucketBrowser.vue` builds the current `filter` and `sort` state.
|
||||
3. Frontend sends the new parameters through the shared document-store API.
|
||||
4. Rust applies the filter and sort to `<bucket>.files`.
|
||||
5. The returned file list renders in the table and side detail panel.
|
||||
6. If the user clicks a sortable header, the UI rewrites the `sort` input and reloads.
|
||||
|
||||
### Bucket manager
|
||||
|
||||
1. User opens the `GridFS` manager tab.
|
||||
2. `MongoGridFsBrowser.vue` sends current manager filter and sort state.
|
||||
3. Rust collects bucket summaries, filters them, sorts them, and returns the list.
|
||||
4. The manager table and summary pane update in place.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- invalid Mongo-style file filter input should surface as a visible page error
|
||||
- invalid file sort input should surface as a visible page error
|
||||
- unsupported bucket-manager sort fields should return a clear backend error
|
||||
- empty filter input should behave like no filter
|
||||
- missing sort input should fall back to the page default sort
|
||||
- write operations continue to use the existing read-only safeguards
|
||||
|
||||
## Testing
|
||||
|
||||
### Frontend
|
||||
|
||||
Add or update Vitest coverage for:
|
||||
|
||||
- GridFS query preview formatting in `documentStoreProvider`
|
||||
- request assembly for GridFS file filter and sort loading
|
||||
- request assembly for GridFS bucket filter and sort loading
|
||||
- sidebar icon and color mapping for `mongo-gridfs` and `mongo-bucket`
|
||||
|
||||
### Backend
|
||||
|
||||
Add Rust coverage for:
|
||||
|
||||
- GridFS file filter parsing
|
||||
- default file sort fallback
|
||||
- explicit file sort handling
|
||||
- bucket summary filtering
|
||||
- bucket summary sorting by supported fields
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### Included now
|
||||
|
||||
- service-side GridFS file filtering
|
||||
- service-side GridFS file sorting
|
||||
- service-side GridFS bucket filtering
|
||||
- service-side GridFS bucket sorting
|
||||
- query-style toolbar inputs for the file browser
|
||||
- visually improved GridFS sidebar node styling
|
||||
|
||||
### Not included now
|
||||
|
||||
- pagination contract changes for GridFS APIs
|
||||
- turning bucket summaries into full Mongo document queries
|
||||
- advanced bucket filter builders
|
||||
- drag-and-drop upload improvements
|
||||
- inline file editing or file preview
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- users can narrow GridFS files with Mongo-style filter input
|
||||
- users can sort GridFS files from either toolbar input or table headers
|
||||
- users can narrow and sort GridFS bucket summaries from the manager page
|
||||
- the dedicated GridFS sidebar node is easier to distinguish at a glance
|
||||
- existing GridFS CRUD flows keep working without regression
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import * as gridFsBrowserModule from "../../apps/desktop/src/lib/document/gridFsBrowser.ts";
|
||||
import {
|
||||
buildGridFsFilesStructuredFilter,
|
||||
currentGridFsBucketFilter,
|
||||
currentGridFsBucketSort,
|
||||
currentGridFsBucketSortDirection,
|
||||
currentGridFsFileSortDirection,
|
||||
gridFsFileFilterFieldOptions,
|
||||
gridFsBucketSortInputForColumn,
|
||||
gridFsFilesQueryPreview,
|
||||
parseGridFsBucketSort,
|
||||
} from "../../apps/desktop/src/lib/document/gridFsBrowser.ts";
|
||||
|
||||
test("builds a Mongo-style GridFS files query preview", () => {
|
||||
assert.equal(
|
||||
gridFsFilesQueryPreview({
|
||||
bucket: "fs",
|
||||
filterJson: '{"metadata.owner":"alice"}',
|
||||
sortJson: '{"uploadDate":-1}',
|
||||
}),
|
||||
'db.getCollection("fs.files").find({"metadata.owner":"alice"}).sort({"uploadDate":-1})',
|
||||
);
|
||||
});
|
||||
|
||||
test("omits the sort stage when the GridFS file preview has no explicit sort", () => {
|
||||
assert.equal(
|
||||
gridFsFilesQueryPreview({
|
||||
bucket: "fs",
|
||||
filterJson: '{"filename":"report.zip"}',
|
||||
}),
|
||||
'db.getCollection("fs.files").find({"filename":"report.zip"})',
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes GridFS bucket filters by trimming blank input", () => {
|
||||
assert.equal(currentGridFsBucketFilter(" nightly "), "nightly");
|
||||
assert.equal(currentGridFsBucketFilter(" "), undefined);
|
||||
});
|
||||
|
||||
test("normalizes GridFS manager sort directions for supported fields", () => {
|
||||
assert.deepEqual(parseGridFsBucketSort('{"totalBytes":-1}'), { field: "totalBytes", direction: "desc" });
|
||||
assert.deepEqual(parseGridFsBucketSort("{ fileCount: 1 }"), { field: "fileCount", direction: "asc" });
|
||||
});
|
||||
|
||||
test("serializes GridFS manager sort input for supported columns", () => {
|
||||
assert.equal(gridFsBucketSortInputForColumn("name", "asc"), '{"name":1}');
|
||||
assert.equal(gridFsBucketSortInputForColumn("totalBytes", "desc"), '{"totalBytes":-1}');
|
||||
assert.equal(gridFsBucketSortInputForColumn("fileCount", null), "");
|
||||
});
|
||||
|
||||
test("rejects unsupported GridFS bucket sort fields", () => {
|
||||
assert.throws(() => parseGridFsBucketSort('{"createdAt":-1}'), /Unsupported GridFS bucket sort field/);
|
||||
});
|
||||
|
||||
test("returns canonical GridFS bucket sort JSON", () => {
|
||||
assert.equal(currentGridFsBucketSort("{ totalBytes: -1 }"), '{"totalBytes":-1}');
|
||||
assert.equal(currentGridFsBucketSort(" "), undefined);
|
||||
});
|
||||
|
||||
test("exposes the available GridFS file filter fields for the compact builder", () => {
|
||||
assert.deepEqual(gridFsFileFilterFieldOptions, ["_id", "filename", "contentType", "length", "chunkSize", "uploadDate", "md5"]);
|
||||
});
|
||||
|
||||
test("exposes shared GridFS file field display metadata for filters and headers", () => {
|
||||
assert.deepEqual((gridFsBrowserModule as any).gridFsFileFieldDisplayOptions, [
|
||||
{ fieldName: "_id", label: "ID" },
|
||||
{ fieldName: "filename", labelKey: "gridfsBrowser.name" },
|
||||
{ fieldName: "contentType", labelKey: "gridfsBrowser.contentType" },
|
||||
{ fieldName: "length", labelKey: "gridfsBrowser.totalSize" },
|
||||
{ fieldName: "chunkSize", labelKey: "gridfsBrowser.chunkSize" },
|
||||
{ fieldName: "uploadDate", labelKey: "gridfsBrowser.uploadDate" },
|
||||
{ fieldName: "md5", label: "MD5" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("combines GridFS file filter rules into a MongoDB structured filter", () => {
|
||||
assert.deepEqual(
|
||||
buildGridFsFilesStructuredFilter([
|
||||
{
|
||||
id: "rule-1",
|
||||
fieldName: "filename",
|
||||
mode: "equals",
|
||||
rawValue: "report.zip",
|
||||
conjunction: "AND",
|
||||
},
|
||||
{
|
||||
id: "rule-2",
|
||||
fieldName: "contentType",
|
||||
mode: "like",
|
||||
rawValue: "image",
|
||||
conjunction: "AND",
|
||||
},
|
||||
]),
|
||||
{
|
||||
$and: [
|
||||
{ filename: "report.zip" },
|
||||
{ contentType: { $regex: "image", $options: "i" } },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("derives explicit GridFS file sort directions for visible header indicators", () => {
|
||||
assert.equal(currentGridFsFileSortDirection('{"filename":1}', "filename"), "asc");
|
||||
assert.equal(currentGridFsFileSortDirection('{"filename":-1}', "filename"), "desc");
|
||||
assert.equal(currentGridFsFileSortDirection('{"filename":1}', "uploadDate"), "none");
|
||||
assert.equal(currentGridFsFileSortDirection('{"filename":1,"uploadDate":-1}', "filename"), "none");
|
||||
});
|
||||
|
||||
test("derives explicit GridFS bucket sort directions for visible header indicators", () => {
|
||||
assert.equal(currentGridFsBucketSortDirection('{"name":1}', "name"), "asc");
|
||||
assert.equal(currentGridFsBucketSortDirection('{"totalBytes":-1}', "totalBytes"), "desc");
|
||||
assert.equal(currentGridFsBucketSortDirection('{"name":1}', "fileCount"), "none");
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { getTreeNodeIconInfo } from "../../apps/desktop/src/lib/sidebar/treeNodeIcon.ts";
|
||||
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
test("GridFS sidebar nodes use a dedicated cool-color icon treatment", () => {
|
||||
const gridfs = getTreeNodeIconInfo({ type: "mongo-gridfs" } as TreeNode);
|
||||
const bucket = getTreeNodeIconInfo({ type: "mongo-bucket" } as TreeNode);
|
||||
|
||||
assert.equal(gridfs?.colorClass, "text-cyan-500");
|
||||
assert.equal(bucket?.colorClass, "text-cyan-400");
|
||||
});
|
||||
|
||||
test("GridFS sidebar icon mapping stays distinct from Mongo collections", () => {
|
||||
const gridfs = getTreeNodeIconInfo({ type: "mongo-gridfs" } as TreeNode);
|
||||
const collection = getTreeNodeIconInfo({ type: "mongo-collection" } as TreeNode);
|
||||
|
||||
assert.notEqual(gridfs?.colorClass, collection?.colorClass);
|
||||
});
|
||||
|
|
@ -135,8 +135,17 @@ pub async fn document_list_gridfs_buckets(
|
|||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
filter: Option<String>,
|
||||
sort: Option<String>,
|
||||
) -> Result<Vec<dbx_core::document_ops::MongoGridFsBucketInfo>, String> {
|
||||
dbx_core::document_ops::list_gridfs_buckets_core(&state, &connection_id, &database).await
|
||||
dbx_core::document_ops::list_gridfs_buckets_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
filter.as_deref(),
|
||||
sort.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -167,8 +176,18 @@ pub async fn document_list_gridfs_files(
|
|||
connection_id: String,
|
||||
database: String,
|
||||
bucket: String,
|
||||
filter: Option<String>,
|
||||
sort: Option<String>,
|
||||
) -> Result<Vec<dbx_core::document_ops::MongoGridFsFileInfo>, String> {
|
||||
dbx_core::document_ops::list_gridfs_files_core(&state, &connection_id, &database, &bucket).await
|
||||
dbx_core::document_ops::list_gridfs_files_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
&database,
|
||||
&bucket,
|
||||
filter.as_deref(),
|
||||
sort.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
Loading…
Reference in New Issue