feat(grid): copy column names with quoting options

This commit is contained in:
Jamie authored and GitHub committed 2026-07-25 08:47:51 +08:00
1 parent d341581ff0
commit 7eda73fbd8
15 files changed
+336 -11

No files matched your search

+37 -5
View File
@@ -180,6 +180,7 @@ import { useToast } from "@/composables/useToast";
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
@@ -229,6 +230,7 @@ const DataGridCellDetailDialog = defineAsyncComponent(() => import("@/components
const DataGridMongoJsonPreview = defineAsyncComponent(() => import("@/components/grid/DataGridMongoJsonPreview.vue"));
const DataGridDetailDialogs = defineAsyncComponent(() => import("@/components/grid/DataGridDetailDialogs.vue"));
const DataGridBulkEditDialog = defineAsyncComponent(() => import("@/components/grid/DataGridBulkEditDialog.vue"));
const DataGridCopyColumnNamesDialog = defineAsyncComponent(() => import("@/components/grid/DataGridCopyColumnNamesDialog.vue"));
const ExportProgressDialog = defineAsyncComponent(() => import("@/components/export/ExportProgressDialog.vue"));
const FORMATTED_JSON_EDIT_WARNING_COUNT_STORAGE_KEY = "dbx-cell-detail-formatted-json-edit-warning-count";
const FORMATTED_JSON_EDIT_WARNING_MAX_COUNT = 3;
@@ -551,6 +553,8 @@ const contextHeaderColumnIndex = ref<number | null>(null);
const contextHeaderVisibleColIdx = ref<number | null>(null);
const bulkEditDialogOpen = ref(false);
const bulkEditValue = ref("");
const copyColumnNamesDialogOpen = ref(false);
const copyColumnNamesDialogColumns = ref<string[]>([]);
const generateIncrementDialogOpen = ref(false);
const generateIncrementStartValue = ref("1");
const generateIncrementTarget = ref<"selection" | "detail">("selection");
@@ -572,6 +576,7 @@ const imagePreviewOpen = ref(false);
const imagePreviewSrc = ref("");
const imagePreviewTitle = ref("");
const bulkEditDialogMounted = useDataGridAsyncSurface(bulkEditDialogOpen);
const copyColumnNamesDialogMounted = useDataGridAsyncSurface(copyColumnNamesDialogOpen);
const cellDetailDialogMounted = useDataGridAsyncSurface(cellDetailDialogOpen);
const detailDialogsMounted = useDataGridAsyncSurface(computed(() => rowDetailDialogOpen.value || columnDetailDialogOpen.value));
const imagePreviewMounted = useDataGridAsyncSurface(imagePreviewOpen);
@@ -5079,7 +5084,6 @@ const {
canCopySelectionAsInsert,
copySelectedRowsTsv,
copySelectedRowsTsvWithHeaders,
copyColumnNames,
exportCsv,
exportCurrentPageCsv,
exportJson,
@@ -6480,6 +6484,32 @@ async function copyHeaderColumn() {
await copyText(contextHeaderColumn.value);
}
// 显式多选的列名(按显示顺序);仅用于表头「复制选中列名」
const selectedColumnNamesForCopy = computed(() => {
return [...selectedColumnIndexes.value]
.sort((a, b) => a - b)
.map((index) => visibleColumns.value[index])
.filter((name): name is string => name !== undefined);
});
function openCopyColumnNamesDialog(names: string[]) {
if (names.length === 0) return;
copyColumnNamesDialogColumns.value = names;
copyColumnNamesDialogOpen.value = true;
}
function openCopyAllColumnNamesDialog() {
openCopyColumnNamesDialog(columnNamesForCopy(props.result.columns, visibleColumns.value, "all"));
}
function copyHeaderColumnOrSelected() {
if (selectedColumnNamesForCopy.value.length > 1) {
openCopyColumnNamesDialog(selectedColumnNamesForCopy.value);
return;
}
void copyHeaderColumn();
}
const canCopyAlterColumnSql = computed(() => {
if (!contextHeaderColumn.value || !props.tableMeta?.columns) return false;
if (tableStructureCapabilities.value.alterStrategy !== "direct") return false;
@@ -7425,7 +7455,7 @@ function copySubmenu(): ContextMenuItem {
items.push({ label: labels.update, action: copyRowAsUpdate });
}
items.push({ label: t("grid.copyAll"), action: copyAll });
items.push({ label: t("grid.copyColumnNames"), action: copyColumnNames });
items.push({ label: t("grid.copyColumnNames"), action: openCopyAllColumnNamesDialog });
return { label: t("grid.copy"), icon: Copy, children: items };
}
@@ -7514,7 +7544,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
hasColumnSelection: hasColumnSelection.value,
labels: {
copyName: t("grid.copyColumnName"),
copyName: selectedColumnNamesForCopy.value.length > 1 ? t("grid.copyColumnNamesSelected", { count: selectedColumnNamesForCopy.value.length }) : t("grid.copyColumnName"),
copyNames: t("grid.copyColumnNames"),
details: t("grid.openColumnDetailsDialog"),
copyAlterSql: t("grid.copyAlterColumnSql"),
@@ -7529,8 +7559,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
},
icons: { copy: Copy, columnDetails: TableProperties, database: Database, ascending: ArrowUp, descending: ArrowDown, clearSort: Eraser },
actions: {
copyName: copyHeaderColumn,
copyNames: copyColumnNames,
copyName: copyHeaderColumnOrSelected,
copyNames: openCopyAllColumnNamesDialog,
details: openContextColumnDetailDialog,
copyAlterSql: copyAlterColumnSql,
sort: applyContextSort,
@@ -9117,6 +9147,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" @copy="copyText" />
<Dialog v-model:open="generateIncrementDialogOpen">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, formatColumnNamesForCopy, isColumnNameCopySeparator, loadColumnNameCopySeparator, saveColumnNameCopySeparator, supportsColumnNameQuoting, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import type { DatabaseType } from "@/types/database";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{ columnNames: string[]; databaseType?: DatabaseType }>();
const emit = defineEmits<{ copy: [text: string] }>();
const separator = ref<ColumnNameCopySeparator>(loadColumnNameCopySeparator());
const quote = ref(false);
// 每次打开时重新读取上次分隔符选择;转义选项每次默认关闭
watch(open, (value) => {
if (!value) return;
separator.value = loadColumnNameCopySeparator();
quote.value = false;
});
const showQuoteOption = computed(() => supportsColumnNameQuoting(props.databaseType));
const previewText = computed(() => formatColumnNamesForCopy(props.columnNames, { separator: separator.value, quote: quote.value, databaseType: props.databaseType }));
function onSeparatorChange(value: unknown) {
if (isColumnNameCopySeparator(value)) separator.value = value;
}
function confirmCopy() {
saveColumnNameCopySeparator(separator.value);
emit("copy", previewText.value);
open.value = false;
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[440px]">
<DialogHeader
><DialogTitle>{{ t("grid.copyColumnNamesTitle", { count: columnNames.length }) }}</DialogTitle></DialogHeader
>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<Label class="shrink-0 text-sm">{{ t("grid.copyColumnNamesSeparator") }}</Label>
<Select :model-value="separator" @update:model-value="onSeparatorChange">
<SelectTrigger class="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectItem v-for="option in COLUMN_NAME_COPY_SEPARATOR_OPTIONS" :key="option" :value="option" class="dbx-data-grid-value-font text-xs">{{ COLUMN_NAME_COPY_SEPARATOR_LABELS[option] }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="showQuoteOption" class="flex items-center justify-between gap-3">
<Label class="shrink-0 text-sm" for="copy-column-names-quote">{{ t("grid.copyColumnNamesQuote") }}</Label>
<Switch id="copy-column-names-quote" v-model="quote" />
</div>
<div class="space-y-1.5">
<Label class="text-sm">{{ t("grid.copyColumnNamesPreview") }}</Label>
<pre class="dbx-data-grid-value-font max-h-40 min-h-16 w-full overflow-auto whitespace-pre-wrap break-all rounded-[6px] border border-input bg-muted/30 px-2.5 py-1.5 text-xs" data-copy-column-names-preview>{{ previewText }}</pre>
</div>
</div>
<DialogFooter
><Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button
><Button @click="confirmCopy">{{ t("grid.copy") }}</Button></DialogFooter
>
</DialogContent>
</Dialog>
</template>
@@ -64,6 +64,8 @@ vi.mock("@/components/ui/select", async () => {
return { Select: createPassthroughStub("Select"), SelectContent: createPassthroughStub("SelectContent"), SelectItem: createPassthroughStub("SelectItem"), SelectTrigger: createPassthroughStub("SelectTrigger"), SelectValue: createPassthroughStub("SelectValue") };
});
vi.mock("@/components/ui/tabs", async () => ({ TabsContent: (await import("./vueHostHarness")).createPassthroughStub("TabsContent") }));
vi.mock("@/components/ui/switch", async () => ({ Switch: (await import("./vueHostHarness")).createPassthroughStub("Switch", "button") }));
vi.mock("@/components/ui/label", async () => ({ Label: (await import("./vueHostHarness")).createPassthroughStub("Label", "label") }));
vi.mock("@/components/ui/LightDropdown.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightDropdown") }));
vi.mock("@/components/ui/LightTooltip.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightTooltip") }));
vi.mock("@/components/grid/TemporalCellEditor.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("TemporalCellEditor") }));
@@ -84,6 +86,7 @@ vi.mock("@/composables/useDataGridCellDetail", async () => {
import DataGridCellDetailDialog from "@/components/grid/DataGridCellDetailDialog.vue";
import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
@@ -616,3 +619,53 @@ describe("cell detail surfaces", () => {
expect(mocks.panelOpenSearch).toHaveBeenCalledOnce();
});
});
describe("DataGridCopyColumnNamesDialog", () => {
beforeEach(() => {
localStorage.removeItem("dbx-copy-column-names-separator");
});
function previewText(mounted: ReturnType<typeof mountComponent>) {
return hostText(findOne(mounted.root, (node) => node.props["data-copy-column-names-preview"] === ""));
}
it("previews the formatted names and copies with the chosen separator and quoting", async () => {
const copy = vi.fn();
const openChange = vi.fn();
const mounted = mountComponent(DataGridCopyColumnNamesDialog, {
open: true,
columnNames: ["id", "type"],
databaseType: "mysql",
onCopy: copy,
"onUpdate:open": openChange,
});
expect(previewText(mounted)).toBe("id\ttype");
findOne(mounted.root, (node) => node.props["data-stub"] === "Select").props["onUpdate:modelValue"]("comma-newline");
findOne(mounted.root, (node) => node.props["data-stub"] === "Switch").props["onUpdate:modelValue"](true);
await nextTick();
expect(previewText(mounted)).toBe("`id`,\n`type`");
dispatch(
findOne(mounted.root, (node) => node.props["data-stub"] === "Button" && hostText(node) === "grid.copy"),
"click",
);
expect(copy).toHaveBeenCalledWith("`id`,\n`type`");
expect(openChange).toHaveBeenCalledWith(false);
expect(localStorage.getItem("dbx-copy-column-names-separator")).toBe("comma-newline");
});
it("hides the quote option for non-SQL databases and ignores invalid separators", async () => {
const mounted = mountComponent(DataGridCopyColumnNamesDialog, {
open: true,
columnNames: ["id", "type"],
databaseType: "mongodb",
onCopy: vi.fn(),
});
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Switch")).toHaveLength(0);
findOne(mounted.root, (node) => node.props["data-stub"] === "Select").props["onUpdate:modelValue"]("bogus");
await nextTick();
expect(previewText(mounted)).toBe("id\ttype");
});
});