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

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.
@@ -4,7 +4,7 @@ Navette is an independent project, not affiliated with or endorsed by the Filame
4
4
 
5
5
  | Work | Used for | License | License file |
6
6
  |---|---|---|---|
7
- | [Filament](https://github.com/filamentphp/filament) © Filament | HTML markup derived from its Blade views, compiled CSS and JavaScript (`@navette-js/panels/dist/filament`), English translations (`@navette-js/support/data`) | MIT | `packages/panels/dist/FILAMENT-LICENSE.md` |
7
+ | [Filament](https://github.com/filamentphp/filament) © Filament | HTML markup derived from its Blade views, compiled CSS and JavaScript (`@navette-js/panels/dist/filament`), English translations (`@navette-js/support/data`), image editor icons (`@navette-js/support/icons/filament`) | MIT | `packages/panels/dist/FILAMENT-LICENSE.md`, `packages/support/icons/filament/LICENSE.md` |
8
8
  | [Livewire](https://github.com/livewire/livewire) © Caleb Porzio | Client script `livewire.js` (`@navette-js/wire/vendor`); the server runtime follows its protocol | MIT | `packages/wire/vendor/LIVEWIRE-LICENSE.md` |
9
9
  | [Heroicons](https://github.com/tailwindlabs/heroicons) | SVG icons (`@navette-js/support/icons`) | MIT | `packages/support/icons/HEROICONS-LICENSE.md` |
10
10
  | [Inter](https://github.com/rsms/inter) © The Inter Project Authors | Default font files (`@navette-js/panels/dist/filament/fonts`) | SIL Open Font License 1.1 | `packages/panels/dist/filament/fonts/INTER-LICENSE.txt` |
@@ -0,0 +1,142 @@
1
+ import { Action } from '@navette-js/actions';
2
+ type StoredFile = {
3
+ owner: string;
4
+ name: string;
5
+ type: string;
6
+ content: Buffer;
7
+ expires: number;
8
+ };
9
+ /**
10
+ * Files offered for download to one user (exports, failed import rows), kept in memory for a limited time. They are lost
11
+ * when the process restarts and are not shared between processes.
12
+ */
13
+ export declare class DownloadStore {
14
+ private files;
15
+ private readonly ttl;
16
+ private readonly maxFiles;
17
+ constructor(options?: {
18
+ ttl?: number;
19
+ maxFiles?: number;
20
+ });
21
+ /** Stores a file for a user and returns its token (part of the download URL). */
22
+ put(owner: string | number, name: string, type: string, content: string | Buffer): string;
23
+ /** The file of a token, only for the user who created it. */
24
+ get(token: string, owner: string | number): StoredFile | null;
25
+ }
26
+ /**
27
+ * A CSV cell. Text starting with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with `'`, so that spreadsheet
28
+ * software does not run it as a formula (CSV injection).
29
+ */
30
+ export declare function csvCell(value: unknown): string;
31
+ export declare const csvLine: (cells: unknown[]) => string;
32
+ /** Parses CSV text (quoted cells, doubled quotes, line breaks inside quotes). */
33
+ export declare function parseCsv(text: string, maxRows?: number): string[][];
34
+ /** Column of an export: its header and the value written for each record. */
35
+ export declare class ExportColumn {
36
+ readonly name: string;
37
+ private _label;
38
+ private _state;
39
+ private _format;
40
+ private _enabledByDefault;
41
+ constructor(name: string);
42
+ static make(name: string): ExportColumn;
43
+ label(label: string | null): this;
44
+ getLabel(): string;
45
+ /** Value from the record, instead of the attribute with the column's name (dots read relationships: `author.name`). */
46
+ state(resolver: ((record: any) => unknown) | null): this;
47
+ formatStateUsing(format: ((state: unknown, record: any) => unknown) | null): this;
48
+ /** `false`: unchecked in the export modal. */
49
+ enabledByDefault(condition?: boolean): this;
50
+ isEnabledByDefault(): boolean;
51
+ getValue(record: any): unknown;
52
+ }
53
+ /** Describes an export: its columns, and optionally the query and the notification. */
54
+ export declare abstract class Exporter {
55
+ static getColumns(): ExportColumn[];
56
+ /** Changes the query of the exported records (e.g. eager loading). */
57
+ static modifyQuery(_query: any): void;
58
+ /** Body of the "Export completed" notification. */
59
+ static getCompletedNotificationBody(count: number): string;
60
+ /** File name, without extension. */
61
+ static getFileName(_count: number): string;
62
+ }
63
+ export type ExportActionInstance = Action & {
64
+ exporter(exporter: typeof Exporter): ExportActionInstance;
65
+ /** Refuses exports of more rows (10,000 by default, 100,000 at most). */
66
+ maxRows(count: number): ExportActionInstance;
67
+ /** `false`: no column selection, every column is exported (the modal only confirms). */
68
+ columnMapping(condition: boolean): ExportActionInstance;
69
+ /** Files offered for download: `['csv', 'xlsx']` (both by default). */
70
+ formats(formats: ('csv' | 'xlsx')[]): ExportActionInstance;
71
+ };
72
+ /** Exports the records of the table (with its filters, search and sort) to a CSV file. */
73
+ export declare class ExportAction {
74
+ static make(name?: string): ExportActionInstance;
75
+ }
76
+ /** Exports the selected records to a CSV file. */
77
+ export declare class ExportBulkAction {
78
+ static make(name?: string): ExportActionInstance;
79
+ }
80
+ type ImportRule = string | ((value: unknown, row: Record<string, unknown>) => string | null);
81
+ /** Column of an import: the file column it is read from, its validation and how the value is cast. */
82
+ export declare class ImportColumn {
83
+ readonly name: string;
84
+ private _label;
85
+ private _guesses;
86
+ private _required;
87
+ private _rules;
88
+ private _cast;
89
+ private _example;
90
+ private _fill;
91
+ constructor(name: string);
92
+ static make(name: string): ImportColumn;
93
+ label(label: string | null): this;
94
+ getLabel(): string;
95
+ /** Other file headers that map to this column (compared case-insensitively). */
96
+ guess(headers: string[]): this;
97
+ /** The file must contain this column. */
98
+ requiredMapping(condition?: boolean): this;
99
+ isMappingRequired(): boolean;
100
+ /** `required`, `email`, `numeric`, `integer`, `boolean`, `min:n`, `max:n`, `in:a,b`, or a function returning an error message. */
101
+ rules(rules: ImportRule[]): this;
102
+ /** Converts the text of the cell before it is saved. */
103
+ castStateUsing(cast: ((value: string | null) => unknown) | null): this;
104
+ numeric(): this;
105
+ boolean(): this;
106
+ /** Value written in the example file. */
107
+ example(value: string | null): this;
108
+ getExample(): string;
109
+ /** Writes the value into the record yourself, instead of the attribute with the column's name. */
110
+ fillRecordUsing(fill: ((record: Record<string, unknown>, value: unknown) => void) | null): this;
111
+ matches(header: string): boolean;
112
+ validate(value: string | null, row: Record<string, unknown>): string | null;
113
+ cast(value: string | null): unknown;
114
+ fill(record: Record<string, unknown>, value: unknown): void;
115
+ }
116
+ /** Describes an import: its columns, how a row finds or creates its record, and hooks. */
117
+ export declare abstract class Importer {
118
+ static getColumns(): ImportColumn[];
119
+ /**
120
+ * Existing record a row updates, or `null` to create one (the default). For example, find a product by its SKU with the
121
+ * data adapter. Only return records the user may change.
122
+ */
123
+ static resolveRecord(_data: Record<string, unknown>, _ctx: {
124
+ livewire: any;
125
+ }): Promise<any | null>;
126
+ /** Runs before a record is saved; `data` may be changed. */
127
+ static beforeSave(_data: Record<string, unknown>, _record: any | null): Promise<void>;
128
+ static afterSave(_record: any): Promise<void>;
129
+ static getCompletedNotificationBody(imported: number, failed: number): string;
130
+ }
131
+ export type ImportActionInstance = Action & {
132
+ importer(importer: typeof Importer): ImportActionInstance;
133
+ /** Refuses files with more rows (1,000 by default, 10,000 at most). */
134
+ maxRows(count: number): ImportActionInstance;
135
+ /** Largest accepted file, in kilobytes (5,120 by default). */
136
+ maxFileSize(kilobytes: number): ImportActionInstance;
137
+ };
138
+ /** Imports the rows of a CSV file: each row is validated, then creates or updates a record through the data adapter. */
139
+ export declare class ImportAction {
140
+ static make(name?: string): ImportActionInstance;
141
+ }
142
+ export {};
@@ -0,0 +1,370 @@
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";
3
+ import { randomBytes } from 'node:crypto';
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { Action } from '@navette-js/actions';
6
+ import { CheckboxList, FileUpload } from '@navette-js/forms';
7
+ import { configured } from '@navette-js/support';
8
+ import { t, tc } from '@navette-js/support/i18n';
9
+ import { getTableQuery } from '@navette-js/tables';
10
+ import { Notification } from "./notifications.js";
11
+ /**
12
+ * Files offered for download to one user (exports, failed import rows), kept in memory for a limited time. They are lost
13
+ * when the process restarts and are not shared between processes.
14
+ */
15
+ export class DownloadStore {
16
+ files = new Map();
17
+ ttl;
18
+ maxFiles;
19
+ constructor(options = {}) { this.ttl = options.ttl ?? 24 * 60 * 60 * 1000; this.maxFiles = options.maxFiles ?? 500; }
20
+ /** Stores a file for a user and returns its token (part of the download URL). */
21
+ put(owner, name, type, content) {
22
+ const now = Date.now();
23
+ for (const [token, file] of this.files)
24
+ if (file.expires < now)
25
+ this.files.delete(token);
26
+ while (this.files.size >= this.maxFiles)
27
+ this.files.delete(this.files.keys().next().value);
28
+ const token = randomBytes(24).toString('base64url');
29
+ this.files.set(token, { owner: String(owner), name, type, content: Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'), expires: now + this.ttl });
30
+ return token;
31
+ }
32
+ /** The file of a token, only for the user who created it. */
33
+ get(token, owner) {
34
+ const file = this.files.get(token);
35
+ if (!file || file.expires < Date.now() || file.owner !== String(owner))
36
+ return null;
37
+ return file;
38
+ }
39
+ }
40
+ // ------------------------------------------------------------------ CSV
41
+ /**
42
+ * A CSV cell. Text starting with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with `'`, so that spreadsheet
43
+ * software does not run it as a formula (CSV injection).
44
+ */
45
+ export function csvCell(value) {
46
+ let text = value === null || value === undefined ? '' : value instanceof Date ? value.toISOString() : String(value);
47
+ if (/^[=+\-@\t\r]/.test(text) && !/^-?\d+(\.\d+)?$/.test(text))
48
+ text = `'${text}`;
49
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
50
+ }
51
+ export const csvLine = (cells) => cells.map(csvCell).join(',');
52
+ /** Parses CSV text (quoted cells, doubled quotes, line breaks inside quotes). */
53
+ export function parseCsv(text, maxRows = Infinity) {
54
+ const rows = [];
55
+ let row = [], cell = '', quoted = false;
56
+ const source = text.replace(/^/, '');
57
+ for (let index = 0; index < source.length; index++) {
58
+ const char = source[index];
59
+ if (quoted) {
60
+ if (char === '"' && source[index + 1] === '"') {
61
+ cell += '"';
62
+ index++;
63
+ }
64
+ else if (char === '"')
65
+ quoted = false;
66
+ else
67
+ cell += char;
68
+ continue;
69
+ }
70
+ if (char === '"')
71
+ quoted = true;
72
+ else if (char === ',') {
73
+ row.push(cell);
74
+ cell = '';
75
+ }
76
+ else if (char === '\n' || char === '\r') {
77
+ if (char === '\r' && source[index + 1] === '\n')
78
+ index++;
79
+ row.push(cell);
80
+ cell = '';
81
+ if (row.some((value) => value !== ''))
82
+ rows.push(row);
83
+ row = [];
84
+ if (rows.length > maxRows)
85
+ return rows;
86
+ }
87
+ else
88
+ cell += char;
89
+ }
90
+ row.push(cell);
91
+ if (row.some((value) => value !== ''))
92
+ rows.push(row);
93
+ return rows;
94
+ }
95
+ // ------------------------------------------------------------------ export
96
+ /** Column of an export: its header and the value written for each record. */
97
+ export class ExportColumn {
98
+ name;
99
+ _label = null;
100
+ _state = null;
101
+ _format = null;
102
+ _enabledByDefault = true;
103
+ constructor(name) { this.name = name; }
104
+ static make(name) { return configured(new ExportColumn(name)); }
105
+ label(label) { this._label = label; return this; }
106
+ getLabel() { return this._label ?? this.name.replace(/[_.]+/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
107
+ /** Value from the record, instead of the attribute with the column's name (dots read relationships: `author.name`). */
108
+ state(resolver) { this._state = resolver; return this; }
109
+ formatStateUsing(format) { this._format = format; return this; }
110
+ /** `false`: unchecked in the export modal. */
111
+ enabledByDefault(condition = true) { this._enabledByDefault = condition; return this; }
112
+ isEnabledByDefault() { return this._enabledByDefault; }
113
+ getValue(record) {
114
+ const state = this._state ? this._state(record) : this.name.split('.').reduce((value, key) => (value === null || value === undefined ? value : value[key]), record);
115
+ return this._format ? this._format(state, record) : state;
116
+ }
117
+ }
118
+ /** Describes an export: its columns, and optionally the query and the notification. */
119
+ export class Exporter {
120
+ static getColumns() { return []; }
121
+ /** Changes the query of the exported records (e.g. eager loading). */
122
+ static modifyQuery(_query) { }
123
+ /** Body of the "Export completed" notification. */
124
+ static getCompletedNotificationBody(count) { return `Your export has completed and ${count.toLocaleString('en-US')} ${count === 1 ? 'row' : 'rows'} exported.`; }
125
+ /** File name, without extension. */
126
+ static getFileName(_count) { return `export-${new Date().toISOString().slice(0, 10)}`; }
127
+ }
128
+ const DEFAULT_MAX_EXPORT_ROWS = 10000, HARD_MAX_EXPORT_ROWS = 100000, CHUNK = 1000;
129
+ const fileNameOf = (name) => name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 120) || 'export';
130
+ function makeExportAction(name, bulk) {
131
+ let exporter = null, maxRows = DEFAULT_MAX_EXPORT_ROWS, mapping = true, formats = ['csv', 'xlsx'];
132
+ const columnsOf = () => { if (!exporter)
133
+ throw new Error('ExportAction: call exporter() with an Exporter class.'); return exporter.getColumns(); };
134
+ const action = Action.make(name).color('gray').icon('m-arrow-down-tray')
135
+ // Exporting reads the records: the resource's `viewAny` ability (or the host's own checks) applies.
136
+ .authorize(async ({ livewire }) => (typeof livewire?.resource?.can === 'function' ? livewire.resource.can('viewAny', livewire._ctx?.user ?? null) : true))
137
+ .label(({ livewire }) => t('filament-actions::export.label', { label: livewire?.resource?.getPluralModelLabel?.() ?? '' }).trim())
138
+ .modalHeading(({ livewire }) => t('filament-actions::export.modal.heading', { label: livewire?.resource?.getPluralModelLabel?.() ?? '' }).trim())
139
+ .modalSubmitActionLabel(t('filament-actions::export.modal.actions.export.label'))
140
+ .modalWidth('md')
141
+ .schema(() => (mapping ? [CheckboxList.make('columns').label(t('filament-actions::export.modal.form.columns.label')).options(columnsOf().map((column) => [column.name, column.getLabel()])).columns(2)] : []))
142
+ .fillForm(() => ({ columns: columnsOf().filter((column) => column.isEnabledByDefault()).map((column) => column.name) }))
143
+ .action(async ({ livewire, data }) => {
144
+ // Only declared columns are exported, whatever list the browser sends.
145
+ const requested = mapping ? (Array.isArray(data.columns) ? data.columns.map(String) : []) : columnsOf().map((column) => column.name);
146
+ const columns = columnsOf().filter((column) => requested.includes(column.name));
147
+ if (!columns.length) {
148
+ Notification.make().danger().title(t('filament-actions::export.notifications.no_columns.title')).body(t('filament-actions::export.notifications.no_columns.body')).send();
149
+ return;
150
+ }
151
+ const adapter = livewire.getDataAdapterForTable();
152
+ const table = livewire.getTable();
153
+ // The table's current query (filters, search, sort); a bulk export only takes the selected records.
154
+ const query = { ...getTableQuery(table, livewire), modifiers: [...(getTableQuery(table, livewire).modifiers ?? []), (q) => exporter.modifyQuery(q)] };
155
+ if (bulk)
156
+ query.keys = await livewire.getSelectedRecordKeys();
157
+ const first = await adapter.paginate({ ...query, page: 1, perPage: CHUNK });
158
+ const limit = Math.min(maxRows, HARD_MAX_EXPORT_ROWS);
159
+ if (first.total > limit) {
160
+ 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
+ return;
162
+ }
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
+ const user = livewire._ctx?.user;
171
+ 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();
180
+ if (bulk)
181
+ livewire.deselectAllTableRecords();
182
+ });
183
+ action.exporter = (value) => { exporter = value; return action; };
184
+ action.maxRows = (count) => { maxRows = Math.max(1, Math.floor(count)); return action; };
185
+ action.formats = (list) => { const allowed = list.filter((format) => format === 'csv' || format === 'xlsx'); if (!allowed.length)
186
+ throw new Error('ExportAction: formats() needs csv and/or xlsx.'); formats = [...new Set(allowed)]; return action; };
187
+ action.columnMapping = (condition) => { mapping = condition; if (!condition)
188
+ action.requiresConfirmation(); return action; };
189
+ return action;
190
+ }
191
+ /** Exports the records of the table (with its filters, search and sort) to a CSV file. */
192
+ export class ExportAction {
193
+ static make(name = 'export') { return makeExportAction(name, false); }
194
+ }
195
+ /** Exports the selected records to a CSV file. */
196
+ export class ExportBulkAction {
197
+ static make(name = 'export') { return makeExportAction(name, true); }
198
+ }
199
+ /** Column of an import: the file column it is read from, its validation and how the value is cast. */
200
+ export class ImportColumn {
201
+ name;
202
+ _label = null;
203
+ _guesses = [];
204
+ _required = false;
205
+ _rules = [];
206
+ _cast = null;
207
+ _example = null;
208
+ _fill = null;
209
+ constructor(name) { this.name = name; }
210
+ static make(name) { return configured(new ImportColumn(name)); }
211
+ label(label) { this._label = label; return this; }
212
+ getLabel() { return this._label ?? this.name.replace(/[_.]+/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
213
+ /** Other file headers that map to this column (compared case-insensitively). */
214
+ guess(headers) { this._guesses = headers; return this; }
215
+ /** The file must contain this column. */
216
+ requiredMapping(condition = true) { this._required = condition; return this; }
217
+ isMappingRequired() { return this._required; }
218
+ /** `required`, `email`, `numeric`, `integer`, `boolean`, `min:n`, `max:n`, `in:a,b`, or a function returning an error message. */
219
+ rules(rules) { this._rules = rules; return this; }
220
+ /** Converts the text of the cell before it is saved. */
221
+ castStateUsing(cast) { this._cast = cast; return this; }
222
+ numeric() { return this.castStateUsing((value) => (value === null || value === '' ? null : Number(value))); }
223
+ boolean() { return this.castStateUsing((value) => (value === null || value === '' ? null : ['1', 'true', 'yes', 'y', 'on'].includes(value.toLowerCase()) ? 1 : 0)); }
224
+ /** Value written in the example file. */
225
+ example(value) { this._example = value; return this; }
226
+ getExample() { return this._example ?? ''; }
227
+ /** Writes the value into the record yourself, instead of the attribute with the column's name. */
228
+ fillRecordUsing(fill) { this._fill = fill; return this; }
229
+ matches(header) { const normalized = header.trim().toLowerCase(); return [this.name, this.getLabel(), ...this._guesses].some((candidate) => candidate.toLowerCase() === normalized); }
230
+ validate(value, row) {
231
+ const label = this.getLabel().toLowerCase();
232
+ const blank = value === null || value.trim() === '';
233
+ for (const rule of this._rules) {
234
+ if (typeof rule === 'function') {
235
+ const message = rule(value, row);
236
+ if (message)
237
+ return message;
238
+ continue;
239
+ }
240
+ const [name, parameter = ''] = rule.split(':');
241
+ if (name === 'required' && blank)
242
+ return `The ${label} field is required.`;
243
+ if (blank)
244
+ continue;
245
+ const text = value.trim();
246
+ if (name === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text))
247
+ return `The ${label} field must be a valid email address.`;
248
+ if (name === 'numeric' && !Number.isFinite(Number(text)))
249
+ return `The ${label} field must be a number.`;
250
+ if (name === 'integer' && !/^-?\d+$/.test(text))
251
+ return `The ${label} field must be an integer.`;
252
+ if (name === 'boolean' && !['0', '1', 'true', 'false', 'yes', 'no'].includes(text.toLowerCase()))
253
+ return `The ${label} field must be true or false.`;
254
+ if (name === 'max' && text.length > Number(parameter))
255
+ return `The ${label} field must not be greater than ${parameter} characters.`;
256
+ if (name === 'min' && text.length < Number(parameter))
257
+ return `The ${label} field must be at least ${parameter} characters.`;
258
+ if (name === 'in' && !parameter.split(',').includes(text))
259
+ return `The selected ${label} is invalid.`;
260
+ }
261
+ return null;
262
+ }
263
+ cast(value) { return this._cast ? this._cast(value) : value === '' ? null : value; }
264
+ fill(record, value) { if (this._fill)
265
+ this._fill(record, value);
266
+ else
267
+ record[this.name] = value; }
268
+ }
269
+ /** Describes an import: its columns, how a row finds or creates its record, and hooks. */
270
+ export class Importer {
271
+ static getColumns() { return []; }
272
+ /**
273
+ * Existing record a row updates, or `null` to create one (the default). For example, find a product by its SKU with the
274
+ * data adapter. Only return records the user may change.
275
+ */
276
+ static async resolveRecord(_data, _ctx) { return null; }
277
+ /** Runs before a record is saved; `data` may be changed. */
278
+ static async beforeSave(_data, _record) { }
279
+ static async afterSave(_record) { }
280
+ static getCompletedNotificationBody(imported, failed) {
281
+ 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
+ }
283
+ }
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. */
286
+ export class ImportAction {
287
+ static make(name = 'import') {
288
+ let importer = null, maxRows = DEFAULT_MAX_IMPORT_ROWS, maxKilobytes = 5120;
289
+ const columnsOf = () => { if (!importer)
290
+ throw new Error('ImportAction: call importer() with an Importer class.'); return importer.getColumns(); };
291
+ const example = Action.make('downloadExample').label(t('filament-actions::import.modal.actions.download_example.label')).color('gray').link()
292
+ .action(({ livewire }) => {
293
+ const columns = columnsOf();
294
+ const csv = `${csvLine(columns.map((column) => column.name))}\r\n${csvLine(columns.map((column) => column.getExample()))}\r\n`;
295
+ const panel = livewire._ctx?.panel;
296
+ livewire.redirect(panel.getDownloadUrl(panel.getDownloads().put(livewire._ctx.user.id, 'import-example.csv', 'text/csv; charset=utf-8', csv)));
297
+ });
298
+ const action = Action.make(name).color('gray').icon('m-arrow-up-tray')
299
+ // Importing creates or updates records: the resource's `create` ability applies.
300
+ .authorize(async ({ livewire }) => (typeof livewire?.resource?.can === 'function' ? livewire.resource.can('create', livewire._ctx?.user ?? null) : true))
301
+ .label(({ livewire }) => t('filament-actions::import.label', { label: livewire?.resource?.getPluralModelLabel?.() ?? '' }).trim())
302
+ .modalHeading(({ livewire }) => t('filament-actions::import.modal.heading', { label: livewire?.resource?.getPluralModelLabel?.() ?? '' }).trim())
303
+ .modalSubmitActionLabel(t('filament-actions::import.modal.actions.import.label'))
304
+ .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()])
306
+ .extraModalFooterActions([example])
307
+ .action(async ({ livewire, data }) => {
308
+ const entry = Array.isArray(data.file) ? data.file[0] : data.file;
309
+ const path = entry?.file?.path;
310
+ // Only a temporary upload of this request is read, never a path sent by the browser.
311
+ if (typeof path !== 'string' || statSync(path).size > maxKilobytes * 1024)
312
+ 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() ?? [];
316
+ if (rows.length > limit) {
317
+ 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
+ return;
319
+ }
320
+ 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);
323
+ if (missing.length) {
324
+ Notification.make().danger().title(t('filament-actions::import.notifications.completed.title'))
325
+ .body(missing.map((column) => t('filament-actions::import.failure_csv.column_mapping_required_for_new_record', { attribute: column.getLabel() })).join(' ')).send();
326
+ return;
327
+ }
328
+ 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;
339
+ }
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++;
350
+ }
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) }]);
362
+ }
363
+ (imported ? (failures.length ? notification.warning() : notification.success()) : notification.danger()).send();
364
+ });
365
+ action.importer = (value) => { importer = value; return action; };
366
+ action.maxRows = (count) => { maxRows = Math.max(1, Math.floor(count)); return action; };
367
+ action.maxFileSize = (kilobytes) => { maxKilobytes = Math.max(1, Math.floor(kilobytes)); return action; };
368
+ return action;
369
+ }
370
+ }