@wix/bex-core 2.331.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 (171) hide show
  1. package/dist/cjs/assets/locale/messages_ar.json +11 -0
  2. package/dist/cjs/assets/locale/messages_bg.json +11 -0
  3. package/dist/cjs/assets/locale/messages_ca.json +11 -0
  4. package/dist/cjs/assets/locale/messages_cs.json +11 -0
  5. package/dist/cjs/assets/locale/messages_da.json +11 -0
  6. package/dist/cjs/assets/locale/messages_de.json +11 -0
  7. package/dist/cjs/assets/locale/messages_el.json +11 -0
  8. package/dist/cjs/assets/locale/messages_en.json +11 -0
  9. package/dist/cjs/assets/locale/messages_es.json +11 -0
  10. package/dist/cjs/assets/locale/messages_fi.json +11 -0
  11. package/dist/cjs/assets/locale/messages_fr.json +11 -0
  12. package/dist/cjs/assets/locale/messages_he.json +11 -0
  13. package/dist/cjs/assets/locale/messages_hi.json +11 -0
  14. package/dist/cjs/assets/locale/messages_hu.json +11 -0
  15. package/dist/cjs/assets/locale/messages_id.json +11 -0
  16. package/dist/cjs/assets/locale/messages_it.json +11 -0
  17. package/dist/cjs/assets/locale/messages_ja.json +11 -0
  18. package/dist/cjs/assets/locale/messages_ko.json +11 -0
  19. package/dist/cjs/assets/locale/messages_lt.json +11 -0
  20. package/dist/cjs/assets/locale/messages_ms.json +11 -0
  21. package/dist/cjs/assets/locale/messages_nl.json +11 -0
  22. package/dist/cjs/assets/locale/messages_no.json +11 -0
  23. package/dist/cjs/assets/locale/messages_pl.json +11 -0
  24. package/dist/cjs/assets/locale/messages_pt.json +11 -0
  25. package/dist/cjs/assets/locale/messages_ro.json +11 -0
  26. package/dist/cjs/assets/locale/messages_ru.json +11 -0
  27. package/dist/cjs/assets/locale/messages_sk.json +11 -0
  28. package/dist/cjs/assets/locale/messages_sl.json +11 -0
  29. package/dist/cjs/assets/locale/messages_sv.json +11 -0
  30. package/dist/cjs/assets/locale/messages_th.json +11 -0
  31. package/dist/cjs/assets/locale/messages_tl.json +11 -0
  32. package/dist/cjs/assets/locale/messages_tr.json +11 -0
  33. package/dist/cjs/assets/locale/messages_uk.json +11 -0
  34. package/dist/cjs/assets/locale/messages_vi.json +11 -0
  35. package/dist/cjs/assets/locale/messages_zh.json +11 -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/hooks/useImport.js +3 -13
  41. package/dist/cjs/hooks/useImport.js.map +1 -1
  42. package/dist/cjs/index.js +0 -7
  43. package/dist/cjs/index.js.map +1 -1
  44. package/dist/cjs/services/translations.js.map +1 -1
  45. package/dist/cjs/state/CustomFieldModalState.js +12 -0
  46. package/dist/cjs/state/CustomFieldModalState.js.map +1 -1
  47. package/dist/cjs/state/ImportState/ImportState.js +401 -133
  48. package/dist/cjs/state/ImportState/ImportState.js.map +1 -1
  49. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js +671 -62
  50. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -1
  51. package/dist/cjs/state/ImportState/detectFieldType.js +23 -0
  52. package/dist/cjs/state/ImportState/detectFieldType.js.map +1 -1
  53. package/dist/cjs/state/ImportState/fieldEdit.js +49 -0
  54. package/dist/cjs/state/ImportState/fieldEdit.js.map +1 -1
  55. package/dist/cjs/state/TagsState/TagsManagementState.js +17 -44
  56. package/dist/cjs/state/TagsState/TagsManagementState.js.map +1 -1
  57. package/dist/cjs/state/TagsState/TagsSearchState.js +0 -6
  58. package/dist/cjs/state/TagsState/TagsSearchState.js.map +1 -1
  59. package/dist/cjs/testkit/createInMemorySchemaSource.js +186 -0
  60. package/dist/cjs/testkit/createInMemorySchemaSource.js.map +1 -0
  61. package/dist/cjs/testkit/hubJobImportMocks.js +18 -2
  62. package/dist/cjs/testkit/hubJobImportMocks.js.map +1 -1
  63. package/dist/cjs/testkit/index.js +6 -0
  64. package/dist/cjs/testkit/index.js.map +1 -1
  65. package/dist/cjs/types/FieldsSource.js.map +1 -1
  66. package/dist/cjs/types/Schema.js.map +1 -1
  67. package/dist/cjs/types/SchemaSource.js +2 -1
  68. package/dist/cjs/types/SchemaSource.js.map +1 -1
  69. package/dist/esm/assets/locale/messages_ar.json +11 -0
  70. package/dist/esm/assets/locale/messages_bg.json +11 -0
  71. package/dist/esm/assets/locale/messages_ca.json +11 -0
  72. package/dist/esm/assets/locale/messages_cs.json +11 -0
  73. package/dist/esm/assets/locale/messages_da.json +11 -0
  74. package/dist/esm/assets/locale/messages_de.json +11 -0
  75. package/dist/esm/assets/locale/messages_el.json +11 -0
  76. package/dist/esm/assets/locale/messages_en.json +11 -0
  77. package/dist/esm/assets/locale/messages_es.json +11 -0
  78. package/dist/esm/assets/locale/messages_fi.json +11 -0
  79. package/dist/esm/assets/locale/messages_fr.json +11 -0
  80. package/dist/esm/assets/locale/messages_he.json +11 -0
  81. package/dist/esm/assets/locale/messages_hi.json +11 -0
  82. package/dist/esm/assets/locale/messages_hu.json +11 -0
  83. package/dist/esm/assets/locale/messages_id.json +11 -0
  84. package/dist/esm/assets/locale/messages_it.json +11 -0
  85. package/dist/esm/assets/locale/messages_ja.json +11 -0
  86. package/dist/esm/assets/locale/messages_ko.json +11 -0
  87. package/dist/esm/assets/locale/messages_lt.json +11 -0
  88. package/dist/esm/assets/locale/messages_ms.json +11 -0
  89. package/dist/esm/assets/locale/messages_nl.json +11 -0
  90. package/dist/esm/assets/locale/messages_no.json +11 -0
  91. package/dist/esm/assets/locale/messages_pl.json +11 -0
  92. package/dist/esm/assets/locale/messages_pt.json +11 -0
  93. package/dist/esm/assets/locale/messages_ro.json +11 -0
  94. package/dist/esm/assets/locale/messages_ru.json +11 -0
  95. package/dist/esm/assets/locale/messages_sk.json +11 -0
  96. package/dist/esm/assets/locale/messages_sl.json +11 -0
  97. package/dist/esm/assets/locale/messages_sv.json +11 -0
  98. package/dist/esm/assets/locale/messages_th.json +11 -0
  99. package/dist/esm/assets/locale/messages_tl.json +11 -0
  100. package/dist/esm/assets/locale/messages_tr.json +11 -0
  101. package/dist/esm/assets/locale/messages_uk.json +11 -0
  102. package/dist/esm/assets/locale/messages_vi.json +11 -0
  103. package/dist/esm/assets/locale/messages_zh.json +11 -0
  104. package/dist/esm/assets/locale/version_file +1 -1
  105. package/dist/esm/assets/visual-e2e-locale/messages_visuale2e.json +29 -2
  106. package/dist/esm/hooks/index.js +0 -1
  107. package/dist/esm/hooks/index.js.map +1 -1
  108. package/dist/esm/hooks/useImport.js +2 -7
  109. package/dist/esm/hooks/useImport.js.map +1 -1
  110. package/dist/esm/index.js +0 -1
  111. package/dist/esm/index.js.map +1 -1
  112. package/dist/esm/state/CustomFieldModalState.js +8 -0
  113. package/dist/esm/state/CustomFieldModalState.js.map +1 -1
  114. package/dist/esm/state/ImportState/ImportState.js +299 -108
  115. package/dist/esm/state/ImportState/ImportState.js.map +1 -1
  116. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js +442 -55
  117. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -1
  118. package/dist/esm/state/ImportState/detectFieldType.js +19 -0
  119. package/dist/esm/state/ImportState/detectFieldType.js.map +1 -1
  120. package/dist/esm/state/ImportState/fieldEdit.js +44 -0
  121. package/dist/esm/state/ImportState/fieldEdit.js.map +1 -1
  122. package/dist/esm/state/TagsState/TagsManagementState.js +17 -33
  123. package/dist/esm/state/TagsState/TagsManagementState.js.map +1 -1
  124. package/dist/esm/state/TagsState/TagsSearchState.js +0 -6
  125. package/dist/esm/state/TagsState/TagsSearchState.js.map +1 -1
  126. package/dist/esm/testkit/createInMemorySchemaSource.js +140 -0
  127. package/dist/esm/testkit/createInMemorySchemaSource.js.map +1 -0
  128. package/dist/esm/testkit/hubJobImportMocks.js +17 -1
  129. package/dist/esm/testkit/hubJobImportMocks.js.map +1 -1
  130. package/dist/esm/testkit/index.js +1 -0
  131. package/dist/esm/testkit/index.js.map +1 -1
  132. package/dist/esm/types/SchemaSource.js.map +1 -1
  133. package/dist/types/hooks/index.d.ts +0 -1
  134. package/dist/types/hooks/index.d.ts.map +1 -1
  135. package/dist/types/hooks/useImport.d.ts +3 -18
  136. package/dist/types/hooks/useImport.d.ts.map +1 -1
  137. package/dist/types/index.d.ts +0 -1
  138. package/dist/types/index.d.ts.map +1 -1
  139. package/dist/types/services/translations.d.ts +20 -1
  140. package/dist/types/services/translations.d.ts.map +1 -1
  141. package/dist/types/state/CustomFieldModalState.d.ts +9 -0
  142. package/dist/types/state/CustomFieldModalState.d.ts.map +1 -1
  143. package/dist/types/state/ImportState/ImportState.d.ts +201 -62
  144. package/dist/types/state/ImportState/ImportState.d.ts.map +1 -1
  145. package/dist/types/state/ImportState/detectFieldType.d.ts +2 -0
  146. package/dist/types/state/ImportState/detectFieldType.d.ts.map +1 -1
  147. package/dist/types/state/ImportState/fieldEdit.d.ts +6 -0
  148. package/dist/types/state/ImportState/fieldEdit.d.ts.map +1 -1
  149. package/dist/types/state/TagsState/TagsManagementState.d.ts.map +1 -1
  150. package/dist/types/state/TagsState/TagsSearchState.d.ts.map +1 -1
  151. package/dist/types/testkit/WixPatternsContainer.driver.d.ts +4 -0
  152. package/dist/types/testkit/WixPatternsContainer.driver.d.ts.map +1 -1
  153. package/dist/types/testkit/createInMemorySchemaSource.d.ts +41 -0
  154. package/dist/types/testkit/createInMemorySchemaSource.d.ts.map +1 -0
  155. package/dist/types/testkit/hubJobImportMocks.d.ts +11 -1
  156. package/dist/types/testkit/hubJobImportMocks.d.ts.map +1 -1
  157. package/dist/types/testkit/index.d.ts +1 -0
  158. package/dist/types/testkit/index.d.ts.map +1 -1
  159. package/dist/types/types/FieldsSource.d.ts +5 -1
  160. package/dist/types/types/FieldsSource.d.ts.map +1 -1
  161. package/dist/types/types/Schema.d.ts +6 -0
  162. package/dist/types/types/Schema.d.ts.map +1 -1
  163. package/dist/types/types/SchemaSource.d.ts +6 -4
  164. package/dist/types/types/SchemaSource.d.ts.map +1 -1
  165. package/package.json +4 -4
  166. package/dist/cjs/hooks/useImportConfig.js +0 -10
  167. package/dist/cjs/hooks/useImportConfig.js.map +0 -1
  168. package/dist/esm/hooks/useImportConfig.js +0 -6
  169. package/dist/esm/hooks/useImportConfig.js.map +0 -1
  170. package/dist/types/hooks/useImportConfig.d.ts +0 -21
  171. 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,22 +101,18 @@ 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;
106
- this.fields = (props.fields ?? []);
107
- this.collectionId = props.collectionId;
108
- this.destination = props.destination;
109
- this.backup = props.backup;
110
- this.updateSchema = props.updateSchema;
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;
109
+ this.config = props.config;
111
110
  this.collection = props.collection;
112
111
  this.importModalState = props.importModalState;
113
112
  this.translate = props.translate;
114
113
  this.errorHandler = props.errorHandler;
115
114
  this.showToast = props.showToast;
116
115
  this.downloadCsv = props.downloadCsv;
117
- this.onJobError = props.onJobError;
118
116
  this._detectColumnType = props.detectColumnType ?? detectColumnType;
119
117
  this.hubJobClient = createHubJobClient({
120
118
  httpClient: props.httpClient,
@@ -138,7 +136,12 @@ export class ImportState {
138
136
  hasMappings: computed,
139
137
  hasNoMappedData: computed,
140
138
  selectedFieldIds: computed,
139
+ missingRequiredFields: computed,
140
+ hasMissingRequiredFields: computed,
141
141
  idMappedHeader: computed,
142
+ editingHeader: observable.ref,
143
+ editingMapping: computed,
144
+ endFieldEdit: action.bound,
142
145
  setWritePolicy: action.bound,
143
146
  onUploadContinue: action.bound,
144
147
  goToUpload: action.bound,
@@ -147,12 +150,56 @@ export class ImportState {
147
150
  setNewFieldConfig: action.bound,
148
151
  beginFieldEdit: action.bound,
149
152
  applyFieldEdit: action.bound,
153
+ applyDataExtensionFieldEdit: action.bound,
150
154
  close: action.bound,
155
+ done: action.bound,
156
+ downloadFailedRows: action.bound,
151
157
  onFileSelected: action.bound,
152
158
  startImport: action.bound,
153
159
  reset: action.bound,
154
160
  });
155
161
  }
162
+ // Import inputs read live from `config` — a live schema-backed config exposes
163
+ // these as getters, a manual config as plain values; both read the same way.
164
+ /** Existing collection fields, for column auto-mapping. */
165
+ get fields() {
166
+ return this.config.fields;
167
+ }
168
+ /** Wix Data collection the import writes into (unset for platformized). */
169
+ get collectionId() {
170
+ const ds = this.config.dataSource;
171
+ return 'collectionId' in ds ? ds.collectionId : undefined;
172
+ }
173
+ get destination() {
174
+ const ds = this.config.dataSource;
175
+ return 'destination' in ds ? ds.destination : undefined;
176
+ }
177
+ get backup() {
178
+ return this.config.backup;
179
+ }
180
+ /** CMS create-fields callback, present only on the `collectionId` branch. */
181
+ get updateSchema() {
182
+ const ds = this.config.dataSource;
183
+ return 'collectionId' in ds ? ds.updateSchema : undefined;
184
+ }
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;
197
+ }
198
+ // The collection's id-field key; a schema-backed config may report a non-`_id`
199
+ // identifier. Defaults to the Wix Data `_id`.
200
+ get idFieldKey() {
201
+ return this.config.idFieldKey ?? ID_FIELD_KEY;
202
+ }
156
203
  init() {
157
204
  return () => {
158
205
  // Unmounting mid-import stops the poll loop (it checks `_cancelled`) so it
@@ -194,6 +241,30 @@ export class ImportState {
194
241
  .filter((m) => m?.kind === 'existing')
195
242
  .map((m) => m.fieldId));
196
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
+ }
197
268
  /**
198
269
  * The CSV header currently mapped to the collection's `_id` field, or `null`
199
270
  * when no column targets it. Drives both the "When items share the same ID"
@@ -201,7 +272,8 @@ export class ImportState {
201
272
  * with no `_id` mapped, every row is a fresh insert and collisions can't occur.
202
273
  */
203
274
  get idMappedHeader() {
204
- const entry = Object.entries(this.mappingByHeader).find(([, mapping]) => mapping?.kind === 'existing' && mapping.fieldId === ID_FIELD_KEY);
275
+ const idFieldKey = this.idFieldKey;
276
+ const entry = Object.entries(this.mappingByHeader).find(([, mapping]) => mapping?.kind === 'existing' && mapping.fieldId === idFieldKey);
205
277
  return entry ? entry[0] : null;
206
278
  }
207
279
  setWritePolicy(policy) {
@@ -209,12 +281,13 @@ export class ImportState {
209
281
  }
210
282
  /**
211
283
  * Whether columns may be mapped to a brand-new field created during import.
212
- * Driven purely by the presence of the `updateSchema` callback — providing the
213
- * capability is the opt-in (see `getDefaultMapping`). Never changes after
214
- * construction, so it isn't an observable.
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`).
215
288
  */
216
289
  get canCreateNewField() {
217
- return this.updateSchema != null;
290
+ return (this.updateSchema != null || this.createDataExtensionFields != null);
218
291
  }
219
292
  /**
220
293
  * Whether the write-policy (id-collision) choice is meaningful for this import.
@@ -246,7 +319,10 @@ export class ImportState {
246
319
  this.result = {
247
320
  succeeded: 0,
248
321
  failed: 0,
322
+ created: 0,
323
+ updated: 0,
249
324
  };
325
+ this._pendingFieldsMap = undefined;
250
326
  this.isBackingUp = false;
251
327
  this.isRestoring = false;
252
328
  this.restoreFn = null;
@@ -255,11 +331,10 @@ export class ImportState {
255
331
  }
256
332
  async close() {
257
333
  // While on the progress step we treat close as a cancel — terminate the job
258
- // and restore the backup — UNLESS the import already succeeded. On the
259
- // success path `startImport` lingers ~600ms on the completed bar with the
260
- // step still 'progress'; closing during that window must NOT restore (it
261
- // would undo a clean import). A successful close just closes the modal and
262
- // 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.
263
338
  if (this.currentStep === 'progress' && !this.importStatus.isSuccess) {
264
339
  this._cancelled = true;
265
340
  this._abortController?.abort();
@@ -282,6 +357,13 @@ export class ImportState {
282
357
  }
283
358
  this.importModalState.close();
284
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
+ }
285
367
  async _restoreBackup() {
286
368
  const restoreFn = this.restoreFn;
287
369
  if (!restoreFn) {
@@ -450,50 +532,44 @@ export class ImportState {
450
532
  },
451
533
  });
452
534
  }
453
- _showPartialSuccessToast() {
454
- this.showToast?.({
455
- type: 'WARNING',
456
- message: this.translate('cairo.import.partialSuccessToast', {
457
- failed: String(this.result.failed),
458
- imported: String(this.result.succeeded),
459
- }),
460
- action: {
461
- text: this.translate('cairo.import.downloadFailedRows'),
462
- onClick: () => this._downloadFailedRows(),
463
- removeToastOnClick: true,
464
- },
465
- });
466
- }
467
535
  /**
468
- * Builds a plain, re-importable CSV of the rows the server rejected the
469
- * original headers plus just the failed rows and hands it to `downloadCsv`.
470
- * The failed rows come from the job's movement logs (which row failed), mapped
471
- * 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.
472
541
  */
473
- async _downloadFailedRows() {
542
+ async downloadFailedRows() {
474
543
  const jobId = this._jobId;
475
544
  if (!jobId) {
476
545
  return;
477
546
  }
478
- let indices;
547
+ let failures;
479
548
  try {
480
- indices = await this._fetchFailedRowIndices(jobId);
549
+ failures = await this._fetchFailedRows(jobId);
481
550
  }
482
551
  catch (error) {
483
552
  console.error('Fetching failed rows report failed', error);
484
553
  return;
485
554
  }
486
- const failedRows = indices
487
- .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
+ })
488
560
  .filter((row) => row != null);
489
- if (failedRows.length === 0) {
561
+ if (rows.length === 0) {
490
562
  return;
491
563
  }
492
- 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);
493
569
  this.downloadCsv?.(content, `failed-${this.fileName || 'import.csv'}`);
494
570
  }
495
- // Pages through the job's movement logs and returns the `csvRows` indices of
496
- // 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
497
573
  // (wix-data-hub: packages/wix-data-hub-executor-c/src/plugins/formats/csv/
498
574
  // csv-binary-decoder.ts) parses with the `csv-parse` library (`columns: true`)
499
575
  // and sets `sourceItemId = "row ${++index + 1}"`, incrementing once **per
@@ -502,8 +578,8 @@ export class ImportState {
502
578
  // `csvRows[0]`, i.e. `csvRows[N - 2]`. Because `csv-parse` is RFC-4180,
503
579
  // quoted cells containing embedded newlines are a single record, so they do
504
580
  // NOT shift the numbering. Out-of-range indices are guarded below.
505
- async _fetchFailedRowIndices(jobId) {
506
- const indices = [];
581
+ async _fetchFailedRows(jobId) {
582
+ const failures = [];
507
583
  let cursor;
508
584
  do {
509
585
  const { logs, pagingMetadata } = await this.hubJobClient.listMovementLogs(jobId, cursor ? { cursor } : undefined);
@@ -516,13 +592,13 @@ export class ImportState {
516
592
  if (Number.isInteger(index) &&
517
593
  index >= 0 &&
518
594
  index < this.csvRows.length) {
519
- indices.push(index);
595
+ failures.push({ index, reason: log.failure.description ?? '' });
520
596
  }
521
597
  }
522
598
  cursor =
523
599
  (pagingMetadata?.hasNext && pagingMetadata.cursors?.next) || undefined;
524
600
  } while (cursor);
525
- return indices;
601
+ return failures;
526
602
  }
527
603
  setMapping(header, fieldId) {
528
604
  this.mappingByHeader = {
@@ -560,23 +636,51 @@ export class ImportState {
560
636
  };
561
637
  }
562
638
  /**
563
- * Marks which column's pending new field the field editor is currently
564
- * editing, so a later `applyFieldEdit` (the editor's `onSubmit`) knows where to
565
- * 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.
566
642
  */
567
643
  beginFieldEdit(header) {
568
- 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;
569
662
  }
570
663
  /**
571
- * `onSubmit` handler for the field editor: maps the edited field spec into the
572
- * 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
573
666
  * edited.
574
667
  */
575
668
  applyFieldEdit(field) {
576
- if (this._editingHeader == null) {
669
+ if (this.editingHeader == null) {
670
+ return;
671
+ }
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) {
577
681
  return;
578
682
  }
579
- this.setNewFieldConfig(this._editingHeader, toFieldEdit(field));
683
+ this.setNewFieldConfig(this.editingHeader, toDataExtensionFieldEdit(draft));
580
684
  }
581
685
  /**
582
686
  * Runs the server-side import: align the CSV to the resolved field keys,
@@ -621,21 +725,30 @@ export class ImportState {
621
725
  // The job ran server-side, so a failure arrives on a 200 (the poll
622
726
  // succeeded; the job didn't) with no HTTP error to carry the application
623
727
  // error. Build a synthetic, HTTP-error-shaped error (the error handler
624
- // reads the code from `response.data.details.applicationError`) and hand
625
- // the throwing operation to the consumer's `onJobError` so THEY wrap it
626
- // with their own `withErrorHandler` + error-code map — `job.error` is the
627
- // consumer's application error (their import business logic), so only they
628
- // can map its codes, and the map stays coupled to the `withErrorHandler`
629
- // call as everywhere else (e.g. `fetchData`). The map is attached to the
630
- // error object, which the progress step resolves via `getResolvedError`.
631
- // Without a handler the same error still throws, falling back to the
632
- // 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.
633
737
  const error = Object.assign(new Error(`ImportJob.${job.error?.code ?? 'failed'}`), { response: { data: { details: { applicationError: job.error } } } });
634
- const run = () => Promise.reject(error);
635
- if (this.onJobError) {
636
- // The consumer wraps `run` with `withErrorHandler`; it re-rejects with
637
- // `error` (now carrying their map), which propagates from here.
638
- 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);
639
752
  }
640
753
  throw error;
641
754
  }
@@ -664,13 +777,21 @@ export class ImportState {
664
777
  const succeeded = progress?.itemsSuccessful ?? 0;
665
778
  const failed = progress?.itemsRejected ?? 0;
666
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;
667
785
  // `totalCount` is fixed up front in `startImport` from the CSV row count
668
786
  // (one uploaded row per CSV row); polling only advances `processedCount`.
669
787
  runInAction(() => {
670
788
  this.processedCount = Math.min(current, this.totalCount);
671
- this.result = { succeeded, failed };
789
+ this.result = { succeeded, failed, created, updated };
672
790
  });
673
791
  }
792
+ _lingerOnCompletion() {
793
+ return new Promise((resolve) => setTimeout(resolve, COMPLETION_LINGER_MS));
794
+ }
674
795
  async _runBackup() {
675
796
  const backup = this.backup;
676
797
  if (!backup) {
@@ -703,9 +824,6 @@ export class ImportState {
703
824
  });
704
825
  return false;
705
826
  }
706
- _lingerOnCompletion() {
707
- return new Promise((resolve) => setTimeout(resolve, COMPLETION_LINGER_MS));
708
- }
709
827
  /**
710
828
  * Resolves every column to the final destination key used in the upload
711
829
  * payload: an existing field id, a new field's (Cairo-decided) key, or `null`
@@ -715,13 +833,16 @@ export class ImportState {
715
833
  */
716
834
  async _resolveFieldMapping() {
717
835
  const resolved = {};
718
- 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 = [];
719
839
  // Seed with existing field ids/display names so derived keys and names never
720
840
  // collide with them or with each other.
721
841
  const usedKeys = new Set(this.fields.map((field) => field.id).filter((id) => !!id));
722
842
  const usedNames = new Set(this.fields
723
843
  .map((field) => field.header)
724
844
  .filter((header) => !!header));
845
+ const canCreate = this.canCreateNewField;
725
846
  for (const header of this.csvHeaders) {
726
847
  const mapping = this.mappingByHeader[header];
727
848
  if (mapping == null) {
@@ -730,35 +851,75 @@ export class ImportState {
730
851
  else if (mapping.kind === 'existing') {
731
852
  resolved[header] = mapping.fieldId;
732
853
  }
733
- else if (this.updateSchema) {
854
+ else if (canCreate) {
734
855
  // The user's name override (from the field editor) seeds the unique
735
856
  // name/key derivation; otherwise fall back to the column header.
736
857
  const baseName = mapping.displayName?.trim() || header;
737
858
  const { displayName, key } = uniqueFieldNameAndKey(baseName, usedNames, usedKeys);
738
859
  usedKeys.add(key);
739
860
  usedNames.add(displayName);
740
- newFields.push({
741
- key,
742
- type: mapping.fieldType,
743
- displayName,
744
- ...(mapping.config && { config: mapping.config }),
745
- });
861
+ pending.push({ key, displayName, mapping });
746
862
  resolved[header] = key;
747
863
  }
748
864
  else {
749
- // "new" mapping without an `updateSchema` callback — nothing to map into.
865
+ // "new" mapping with no create callback on this source — nothing to map into.
750
866
  resolved[header] = null;
751
867
  }
752
868
  }
753
- if (newFields.length > 0 && this.updateSchema) {
754
- await this.updateSchema(newFields);
869
+ if (pending.length > 0) {
870
+ // Stash the returned fields map to merge into the table on success; kept off
871
+ // the observable until then so columns don't update mid-import.
872
+ this._pendingFieldsMap = await this._createPendingFields(pending);
755
873
  }
756
874
  return resolved;
757
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
+ }
758
907
  async startImport() {
759
908
  if (!this.hasMappings) {
760
909
  return;
761
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
+ }
762
923
  this._cancelled = false;
763
924
  this._abortController = new AbortController();
764
925
  // One uploaded row per CSV row (the rewrite drops columns, not rows), so the
@@ -775,8 +936,13 @@ export class ImportState {
775
936
  this.result = {
776
937
  succeeded: 0,
777
938
  failed: 0,
939
+ created: 0,
940
+ updated: 0,
778
941
  };
779
942
  this._jobId = null;
943
+ // Drop any fields map from a prior attempt so a no-new-fields "Try again"
944
+ // can't merge stale fields.
945
+ this._pendingFieldsMap = undefined;
780
946
  });
781
947
  const backupFailed = await this._runBackup();
782
948
  if (backupFailed) {
@@ -809,6 +975,11 @@ export class ImportState {
809
975
  ...queryStatus.loading,
810
976
  promise,
811
977
  };
978
+ // Assigned only on the success path below, so the post-import merge (after the
979
+ // try/catch) fires only for a completed import — without re-checking status
980
+ // there. Reading `_pendingFieldsMap` directly wouldn't do: it's set pre-upload,
981
+ // so it stays truthy even when the job later fails.
982
+ let createdFields;
812
983
  try {
813
984
  await promise;
814
985
  if (this._cancelled) {
@@ -819,24 +990,30 @@ export class ImportState {
819
990
  ...queryStatus.success,
820
991
  data: this.result,
821
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;
822
998
  });
823
- // On a clean import, hold the completed (100%) progress bar on screen
824
- // briefly so the completion is perceptible even for very few rows. Skip
825
- // the wait when there are failed rows that path closes and surfaces a
826
- // toast instead.
827
- if (this.result.failed === 0) {
828
- await this._lingerOnCompletion();
829
- if (this._cancelled) {
830
- return;
831
- }
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;
832
1005
  }
1006
+ // Success path only. `_pendingFieldsMap` holds this run's returned map — it's
1007
+ // cleared at the start of `startImport` (the stale-guard) and set by
1008
+ // `_resolveFieldMapping`; snapshotting here keeps the merge success-gated.
1009
+ createdFields = this._pendingFieldsMap;
833
1010
  runInAction(() => {
834
- // Rows the server rejected are reported via a toast (the downloadable
835
- // failed-rows report is added by the failed-rows-report task).
836
- if (this.result.failed > 0) {
837
- this._showPartialSuccessToast();
838
- }
839
- 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';
840
1017
  this.collection.clearResultAndMoveToStart({ force: true });
841
1018
  });
842
1019
  }
@@ -848,6 +1025,20 @@ export class ImportState {
848
1025
  };
849
1026
  });
850
1027
  }
1028
+ // Merge the created fields into the table so the new columns appear with no
1029
+ // manual reload (CAIRO-4400). Called imperatively here, outside the try/catch
1030
+ // above, so a merge failure can't flip a completed import into the error state
1031
+ // (whose "Try again" re-imports every row). `createdFields` is set only on the
1032
+ // success path and only when fields were created.
1033
+ if (createdFields) {
1034
+ try {
1035
+ this.config.mergeFields?.(createdFields);
1036
+ }
1037
+ catch (error) {
1038
+ // eslint-disable-next-line no-console
1039
+ console.error('ImportState config.mergeFields failed', error);
1040
+ }
1041
+ }
851
1042
  }
852
1043
  }
853
1044
  //# sourceMappingURL=ImportState.js.map