@navette-js/panels 1.0.0-beta.4 → 1.0.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,6 +68,8 @@ export type ExportActionInstance = Action & {
68
68
  columnMapping(condition: boolean): ExportActionInstance;
69
69
  /** Files offered for download: `['csv', 'xlsx']` (both by default). */
70
70
  formats(formats: ('csv' | 'xlsx')[]): ExportActionInstance;
71
+ /** Builds the files after the response, in the background; the download links arrive in the bell. */
72
+ queued(condition?: boolean): ExportActionInstance;
71
73
  };
72
74
  /** Exports the records of the table (with its filters, search and sort) to a CSV file. */
73
75
  export declare class ExportAction {
@@ -130,12 +132,53 @@ export declare abstract class Importer {
130
132
  }
131
133
  export type ImportActionInstance = Action & {
132
134
  importer(importer: typeof Importer): ImportActionInstance;
133
- /** Refuses files with more rows (1,000 by default, 10,000 at most). */
135
+ /** Refuses files with more rows (1,000 by default, 10,000 at most; 100,000 for a queued import). */
134
136
  maxRows(count: number): ImportActionInstance;
135
137
  /** Largest accepted file, in kilobytes (5,120 by default). */
136
138
  maxFileSize(kilobytes: number): ImportActionInstance;
139
+ /** `false`: no column selection after the upload; file headers are matched to the columns automatically. */
140
+ columnMapping(condition: boolean): ImportActionInstance;
141
+ /** Runs after the response, in the background: the user is told it started, then notified in the bell when it ends. */
142
+ queued(condition?: boolean): ImportActionInstance;
137
143
  };
138
- /** Imports the rows of a CSV file: each row is validated, then creates or updates a record through the data adapter. */
144
+ /** Rows of an uploaded CSV or XLSX file (detected from its content, never its name), up to `limit` rows. */
145
+ export declare function readSpreadsheet(path: string, limit: number): string[][];
146
+ export type BackgroundTask = () => Promise<void>;
147
+ /**
148
+ * Runs queued imports and exports. By default, one at a time in this process, after the response. Plug your own queue (BullMQ,
149
+ * a worker…) with a function receiving each task.
150
+ */
151
+ export declare const useBackgroundRunner: (runner: (task: BackgroundTask) => void) => void;
152
+ export declare const runInBackground: (task: BackgroundTask) => void;
153
+ /** Where runs of imports and exports are recorded (see `knexImportExportHistory()`). */
154
+ export interface ImportExportHistory {
155
+ startImport(data: {
156
+ userId: string | number;
157
+ importer: string;
158
+ fileName: string;
159
+ totalRows: number;
160
+ }): Promise<string | number>;
161
+ finishImport(id: string | number, data: {
162
+ processedRows: number;
163
+ successfulRows: number;
164
+ }): Promise<void>;
165
+ recordFailedImportRow(importId: string | number, row: Record<string, unknown>, error: string): Promise<void>;
166
+ startExport(data: {
167
+ userId: string | number;
168
+ exporter: string;
169
+ fileName: string;
170
+ totalRows: number;
171
+ }): Promise<string | number>;
172
+ finishExport(id: string | number, data: {
173
+ successfulRows: number;
174
+ }): Promise<void>;
175
+ }
176
+ type Query = (table: string) => any;
177
+ /** History in `imports`, `exports` and `failed_import_rows` tables (create them with `importExportSchema()`). */
178
+ export declare function knexImportExportHistory(db: Query): ImportExportHistory;
179
+ /** Creates the history tables when missing (call it from a migration). */
180
+ export declare function importExportSchema(knex: any): Promise<void>;
181
+ /** Imports the rows of a CSV or XLSX file: each row is validated, then creates or updates a record through the data adapter. */
139
182
  export declare class ImportAction {
140
183
  static make(name?: string): ImportActionInstance;
141
184
  }
@@ -1,9 +1,11 @@
1
- // Import and export actions: CSV files read and written during the request (no queue), and a per-user download store.
2
- import { buildXlsx } from "./xlsx.js";
1
+ // Import and export actions: CSV and XLSX files, read and written during the request or in the background (queued()),
2
+ // an optional history of runs, and a per-user download store.
3
+ import { buildXlsx, readXlsx } from "./xlsx.js";
3
4
  import { randomBytes } from 'node:crypto';
4
5
  import { readFileSync, statSync } from 'node:fs';
5
6
  import { Action } from '@navette-js/actions';
6
- import { CheckboxList, FileUpload } from '@navette-js/forms';
7
+ import { CheckboxList, FileUpload, Select } from '@navette-js/forms';
8
+ import { Fieldset } from '@navette-js/schemas';
7
9
  import { configured } from '@navette-js/support';
8
10
  import { t, tc } from '@navette-js/support/i18n';
9
11
  import { getTableQuery } from '@navette-js/tables';
@@ -128,7 +130,7 @@ export class Exporter {
128
130
  const DEFAULT_MAX_EXPORT_ROWS = 10000, HARD_MAX_EXPORT_ROWS = 100000, CHUNK = 1000;
129
131
  const fileNameOf = (name) => name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 120) || 'export';
130
132
  function makeExportAction(name, bulk) {
131
- let exporter = null, maxRows = DEFAULT_MAX_EXPORT_ROWS, mapping = true, formats = ['csv', 'xlsx'];
133
+ let exporter = null, maxRows = DEFAULT_MAX_EXPORT_ROWS, mapping = true, formats = ['csv', 'xlsx'], queued = false;
132
134
  const columnsOf = () => { if (!exporter)
133
135
  throw new Error('ExportAction: call exporter() with an Exporter class.'); return exporter.getColumns(); };
134
136
  const action = Action.make(name).color('gray').icon('m-arrow-down-tray')
@@ -160,25 +162,37 @@ function makeExportAction(name, bulk) {
160
162
  Notification.make().danger().title(t('filament-actions::export.notifications.max_rows.title')).body(tc('filament-actions::export.notifications.max_rows.body', limit, { count: limit.toLocaleString('en-US') })).send();
161
163
  return;
162
164
  }
163
- const rows = [columns.map((column) => column.getLabel())];
164
- for (let page = 1; page <= first.lastPage; page++) {
165
- const { records } = page === 1 ? first : await adapter.paginate({ ...query, page, perPage: CHUNK });
166
- for (const record of records)
167
- rows.push(columns.map((column) => column.getValue(record)));
168
- }
169
- const count = rows.length - 1;
170
165
  const user = livewire._ctx?.user;
171
166
  const panel = livewire._ctx?.panel;
172
- const fileName = fileNameOf(exporter.getFileName(count));
173
- const links = [];
174
- if (formats.includes('csv'))
175
- links.push({ label: t('filament-actions::export.notifications.completed.actions.download_csv.label'), url: panel.getDownloadUrl(panel.getDownloads().put(user.id, `${fileName}.csv`, 'text/csv; charset=utf-8', `\uFEFF${rows.map(csvLine).join('\r\n')}\r\n`)) });
176
- if (formats.includes('xlsx'))
177
- links.push({ label: t('filament-actions::export.notifications.completed.actions.download_xlsx.label'), url: panel.getDownloadUrl(panel.getDownloads().put(user.id, `${fileName}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buildXlsx(rows))) });
178
- Notification.make().success().persistent().title(t('filament-actions::export.notifications.completed.title')).body(exporter.getCompletedNotificationBody(count))
179
- .actions(links).send();
167
+ const background = queued && canQueue(panel);
168
+ const history = panel?.getImportExportHistory?.() ?? null;
169
+ const run = async () => {
170
+ const exportId = history ? await history.startExport({ userId: user.id, exporter: exporter.name, fileName: fileNameOf(exporter.getFileName(first.total)), totalRows: first.total }) : null;
171
+ const rows = [columns.map((column) => column.getLabel())];
172
+ for (let page = 1; page <= first.lastPage; page++) {
173
+ const { records } = page === 1 ? first : await adapter.paginate({ ...query, page, perPage: CHUNK });
174
+ for (const record of records)
175
+ rows.push(columns.map((column) => column.getValue(record)));
176
+ }
177
+ const count = rows.length - 1;
178
+ const fileName = fileNameOf(exporter.getFileName(count));
179
+ const links = [];
180
+ if (formats.includes('csv'))
181
+ links.push({ label: t('filament-actions::export.notifications.completed.actions.download_csv.label'), url: panel.getDownloadUrl(panel.getDownloads().put(user.id, `${fileName}.csv`, 'text/csv; charset=utf-8', `${rows.map(csvLine).join('\r\n')}\r\n`)) });
182
+ if (formats.includes('xlsx'))
183
+ links.push({ label: t('filament-actions::export.notifications.completed.actions.download_xlsx.label'), url: panel.getDownloadUrl(panel.getDownloads().put(user.id, `${fileName}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buildXlsx(rows))) });
184
+ if (history && exportId !== null)
185
+ await history.finishExport(exportId, { successfulRows: count });
186
+ await deliver(Notification.make().success().persistent().title(t('filament-actions::export.notifications.completed.title')).body(exporter.getCompletedNotificationBody(count)).actions(links), background, panel, user);
187
+ };
180
188
  if (bulk)
181
189
  livewire.deselectAllTableRecords();
190
+ if (!background) {
191
+ await run();
192
+ return;
193
+ }
194
+ Notification.make().success().title(t('filament-actions::export.notifications.started.title')).body(tc('filament-actions::export.notifications.started.body', first.total, { count: first.total.toLocaleString('en-US') })).send();
195
+ runInBackground(run);
182
196
  });
183
197
  action.exporter = (value) => { exporter = value; return action; };
184
198
  action.maxRows = (count) => { maxRows = Math.max(1, Math.floor(count)); return action; };
@@ -186,6 +200,7 @@ function makeExportAction(name, bulk) {
186
200
  throw new Error('ExportAction: formats() needs csv and/or xlsx.'); formats = [...new Set(allowed)]; return action; };
187
201
  action.columnMapping = (condition) => { mapping = condition; if (!condition)
188
202
  action.requiresConfirmation(); return action; };
203
+ action.queued = (condition = true) => { queued = condition; return action; };
189
204
  return action;
190
205
  }
191
206
  /** Exports the records of the table (with its filters, search and sort) to a CSV file. */
@@ -281,11 +296,121 @@ export class Importer {
281
296
  return `Your import has completed and ${imported.toLocaleString('en-US')} ${imported === 1 ? 'row' : 'rows'} imported.${failed ? ` ${failed.toLocaleString('en-US')} ${failed === 1 ? 'row' : 'rows'} failed to import.` : ''}`;
282
297
  }
283
298
  }
284
- const DEFAULT_MAX_IMPORT_ROWS = 1000, HARD_MAX_IMPORT_ROWS = 10000;
285
- /** Imports the rows of a CSV file: each row is validated, then creates or updates a record through the data adapter. */
299
+ const DEFAULT_MAX_IMPORT_ROWS = 1000, HARD_MAX_IMPORT_ROWS = 10000, HARD_MAX_QUEUED_IMPORT_ROWS = 100000;
300
+ /** Rows of an uploaded CSV or XLSX file (detected from its content, never its name), up to `limit` rows. */
301
+ export function readSpreadsheet(path, limit) {
302
+ const buffer = readFileSync(path);
303
+ if (buffer.length >= 4 && buffer.readUInt32LE(0) === 0x04034b50)
304
+ return readXlsx(buffer, limit);
305
+ return parseCsv(buffer.toString('utf8').replace(/^/, ''), limit);
306
+ }
307
+ /** Path of the temporary file uploaded in a FileUpload state, if any. */
308
+ function temporaryPathOf(livewire, state) {
309
+ // A saved entry (`{ file: TemporaryUpload }`), a list of them, or the raw state of the field (upload references).
310
+ if (state && typeof state === 'object' && typeof state.file?.path === 'string')
311
+ return state.file.path;
312
+ const values = Array.isArray(state) ? state : state && typeof state === 'object' ? Object.values(state) : [state];
313
+ for (const value of values) {
314
+ const file = (value && typeof value === 'object' && value.file?.path) ? value.file : livewire?._runtime?.uploads?.resolve?.(value);
315
+ if (typeof file?.path === 'string')
316
+ return file.path;
317
+ }
318
+ return null;
319
+ }
320
+ const headersOf = (livewire, state) => {
321
+ const path = temporaryPathOf(livewire, state);
322
+ if (!path)
323
+ return [];
324
+ try {
325
+ return (readSpreadsheet(path, 1)[0] ?? []).map((cell) => cell.trim()).filter((cell, index, all) => cell !== '' && all.indexOf(cell) === index);
326
+ }
327
+ catch {
328
+ return [];
329
+ }
330
+ };
331
+ const pendingTasks = [];
332
+ let draining = false;
333
+ async function drainTasks() {
334
+ if (draining)
335
+ return;
336
+ draining = true;
337
+ while (pendingTasks.length) {
338
+ const task = pendingTasks.shift();
339
+ try {
340
+ await task();
341
+ }
342
+ catch (error) {
343
+ console.error('[navette] background task failed:', error);
344
+ }
345
+ }
346
+ draining = false;
347
+ }
348
+ let backgroundRunner = (task) => { pendingTasks.push(task); setImmediate(() => { void drainTasks(); }); };
349
+ /**
350
+ * Runs queued imports and exports. By default, one at a time in this process, after the response. Plug your own queue (BullMQ,
351
+ * a worker…) with a function receiving each task.
352
+ */
353
+ export const useBackgroundRunner = (runner) => { backgroundRunner = runner; };
354
+ export const runInBackground = (task) => backgroundRunner(task);
355
+ const now = () => new Date().toISOString().slice(0, 19).replace('T', ' ');
356
+ /** History in `imports`, `exports` and `failed_import_rows` tables (create them with `importExportSchema()`). */
357
+ export function knexImportExportHistory(db) {
358
+ const insert = async (table, values) => { const [id] = await db(table).insert(values); return typeof id === 'object' && id !== null ? id.id : id; };
359
+ return {
360
+ startImport: (data) => insert('imports', { user_id: data.userId, importer: data.importer, file_name: data.fileName.slice(0, 255), total_rows: data.totalRows, processed_rows: 0, successful_rows: 0, created_at: now(), updated_at: now() }),
361
+ async finishImport(id, data) { await db('imports').where('id', id).update({ processed_rows: data.processedRows, successful_rows: data.successfulRows, completed_at: now(), updated_at: now() }); },
362
+ async recordFailedImportRow(importId, row, error) { await db('failed_import_rows').insert({ import_id: importId, data: JSON.stringify(row), validation_error: error.slice(0, 1000), created_at: now(), updated_at: now() }); },
363
+ startExport: (data) => insert('exports', { user_id: data.userId, exporter: data.exporter, file_name: data.fileName.slice(0, 255), total_rows: data.totalRows, successful_rows: 0, created_at: now(), updated_at: now() }),
364
+ async finishExport(id, data) { await db('exports').where('id', id).update({ successful_rows: data.successfulRows, completed_at: now(), updated_at: now() }); },
365
+ };
366
+ }
367
+ /** Creates the history tables when missing (call it from a migration). */
368
+ export async function importExportSchema(knex) {
369
+ if (!(await knex.schema.hasTable('imports')))
370
+ await knex.schema.createTable('imports', (table) => {
371
+ table.increments('id');
372
+ table.integer('user_id').index();
373
+ table.string('importer');
374
+ table.string('file_name');
375
+ table.integer('total_rows');
376
+ table.integer('processed_rows').defaultTo(0);
377
+ table.integer('successful_rows').defaultTo(0);
378
+ table.timestamp('completed_at').nullable();
379
+ table.timestamps();
380
+ });
381
+ if (!(await knex.schema.hasTable('failed_import_rows')))
382
+ await knex.schema.createTable('failed_import_rows', (table) => {
383
+ table.increments('id');
384
+ table.integer('import_id').index();
385
+ table.text('data');
386
+ table.text('validation_error').nullable();
387
+ table.timestamps();
388
+ });
389
+ if (!(await knex.schema.hasTable('exports')))
390
+ await knex.schema.createTable('exports', (table) => {
391
+ table.increments('id');
392
+ table.integer('user_id').index();
393
+ table.string('exporter');
394
+ table.string('file_name');
395
+ table.integer('total_rows');
396
+ table.integer('successful_rows').defaultTo(0);
397
+ table.timestamp('completed_at').nullable();
398
+ table.timestamps();
399
+ });
400
+ }
401
+ /** Sends a notification now (in the request) or, for background work, to the user's database notifications. */
402
+ async function deliver(notification, background, panel, user) {
403
+ if (background)
404
+ await notification.sendToDatabase(panel.getDatabaseNotifications?.() ?? null, user.id);
405
+ else
406
+ notification.send();
407
+ }
408
+ /** Background work needs database notifications to tell the user it ended; without them, it runs in the request. */
409
+ const canQueue = (panel) => Boolean(panel?.getDatabaseNotifications?.());
410
+ /** Imports the rows of a CSV or XLSX file: each row is validated, then creates or updates a record through the data adapter. */
286
411
  export class ImportAction {
287
412
  static make(name = 'import') {
288
- let importer = null, maxRows = DEFAULT_MAX_IMPORT_ROWS, maxKilobytes = 5120;
413
+ let importer = null, maxRows = DEFAULT_MAX_IMPORT_ROWS, maxKilobytes = 5120, mapping = true, queued = false;
289
414
  const columnsOf = () => { if (!importer)
290
415
  throw new Error('ImportAction: call importer() with an Importer class.'); return importer.getColumns(); };
291
416
  const example = Action.make('downloadExample').label(t('filament-actions::import.modal.actions.download_example.label')).color('gray').link()
@@ -302,69 +427,117 @@ export class ImportAction {
302
427
  .modalHeading(({ livewire }) => t('filament-actions::import.modal.heading', { label: livewire?.resource?.getPluralModelLabel?.() ?? '' }).trim())
303
428
  .modalSubmitActionLabel(t('filament-actions::import.modal.actions.import.label'))
304
429
  .modalWidth('md')
305
- .schema(() => [FileUpload.make('file').label(t('filament-actions::import.modal.form.file.label')).acceptedFileTypes(['text/csv', 'text/plain', 'application/vnd.ms-excel', '.csv']).maxSize(maxKilobytes).storeFiles(false).required()])
430
+ .schema((schema) => {
431
+ const livewire = schema.livewire;
432
+ const columns = columnsOf();
433
+ // The columns of the uploaded file, to map them to the importer's columns.
434
+ const mounted = livewire?.mountedActions?.find?.((candidate) => candidate.name === name);
435
+ const headers = mapping ? headersOf(livewire, mounted?.data?.file) : [];
436
+ const file = FileUpload.make('file').label(t('filament-actions::import.modal.form.file.label')).placeholder(t('filament-actions::import.modal.form.file.placeholder'))
437
+ .acceptedFileTypes(['text/csv', 'text/plain', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.csv', '.xlsx']).maxSize(maxKilobytes).storeFiles(false).required();
438
+ if (mapping)
439
+ file.live().afterStateUpdated(({ state, set }) => {
440
+ const found = headersOf(livewire, state);
441
+ set('columnMap', Object.fromEntries(columns.map((column) => [column.name, found.find((header) => column.matches(header)) ?? null])));
442
+ });
443
+ return schema.components([
444
+ file,
445
+ ...(mapping && headers.length ? [Fieldset.make(t('filament-actions::import.modal.form.columns.label')).statePath('columnMap').columns(1).schema(columns.map((column) => Select.make(column.name).label(column.getLabel()).placeholder(t('filament-actions::import.modal.form.columns.placeholder')).options(headers.map((header) => [header, header])).required(column.isMappingRequired())))] : []),
446
+ ]);
447
+ })
306
448
  .extraModalFooterActions([example])
307
449
  .action(async ({ livewire, data }) => {
308
- const entry = Array.isArray(data.file) ? data.file[0] : data.file;
309
- const path = entry?.file?.path;
450
+ const path = temporaryPathOf(livewire, data.file);
310
451
  // Only a temporary upload of this request is read, never a path sent by the browser.
311
452
  if (typeof path !== 'string' || statSync(path).size > maxKilobytes * 1024)
312
453
  return;
313
- const limit = Math.min(maxRows, HARD_MAX_IMPORT_ROWS);
314
- const rows = parseCsv(readFileSync(path, 'utf8'), limit + 1);
315
- const header = rows.shift() ?? [];
454
+ const panel = livewire._ctx?.panel, user = livewire._ctx?.user;
455
+ const background = queued && canQueue(panel);
456
+ const limit = Math.min(maxRows, background ? HARD_MAX_QUEUED_IMPORT_ROWS : HARD_MAX_IMPORT_ROWS);
457
+ let rows;
458
+ try {
459
+ rows = readSpreadsheet(path, limit + 1);
460
+ }
461
+ catch {
462
+ Notification.make().danger().title(t('filament-actions::import.notifications.completed.title')).body('The file could not be read: upload a CSV or XLSX file.').send();
463
+ return;
464
+ }
465
+ const header = (rows.shift() ?? []).map((cell) => cell.trim());
316
466
  if (rows.length > limit) {
317
467
  Notification.make().danger().title(t('filament-actions::import.notifications.max_rows.title')).body(tc('filament-actions::import.notifications.max_rows.body', limit, { count: limit.toLocaleString('en-US') })).send();
318
468
  return;
319
469
  }
320
470
  const columns = columnsOf();
321
- const mapping = new Map(columns.map((column) => [column, header.findIndex((cell) => column.matches(cell))]));
322
- const missing = columns.filter((column) => column.isMappingRequired() && mapping.get(column) === -1);
471
+ // Chosen columns (only headers of the file are accepted), else automatic matching.
472
+ const chosen = (data.columnMap && typeof data.columnMap === 'object' ? data.columnMap : {});
473
+ const indexOf = (column) => {
474
+ if (mapping && Object.hasOwn(chosen, column.name))
475
+ return typeof chosen[column.name] === 'string' ? header.indexOf(chosen[column.name]) : -1;
476
+ return header.findIndex((cell) => column.matches(cell));
477
+ };
478
+ const columnIndexes = new Map(columns.map((column) => [column, indexOf(column)]));
479
+ const missing = columns.filter((column) => column.isMappingRequired() && columnIndexes.get(column) === -1);
323
480
  if (missing.length) {
324
481
  Notification.make().danger().title(t('filament-actions::import.notifications.completed.title'))
325
482
  .body(missing.map((column) => t('filament-actions::import.failure_csv.column_mapping_required_for_new_record', { attribute: column.getLabel() })).join(' ')).send();
326
483
  return;
327
484
  }
328
485
  const adapter = livewire.getDataAdapterForTable();
329
- let imported = 0;
330
- const failures = [];
331
- for (const cells of rows) {
332
- const raw = {};
333
- for (const [column, index] of mapping)
334
- raw[column.name] = index === -1 ? null : (cells[index] ?? null);
335
- const error = columns.map((column) => (mapping.get(column) === -1 ? null : column.validate(raw[column.name], raw))).find((message) => message !== null);
336
- if (error) {
337
- failures.push(csvLine([...cells, error]));
338
- continue;
486
+ const history = panel?.getImportExportHistory?.() ?? null;
487
+ const fileName = String(Object.values(data.file ?? {})[0]?.file?.name ?? Object.values(data.file ?? {})[0]?.name ?? 'import');
488
+ const run = async () => {
489
+ const importId = history ? await history.startImport({ userId: user.id, importer: importer.name, fileName, totalRows: rows.length }) : null;
490
+ let imported = 0;
491
+ const failures = [];
492
+ for (const cells of rows) {
493
+ const raw = {};
494
+ for (const [column, index] of columnIndexes)
495
+ raw[column.name] = index === -1 ? null : (cells[index] ?? null);
496
+ const fail = async (message) => { failures.push(csvLine([...cells, message])); if (history && importId !== null)
497
+ await history.recordFailedImportRow(importId, raw, message); };
498
+ const error = columns.map((column) => (columnIndexes.get(column) === -1 ? null : column.validate(raw[column.name], raw))).find((message) => message !== null);
499
+ if (error) {
500
+ await fail(error);
501
+ continue;
502
+ }
503
+ try {
504
+ const record = await importer.resolveRecord(raw, { livewire });
505
+ const values = {};
506
+ for (const column of columns)
507
+ if (columnIndexes.get(column) !== -1)
508
+ column.fill(values, column.cast(raw[column.name]));
509
+ await importer.beforeSave(values, record);
510
+ const saved = record ? await adapter.update(adapter.getKey(record), values) : await adapter.create(values);
511
+ await importer.afterSave(saved);
512
+ imported++;
513
+ }
514
+ catch (failure) {
515
+ console.error(failure);
516
+ await fail(t('filament-actions::import.failure_csv.system_error'));
517
+ }
339
518
  }
340
- try {
341
- const record = await importer.resolveRecord(raw, { livewire });
342
- const values = {};
343
- for (const column of columns)
344
- if (mapping.get(column) !== -1)
345
- column.fill(values, column.cast(raw[column.name]));
346
- await importer.beforeSave(values, record);
347
- const saved = record ? await adapter.update(adapter.getKey(record), values) : await adapter.create(values);
348
- await importer.afterSave(saved);
349
- imported++;
519
+ if (history && importId !== null)
520
+ await history.finishImport(importId, { processedRows: rows.length, successfulRows: imported });
521
+ const notification = Notification.make().title(t('filament-actions::import.notifications.completed.title')).body(importer.getCompletedNotificationBody(imported, failures.length)).persistent();
522
+ if (failures.length) {
523
+ const csv = `${[csvLine([...header, t('filament-actions::import.failure_csv.error_header')]), ...failures].join('\r\n')}\r\n`;
524
+ const token = panel.getDownloads().put(user.id, 'import-failed-rows.csv', 'text/csv; charset=utf-8', csv);
525
+ notification.actions([{ label: tc('filament-actions::import.notifications.completed.actions.download_failed_rows_csv.label', failures.length), url: panel.getDownloadUrl(token) }]);
350
526
  }
351
- catch (failure) {
352
- console.error(failure);
353
- failures.push(csvLine([...cells, t('filament-actions::import.failure_csv.system_error')]));
354
- }
355
- }
356
- const notification = Notification.make().title(t('filament-actions::import.notifications.completed.title')).body(importer.getCompletedNotificationBody(imported, failures.length)).persistent();
357
- if (failures.length) {
358
- const panel = livewire._ctx?.panel;
359
- const csv = `${[csvLine([...header, t('filament-actions::import.failure_csv.error_header')]), ...failures].join('\r\n')}\r\n`;
360
- const token = panel.getDownloads().put(livewire._ctx.user.id, 'import-failed-rows.csv', 'text/csv; charset=utf-8', csv);
361
- notification.actions([{ label: tc('filament-actions::import.notifications.completed.actions.download_failed_rows_csv.label', failures.length), url: panel.getDownloadUrl(token) }]);
527
+ await deliver(imported ? (failures.length ? notification.warning() : notification.success()) : notification.danger(), background, panel, user);
528
+ };
529
+ if (!background) {
530
+ await run();
531
+ return;
362
532
  }
363
- (imported ? (failures.length ? notification.warning() : notification.success()) : notification.danger()).send();
533
+ Notification.make().success().title(t('filament-actions::import.notifications.started.title')).body(tc('filament-actions::import.notifications.started.body', rows.length, { count: rows.length.toLocaleString('en-US') })).send();
534
+ runInBackground(run);
364
535
  });
365
536
  action.importer = (value) => { importer = value; return action; };
366
537
  action.maxRows = (count) => { maxRows = Math.max(1, Math.floor(count)); return action; };
367
538
  action.maxFileSize = (kilobytes) => { maxKilobytes = Math.max(1, Math.floor(kilobytes)); return action; };
539
+ action.columnMapping = (condition) => { mapping = condition; return action; };
540
+ action.queued = (condition = true) => { queued = condition; return action; };
368
541
  return action;
369
542
  }
370
543
  }