@wix/bex-core 2.332.0 → 2.333.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.
Files changed (149) hide show
  1. package/dist/cjs/assets/locale/messages_ar.json +3 -0
  2. package/dist/cjs/assets/locale/messages_bg.json +3 -0
  3. package/dist/cjs/assets/locale/messages_ca.json +3 -0
  4. package/dist/cjs/assets/locale/messages_cs.json +3 -0
  5. package/dist/cjs/assets/locale/messages_da.json +3 -0
  6. package/dist/cjs/assets/locale/messages_de.json +3 -0
  7. package/dist/cjs/assets/locale/messages_el.json +3 -0
  8. package/dist/cjs/assets/locale/messages_en.json +3 -0
  9. package/dist/cjs/assets/locale/messages_es.json +3 -0
  10. package/dist/cjs/assets/locale/messages_fi.json +3 -0
  11. package/dist/cjs/assets/locale/messages_fr.json +3 -0
  12. package/dist/cjs/assets/locale/messages_he.json +3 -0
  13. package/dist/cjs/assets/locale/messages_hi.json +3 -0
  14. package/dist/cjs/assets/locale/messages_hu.json +3 -0
  15. package/dist/cjs/assets/locale/messages_id.json +3 -0
  16. package/dist/cjs/assets/locale/messages_it.json +3 -0
  17. package/dist/cjs/assets/locale/messages_ja.json +3 -0
  18. package/dist/cjs/assets/locale/messages_ko.json +3 -0
  19. package/dist/cjs/assets/locale/messages_lt.json +3 -0
  20. package/dist/cjs/assets/locale/messages_ms.json +3 -0
  21. package/dist/cjs/assets/locale/messages_nl.json +3 -0
  22. package/dist/cjs/assets/locale/messages_no.json +3 -0
  23. package/dist/cjs/assets/locale/messages_pl.json +3 -0
  24. package/dist/cjs/assets/locale/messages_pt.json +3 -0
  25. package/dist/cjs/assets/locale/messages_ro.json +3 -0
  26. package/dist/cjs/assets/locale/messages_ru.json +3 -0
  27. package/dist/cjs/assets/locale/messages_sk.json +3 -0
  28. package/dist/cjs/assets/locale/messages_sl.json +3 -0
  29. package/dist/cjs/assets/locale/messages_sv.json +3 -0
  30. package/dist/cjs/assets/locale/messages_th.json +3 -0
  31. package/dist/cjs/assets/locale/messages_tl.json +3 -0
  32. package/dist/cjs/assets/locale/messages_tr.json +3 -0
  33. package/dist/cjs/assets/locale/messages_uk.json +3 -0
  34. package/dist/cjs/assets/locale/messages_vi.json +3 -0
  35. package/dist/cjs/assets/locale/messages_zh.json +3 -0
  36. package/dist/cjs/assets/locale/version_file +1 -1
  37. package/dist/cjs/assets/visual-e2e-locale/messages_visuale2e.json +29 -2
  38. package/dist/cjs/hooks/index.js +0 -6
  39. package/dist/cjs/hooks/index.js.map +1 -1
  40. package/dist/cjs/index.js +0 -7
  41. package/dist/cjs/index.js.map +1 -1
  42. package/dist/cjs/services/translations.js.map +1 -1
  43. package/dist/cjs/state/CustomFieldModalState.js +12 -0
  44. package/dist/cjs/state/CustomFieldModalState.js.map +1 -1
  45. package/dist/cjs/state/ImportState/ImportState.js +324 -133
  46. package/dist/cjs/state/ImportState/ImportState.js.map +1 -1
  47. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js +413 -79
  48. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -1
  49. package/dist/cjs/state/ImportState/detectFieldType.js +23 -0
  50. package/dist/cjs/state/ImportState/detectFieldType.js.map +1 -1
  51. package/dist/cjs/state/ImportState/fieldEdit.js +49 -0
  52. package/dist/cjs/state/ImportState/fieldEdit.js.map +1 -1
  53. package/dist/cjs/testkit/createInMemorySchemaSource.js +186 -0
  54. package/dist/cjs/testkit/createInMemorySchemaSource.js.map +1 -0
  55. package/dist/cjs/testkit/hubJobImportMocks.js +18 -2
  56. package/dist/cjs/testkit/hubJobImportMocks.js.map +1 -1
  57. package/dist/cjs/testkit/index.js +6 -0
  58. package/dist/cjs/testkit/index.js.map +1 -1
  59. package/dist/cjs/types/SchemaSource.js +2 -1
  60. package/dist/cjs/types/SchemaSource.js.map +1 -1
  61. package/dist/esm/assets/locale/messages_ar.json +3 -0
  62. package/dist/esm/assets/locale/messages_bg.json +3 -0
  63. package/dist/esm/assets/locale/messages_ca.json +3 -0
  64. package/dist/esm/assets/locale/messages_cs.json +3 -0
  65. package/dist/esm/assets/locale/messages_da.json +3 -0
  66. package/dist/esm/assets/locale/messages_de.json +3 -0
  67. package/dist/esm/assets/locale/messages_el.json +3 -0
  68. package/dist/esm/assets/locale/messages_en.json +3 -0
  69. package/dist/esm/assets/locale/messages_es.json +3 -0
  70. package/dist/esm/assets/locale/messages_fi.json +3 -0
  71. package/dist/esm/assets/locale/messages_fr.json +3 -0
  72. package/dist/esm/assets/locale/messages_he.json +3 -0
  73. package/dist/esm/assets/locale/messages_hi.json +3 -0
  74. package/dist/esm/assets/locale/messages_hu.json +3 -0
  75. package/dist/esm/assets/locale/messages_id.json +3 -0
  76. package/dist/esm/assets/locale/messages_it.json +3 -0
  77. package/dist/esm/assets/locale/messages_ja.json +3 -0
  78. package/dist/esm/assets/locale/messages_ko.json +3 -0
  79. package/dist/esm/assets/locale/messages_lt.json +3 -0
  80. package/dist/esm/assets/locale/messages_ms.json +3 -0
  81. package/dist/esm/assets/locale/messages_nl.json +3 -0
  82. package/dist/esm/assets/locale/messages_no.json +3 -0
  83. package/dist/esm/assets/locale/messages_pl.json +3 -0
  84. package/dist/esm/assets/locale/messages_pt.json +3 -0
  85. package/dist/esm/assets/locale/messages_ro.json +3 -0
  86. package/dist/esm/assets/locale/messages_ru.json +3 -0
  87. package/dist/esm/assets/locale/messages_sk.json +3 -0
  88. package/dist/esm/assets/locale/messages_sl.json +3 -0
  89. package/dist/esm/assets/locale/messages_sv.json +3 -0
  90. package/dist/esm/assets/locale/messages_th.json +3 -0
  91. package/dist/esm/assets/locale/messages_tl.json +3 -0
  92. package/dist/esm/assets/locale/messages_tr.json +3 -0
  93. package/dist/esm/assets/locale/messages_uk.json +3 -0
  94. package/dist/esm/assets/locale/messages_vi.json +3 -0
  95. package/dist/esm/assets/locale/messages_zh.json +3 -0
  96. package/dist/esm/assets/locale/version_file +1 -1
  97. package/dist/esm/assets/visual-e2e-locale/messages_visuale2e.json +29 -2
  98. package/dist/esm/hooks/index.js +0 -1
  99. package/dist/esm/hooks/index.js.map +1 -1
  100. package/dist/esm/index.js +0 -1
  101. package/dist/esm/index.js.map +1 -1
  102. package/dist/esm/state/CustomFieldModalState.js +8 -0
  103. package/dist/esm/state/CustomFieldModalState.js.map +1 -1
  104. package/dist/esm/state/ImportState/ImportState.js +246 -111
  105. package/dist/esm/state/ImportState/ImportState.js.map +1 -1
  106. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js +293 -67
  107. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -1
  108. package/dist/esm/state/ImportState/detectFieldType.js +19 -0
  109. package/dist/esm/state/ImportState/detectFieldType.js.map +1 -1
  110. package/dist/esm/state/ImportState/fieldEdit.js +44 -0
  111. package/dist/esm/state/ImportState/fieldEdit.js.map +1 -1
  112. package/dist/esm/testkit/createInMemorySchemaSource.js +140 -0
  113. package/dist/esm/testkit/createInMemorySchemaSource.js.map +1 -0
  114. package/dist/esm/testkit/hubJobImportMocks.js +17 -1
  115. package/dist/esm/testkit/hubJobImportMocks.js.map +1 -1
  116. package/dist/esm/testkit/index.js +1 -0
  117. package/dist/esm/testkit/index.js.map +1 -1
  118. package/dist/esm/types/SchemaSource.js.map +1 -1
  119. package/dist/types/hooks/index.d.ts +0 -1
  120. package/dist/types/hooks/index.d.ts.map +1 -1
  121. package/dist/types/index.d.ts +0 -1
  122. package/dist/types/index.d.ts.map +1 -1
  123. package/dist/types/services/translations.d.ts +7 -1
  124. package/dist/types/services/translations.d.ts.map +1 -1
  125. package/dist/types/state/CustomFieldModalState.d.ts +9 -0
  126. package/dist/types/state/CustomFieldModalState.d.ts.map +1 -1
  127. package/dist/types/state/ImportState/ImportState.d.ts +165 -61
  128. package/dist/types/state/ImportState/ImportState.d.ts.map +1 -1
  129. package/dist/types/state/ImportState/detectFieldType.d.ts +2 -0
  130. package/dist/types/state/ImportState/detectFieldType.d.ts.map +1 -1
  131. package/dist/types/state/ImportState/fieldEdit.d.ts +6 -0
  132. package/dist/types/state/ImportState/fieldEdit.d.ts.map +1 -1
  133. package/dist/types/testkit/WixPatternsContainer.driver.d.ts +4 -0
  134. package/dist/types/testkit/WixPatternsContainer.driver.d.ts.map +1 -1
  135. package/dist/types/testkit/createInMemorySchemaSource.d.ts +41 -0
  136. package/dist/types/testkit/createInMemorySchemaSource.d.ts.map +1 -0
  137. package/dist/types/testkit/hubJobImportMocks.d.ts +11 -1
  138. package/dist/types/testkit/hubJobImportMocks.d.ts.map +1 -1
  139. package/dist/types/testkit/index.d.ts +1 -0
  140. package/dist/types/testkit/index.d.ts.map +1 -1
  141. package/dist/types/types/SchemaSource.d.ts +6 -4
  142. package/dist/types/types/SchemaSource.d.ts.map +1 -1
  143. package/package.json +4 -4
  144. package/dist/cjs/hooks/useImportConfig.js +0 -12
  145. package/dist/cjs/hooks/useImportConfig.js.map +0 -1
  146. package/dist/esm/hooks/useImportConfig.js +0 -6
  147. package/dist/esm/hooks/useImportConfig.js.map +0 -1
  148. package/dist/types/hooks/useImportConfig.d.ts +0 -5
  149. package/dist/types/hooks/useImportConfig.d.ts.map +0 -1
@@ -7,16 +7,16 @@ import { rewriteParsedCsvForImport } from './rewriteCsv';
7
7
  import { createHubJobClient } from './hubJobClient';
8
8
  import { pollImportJob } from './importJobPoller';
9
9
  import { detectColumnType, TYPE_SAMPLE_SIZE, } from './detectFieldType';
10
- import { toFieldEdit } from './fieldEdit';
10
+ import { toDataExtensionFieldEdit, toFieldEdit } from './fieldEdit';
11
11
  // `NewFieldType`, `detectColumnType`, etc. are re-exported from the package via
12
12
  // the `ImportState` barrel (`./index`), which also exports `./detectFieldType`.
13
13
  // Re-export the write-policy enum so consumers (and the cairo UI) reference it
14
14
  // from `@wix/bex-core` rather than the bundled ambassador path.
15
15
  export { WritePolicy } from '@wix/bex-utils/@wix/ambassador-hub-v1-job/types';
16
- // How long to keep the completed progress bar (100%) on screen before closing
17
- // the modal on a clean import. Without it, an import of very few rows finishes
18
- // near-instantly and the modal appears to vanish without ever showing the bar
19
- // reaching completion.
16
+ // How long to hold the completed (100%) progress bar on screen before moving to
17
+ // the summary step (CAIRO-4476). The job reports counts only when it finishes,
18
+ // so the bar sits at 0 for the whole import and would otherwise flip straight to
19
+ // the summary without the user ever seeing it reach completion.
20
20
  const COMPLETION_LINGER_MS = 600;
21
21
  // The Wix Data collection's id field key. A CSV column mapped to this field lets
22
22
  // the user choose how rows that collide on `_id` are written (writePolicy).
@@ -90,6 +90,8 @@ export class ImportState {
90
90
  this.result = {
91
91
  succeeded: 0,
92
92
  failed: 0,
93
+ created: 0,
94
+ updated: 0,
93
95
  };
94
96
  this.isBackingUp = false;
95
97
  this.isRestoring = false;
@@ -99,10 +101,11 @@ export class ImportState {
99
101
  // Id of the running data-movement job, set once submitted — used to cancel it
100
102
  // (`terminateJob`) if the user closes mid-import.
101
103
  this._jobId = null;
102
- // The column whose pending new field the field editor is currently editing.
103
- // Not observable transient wiring between `beginFieldEdit` and the editor's
104
- // `applyFieldEdit` callback, never rendered.
105
- this._editingHeader = null;
104
+ // The column whose pending new field the field editor is currently editing, or
105
+ // `null` when the editor is closed. Observable so the source's field-editor
106
+ // component (rendered via `renderFieldEditor`) opens/prefills itself when this
107
+ // is set and closes when it clears — the editor is decoupled from the layout.
108
+ this.editingHeader = null;
106
109
  this.config = props.config;
107
110
  this.collection = props.collection;
108
111
  this.importModalState = props.importModalState;
@@ -133,7 +136,12 @@ export class ImportState {
133
136
  hasMappings: computed,
134
137
  hasNoMappedData: computed,
135
138
  selectedFieldIds: computed,
139
+ missingRequiredFields: computed,
140
+ hasMissingRequiredFields: computed,
136
141
  idMappedHeader: computed,
142
+ editingHeader: observable.ref,
143
+ editingMapping: computed,
144
+ endFieldEdit: action.bound,
137
145
  setWritePolicy: action.bound,
138
146
  onUploadContinue: action.bound,
139
147
  goToUpload: action.bound,
@@ -142,7 +150,10 @@ export class ImportState {
142
150
  setNewFieldConfig: action.bound,
143
151
  beginFieldEdit: action.bound,
144
152
  applyFieldEdit: action.bound,
153
+ applyDataExtensionFieldEdit: action.bound,
145
154
  close: action.bound,
155
+ done: action.bound,
156
+ downloadFailedRows: action.bound,
146
157
  onFileSelected: action.bound,
147
158
  startImport: action.bound,
148
159
  reset: action.bound,
@@ -156,19 +167,33 @@ export class ImportState {
156
167
  }
157
168
  /** Wix Data collection the import writes into (unset for platformized). */
158
169
  get collectionId() {
159
- return this.config.collectionId;
170
+ const ds = this.config.dataSource;
171
+ return 'collectionId' in ds ? ds.collectionId : undefined;
160
172
  }
161
173
  get destination() {
162
- return this.config.destination;
174
+ const ds = this.config.dataSource;
175
+ return 'destination' in ds ? ds.destination : undefined;
163
176
  }
164
177
  get backup() {
165
178
  return this.config.backup;
166
179
  }
180
+ /** CMS create-fields callback, present only on the `collectionId` branch. */
167
181
  get updateSchema() {
168
- return this.config.updateSchema;
182
+ const ds = this.config.dataSource;
183
+ return 'collectionId' in ds ? ds.updateSchema : undefined;
169
184
  }
170
- get onJobError() {
171
- return this.config.onJobError;
185
+ /** DataExtension create-fields callback, present only on the `destination` branch. */
186
+ get createDataExtensionFields() {
187
+ const ds = this.config.dataSource;
188
+ return 'destination' in ds ? ds.createFields : undefined;
189
+ }
190
+ /** DataExtension field-management for the editor, on the `destination` branch. */
191
+ get dataExtensionFieldManagement() {
192
+ const ds = this.config.dataSource;
193
+ return 'destination' in ds ? ds.fieldManagement : undefined;
194
+ }
195
+ get jobApplicationErrorMap() {
196
+ return this.config.jobApplicationErrorMap;
172
197
  }
173
198
  // The collection's id-field key; a schema-backed config may report a non-`_id`
174
199
  // identifier. Defaults to the Wix Data `_id`.
@@ -216,6 +241,30 @@ export class ImportState {
216
241
  .filter((m) => m?.kind === 'existing')
217
242
  .map((m) => m.fieldId));
218
243
  }
244
+ /**
245
+ * Required destination fields that no CSV column is mapped to (CAIRO-4452).
246
+ * A required field is an existing collection field, so only an `existing`
247
+ * mapping can satisfy it — a "create new field" column can't. Covers both
248
+ * ways a required field ends up unmapped: the CSV has no matching column at
249
+ * all, or the user set its column to "don't import". Drives the footer
250
+ * indicator and blocks `startImport`.
251
+ */
252
+ get missingRequiredFields() {
253
+ return this.fields.filter((field) => field.required &&
254
+ field.id != null &&
255
+ !this.selectedFieldIds.has(field.id));
256
+ }
257
+ get hasMissingRequiredFields() {
258
+ return this.missingRequiredFields.length > 0;
259
+ }
260
+ // Comma-separated display names of the missing required fields, for the block
261
+ // toast. Falls back to the field id when a field carries no header.
262
+ _missingRequiredFieldNames() {
263
+ return this.missingRequiredFields
264
+ .map((field) => field.header || field.id || '')
265
+ .filter(Boolean)
266
+ .join(', ');
267
+ }
219
268
  /**
220
269
  * The CSV header currently mapped to the collection's `_id` field, or `null`
221
270
  * when no column targets it. Drives both the "When items share the same ID"
@@ -232,12 +281,13 @@ export class ImportState {
232
281
  }
233
282
  /**
234
283
  * Whether columns may be mapped to a brand-new field created during import.
235
- * When the config sets `canAddFields` (e.g. a schema's create-field permission)
236
- * that gates it; otherwise the presence of `updateSchema` is the opt-in (see
237
- * `getDefaultMapping`).
284
+ * The opt-in is the presence of the active `dataSource` branch's create
285
+ * callback `updateSchema` (CMS) or `createFields` (DataExtension). A source
286
+ * that can't create fields (e.g. no create permission) supplies neither, so
287
+ * columns fall back to "don't import" (see `getDefaultMapping`).
238
288
  */
239
289
  get canCreateNewField() {
240
- return this.config.canAddFields ?? this.config.updateSchema != null;
290
+ return (this.updateSchema != null || this.createDataExtensionFields != null);
241
291
  }
242
292
  /**
243
293
  * Whether the write-policy (id-collision) choice is meaningful for this import.
@@ -269,6 +319,8 @@ export class ImportState {
269
319
  this.result = {
270
320
  succeeded: 0,
271
321
  failed: 0,
322
+ created: 0,
323
+ updated: 0,
272
324
  };
273
325
  this._pendingFieldsMap = undefined;
274
326
  this.isBackingUp = false;
@@ -279,11 +331,10 @@ export class ImportState {
279
331
  }
280
332
  async close() {
281
333
  // While on the progress step we treat close as a cancel — terminate the job
282
- // and restore the backup — UNLESS the import already succeeded. On the
283
- // success path `startImport` lingers ~600ms on the completed bar with the
284
- // step still 'progress'; closing during that window must NOT restore (it
285
- // would undo a clean import). A successful close just closes the modal and
286
- // lets the success path do its collection refresh.
334
+ // and restore the backup — UNLESS the import already succeeded. On success
335
+ // `startImport` moves straight to the 'summary' step, so a close from there
336
+ // just closes the modal (the summary's own `done` is the normal exit); this
337
+ // guard covers only closing mid-import.
287
338
  if (this.currentStep === 'progress' && !this.importStatus.isSuccess) {
288
339
  this._cancelled = true;
289
340
  this._abortController?.abort();
@@ -306,6 +357,13 @@ export class ImportState {
306
357
  }
307
358
  this.importModalState.close();
308
359
  }
360
+ /**
361
+ * Closes the summary step at the end of a completed import. The collection was
362
+ * already refreshed when the summary opened, so this only dismisses the modal.
363
+ */
364
+ done() {
365
+ this.importModalState.close();
366
+ }
309
367
  async _restoreBackup() {
310
368
  const restoreFn = this.restoreFn;
311
369
  if (!restoreFn) {
@@ -474,50 +532,44 @@ export class ImportState {
474
532
  },
475
533
  });
476
534
  }
477
- _showPartialSuccessToast() {
478
- this.showToast?.({
479
- type: 'WARNING',
480
- message: this.translate('cairo.import.partialSuccessToast', {
481
- failed: String(this.result.failed),
482
- imported: String(this.result.succeeded),
483
- }),
484
- action: {
485
- text: this.translate('cairo.import.downloadFailedRows'),
486
- onClick: () => this._downloadFailedRows(),
487
- removeToastOnClick: true,
488
- },
489
- });
490
- }
491
535
  /**
492
- * Builds a plain, re-importable CSV of the rows the server rejected the
493
- * original headers plus just the failed rows and hands it to `downloadCsv`.
494
- * The failed rows come from the job's movement logs (which row failed), mapped
495
- * back to the parsed `csvRows` we still hold in memory.
536
+ * Builds a re-importable CSV of the rows the server rejected and hands it to
537
+ * `downloadCsv`. Each failed row is prefixed with a leading "why it failed"
538
+ * column holding the server's error message, so the user can see and fix each
539
+ * reason. The failed rows (and their reasons) come from the job's movement
540
+ * logs, mapped back to the parsed `csvRows` we still hold in memory.
496
541
  */
497
- async _downloadFailedRows() {
542
+ async downloadFailedRows() {
498
543
  const jobId = this._jobId;
499
544
  if (!jobId) {
500
545
  return;
501
546
  }
502
- let indices;
547
+ let failures;
503
548
  try {
504
- indices = await this._fetchFailedRowIndices(jobId);
549
+ failures = await this._fetchFailedRows(jobId);
505
550
  }
506
551
  catch (error) {
507
552
  console.error('Fetching failed rows report failed', error);
508
553
  return;
509
554
  }
510
- const failedRows = indices
511
- .map((index) => this.csvRows[index])
555
+ const rows = failures
556
+ .map(({ index, reason }) => {
557
+ const row = this.csvRows[index];
558
+ return row != null ? [reason, ...row] : null;
559
+ })
512
560
  .filter((row) => row != null);
513
- if (failedRows.length === 0) {
561
+ if (rows.length === 0) {
514
562
  return;
515
563
  }
516
- const content = unparseCsv(this.csvHeaders, failedRows);
564
+ const headers = [
565
+ this.translate('cairo.import.failureReasonColumn'),
566
+ ...this.csvHeaders,
567
+ ];
568
+ const content = unparseCsv(headers, rows);
517
569
  this.downloadCsv?.(content, `failed-${this.fileName || 'import.csv'}`);
518
570
  }
519
- // Pages through the job's movement logs and returns the `csvRows` indices of
520
- // the rows that failed. data-hub's CSV decoder
571
+ // Pages through the job's movement logs and returns, for each row that failed,
572
+ // its `csvRows` index and the server's failure reason. data-hub's CSV decoder
521
573
  // (wix-data-hub: packages/wix-data-hub-executor-c/src/plugins/formats/csv/
522
574
  // csv-binary-decoder.ts) parses with the `csv-parse` library (`columns: true`)
523
575
  // and sets `sourceItemId = "row ${++index + 1}"`, incrementing once **per
@@ -526,8 +578,8 @@ export class ImportState {
526
578
  // `csvRows[0]`, i.e. `csvRows[N - 2]`. Because `csv-parse` is RFC-4180,
527
579
  // quoted cells containing embedded newlines are a single record, so they do
528
580
  // NOT shift the numbering. Out-of-range indices are guarded below.
529
- async _fetchFailedRowIndices(jobId) {
530
- const indices = [];
581
+ async _fetchFailedRows(jobId) {
582
+ const failures = [];
531
583
  let cursor;
532
584
  do {
533
585
  const { logs, pagingMetadata } = await this.hubJobClient.listMovementLogs(jobId, cursor ? { cursor } : undefined);
@@ -540,13 +592,13 @@ export class ImportState {
540
592
  if (Number.isInteger(index) &&
541
593
  index >= 0 &&
542
594
  index < this.csvRows.length) {
543
- indices.push(index);
595
+ failures.push({ index, reason: log.failure.description ?? '' });
544
596
  }
545
597
  }
546
598
  cursor =
547
599
  (pagingMetadata?.hasNext && pagingMetadata.cursors?.next) || undefined;
548
600
  } while (cursor);
549
- return indices;
601
+ return failures;
550
602
  }
551
603
  setMapping(header, fieldId) {
552
604
  this.mappingByHeader = {
@@ -584,23 +636,51 @@ export class ImportState {
584
636
  };
585
637
  }
586
638
  /**
587
- * Marks which column's pending new field the field editor is currently
588
- * editing, so a later `applyFieldEdit` (the editor's `onSubmit`) knows where to
589
- * write the result.
639
+ * Opens the field editor for a column's pending new field: sets
640
+ * `editingHeader`, which the source's editor component watches to open and
641
+ * prefill itself. No-op if the column isn't mapped to a new field.
590
642
  */
591
643
  beginFieldEdit(header) {
592
- this._editingHeader = header;
644
+ if (this.mappingByHeader[header]?.kind === 'new') {
645
+ this.editingHeader = header;
646
+ }
647
+ }
648
+ /** Closes the field editor (clears `editingHeader`). */
649
+ endFieldEdit() {
650
+ this.editingHeader = null;
651
+ }
652
+ /**
653
+ * The `new` mapping currently being edited, or `null` when the editor is
654
+ * closed. The editor component reads this to prefill its form.
655
+ */
656
+ get editingMapping() {
657
+ if (this.editingHeader == null) {
658
+ return null;
659
+ }
660
+ const mapping = this.mappingByHeader[this.editingHeader];
661
+ return mapping?.kind === 'new' ? mapping : null;
593
662
  }
594
663
  /**
595
- * `onSubmit` handler for the field editor: maps the edited field spec into the
596
- * column being edited (set via `beginFieldEdit`). No-op if nothing is being
664
+ * `onSubmit` handler for the CMS field editor: maps the edited field spec into
665
+ * the column being edited (set via `beginFieldEdit`). No-op if nothing is being
597
666
  * edited.
598
667
  */
599
668
  applyFieldEdit(field) {
600
- if (this._editingHeader == null) {
669
+ if (this.editingHeader == null) {
601
670
  return;
602
671
  }
603
- this.setNewFieldConfig(this._editingHeader, toFieldEdit(field));
672
+ this.setNewFieldConfig(this.editingHeader, toFieldEdit(field));
673
+ }
674
+ /**
675
+ * `onSubmit` handler for the DataExtension field editor: maps the edited
676
+ * DataExtension draft (which carries per-audience permissions) into the column
677
+ * being edited. The `destination`-branch counterpart to `applyFieldEdit`.
678
+ */
679
+ applyDataExtensionFieldEdit(draft) {
680
+ if (this.editingHeader == null) {
681
+ return;
682
+ }
683
+ this.setNewFieldConfig(this.editingHeader, toDataExtensionFieldEdit(draft));
604
684
  }
605
685
  /**
606
686
  * Runs the server-side import: align the CSV to the resolved field keys,
@@ -645,21 +725,30 @@ export class ImportState {
645
725
  // The job ran server-side, so a failure arrives on a 200 (the poll
646
726
  // succeeded; the job didn't) with no HTTP error to carry the application
647
727
  // error. Build a synthetic, HTTP-error-shaped error (the error handler
648
- // reads the code from `response.data.details.applicationError`) and hand
649
- // the throwing operation to the consumer's `onJobError` so THEY wrap it
650
- // with their own `withErrorHandler` + error-code map — `job.error` is the
651
- // consumer's application error (their import business logic), so only they
652
- // can map its codes, and the map stays coupled to the `withErrorHandler`
653
- // call as everywhere else (e.g. `fetchData`). The map is attached to the
654
- // error object, which the progress step resolves via `getResolvedError`.
655
- // Without a handler the same error still throws, falling back to the
656
- // generic message.
728
+ // reads the code from `response.data.details.applicationError`), then
729
+ // resolve it through the consumer's per-code map via `withErrorHandler`
730
+ // the same way as `fetchData` etc. `job.error` is the consumer's
731
+ // application error (their import business logic), so only they can map
732
+ // its codes; they supply just the code→message map and we do the
733
+ // wrapping. `withErrorHandler` attaches the map to the error and
734
+ // re-rejects it, so the progress step resolves a consumer-specific
735
+ // message via `getResolvedError`. Without a map the same error still
736
+ // throws, falling back to the generic message.
657
737
  const error = Object.assign(new Error(`ImportJob.${job.error?.code ?? 'failed'}`), { response: { data: { details: { applicationError: job.error } } } });
658
- const run = () => Promise.reject(error);
659
- if (this.onJobError) {
660
- // The consumer wraps `run` with `withErrorHandler`; it re-rejects with
661
- // `error` (now carrying their map), which propagates from here.
662
- await this.onJobError(run);
738
+ const jobApplicationErrorMap = this.jobApplicationErrorMap;
739
+ if (jobApplicationErrorMap) {
740
+ // Adapt the consumer's code→message map into the error handler's
741
+ // `errorCodesMap`: each entry, when its code matches the job's
742
+ // application error, resolves with that error. `withErrorHandler`
743
+ // attaches the resolved message to `error` and re-rejects it, so the
744
+ // progress step surfaces it via `getResolvedError` — the consumer only
745
+ // supplies the map, not the wrapping.
746
+ const appError = (job.error ?? {});
747
+ const errorCodesMap = Object.fromEntries(Object.entries(jobApplicationErrorMap).map(([code, resolve]) => [
748
+ code,
749
+ () => resolve(appError),
750
+ ]));
751
+ await this.errorHandler.withErrorHandler(() => Promise.reject(error), errorCodesMap);
663
752
  }
664
753
  throw error;
665
754
  }
@@ -688,13 +777,21 @@ export class ImportState {
688
777
  const succeeded = progress?.itemsSuccessful ?? 0;
689
778
  const failed = progress?.itemsRejected ?? 0;
690
779
  const current = progress?.current ?? succeeded + failed;
780
+ // The server splits successful items into created (new) and updated
781
+ // (existing) — the summary step reports each. Absent on older jobs, in
782
+ // which case both stay 0 and only the total `succeeded` is shown.
783
+ const created = progress?.itemsSuccessfulInfo?.itemsCreated ?? 0;
784
+ const updated = progress?.itemsSuccessfulInfo?.itemsUpdated ?? 0;
691
785
  // `totalCount` is fixed up front in `startImport` from the CSV row count
692
786
  // (one uploaded row per CSV row); polling only advances `processedCount`.
693
787
  runInAction(() => {
694
788
  this.processedCount = Math.min(current, this.totalCount);
695
- this.result = { succeeded, failed };
789
+ this.result = { succeeded, failed, created, updated };
696
790
  });
697
791
  }
792
+ _lingerOnCompletion() {
793
+ return new Promise((resolve) => setTimeout(resolve, COMPLETION_LINGER_MS));
794
+ }
698
795
  async _runBackup() {
699
796
  const backup = this.backup;
700
797
  if (!backup) {
@@ -727,9 +824,6 @@ export class ImportState {
727
824
  });
728
825
  return false;
729
826
  }
730
- _lingerOnCompletion() {
731
- return new Promise((resolve) => setTimeout(resolve, COMPLETION_LINGER_MS));
732
- }
733
827
  /**
734
828
  * Resolves every column to the final destination key used in the upload
735
829
  * payload: an existing field id, a new field's (Cairo-decided) key, or `null`
@@ -739,13 +833,16 @@ export class ImportState {
739
833
  */
740
834
  async _resolveFieldMapping() {
741
835
  const resolved = {};
742
- const newFields = [];
836
+ // Pending new columns with their deduped key + display name. Source-neutral;
837
+ // the per-source field def is built from these in `_createPendingFields`.
838
+ const pending = [];
743
839
  // Seed with existing field ids/display names so derived keys and names never
744
840
  // collide with them or with each other.
745
841
  const usedKeys = new Set(this.fields.map((field) => field.id).filter((id) => !!id));
746
842
  const usedNames = new Set(this.fields
747
843
  .map((field) => field.header)
748
844
  .filter((header) => !!header));
845
+ const canCreate = this.canCreateNewField;
749
846
  for (const header of this.csvHeaders) {
750
847
  const mapping = this.mappingByHeader[header];
751
848
  if (mapping == null) {
@@ -754,41 +851,75 @@ export class ImportState {
754
851
  else if (mapping.kind === 'existing') {
755
852
  resolved[header] = mapping.fieldId;
756
853
  }
757
- else if (this.updateSchema) {
854
+ else if (canCreate) {
758
855
  // The user's name override (from the field editor) seeds the unique
759
856
  // name/key derivation; otherwise fall back to the column header.
760
857
  const baseName = mapping.displayName?.trim() || header;
761
858
  const { displayName, key } = uniqueFieldNameAndKey(baseName, usedNames, usedKeys);
762
859
  usedKeys.add(key);
763
860
  usedNames.add(displayName);
764
- newFields.push({
765
- key,
766
- type: mapping.fieldType,
767
- displayName,
768
- ...(mapping.config && { config: mapping.config }),
769
- });
861
+ pending.push({ key, displayName, mapping });
770
862
  resolved[header] = key;
771
863
  }
772
864
  else {
773
- // "new" mapping without an `updateSchema` callback — nothing to map into.
865
+ // "new" mapping with no create callback on this source — nothing to map into.
774
866
  resolved[header] = null;
775
867
  }
776
868
  }
777
- if (newFields.length > 0 && this.updateSchema) {
869
+ if (pending.length > 0) {
778
870
  // Stash the returned fields map to merge into the table on success; kept off
779
- // the observable until then so columns don't update mid-import. Guard the
780
- // empty map: SchemaState.mergeFields is a full replace, so merging `{}` would
781
- // wipe every column — treat a missing/empty return as "nothing to merge".
782
- const updated = await this.updateSchema(newFields);
783
- this._pendingFieldsMap =
784
- updated && Object.keys(updated).length > 0 ? updated : undefined;
871
+ // the observable until then so columns don't update mid-import.
872
+ this._pendingFieldsMap = await this._createPendingFields(pending);
785
873
  }
786
874
  return resolved;
787
875
  }
876
+ /**
877
+ * Creates the pending new fields via the active `dataSource` branch's callback
878
+ * — `updateSchema` (CMS) or `createFields` (DataExtension) — building the
879
+ * source-specific field def from the shared, already-deduped pending columns.
880
+ * The callback may throw; `startImport` surfaces that as the error state.
881
+ */
882
+ async _createPendingFields(pending) {
883
+ const updateSchema = this.updateSchema;
884
+ const createDataExtensionFields = this.createDataExtensionFields;
885
+ let updated;
886
+ if (updateSchema) {
887
+ updated = await updateSchema(pending.map(({ key, displayName, mapping }) => ({
888
+ key,
889
+ type: mapping.fieldType,
890
+ displayName,
891
+ ...(mapping.config && { config: mapping.config }),
892
+ })));
893
+ }
894
+ else if (createDataExtensionFields) {
895
+ // The DataExtension source builds its own field shape from the neutral
896
+ // pending columns — no DataExtension-specific def-building in core.
897
+ updated = await createDataExtensionFields(pending);
898
+ }
899
+ else {
900
+ return undefined;
901
+ }
902
+ // Guard the empty map: SchemaState.mergeFields is a full replace, so merging
903
+ // `{}` would wipe every column — treat a missing/empty return as "nothing to
904
+ // merge".
905
+ return updated && Object.keys(updated).length > 0 ? updated : undefined;
906
+ }
788
907
  async startImport() {
789
908
  if (!this.hasMappings) {
790
909
  return;
791
910
  }
911
+ // Block import while any required destination field is unmapped and tell the
912
+ // user which (CAIRO-4452). The footer already flags this; the Import button
913
+ // stays enabled, so this is where the click is stopped.
914
+ if (this.hasMissingRequiredFields) {
915
+ this.showToast?.({
916
+ type: 'ERROR',
917
+ message: this.translate('cairo.import.missingRequiredMessage', {
918
+ fields: this._missingRequiredFieldNames(),
919
+ }),
920
+ });
921
+ return;
922
+ }
792
923
  this._cancelled = false;
793
924
  this._abortController = new AbortController();
794
925
  // One uploaded row per CSV row (the rewrite drops columns, not rows), so the
@@ -805,6 +936,8 @@ export class ImportState {
805
936
  this.result = {
806
937
  succeeded: 0,
807
938
  failed: 0,
939
+ created: 0,
940
+ updated: 0,
808
941
  };
809
942
  this._jobId = null;
810
943
  // Drop any fields map from a prior attempt so a no-new-fields "Try again"
@@ -857,28 +990,30 @@ export class ImportState {
857
990
  ...queryStatus.success,
858
991
  data: this.result,
859
992
  };
993
+ // The job reports counts only when it finishes, so the bar sat at 0 for
994
+ // the whole import (CAIRO-4476). Snap it to 100% and hold it (below)
995
+ // before the summary step replaces the progress step, so the user sees
996
+ // the import reach completion.
997
+ this.processedCount = this.totalCount;
860
998
  });
861
- // On a clean import, hold the completed (100%) progress bar on screen
862
- // briefly so the completion is perceptible even for very few rows. Skip
863
- // the wait when there are failed rows that path closes and surfaces a
864
- // toast instead.
865
- if (this.result.failed === 0) {
866
- await this._lingerOnCompletion();
867
- if (this._cancelled) {
868
- return;
869
- }
999
+ // Keep the completed bar on screen briefly before moving on. `importStatus`
1000
+ // is already success, so a close during this window just dismisses the
1001
+ // modal (the `close()` guard doesn't treat it as a cancel).
1002
+ await this._lingerOnCompletion();
1003
+ if (this._cancelled) {
1004
+ return;
870
1005
  }
871
1006
  // Success path only. `_pendingFieldsMap` holds this run's returned map — it's
872
1007
  // cleared at the start of `startImport` (the stale-guard) and set by
873
1008
  // `_resolveFieldMapping`; snapshotting here keeps the merge success-gated.
874
1009
  createdFields = this._pendingFieldsMap;
875
1010
  runInAction(() => {
876
- // Rows the server rejected are reported via a toast (the downloadable
877
- // failed-rows report is added by the failed-rows-report task).
878
- if (this.result.failed > 0) {
879
- this._showPartialSuccessToast();
880
- }
881
- this.importModalState.close();
1011
+ // Import finished: show the summary step (created / updated / failed
1012
+ // counts, plus a "Download CSV" of the failed rows when any failed).
1013
+ // The modal stays open; the user dismisses it from the summary via
1014
+ // `done`. Refresh the collection now so the table behind the modal is
1015
+ // up to date by the time it closes.
1016
+ this.currentStep = 'summary';
882
1017
  this.collection.clearResultAndMoveToStart({ force: true });
883
1018
  });
884
1019
  }