feat(mongodb): allow editing inferred CSV import column types
This commit is contained in:
1 parent
99738c160c
commit
83c8447b4d
17 files changed
+706
-33
No files matched your search
@@ -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<api.MongoImportTypeMode>("auto");
|
||||
const columnTypeOverrides = ref<Record<string, api.MongoImportInferredType>>({});
|
||||
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<LightDropdownItem[]>(() =>
|
||||
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() {
|
||||
<div class="grid grid-cols-3 gap-3 rounded-md border p-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.sourceFormat") }}</Label>
|
||||
<Select :model-value="format" @update:model-value="setFormat">
|
||||
<Select :model-value="format" :aria-label="t('tableImport.sourceFormat')" @update:model-value="setFormat">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
@@ -339,11 +379,11 @@ function requestClose() {
|
||||
</div>
|
||||
<div v-if="format === 'csv'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.delimiter") }}</Label>
|
||||
<Input v-model="delimiter" class="h-8 text-xs font-mono" />
|
||||
<Input v-model="delimiter" class="h-8 text-xs font-mono" :aria-label="t('tableImport.delimiter')" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("mongo.import.typeMode") }}</Label>
|
||||
<Select :model-value="typeMode" @update:model-value="setTypeMode">
|
||||
<Select :model-value="typeMode" :aria-label="t('mongo.import.typeMode')" @update:model-value="setTypeMode">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{{ t("mongo.import.typeString") }}</SelectItem>
|
||||
@@ -365,28 +405,39 @@ function requestClose() {
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border p-3">
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="hasHeader" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("tableImport.hasHeader") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="hasHeader" type="checkbox" class="h-3.5 w-3.5 accent-primary" :aria-label="t('tableImport.hasHeader')" /> {{ t("tableImport.hasHeader") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="trim" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("tableImport.trimValues") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="emptyAsNull" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.emptyAsNull") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="recognizeObjectIdHex" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.recognizeObjectIdHex") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="skipErrorRows" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.skipErrorRows") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div v-if="loadingPreview && !preview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("mongo.import.previewing") }}
|
||||
</div>
|
||||
<div v-else-if="previewError" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ previewError }}</div>
|
||||
<div v-else-if="preview" class="space-y-2">
|
||||
<div v-if="previewError" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ previewError }}</div>
|
||||
<div v-if="preview" class="space-y-2">
|
||||
<div class="text-xs text-muted-foreground">{{ t("mongo.import.estimatedRows", { count: preview.estimatedRows ?? 0 }) }}</div>
|
||||
<div class="max-h-56 overflow-auto rounded-md border">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b px-2 py-1 text-left">#</th>
|
||||
<th v-for="column in preview.columns" :key="column.name" class="border-b px-2 py-1 text-left">
|
||||
{{ column.name }}
|
||||
<span class="text-muted-foreground">({{ column.inferredType }})</span>
|
||||
<th v-for="column in preview.columns" :key="column.name" class="border-b px-2 py-1 text-left align-top">
|
||||
<div class="font-medium">{{ column.name }}</div>
|
||||
<LightDropdown
|
||||
v-if="showColumnTypeEditors"
|
||||
:model-value="columnType(column)"
|
||||
:items="columnTypeItems"
|
||||
:aria-label="t('mongo.import.columnType', { name: column.name })"
|
||||
:disabled="running"
|
||||
trigger-class="mt-1 inline-flex h-6 w-full min-w-[6.5rem] max-w-[8.5rem] items-center justify-between gap-1 rounded-md border bg-background px-1.5 text-[11px] font-normal hover:bg-muted/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
content-class="pointer-events-auto !z-[80] ring-1 ring-foreground/10"
|
||||
check-position="right"
|
||||
@update:model-value="(value) => setColumnType(column.name, value)"
|
||||
/>
|
||||
<span v-else-if="format !== 'csv'" class="text-muted-foreground">({{ column.inferredType }})</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -27,6 +27,71 @@ vi.mock("@/lib/database/productionExecutionGuard", () => ({
|
||||
executeWithProductionContextGuard: async ({ execute }: { execute: () => Promise<unknown> }) => execute(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
|
||||
function collectOptions(nodes: unknown[] | undefined): ReturnType<typeof h>[] {
|
||||
if (!nodes) return [];
|
||||
const options: ReturnType<typeof h>[] = [];
|
||||
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> = {}): 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<string, unknown> | 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
@@ -180,7 +187,7 @@ onBeforeUnmount(close);
|
||||
<ChevronDown v-if="showChevron" class="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" ref="menuRef" class="fixed z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-md p-1 cn-menu-translucent text-popover-foreground" :class="contentClass" :style="menuStyle" role="menu">
|
||||
<div v-if="open" ref="menuRef" class="pointer-events-auto fixed z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-md p-1 cn-menu-translucent text-popover-foreground" :class="contentClass" :style="menuStyle" role="menu" @pointerdown.prevent>
|
||||
<div v-if="label" :class="labelClass">{{ label }}</div>
|
||||
<div v-if="label" class="bg-border -mx-1 my-1 h-px" />
|
||||
<template v-for="item in items" :key="item.value">
|
||||
@@ -194,7 +201,8 @@ onBeforeUnmount(close);
|
||||
:title="item.title"
|
||||
:style="{ paddingInlineStart: `${0.375 + (item.indentLevel ?? 0) * 0.75}rem` }"
|
||||
role="menuitem"
|
||||
@click="selectItem(item)"
|
||||
@pointerdown.prevent="selectItem(item)"
|
||||
@click="selectItemOnKeyboard(item, $event)"
|
||||
>
|
||||
<Check v-if="checkPosition === 'left'" class="h-3 w-3 shrink-0" :class="[isItemSelected(item) ? selectedCheckClass : 'opacity-0']" />
|
||||
<span v-if="item.leadingText" class="inline-flex h-5 w-6 shrink-0 items-center justify-center text-sm font-medium leading-none">
|
||||
|
||||
@@ -32,6 +32,10 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (target.closest('[role=menu], [role=listbox], [data-slot=select-content]')) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@@ -5372,6 +5372,18 @@ export default withEnglishFallback({
|
||||
typeString: "Hamısı sətir",
|
||||
typeAuto: "Avtomatik aşkarlama",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "{name} tipi",
|
||||
inferredType: {
|
||||
string: "Sətir",
|
||||
boolean: "Məntiqi",
|
||||
integer: "Tam ədəd",
|
||||
decimal: "Onluq",
|
||||
date: "Tarix",
|
||||
objectId: "ObjectId",
|
||||
object: "Obyekt",
|
||||
array: "Massiv",
|
||||
mixed: "Qarışıq",
|
||||
},
|
||||
hasHeader: "Birinci sətir başlıqdır",
|
||||
trim: "Dəyərləri kəs",
|
||||
emptyAsNull: "Boş xanaları null yaz",
|
||||
|
||||
@@ -5522,6 +5522,18 @@ export default {
|
||||
typeString: "All strings",
|
||||
typeAuto: "Auto detect",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "Type for {name}",
|
||||
inferredType: {
|
||||
string: "String",
|
||||
boolean: "Boolean",
|
||||
integer: "Integer",
|
||||
decimal: "Decimal",
|
||||
date: "Date",
|
||||
objectId: "ObjectId",
|
||||
object: "Object",
|
||||
array: "Array",
|
||||
mixed: "Mixed",
|
||||
},
|
||||
hasHeader: "First row is header",
|
||||
trim: "Trim values",
|
||||
emptyAsNull: "Empty cells as null",
|
||||
|
||||
@@ -5224,6 +5224,18 @@ export default withEnglishFallback({
|
||||
typeString: "Todo como texto",
|
||||
typeAuto: "Detectar automáticamente",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "Tipo de {name}",
|
||||
inferredType: {
|
||||
string: "Cadena",
|
||||
boolean: "Booleano",
|
||||
integer: "Entero",
|
||||
decimal: "Decimal",
|
||||
date: "Fecha",
|
||||
objectId: "ObjectId",
|
||||
object: "Objeto",
|
||||
array: "Matriz",
|
||||
mixed: "Mixto",
|
||||
},
|
||||
hasHeader: "La primera fila es el encabezado",
|
||||
trim: "Recortar valores",
|
||||
emptyAsNull: "Celdas vacías como null",
|
||||
|
||||
@@ -5222,6 +5222,18 @@ export default withEnglishFallback({
|
||||
typeString: "Tutto come stringa",
|
||||
typeAuto: "Rilevamento automatico",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "Tipo di {name}",
|
||||
inferredType: {
|
||||
string: "Stringa",
|
||||
boolean: "Booleano",
|
||||
integer: "Intero",
|
||||
decimal: "Decimale",
|
||||
date: "Data",
|
||||
objectId: "ObjectId",
|
||||
object: "Oggetto",
|
||||
array: "Array",
|
||||
mixed: "Misto",
|
||||
},
|
||||
hasHeader: "La prima riga è l'intestazione",
|
||||
trim: "Taglia gli spazi",
|
||||
emptyAsNull: "Celle vuote come null",
|
||||
|
||||
@@ -5251,6 +5251,18 @@ export default withEnglishFallback({
|
||||
typeString: "すべて文字列",
|
||||
typeAuto: "自動検出",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "{name} の型",
|
||||
inferredType: {
|
||||
string: "文字列",
|
||||
boolean: "ブール",
|
||||
integer: "整数",
|
||||
decimal: "小数",
|
||||
date: "日付",
|
||||
objectId: "ObjectId",
|
||||
object: "オブジェクト",
|
||||
array: "配列",
|
||||
mixed: "混合",
|
||||
},
|
||||
hasHeader: "先頭行をヘッダーにする",
|
||||
trim: "値をトリム",
|
||||
emptyAsNull: "空セルを null にする",
|
||||
|
||||
@@ -4859,6 +4859,18 @@ export default withEnglishFallback({
|
||||
typeString: "모두 문자열",
|
||||
typeAuto: "자동 감지",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "{name} 유형",
|
||||
inferredType: {
|
||||
string: "문자열",
|
||||
boolean: "불리언",
|
||||
integer: "정수",
|
||||
decimal: "소수",
|
||||
date: "날짜",
|
||||
objectId: "ObjectId",
|
||||
object: "객체",
|
||||
array: "배열",
|
||||
mixed: "혼합",
|
||||
},
|
||||
hasHeader: "첫 행을 헤더로 사용",
|
||||
trim: "값 앞뒤 공백 제거",
|
||||
emptyAsNull: "빈 셀을 null로 기록",
|
||||
|
||||
@@ -5224,6 +5224,18 @@ export default withEnglishFallback({
|
||||
typeString: "Tudo como texto",
|
||||
typeAuto: "Detectar automaticamente",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "Tipo de {name}",
|
||||
inferredType: {
|
||||
string: "Texto",
|
||||
boolean: "Booleano",
|
||||
integer: "Inteiro",
|
||||
decimal: "Decimal",
|
||||
date: "Data",
|
||||
objectId: "ObjectId",
|
||||
object: "Objeto",
|
||||
array: "Array",
|
||||
mixed: "Misto",
|
||||
},
|
||||
hasHeader: "A primeira linha é o cabeçalho",
|
||||
trim: "Remover espaços",
|
||||
emptyAsNull: "Células vazias como null",
|
||||
|
||||
@@ -5266,6 +5266,18 @@ export default withEnglishFallback({
|
||||
typeString: "Tümü metin",
|
||||
typeAuto: "Otomatik algıla",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "{name} türü",
|
||||
inferredType: {
|
||||
string: "Metin",
|
||||
boolean: "Mantıksal",
|
||||
integer: "Tam sayı",
|
||||
decimal: "Ondalık",
|
||||
date: "Tarih",
|
||||
objectId: "ObjectId",
|
||||
object: "Nesne",
|
||||
array: "Dizi",
|
||||
mixed: "Karışık",
|
||||
},
|
||||
hasHeader: "İlk satır başlıktır",
|
||||
trim: "Değerleri kırp",
|
||||
emptyAsNull: "Boş hücreleri null yaz",
|
||||
|
||||
@@ -5497,6 +5497,18 @@ export default withEnglishFallback({
|
||||
typeString: "全部按字符串",
|
||||
typeAuto: "自动推断",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "{name} 的类型",
|
||||
inferredType: {
|
||||
string: "字符串",
|
||||
boolean: "布尔",
|
||||
integer: "整数",
|
||||
decimal: "小数",
|
||||
date: "日期",
|
||||
objectId: "ObjectId",
|
||||
object: "对象",
|
||||
array: "数组",
|
||||
mixed: "混合",
|
||||
},
|
||||
hasHeader: "首行是表头",
|
||||
trim: "去除首尾空白",
|
||||
emptyAsNull: "空单元格写入 null",
|
||||
|
||||
@@ -4539,6 +4539,18 @@ export default withEnglishFallback({
|
||||
typeString: "全部按字串",
|
||||
typeAuto: "自動推斷",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
columnType: "{name} 的類型",
|
||||
inferredType: {
|
||||
string: "字串",
|
||||
boolean: "布林",
|
||||
integer: "整數",
|
||||
decimal: "小數",
|
||||
date: "日期",
|
||||
objectId: "ObjectId",
|
||||
object: "物件",
|
||||
array: "陣列",
|
||||
mixed: "混合",
|
||||
},
|
||||
hasHeader: "首列是表頭",
|
||||
trim: "去除首尾空白",
|
||||
emptyAsNull: "空儲存格寫入 null",
|
||||
|
||||
@@ -1033,6 +1033,8 @@ export type {
|
||||
TableImportProgress,
|
||||
MongoImportFormat,
|
||||
MongoImportTypeMode,
|
||||
MongoImportInferredType,
|
||||
MongoImportColumn,
|
||||
MongoImportIssue,
|
||||
MongoImportParseOptions,
|
||||
MongoImportPreviewRequest,
|
||||
|
||||
@@ -5047,7 +5047,7 @@ export async function releaseTableImportSource(_sourceRef: string): Promise<bool
|
||||
|
||||
export type MongoImportFormat = "csv" | "json" | "ndjson";
|
||||
export type MongoImportTypeMode = "string" | "auto" | "extendedJson";
|
||||
export type MongoImportInferredType = "boolean" | "integer" | "decimal" | "date" | "object" | "array" | "string";
|
||||
export type MongoImportInferredType = "boolean" | "integer" | "decimal" | "date" | "objectId" | "object" | "array" | "mixed" | "string";
|
||||
export type MongoImportStatus = "running" | "done" | "error" | "cancelled";
|
||||
export type MongoImportPhase = "preparing" | "parsing" | "writing" | "done";
|
||||
export type MongoExportFormat = "csv" | "ndjson";
|
||||
@@ -5072,6 +5072,7 @@ export interface MongoImportParseOptions {
|
||||
typeMode?: MongoImportTypeMode | null;
|
||||
recognizeObjectIdHex?: boolean | null;
|
||||
skipErrorRows?: boolean | null;
|
||||
columnTypes?: Partial<Record<string, MongoImportInferredType>> | null;
|
||||
}
|
||||
|
||||
export interface MongoImportPreviewRequest {
|
||||
|
||||
Reference in new issue
Block a user