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:
14 files changed
+1294
-40
No files matched your search
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user