feat(grid): copy column names with quoting options
This commit is contained in:
1 parent
d341581ff0
commit
7eda73fbd8
15 files changed
+336
-11
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user