feat(grid): import BYTEA/BLOB cell from file with size gate
This commit is contained in:
20 files changed
+326
-17
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user