feat(grid): import BYTEA/BLOB cell from file with size gate

This commit is contained in:
zipg authored and GitHub committed 2026-08-09 20:13:18 +08:00
1 parent 31587bcbfe
commit 35362c8bf4
20 files changed
+326 -17

No files matched your search

+61 -6
View File
@@ -6,7 +6,8 @@ import {
ArrowDown,
ArrowUpDown,
ArrowUpRight,
Upload,
Download,
FileUp,
Trash2,
ChevronDown,
ChevronUp,
@@ -122,12 +123,17 @@ import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistr
import "@/lib/dataGrid/geometryMapPreview";
import {
BINARY_CELL_DOWNLOAD_MODES,
BinaryCellImportTooLargeError,
binaryCellBytesToHexValue,
binaryCellDisplayText,
binaryCellDownloadFileName,
binaryCellDownloadPayload,
canImportBinaryCellFile,
canDownloadBinaryCellValue,
downloadBinaryCellPayload,
formatBinaryCellByteSize,
isBinaryCellColumnType,
openBinaryCellFile,
parseBinaryCellBytes,
retainBinaryCellDownloadMenuForHover,
type BinaryCellDownloadMode,
@@ -7063,6 +7069,39 @@ function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolea
return !!detail && canDownloadBinaryCellValue(detail.value, detail.type);
}
function canImportDetailBinaryValue(detail: DataGridCellDetail | null): boolean {
return !!detail?.isEditable && canImportBinaryCellFile(resolvedDatabaseType.value, detail.type);
}
async function importDetailBinaryValue(detail: DataGridCellDetail | null) {
if (!detail || !canImportDetailBinaryValue(detail)) return;
try {
const bytes = await openBinaryCellFile();
if (!bytes) return;
const value = binaryCellBytesToHexValue(bytes);
applyCellValue(detail.rowId, detail.colIndex, value);
if (activeCellDetail.value?.rowId === detail.rowId && activeCellDetail.value.colIndex === detail.colIndex) {
detailEditValue.value = value;
detailEditOriginalValue.value = value;
syncEditorFromDetailEdit();
detailCell.value = detailCell.value ? { ...detailCell.value } : null;
}
toast(t("grid.binaryImportApplied", { count: bytes.length }));
} catch (e: any) {
if (e instanceof BinaryCellImportTooLargeError) {
toast(
t("grid.binaryImportTooLarge", {
size: formatBinaryCellByteSize(e.bytes),
limit: formatBinaryCellByteSize(e.limit),
}),
5000,
);
return;
}
toast(t("grid.binaryImportFailed", { message: e?.message || String(e) }), 5000);
}
}
function canQuickDownloadCellValue(rowIndex: number, columnIndex: number): boolean {
return canDownloadDetailBinaryValue(cellDetailFor(rowIndex, columnIndex));
}
@@ -7096,7 +7135,7 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
if (!canDownloadDetailBinaryValue(detail)) return null;
return {
label: t("grid.downloadBinaryValue"),
icon: Upload,
icon: Download,
children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
label: t(`grid.binaryDownload.${mode}`),
action: () => {
@@ -7106,6 +7145,17 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
};
}
function binaryImportItem(detail: DataGridCellDetail | null): ContextMenuItem | null {
if (!canImportDetailBinaryValue(detail)) return null;
return {
label: t("grid.importBinaryValue"),
icon: FileUp,
action: () => {
void importDetailBinaryValue(detail);
},
};
}
async function copyDetailSqlCondition() {
if (!canCopyPreparedDetailSqlCondition()) return;
copyText(detailSqlConditionCopy.value.text);
@@ -8846,7 +8896,7 @@ function exportSubmenu(): ContextMenuItem {
{ label: t("grid.exportSelectedRowsTxt"), action: exportSelectedRowsTxt },
);
}
return { label: t("grid.export"), icon: Upload, children: items };
return { label: t("grid.export"), icon: Download, children: items };
}
const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@@ -8939,6 +8989,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
},
icons: { cellDetails: Maximize2, columnDetails: TableProperties, rowDetails: ListTree, setNull: X, bulkEdit: Pencil, transpose: Rows3 },
actions: { cellDetails: openContextCellDetailDialog, columnDetails: openContextColumnDetailDialog, rowDetails: openContextRowDetailDialog, setNull: setSelectionNull, bulkEdit: openBulkEditDialog, transpose: openContextTranspose },
importItem: binaryImportItem(contextCellDetail.value),
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
foreignKeyItem: contextForeignKeyMenuItem(),
copySubmenu: copySubmenu(),
@@ -9376,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
@@ -9890,7 +9941,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
@@ -10093,7 +10144,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
>
<template #trigger="{ open, toggle }">
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</button>
</template>
</LightDropdownMenu>
@@ -10408,6 +10459,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:type-color-class="typeColorClass"
:can-download-binary-value="canDownloadDetailBinaryValue"
:download-binary-value="downloadDetailBinaryValue"
:can-import-binary-value="canImportDetailBinaryValue"
:import-binary-value="importDetailBinaryValue"
:open-image-preview="openImagePreview"
:can-copy-sql-condition="canCopyPreparedDetailSqlCondition"
@start-edit="startDetailEdit"
@@ -10582,6 +10635,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:copy-text="copyText"
:can-download-binary-value="canDownloadDetailBinaryValue"
:download-binary-value="downloadDetailBinaryValue"
:can-import-binary-value="canImportDetailBinaryValue"
:import-binary-value="importDetailBinaryValue"
@edit="openDialogCellInSidePanel"
/>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { Code2, Copy, Eye, Info, Pencil, Upload } from "@lucide/vue";
import { Code2, Copy, Download, Eye, FileUp, Info, Pencil } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -25,6 +25,8 @@ const props = defineProps<{
copyText: (text: string) => void;
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
}>();
const emit = defineEmits<{
@@ -152,10 +154,13 @@ watch(
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.copyValue')" @click="copyCurrentValue">
<Copy class="h-3 w-3" />
</Button>
<Button v-if="canImportBinaryValue(detail)" variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.importBinaryValue')" @click="importBinaryValue(detail)">
<FileUp class="h-3 w-3" />
</Button>
<DropdownMenu v-if="canDownloadBinaryValue(detail)">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.downloadBinaryValue')">
<Upload class="h-3 w-3" />
<Download class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { toRef } from "vue";
import { Code2, Copy, Eye, Pencil, Upload, X } from "@lucide/vue";
import { Code2, Copy, Download, Eye, FileUp, Pencil, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -30,6 +30,8 @@ const props = defineProps<{
typeColorClass: (type: string) => string;
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
openImagePreview: (src: string, title: string) => void;
canCopySqlCondition: () => boolean;
}>();
@@ -122,9 +124,10 @@ defineExpose({ openSearch });
<Button v-if="!editing && detail.formattedJson" :variant="sideJsonView ? 'secondary' : 'ghost'" size="sm" class="h-5 gap-1 px-1.5 text-xs" :title="t('grid.formattedJson')" @click="emit('toggleFormatted')"><Code2 class="h-3 w-3" />{{ t("grid.formattedJson") }}</Button>
<Button v-if="!editing && detail.isEditable" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.editValue')" @click="emit('startEdit')"><Pencil class="h-3 w-3" /></Button>
<Button v-if="!editing" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.copyValue')" @click="emit('copyValue')"><Copy class="h-3 w-3" /></Button>
<Button v-if="canImportBinaryValue(detail)" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.importBinaryValue')" @click="importBinaryValue(detail)"><FileUp class="h-3 w-3" /></Button>
<DropdownMenu v-if="!editing && canDownloadBinaryValue(detail)"
><DropdownMenuTrigger as-child
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Upload class="h-3 w-3" /></Button></DropdownMenuTrigger
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Download class="h-3 w-3" /></Button></DropdownMenuTrigger
><DropdownMenuContent align="end" class="w-44"
><DropdownMenuItem v-for="mode in BINARY_CELL_DOWNLOAD_MODES" :key="mode" @click="downloadBinaryValue(detail, mode)">{{ t(`grid.binaryDownload.${mode}`) }}</DropdownMenuItem></DropdownMenuContent
></DropdownMenu
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Upload } from "@lucide/vue";
import { Download } from "@lucide/vue";
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
defineProps<{
@@ -14,7 +14,7 @@ defineProps<{
model-value=""
:items="items"
:aria-label="label"
:trigger-icon="Upload"
:trigger-icon="Download"
:trigger-label="label"
trigger-class="inline-flex h-6 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 text-foreground/80 hover:bg-accent hover:text-accent-foreground"
trigger-icon-class="h-3.5 w-3.5"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Check, ChevronDown, Copy, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2, Upload } from "@lucide/vue";
import { Check, ChevronDown, Copy, Download, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -164,7 +164,7 @@ function actionLabelClass() {
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="exportData?.disabled || !exportData?.items.length">
<Upload class="data-grid-topbar-action-icon h-3 w-3" />
<Download class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ exportData?.label }}</span>
</Button>
</DropdownMenuTrigger>
@@ -25,8 +25,10 @@ vi.mock("@lucide/vue", async () => {
ChevronRight: icon,
ChevronsLeft: icon,
ChevronsRight: icon,
Download: icon,
Filter: icon,
Loader2: icon,
FileUp: icon,
Upload: icon,
Search: icon,
X: icon,
@@ -873,7 +875,20 @@ describe("cell detail surfaces", () => {
const copyText = vi.fn();
const edit = vi.fn();
const updateOpen = vi.fn();
const mounted = mountComponent(DataGridCellDetailDialog, { open: true, detail: detail(), typeColorClass: () => "", openImagePreview: vi.fn(), copyText, canDownloadBinaryValue: () => false, downloadBinaryValue: vi.fn(), onEdit: edit, "onUpdate:open": updateOpen });
const importBinaryValue = vi.fn();
const mounted = mountComponent(DataGridCellDetailDialog, {
open: true,
detail: detail({ type: "BYTEA", isEditable: true }),
typeColorClass: () => "",
openImagePreview: vi.fn(),
copyText,
canDownloadBinaryValue: () => false,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => true,
importBinaryValue,
onEdit: edit,
"onUpdate:open": updateOpen,
});
await nextTick();
await nextTick();
@@ -885,6 +900,11 @@ describe("cell detail surfaces", () => {
"click",
);
expect(edit).toHaveBeenCalledOnce();
dispatch(
findOne(mounted.root, (node) => node.props.title === "grid.importBinaryValue"),
"click",
);
expect(importBinaryValue).toHaveBeenCalledOnce();
await mounted.setProps({ detail: detail({ rawValue: '{"b":2}', formattedJson: '{\n "b": 2\n}' }) });
expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json");
@@ -912,6 +932,8 @@ describe("cell detail surfaces", () => {
typeColorClass: () => "",
canDownloadBinaryValue: () => false,
downloadBinaryValue: vi.fn(),
canImportBinaryValue: () => false,
importBinaryValue: vi.fn(),
openImagePreview: vi.fn(),
canCopySqlCondition: () => true,
onStartEdit: startEdit,