@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.
- package/THIRD-PARTY-NOTICES.md +1 -0
- package/dist/navette/navette/calendar.css +5 -5
- package/dist/navette/navette/motion.css +21 -21
- package/dist/navette/navette/motion.js +6 -6
- package/dist/navette/navette/security-keys.css +11 -0
- package/dist/navette/navette/security-keys.js +72 -0
- package/lib/auth.d.ts +17 -0
- package/lib/auth.js +70 -0
- package/lib/error-pages.d.ts +9 -0
- package/lib/error-pages.js +173 -0
- package/lib/import-export.d.ts +45 -2
- package/lib/import-export.js +235 -62
- package/lib/index.d.ts +447 -74
- package/lib/index.js +1280 -267
- package/lib/multi-factor.d.ts +348 -0
- package/lib/multi-factor.js +928 -0
- package/lib/qr-code.d.ts +16 -0
- package/lib/qr-code.js +255 -0
- package/lib/throttle.d.ts +10 -0
- package/lib/throttle.js +25 -0
- package/lib/topbar.d.ts +3 -1
- package/lib/topbar.js +18 -1
- package/lib/totp.d.ts +38 -0
- package/lib/totp.js +104 -0
- package/lib/webauthn.d.ts +155 -0
- package/lib/webauthn.js +287 -0
- package/lib/xlsx.d.ts +5 -0
- package/lib/xlsx.js +103 -1
- package/package.json +9 -9
- package/views/components/tenant-menu.ejs +3 -3
- package/views/errors/page.ejs +87 -0
- package/views/layout/base.ejs +2 -0
- package/views/livewire/sidebar.ejs +29 -15
- package/views/livewire/topbar.ejs +1 -1
- package/views/pages/page.ejs +53 -35
- package/views/pages/simple.ejs +4 -0
package/lib/import-export.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
}
|
package/lib/import-export.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Import and export actions: CSV files read and written during the request
|
|
2
|
-
|
|
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
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
/**
|
|
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(() =>
|
|
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
|
|
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
|
|
314
|
-
const
|
|
315
|
-
const
|
|
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
|
-
|
|
322
|
-
const
|
|
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
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
}
|