diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts
index 6916ef387..5ac046ded 100644
--- a/apps/console/vite.config.ts
+++ b/apps/console/vite.config.ts
@@ -101,6 +101,23 @@ const workspaceAliases: Record = {
'@object-ui/plugin-designer': path.resolve(__dirname, '../../packages/plugin-designer/src'),
};
+// Opt-in override of the installed `@objectstack/client`. The published client
+// (11.2.0) predates the async import-job API (`data.createImportJob` et al.),
+// so to exercise the full background-import + undo flow through the real
+// console before that client ships, point OBJECTSTACK_CLIENT_DIST at a locally
+// built client (its dist entry or package dir). Inert when unset — production
+// and CI builds use the installed client unchanged.
+const clientDistOverride = process.env.OBJECTSTACK_CLIENT_DIST;
+// Extra dirs the dev server may read the override from — it lives outside the
+// workspace root, so Vite's default `server.fs.allow` would 403 it (blank page).
+const clientFsAllow: string[] = [];
+if (clientDistOverride) {
+ const resolved = path.resolve(clientDistOverride);
+ workspaceAliases['@objectstack/client'] = resolved;
+ // Allow the containing package (…/dist/index.mjs → …/) so Vite can serve it.
+ clientFsAllow.push(path.dirname(resolved), path.resolve(path.dirname(resolved), '..'));
+}
+
// https://vitejs.dev/config/
export default defineConfig({
base: basePath,
@@ -234,6 +251,9 @@ export default defineConfig({
},
server: {
port: 5180,
+ // Widen the fs allow-list only when an out-of-tree client override is set
+ // (see OBJECTSTACK_CLIENT_DIST above); otherwise keep Vite's defaults.
+ ...(clientFsAllow.length ? { fs: { allow: [path.resolve(__dirname, '../..'), ...clientFsAllow] } } : {}),
proxy: {
'/api': { target: process.env.DEV_PROXY_TARGET || 'http://localhost:3000', changeOrigin: true },
},
diff --git a/e2e/import-console/import-console-undo.spec.ts b/e2e/import-console/import-console-undo.spec.ts
new file mode 100644
index 000000000..9d85742ff
--- /dev/null
+++ b/e2e/import-console/import-console-undo.spec.ts
@@ -0,0 +1,114 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * The real-product Import Wizard flow, driven exactly the way a user reaches it:
+ *
+ * log in → open an object's list view (ObjectView) → click the toolbar
+ * "Import" button → upload a small CSV → opt into a BACKGROUND import → run it
+ * → open History → Undo → confirm the created rows are gone.
+ *
+ * This is the end-to-end guard for the "background import" gap fix: the wizard
+ * only routed to an undoable async job for files over the async threshold (5000
+ * rows), but the server only captures undo state at/under it — so an undoable
+ * job was unreachable through the UI. The `import-opt-background` toggle closes
+ * that gap; here we prove a 3-row import made through the *real console* is
+ * actually undoable, asserting record counts at the backend on both sides.
+ *
+ * Setup + skip behaviour: see playwright.import-console.config.ts. Gated on
+ * IMPORT_CONSOLE_LIVE=1 (the flow needs an import-job-capable client wired into
+ * the console) and additionally skips when the backend exposes no import-job
+ * route — so an unconfigured run reports skipped, not failed.
+ */
+const API = process.env.LIVE_API_URL || 'http://localhost:3000';
+const APP_NAME = process.env.LIVE_IMPORT_APP || 'crm_app';
+const OBJECT = process.env.LIVE_IMPORT_OBJECT || 'crm_lead';
+
+test.describe('Import Wizard — real console: background import + undo', () => {
+ test('a small background import made through the console is undoable', async ({ page }) => {
+ test.skip(
+ process.env.IMPORT_CONSOLE_LIVE !== '1',
+ 'set IMPORT_CONSOLE_LIVE=1 (and wire an import-job-capable client into the console) to run this real-console flow',
+ );
+
+ // The whole flow depends on the backend having the async import-job routes.
+ let jobsSupported = false;
+ try {
+ const r = await page.request.get(`${API}/api/v1/data/import/jobs`);
+ jobsSupported = r.status() !== 404;
+ } catch {
+ jobsSupported = false;
+ }
+ test.skip(!jobsSupported, `backend at ${API} has no import-job route (/api/v1/data/import/jobs)`);
+
+ // Count rows straight from the backend (cookie carried by the auth context).
+ const countRecords = async (): Promise => {
+ const res = await page.request.get(`${API}/api/v1/data/${OBJECT}`);
+ const body = await res.json();
+ return (body.records ?? []).length;
+ };
+
+ // 1) Land on the REAL object list view and find the REAL toolbar button.
+ await page.goto(`/apps/${APP_NAME}/${OBJECT}`);
+ const importBtn = page.getByTestId('object-view-import-button');
+ await expect(importBtn).toBeVisible({ timeout: 20_000 });
+
+ const baseline = await countRecords();
+
+ // 2) Open the wizard and upload a 3-row CSV (well under the async threshold).
+ await importBtn.click();
+ const stamp = Date.now();
+ const csv = [
+ 'name,email,status',
+ `RC One,rc.one.${stamp}@example.test,new`,
+ `RC Two,rc.two.${stamp}@example.test,new`,
+ `RC Three,rc.three.${stamp}@example.test,new`,
+ '',
+ ].join('\n');
+ await page.locator('input[type=file]').setInputFiles({
+ name: 'rc-import.csv',
+ mimeType: 'text/csv',
+ buffer: Buffer.from(csv, 'utf8'),
+ });
+
+ // 3) Mapping → Preview (exact-name headers auto-map name/email/status).
+ await page.getByTestId('import-next-btn').click();
+
+ // 4) The background-import toggle must be offered for a sub-threshold file —
+ // this is the gap fix — and we opt in.
+ const backgroundOpt = page.getByTestId('import-opt-background');
+ await expect(backgroundOpt).toBeVisible();
+ await backgroundOpt.getByRole('checkbox').click();
+
+ // 5) Run it — the toggle routes it through the async job path.
+ const jobCreate = page.waitForResponse(
+ (r) => /\/import\/jobs$/.test(r.url()) && r.request().method() === 'POST' && r.status() === 201,
+ );
+ await page.getByTestId('import-run-btn').click();
+ await jobCreate;
+
+ // Rows land at the backend.
+ await expect.poll(countRecords, { timeout: 20_000 }).toBe(baseline + 3);
+
+ // Identify the fresh, undoable job created by this run.
+ const jobsBody = await (await page.request.get(`${API}/api/v1/data/import/jobs`)).json();
+ const jobs: Array<{ jobId: string; undoable: boolean; revertedAt: string | null; createdAt: string }> =
+ jobsBody.jobs ?? jobsBody.records ?? jobsBody;
+ const mine = jobs
+ .filter((j) => j.undoable && !j.revertedAt)
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0];
+ expect(mine, 'a fresh undoable job should exist').toBeTruthy();
+
+ // 6) Undo through the History UI. Reload for a clean wizard rather than
+ // dismissing the result screen (whose Close button re-renders as the job
+ // settles, making the click flaky).
+ page.on('dialog', (d) => d.accept()); // accept the confirm() prompt
+ await page.reload();
+ await page.getByTestId('object-view-import-button').click();
+ await page.getByTestId('import-history-toggle').click();
+ await page.getByTestId(`import-history-undo-${mine.jobId}`).click();
+
+ // 7) The created rows are deleted and the job row flips to reverted.
+ await expect.poll(countRecords, { timeout: 20_000 }).toBe(baseline);
+ await expect(page.getByTestId(`import-history-reverted-${mine.jobId}`)).toBeVisible();
+ });
+});
diff --git a/e2e/import-harness/import-undo.spec.ts b/e2e/import-harness/import-undo.spec.ts
new file mode 100644
index 000000000..963072f5b
--- /dev/null
+++ b/e2e/import-harness/import-undo.spec.ts
@@ -0,0 +1,103 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * Full-stack Import Wizard flow against a real backend:
+ * upload a small CSV → opt into a BACKGROUND import → run it → open History →
+ * Undo → assert the created rows are gone.
+ *
+ * This is the regression guard for the "background import" gap: the wizard used
+ * to run a job only for files over the async threshold (5000 rows), but the
+ * server only captures undo state for files at or under it — so an undoable job
+ * was unreachable through the UI. The `import-opt-background` toggle closes that
+ * gap; this test proves a 3-row import made through the UI is actually undoable.
+ *
+ * Prereqs and skip behaviour: see playwright.import-harness.config.ts. The test
+ * skips (does not fail) when the harness origin isn't serving `/live.html`, so
+ * it's CI-safe.
+ */
+const HARNESS_PATH = '/live.html';
+const OBJECT = process.env.IMPORT_HARNESS_OBJECT || 'crm_lead';
+
+test.describe('Import Wizard — background import + undo (live UI + backend)', () => {
+ test('a small background import creates an undoable job; Undo deletes the rows', async ({ page, baseURL }) => {
+ // Reachability guard: skip cleanly when the machine-specific harness is down.
+ let reachable = false;
+ try {
+ const res = await page.request.get(`${baseURL}${HARNESS_PATH}`);
+ reachable = res.ok();
+ } catch {
+ reachable = false;
+ }
+ test.skip(!reachable, `import harness not reachable at ${baseURL}${HARNESS_PATH}`);
+
+ // Backend record count via the same proxied origin the harness uses.
+ const countRecords = async (): Promise => {
+ const res = await page.request.get(`/api/v1/data/${OBJECT}`);
+ const body = await res.json();
+ return (body.records ?? []).length;
+ };
+
+ await page.goto(HARNESS_PATH);
+ await expect(page.getByText('connected & authenticated')).toBeVisible({ timeout: 15_000 });
+
+ const baseline = await countRecords();
+
+ // 1) Open the wizard and upload a 3-row CSV (well under the async threshold).
+ await page.getByRole('button', { name: 'Open import' }).click();
+ const stamp = Date.now();
+ const csv = [
+ 'first_name,last_name,email',
+ `Bg,One,bg.one.${stamp}@example.test`,
+ `Bg,Two,bg.two.${stamp}@example.test`,
+ `Bg,Three,bg.three.${stamp}@example.test`,
+ '',
+ ].join('\n');
+ await page.locator('input[type=file]').setInputFiles({
+ name: 'bg-import.csv',
+ mimeType: 'text/csv',
+ buffer: Buffer.from(csv, 'utf8'),
+ });
+
+ // 2) Mapping → Preview.
+ await page.getByRole('button', { name: /^Next/ }).click();
+
+ // 3) The background-import toggle must be offered for a sub-threshold file
+ // when the data source supports jobs — this is the gap fix.
+ const backgroundOpt = page.getByTestId('import-opt-background');
+ await expect(backgroundOpt).toBeVisible();
+ await backgroundOpt.getByRole('checkbox').click();
+
+ // 4) Run it — routes through the async job path because of the toggle.
+ const jobCreate = page.waitForResponse(
+ (r) => /\/import\/jobs$/.test(r.url()) && r.request().method() === 'POST' && r.status() === 201,
+ );
+ await page.getByRole('button', { name: /Import\s+3\s+Rows/i }).click();
+ await jobCreate;
+
+ // Rows land at the backend.
+ await expect.poll(countRecords, { timeout: 20_000 }).toBe(baseline + 3);
+
+ // Identify the fresh, undoable job created by this run.
+ const jobsBody = await (await page.request.get('/api/v1/data/import/jobs')).json();
+ const jobs: Array<{ jobId: string; undoable: boolean; revertedAt: string | null; createdAt: string }> =
+ jobsBody.jobs ?? jobsBody.records ?? jobsBody;
+ const mine = jobs
+ .filter((j) => j.undoable && !j.revertedAt)
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0];
+ expect(mine, 'a fresh undoable job should exist').toBeTruthy();
+
+ // 5) Undo through the History UI. Reload for a clean wizard rather than
+ // dismissing the result screen (whose Close button re-renders as the job
+ // settles, making the click flaky).
+ page.on('dialog', (d) => d.accept()); // accept the confirm() prompt
+ await page.reload();
+ await expect(page.getByText('connected & authenticated')).toBeVisible({ timeout: 15_000 });
+ await page.getByRole('button', { name: 'Open import' }).click();
+ await page.getByTestId('import-history-toggle').click();
+ await page.getByTestId(`import-history-undo-${mine.jobId}`).click();
+
+ // 6) The created rows are deleted and the row flips to reverted.
+ await expect.poll(countRecords, { timeout: 20_000 }).toBe(baseline);
+ await expect(page.getByTestId(`import-history-reverted-${mine.jobId}`)).toBeVisible();
+ });
+});
diff --git a/package.json b/package.json
index fe02d9740..20e3f726f 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,9 @@
"changeset:publish": "changeset publish",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
- "test:e2e:live": "playwright test --config=playwright.live.config.ts"
+ "test:e2e:live": "playwright test --config=playwright.live.config.ts",
+ "test:e2e:import-harness": "playwright test --config=playwright.import-harness.config.ts",
+ "test:e2e:import-console": "playwright test --config=playwright.import-console.config.ts"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx
index efa3ad0ca..628995bc0 100644
--- a/packages/app-shell/src/views/ObjectView.tsx
+++ b/packages/app-shell/src/views/ObjectView.tsx
@@ -1616,12 +1616,25 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
onOpenChange={setShowImport}
objectName={objectDef.name}
objectLabel={objectLabel(objectDef)}
- fields={Object.entries(objectDef.fields || {}).map(([name, def]: [string, any]) => ({
- name,
- label: def?.label || name,
- type: def?.type || 'text',
- required: !!def?.required,
- }))}
+ fields={Object.entries(objectDef.fields || {})
+ // Only writable fields are importable targets. Computed
+ // types (formula/summary/autonumber) and fields flagged
+ // readonly / write:false are server-rejected, so we omit
+ // them from the mapping step rather than let a user map to
+ // a column the import will silently drop.
+ .filter(([, def]: [string, any]) =>
+ !['formula', 'summary', 'autonumber'].includes(def?.type) &&
+ !def?.readonly &&
+ def?.permissions?.write !== false,
+ )
+ .map(([name, def]: [string, any]) => ({
+ name,
+ label: def?.label || name,
+ type: def?.type || 'text',
+ required: !!def?.required,
+ // Enum options seed the downloadable template's example row.
+ ...(def?.options ? { options: def.options } : {}),
+ }))}
dataSource={dataSource}
onComplete={(result) => {
setRefreshKey(k => k + 1);
diff --git a/packages/data-objectstack/src/importJob.test.ts b/packages/data-objectstack/src/importJob.test.ts
new file mode 100644
index 000000000..cd28bccba
--- /dev/null
+++ b/packages/data-objectstack/src/importJob.test.ts
@@ -0,0 +1,71 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { ObjectStackAdapter } from './index';
+
+/**
+ * Adapter over a mock client `data` namespace. The async import-job methods are
+ * thin pass-throughs to the client SDK, so we assert delegation + argument
+ * shaping, plus graceful degradation when the client lacks the job API.
+ */
+function makeDS(stub: Record) {
+ const ds: any = new ObjectStackAdapter({
+ baseUrl: 'http://test.local',
+ fetch: vi.fn(async () => new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })),
+ });
+ ds.connected = true;
+ ds.connectionState = 'connected';
+ ds.client = { data: stub };
+ return ds;
+}
+
+describe('ObjectStackAdapter async import jobs', () => {
+ it('createImportJob delegates to client.data.createImportJob', async () => {
+ const createImportJob = vi.fn().mockResolvedValue({ jobId: 'imp_1', object: 'task', status: 'pending', total: 2 });
+ const ds = makeDS({ createImportJob });
+ const req = { format: 'json' as const, rows: [{ a: 1 }, { a: 2 }], writeMode: 'upsert' as const, matchFields: ['a'] };
+ const res = await ds.createImportJob('task', req);
+ expect(createImportJob).toHaveBeenCalledTimes(1);
+ expect(createImportJob.mock.calls[0]).toEqual(['task', req]);
+ expect(res).toMatchObject({ jobId: 'imp_1', status: 'pending', total: 2 });
+ });
+
+ it('getImportJobProgress / getImportJobResults / cancelImportJob delegate by jobId', async () => {
+ const getImportJobProgress = vi.fn().mockResolvedValue({ jobId: 'imp_1', status: 'running', percentComplete: 50 });
+ const getImportJobResults = vi.fn().mockResolvedValue({ jobId: 'imp_1', status: 'succeeded', results: [], resultsTruncated: false });
+ const cancelImportJob = vi.fn().mockResolvedValue({ success: true });
+ const ds = makeDS({ createImportJob: vi.fn(), getImportJobProgress, getImportJobResults, cancelImportJob });
+
+ expect((await ds.getImportJobProgress('imp_1')).percentComplete).toBe(50);
+ expect(getImportJobProgress).toHaveBeenCalledWith('imp_1');
+
+ expect((await ds.getImportJobResults('imp_1')).resultsTruncated).toBe(false);
+ expect(getImportJobResults).toHaveBeenCalledWith('imp_1');
+
+ await ds.cancelImportJob('imp_1');
+ expect(cancelImportJob).toHaveBeenCalledWith('imp_1');
+ });
+
+ it('listImportJobs forwards filters and returns the jobs array', async () => {
+ const listImportJobs = vi.fn().mockResolvedValue([{ jobId: 'imp_1', object: 'task', status: 'succeeded' }]);
+ const ds = makeDS({ createImportJob: vi.fn(), listImportJobs });
+ const jobs = await ds.listImportJobs({ object: 'task', status: 'succeeded', limit: 10 });
+ expect(listImportJobs).toHaveBeenCalledWith({ object: 'task', status: 'succeeded', limit: 10 });
+ expect(jobs).toHaveLength(1);
+ });
+
+ it('throws UNSUPPORTED_OPERATION when the client lacks the job API', async () => {
+ const ds = makeDS({ import: vi.fn() }); // sync import only, no createImportJob
+ await expect(ds.createImportJob('task', { format: 'json', rows: [] })).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' });
+ await expect(ds.getImportJobProgress('imp_1')).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' });
+ });
+});
diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts
index d2d32a161..179d9423f 100644
--- a/packages/data-objectstack/src/index.ts
+++ b/packages/data-objectstack/src/index.ts
@@ -7,7 +7,21 @@
*/
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
-import type { DataSource, QueryParams, QueryResult, FileUploadResult, ExportDownloadRequest } from '@object-ui/types';
+import type {
+ DataSource,
+ QueryParams,
+ QueryResult,
+ FileUploadResult,
+ ExportDownloadRequest,
+ ImportRequestOptions,
+ ImportRecordsResult,
+ CreateImportJobResult,
+ ImportJobProgressInfo,
+ ImportJobResultsInfo,
+ ImportJobSummaryInfo,
+ ImportJobUndoResult,
+ ListImportJobsOptions,
+} from '@object-ui/types';
import { convertFiltersToAST } from '@object-ui/core';
import { MetadataCache } from './cache/MetadataCache';
import {
@@ -1047,6 +1061,183 @@ export class ObjectStackAdapter implements DataSource {
}
}
+ /**
+ * Bulk-import raw spreadsheet rows in a single server round-trip via
+ * `POST /api/v1/data/:object/import`. The server performs all value coercion
+ * (booleans, numbers, dates→ISO, select label→code, lookup name→id) from the
+ * object's field metadata, so this method forwards the request verbatim and
+ * returns the aggregate + per-row result untouched.
+ *
+ * Requires `@objectstack/client` with `data.import` (server `/import` route).
+ * Callers should feature-detect (`typeof dataSource.importRecords`) and fall
+ * back to a per-row `create` loop when unavailable.
+ */
+ async importRecords(
+ resource: string,
+ request: ImportRequestOptions,
+ ): Promise {
+ await this.connect();
+ const importFn = (this.client.data as { import?: unknown }).import;
+ if (typeof importFn !== 'function') {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support data.import(). ' +
+ 'Upgrade the client, or import via a per-row create fallback.',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ const result = await (importFn as (
+ object: string,
+ req: ImportRequestOptions,
+ ) => Promise).call(this.client.data, resource, request);
+ return result;
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
+ /**
+ * Feature-detect the async import-job API on the connected client. Older
+ * clients/servers lack these routes; callers fall back to {@link importRecords}.
+ */
+ private importJobApi(): {
+ createImportJob: (object: string, req: ImportRequestOptions) => Promise;
+ getImportJobProgress: (jobId: string) => Promise;
+ getImportJobResults: (jobId: string) => Promise;
+ listImportJobs: (query: ListImportJobsOptions) => Promise;
+ cancelImportJob: (jobId: string) => Promise<{ success: boolean }>;
+ undoImportJob: (jobId: string) => Promise;
+ } | undefined {
+ const d = this.client.data as Record;
+ if (typeof d.createImportJob !== 'function') return undefined;
+ return d as any;
+ }
+
+ /**
+ * Start an asynchronous import job — the large-file counterpart to
+ * {@link importRecords}. Posts the whole payload once; the server processes
+ * rows in the background. Requires an `@objectstack/client` new enough to
+ * expose `data.createImportJob` (server `/import/jobs` route). Callers should
+ * feature-detect (`typeof dataSource.createImportJob`) and fall back to the
+ * synchronous path when unavailable.
+ */
+ async createImportJob(
+ resource: string,
+ request: ImportRequestOptions,
+ ): Promise {
+ await this.connect();
+ const api = this.importJobApi();
+ if (!api) {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support async import jobs (data.createImportJob). ' +
+ 'Upgrade the client, or use the synchronous importRecords() path.',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ return await api.createImportJob.call(this.client.data, resource, request);
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
+ /** Poll an import job's progress. Requires {@link createImportJob} support. */
+ async getImportJobProgress(jobId: string): Promise {
+ await this.connect();
+ const api = this.importJobApi();
+ if (!api) {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support async import jobs.',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ return await api.getImportJobProgress.call(this.client.data, jobId);
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
+ /** Fetch an import job's capped per-row results. */
+ async getImportJobResults(jobId: string): Promise {
+ await this.connect();
+ const api = this.importJobApi();
+ if (!api) {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support async import jobs.',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ return await api.getImportJobResults.call(this.client.data, jobId);
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
+ /** List recent import jobs (history), newest first. */
+ async listImportJobs(options: ListImportJobsOptions = {}): Promise {
+ await this.connect();
+ const api = this.importJobApi();
+ if (!api) {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support async import jobs.',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ return await api.listImportJobs.call(this.client.data, options);
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
+ /** Cancel a pending/running import job (cooperative). */
+ async cancelImportJob(jobId: string): Promise {
+ await this.connect();
+ const api = this.importJobApi();
+ if (!api) {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support async import jobs.',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ await api.cancelImportJob.call(this.client.data, jobId);
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
+ /**
+ * Logically roll back a finished import job — delete the records it created
+ * and restore the records it updated to their pre-import values. Requires an
+ * `@objectstack/client` new enough to expose `data.undoImportJob`, and a job
+ * the server captured an undo log for (see {@link ImportJobProgressInfo.undoable}).
+ */
+ async undoImportJob(jobId: string): Promise {
+ await this.connect();
+ const api = this.importJobApi();
+ if (!api || typeof (api as { undoImportJob?: unknown }).undoImportJob !== 'function') {
+ throw new ObjectStackError(
+ 'The connected @objectstack/client does not support undoing import jobs (data.undoImportJob).',
+ 'UNSUPPORTED_OPERATION',
+ 400,
+ );
+ }
+ try {
+ return await api.undoImportJob.call(this.client.data, jobId);
+ } catch (err) {
+ throw normaliseClientError(err);
+ }
+ }
+
/**
* Normalize the result from data.find() or data.query() into a consistent QueryResult.
*/
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index 2ff4d4db9..444f66cb9 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -231,6 +231,79 @@ const zh = {
requiredMark: '*',
required: '必填',
invalidType: '{{type}} 格式无效',
+ // 自动匹配(Airtable 风格)
+ autoMatched: '自动匹配',
+ autoMatchedSummary: '已自动匹配 {{count}} 列 — 请在下方检查并调整。',
+ confidence: {
+ high: '高置信度',
+ medium: '中置信度',
+ low: '低置信度',
+ },
+ // 下载模板
+ downloadTemplate: '下载模板',
+ downloadTemplateHint: '获取带正确列的 CSV(必填字段标 *)。',
+ // 校验(服务端 dryRun 预检)
+ validate: '校验数据',
+ validateHint: '导入前在服务端逐行校验。',
+ validating: '校验中…',
+ validatePassed: '全部 {{ok}} 行均有效。',
+ validateFailed: '{{ok}} 条有效,{{errors}} 条有错误。',
+ errorCount: '{{count}} 个错误',
+ errorRowPrefix: '第 {{row}} 行:',
+ // 导入选项 / 写入模式
+ options: '导入选项',
+ writeMode: '当某行匹配到已有记录时',
+ writeModeOpt: {
+ insert: '始终新建',
+ update: '更新已有(无匹配则跳过)',
+ upsert: '匹配则更新,否则新建',
+ },
+ matchFields: '匹配字段',
+ matchFieldsPlaceholder: '选择匹配字段…',
+ matchFieldsHint: '按这些字段将行匹配到已有记录。',
+ needMatchFields: '请至少选择一个匹配字段。',
+ optRunAutomations: '运行自动化与触发器',
+ optCreateOptions: '保留未知选项值',
+ optSkipBlankKey: '跳过匹配值为空的行',
+ optBackground: '后台导入',
+ optBackgroundHint: '(作为可撤销任务运行)',
+ // 结果
+ createdCount: '新建 {{count}} 条',
+ updatedCount: '更新 {{count}} 条',
+ resultsTruncated: '显示前 {{count}} 行结果(共 {{total}} 行)。',
+ downloadFailed: '下载失败行',
+ cancelImport: '取消导入',
+ importCancelled: '导入已取消',
+ // 大文件 / 异步导入
+ largeSampleNotice: '正在预览前 {{shown}}/{{total}} 行。',
+ asyncLargeHint: '文件较大,将在后台导入。',
+ asyncQueued: '已排队 — 正在准备导入…',
+ asyncProcessing: '正在导入第 {{processed}}/{{total}} 行… {{progress}}%',
+ // 导入历史
+ history: '历史',
+ historyBack: '返回导入',
+ historyDescription: '此对象的近期导入记录。',
+ historyHint: '后台导入任务,最新在前。',
+ historyRefresh: '刷新',
+ historyLoading: '加载中…',
+ historyEmpty: '暂无导入记录。',
+ historyUnsupported: '此数据源不支持导入历史。',
+ historyColStatus: '状态',
+ historyColRows: '行数',
+ historyColResult: '结果',
+ historyColTime: '时间',
+ jobStatus: {
+ pending: '等待中',
+ running: '进行中',
+ succeeded: '成功',
+ failed: '失败',
+ cancelled: '已取消',
+ },
+ // 撤销 / 逻辑回滚
+ undoImport: '撤销导入',
+ undoing: '撤销中…',
+ reverted: '已撤销',
+ undoConfirm: '撤销此次导入?它新建的记录将被删除,它更新的记录将恢复为之前的值。',
},
},
calendar: {
diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx
index 71b8d6eb6..abdd98c8b 100644
--- a/packages/plugin-grid/src/ImportWizard.tsx
+++ b/packages/plugin-grid/src/ImportWizard.tsx
@@ -5,16 +5,28 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
- cn, Button, Badge, Progress, Input,
+ cn, Button, Badge, Progress, Input, Checkbox, Label,
Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@object-ui/components';
-import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste } from 'lucide-react';
+import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste, Download, Undo2 } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/react';
+import type {
+ DataSource,
+ ImportRequestOptions,
+ ImportRecordsResult,
+ ImportWriteMode,
+ CreateImportJobResult,
+ ImportJobProgressInfo,
+ ImportJobResultsInfo,
+ ImportJobStatus,
+ ImportJobSummaryInfo,
+} from '@object-ui/types';
import {
parseSpreadsheetFile, parseClipboardTable, inferColumnType, isTypeCompatible,
- ImportParseError, type InferredType,
+ suggestColumnMappings, ImportParseError,
+ type InferredType, type ColumnSuggestion, type MappingConfidence,
} from './importParsers';
/** Default English fallback strings used when no I18nProvider is mounted
@@ -29,6 +41,8 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record = {
'grid.import.previewDescription': 'Review data before importing.',
'grid.import.dragDrop': 'Drag & drop a CSV or Excel file here, or click to browse',
'grid.import.browseFiles': 'Browse Files',
+ 'grid.import.downloadTemplate': 'Download template',
+ 'grid.import.downloadTemplateHint': 'Get a CSV with the right columns (required fields marked *).',
'grid.import.parsing': 'Parsing…',
'grid.import.pasteHint': 'or paste (Ctrl/⌘+V) rows copied from Excel or Google Sheets',
'grid.import.legacyXls': "Legacy .xls files aren't supported — please re-save as .xlsx.",
@@ -46,6 +60,11 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record = {
'grid.import.csvColumn': 'Column',
'grid.import.mapsTo': 'Maps To',
'grid.import.typeMismatch': 'Looks like {{type}}',
+ 'grid.import.autoMatched': 'Auto-matched',
+ 'grid.import.autoMatchedSummary': 'Auto-matched {{count}} column(s) — review and adjust below.',
+ 'grid.import.confidence.high': 'High confidence',
+ 'grid.import.confidence.medium': 'Medium confidence',
+ 'grid.import.confidence.low': 'Low confidence',
'grid.import.type.number': 'Number',
'grid.import.type.boolean': 'Boolean',
'grid.import.type.date': 'Date',
@@ -61,10 +80,67 @@ const IMPORT_DEFAULT_TRANSLATIONS: Record = {
'grid.import.clickToFix': '— click a highlighted cell to fix it inline.',
'grid.import.showingRows': 'Showing {{shown}} of {{total}} rows',
'grid.import.importing': 'Importing… {{progress}}%',
+ // Async (large-file) import — job queued + processed server-side.
+ 'grid.import.asyncQueued': 'Queued — preparing to import…',
+ 'grid.import.asyncProcessing': 'Importing {{processed}} of {{total}} rows… {{progress}}%',
+ 'grid.import.asyncLargeHint': 'This file is large, so it will be imported in the background.',
+ 'grid.import.largeSampleNotice': 'Previewing the first {{shown}} of {{total}} rows.',
+ 'grid.import.cancelImport': 'Cancel import',
+ 'grid.import.importCancelled': 'Import cancelled',
+ 'grid.import.resultsTruncated': 'Showing the first {{count}} row results (of {{total}}).',
'grid.import.importComplete': 'Import Complete',
'grid.import.imported': '{{count}} imported',
+ 'grid.import.createdCount': '{{count}} created',
+ 'grid.import.updatedCount': '{{count}} updated',
'grid.import.skippedCount': '{{count}} skipped',
'grid.import.moreErrors': '…and {{count}} more errors',
+ 'grid.import.downloadFailed': 'Download failed rows',
+ // Write-mode / options (preview step)
+ 'grid.import.options': 'Import options',
+ 'grid.import.writeMode': 'When a row matches an existing record',
+ 'grid.import.writeModeOpt.insert': 'Always create new',
+ 'grid.import.writeModeOpt.update': 'Update existing (skip if no match)',
+ 'grid.import.writeModeOpt.upsert': 'Update if matched, else create',
+ 'grid.import.matchFields': 'Match on',
+ 'grid.import.matchFieldsPlaceholder': 'Choose match field(s)…',
+ 'grid.import.matchFieldsHint': 'Rows are matched to existing records by these field(s).',
+ 'grid.import.needMatchFields': 'Select at least one field to match on.',
+ 'grid.import.optCreateOptions': 'Keep unknown option values',
+ 'grid.import.optRunAutomations': 'Run automations & triggers',
+ 'grid.import.optSkipBlankKey': 'Skip rows with a blank match value',
+ 'grid.import.optBackground': 'Import in the background',
+ 'grid.import.optBackgroundHint': '(runs as an undoable job)',
+ // Server dry-run pre-check (small files, preview step)
+ 'grid.import.validate': 'Validate data',
+ 'grid.import.validating': 'Validating…',
+ 'grid.import.validateHint': 'Check every row against the server before importing.',
+ 'grid.import.validatePassed': 'All {{ok}} rows are valid.',
+ 'grid.import.validateFailed': '{{ok}} valid, {{errors}} with errors.',
+ 'grid.import.errorRowPrefix': 'Row {{row}}: ',
+ // Import-job history
+ 'grid.import.history': 'History',
+ 'grid.import.historyBack': 'Back to import',
+ 'grid.import.historyDescription': 'Recent imports for this object.',
+ 'grid.import.historyHint': 'Background import jobs, newest first.',
+ 'grid.import.historyRefresh': 'Refresh',
+ 'grid.import.historyLoading': 'Loading…',
+ 'grid.import.historyEmpty': 'No imports yet.',
+ 'grid.import.historyUnsupported': 'Import history isn’t available for this data source.',
+ 'grid.import.historyColStatus': 'Status',
+ 'grid.import.historyColRows': 'Rows',
+ 'grid.import.historyColResult': 'Result',
+ 'grid.import.historyColTime': 'When',
+ 'grid.import.errorCount': '{{count}} errors',
+ // Undo / logical rollback
+ 'grid.import.undoImport': 'Undo import',
+ 'grid.import.undoing': 'Undoing…',
+ 'grid.import.undoConfirm': 'Undo this import? Records it created will be deleted and records it updated will be restored to their previous values.',
+ 'grid.import.reverted': 'Undone',
+ 'grid.import.jobStatus.pending': 'Pending',
+ 'grid.import.jobStatus.running': 'Running',
+ 'grid.import.jobStatus.succeeded': 'Succeeded',
+ 'grid.import.jobStatus.failed': 'Failed',
+ 'grid.import.jobStatus.cancelled': 'Cancelled',
'grid.import.cancel': 'Cancel',
'grid.import.back': 'Back',
'grid.import.next': 'Next',
@@ -114,6 +190,14 @@ export const __testables = {
get loadTemplates() { return loadTemplates; },
get saveTemplates() { return saveTemplates; },
get autoMapColumns() { return autoMapColumns; },
+ get isUnsupportedImport() { return isUnsupportedImport; },
+ get isUnsupportedImportJob() { return isUnsupportedImportJob; },
+ get jobResultToImportResult() { return jobResultToImportResult; },
+ get buildFailedRowsCsv() { return buildFailedRowsCsv; },
+ get buildImportTemplateCsv() { return buildImportTemplateCsv; },
+ get assembleImportRequest() { return assembleImportRequest; },
+ get isImportJobActive() { return isImportJobActive; },
+ get isImportJobUndoable() { return isImportJobUndoable; },
};
/** A reusable column-mapping template, persisted across sessions. Keys are
@@ -137,7 +221,15 @@ export interface ImportTemplateStorage {
export interface ImportWizardProps {
objectName: string;
objectLabel?: string;
- fields: Array<{ name: string; label: string; type: string; required?: boolean }>;
+ fields: Array<{
+ name: string;
+ label: string;
+ type: string;
+ required?: boolean;
+ /** Allowed values for select/enum fields — used to seed the downloadable
+ * template's example row. Accepts option objects or bare strings. */
+ options?: Array<{ label?: string; value?: string | number } | string>;
+ }>;
dataSource: any;
onComplete?: (result: ImportResult) => void;
onCancel?: () => void;
@@ -158,6 +250,16 @@ export interface ImportResult {
importedRows: number;
skippedRows: number;
errors: Array<{ row: number; field: string; message: string }>;
+ /** Rows that created a new record (server-side import). */
+ createdRows?: number;
+ /** Rows that updated an existing record (server-side import). */
+ updatedRows?: number;
+ /** The raw per-row server result, when the server `/import` path was used. */
+ serverResult?: ImportRecordsResult;
+ /** True when an async job's per-row `errors` were capped by the server. */
+ resultsTruncated?: boolean;
+ /** True when the user cancelled an in-flight async import job. */
+ cancelled?: boolean;
}
type WizardStep = 'upload' | 'mapping' | 'preview';
@@ -165,24 +267,48 @@ type WizardStep = 'upload' | 'mapping' | 'preview';
/** Maximum number of rows to show in the preview step */
const PREVIEW_ROW_COUNT = 10;
+/**
+ * Row count above which the wizard prefers an asynchronous import job (when the
+ * data source supports it) instead of the synchronous single-call import. Kept
+ * in step with the server's synchronous `/import` ceiling (`maxRows: 5000`), so
+ * files the sync route would reject with 413 are routed to a background job.
+ */
+const ASYNC_IMPORT_THRESHOLD = 5000;
+
+/** How often (ms) to poll an in-flight import job for progress. */
+const IMPORT_JOB_POLL_INTERVAL = 800;
+
+/** Text colour for the auto-match confidence hint, keyed by confidence bucket. */
+const CONFIDENCE_CLASS: Record = {
+ high: 'text-emerald-600',
+ medium: 'text-sky-600',
+ low: 'text-muted-foreground',
+};
+
+/** Boolean tokens the server's import coercion accepts (import-coerce.ts).
+ * Kept in sync so the preview step doesn't flag a cell the server would take
+ * (e.g. Chinese 是/否, on/off, ✓/×). Compared case-insensitively. */
+const BOOLEAN_IMPORT_TOKENS = new Set([
+ 'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√',
+ 'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×',
+]);
+
function validateValue(value: string, type: string): boolean {
if (!value) return true;
switch (type) {
case 'number': case 'currency': case 'percent': return !isNaN(Number(value));
- case 'boolean': return ['true', 'false', '1', '0', 'yes', 'no'].includes(value.toLowerCase());
+ case 'boolean': return BOOLEAN_IMPORT_TOKENS.has(value.trim().toLowerCase());
case 'date': case 'datetime': return !isNaN(Date.parse(value));
default: return true;
}
}
-const normalizeKey = (s: string) => s.toLowerCase().replace(/[_\s-]/g, '');
-
/**
- * Auto-map source columns to object fields. Pass 1 matches by normalized
- * name/label (exact). Pass 2 fills still-unmapped columns by fuzzy name
- * containment *gated on type compatibility* with the column's inferred type —
- * the type gate keeps the fuzzy pass from confidently mis-mapping. `rows` is
- * optional; without it only the exact pass runs.
+ * Auto-map source columns to object fields, Airtable-style. Delegates to
+ * {@link suggestColumnMappings} (name/label similarity + bilingual synonyms +
+ * token overlap + content-inferred type gating, assigned globally by
+ * confidence) and keeps only the confidently-matched columns. `rows` is
+ * optional; without it only name-based signals fire.
*/
function autoMapColumns(
headers: string[],
@@ -190,29 +316,8 @@ function autoMapColumns(
rows?: string[][],
): Record {
const mapping: Record = {};
- const used = new Set();
- // Pass 1 — exact normalized name/label match.
- headers.forEach((header, idx) => {
- const h = normalizeKey(header);
- const match = fields.find((f) => normalizeKey(f.name) === h || normalizeKey(f.label) === h);
- if (match && !used.has(match.name)) { mapping[idx] = match.name; used.add(match.name); }
- });
- // Pass 2 — fuzzy containment, gated on inferred-type compatibility.
- if (rows && rows.length) {
- headers.forEach((header, idx) => {
- if (mapping[idx]) return;
- const h = normalizeKey(header);
- if (h.length < 3) return;
- const inferred = inferColumnType(rows.map((r) => r[idx]));
- const match = fields.find((f) => {
- if (used.has(f.name)) return false;
- if (!isTypeCompatible(inferred, f.type)) return false;
- const name = normalizeKey(f.name);
- const label = normalizeKey(f.label);
- return name.includes(h) || h.includes(name) || label.includes(h) || h.includes(label);
- });
- if (match) { mapping[idx] = match.name; used.add(match.name); }
- });
+ for (const s of suggestColumnMappings(headers, fields, rows)) {
+ if (s.fieldName) mapping[s.columnIndex] = s.fieldName;
}
return mapping;
}
@@ -285,6 +390,169 @@ function validateRow(row: string[], mappedCols: MappedCol[], rowIndex: number) {
return { record, errors };
}
+/** Assemble the server `/import` request from mapping-applied raw rows plus the
+ * current write-mode + coercion options. Kept pure (no component state) so the
+ * real import and the dry-run pre-check send byte-identical payloads and it can
+ * be unit-tested. `matchFields` is only sent when the write-mode consults it. */
+function assembleImportRequest(
+ rows: Record[],
+ opts: {
+ writeMode: ImportWriteMode;
+ matchFields: string[];
+ createMissingOptions: boolean;
+ runAutomations: boolean;
+ skipBlankMatchKey: boolean;
+ dryRun?: boolean;
+ },
+): ImportRequestOptions {
+ return {
+ format: 'json',
+ rows,
+ writeMode: opts.writeMode,
+ ...(opts.writeMode !== 'insert' ? { matchFields: opts.matchFields } : {}),
+ createMissingOptions: opts.createMissingOptions,
+ runAutomations: opts.runAutomations,
+ skipBlankMatchKey: opts.skipBlankMatchKey,
+ ...(opts.dryRun ? { dryRun: true } : {}),
+ };
+}
+
+/** True when the adapter/client can't speak the server `/import` route, so the
+ * wizard should transparently fall back to a per-row `create` loop. */
+function isUnsupportedImport(err: unknown): boolean {
+ const code = (err as { code?: unknown })?.code;
+ if (code === 'UNSUPPORTED_OPERATION') return true;
+ const msg = err instanceof Error ? err.message : '';
+ return /does not support data\.import|importRecords is not a function|\.import is not a function/i.test(msg);
+}
+
+/** True when the data source lacks the async import-job API (older
+ * adapter/client/server), so the wizard should fall back to the sync path. */
+function isUnsupportedImportJob(err: unknown): boolean {
+ const code = (err as { code?: unknown })?.code;
+ if (code === 'UNSUPPORTED_OPERATION') return true;
+ const msg = err instanceof Error ? err.message : '';
+ return /does not support async import|createImportJob is not a function|import\/jobs|404/i.test(msg);
+}
+
+/** Map an async import-job's final results payload onto the wizard's
+ * {@link ImportResult} shape — identical to the synchronous mapping so the
+ * completion screen renders the same regardless of which path ran. */
+function jobResultToImportResult(res: ImportJobResultsInfo): ImportResult {
+ return {
+ totalRows: res.total,
+ importedRows: res.created + res.updated,
+ skippedRows: res.skipped + res.errors,
+ createdRows: res.created,
+ updatedRows: res.updated,
+ errors: (res.results ?? [])
+ .filter((r) => !r.ok)
+ .map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed' })),
+ resultsTruncated: res.resultsTruncated,
+ };
+}
+
+/** True while an import job is still in flight — it can be cancelled and the
+ * history list should keep polling it. Terminal states are the rest. */
+function isImportJobActive(status: ImportJobStatus): boolean {
+ return status === 'pending' || status === 'running';
+}
+
+/** Whether to show the "Undo import" button for a history row: the adapter must
+ * support undo, the job must be terminal, still undoable, and not already
+ * reverted. Mirrors the server's `importJobUndoable`. */
+function isImportJobUndoable(job: Pick, canUndo: boolean): boolean {
+ return canUndo && !!job.undoable && !job.revertedAt && !isImportJobActive(job.status);
+}
+
+/** Build a CSV blob of failed rows for re-export: the original mapped columns
+ * plus an `_error` column, so a user can fix and re-import just the failures. */
+function buildFailedRowsCsv(
+ headers: string[],
+ rows: string[][],
+ mapping: Record,
+ errorsByRow: Map,
+): string {
+ const cols = Object.keys(mapping).map(Number).sort((a, b) => a - b);
+ const esc = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
+ const head = [...cols.map((c) => headers[c] ?? `col${c}`), '_error'];
+ const lines = [head.map(esc).join(',')];
+ // errorsByRow is keyed by 1-based row number.
+ for (const [rowNum, message] of errorsByRow) {
+ const src = rows[rowNum - 1];
+ if (!src) continue;
+ lines.push([...cols.map((c) => esc(src[c] ?? '')), esc(message)].join(','));
+ }
+ return lines.join('\n');
+}
+
+/** Pick a representative allowed value from a select field's options, for the
+ * template example row. Prefers the stored value over the display label. */
+function firstOptionValue(
+ options: ImportWizardProps['fields'][number]['options'],
+): string | undefined {
+ const first = options?.[0];
+ if (first === undefined || first === null) return undefined;
+ if (typeof first === 'string') return first;
+ if (first.value !== undefined && first.value !== null) return String(first.value);
+ if (first.label) return first.label;
+ return undefined;
+}
+
+/** A type-appropriate example cell for the downloadable import template. Kept
+ * format-oriented (dates, emails) rather than prose so it reads the same in
+ * any locale; text-ish fields are left blank so the row is obviously a sample. */
+function exampleForField(field: ImportWizardProps['fields'][number]): string {
+ switch (field.type) {
+ case 'number':
+ case 'currency':
+ case 'percent':
+ return '0';
+ case 'date':
+ return '2024-01-31';
+ case 'datetime':
+ return '2024-01-31 09:00';
+ case 'time':
+ return '09:00';
+ case 'boolean':
+ return 'true';
+ case 'email':
+ return 'name@example.com';
+ case 'url':
+ return 'https://example.com';
+ case 'select':
+ case 'multiselect':
+ case 'lookup':
+ case 'reference':
+ return firstOptionValue(field.options) ?? '';
+ default:
+ return '';
+ }
+}
+
+/** Build a downloadable CSV import template for the given fields: a header row
+ * of field labels (required fields marked with `*`, which re-import tolerates)
+ * plus a single example row. Not persisted — a convenience starting point. */
+function buildImportTemplateCsv(fields: ImportWizardProps['fields']): string {
+ const esc = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
+ const header = fields.map((f) => `${f.label}${f.required ? ' *' : ''}`);
+ const example = fields.map((f) => exampleForField(f));
+ return [header.map(esc).join(','), example.map(esc).join(',')].join('\n');
+}
+
+/** Trigger a client-side text file download (prepends a UTF-8 BOM so Excel
+ * reads non-ASCII correctly). No-op in non-DOM environments. */
+function downloadTextFile(filename: string, text: string, mime = 'text/csv;charset=utf-8'): void {
+ if (typeof document === 'undefined' || typeof URL?.createObjectURL !== 'function') return;
+ const blob = new Blob([`${text}`], { type: mime });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
/** Map a thrown import-parse error code to a translated, user-facing message. */
function parseErrorMessage(err: unknown, t: (k: string, v?: Record) => string): string {
const code = err instanceof Error ? err.message : '';
@@ -294,7 +562,11 @@ function parseErrorMessage(err: unknown, t: (k: string, v?: Record void }> = ({ onFileLoaded }) => {
+const StepUpload: React.FC<{
+ onFileLoaded: (headers: string[], rows: string[][]) => void;
+ fields: ImportWizardProps['fields'];
+ objectName: string;
+}> = ({ onFileLoaded, fields, objectName }) => {
const { t } = useImportTranslation();
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState(null);
@@ -360,6 +632,20 @@ const StepUpload: React.FC<{ onFileLoaded: (headers: string[], rows: string[][])
{t('grid.import.pasteHint')}
+ {fields.length > 0 && (
+
+
+
{t('grid.import.downloadTemplateHint')}
+
+ )}
{error && (
{error}
@@ -462,14 +748,26 @@ const StepMapping: React.FC<{
mapping: Record;
onMappingChange: (mapping: Record) => void;
inferredTypes: InferredType[];
+ suggestions: ColumnSuggestion[];
templates: ImportMappingTemplate[];
selectedTemplateId: string | null;
onSelectTemplate: (id: string) => void;
onSaveTemplate: (name: string) => void;
onDeleteTemplate: () => void;
-}> = ({ headers, fields, mapping, onMappingChange, inferredTypes, templates, selectedTemplateId, onSelectTemplate, onSaveTemplate, onDeleteTemplate }) => {
+}> = ({ headers, fields, mapping, onMappingChange, inferredTypes, suggestions, templates, selectedTemplateId, onSelectTemplate, onSaveTemplate, onDeleteTemplate }) => {
const { t } = useImportTranslation();
const usedFields = useMemo(() => new Set(Object.values(mapping)), [mapping]);
+ const suggestionByCol = useMemo(() => {
+ const m = new Map();
+ suggestions.forEach((s) => m.set(s.columnIndex, s));
+ return m;
+ }, [suggestions]);
+ // How many columns were auto-matched (vs. the current, possibly-edited mapping).
+ const autoMatchedCount = useMemo(() => {
+ let n = 0;
+ suggestionByCol.forEach((s, idx) => { if (s.fieldName && mapping[idx] === s.fieldName) n++; });
+ return n;
+ }, [suggestionByCol, mapping]);
const handleChange = useCallback((colIdx: number, fieldName: string) => {
const next = { ...mapping };
if (fieldName === '__skip__') delete next[colIdx]; else next[colIdx] = fieldName;
@@ -486,6 +784,12 @@ const StepMapping: React.FC<{
onDelete={onDeleteTemplate}
disabled={Object.keys(mapping).length === 0}
/>
+ {autoMatchedCount > 0 && (
+
+
+ {t('grid.import.autoMatchedSummary', { count: String(autoMatchedCount) })}
+
+ )}
@@ -500,6 +804,10 @@ const StepMapping: React.FC<{
const inferred = inferredTypes[idx] ?? 'text';
const mappedField = mapping[idx] ? fields.find((f) => f.name === mapping[idx]) : undefined;
const typeMismatch = !!mappedField && !isTypeCompatible(inferred, mappedField.type);
+ const suggestion = suggestionByCol.get(idx);
+ // Badge only while the user's choice still matches what we auto-suggested.
+ const autoMatched = !!suggestion?.fieldName && mapping[idx] === suggestion.fieldName
+ ? suggestion.confidence : null;
return (
@@ -524,6 +832,11 @@ const StepMapping: React.FC<{
))}
+ {autoMatched && (
+
+ {t('grid.import.autoMatched')} · {t(`grid.import.confidence.${autoMatched}`)}
+
+ )}
{typeMismatch && (
{t('grid.import.typeMismatch', { type: t(`grid.import.type.${inferred}`) })}
@@ -652,6 +965,273 @@ const StepPreview: React.FC<{
);
};
+/** Options controlling how the server commits each row (insert/update/upsert
+ * + toggles). Rendered above the preview so the choices are visible before the
+ * import runs. */
+const ImportOptions: React.FC<{
+ fields: ImportWizardProps['fields'];
+ mapping: Record;
+ writeMode: ImportWriteMode;
+ onWriteMode: (m: ImportWriteMode) => void;
+ matchFields: string[];
+ onToggleMatchField: (name: string) => void;
+ createMissingOptions: boolean;
+ onCreateMissingOptions: (v: boolean) => void;
+ runAutomations: boolean;
+ onRunAutomations: (v: boolean) => void;
+ skipBlankMatchKey: boolean;
+ onSkipBlankMatchKey: (v: boolean) => void;
+ showBackground: boolean;
+ backgroundImport: boolean;
+ onBackgroundImport: (v: boolean) => void;
+}> = ({
+ fields, mapping, writeMode, onWriteMode, matchFields, onToggleMatchField,
+ createMissingOptions, onCreateMissingOptions, runAutomations, onRunAutomations,
+ skipBlankMatchKey, onSkipBlankMatchKey,
+ showBackground, backgroundImport, onBackgroundImport,
+}) => {
+ const { t } = useImportTranslation();
+ // Only fields that are actually mapped can serve as match keys.
+ const mappedFieldNames = useMemo(() => new Set(Object.values(mapping)), [mapping]);
+ const matchable = useMemo(() => fields.filter((f) => mappedFieldNames.has(f.name)), [fields, mappedFieldNames]);
+ const needsMatch = writeMode !== 'insert';
+
+ return (
+
+
{t('grid.import.options')}
+
+
+
+
+
+
+ {needsMatch && (
+
+
+
+ {matchable.length === 0 && (
+ {t('grid.import.matchFieldsPlaceholder')}
+ )}
+ {matchable.map((f) => (
+
+ ))}
+
+
+ {matchFields.length === 0 ? t('grid.import.needMatchFields') : t('grid.import.matchFieldsHint')}
+
+
+ )}
+
+
+
+ {needsMatch && (
+
+ )}
+
+ {showBackground && (
+
+ )}
+
+
+
+ );
+};
+
+/** Colour intent for each import-job status badge. */
+const IMPORT_JOB_STATUS_VARIANT: Record = {
+ pending: 'outline',
+ running: 'secondary',
+ succeeded: 'default',
+ failed: 'destructive',
+ cancelled: 'outline',
+};
+
+/** Format an ISO timestamp compactly for the history table; falls back to the
+ * raw string (or a dash) when it isn't a parseable date. */
+function formatImportJobTime(iso?: string): string {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleString();
+}
+
+/**
+ * Import-job history for one object: lists prior async jobs (status, counts,
+ * time), lets the user cancel an in-flight job, and refresh. Degrades to an
+ * empty state when the data source lacks `listImportJobs` (older adapter).
+ */
+const ImportHistoryPanel: React.FC<{
+ objectName: string;
+ dataSource: unknown;
+ t: (key: string, vars?: Record) => string;
+}> = ({ objectName, dataSource, t }) => {
+ const [jobs, setJobs] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ // Job id currently being undone (disables its row's Undo button + confirm).
+ const [undoingId, setUndoingId] = useState(null);
+
+ const ds = dataSource as Partial | undefined;
+ const supported = typeof ds?.listImportJobs === 'function';
+ const canUndo = typeof ds?.undoImportJob === 'function';
+
+ const load = useCallback(async () => {
+ if (typeof ds?.listImportJobs !== 'function') return;
+ setLoading(true); setError(null);
+ try {
+ const list = await ds.listImportJobs({ object: objectName, limit: 50 });
+ setJobs(list);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ setJobs([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [ds, objectName]);
+
+ useEffect(() => { void load(); }, [load]);
+
+ const handleCancel = useCallback(async (jobId: string) => {
+ if (typeof ds?.cancelImportJob !== 'function') return;
+ try { await ds.cancelImportJob(jobId); } catch { /* best-effort */ }
+ void load();
+ }, [ds, load]);
+
+ // Logical rollback: delete created records + restore updated ones. Confirms
+ // first (destructive + irreversible), then reloads so the row flips to
+ // "reverted" and its Undo button disappears.
+ const handleUndo = useCallback(async (jobId: string) => {
+ if (typeof ds?.undoImportJob !== 'function') return;
+ // eslint-disable-next-line no-alert
+ if (typeof window !== 'undefined' && !window.confirm(t('grid.import.undoConfirm'))) return;
+ setUndoingId(jobId); setError(null);
+ try {
+ await ds.undoImportJob(jobId);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setUndoingId(null);
+ void load();
+ }
+ }, [ds, load, t]);
+
+ if (!supported) {
+ return (
+
+ {t('grid.import.historyUnsupported')}
+
+ );
+ }
+
+ return (
+
+
+
{t('grid.import.historyHint')}
+
+
+ {error &&
{error}
}
+ {jobs && jobs.length === 0 && !loading && (
+
+ {t('grid.import.historyEmpty')}
+
+ )}
+ {jobs && jobs.length > 0 && (
+
+
+
+ {t('grid.import.historyColStatus')}
+ {t('grid.import.historyColRows')}
+ {t('grid.import.historyColResult')}
+ {t('grid.import.historyColTime')}
+
+
+
+
+ {jobs.map((job) => (
+
+
+
+ {t(`grid.import.jobStatus.${job.status}`)}
+
+
+ {job.processed}/{job.total}
+
+ {t('grid.import.createdCount', { count: job.created })}
+ {job.updated > 0 && · {t('grid.import.updatedCount', { count: job.updated })}}
+ {job.skipped > 0 && · {t('grid.import.skippedCount', { count: job.skipped })}}
+ {job.errors > 0 && · {t('grid.import.errorCount', { count: job.errors })}}
+
+ {formatImportJobTime(job.completedAt ?? job.createdAt)}
+
+ {isImportJobActive(job.status) && typeof ds?.cancelImportJob === 'function' && (
+
+ )}
+ {job.revertedAt && (
+
+ {t('grid.import.reverted')}
+
+ )}
+ {isImportJobUndoable(job, canUndo) && (
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
// Main wizard component
export const ImportWizard: React.FC = ({
objectName, objectLabel, fields, dataSource, onComplete, onCancel, open, onOpenChange, onErrorMode = 'skip',
@@ -665,8 +1245,53 @@ export const ImportWizard: React.FC = ({
const [progress, setProgress] = useState(0);
const [result, setResult] = useState(null);
const [corrections, setCorrections] = useState>>({});
+ // Import-job history view (swaps the wizard body for a list of prior jobs).
+ const [showHistory, setShowHistory] = useState(false);
+ // Async (large-file) import job — jobId + live processed/total, plus a ref the
+ // poll loop reads so a mid-flight Cancel stops polling without a re-render race.
+ const [jobId, setJobId] = useState(null);
+ const [asyncCounts, setAsyncCounts] = useState<{ processed: number; total: number } | null>(null);
+ const cancelPollRef = React.useRef(false);
+ // Small-file server dry-run pre-check — validates the exact payload without
+ // writing, so the summary/error list reflect real coercion outcomes.
+ const [validating, setValidating] = useState(false);
+ const [dryRunResult, setDryRunResult] = useState(null);
+ // Write-mode + coercion options (drive the server-side /import request).
+ const [writeMode, setWriteMode] = useState('insert');
+ const [matchFields, setMatchFields] = useState([]);
+ const [createMissingOptions, setCreateMissingOptions] = useState(false);
+ const [runAutomations, setRunAutomations] = useState(false);
+ const [skipBlankMatchKey, setSkipBlankMatchKey] = useState(false);
+ // Opt-in: route this import through a background job even when the row count
+ // is under the async threshold. This is the only way to obtain an undoable
+ // job for a small import — the sync path never captures undo state.
+ const [backgroundImport, setBackgroundImport] = useState(false);
const label = objectLabel ?? objectName;
+ // The background-import toggle only makes sense when the data source can
+ // actually run jobs (create + poll + fetch results). Mirrors the guard in
+ // runAsyncImport so the checkbox never promises an unsupported path.
+ const supportsImportJob = useMemo(() => {
+ const ds = dataSource as Partial | undefined;
+ return typeof ds?.createImportJob === 'function'
+ && typeof ds?.getImportJobProgress === 'function'
+ && typeof ds?.getImportJobResults === 'function';
+ }, [dataSource]);
+
+ const toggleMatchField = useCallback((name: string) => {
+ setMatchFields((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]));
+ }, []);
+
+ // Keep matchFields consistent with the current column mapping — drop any
+ // match key whose column was unmapped so we never send a stale key.
+ useEffect(() => {
+ const mapped = new Set(Object.values(mapping));
+ setMatchFields((prev) => {
+ const next = prev.filter((n) => mapped.has(n));
+ return next.length === prev.length ? prev : next;
+ });
+ }, [mapping]);
+
// Template storage — resolved once; `null` opts out of persistence.
const storage = useMemo(
() => (templateStorage === undefined ? defaultTemplateStorage() : templateStorage),
@@ -738,8 +1363,34 @@ export const ImportWizard: React.FC = ({
[headers, rows],
);
- const handleImport = useCallback(async () => {
- setImporting(true); setProgress(0);
+ // Airtable-style auto-mapping suggestions (with confidence), computed once per
+ // file. Drives the "auto-matched" badges: a badge shows only while the user's
+ // current mapping for a column still equals what we suggested.
+ const suggestions = useMemo(
+ () => suggestColumnMappings(headers, fields, rows),
+ [headers, fields, rows],
+ );
+
+ // Build raw, mapping-applied rows keyed by target field name (inline
+ // corrections applied). Values stay RAW strings — the server coerces them to
+ // storage values from field metadata, so booleans / dates / lookups / selects
+ // are all handled uniformly server-side rather than guessed on the client.
+ const buildRawRows = useCallback((): Array> => {
+ const mappedCols = Object.entries(mapping).map(([idx, name]) => ({ csvIdx: Number(idx), field: name }));
+ return rows.map((original, i) => {
+ const fixes = corrections[i];
+ const out: Record = {};
+ for (const col of mappedCols) {
+ const v = fixes && fixes[col.csvIdx] !== undefined ? fixes[col.csvIdx] : (original[col.csvIdx] ?? '');
+ out[col.field] = v;
+ }
+ return out;
+ });
+ }, [rows, mapping, corrections]);
+
+ // Legacy fallback — per-row `create` with light client-side validation. Used
+ // only when the adapter/client can't reach the server `/import` route.
+ const legacyImport = useCallback(async () => {
const errors: ImportResult['errors'] = [];
let importedRows = 0, skippedRows = 0;
const mappedCols = Object.entries(mapping).map(([idx, name]) => ({
@@ -747,8 +1398,6 @@ export const ImportWizard: React.FC = ({
}));
for (let i = 0; i < rows.length; i++) {
- // Apply inline corrections (only available for the visible preview rows)
- // before validation so users can fix issues without re-uploading the file.
const original = rows[i];
const fixes = corrections[i];
const effectiveRow = fixes
@@ -775,11 +1424,256 @@ export const ImportWizard: React.FC = ({
setResult(importResult); setImporting(false); onComplete?.(importResult);
}, [rows, mapping, fields, dataSource, objectName, onComplete, onErrorMode, corrections]);
+ // Large-file path: hand the rows to a server-side background job and poll it
+ // to completion. Returns `true` when the async path handled the import
+ // (success / failure / cancel) and `false` when the data source can't run
+ // jobs, signalling the caller to fall back to the synchronous route.
+ const runAsyncImport = useCallback(async (request: ImportRequestOptions): Promise => {
+ const ds = dataSource as Partial | undefined;
+ if (
+ typeof ds?.createImportJob !== 'function' ||
+ typeof ds?.getImportJobProgress !== 'function' ||
+ typeof ds?.getImportJobResults !== 'function'
+ ) {
+ return false;
+ }
+
+ cancelPollRef.current = false;
+ let created: CreateImportJobResult;
+ try {
+ created = await ds.createImportJob(objectName, request);
+ } catch (err) {
+ if (isUnsupportedImportJob(err)) return false;
+ throw err;
+ }
+ setJobId(created.jobId);
+ setAsyncCounts({ processed: 0, total: created.total });
+
+ const terminal = new Set(['succeeded', 'failed', 'cancelled']);
+ let consecutivePollErrors = 0;
+ // Poll until the job reaches a terminal state (or the user cancels, in
+ // which case the cancel handler owns producing the result).
+ for (;;) {
+ if (cancelPollRef.current) return true;
+ await new Promise((resolve) => setTimeout(resolve, IMPORT_JOB_POLL_INTERVAL));
+ if (cancelPollRef.current) return true;
+
+ let prog: ImportJobProgressInfo;
+ try {
+ prog = await ds.getImportJobProgress(created.jobId);
+ consecutivePollErrors = 0;
+ } catch (err) {
+ // Tolerate transient poll blips; give up only after several in a row so
+ // a network hiccup doesn't abort an import that's still running server-side.
+ if (++consecutivePollErrors >= 5) throw err;
+ continue;
+ }
+
+ setAsyncCounts({ processed: prog.processed, total: prog.total });
+ setProgress(prog.percentComplete);
+
+ if (!terminal.has(prog.status)) continue;
+
+ if (prog.status === 'cancelled') {
+ const importResult: ImportResult = {
+ totalRows: prog.total,
+ importedRows: prog.created + prog.updated,
+ skippedRows: prog.skipped + prog.errors,
+ createdRows: prog.created,
+ updatedRows: prog.updated,
+ errors: [],
+ cancelled: true,
+ };
+ setResult(importResult); setImporting(false); onComplete?.(importResult);
+ return true;
+ }
+
+ const results = await ds.getImportJobResults(created.jobId);
+ const importResult = jobResultToImportResult(results);
+ if (prog.status === 'failed' && importResult.errors.length === 0) {
+ importResult.errors.push({ row: 0, field: '', message: prog.error ?? 'Import failed' });
+ }
+ setProgress(100);
+ setResult(importResult); setImporting(false); onComplete?.(importResult);
+ return true;
+ }
+ }, [dataSource, objectName, onComplete]);
+
+ // Assemble the server import request from the current mapping + options.
+ // `dryRun` reuses the exact same payload the real import will send, so the
+ // pre-check validates precisely what would be written.
+ const buildImportRequest = useCallback((dryRun = false): ImportRequestOptions =>
+ assembleImportRequest(buildRawRows(), {
+ writeMode, matchFields, createMissingOptions, runAutomations, skipBlankMatchKey, dryRun,
+ }),
+ [buildRawRows, writeMode, matchFields, createMissingOptions, runAutomations, skipBlankMatchKey]);
+
+ const handleImport = useCallback(async () => {
+ setImporting(true); setProgress(0);
+ cancelPollRef.current = false;
+ setJobId(null); setAsyncCounts(null);
+
+ const request = buildImportRequest();
+
+ // Route large files through a background job so they neither block the UI
+ // nor trip the sync route's row ceiling. Small files can also opt into the
+ // background path (the "background import" toggle) — that's the only way to
+ // get an undoable job for a sub-threshold import. Any unsupported signal
+ // (older adapter / client / server) falls through to the synchronous path.
+ if (rows.length > ASYNC_IMPORT_THRESHOLD || backgroundImport) {
+ try {
+ const handled = await runAsyncImport(request);
+ if (handled) return;
+ } catch (err) {
+ if (!isUnsupportedImportJob(err)) {
+ const msg = err instanceof Error ? err.message : 'Import failed';
+ const importResult: ImportResult = {
+ totalRows: rows.length, importedRows: 0, skippedRows: rows.length,
+ errors: [{ row: 0, field: '', message: msg }],
+ };
+ setResult(importResult); setImporting(false); onComplete?.(importResult);
+ return;
+ }
+ // Unsupported — fall through to the synchronous path below.
+ }
+ }
+
+ // Prefer the single-call server import: it coerces special values and
+ // routes each row to insert / update / upsert. Fall back to the per-row
+ // create loop only when the adapter can't speak `/import`.
+ const serverImport = (dataSource as {
+ importRecords?: (o: string, r: ImportRequestOptions) => Promise;
+ } | undefined)?.importRecords;
+
+ if (typeof serverImport === 'function') {
+ try {
+ const res = await serverImport.call(dataSource, objectName, request);
+ const importResult: ImportResult = {
+ totalRows: res.total,
+ importedRows: res.ok,
+ skippedRows: res.skipped + res.errors,
+ createdRows: res.created,
+ updatedRows: res.updated,
+ errors: res.results
+ .filter((r) => !r.ok)
+ .map((r) => ({ row: r.row, field: r.field ?? '', message: r.error ?? r.code ?? 'Import failed' })),
+ serverResult: res,
+ };
+ setProgress(100);
+ setResult(importResult); setImporting(false); onComplete?.(importResult);
+ return;
+ } catch (err) {
+ if (!isUnsupportedImport(err)) {
+ // A real server failure — surface it rather than silently retrying
+ // via the legacy loop (which could double-import partial successes).
+ const msg = err instanceof Error ? err.message : 'Import failed';
+ const importResult: ImportResult = {
+ totalRows: rows.length, importedRows: 0, skippedRows: rows.length,
+ errors: [{ row: 0, field: '', message: msg }],
+ };
+ setResult(importResult); setImporting(false); onComplete?.(importResult);
+ return;
+ }
+ // Unsupported — fall through to the legacy path below.
+ }
+ }
+
+ await legacyImport();
+ }, [
+ dataSource, objectName, buildImportRequest, onComplete, rows.length, legacyImport, runAsyncImport,
+ backgroundImport,
+ ]);
+
+ // Small-file server dry-run pre-check: validate + coerce every row without
+ // persisting, so mapping / type / required errors are caught before import.
+ // Large files skip this (they're validated row-by-row during the async job).
+ const handleValidate = useCallback(async () => {
+ const serverImport = (dataSource as {
+ importRecords?: (o: string, r: ImportRequestOptions) => Promise;
+ } | undefined)?.importRecords;
+ if (typeof serverImport !== 'function') return;
+ setValidating(true);
+ try {
+ const res = await serverImport.call(dataSource, objectName, buildImportRequest(true));
+ setDryRunResult(res);
+ } catch (err) {
+ // Older adapter/client without /import — silently fall back to the
+ // client-side cell validation that StepPreview already shows.
+ if (!isUnsupportedImport(err)) {
+ const msg = err instanceof Error ? err.message : 'Validation failed';
+ setDryRunResult({
+ object: objectName, dryRun: true, writeMode, total: rows.length,
+ ok: 0, errors: rows.length, created: 0, updated: 0, skipped: 0,
+ results: [{ row: 0, ok: false, error: msg }],
+ });
+ }
+ } finally {
+ setValidating(false);
+ }
+ }, [dataSource, objectName, buildImportRequest, writeMode, rows.length]);
+
+ // A prior dry-run becomes stale the moment the payload changes (mapping,
+ // write-mode, options, or an inline cell correction) — drop it so the summary
+ // never reflects data the user has since edited.
+ useEffect(() => {
+ setDryRunResult(null);
+ }, [mapping, corrections, writeMode, matchFields, createMissingOptions, runAutomations, skipBlankMatchKey]);
+
+ // User-initiated cancel of an in-flight async job. Stops the poll loop, asks
+ // the server to cancel (best-effort), and shows a cancelled result.
+ const handleCancelImport = useCallback(async () => {
+ cancelPollRef.current = true;
+ const id = jobId;
+ const ds = dataSource as Partial | undefined;
+ if (id && typeof ds?.cancelImportJob === 'function') {
+ try { await ds.cancelImportJob(id); } catch { /* best-effort — the poll loop already stopped */ }
+ }
+ const importResult: ImportResult = {
+ totalRows: asyncCounts?.total ?? rows.length,
+ importedRows: 0,
+ skippedRows: 0,
+ errors: [],
+ cancelled: true,
+ };
+ setResult(importResult); setImporting(false);
+ }, [jobId, dataSource, asyncCounts, rows.length]);
+
const reset = useCallback(() => {
+ cancelPollRef.current = false;
setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null);
setCorrections({}); setSelectedTemplateId(null);
+ setWriteMode('insert'); setMatchFields([]);
+ setCreateMissingOptions(false); setRunAutomations(false); setSkipBlankMatchKey(false);
+ setJobId(null); setAsyncCounts(null);
+ setValidating(false); setDryRunResult(null);
+ setShowHistory(false);
}, []);
+ /** Download a CSV of just the failed rows (original values + `_error`). */
+ const handleDownloadFailed = useCallback(() => {
+ if (!result || result.errors.length === 0) return;
+ const errorsByRow = new Map();
+ for (const e of result.errors) {
+ if (e.row < 1) continue; // top-level (row 0) errors have no source row
+ const prefix = e.field ? `${e.field}: ` : '';
+ const existing = errorsByRow.get(e.row);
+ errorsByRow.set(e.row, existing ? `${existing}; ${prefix}${e.message}` : `${prefix}${e.message}`);
+ }
+ if (errorsByRow.size === 0) return;
+ const csv = buildFailedRowsCsv(headers, rows, mapping, errorsByRow);
+ try {
+ const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${objectName}-import-errors.csv`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+ } catch { /* non-browser env */ }
+ }, [result, headers, rows, mapping, objectName]);
+
const handleClose = useCallback(() => { reset(); onOpenChange?.(false); onCancel?.(); }, [reset, onOpenChange, onCancel]);
const { t } = useImportTranslation();
@@ -795,32 +1689,51 @@ export const ImportWizard: React.FC = ({
onInteractOutside={(e) => e.preventDefault()}
>
-
- {t('grid.import.title', { object: label })}
-
+
+
+ {t('grid.import.title', { object: label })}
+
+ {step === 'upload' && !result && !importing
+ && typeof (dataSource as Partial | undefined)?.listImportJobs === 'function' && (
+
+ )}
+
- {step === 'upload' && t('grid.import.uploadDescription')}
- {step === 'mapping' && t('grid.import.mappingDescription')}
- {step === 'preview' && t('grid.import.previewDescription')}
+ {showHistory
+ ? t('grid.import.historyDescription')
+ : step === 'upload' ? t('grid.import.uploadDescription')
+ : step === 'mapping' ? t('grid.import.mappingDescription')
+ : t('grid.import.previewDescription')}
{/* Step indicators */}
-
- {(['upload', 'mapping', 'preview'] as WizardStep[]).map((s, i) => (
-
- {i > 0 && }
-
- {i + 1}. {s === 'upload' ? t('grid.import.stepUpload') : s === 'mapping' ? t('grid.import.stepMapping') : t('grid.import.stepPreview')}
-
-
- ))}
-
+ {!showHistory && (
+
+ {(['upload', 'mapping', 'preview'] as WizardStep[]).map((s, i) => (
+
+ {i > 0 && }
+
+ {i + 1}. {s === 'upload' ? t('grid.import.stepUpload') : s === 'mapping' ? t('grid.import.stepMapping') : t('grid.import.stepPreview')}
+
+
+ ))}
+
+ )}
- {!result ? (
+ {showHistory ? (
+
+ ) : !result ? (
<>
- {step === 'upload' &&
}
+ {step === 'upload' &&
}
{step === 'mapping' && (
= ({
mapping={mapping}
onMappingChange={setMapping}
inferredTypes={inferredTypes}
+ suggestions={suggestions}
templates={templates}
selectedTemplateId={selectedTemplateId}
onSelectTemplate={handleSelectTemplate}
@@ -836,45 +1750,157 @@ export const ImportWizard: React.FC = ({
/>
)}
{step === 'preview' && (
-
+ <>
+
+
+ {rows.length > ASYNC_IMPORT_THRESHOLD && (
+
+
+
{t('grid.import.largeSampleNotice', { shown: PREVIEW_ROW_COUNT, total: rows.length })} {t('grid.import.asyncLargeHint')}
+
+ )}
+ {rows.length <= ASYNC_IMPORT_THRESHOLD
+ && typeof (dataSource as Partial | undefined)?.importRecords === 'function' && (
+
+
+
{t('grid.import.validateHint')}
+
+
+ {dryRunResult && (
+
+
0 ? 'text-destructive' : 'text-emerald-600'}`}>
+ {dryRunResult.errors > 0
+ ? t('grid.import.validateFailed', { ok: dryRunResult.ok, errors: dryRunResult.errors })
+ : t('grid.import.validatePassed', { ok: dryRunResult.ok })}
+
+ {dryRunResult.errors > 0 && (
+
+ {dryRunResult.results.filter((r) => !r.ok).slice(0, 20).map((r, i) => (
+ -
+ {r.row > 0 ? t('grid.import.errorRowPrefix', { row: r.row }) : ''}
+ {r.field ? `${r.field}: ` : ''}{r.error ?? r.code ?? ''}
+
+ ))}
+
+ )}
+
+ )}
+
+ )}
+ >
)}
{importing && (
-
-
-
{t('grid.import.importing', { progress })}
+
+
+
+ {jobId
+ ? asyncCounts
+ ? t('grid.import.asyncProcessing', { processed: asyncCounts.processed, total: asyncCounts.total, progress })
+ : t('grid.import.asyncQueued')
+ : t('grid.import.importing', { progress })}
+
+ {jobId && typeof (dataSource as Partial
| undefined)?.cancelImportJob === 'function' && (
+
+ )}
)}
>
) : (
-
-
{t('grid.import.importComplete')}
-
-
{t('grid.import.imported', { count: result.importedRows })}
+ {result.cancelled ? (
+ <>
+
+
{t('grid.import.importCancelled')}
+ >
+ ) : (
+ <>
+
+
{t('grid.import.importComplete')}
+ >
+ )}
+
+ {/* Prefer the finer created/updated breakdown when the server
+ reports it; otherwise fall back to a single "imported" count. */}
+ {result.createdRows !== undefined || result.updatedRows !== undefined ? (
+ <>
+ {(result.createdRows ?? 0) > 0 && {t('grid.import.createdCount', { count: result.createdRows })}}
+ {(result.updatedRows ?? 0) > 0 && {t('grid.import.updatedCount', { count: result.updatedRows })}}
+ {(result.createdRows ?? 0) === 0 && (result.updatedRows ?? 0) === 0 && (
+ {t('grid.import.imported', { count: result.importedRows })}
+ )}
+ >
+ ) : (
+ {t('grid.import.imported', { count: result.importedRows })}
+ )}
{result.skippedRows > 0 && {t('grid.import.skippedCount', { count: result.skippedRows })}}
{result.errors.length > 0 && (
-
- {result.errors.slice(0, 10).map((err, i) => (
-
Row {err.row}{err.field ? ` (${err.field})` : ''}: {err.message}
- ))}
- {result.errors.length > 10 &&
{t('grid.import.moreErrors', { count: result.errors.length - 10 })}
}
-
+ <>
+
+ {result.errors.slice(0, 10).map((err, i) => (
+
Row {err.row}{err.field ? ` (${err.field})` : ''}: {err.message}
+ ))}
+ {result.errors.length > 10 &&
{t('grid.import.moreErrors', { count: result.errors.length - 10 })}
}
+
+ {result.errors.some((e) => e.row >= 1) && (
+
+ )}
+ >
+ )}
+ {result.resultsTruncated && (
+
+ {t('grid.import.resultsTruncated', { count: result.errors.length, total: result.skippedRows })}
+
)}
)}
- {result ? (
-
+ {showHistory ? (
+
+ ) : result ? (
+
) : (
<>
@@ -884,12 +1910,16 @@ export const ImportWizard: React.FC = ({
)}
{step === 'mapping' && (
-