Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions e2e/import-harness/import-undo.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number> => {
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();
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"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"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
Expand Down
25 changes: 19 additions & 6 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions packages/data-objectstack/src/importJob.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) {
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' });
});
});
Loading