Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
20 changes: 20 additions & 0 deletions apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ const workspaceAliases: Record<string, string> = {
'@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 → …/<pkg>) so Vite can serve it.
clientFsAllow.push(path.dirname(resolved), path.resolve(path.dirname(resolved), '..'));
}

// https://vitejs.dev/config/
export default defineConfig({
base: basePath,
Expand Down Expand Up @@ -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 },
},
Expand Down
114 changes: 114 additions & 0 deletions e2e/import-console/import-console-undo.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number> => {
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();
});
});
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();
});
});
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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
Loading