diff --git a/apps/desktop/src/components/document/MongoImportDialog.vue b/apps/desktop/src/components/document/MongoImportDialog.vue index 39686e8e7a..31ada2b70a 100644 --- a/apps/desktop/src/components/document/MongoImportDialog.vue +++ b/apps/desktop/src/components/document/MongoImportDialog.vue @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue"; import { AlertTriangle, ArrowLeft, ArrowRight, FileUp, Loader2, Square, Upload, X } from "@lucide/vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess"; @@ -42,6 +43,7 @@ const hasHeader = ref(true); const trim = ref(false); const emptyAsNull = ref(true); const typeMode = ref("auto"); +const columnTypeOverrides = ref>({}); const recognizeObjectIdHex = ref(false); const skipErrorRows = ref(false); const batchSize = ref(500); @@ -66,8 +68,18 @@ const fileLabel = computed(() => sourceName.value || t("tableImport.noFileSelect const encodingOptions = TABLE_IMPORT_ENCODING_OPTIONS; const targetLabel = computed(() => `${props.database}.${props.collection}`); -const parseOptions = computed( - (): api.MongoImportParseOptions => ({ +const columnTypeOptions: api.MongoImportInferredType[] = ["string", "boolean", "integer", "decimal", "date", "objectId", "object", "array", "mixed"]; +const columnTypeItems = computed(() => + columnTypeOptions.map((value) => ({ + value, + label: t(`mongo.import.inferredType.${value}`), + })), +); +const showColumnTypeEditors = computed(() => format.value === "csv" && typeMode.value !== "extendedJson"); + +const parseOptions = computed((): api.MongoImportParseOptions => { + const overrides = columnTypeOverrides.value; + return { encoding: encoding.value, delimiter: delimiter.value, hasHeader: hasHeader.value, @@ -76,8 +88,9 @@ const parseOptions = computed( typeMode: typeMode.value, recognizeObjectIdHex: recognizeObjectIdHex.value, skipErrorRows: skipErrorRows.value, - }), -); + columnTypes: Object.keys(overrides).length ? { ...overrides } : null, + }; +}); function formatFromName(name: string): api.MongoImportFormat { const lower = name.toLowerCase(); @@ -99,6 +112,7 @@ function assignSource(source: string | File) { sourceName.value = importSourceDisplayName(source); format.value = formatFromName(sourceName.value); typeMode.value = format.value === "csv" ? "auto" : "extendedJson"; + columnTypeOverrides.value = {}; delimiter.value = importTextDelimiterForName(sourceName.value); wizardStep.value = "source"; queuePreview(); @@ -137,8 +151,11 @@ async function loadPreview() { const source = selectedSource.value; if (!source) return; const requestId = ++previewRequestId; - loadingPreview.value = true; - previewError.value = ""; + const keepExistingPreview = !!preview.value; + if (!keepExistingPreview) { + loadingPreview.value = true; + previewError.value = ""; + } try { const input = importPreviewInput(uploadedSource, source); const next = await api.previewMongodbImportFile(input.fileOrPath, { @@ -149,6 +166,7 @@ async function loadPreview() { }); if (requestId !== previewRequestId) return; preview.value = next; + previewError.value = ""; uploadedSource = uploadedImportSourceFromPreview(next) ?? uploadedSource; } catch (error) { if (requestId !== previewRequestId) return; @@ -159,7 +177,11 @@ async function loadPreview() { } } -watch([format, encoding, delimiter, hasHeader, trim, emptyAsNull, typeMode, recognizeObjectIdHex, previewLimit], () => { +watch([format, hasHeader, typeMode], () => { + if (Object.keys(columnTypeOverrides.value).length) columnTypeOverrides.value = {}; +}); + +watch([format, encoding, delimiter, hasHeader, trim, emptyAsNull, typeMode, recognizeObjectIdHex, previewLimit, columnTypeOverrides], () => { if (selectedSource.value) queuePreview(); }); @@ -178,6 +200,7 @@ function reset() { selectedSource.value = null; sourceName.value = ""; previewError.value = ""; + columnTypeOverrides.value = {}; wizardStep.value = "source"; running.value = false; cancelling.value = false; @@ -274,6 +297,23 @@ function setTypeMode(value: unknown) { if (value === "string" || value === "auto" || value === "extendedJson") typeMode.value = value; } +function isImportInferredType(value: unknown): value is api.MongoImportInferredType { + return columnTypeOptions.includes(value as api.MongoImportInferredType); +} + +function columnType(column: api.MongoImportColumn): api.MongoImportInferredType { + return columnTypeOverrides.value[column.name] ?? column.inferredType; +} + +function setColumnType(name: string, value: unknown) { + if (!isImportInferredType(value)) return; + const inferred = preview.value?.columns.find((column) => column.name === name)?.inferredType; + const next = { ...columnTypeOverrides.value }; + if (value === inferred) delete next[name]; + else next[name] = value; + columnTypeOverrides.value = next; +} + function requestClose() { if (running.value) { closeBlocked.value = true; @@ -319,7 +359,7 @@ function requestClose() {
- CSV @@ -339,11 +379,11 @@ function requestClose() {
- +
- {{ t("mongo.import.typeString") }} @@ -365,28 +405,39 @@ function requestClose() {
- +
-
+
{{ t("mongo.import.previewing") }}
-
{{ previewError }}
-
+
{{ previewError }}
+
{{ t("mongo.import.estimatedRows", { count: preview.estimatedRows ?? 0 }) }}
- diff --git a/apps/desktop/src/components/document/__tests__/MongoImportDialog.spec.ts b/apps/desktop/src/components/document/__tests__/MongoImportDialog.spec.ts index 282f951a74..68e2f2942a 100644 --- a/apps/desktop/src/components/document/__tests__/MongoImportDialog.spec.ts +++ b/apps/desktop/src/components/document/__tests__/MongoImportDialog.spec.ts @@ -27,6 +27,71 @@ vi.mock("@/lib/database/productionExecutionGuard", () => ({ executeWithProductionContextGuard: async ({ execute }: { execute: () => Promise }) => execute(), })); +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + + function collectOptions(nodes: unknown[] | undefined): ReturnType[] { + if (!nodes) return []; + const options: ReturnType[] = []; + for (const node of nodes) { + if (!node || typeof node !== "object") continue; + const vnode = node as { type?: { name?: string }; props?: { value?: unknown }; children?: unknown }; + if (vnode.type?.name === "SelectItemStub") { + const children = vnode.children; + const label = typeof children === "object" && children && "default" in children && typeof (children as { default: unknown }).default === "function" ? (children as { default: () => unknown }).default() : children; + options.push(h("option", { value: String(vnode.props?.value ?? "") }, label as never)); + continue; + } + const children = vnode.children; + if (typeof children === "object" && children && "default" in children && typeof (children as { default: unknown }).default === "function") { + options.push(...collectOptions((children as { default: () => unknown[] }).default())); + } else if (Array.isArray(children)) { + options.push(...collectOptions(children)); + } + } + return options; + } + + return { + Select: defineComponent({ + props: { modelValue: { type: [String, Number, Boolean], default: undefined } }, + emits: ["update:modelValue"], + setup(props, { emit, slots, attrs }) { + return () => + h( + "select", + { + ...attrs, + value: props.modelValue, + onChange: (event: Event) => emit("update:modelValue", (event.target as HTMLSelectElement).value), + }, + collectOptions(slots.default?.() as unknown[] | undefined), + ); + }, + }), + SelectItem: defineComponent({ + name: "SelectItemStub", + props: { value: { type: [String, Number, Boolean], required: true } }, + setup: () => () => null, + }), + SelectContent: defineComponent({ + setup: + (_, { slots }) => + () => + h("div", slots.default?.()), + }), + SelectTrigger: defineComponent({ + setup: + (_, { slots }) => + () => + h("div", slots.default?.()), + }), + SelectValue: defineComponent({ + setup: () => () => null, + }), + }; +}); + import MongoImportDialog from "../MongoImportDialog.vue"; function previewResult(overrides: Partial = {}): MongoImportPreview { @@ -128,4 +193,191 @@ describe("MongoImportDialog", () => { expect(api.releaseMongodbImportSource).toHaveBeenCalledWith("src-1"); app.unmount(); }); + + async function mountWithFile(fileName: string, preview: MongoImportPreview) { + api.previewMongodbImportFile.mockResolvedValue(preview); + const app = createApp( + defineComponent({ + setup() { + return () => + h(MongoImportDialog, { + open: true, + connectionId: "c1", + database: "shop", + collection: "orders", + }); + }, + }), + ); + app.mount(document.body); + await nextTick(); + const input = document.querySelector("input[type='file']") as HTMLInputElement; + const file = new File(["id\n1"], fileName, { type: "text/csv" }); + Object.defineProperty(input, "files", { configurable: true, value: [file] }); + input.dispatchEvent(new Event("change")); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + return app; + } + + function columnTypeTrigger() { + return document.querySelector('button[aria-label="mongo.import.columnType"]') as HTMLButtonElement | null; + } + + async function setColumnTypeValue(value: string) { + const trigger = columnTypeTrigger(); + expect(trigger).not.toBeNull(); + trigger!.click(); + await nextTick(); + const item = [...document.querySelectorAll('[role="menuitem"]')].find((element) => (element.textContent ?? "").includes(`inferredType.${value}`)) as HTMLButtonElement | undefined; + expect(item).toBeTruthy(); + item!.click(); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + } + + it("defaults csv column types to inference and lets the user override them", async () => { + const app = await mountWithFile("orders.csv", previewResult({ columns: [{ name: "id", inferredType: "integer" }], rows: [{ id: 1 }] })); + const trigger = columnTypeTrigger(); + expect(trigger).not.toBeNull(); + expect(trigger?.textContent).toContain("inferredType.integer"); + expect(trigger?.getAttribute("aria-label")).toBe("mongo.import.columnType"); + + await setColumnTypeValue("string"); + + expect(api.previewMongodbImportFile.mock.calls.at(-1)?.[1]).toMatchObject({ + parseOptions: expect.objectContaining({ columnTypes: { id: "string" } }), + }); + app.unmount(); + }); + + it("selects a column type on pointerdown so dialogs do not swallow the click", async () => { + const app = await mountWithFile("orders.csv", previewResult({ columns: [{ name: "id", inferredType: "integer" }], rows: [{ id: 1 }] })); + const trigger = columnTypeTrigger(); + expect(trigger).not.toBeNull(); + trigger!.click(); + await nextTick(); + const item = [...document.querySelectorAll('[role="menuitem"]')].find((element) => (element.textContent ?? "").includes("inferredType.boolean")) as HTMLButtonElement | undefined; + expect(item).toBeTruthy(); + item!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + expect(api.previewMongodbImportFile.mock.calls.at(-1)?.[1]).toMatchObject({ + parseOptions: expect.objectContaining({ columnTypes: { id: "boolean" } }), + }); + app.unmount(); + }); + + it("does not replace the preview table with a loading state when column types change", async () => { + const app = await mountWithFile("orders.csv", previewResult({ columns: [{ name: "id", inferredType: "integer" }], rows: [{ id: 1 }] })); + expect(columnTypeTrigger()).not.toBeNull(); + api.previewMongodbImportFile.mockImplementation(() => new Promise(() => {})); + + const trigger = columnTypeTrigger(); + trigger!.click(); + await nextTick(); + const item = [...document.querySelectorAll('[role="menuitem"]')].find((element) => (element.textContent ?? "").includes("inferredType.string")) as HTMLButtonElement | undefined; + expect(item).toBeTruthy(); + item!.click(); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + + expect(columnTypeTrigger()).not.toBeNull(); + expect(document.body.textContent).not.toContain("mongo.import.previewing"); + app.unmount(); + }); + + it("clears column type overrides when the file changes", async () => { + const app = await mountWithFile("orders.csv", previewResult({ columns: [{ name: "id", inferredType: "integer" }] })); + await setColumnTypeValue("string"); + + const input = document.querySelector("input[type='file']") as HTMLInputElement; + const file = new File(["id\n2"], "other.csv", { type: "text/csv" }); + Object.defineProperty(input, "files", { configurable: true, value: [file] }); + input.dispatchEvent(new Event("change")); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + + expect(api.previewMongodbImportFile.mock.calls.at(-1)?.[1]).toMatchObject({ + parseOptions: expect.objectContaining({ columnTypes: null }), + }); + app.unmount(); + }); + + it("does not show column type editors for json imports", async () => { + const app = await mountWithFile( + "orders.json", + previewResult({ + format: "json", + fileName: "orders.json", + columns: [{ name: "id", inferredType: "string" }], + }), + ); + expect(columnTypeTrigger()).toBeNull(); + expect(document.body.textContent).toContain("(string)"); + app.unmount(); + }); + + function lastParseOptions() { + return api.previewMongodbImportFile.mock.calls.at(-1)?.[1]?.parseOptions as Record | undefined; + } + + async function overrideIdToString() { + await setColumnTypeValue("string"); + } + + async function setLabeledSelect(label: string, value: string) { + const select = document.querySelector(`select[aria-label="${label}"]`) as HTMLSelectElement; + expect(select).not.toBeNull(); + select.value = value; + select.dispatchEvent(new Event("change")); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + } + + it("keeps column type overrides when only the delimiter changes", async () => { + const app = await mountWithFile("orders.csv", previewResult({ columns: [{ name: "id", inferredType: "integer" }] })); + await overrideIdToString(); + expect(lastParseOptions()).toMatchObject({ columnTypes: { id: "string" } }); + + const delimiter = document.querySelector('input[aria-label="tableImport.delimiter"]') as HTMLInputElement; + expect(delimiter).not.toBeNull(); + delimiter.value = ";"; + delimiter.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + + expect(lastParseOptions()).toMatchObject({ columnTypes: { id: "string" }, delimiter: ";" }); + app.unmount(); + }); + + it("clears column type overrides when format, header row, or type mode changes", async () => { + const app = await mountWithFile("orders.csv", previewResult({ columns: [{ name: "id", inferredType: "integer" }] })); + + await overrideIdToString(); + await setLabeledSelect("tableImport.sourceFormat", "json"); + expect(lastParseOptions()).toMatchObject({ columnTypes: null }); + + await setLabeledSelect("tableImport.sourceFormat", "csv"); + await overrideIdToString(); + const header = document.querySelector('input[aria-label="tableImport.hasHeader"]') as HTMLInputElement; + header.click(); + await vi.advanceTimersByTimeAsync(200); + await Promise.resolve(); + await nextTick(); + expect(lastParseOptions()).toMatchObject({ columnTypes: null }); + + await overrideIdToString(); + await setLabeledSelect("mongo.import.typeMode", "string"); + expect(lastParseOptions()).toMatchObject({ columnTypes: null }); + + app.unmount(); + }); }); diff --git a/apps/desktop/src/components/ui/LightDropdown.vue b/apps/desktop/src/components/ui/LightDropdown.vue index 850005e09b..3d2227231d 100644 --- a/apps/desktop/src/components/ui/LightDropdown.vue +++ b/apps/desktop/src/components/ui/LightDropdown.vue @@ -168,6 +168,13 @@ function selectItem(item: LightDropdownItem) { if (props.closeOnSelect) close(); } +function selectItemOnKeyboard(item: LightDropdownItem, event: MouseEvent) { + // Keyboard activation (Enter/Space) emits a click with detail 0; mouse + // clicks are already handled by pointerdown, so only pick up keyboard here + // to avoid selecting twice for one press. + if (event.detail === 0) selectItem(item); +} + onBeforeUnmount(close); @@ -180,7 +187,7 @@ onBeforeUnmount(close); -
# - {{ column.name }} - ({{ column.inferredType }}) + +
{{ column.name }}
+ + ({{ column.inferredType }})