ngx-dynamic-entity 1.13.0 → 1.14.0
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.
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/forms';
|
|
4
|
-
import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } from '@angular/forms';
|
|
5
|
-
import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, ambiguousFieldIds, parseFieldRef, toRefToken, collectFieldScopes, fieldRefFor, refOf, getTabPath, applyAutoPatch, applyPatchOnTrue } from '@dynamic-entity/core';
|
|
4
|
+
import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup, FormsModule } from '@angular/forms';
|
|
5
|
+
import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, ambiguousFieldIds, parseFieldRef, toRefToken, collectFieldScopes, fieldRefFor, refOf, getTabPath, applyAutoPatch, applyPatchOnTrue, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
|
|
6
6
|
export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
|
|
7
7
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
8
8
|
import { Subject, isObservable, firstValueFrom } from 'rxjs';
|
|
@@ -139,6 +139,46 @@ const COMMON_MODULES_REGISTRY = new InjectionToken('COMMON_MODULES_REGISTRY');
|
|
|
139
139
|
* { provide: UPLOAD_HANDLER, useFactory: () => (file: File) => myUploadService.upload(file) }
|
|
140
140
|
*/
|
|
141
141
|
const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
|
|
142
|
+
/**
|
|
143
|
+
* Reads an uploaded spreadsheet into headers and positional rows, for the import wizard.
|
|
144
|
+
*
|
|
145
|
+
* Unregistered, the wizard reads CSV with the dependency-free parser in
|
|
146
|
+
* `@dynamic-entity/core`, so it works with nothing provided. Register one to accept `.xlsx`
|
|
147
|
+
* — which spreadsheet library that uses stays the consumer's choice, and this package keeps
|
|
148
|
+
* having no runtime dependency.
|
|
149
|
+
*
|
|
150
|
+
* Rows come back **positional** (`string[][]`) rather than keyed by header, because a real
|
|
151
|
+
* sheet has duplicate headers and blank ones; a header-keyed row silently loses the second
|
|
152
|
+
* of two columns both called "Notes".
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* provideNgxDynamicEntity({
|
|
156
|
+
* sheetParser: async file => {
|
|
157
|
+
* const wb = XLSX.read(await file.arrayBuffer());
|
|
158
|
+
* const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], {
|
|
159
|
+
* header: 1, raw: false, defval: '',
|
|
160
|
+
* }) as string[][];
|
|
161
|
+
* return { headers: rows[0] ?? [], rows: rows.slice(1) };
|
|
162
|
+
* },
|
|
163
|
+
* })
|
|
164
|
+
*/
|
|
165
|
+
const SHEET_PARSER = new InjectionToken('SHEET_PARSER');
|
|
166
|
+
/**
|
|
167
|
+
* Where an import's work happens.
|
|
168
|
+
*
|
|
169
|
+
* Unregistered, everything runs in the browser through `@dynamic-entity/core` and nothing
|
|
170
|
+
* touches the network — the wizard is fully usable with no backend. Register one that posts
|
|
171
|
+
* to a server and the same components drive that instead, over the same pure mapping engine,
|
|
172
|
+
* which is what keeps a client-side import and a server-side one from disagreeing about what
|
|
173
|
+
* they produced.
|
|
174
|
+
*
|
|
175
|
+
* The reason to register one is size: the default reads the whole file into memory, which is
|
|
176
|
+
* right for a sheet somebody assembled and wrong for a fifty-thousand-row export.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* { provide: IMPORT_TRANSPORT, useClass: HttpImportTransport }
|
|
180
|
+
*/
|
|
181
|
+
const IMPORT_TRANSPORT = new InjectionToken('IMPORT_TRANSPORT');
|
|
142
182
|
/**
|
|
143
183
|
* Backing store for the entity-reference options cache. Defaults to in-memory.
|
|
144
184
|
* Provide your own to make the cache outlive a page refresh.
|
|
@@ -252,6 +292,10 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
|
|
|
252
292
|
{ provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
|
|
253
293
|
{ provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
|
|
254
294
|
{ provide: UI_TEXT, useValue: config.uiText ?? {} },
|
|
295
|
+
// Both optional and both null when unset: the wizard falls back to the built-in CSV
|
|
296
|
+
// parser and the in-browser transport, so it works with nothing registered.
|
|
297
|
+
{ provide: SHEET_PARSER, useValue: config.sheetParser ?? null },
|
|
298
|
+
{ provide: IMPORT_TRANSPORT, useValue: config.importTransport ?? null },
|
|
255
299
|
]);
|
|
256
300
|
|
|
257
301
|
/** The built-in English messages. Every key here can be overridden individually. */
|
|
@@ -837,6 +881,49 @@ const DEFAULT_UI_TEXT = {
|
|
|
837
881
|
criticalFieldUnlockHint: 'Critical field — click to unlock for editing',
|
|
838
882
|
dismissBanner: 'Dismiss: {message}',
|
|
839
883
|
jumpToField: 'Click to jump to {field}',
|
|
884
|
+
// Import wizard — the steps, in order
|
|
885
|
+
importUpload: 'Choose a file',
|
|
886
|
+
importMap: 'Match columns',
|
|
887
|
+
importReview: 'Review',
|
|
888
|
+
importDone: 'Done',
|
|
889
|
+
// Import wizard — choosing a file
|
|
890
|
+
importChooseFile: 'Choose a spreadsheet',
|
|
891
|
+
importFileChosen: '{name} — {rows} row(s)',
|
|
892
|
+
importReading: 'Reading the file…',
|
|
893
|
+
importNeedFile: 'Choose a file to continue.',
|
|
894
|
+
// Import wizard — the template a user can start from
|
|
895
|
+
importTemplateHeading: 'Start from a template',
|
|
896
|
+
importTemplateExplain: 'Download a sheet with the right columns, fill it in, and upload it.',
|
|
897
|
+
importTemplateDownload: 'Download template',
|
|
898
|
+
importTemplateSelectAll: 'Select all',
|
|
899
|
+
importTemplateSelectNone: 'Select none',
|
|
900
|
+
importTemplateUnsupported: '{count} field(s) cannot be carried by a spreadsheet.',
|
|
901
|
+
// Import wizard — matching columns
|
|
902
|
+
importColumnInFile: 'Column in your file',
|
|
903
|
+
importTargetField: 'Field',
|
|
904
|
+
importUnmapped: 'Not imported',
|
|
905
|
+
importGuessed: 'Guessed',
|
|
906
|
+
importMappedCount: '{mapped} of {total} field(s) matched.',
|
|
907
|
+
importRequiredUnmapped: '{count} required field(s) still need a column.',
|
|
908
|
+
// Import wizard — review and result
|
|
909
|
+
importPreviewHeading: 'First {count} row(s), as they will be saved',
|
|
910
|
+
importRowRejected: 'This row will not be imported: {reason}',
|
|
911
|
+
importRunImport: 'Import {count} row(s)',
|
|
912
|
+
importImporting: 'Importing…',
|
|
913
|
+
importSucceeded: 'Imported {count} record(s).',
|
|
914
|
+
importSkippedRows: '{count} blank row(s) skipped.',
|
|
915
|
+
importFailedRows: '{count} row(s) could not be imported.',
|
|
916
|
+
// A streaming transport caps what it retains — fifty thousand error objects is the same
|
|
917
|
+
// unbounded growth as holding the file — so the count and the sample are different numbers
|
|
918
|
+
// and the user is told which they are looking at.
|
|
919
|
+
importErrorsTruncated: 'Showing the first {shown} of {count} problem(s).',
|
|
920
|
+
importStartOver: 'Import another file',
|
|
921
|
+
// Import wizard — what went wrong
|
|
922
|
+
importErrorsHeading: 'Rows that need attention',
|
|
923
|
+
importErrorRow: 'Row {row}',
|
|
924
|
+
importErrorField: 'Field',
|
|
925
|
+
importErrorReason: 'Reason',
|
|
926
|
+
importPlanProblem: 'This mapping cannot be used: {message}',
|
|
840
927
|
// Banners and whole-record states
|
|
841
928
|
entityLabel: 'Entity: {entity}',
|
|
842
929
|
criticalFieldChanged: '🔒 Critical field changed: {fields} — this differs from the value at the start of this session.',
|
|
@@ -8127,11 +8214,1476 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
8127
8214
|
args: [DynamicFormComponent]
|
|
8128
8215
|
}] } });
|
|
8129
8216
|
|
|
8217
|
+
/**
|
|
8218
|
+
* import-contracts.ts — the two seams the import wizard hangs on.
|
|
8219
|
+
*
|
|
8220
|
+
* Both follow the pattern the rest of this package already uses for anything the library
|
|
8221
|
+
* cannot decide for a consumer — `FileUploadHandler`, `EntityReferenceLoader`,
|
|
8222
|
+
* `LookupListLoader`: a function type here, a token to register it against, and a default
|
|
8223
|
+
* that works without registering anything.
|
|
8224
|
+
*
|
|
8225
|
+
* **Why two rather than one.** Reading a file and deciding where the work happens are
|
|
8226
|
+
* different questions with different answers. A consumer may want `.xlsx` support in a
|
|
8227
|
+
* wizard that still runs entirely in the browser, or plain CSV that is nonetheless processed
|
|
8228
|
+
* on a server because their files have fifty thousand rows. Collapsing them into one option
|
|
8229
|
+
* would make each answer imply the other.
|
|
8230
|
+
*/
|
|
8231
|
+
|
|
8232
|
+
/**
|
|
8233
|
+
* sheet-parser.ts — the built-in CSV reader, and the reason it is the only one.
|
|
8234
|
+
*
|
|
8235
|
+
* CSV is what every spreadsheet tool exports and what a text editor can produce, and
|
|
8236
|
+
* `@dynamic-entity/core` parses it with no dependency. Shipping that as the default means the
|
|
8237
|
+
* import wizard works on a fresh `npm install` with nothing registered — `SHEET_PARSER` then
|
|
8238
|
+
* exists for the consumer who *also* wants `.xlsx`, rather than for the consumer who wants
|
|
8239
|
+
* the feature to work at all.
|
|
8240
|
+
*
|
|
8241
|
+
* The alternative was a hard dependency on a spreadsheet library in a package that currently
|
|
8242
|
+
* has none, for a file format many consumers will never see.
|
|
8243
|
+
*/
|
|
8244
|
+
/** Extensions the built-in parser will attempt. */
|
|
8245
|
+
const TEXT_EXTENSIONS = /\.(csv|tsv|txt)$/i;
|
|
8246
|
+
/**
|
|
8247
|
+
* Read a text file as CSV.
|
|
8248
|
+
*
|
|
8249
|
+
* A binary workbook is refused by name rather than attempted: `.xlsx` is a zip, so reading it
|
|
8250
|
+
* as text yields a header row of mojibake and a mapping screen full of nonsense, which is a
|
|
8251
|
+
* much harder thing to diagnose than being told the format is not supported.
|
|
8252
|
+
*/
|
|
8253
|
+
const defaultSheetParser = async (file) => {
|
|
8254
|
+
if (file?.name && !TEXT_EXTENSIONS.test(file.name)) {
|
|
8255
|
+
throw new Error(`Cannot read "${file.name}". The built-in parser handles CSV only — register a ` +
|
|
8256
|
+
'sheetParser with provideNgxDynamicEntity({ sheetParser }) to accept other formats.');
|
|
8257
|
+
}
|
|
8258
|
+
return parseCsv(await file.text());
|
|
8259
|
+
};
|
|
8260
|
+
|
|
8261
|
+
/**
|
|
8262
|
+
* local-import-transport.ts — the whole import, in the browser, with no server.
|
|
8263
|
+
*
|
|
8264
|
+
* This is the default `IMPORT_TRANSPORT`, and it is a thin arrangement of pure functions
|
|
8265
|
+
* from `@dynamic-entity/core` rather than an implementation of anything. That matters: a
|
|
8266
|
+
* consumer who later registers a server transport is not switching to a second set of
|
|
8267
|
+
* mapping rules, they are moving the same ones to a different machine.
|
|
8268
|
+
*
|
|
8269
|
+
* Its limit is the one every browser has — the file is read into memory whole. That is fine
|
|
8270
|
+
* for the sizes a person assembles by hand and wrong for a fifty-thousand-row export, which
|
|
8271
|
+
* is what a streaming server transport is for.
|
|
8272
|
+
*/
|
|
8273
|
+
/** How many rows the mapping screen shows under each column. */
|
|
8274
|
+
const SAMPLE_ROWS = 5;
|
|
8275
|
+
class LocalImportTransport {
|
|
8276
|
+
registered = inject(SHEET_PARSER, { optional: true });
|
|
8277
|
+
/**
|
|
8278
|
+
* One parse per file, not one per call.
|
|
8279
|
+
*
|
|
8280
|
+
* A wizard run reads the same file twice — once to show its headers, once to import it —
|
|
8281
|
+
* and this is the transport whose stated limit is that it holds the file in memory. Doing
|
|
8282
|
+
* the work twice doubled both the parse and the peak memory of the one implementation that
|
|
8283
|
+
* can least afford it.
|
|
8284
|
+
*
|
|
8285
|
+
* A `WeakMap` keyed on the `File` itself: the entry lives exactly as long as something
|
|
8286
|
+
* still holds the file, so choosing a different file drops the old sheet without anything
|
|
8287
|
+
* having to remember to. Keying on a name or a size would collide between two files a user
|
|
8288
|
+
* picked in sequence.
|
|
8289
|
+
*/
|
|
8290
|
+
parsed = new WeakMap();
|
|
8291
|
+
/** The registered parser, or the built-in CSV one. */
|
|
8292
|
+
read(file) {
|
|
8293
|
+
const cached = this.parsed.get(file);
|
|
8294
|
+
if (cached)
|
|
8295
|
+
return cached;
|
|
8296
|
+
// The *promise* is cached, not its result, so two calls that overlap share one parse
|
|
8297
|
+
// rather than starting a second before the first resolves.
|
|
8298
|
+
const pending = Promise.resolve((this.registered ?? defaultSheetParser)(file));
|
|
8299
|
+
this.parsed.set(file, pending);
|
|
8300
|
+
// A failed parse must not be remembered: the user may fix the file and pick it again.
|
|
8301
|
+
pending.catch(() => this.parsed.delete(file));
|
|
8302
|
+
return pending;
|
|
8303
|
+
}
|
|
8304
|
+
async preview(file, context) {
|
|
8305
|
+
const sheet = await this.read(file);
|
|
8306
|
+
const { columns } = deriveImportColumns(context.config, { lang: context.lang });
|
|
8307
|
+
return {
|
|
8308
|
+
headers: sheet.headers,
|
|
8309
|
+
sample: sheet.rows.slice(0, SAMPLE_ROWS),
|
|
8310
|
+
rowCount: sheet.rows.length,
|
|
8311
|
+
suggestion: suggestMapping(sheet.headers, columns, context.config?.entity ?? ''),
|
|
8312
|
+
};
|
|
8313
|
+
}
|
|
8314
|
+
async commit(file, plan, context) {
|
|
8315
|
+
const sheet = await this.read(file);
|
|
8316
|
+
return applyMapping(sheet.rows, plan, context.config, {
|
|
8317
|
+
lang: context.lang,
|
|
8318
|
+
rules: context.rules,
|
|
8319
|
+
lookups: context.lookups,
|
|
8320
|
+
});
|
|
8321
|
+
}
|
|
8322
|
+
async template(spec, format) {
|
|
8323
|
+
if (format === 'xlsx') {
|
|
8324
|
+
// Deliberately not a silent fallback to CSV. A file named `.xlsx` that is really CSV
|
|
8325
|
+
// opens with a warning in Excel and imports wrongly elsewhere, and the consumer would
|
|
8326
|
+
// have no idea why — where this message says exactly which seam to fill.
|
|
8327
|
+
throw new Error('This build can only write CSV templates. Register an IMPORT_TRANSPORT that writes ' +
|
|
8328
|
+
'xlsx — see provideNgxDynamicEntity({ importTransport }) — or request csv.');
|
|
8329
|
+
}
|
|
8330
|
+
// Headers only, and `spec.notes` deliberately left out of the file.
|
|
8331
|
+
//
|
|
8332
|
+
// A guidance row under the headers reads well and breaks the round trip: CSV has one
|
|
8333
|
+
// header row and no concept of a second, so re-importing the template the user filled in
|
|
8334
|
+
// would treat the guidance as a record and report a failed row 2 on every single import.
|
|
8335
|
+
// The notes are shown beside each column in the picker instead, where they can be read
|
|
8336
|
+
// without being parsed. A format with somewhere to put them — an xlsx cell comment, a
|
|
8337
|
+
// second sheet — can carry them; CSV has nowhere that is not data.
|
|
8338
|
+
const csv = toCsv(spec.columns.map(column => column.header));
|
|
8339
|
+
return new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
8340
|
+
}
|
|
8341
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: LocalImportTransport, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
8342
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: LocalImportTransport, providedIn: 'root' });
|
|
8343
|
+
}
|
|
8344
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: LocalImportTransport, decorators: [{
|
|
8345
|
+
type: Injectable,
|
|
8346
|
+
args: [{ providedIn: 'root' }]
|
|
8347
|
+
}] });
|
|
8348
|
+
|
|
8349
|
+
/**
|
|
8350
|
+
* http-import-transport.ts — the same wizard, with the work happening on a server.
|
|
8351
|
+
*
|
|
8352
|
+
* The components do not change at all: they already talk to an `ImportTransport`, which was
|
|
8353
|
+
* the point of the seam. Registering this one moves the mapping, coercion and validation from
|
|
8354
|
+
* the tab to a server that runs the *same* `@dynamic-entity/core` functions — so the reason to
|
|
8355
|
+
* do it is size, not behaviour.
|
|
8356
|
+
*
|
|
8357
|
+
* **Built on `fetch`, not `HttpClient`.** This package has no `@angular/common/http` dependency
|
|
8358
|
+
* today, and four requests is not the reason to acquire one — a consumer who wants interceptors
|
|
8359
|
+
* passes their own `fetch`, which is a smaller seam than a dependency.
|
|
8360
|
+
*/
|
|
8361
|
+
/** `Content-Type` is deliberately not settable: `fetch` must set the multipart boundary. */
|
|
8362
|
+
function resolveHeaders(options) {
|
|
8363
|
+
const headers = typeof options.headers === 'function' ? options.headers() : options.headers ?? {};
|
|
8364
|
+
const out = {};
|
|
8365
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
8366
|
+
if (name.toLowerCase() === 'content-type')
|
|
8367
|
+
continue;
|
|
8368
|
+
out[name] = value;
|
|
8369
|
+
}
|
|
8370
|
+
return out;
|
|
8371
|
+
}
|
|
8372
|
+
const trimSlash = (url) => String(url ?? '').replace(/\/+$/, '');
|
|
8373
|
+
/** Same major and minor. Two engines a patch apart are the same engine. */
|
|
8374
|
+
function sameLine(a, b) {
|
|
8375
|
+
const line = (version) => version.split('.').slice(0, 2).join('.');
|
|
8376
|
+
return line(a) === line(b);
|
|
8377
|
+
}
|
|
8378
|
+
/**
|
|
8379
|
+
* Turn a failed response into an `Error` the wizard can show.
|
|
8380
|
+
*
|
|
8381
|
+
* The server's envelope carries a code and a message it wrote itself; anything else — a proxy's
|
|
8382
|
+
* HTML error page, a network failure — has no message worth showing, so a status line is what
|
|
8383
|
+
* the user gets rather than a page of markup.
|
|
8384
|
+
*/
|
|
8385
|
+
async function failureOf(response) {
|
|
8386
|
+
let body;
|
|
8387
|
+
try {
|
|
8388
|
+
body = (await response.json());
|
|
8389
|
+
}
|
|
8390
|
+
catch {
|
|
8391
|
+
/* not our envelope */
|
|
8392
|
+
}
|
|
8393
|
+
const message = body?.error?.message;
|
|
8394
|
+
return new Error(message || `The import server answered ${response.status}.`);
|
|
8395
|
+
}
|
|
8396
|
+
/**
|
|
8397
|
+
* The field selection, as short as it can be said without changing what it means.
|
|
8398
|
+
*
|
|
8399
|
+
* Two reductions, and both are exact rather than approximate:
|
|
8400
|
+
*
|
|
8401
|
+
* - **Row numbers come off.** `buildTemplateSpec` matches a column by its ref *or* by its ref
|
|
8402
|
+
* with indices stripped, and the picker that produced this selection worked in stripped refs
|
|
8403
|
+
* to begin with — so `contacts.0.email, contacts.1.email, contacts.2.email` is the expansion
|
|
8404
|
+
* of one choice, and collapsing it back recovers exactly what the user ticked.
|
|
8405
|
+
* - **A full selection is sent as no selection at all**, because omitting `fields` already
|
|
8406
|
+
* means every column.
|
|
8407
|
+
*
|
|
8408
|
+
* Without them a wide config produced a query string of ten kilobytes or more — past the eight
|
|
8409
|
+
* nginx and Node default to — and the request most likely to hit it is "give me a template with
|
|
8410
|
+
* everything", which is the one this now sends as a bare `?format=`.
|
|
8411
|
+
*/
|
|
8412
|
+
function selectionOf(spec, context, fields) {
|
|
8413
|
+
// The caller's own selection when it gave one — the wizard does — and the expanded spec
|
|
8414
|
+
// reduced back to it otherwise, for a host calling `template` directly.
|
|
8415
|
+
const chosen = new Set(fields ?? spec.columns.map(column => stripIndices(column.ref)));
|
|
8416
|
+
const all = new Set(deriveImportColumns(context?.config, { lang: context?.lang }).columns.map(column => stripIndices(column.ref)));
|
|
8417
|
+
if (all.size && chosen.size === all.size && [...all].every(ref => chosen.has(ref)))
|
|
8418
|
+
return '';
|
|
8419
|
+
return [...chosen].join(',');
|
|
8420
|
+
}
|
|
8421
|
+
/**
|
|
8422
|
+
* Not `@Injectable`, and registered by value rather than by class.
|
|
8423
|
+
*
|
|
8424
|
+
* Its constructor takes options rather than dependencies, so there is nothing for an injector
|
|
8425
|
+
* to resolve — and a consumer who wants two of them, pointed at two backends, gets that for
|
|
8426
|
+
* free.
|
|
8427
|
+
*/
|
|
8428
|
+
class HttpImportTransport {
|
|
8429
|
+
options;
|
|
8430
|
+
constructor(options) {
|
|
8431
|
+
this.options = options;
|
|
8432
|
+
}
|
|
8433
|
+
get base() {
|
|
8434
|
+
return trimSlash(this.options.baseUrl);
|
|
8435
|
+
}
|
|
8436
|
+
get send() {
|
|
8437
|
+
// Bound rather than referenced: a bare `globalThis.fetch` passed around as a value throws
|
|
8438
|
+
// "Illegal invocation" in a browser, because it needs `window` as its receiver.
|
|
8439
|
+
return this.options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
8440
|
+
}
|
|
8441
|
+
entityOf(context) {
|
|
8442
|
+
return encodeURIComponent(context?.config?.entity ?? '');
|
|
8443
|
+
}
|
|
8444
|
+
/**
|
|
8445
|
+
* Warn when the server's engine is not the one this bundle was built against.
|
|
8446
|
+
*
|
|
8447
|
+
* The two halves are separately deployed and may differ. `MappingPlan.configVersion` catches
|
|
8448
|
+
* *config* drift and says nothing about *engine* drift, so "one engine, both sides" is only
|
|
8449
|
+
* true if something checks — and this is the something. A warning rather than a refusal: a
|
|
8450
|
+
* patch release is not a reason to stop somebody importing.
|
|
8451
|
+
*/
|
|
8452
|
+
checkEngine(version) {
|
|
8453
|
+
// Compared at major.minor. A patch difference between two deploys is the normal state of
|
|
8454
|
+
// a rolling release and says nothing about whether the engines agree; warning on it puts a
|
|
8455
|
+
// line in the console on every preview and every commit, which is how people learn to
|
|
8456
|
+
// scroll past the warning that does matter.
|
|
8457
|
+
if (!version || sameLine(version, CORE_VERSION))
|
|
8458
|
+
return;
|
|
8459
|
+
const warn = this.options.warn ?? ((message) => console.warn(message));
|
|
8460
|
+
warn(`[ngx-dynamic-entity] The import server runs @dynamic-entity/core ${version}; this app ` +
|
|
8461
|
+
`was built against ${CORE_VERSION}. The two halves of an import are supposed to be the ` +
|
|
8462
|
+
'same engine — bring them into step before trusting a difference in what they produce.');
|
|
8463
|
+
}
|
|
8464
|
+
async preview(file, context) {
|
|
8465
|
+
const body = new FormData();
|
|
8466
|
+
body.append('file', file, file.name || 'upload');
|
|
8467
|
+
const response = await this.send(`${this.base}/${this.entityOf(context)}/preview`, {
|
|
8468
|
+
method: 'POST',
|
|
8469
|
+
headers: resolveHeaders(this.options),
|
|
8470
|
+
body,
|
|
8471
|
+
});
|
|
8472
|
+
if (!response.ok)
|
|
8473
|
+
throw await failureOf(response);
|
|
8474
|
+
const preview = (await response.json());
|
|
8475
|
+
this.checkEngine(preview.engineVersion);
|
|
8476
|
+
return {
|
|
8477
|
+
headers: preview.headers ?? [],
|
|
8478
|
+
sample: preview.sample ?? [],
|
|
8479
|
+
suggestion: preview.suggestion,
|
|
8480
|
+
rowCount: preview.rowCount ?? 0,
|
|
8481
|
+
};
|
|
8482
|
+
}
|
|
8483
|
+
async commit(file, plan, context) {
|
|
8484
|
+
const body = new FormData();
|
|
8485
|
+
// **The plan first, and the file second.** The server starts importing while the file is
|
|
8486
|
+
// still arriving — that is the whole reason it exists — so the plan has to have arrived
|
|
8487
|
+
// already. `FormData` preserves append order, so this ordering is the contract.
|
|
8488
|
+
body.append('plan', JSON.stringify(plan));
|
|
8489
|
+
body.append('file', file, file.name || 'upload');
|
|
8490
|
+
const response = await this.send(`${this.base}/${this.entityOf(context)}/import`, {
|
|
8491
|
+
method: 'POST',
|
|
8492
|
+
headers: resolveHeaders(this.options),
|
|
8493
|
+
body,
|
|
8494
|
+
});
|
|
8495
|
+
if (!response.ok)
|
|
8496
|
+
throw await failureOf(response);
|
|
8497
|
+
const result = (await response.json());
|
|
8498
|
+
this.checkEngine(result.engineVersion);
|
|
8499
|
+
return {
|
|
8500
|
+
// Empty, and not because nothing was imported. The server streamed the file precisely so
|
|
8501
|
+
// that fifty thousand records never had to exist at once; sending them back would undo
|
|
8502
|
+
// that on the way out. `imported` is the count, and the records are in the consumer's
|
|
8503
|
+
// database.
|
|
8504
|
+
records: [],
|
|
8505
|
+
imported: result.imported ?? 0,
|
|
8506
|
+
skipped: result.skipped ?? 0,
|
|
8507
|
+
failed: result.failed ?? 0,
|
|
8508
|
+
errors: result.errors ?? [],
|
|
8509
|
+
errorCount: result.errorCount ?? result.errors?.length ?? 0,
|
|
8510
|
+
truncated: result.truncated ?? false,
|
|
8511
|
+
planProblems: result.planProblems ?? [],
|
|
8512
|
+
};
|
|
8513
|
+
}
|
|
8514
|
+
async template(spec, format, context, fields) {
|
|
8515
|
+
// The server rebuilds the spec from its own config; only the selection crosses the wire.
|
|
8516
|
+
// Posting a whole spec would let a client choose the headers of a file the server signs
|
|
8517
|
+
// its name to, and the server has the config anyway.
|
|
8518
|
+
const query = new URLSearchParams({ format });
|
|
8519
|
+
const selection = selectionOf(spec, context, fields);
|
|
8520
|
+
if (selection)
|
|
8521
|
+
query.set('fields', selection);
|
|
8522
|
+
const response = await this.send(`${this.base}/${this.entityOf(context)}/template?${query.toString()}`, { method: 'GET', headers: resolveHeaders(this.options) });
|
|
8523
|
+
if (!response.ok)
|
|
8524
|
+
throw await failureOf(response);
|
|
8525
|
+
return response.blob();
|
|
8526
|
+
}
|
|
8527
|
+
}
|
|
8528
|
+
/**
|
|
8529
|
+
* Register the HTTP transport.
|
|
8530
|
+
*
|
|
8531
|
+
* ```ts
|
|
8532
|
+
* provideNgxDynamicEntity({}),
|
|
8533
|
+
* provideHttpImportTransport({ baseUrl: '/api/import' }),
|
|
8534
|
+
* ```
|
|
8535
|
+
*
|
|
8536
|
+
* A plain `Provider` rather than `EnvironmentProviders`, so it composes inside a component's
|
|
8537
|
+
* or a route's `providers` array as well as at the root — which is how a consumer points one
|
|
8538
|
+
* part of an app at a different backend.
|
|
8539
|
+
*/
|
|
8540
|
+
function provideHttpImportTransport(options) {
|
|
8541
|
+
return { provide: IMPORT_TRANSPORT, useValue: new HttpImportTransport(options) };
|
|
8542
|
+
}
|
|
8543
|
+
|
|
8544
|
+
/**
|
|
8545
|
+
* The rows an import could not take, and why.
|
|
8546
|
+
*
|
|
8547
|
+
* Grouped by row rather than listed flat: a row with four problems is one row to go and fix,
|
|
8548
|
+
* and four separate lines invite the reader to think there are four. The row number is the
|
|
8549
|
+
* one the spreadsheet shows in its gutter — header counted as row 1 — because that is the
|
|
8550
|
+
* number they will scroll to.
|
|
8551
|
+
*/
|
|
8552
|
+
class ImportErrorsComponent {
|
|
8553
|
+
errors = [];
|
|
8554
|
+
language = 'en';
|
|
8555
|
+
ui = inject(UiTextService);
|
|
8556
|
+
/** Row number → its problems, in the order the rows appear in the file. */
|
|
8557
|
+
grouped() {
|
|
8558
|
+
const byRow = new Map();
|
|
8559
|
+
for (const error of this.errors) {
|
|
8560
|
+
const list = byRow.get(error.row) ?? [];
|
|
8561
|
+
list.push(error);
|
|
8562
|
+
byRow.set(error.row, list);
|
|
8563
|
+
}
|
|
8564
|
+
return [...byRow.entries()]
|
|
8565
|
+
.sort((a, b) => a[0] - b[0])
|
|
8566
|
+
.map(([row, problems]) => ({ row, problems }));
|
|
8567
|
+
}
|
|
8568
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportErrorsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8569
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportErrorsComponent, isStandalone: true, selector: "ngx-import-errors", inputs: { errors: "errors", language: "language" }, ngImport: i0, template: `
|
|
8570
|
+
@if (errors.length) {
|
|
8571
|
+
<section class="ngx-import-errors" data-testid="import-errors">
|
|
8572
|
+
<h4 class="ngx-import-errors__heading">{{ ui.text('importErrorsHeading', language) }}</h4>
|
|
8573
|
+
<table class="ngx-import-errors__table">
|
|
8574
|
+
<thead>
|
|
8575
|
+
<tr>
|
|
8576
|
+
<th scope="col">{{ ui.text('importErrorRow', language, { row: '#' }) }}</th>
|
|
8577
|
+
<th scope="col">{{ ui.text('importErrorField', language) }}</th>
|
|
8578
|
+
<th scope="col">{{ ui.text('importErrorReason', language) }}</th>
|
|
8579
|
+
</tr>
|
|
8580
|
+
</thead>
|
|
8581
|
+
<tbody>
|
|
8582
|
+
@for (group of grouped(); track group.row) {
|
|
8583
|
+
@for (problem of group.problems; track $index) {
|
|
8584
|
+
<tr [attr.data-testid]="'import-error-row-' + group.row">
|
|
8585
|
+
@if ($first) {
|
|
8586
|
+
<th scope="row" [attr.rowspan]="group.problems.length">
|
|
8587
|
+
{{ ui.text('importErrorRow', language, { row: group.row }) }}
|
|
8588
|
+
</th>
|
|
8589
|
+
}
|
|
8590
|
+
<td>{{ problem.ref }}</td>
|
|
8591
|
+
<td>{{ problem.message }}</td>
|
|
8592
|
+
</tr>
|
|
8593
|
+
}
|
|
8594
|
+
}
|
|
8595
|
+
</tbody>
|
|
8596
|
+
</table>
|
|
8597
|
+
</section>
|
|
8598
|
+
}
|
|
8599
|
+
`, isInline: true, styles: [".ngx-import-errors__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px);color:var(--ngx-color-error, #b91c1c)}.ngx-import-errors__table{width:100%;border-collapse:collapse}.ngx-import-errors__table th,.ngx-import-errors__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:top}.ngx-import-errors__table thead th{color:var(--ngx-color-muted, #6b7280)}.ngx-import-errors__table tbody th{white-space:nowrap;color:var(--ngx-color-error, #b91c1c)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
8600
|
+
}
|
|
8601
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportErrorsComponent, decorators: [{
|
|
8602
|
+
type: Component,
|
|
8603
|
+
args: [{ selector: 'ngx-import-errors', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [], template: `
|
|
8604
|
+
@if (errors.length) {
|
|
8605
|
+
<section class="ngx-import-errors" data-testid="import-errors">
|
|
8606
|
+
<h4 class="ngx-import-errors__heading">{{ ui.text('importErrorsHeading', language) }}</h4>
|
|
8607
|
+
<table class="ngx-import-errors__table">
|
|
8608
|
+
<thead>
|
|
8609
|
+
<tr>
|
|
8610
|
+
<th scope="col">{{ ui.text('importErrorRow', language, { row: '#' }) }}</th>
|
|
8611
|
+
<th scope="col">{{ ui.text('importErrorField', language) }}</th>
|
|
8612
|
+
<th scope="col">{{ ui.text('importErrorReason', language) }}</th>
|
|
8613
|
+
</tr>
|
|
8614
|
+
</thead>
|
|
8615
|
+
<tbody>
|
|
8616
|
+
@for (group of grouped(); track group.row) {
|
|
8617
|
+
@for (problem of group.problems; track $index) {
|
|
8618
|
+
<tr [attr.data-testid]="'import-error-row-' + group.row">
|
|
8619
|
+
@if ($first) {
|
|
8620
|
+
<th scope="row" [attr.rowspan]="group.problems.length">
|
|
8621
|
+
{{ ui.text('importErrorRow', language, { row: group.row }) }}
|
|
8622
|
+
</th>
|
|
8623
|
+
}
|
|
8624
|
+
<td>{{ problem.ref }}</td>
|
|
8625
|
+
<td>{{ problem.message }}</td>
|
|
8626
|
+
</tr>
|
|
8627
|
+
}
|
|
8628
|
+
}
|
|
8629
|
+
</tbody>
|
|
8630
|
+
</table>
|
|
8631
|
+
</section>
|
|
8632
|
+
}
|
|
8633
|
+
`, styles: [".ngx-import-errors__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px);color:var(--ngx-color-error, #b91c1c)}.ngx-import-errors__table{width:100%;border-collapse:collapse}.ngx-import-errors__table th,.ngx-import-errors__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:top}.ngx-import-errors__table thead th{color:var(--ngx-color-muted, #6b7280)}.ngx-import-errors__table tbody th{white-space:nowrap;color:var(--ngx-color-error, #b91c1c)}\n"] }]
|
|
8634
|
+
}], propDecorators: { errors: [{
|
|
8635
|
+
type: Input
|
|
8636
|
+
}], language: [{
|
|
8637
|
+
type: Input
|
|
8638
|
+
}] } });
|
|
8639
|
+
|
|
8640
|
+
/**
|
|
8641
|
+
* Match the sheet's columns to the config's fields.
|
|
8642
|
+
*
|
|
8643
|
+
* The table is driven by the **target fields**, not by the sheet's columns, which is the
|
|
8644
|
+
* choice that decides how the screen reads. A user is answering "where does Start Date come
|
|
8645
|
+
* from?", which they can do; column-first would ask "what is column G?", which they often
|
|
8646
|
+
* cannot, and would make a sheet with forty irrelevant columns forty questions long.
|
|
8647
|
+
*
|
|
8648
|
+
* A selection is a **column index**, never header text. Two columns headed "Notes" are two
|
|
8649
|
+
* different answers, and text cannot tell them apart.
|
|
8650
|
+
*/
|
|
8651
|
+
class ImportMapperComponent {
|
|
8652
|
+
config;
|
|
8653
|
+
headers = [];
|
|
8654
|
+
/** The starting point, normally `suggestMapping`'s output. */
|
|
8655
|
+
plan = null;
|
|
8656
|
+
language = 'en';
|
|
8657
|
+
/** Emitted whenever the user changes a selection. */
|
|
8658
|
+
planChange = new EventEmitter();
|
|
8659
|
+
ui = inject(UiTextService);
|
|
8660
|
+
rows = signal([], ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
|
|
8661
|
+
/**
|
|
8662
|
+
* How many fields a spreadsheet cannot carry at all.
|
|
8663
|
+
*
|
|
8664
|
+
* Shown here as well as in the template picker, because a user who already had a sheet
|
|
8665
|
+
* skipped that screen — and this is the only place they would otherwise find out that an
|
|
8666
|
+
* attachment column was never going to arrive.
|
|
8667
|
+
*/
|
|
8668
|
+
unsupported = signal(0, ...(ngDevMode ? [{ debugName: "unsupported" }] : /* istanbul ignore next */ []));
|
|
8669
|
+
ngOnChanges() {
|
|
8670
|
+
const { columns, unsupported } = deriveImportColumns(this.config, { lang: this.language });
|
|
8671
|
+
this.unsupported.set(unsupported.length);
|
|
8672
|
+
const byRef = new Map((this.plan?.entries ?? []).map(entry => [entry.ref, entry]));
|
|
8673
|
+
this.rows.set(columns.map(column => {
|
|
8674
|
+
const entry = byRef.get(column.ref);
|
|
8675
|
+
return {
|
|
8676
|
+
column,
|
|
8677
|
+
source: entry?.column ?? null,
|
|
8678
|
+
guessed: entry?.confidence === 'guess',
|
|
8679
|
+
};
|
|
8680
|
+
}));
|
|
8681
|
+
}
|
|
8682
|
+
mappedCount() {
|
|
8683
|
+
return this.rows().filter(row => row.source !== null).length;
|
|
8684
|
+
}
|
|
8685
|
+
missingRequired() {
|
|
8686
|
+
return this.rows().filter(row => row.column.required && row.source === null).length;
|
|
8687
|
+
}
|
|
8688
|
+
choose(row, source) {
|
|
8689
|
+
this.rows.update(rows => rows.map(candidate => candidate.column.ref === row.column.ref
|
|
8690
|
+
? // A hand-made choice is not a guess any more, so the badge goes.
|
|
8691
|
+
{ ...candidate, source, guessed: false }
|
|
8692
|
+
: candidate));
|
|
8693
|
+
this.planChange.emit(this.toPlan());
|
|
8694
|
+
}
|
|
8695
|
+
/** The current selections as the plan that will be applied. */
|
|
8696
|
+
toPlan() {
|
|
8697
|
+
const entries = this.rows()
|
|
8698
|
+
.filter(row => row.source !== null)
|
|
8699
|
+
.map(row => ({
|
|
8700
|
+
ref: row.column.ref,
|
|
8701
|
+
column: row.source,
|
|
8702
|
+
header: this.headers[row.source],
|
|
8703
|
+
confidence: row.guessed ? 'guess' : 'exact',
|
|
8704
|
+
}));
|
|
8705
|
+
return {
|
|
8706
|
+
entity: this.config?.entity ?? '',
|
|
8707
|
+
configVersion: this.config?.version,
|
|
8708
|
+
sourceHeaders: [...this.headers],
|
|
8709
|
+
entries,
|
|
8710
|
+
};
|
|
8711
|
+
}
|
|
8712
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportMapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8713
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportMapperComponent, isStandalone: true, selector: "ngx-import-mapper", inputs: { config: "config", headers: "headers", plan: "plan", language: "language" }, outputs: { planChange: "planChange" }, usesOnChanges: true, ngImport: i0, template: `
|
|
8714
|
+
<section class="ngx-import-mapper" data-testid="import-mapper">
|
|
8715
|
+
<p class="ngx-import-mapper__summary" data-testid="import-mapped-count">
|
|
8716
|
+
{{ ui.text('importMappedCount', language, { mapped: mappedCount(), total: rows().length }) }}
|
|
8717
|
+
</p>
|
|
8718
|
+
@if (missingRequired() > 0) {
|
|
8719
|
+
<p class="ngx-import-mapper__warning" data-testid="import-required-unmapped" role="status">
|
|
8720
|
+
{{ ui.text('importRequiredUnmapped', language, { count: missingRequired() }) }}
|
|
8721
|
+
</p>
|
|
8722
|
+
}
|
|
8723
|
+
@if (unsupported() > 0) {
|
|
8724
|
+
<p class="ngx-import-mapper__note" data-testid="import-mapper-unsupported">
|
|
8725
|
+
{{ ui.text('importTemplateUnsupported', language, { count: unsupported() }) }}
|
|
8726
|
+
</p>
|
|
8727
|
+
}
|
|
8728
|
+
|
|
8729
|
+
<table class="ngx-import-mapper__table">
|
|
8730
|
+
<thead>
|
|
8731
|
+
<tr>
|
|
8732
|
+
<th scope="col">{{ ui.text('importTargetField', language) }}</th>
|
|
8733
|
+
<th scope="col">{{ ui.text('importColumnInFile', language) }}</th>
|
|
8734
|
+
</tr>
|
|
8735
|
+
</thead>
|
|
8736
|
+
<tbody>
|
|
8737
|
+
@for (row of rows(); track row.column.ref) {
|
|
8738
|
+
<tr [attr.data-testid]="'import-map-' + row.column.ref">
|
|
8739
|
+
<th scope="row">
|
|
8740
|
+
{{ row.column.header }}
|
|
8741
|
+
@if (row.column.required) {
|
|
8742
|
+
<span class="ngx-import-mapper__required" aria-hidden="true">*</span>
|
|
8743
|
+
}
|
|
8744
|
+
</th>
|
|
8745
|
+
<td>
|
|
8746
|
+
<select
|
|
8747
|
+
class="ngx-import-mapper__select"
|
|
8748
|
+
[attr.data-testid]="'import-select-' + row.column.ref"
|
|
8749
|
+
[attr.aria-label]="row.column.header"
|
|
8750
|
+
[ngModel]="row.source"
|
|
8751
|
+
(ngModelChange)="choose(row, $event)"
|
|
8752
|
+
>
|
|
8753
|
+
<option [ngValue]="null">{{ ui.text('importUnmapped', language) }}</option>
|
|
8754
|
+
@for (header of headers; track $index) {
|
|
8755
|
+
<option [ngValue]="$index">{{ header || '—' }}</option>
|
|
8756
|
+
}
|
|
8757
|
+
</select>
|
|
8758
|
+
@if (row.guessed && row.source !== null) {
|
|
8759
|
+
<span class="ngx-import-mapper__guess" [attr.data-testid]="'import-guess-' + row.column.ref">
|
|
8760
|
+
{{ ui.text('importGuessed', language) }}
|
|
8761
|
+
</span>
|
|
8762
|
+
}
|
|
8763
|
+
</td>
|
|
8764
|
+
</tr>
|
|
8765
|
+
}
|
|
8766
|
+
</tbody>
|
|
8767
|
+
</table>
|
|
8768
|
+
</section>
|
|
8769
|
+
`, isInline: true, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
8770
|
+
}
|
|
8771
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportMapperComponent, decorators: [{
|
|
8772
|
+
type: Component,
|
|
8773
|
+
args: [{ selector: 'ngx-import-mapper', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule], template: `
|
|
8774
|
+
<section class="ngx-import-mapper" data-testid="import-mapper">
|
|
8775
|
+
<p class="ngx-import-mapper__summary" data-testid="import-mapped-count">
|
|
8776
|
+
{{ ui.text('importMappedCount', language, { mapped: mappedCount(), total: rows().length }) }}
|
|
8777
|
+
</p>
|
|
8778
|
+
@if (missingRequired() > 0) {
|
|
8779
|
+
<p class="ngx-import-mapper__warning" data-testid="import-required-unmapped" role="status">
|
|
8780
|
+
{{ ui.text('importRequiredUnmapped', language, { count: missingRequired() }) }}
|
|
8781
|
+
</p>
|
|
8782
|
+
}
|
|
8783
|
+
@if (unsupported() > 0) {
|
|
8784
|
+
<p class="ngx-import-mapper__note" data-testid="import-mapper-unsupported">
|
|
8785
|
+
{{ ui.text('importTemplateUnsupported', language, { count: unsupported() }) }}
|
|
8786
|
+
</p>
|
|
8787
|
+
}
|
|
8788
|
+
|
|
8789
|
+
<table class="ngx-import-mapper__table">
|
|
8790
|
+
<thead>
|
|
8791
|
+
<tr>
|
|
8792
|
+
<th scope="col">{{ ui.text('importTargetField', language) }}</th>
|
|
8793
|
+
<th scope="col">{{ ui.text('importColumnInFile', language) }}</th>
|
|
8794
|
+
</tr>
|
|
8795
|
+
</thead>
|
|
8796
|
+
<tbody>
|
|
8797
|
+
@for (row of rows(); track row.column.ref) {
|
|
8798
|
+
<tr [attr.data-testid]="'import-map-' + row.column.ref">
|
|
8799
|
+
<th scope="row">
|
|
8800
|
+
{{ row.column.header }}
|
|
8801
|
+
@if (row.column.required) {
|
|
8802
|
+
<span class="ngx-import-mapper__required" aria-hidden="true">*</span>
|
|
8803
|
+
}
|
|
8804
|
+
</th>
|
|
8805
|
+
<td>
|
|
8806
|
+
<select
|
|
8807
|
+
class="ngx-import-mapper__select"
|
|
8808
|
+
[attr.data-testid]="'import-select-' + row.column.ref"
|
|
8809
|
+
[attr.aria-label]="row.column.header"
|
|
8810
|
+
[ngModel]="row.source"
|
|
8811
|
+
(ngModelChange)="choose(row, $event)"
|
|
8812
|
+
>
|
|
8813
|
+
<option [ngValue]="null">{{ ui.text('importUnmapped', language) }}</option>
|
|
8814
|
+
@for (header of headers; track $index) {
|
|
8815
|
+
<option [ngValue]="$index">{{ header || '—' }}</option>
|
|
8816
|
+
}
|
|
8817
|
+
</select>
|
|
8818
|
+
@if (row.guessed && row.source !== null) {
|
|
8819
|
+
<span class="ngx-import-mapper__guess" [attr.data-testid]="'import-guess-' + row.column.ref">
|
|
8820
|
+
{{ ui.text('importGuessed', language) }}
|
|
8821
|
+
</span>
|
|
8822
|
+
}
|
|
8823
|
+
</td>
|
|
8824
|
+
</tr>
|
|
8825
|
+
}
|
|
8826
|
+
</tbody>
|
|
8827
|
+
</table>
|
|
8828
|
+
</section>
|
|
8829
|
+
`, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"] }]
|
|
8830
|
+
}], propDecorators: { config: [{
|
|
8831
|
+
type: Input,
|
|
8832
|
+
args: [{ required: true }]
|
|
8833
|
+
}], headers: [{
|
|
8834
|
+
type: Input
|
|
8835
|
+
}], plan: [{
|
|
8836
|
+
type: Input
|
|
8837
|
+
}], language: [{
|
|
8838
|
+
type: Input
|
|
8839
|
+
}], planChange: [{
|
|
8840
|
+
type: Output
|
|
8841
|
+
}] } });
|
|
8842
|
+
|
|
8843
|
+
/**
|
|
8844
|
+
* The first few rows as the record will hold them, not as the sheet holds them.
|
|
8845
|
+
*
|
|
8846
|
+
* Every cell goes through the same `coerceCell` the import will use and then through
|
|
8847
|
+
* `formatDisplayValue`, which is what the renderer uses to show a stored value. So a
|
|
8848
|
+
* `dropdown` cell reading `Aktiv` shows as `Active` here if that is the option it resolved
|
|
8849
|
+
* to, and a cell that will fail shows its reason before anyone commits to anything.
|
|
8850
|
+
*
|
|
8851
|
+
* Showing the raw text instead would be easier and would preview the wrong thing: the point
|
|
8852
|
+
* of this screen is to catch a mapping that is subtly wrong, and a column of unchanged text
|
|
8853
|
+
* looks correct whatever field it was pointed at.
|
|
8854
|
+
*
|
|
8855
|
+
* **The rows are also run through the real import**, not just through coercion. `coerceCell`
|
|
8856
|
+
* alone catches a cell that will not parse and says nothing about a row that will be rejected
|
|
8857
|
+
* — a missing required field, a rule that forbids the combination — so a heading promising
|
|
8858
|
+
* "as they will be saved" was showing rows that would never be saved at all, looking exactly
|
|
8859
|
+
* like the ones that would. `applyMapping` over the sample answers that with the same engine
|
|
8860
|
+
* the commit uses, rather than a second opinion about what counts as valid.
|
|
8861
|
+
*
|
|
8862
|
+
* The whole grid is built once per input change rather than from template method calls.
|
|
8863
|
+
* Deriving the config's columns inside a `@for` means re-walking the entire schema for every
|
|
8864
|
+
* cell on every change-detection pass, which is a lot of work to produce five rows.
|
|
8865
|
+
*/
|
|
8866
|
+
class ImportPreviewComponent {
|
|
8867
|
+
config;
|
|
8868
|
+
plan;
|
|
8869
|
+
rows = [];
|
|
8870
|
+
lookups;
|
|
8871
|
+
/** The same rules the commit will apply; without them a rule-driven rejection is invisible. */
|
|
8872
|
+
rules;
|
|
8873
|
+
language = 'en';
|
|
8874
|
+
ui = inject(UiTextService);
|
|
8875
|
+
/** The columns this plan fills, in the plan's own order. */
|
|
8876
|
+
columns = signal([], ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
|
|
8877
|
+
grid = signal([], ...(ngDevMode ? [{ debugName: "grid" }] : /* istanbul ignore next */ []));
|
|
8878
|
+
ngOnChanges() {
|
|
8879
|
+
const byRef = new Map(deriveImportColumns(this.config, { lang: this.language }).columns.map(c => [c.ref, c]));
|
|
8880
|
+
const entries = (this.plan?.entries ?? []).filter(entry => byRef.has(entry.ref));
|
|
8881
|
+
this.columns.set(entries.map(entry => byRef.get(entry.ref)));
|
|
8882
|
+
// The same engine the commit runs, over the sample only. Row 0 of the sample is numbered
|
|
8883
|
+
// 0 here rather than 2, because these row numbers index the preview, not the file.
|
|
8884
|
+
const dryRun = applyMapping(this.rows, this.plan, this.config, {
|
|
8885
|
+
lang: this.language,
|
|
8886
|
+
lookups: this.lookups,
|
|
8887
|
+
rules: this.rules,
|
|
8888
|
+
stamp: false,
|
|
8889
|
+
firstRowNumber: 0,
|
|
8890
|
+
});
|
|
8891
|
+
const rejectionsByRow = new Map();
|
|
8892
|
+
for (const error of dryRun.errors) {
|
|
8893
|
+
rejectionsByRow.set(error.row, [...(rejectionsByRow.get(error.row) ?? []), error.message]);
|
|
8894
|
+
}
|
|
8895
|
+
this.grid.set(this.rows.map((row, index) => ({
|
|
8896
|
+
rejections: rejectionsByRow.get(index) ?? [],
|
|
8897
|
+
cells: entries.map(entry => {
|
|
8898
|
+
const field = byRef.get(entry.ref).field;
|
|
8899
|
+
const raw = entry.column === undefined ? entry.constant : row[entry.column];
|
|
8900
|
+
const outcome = coerceCell(field, raw, {
|
|
8901
|
+
lang: this.language,
|
|
8902
|
+
lookups: this.lookups,
|
|
8903
|
+
});
|
|
8904
|
+
if ('error' in outcome)
|
|
8905
|
+
return { text: outcome.error, failed: true };
|
|
8906
|
+
return {
|
|
8907
|
+
text: formatDisplayValue(field.type, field.options, outcome.value, this.language),
|
|
8908
|
+
failed: false,
|
|
8909
|
+
};
|
|
8910
|
+
}),
|
|
8911
|
+
})));
|
|
8912
|
+
}
|
|
8913
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
8914
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportPreviewComponent, isStandalone: true, selector: "ngx-import-preview", inputs: { config: "config", plan: "plan", rows: "rows", lookups: "lookups", rules: "rules", language: "language" }, usesOnChanges: true, ngImport: i0, template: `
|
|
8915
|
+
<section class="ngx-import-preview" data-testid="import-preview">
|
|
8916
|
+
<h4 class="ngx-import-preview__heading">
|
|
8917
|
+
{{ ui.text('importPreviewHeading', language, { count: rows.length }) }}
|
|
8918
|
+
</h4>
|
|
8919
|
+
<div class="ngx-import-preview__scroll">
|
|
8920
|
+
<table class="ngx-import-preview__table">
|
|
8921
|
+
<thead>
|
|
8922
|
+
<tr>
|
|
8923
|
+
@for (column of columns(); track column.ref) {
|
|
8924
|
+
<th scope="col">{{ column.header }}</th>
|
|
8925
|
+
}
|
|
8926
|
+
</tr>
|
|
8927
|
+
</thead>
|
|
8928
|
+
<tbody>
|
|
8929
|
+
@for (row of grid(); track $index) {
|
|
8930
|
+
<tr
|
|
8931
|
+
[attr.data-testid]="'import-preview-row-' + $index"
|
|
8932
|
+
[class.ngx-import-preview__row--rejected]="row.rejections.length"
|
|
8933
|
+
>
|
|
8934
|
+
@for (cell of row.cells; track $index) {
|
|
8935
|
+
<td [class.ngx-import-preview__cell--error]="cell.failed">{{ cell.text }}</td>
|
|
8936
|
+
}
|
|
8937
|
+
</tr>
|
|
8938
|
+
@if (row.rejections.length) {
|
|
8939
|
+
<tr class="ngx-import-preview__reason">
|
|
8940
|
+
<td [attr.colspan]="columns().length" [attr.data-testid]="'import-preview-rejected-' + $index">
|
|
8941
|
+
{{ ui.text('importRowRejected', language, { reason: row.rejections.join('; ') }) }}
|
|
8942
|
+
</td>
|
|
8943
|
+
</tr>
|
|
8944
|
+
}
|
|
8945
|
+
}
|
|
8946
|
+
</tbody>
|
|
8947
|
+
</table>
|
|
8948
|
+
</div>
|
|
8949
|
+
</section>
|
|
8950
|
+
`, isInline: true, styles: [".ngx-import-preview__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px)}.ngx-import-preview__scroll{overflow-x:auto;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px)}.ngx-import-preview__table{width:100%;border-collapse:collapse;white-space:nowrap}.ngx-import-preview__table th,.ngx-import-preview__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #f3f4f6)}.ngx-import-preview__table thead th{background:var(--ngx-color-surface-alt, #f9fafb);color:var(--ngx-color-muted, #6b7280)}.ngx-import-preview__cell--error{color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}.ngx-import-preview__row--rejected td{opacity:.65}.ngx-import-preview__reason td{white-space:normal;color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
8951
|
+
}
|
|
8952
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportPreviewComponent, decorators: [{
|
|
8953
|
+
type: Component,
|
|
8954
|
+
args: [{ selector: 'ngx-import-preview', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [], template: `
|
|
8955
|
+
<section class="ngx-import-preview" data-testid="import-preview">
|
|
8956
|
+
<h4 class="ngx-import-preview__heading">
|
|
8957
|
+
{{ ui.text('importPreviewHeading', language, { count: rows.length }) }}
|
|
8958
|
+
</h4>
|
|
8959
|
+
<div class="ngx-import-preview__scroll">
|
|
8960
|
+
<table class="ngx-import-preview__table">
|
|
8961
|
+
<thead>
|
|
8962
|
+
<tr>
|
|
8963
|
+
@for (column of columns(); track column.ref) {
|
|
8964
|
+
<th scope="col">{{ column.header }}</th>
|
|
8965
|
+
}
|
|
8966
|
+
</tr>
|
|
8967
|
+
</thead>
|
|
8968
|
+
<tbody>
|
|
8969
|
+
@for (row of grid(); track $index) {
|
|
8970
|
+
<tr
|
|
8971
|
+
[attr.data-testid]="'import-preview-row-' + $index"
|
|
8972
|
+
[class.ngx-import-preview__row--rejected]="row.rejections.length"
|
|
8973
|
+
>
|
|
8974
|
+
@for (cell of row.cells; track $index) {
|
|
8975
|
+
<td [class.ngx-import-preview__cell--error]="cell.failed">{{ cell.text }}</td>
|
|
8976
|
+
}
|
|
8977
|
+
</tr>
|
|
8978
|
+
@if (row.rejections.length) {
|
|
8979
|
+
<tr class="ngx-import-preview__reason">
|
|
8980
|
+
<td [attr.colspan]="columns().length" [attr.data-testid]="'import-preview-rejected-' + $index">
|
|
8981
|
+
{{ ui.text('importRowRejected', language, { reason: row.rejections.join('; ') }) }}
|
|
8982
|
+
</td>
|
|
8983
|
+
</tr>
|
|
8984
|
+
}
|
|
8985
|
+
}
|
|
8986
|
+
</tbody>
|
|
8987
|
+
</table>
|
|
8988
|
+
</div>
|
|
8989
|
+
</section>
|
|
8990
|
+
`, styles: [".ngx-import-preview__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px)}.ngx-import-preview__scroll{overflow-x:auto;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px)}.ngx-import-preview__table{width:100%;border-collapse:collapse;white-space:nowrap}.ngx-import-preview__table th,.ngx-import-preview__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #f3f4f6)}.ngx-import-preview__table thead th{background:var(--ngx-color-surface-alt, #f9fafb);color:var(--ngx-color-muted, #6b7280)}.ngx-import-preview__cell--error{color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}.ngx-import-preview__row--rejected td{opacity:.65}.ngx-import-preview__reason td{white-space:normal;color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}\n"] }]
|
|
8991
|
+
}], propDecorators: { config: [{
|
|
8992
|
+
type: Input,
|
|
8993
|
+
args: [{ required: true }]
|
|
8994
|
+
}], plan: [{
|
|
8995
|
+
type: Input,
|
|
8996
|
+
args: [{ required: true }]
|
|
8997
|
+
}], rows: [{
|
|
8998
|
+
type: Input
|
|
8999
|
+
}], lookups: [{
|
|
9000
|
+
type: Input
|
|
9001
|
+
}], rules: [{
|
|
9002
|
+
type: Input
|
|
9003
|
+
}], language: [{
|
|
9004
|
+
type: Input
|
|
9005
|
+
}] } });
|
|
9006
|
+
|
|
9007
|
+
/**
|
|
9008
|
+
* Pick the fields a template should carry, then download it.
|
|
9009
|
+
*
|
|
9010
|
+
* The guidance for each field is shown **here**, beside the checkbox, rather than written
|
|
9011
|
+
* into the file as a second header row. CSV has one header row and no concept of a second,
|
|
9012
|
+
* so a guidance row inside the file would come back as a record on re-import and fail row 2
|
|
9013
|
+
* of every import the user ever ran.
|
|
9014
|
+
*
|
|
9015
|
+
* A repeating field is offered once, not once per row number: "Contacts / Name" is a decision,
|
|
9016
|
+
* and "Contacts / Name 1", "Contacts / Name 2", "Contacts / Name 3" is the same decision
|
|
9017
|
+
* asked three times.
|
|
9018
|
+
*/
|
|
9019
|
+
class ImportTemplateComponent {
|
|
9020
|
+
config;
|
|
9021
|
+
language = 'en';
|
|
9022
|
+
/** The refs to include, with row numbers stripped — the caller builds the spec. */
|
|
9023
|
+
download = new EventEmitter();
|
|
9024
|
+
ui = inject(UiTextService);
|
|
9025
|
+
choices = signal([], ...(ngDevMode ? [{ debugName: "choices" }] : /* istanbul ignore next */ []));
|
|
9026
|
+
unsupported = signal([], ...(ngDevMode ? [{ debugName: "unsupported" }] : /* istanbul ignore next */ []));
|
|
9027
|
+
ngOnChanges() {
|
|
9028
|
+
const spec = buildTemplateSpec(this.config, { lang: this.language });
|
|
9029
|
+
const derived = deriveImportColumns(this.config, { lang: this.language });
|
|
9030
|
+
// One entry per field, keyed on the ref with its row number removed, so a repeating
|
|
9031
|
+
// field collapses back into the single choice it is.
|
|
9032
|
+
const seen = new Map();
|
|
9033
|
+
spec.columns.forEach((column, i) => {
|
|
9034
|
+
const ref = stripIndices(column.ref);
|
|
9035
|
+
if (seen.has(ref))
|
|
9036
|
+
return;
|
|
9037
|
+
seen.set(ref, {
|
|
9038
|
+
ref,
|
|
9039
|
+
header: column.arrayIndex === undefined ? column.header : stripRowNumber(column.header),
|
|
9040
|
+
note: spec.notes[i] ?? '',
|
|
9041
|
+
selected: true,
|
|
9042
|
+
});
|
|
9043
|
+
});
|
|
9044
|
+
this.choices.set([...seen.values()]);
|
|
9045
|
+
this.unsupported.set(derived.unsupported);
|
|
9046
|
+
}
|
|
9047
|
+
selectedCount() {
|
|
9048
|
+
return this.choices().filter(choice => choice.selected).length;
|
|
9049
|
+
}
|
|
9050
|
+
selectedRefs() {
|
|
9051
|
+
return this.choices()
|
|
9052
|
+
.filter(choice => choice.selected)
|
|
9053
|
+
.map(choice => choice.ref);
|
|
9054
|
+
}
|
|
9055
|
+
toggle(ref) {
|
|
9056
|
+
this.choices.update(choices => choices.map(choice => choice.ref === ref ? { ...choice, selected: !choice.selected } : choice));
|
|
9057
|
+
}
|
|
9058
|
+
selectAll(selected) {
|
|
9059
|
+
this.choices.update(choices => choices.map(choice => ({ ...choice, selected })));
|
|
9060
|
+
}
|
|
9061
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportTemplateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
9062
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportTemplateComponent, isStandalone: true, selector: "ngx-import-template", inputs: { config: "config", language: "language" }, outputs: { download: "download" }, usesOnChanges: true, ngImport: i0, template: `
|
|
9063
|
+
<section class="ngx-import-template" data-testid="import-template">
|
|
9064
|
+
<h4 class="ngx-import-template__heading">{{ ui.text('importTemplateHeading', language) }}</h4>
|
|
9065
|
+
<p class="ngx-import-template__explain">{{ ui.text('importTemplateExplain', language) }}</p>
|
|
9066
|
+
|
|
9067
|
+
<div class="ngx-import-template__bulk">
|
|
9068
|
+
<button type="button" (click)="selectAll(true)" data-testid="import-template-all">
|
|
9069
|
+
{{ ui.text('importTemplateSelectAll', language) }}
|
|
9070
|
+
</button>
|
|
9071
|
+
<button type="button" (click)="selectAll(false)" data-testid="import-template-none">
|
|
9072
|
+
{{ ui.text('importTemplateSelectNone', language) }}
|
|
9073
|
+
</button>
|
|
9074
|
+
</div>
|
|
9075
|
+
|
|
9076
|
+
<ul class="ngx-import-template__list">
|
|
9077
|
+
@for (choice of choices(); track choice.ref) {
|
|
9078
|
+
<li class="ngx-import-template__item">
|
|
9079
|
+
<label>
|
|
9080
|
+
<input
|
|
9081
|
+
type="checkbox"
|
|
9082
|
+
[checked]="choice.selected"
|
|
9083
|
+
(change)="toggle(choice.ref)"
|
|
9084
|
+
[attr.data-testid]="'import-template-field-' + choice.ref"
|
|
9085
|
+
/>
|
|
9086
|
+
<span class="ngx-import-template__label">{{ choice.header }}</span>
|
|
9087
|
+
</label>
|
|
9088
|
+
@if (choice.note) {
|
|
9089
|
+
<span class="ngx-import-template__note">{{ choice.note }}</span>
|
|
9090
|
+
}
|
|
9091
|
+
</li>
|
|
9092
|
+
}
|
|
9093
|
+
</ul>
|
|
9094
|
+
|
|
9095
|
+
@if (unsupported().length) {
|
|
9096
|
+
<p class="ngx-import-template__unsupported" data-testid="import-template-unsupported">
|
|
9097
|
+
{{ ui.text('importTemplateUnsupported', language, { count: unsupported().length }) }}
|
|
9098
|
+
</p>
|
|
9099
|
+
}
|
|
9100
|
+
|
|
9101
|
+
<button
|
|
9102
|
+
type="button"
|
|
9103
|
+
class="ngx-import-template__download"
|
|
9104
|
+
data-testid="import-template-download"
|
|
9105
|
+
[disabled]="!selectedCount()"
|
|
9106
|
+
(click)="download.emit(selectedRefs())"
|
|
9107
|
+
>
|
|
9108
|
+
{{ ui.text('importTemplateDownload', language) }}
|
|
9109
|
+
</button>
|
|
9110
|
+
</section>
|
|
9111
|
+
`, isInline: true, styles: [".ngx-import-template{padding:14px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import-template__heading{margin:0 0 4px;font-size:var(--ngx-font-size, 14px)}.ngx-import-template__explain{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template__bulk{display:flex;gap:8px;margin-bottom:10px}.ngx-import-template__list{list-style:none;margin:0 0 10px;padding:0;max-height:240px;overflow-y:auto;display:grid;gap:4px}.ngx-import-template__item{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}.ngx-import-template__item label{display:flex;align-items:center;gap:6px;cursor:pointer}.ngx-import-template__note{color:var(--ngx-color-muted, #6b7280);font-size:12px}.ngx-import-template__unsupported{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template button{padding:6px 12px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import-template button:disabled{opacity:.55;cursor:not-allowed}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
9112
|
+
}
|
|
9113
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportTemplateComponent, decorators: [{
|
|
9114
|
+
type: Component,
|
|
9115
|
+
args: [{ selector: 'ngx-import-template', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [], template: `
|
|
9116
|
+
<section class="ngx-import-template" data-testid="import-template">
|
|
9117
|
+
<h4 class="ngx-import-template__heading">{{ ui.text('importTemplateHeading', language) }}</h4>
|
|
9118
|
+
<p class="ngx-import-template__explain">{{ ui.text('importTemplateExplain', language) }}</p>
|
|
9119
|
+
|
|
9120
|
+
<div class="ngx-import-template__bulk">
|
|
9121
|
+
<button type="button" (click)="selectAll(true)" data-testid="import-template-all">
|
|
9122
|
+
{{ ui.text('importTemplateSelectAll', language) }}
|
|
9123
|
+
</button>
|
|
9124
|
+
<button type="button" (click)="selectAll(false)" data-testid="import-template-none">
|
|
9125
|
+
{{ ui.text('importTemplateSelectNone', language) }}
|
|
9126
|
+
</button>
|
|
9127
|
+
</div>
|
|
9128
|
+
|
|
9129
|
+
<ul class="ngx-import-template__list">
|
|
9130
|
+
@for (choice of choices(); track choice.ref) {
|
|
9131
|
+
<li class="ngx-import-template__item">
|
|
9132
|
+
<label>
|
|
9133
|
+
<input
|
|
9134
|
+
type="checkbox"
|
|
9135
|
+
[checked]="choice.selected"
|
|
9136
|
+
(change)="toggle(choice.ref)"
|
|
9137
|
+
[attr.data-testid]="'import-template-field-' + choice.ref"
|
|
9138
|
+
/>
|
|
9139
|
+
<span class="ngx-import-template__label">{{ choice.header }}</span>
|
|
9140
|
+
</label>
|
|
9141
|
+
@if (choice.note) {
|
|
9142
|
+
<span class="ngx-import-template__note">{{ choice.note }}</span>
|
|
9143
|
+
}
|
|
9144
|
+
</li>
|
|
9145
|
+
}
|
|
9146
|
+
</ul>
|
|
9147
|
+
|
|
9148
|
+
@if (unsupported().length) {
|
|
9149
|
+
<p class="ngx-import-template__unsupported" data-testid="import-template-unsupported">
|
|
9150
|
+
{{ ui.text('importTemplateUnsupported', language, { count: unsupported().length }) }}
|
|
9151
|
+
</p>
|
|
9152
|
+
}
|
|
9153
|
+
|
|
9154
|
+
<button
|
|
9155
|
+
type="button"
|
|
9156
|
+
class="ngx-import-template__download"
|
|
9157
|
+
data-testid="import-template-download"
|
|
9158
|
+
[disabled]="!selectedCount()"
|
|
9159
|
+
(click)="download.emit(selectedRefs())"
|
|
9160
|
+
>
|
|
9161
|
+
{{ ui.text('importTemplateDownload', language) }}
|
|
9162
|
+
</button>
|
|
9163
|
+
</section>
|
|
9164
|
+
`, styles: [".ngx-import-template{padding:14px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import-template__heading{margin:0 0 4px;font-size:var(--ngx-font-size, 14px)}.ngx-import-template__explain{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template__bulk{display:flex;gap:8px;margin-bottom:10px}.ngx-import-template__list{list-style:none;margin:0 0 10px;padding:0;max-height:240px;overflow-y:auto;display:grid;gap:4px}.ngx-import-template__item{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}.ngx-import-template__item label{display:flex;align-items:center;gap:6px;cursor:pointer}.ngx-import-template__note{color:var(--ngx-color-muted, #6b7280);font-size:12px}.ngx-import-template__unsupported{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template button{padding:6px 12px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import-template button:disabled{opacity:.55;cursor:not-allowed}\n"] }]
|
|
9165
|
+
}], propDecorators: { config: [{
|
|
9166
|
+
type: Input,
|
|
9167
|
+
args: [{ required: true }]
|
|
9168
|
+
}], language: [{
|
|
9169
|
+
type: Input
|
|
9170
|
+
}], download: [{
|
|
9171
|
+
type: Output
|
|
9172
|
+
}] } });
|
|
9173
|
+
/** "Contacts / Name 1" → "Contacts / Name", for the collapsed choice. */
|
|
9174
|
+
function stripRowNumber(header) {
|
|
9175
|
+
return header.replace(/ \d+$/, '');
|
|
9176
|
+
}
|
|
9177
|
+
|
|
9178
|
+
/**
|
|
9179
|
+
* The import wizard: choose a file, match its columns, look at what that produced, commit.
|
|
9180
|
+
*
|
|
9181
|
+
* It holds no import logic of its own. Everything it does goes through an `ImportTransport`,
|
|
9182
|
+
* which by default is the in-browser one built on `@dynamic-entity/core` — so the wizard
|
|
9183
|
+
* works with no backend, and a consumer who registers a server transport gets the same
|
|
9184
|
+
* screens over the same mapping rules rather than a second implementation of them.
|
|
9185
|
+
*
|
|
9186
|
+
* The review step exists because the expensive mistake here is not a file that fails to
|
|
9187
|
+
* import, it is one that imports *successfully into the wrong fields*. That is invisible in a
|
|
9188
|
+
* summary and obvious in five rendered rows.
|
|
9189
|
+
*/
|
|
9190
|
+
class EntityImportComponent {
|
|
9191
|
+
config;
|
|
9192
|
+
/**
|
|
9193
|
+
* The rules the form is rendered with.
|
|
9194
|
+
*
|
|
9195
|
+
* Pass them, or the import checks field validators only — which is not what the form
|
|
9196
|
+
* enforces, and a required field hidden by a rule will fail every row.
|
|
9197
|
+
*/
|
|
9198
|
+
rules;
|
|
9199
|
+
/**
|
|
9200
|
+
* Named lists, overriding what the wizard already resolves from `LOOKUP_REGISTRY`.
|
|
9201
|
+
*
|
|
9202
|
+
* Not something a host has to supply: every `listName` a config mentions is loaded from the
|
|
9203
|
+
* registry the renderer is already holding. Pass this only to override one.
|
|
9204
|
+
*/
|
|
9205
|
+
lookups;
|
|
9206
|
+
language = 'en';
|
|
9207
|
+
/** Format asked of the transport when a template is downloaded. */
|
|
9208
|
+
templateFormat = 'csv';
|
|
9209
|
+
/** The finished import, once it has run. */
|
|
9210
|
+
importComplete = new EventEmitter();
|
|
9211
|
+
/** A template the transport produced, for a host that would rather save it its own way. */
|
|
9212
|
+
templateReady = new EventEmitter();
|
|
9213
|
+
ui = inject(UiTextService);
|
|
9214
|
+
steps = ['upload', 'map', 'review', 'done'];
|
|
9215
|
+
step = signal('upload', ...(ngDevMode ? [{ debugName: "step" }] : /* istanbul ignore next */ []));
|
|
9216
|
+
busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
|
|
9217
|
+
problem = signal(null, ...(ngDevMode ? [{ debugName: "problem" }] : /* istanbul ignore next */ []));
|
|
9218
|
+
headers = signal([], ...(ngDevMode ? [{ debugName: "headers" }] : /* istanbul ignore next */ []));
|
|
9219
|
+
sample = signal([], ...(ngDevMode ? [{ debugName: "sample" }] : /* istanbul ignore next */ []));
|
|
9220
|
+
rowCount = signal(0, ...(ngDevMode ? [{ debugName: "rowCount" }] : /* istanbul ignore next */ []));
|
|
9221
|
+
fileName = signal('', ...(ngDevMode ? [{ debugName: "fileName" }] : /* istanbul ignore next */ []));
|
|
9222
|
+
plan = signal(null, ...(ngDevMode ? [{ debugName: "plan" }] : /* istanbul ignore next */ []));
|
|
9223
|
+
result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
|
|
9224
|
+
registered = inject(IMPORT_TRANSPORT, { optional: true });
|
|
9225
|
+
local = inject(LocalImportTransport);
|
|
9226
|
+
lookupRegistry = inject(LookupRegistryService);
|
|
9227
|
+
file = null;
|
|
9228
|
+
/** Named lists resolved from `LOOKUP_REGISTRY`, merged under the `lookups` input. */
|
|
9229
|
+
resolvedLookups = signal({}, ...(ngDevMode ? [{ debugName: "resolvedLookups" }] : /* istanbul ignore next */ []));
|
|
9230
|
+
get transport() {
|
|
9231
|
+
return this.registered ?? this.local;
|
|
9232
|
+
}
|
|
9233
|
+
context() {
|
|
9234
|
+
return {
|
|
9235
|
+
config: this.config,
|
|
9236
|
+
rules: this.rules,
|
|
9237
|
+
lookups: this.resolvedLookups(),
|
|
9238
|
+
lang: this.language,
|
|
9239
|
+
};
|
|
9240
|
+
}
|
|
9241
|
+
/**
|
|
9242
|
+
* Load every named list this config's fields refer to.
|
|
9243
|
+
*
|
|
9244
|
+
* `@dynamic-entity/core` is framework-agnostic and cannot reach `LOOKUP_REGISTRY`, so
|
|
9245
|
+
* `coerceCell` takes the values as an argument. That is a reason for *core* to ask, not a
|
|
9246
|
+
* reason for the host to be asked — this package is holding the registry, and making the
|
|
9247
|
+
* consumer re-supply what the library already has is how a `listName` column ends up
|
|
9248
|
+
* storing the raw text `"Gold"` instead of the option object. Such a record renders
|
|
9249
|
+
* correctly and then matches no rule, which is the exact failure the option-shape contract
|
|
9250
|
+
* exists to prevent.
|
|
9251
|
+
*
|
|
9252
|
+
* The `lookups` input still wins where it names a list, so a host can override one without
|
|
9253
|
+
* having to provide all of them.
|
|
9254
|
+
*/
|
|
9255
|
+
async loadLookups() {
|
|
9256
|
+
const fromRegistry = {};
|
|
9257
|
+
const names = new Set();
|
|
9258
|
+
for (const target of collectLeafTargets(this.config)) {
|
|
9259
|
+
if (target.field.listName)
|
|
9260
|
+
names.add(target.field.listName);
|
|
9261
|
+
}
|
|
9262
|
+
for (const listName of names) {
|
|
9263
|
+
// A list that fails to load is not a reason to refuse the import: the field falls back
|
|
9264
|
+
// to passing its text through, which is what happens today for an unregistered list.
|
|
9265
|
+
try {
|
|
9266
|
+
const options = await this.lookupRegistry.resolveOptions({ id: listName, type: 'dropdown', label: {}, listName }, this.language);
|
|
9267
|
+
if (options.length)
|
|
9268
|
+
fromRegistry[listName] = options;
|
|
9269
|
+
}
|
|
9270
|
+
catch {
|
|
9271
|
+
/* left out, so the field passes its text through */
|
|
9272
|
+
}
|
|
9273
|
+
}
|
|
9274
|
+
this.resolvedLookups.set({ ...fromRegistry, ...(this.lookups ?? {}) });
|
|
9275
|
+
}
|
|
9276
|
+
stepLabel(step) {
|
|
9277
|
+
// One key per step rather than an index, so a translator sees words and not positions.
|
|
9278
|
+
const keys = {
|
|
9279
|
+
upload: 'importUpload',
|
|
9280
|
+
map: 'importMap',
|
|
9281
|
+
review: 'importReview',
|
|
9282
|
+
done: 'importDone',
|
|
9283
|
+
};
|
|
9284
|
+
return this.ui.text(keys[step], this.language);
|
|
9285
|
+
}
|
|
9286
|
+
async chooseFile(event) {
|
|
9287
|
+
const input = event.target;
|
|
9288
|
+
const file = input.files?.[0] ?? null;
|
|
9289
|
+
if (!file) {
|
|
9290
|
+
this.problem.set(this.ui.text('importNeedFile', this.language));
|
|
9291
|
+
return;
|
|
9292
|
+
}
|
|
9293
|
+
this.problem.set(null);
|
|
9294
|
+
this.busy.set(true);
|
|
9295
|
+
try {
|
|
9296
|
+
await this.loadLookups();
|
|
9297
|
+
const preview = await this.transport.preview(file, this.context());
|
|
9298
|
+
// Assigned only once the file has been read: holding a file whose headers describe the
|
|
9299
|
+
// previous one is an invariant worth not having to reason about.
|
|
9300
|
+
this.file = file;
|
|
9301
|
+
this.fileName.set(file.name);
|
|
9302
|
+
this.headers.set(preview.headers);
|
|
9303
|
+
this.sample.set(preview.sample);
|
|
9304
|
+
this.rowCount.set(preview.rowCount);
|
|
9305
|
+
this.plan.set(preview.suggestion);
|
|
9306
|
+
this.step.set('map');
|
|
9307
|
+
}
|
|
9308
|
+
catch (error) {
|
|
9309
|
+
// A parser refusing a format, or a file that is not what it claims, is ordinary user
|
|
9310
|
+
// input rather than a defect — it is reported where the user is looking.
|
|
9311
|
+
this.problem.set(messageOf(error));
|
|
9312
|
+
}
|
|
9313
|
+
finally {
|
|
9314
|
+
this.busy.set(false);
|
|
9315
|
+
}
|
|
9316
|
+
}
|
|
9317
|
+
async commit() {
|
|
9318
|
+
const plan = this.plan();
|
|
9319
|
+
if (!this.file || !plan) {
|
|
9320
|
+
this.problem.set(this.ui.text('importNeedFile', this.language));
|
|
9321
|
+
return;
|
|
9322
|
+
}
|
|
9323
|
+
this.busy.set(true);
|
|
9324
|
+
this.problem.set(null);
|
|
9325
|
+
try {
|
|
9326
|
+
const result = await this.transport.commit(this.file, plan, this.context());
|
|
9327
|
+
// A plan-level error means nothing was imported, and saying "imported 0 records" would
|
|
9328
|
+
// describe that as a successful run of an empty file.
|
|
9329
|
+
const fatal = result.planProblems?.filter(p => p.level === 'error') ?? [];
|
|
9330
|
+
if (fatal.length) {
|
|
9331
|
+
this.problem.set(this.ui.text('importPlanProblem', this.language, {
|
|
9332
|
+
message: formatConfigProblems(fatal),
|
|
9333
|
+
}));
|
|
9334
|
+
this.step.set('map');
|
|
9335
|
+
return;
|
|
9336
|
+
}
|
|
9337
|
+
this.result.set(result);
|
|
9338
|
+
this.step.set('done');
|
|
9339
|
+
this.importComplete.emit(result);
|
|
9340
|
+
}
|
|
9341
|
+
catch (error) {
|
|
9342
|
+
this.problem.set(messageOf(error));
|
|
9343
|
+
}
|
|
9344
|
+
finally {
|
|
9345
|
+
this.busy.set(false);
|
|
9346
|
+
}
|
|
9347
|
+
}
|
|
9348
|
+
async downloadTemplate(fields) {
|
|
9349
|
+
this.problem.set(null);
|
|
9350
|
+
try {
|
|
9351
|
+
const spec = buildTemplateSpec(this.config, { lang: this.language, fields });
|
|
9352
|
+
// The selection as well as the spec. A server transport needs the choice, not the
|
|
9353
|
+
// expansion of it — and recovering one from the other is a round trip that only works
|
|
9354
|
+
// while the expansion stays exactly invertible.
|
|
9355
|
+
const blob = await this.transport.template(spec, this.templateFormat, this.context(), fields);
|
|
9356
|
+
this.templateReady.emit(blob);
|
|
9357
|
+
saveBlob(blob, `${this.config?.entity || 'import'}-template.${this.templateFormat}`);
|
|
9358
|
+
}
|
|
9359
|
+
catch (error) {
|
|
9360
|
+
this.problem.set(messageOf(error));
|
|
9361
|
+
}
|
|
9362
|
+
}
|
|
9363
|
+
/**
|
|
9364
|
+
* How many records were written.
|
|
9365
|
+
*
|
|
9366
|
+
* `records.length` for the in-browser transport, which returns every record it built, and
|
|
9367
|
+
* `imported` for a server one, which streamed the file precisely so that fifty thousand
|
|
9368
|
+
* records never had to exist at once and therefore has a number rather than a list. Reading
|
|
9369
|
+
* `records.length` alone would report a successful import of nothing.
|
|
9370
|
+
*/
|
|
9371
|
+
importedCount(result) {
|
|
9372
|
+
return result.imported ?? result.records.length;
|
|
9373
|
+
}
|
|
9374
|
+
/**
|
|
9375
|
+
* How many distinct rows failed, not how many problems they had between them.
|
|
9376
|
+
*
|
|
9377
|
+
* `failed` when the transport reports it, and the distinct rows in `errors` only when it does
|
|
9378
|
+
* not. Counting the errors alone was wrong the moment a transport started capping them: a run
|
|
9379
|
+
* of a thousand rows where two hundred failed said **seven**, because seven was how many
|
|
9380
|
+
* distinct rows fitted inside the first twenty retained problems. This is the number a user
|
|
9381
|
+
* acts on — which rows do I go and fix — so a confident wrong answer is the worst of the
|
|
9382
|
+
* available ones. The in-browser transport returns every error, so counting them there is
|
|
9383
|
+
* exact and `failed` is absent.
|
|
9384
|
+
*/
|
|
9385
|
+
failedRowCount(result) {
|
|
9386
|
+
return result.failed ?? new Set(result.errors.map(error => error.row)).size;
|
|
9387
|
+
}
|
|
9388
|
+
restart() {
|
|
9389
|
+
this.file = null;
|
|
9390
|
+
this.fileName.set('');
|
|
9391
|
+
this.headers.set([]);
|
|
9392
|
+
this.sample.set([]);
|
|
9393
|
+
this.rowCount.set(0);
|
|
9394
|
+
this.plan.set(null);
|
|
9395
|
+
this.result.set(null);
|
|
9396
|
+
this.problem.set(null);
|
|
9397
|
+
this.step.set('upload');
|
|
9398
|
+
}
|
|
9399
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityImportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
9400
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EntityImportComponent, isStandalone: true, selector: "ngx-entity-import", inputs: { config: "config", rules: "rules", lookups: "lookups", language: "language", templateFormat: "templateFormat" }, outputs: { importComplete: "importComplete", templateReady: "templateReady" }, ngImport: i0, template: `
|
|
9401
|
+
<div class="ngx-import" data-testid="entity-import">
|
|
9402
|
+
<ol class="ngx-import__steps" data-testid="import-steps">
|
|
9403
|
+
@for (name of steps; track name) {
|
|
9404
|
+
<li
|
|
9405
|
+
class="ngx-import__step"
|
|
9406
|
+
[class.ngx-import__step--current]="step() === name"
|
|
9407
|
+
[attr.aria-current]="step() === name ? 'step' : null"
|
|
9408
|
+
[attr.data-testid]="'import-step-' + name"
|
|
9409
|
+
>
|
|
9410
|
+
{{ stepLabel(name) }}
|
|
9411
|
+
</li>
|
|
9412
|
+
}
|
|
9413
|
+
</ol>
|
|
9414
|
+
|
|
9415
|
+
@if (problem(); as text) {
|
|
9416
|
+
<p class="ngx-import__problem" role="alert" data-testid="import-problem">{{ text }}</p>
|
|
9417
|
+
}
|
|
9418
|
+
|
|
9419
|
+
@switch (step()) {
|
|
9420
|
+
@case ('upload') {
|
|
9421
|
+
<ngx-import-template
|
|
9422
|
+
[config]="config"
|
|
9423
|
+
[language]="language"
|
|
9424
|
+
(download)="downloadTemplate($event)"
|
|
9425
|
+
/>
|
|
9426
|
+
|
|
9427
|
+
<label class="ngx-import__file">
|
|
9428
|
+
<span>{{ ui.text('importChooseFile', language) }}</span>
|
|
9429
|
+
<input type="file" data-testid="import-file" (change)="chooseFile($event)" />
|
|
9430
|
+
</label>
|
|
9431
|
+
|
|
9432
|
+
@if (busy()) {
|
|
9433
|
+
<p data-testid="import-reading">{{ ui.text('importReading', language) }}</p>
|
|
9434
|
+
}
|
|
9435
|
+
@if (fileName()) {
|
|
9436
|
+
<p data-testid="import-file-chosen">
|
|
9437
|
+
{{ ui.text('importFileChosen', language, { name: fileName(), rows: rowCount() }) }}
|
|
9438
|
+
</p>
|
|
9439
|
+
}
|
|
9440
|
+
}
|
|
9441
|
+
|
|
9442
|
+
@case ('map') {
|
|
9443
|
+
<ngx-import-mapper
|
|
9444
|
+
[config]="config"
|
|
9445
|
+
[headers]="headers()"
|
|
9446
|
+
[plan]="plan()"
|
|
9447
|
+
[language]="language"
|
|
9448
|
+
(planChange)="plan.set($event)"
|
|
9449
|
+
/>
|
|
9450
|
+
<button type="button" data-testid="import-to-review" (click)="step.set('review')">
|
|
9451
|
+
{{ ui.text('importReview', language) }}
|
|
9452
|
+
</button>
|
|
9453
|
+
}
|
|
9454
|
+
|
|
9455
|
+
@case ('review') {
|
|
9456
|
+
@if (plan(); as current) {
|
|
9457
|
+
<ngx-import-preview
|
|
9458
|
+
[config]="config"
|
|
9459
|
+
[plan]="current"
|
|
9460
|
+
[rows]="sample()"
|
|
9461
|
+
[lookups]="resolvedLookups()"
|
|
9462
|
+
[rules]="rules"
|
|
9463
|
+
[language]="language"
|
|
9464
|
+
/>
|
|
9465
|
+
}
|
|
9466
|
+
<button
|
|
9467
|
+
type="button"
|
|
9468
|
+
data-testid="import-commit"
|
|
9469
|
+
[disabled]="busy()"
|
|
9470
|
+
(click)="commit()"
|
|
9471
|
+
>
|
|
9472
|
+
{{
|
|
9473
|
+
busy()
|
|
9474
|
+
? ui.text('importImporting', language)
|
|
9475
|
+
: ui.text('importRunImport', language, { count: rowCount() })
|
|
9476
|
+
}}
|
|
9477
|
+
</button>
|
|
9478
|
+
<button type="button" data-testid="import-back" (click)="step.set('map')">
|
|
9479
|
+
{{ ui.text('importMap', language) }}
|
|
9480
|
+
</button>
|
|
9481
|
+
}
|
|
9482
|
+
|
|
9483
|
+
@case ('done') {
|
|
9484
|
+
@if (result(); as done) {
|
|
9485
|
+
<p data-testid="import-succeeded">
|
|
9486
|
+
{{ ui.text('importSucceeded', language, { count: importedCount(done) }) }}
|
|
9487
|
+
</p>
|
|
9488
|
+
@if (done.skipped) {
|
|
9489
|
+
<p data-testid="import-skipped">
|
|
9490
|
+
{{ ui.text('importSkippedRows', language, { count: done.skipped }) }}
|
|
9491
|
+
</p>
|
|
9492
|
+
}
|
|
9493
|
+
@if (failedRowCount(done)) {
|
|
9494
|
+
<p data-testid="import-failed">
|
|
9495
|
+
{{ ui.text('importFailedRows', language, { count: failedRowCount(done) }) }}
|
|
9496
|
+
</p>
|
|
9497
|
+
}
|
|
9498
|
+
@if (done.truncated) {
|
|
9499
|
+
<p data-testid="import-errors-truncated">
|
|
9500
|
+
{{
|
|
9501
|
+
ui.text('importErrorsTruncated', language, {
|
|
9502
|
+
shown: done.errors.length,
|
|
9503
|
+
count: done.errorCount ?? done.errors.length,
|
|
9504
|
+
})
|
|
9505
|
+
}}
|
|
9506
|
+
</p>
|
|
9507
|
+
}
|
|
9508
|
+
<ngx-import-errors [errors]="done.errors" [language]="language" />
|
|
9509
|
+
}
|
|
9510
|
+
<button type="button" data-testid="import-restart" (click)="restart()">
|
|
9511
|
+
{{ ui.text('importStartOver', language) }}
|
|
9512
|
+
</button>
|
|
9513
|
+
}
|
|
9514
|
+
}
|
|
9515
|
+
</div>
|
|
9516
|
+
`, isInline: true, styles: [".ngx-import{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);color:var(--ngx-color-text, #1f2937);font-size:var(--ngx-font-size, 14px)}.ngx-import__steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}.ngx-import__step{padding:4px 12px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);color:var(--ngx-color-muted, #6b7280);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import__step--current{color:var(--ngx-color-accent, #4f46e5);border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff);font-weight:600}.ngx-import__problem{margin:0;padding:10px 12px;border:1px solid var(--ngx-color-border, #fecaca);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-error-soft, #fef2f2);color:var(--ngx-color-error, #b91c1c);overflow-wrap:anywhere}.ngx-import__file{display:flex;flex-direction:column;gap:6px;padding:14px;border:1px dashed var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff)}.ngx-import button{align-self:flex-start;padding:8px 16px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import button:hover:not(:disabled){border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff)}.ngx-import button:disabled{opacity:.55;cursor:not-allowed}\n"], dependencies: [{ kind: "component", type: ImportTemplateComponent, selector: "ngx-import-template", inputs: ["config", "language"], outputs: ["download"] }, { kind: "component", type: ImportMapperComponent, selector: "ngx-import-mapper", inputs: ["config", "headers", "plan", "language"], outputs: ["planChange"] }, { kind: "component", type: ImportPreviewComponent, selector: "ngx-import-preview", inputs: ["config", "plan", "rows", "lookups", "rules", "language"] }, { kind: "component", type: ImportErrorsComponent, selector: "ngx-import-errors", inputs: ["errors", "language"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
9517
|
+
}
|
|
9518
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityImportComponent, decorators: [{
|
|
9519
|
+
type: Component,
|
|
9520
|
+
args: [{ selector: 'ngx-entity-import', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [
|
|
9521
|
+
ImportTemplateComponent,
|
|
9522
|
+
ImportMapperComponent,
|
|
9523
|
+
ImportPreviewComponent,
|
|
9524
|
+
ImportErrorsComponent,
|
|
9525
|
+
], template: `
|
|
9526
|
+
<div class="ngx-import" data-testid="entity-import">
|
|
9527
|
+
<ol class="ngx-import__steps" data-testid="import-steps">
|
|
9528
|
+
@for (name of steps; track name) {
|
|
9529
|
+
<li
|
|
9530
|
+
class="ngx-import__step"
|
|
9531
|
+
[class.ngx-import__step--current]="step() === name"
|
|
9532
|
+
[attr.aria-current]="step() === name ? 'step' : null"
|
|
9533
|
+
[attr.data-testid]="'import-step-' + name"
|
|
9534
|
+
>
|
|
9535
|
+
{{ stepLabel(name) }}
|
|
9536
|
+
</li>
|
|
9537
|
+
}
|
|
9538
|
+
</ol>
|
|
9539
|
+
|
|
9540
|
+
@if (problem(); as text) {
|
|
9541
|
+
<p class="ngx-import__problem" role="alert" data-testid="import-problem">{{ text }}</p>
|
|
9542
|
+
}
|
|
9543
|
+
|
|
9544
|
+
@switch (step()) {
|
|
9545
|
+
@case ('upload') {
|
|
9546
|
+
<ngx-import-template
|
|
9547
|
+
[config]="config"
|
|
9548
|
+
[language]="language"
|
|
9549
|
+
(download)="downloadTemplate($event)"
|
|
9550
|
+
/>
|
|
9551
|
+
|
|
9552
|
+
<label class="ngx-import__file">
|
|
9553
|
+
<span>{{ ui.text('importChooseFile', language) }}</span>
|
|
9554
|
+
<input type="file" data-testid="import-file" (change)="chooseFile($event)" />
|
|
9555
|
+
</label>
|
|
9556
|
+
|
|
9557
|
+
@if (busy()) {
|
|
9558
|
+
<p data-testid="import-reading">{{ ui.text('importReading', language) }}</p>
|
|
9559
|
+
}
|
|
9560
|
+
@if (fileName()) {
|
|
9561
|
+
<p data-testid="import-file-chosen">
|
|
9562
|
+
{{ ui.text('importFileChosen', language, { name: fileName(), rows: rowCount() }) }}
|
|
9563
|
+
</p>
|
|
9564
|
+
}
|
|
9565
|
+
}
|
|
9566
|
+
|
|
9567
|
+
@case ('map') {
|
|
9568
|
+
<ngx-import-mapper
|
|
9569
|
+
[config]="config"
|
|
9570
|
+
[headers]="headers()"
|
|
9571
|
+
[plan]="plan()"
|
|
9572
|
+
[language]="language"
|
|
9573
|
+
(planChange)="plan.set($event)"
|
|
9574
|
+
/>
|
|
9575
|
+
<button type="button" data-testid="import-to-review" (click)="step.set('review')">
|
|
9576
|
+
{{ ui.text('importReview', language) }}
|
|
9577
|
+
</button>
|
|
9578
|
+
}
|
|
9579
|
+
|
|
9580
|
+
@case ('review') {
|
|
9581
|
+
@if (plan(); as current) {
|
|
9582
|
+
<ngx-import-preview
|
|
9583
|
+
[config]="config"
|
|
9584
|
+
[plan]="current"
|
|
9585
|
+
[rows]="sample()"
|
|
9586
|
+
[lookups]="resolvedLookups()"
|
|
9587
|
+
[rules]="rules"
|
|
9588
|
+
[language]="language"
|
|
9589
|
+
/>
|
|
9590
|
+
}
|
|
9591
|
+
<button
|
|
9592
|
+
type="button"
|
|
9593
|
+
data-testid="import-commit"
|
|
9594
|
+
[disabled]="busy()"
|
|
9595
|
+
(click)="commit()"
|
|
9596
|
+
>
|
|
9597
|
+
{{
|
|
9598
|
+
busy()
|
|
9599
|
+
? ui.text('importImporting', language)
|
|
9600
|
+
: ui.text('importRunImport', language, { count: rowCount() })
|
|
9601
|
+
}}
|
|
9602
|
+
</button>
|
|
9603
|
+
<button type="button" data-testid="import-back" (click)="step.set('map')">
|
|
9604
|
+
{{ ui.text('importMap', language) }}
|
|
9605
|
+
</button>
|
|
9606
|
+
}
|
|
9607
|
+
|
|
9608
|
+
@case ('done') {
|
|
9609
|
+
@if (result(); as done) {
|
|
9610
|
+
<p data-testid="import-succeeded">
|
|
9611
|
+
{{ ui.text('importSucceeded', language, { count: importedCount(done) }) }}
|
|
9612
|
+
</p>
|
|
9613
|
+
@if (done.skipped) {
|
|
9614
|
+
<p data-testid="import-skipped">
|
|
9615
|
+
{{ ui.text('importSkippedRows', language, { count: done.skipped }) }}
|
|
9616
|
+
</p>
|
|
9617
|
+
}
|
|
9618
|
+
@if (failedRowCount(done)) {
|
|
9619
|
+
<p data-testid="import-failed">
|
|
9620
|
+
{{ ui.text('importFailedRows', language, { count: failedRowCount(done) }) }}
|
|
9621
|
+
</p>
|
|
9622
|
+
}
|
|
9623
|
+
@if (done.truncated) {
|
|
9624
|
+
<p data-testid="import-errors-truncated">
|
|
9625
|
+
{{
|
|
9626
|
+
ui.text('importErrorsTruncated', language, {
|
|
9627
|
+
shown: done.errors.length,
|
|
9628
|
+
count: done.errorCount ?? done.errors.length,
|
|
9629
|
+
})
|
|
9630
|
+
}}
|
|
9631
|
+
</p>
|
|
9632
|
+
}
|
|
9633
|
+
<ngx-import-errors [errors]="done.errors" [language]="language" />
|
|
9634
|
+
}
|
|
9635
|
+
<button type="button" data-testid="import-restart" (click)="restart()">
|
|
9636
|
+
{{ ui.text('importStartOver', language) }}
|
|
9637
|
+
</button>
|
|
9638
|
+
}
|
|
9639
|
+
}
|
|
9640
|
+
</div>
|
|
9641
|
+
`, styles: [".ngx-import{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);color:var(--ngx-color-text, #1f2937);font-size:var(--ngx-font-size, 14px)}.ngx-import__steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}.ngx-import__step{padding:4px 12px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);color:var(--ngx-color-muted, #6b7280);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import__step--current{color:var(--ngx-color-accent, #4f46e5);border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff);font-weight:600}.ngx-import__problem{margin:0;padding:10px 12px;border:1px solid var(--ngx-color-border, #fecaca);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-error-soft, #fef2f2);color:var(--ngx-color-error, #b91c1c);overflow-wrap:anywhere}.ngx-import__file{display:flex;flex-direction:column;gap:6px;padding:14px;border:1px dashed var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff)}.ngx-import button{align-self:flex-start;padding:8px 16px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import button:hover:not(:disabled){border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff)}.ngx-import button:disabled{opacity:.55;cursor:not-allowed}\n"] }]
|
|
9642
|
+
}], propDecorators: { config: [{
|
|
9643
|
+
type: Input,
|
|
9644
|
+
args: [{ required: true }]
|
|
9645
|
+
}], rules: [{
|
|
9646
|
+
type: Input
|
|
9647
|
+
}], lookups: [{
|
|
9648
|
+
type: Input
|
|
9649
|
+
}], language: [{
|
|
9650
|
+
type: Input
|
|
9651
|
+
}], templateFormat: [{
|
|
9652
|
+
type: Input
|
|
9653
|
+
}], importComplete: [{
|
|
9654
|
+
type: Output
|
|
9655
|
+
}], templateReady: [{
|
|
9656
|
+
type: Output
|
|
9657
|
+
}] } });
|
|
9658
|
+
function messageOf(error) {
|
|
9659
|
+
return error instanceof Error ? error.message : String(error);
|
|
9660
|
+
}
|
|
9661
|
+
/**
|
|
9662
|
+
* Hand a generated file to the browser.
|
|
9663
|
+
*
|
|
9664
|
+
* The object URL is revoked on the next turn rather than immediately: revoking it in the same
|
|
9665
|
+
* task can cancel the download that was just started, and the leak of waiting one tick is a
|
|
9666
|
+
* single URL.
|
|
9667
|
+
*/
|
|
9668
|
+
function saveBlob(blob, filename) {
|
|
9669
|
+
if (typeof document === 'undefined')
|
|
9670
|
+
return;
|
|
9671
|
+
const url = URL.createObjectURL(blob);
|
|
9672
|
+
const anchor = document.createElement('a');
|
|
9673
|
+
anchor.href = url;
|
|
9674
|
+
anchor.download = filename;
|
|
9675
|
+
anchor.style.display = 'none';
|
|
9676
|
+
document.body.appendChild(anchor);
|
|
9677
|
+
anchor.click();
|
|
9678
|
+
document.body.removeChild(anchor);
|
|
9679
|
+
setTimeout(() => URL.revokeObjectURL(url));
|
|
9680
|
+
}
|
|
9681
|
+
|
|
8130
9682
|
// Tokens
|
|
8131
9683
|
|
|
8132
9684
|
/**
|
|
8133
9685
|
* Generated bundle index. Do not edit.
|
|
8134
9686
|
*/
|
|
8135
9687
|
|
|
8136
|
-
export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
|
|
9688
|
+
export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityImportComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, HttpImportTransport, IMPORT_TRANSPORT, ImageFieldComponent, ImportErrorsComponent, ImportMapperComponent, ImportPreviewComponent, ImportTemplateComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LocalImportTransport, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SHEET_PARSER, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, defaultSheetParser, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideHttpImportTransport, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
|
|
8137
9689
|
//# sourceMappingURL=ngx-dynamic-entity.mjs.map
|