@wix/bex-core 2.332.0 → 2.336.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 (186) hide show
  1. package/dist/cjs/assets/locale/messages_ar.json +13 -2
  2. package/dist/cjs/assets/locale/messages_bg.json +13 -2
  3. package/dist/cjs/assets/locale/messages_ca.json +13 -2
  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 +13 -2
  8. package/dist/cjs/assets/locale/messages_en.json +13 -2
  9. package/dist/cjs/assets/locale/messages_es.json +11 -0
  10. package/dist/cjs/assets/locale/messages_fi.json +13 -2
  11. package/dist/cjs/assets/locale/messages_fr.json +11 -0
  12. package/dist/cjs/assets/locale/messages_he.json +13 -2
  13. package/dist/cjs/assets/locale/messages_hi.json +11 -0
  14. package/dist/cjs/assets/locale/messages_hu.json +13 -2
  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 +13 -2
  20. package/dist/cjs/assets/locale/messages_ms.json +13 -2
  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 +13 -2
  26. package/dist/cjs/assets/locale/messages_ru.json +11 -0
  27. package/dist/cjs/assets/locale/messages_sk.json +13 -2
  28. package/dist/cjs/assets/locale/messages_sl.json +13 -2
  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 +13 -2
  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 +6 -6
  39. package/dist/cjs/hooks/index.js.map +1 -1
  40. package/dist/cjs/hooks/useImport.js +4 -0
  41. package/dist/cjs/hooks/useImport.js.map +1 -1
  42. package/dist/cjs/hooks/useSchemaSource.js +11 -0
  43. package/dist/cjs/hooks/useSchemaSource.js.map +1 -0
  44. package/dist/cjs/index.js +4 -9
  45. package/dist/cjs/index.js.map +1 -1
  46. package/dist/cjs/services/translations.js.map +1 -1
  47. package/dist/cjs/state/CustomFieldModalState.js +12 -0
  48. package/dist/cjs/state/CustomFieldModalState.js.map +1 -1
  49. package/dist/cjs/state/ImportState/ImportState.js +415 -136
  50. package/dist/cjs/state/ImportState/ImportState.js.map +1 -1
  51. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js +417 -82
  52. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -1
  53. package/dist/cjs/state/ImportState/__tests__/buildImportConfig.spec.js +156 -0
  54. package/dist/cjs/state/ImportState/__tests__/buildImportConfig.spec.js.map +1 -0
  55. package/dist/cjs/state/ImportState/detectFieldType.js +41 -0
  56. package/dist/cjs/state/ImportState/detectFieldType.js.map +1 -1
  57. package/dist/cjs/state/ImportState/fieldEdit.js +49 -0
  58. package/dist/cjs/state/ImportState/fieldEdit.js.map +1 -1
  59. package/dist/cjs/state/SourceState.js +150 -0
  60. package/dist/cjs/state/SourceState.js.map +1 -0
  61. package/dist/cjs/state/index.js +7 -0
  62. package/dist/cjs/state/index.js.map +1 -1
  63. package/dist/cjs/testkit/createInMemorySchemaSource.js +190 -0
  64. package/dist/cjs/testkit/createInMemorySchemaSource.js.map +1 -0
  65. package/dist/cjs/testkit/hubJobImportMocks.js +18 -2
  66. package/dist/cjs/testkit/hubJobImportMocks.js.map +1 -1
  67. package/dist/cjs/testkit/index.js +6 -0
  68. package/dist/cjs/testkit/index.js.map +1 -1
  69. package/dist/cjs/types/Field.js.map +1 -1
  70. package/dist/cjs/types/Schema.js.map +1 -1
  71. package/dist/cjs/types/SchemaSource.js +31 -21
  72. package/dist/cjs/types/SchemaSource.js.map +1 -1
  73. package/dist/esm/assets/locale/messages_ar.json +13 -2
  74. package/dist/esm/assets/locale/messages_bg.json +13 -2
  75. package/dist/esm/assets/locale/messages_ca.json +13 -2
  76. package/dist/esm/assets/locale/messages_cs.json +11 -0
  77. package/dist/esm/assets/locale/messages_da.json +11 -0
  78. package/dist/esm/assets/locale/messages_de.json +11 -0
  79. package/dist/esm/assets/locale/messages_el.json +13 -2
  80. package/dist/esm/assets/locale/messages_en.json +13 -2
  81. package/dist/esm/assets/locale/messages_es.json +11 -0
  82. package/dist/esm/assets/locale/messages_fi.json +13 -2
  83. package/dist/esm/assets/locale/messages_fr.json +11 -0
  84. package/dist/esm/assets/locale/messages_he.json +13 -2
  85. package/dist/esm/assets/locale/messages_hi.json +11 -0
  86. package/dist/esm/assets/locale/messages_hu.json +13 -2
  87. package/dist/esm/assets/locale/messages_id.json +11 -0
  88. package/dist/esm/assets/locale/messages_it.json +11 -0
  89. package/dist/esm/assets/locale/messages_ja.json +11 -0
  90. package/dist/esm/assets/locale/messages_ko.json +11 -0
  91. package/dist/esm/assets/locale/messages_lt.json +13 -2
  92. package/dist/esm/assets/locale/messages_ms.json +13 -2
  93. package/dist/esm/assets/locale/messages_nl.json +11 -0
  94. package/dist/esm/assets/locale/messages_no.json +11 -0
  95. package/dist/esm/assets/locale/messages_pl.json +11 -0
  96. package/dist/esm/assets/locale/messages_pt.json +11 -0
  97. package/dist/esm/assets/locale/messages_ro.json +13 -2
  98. package/dist/esm/assets/locale/messages_ru.json +11 -0
  99. package/dist/esm/assets/locale/messages_sk.json +13 -2
  100. package/dist/esm/assets/locale/messages_sl.json +13 -2
  101. package/dist/esm/assets/locale/messages_sv.json +11 -0
  102. package/dist/esm/assets/locale/messages_th.json +11 -0
  103. package/dist/esm/assets/locale/messages_tl.json +13 -2
  104. package/dist/esm/assets/locale/messages_tr.json +11 -0
  105. package/dist/esm/assets/locale/messages_uk.json +11 -0
  106. package/dist/esm/assets/locale/messages_vi.json +11 -0
  107. package/dist/esm/assets/locale/messages_zh.json +11 -0
  108. package/dist/esm/assets/locale/version_file +1 -1
  109. package/dist/esm/assets/visual-e2e-locale/messages_visuale2e.json +29 -2
  110. package/dist/esm/hooks/index.js +1 -1
  111. package/dist/esm/hooks/index.js.map +1 -1
  112. package/dist/esm/hooks/useImport.js +3 -1
  113. package/dist/esm/hooks/useImport.js.map +1 -1
  114. package/dist/esm/hooks/useSchemaSource.js +7 -0
  115. package/dist/esm/hooks/useSchemaSource.js.map +1 -0
  116. package/dist/esm/index.js +1 -2
  117. package/dist/esm/index.js.map +1 -1
  118. package/dist/esm/state/CustomFieldModalState.js +8 -0
  119. package/dist/esm/state/CustomFieldModalState.js.map +1 -1
  120. package/dist/esm/state/ImportState/ImportState.js +306 -113
  121. package/dist/esm/state/ImportState/ImportState.js.map +1 -1
  122. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js +297 -70
  123. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -1
  124. package/dist/esm/state/ImportState/__tests__/buildImportConfig.spec.js +100 -0
  125. package/dist/esm/state/ImportState/__tests__/buildImportConfig.spec.js.map +1 -0
  126. package/dist/esm/state/ImportState/detectFieldType.js +34 -15
  127. package/dist/esm/state/ImportState/detectFieldType.js.map +1 -1
  128. package/dist/esm/state/ImportState/fieldEdit.js +44 -0
  129. package/dist/esm/state/ImportState/fieldEdit.js.map +1 -1
  130. package/dist/esm/state/SourceState.js +131 -0
  131. package/dist/esm/state/SourceState.js.map +1 -0
  132. package/dist/esm/state/index.js +1 -0
  133. package/dist/esm/state/index.js.map +1 -1
  134. package/dist/esm/testkit/createInMemorySchemaSource.js +141 -0
  135. package/dist/esm/testkit/createInMemorySchemaSource.js.map +1 -0
  136. package/dist/esm/testkit/hubJobImportMocks.js +17 -1
  137. package/dist/esm/testkit/hubJobImportMocks.js.map +1 -1
  138. package/dist/esm/testkit/index.js +1 -0
  139. package/dist/esm/testkit/index.js.map +1 -1
  140. package/dist/esm/types/SchemaSource.js +21 -14
  141. package/dist/esm/types/SchemaSource.js.map +1 -1
  142. package/dist/types/hooks/index.d.ts +1 -1
  143. package/dist/types/hooks/index.d.ts.map +1 -1
  144. package/dist/types/hooks/useImport.d.ts +9 -3
  145. package/dist/types/hooks/useImport.d.ts.map +1 -1
  146. package/dist/types/hooks/useSchemaSource.d.ts +4 -0
  147. package/dist/types/hooks/useSchemaSource.d.ts.map +1 -0
  148. package/dist/types/index.d.ts +2 -3
  149. package/dist/types/index.d.ts.map +1 -1
  150. package/dist/types/services/translations.d.ts +7 -1
  151. package/dist/types/services/translations.d.ts.map +1 -1
  152. package/dist/types/state/CustomFieldModalState.d.ts +9 -0
  153. package/dist/types/state/CustomFieldModalState.d.ts.map +1 -1
  154. package/dist/types/state/ImportState/ImportState.d.ts +208 -65
  155. package/dist/types/state/ImportState/ImportState.d.ts.map +1 -1
  156. package/dist/types/state/ImportState/__tests__/buildImportConfig.spec.d.ts +2 -0
  157. package/dist/types/state/ImportState/__tests__/buildImportConfig.spec.d.ts.map +1 -0
  158. package/dist/types/state/ImportState/detectFieldType.d.ts +5 -0
  159. package/dist/types/state/ImportState/detectFieldType.d.ts.map +1 -1
  160. package/dist/types/state/ImportState/fieldEdit.d.ts +6 -0
  161. package/dist/types/state/ImportState/fieldEdit.d.ts.map +1 -1
  162. package/dist/types/state/SourceState.d.ts +39 -0
  163. package/dist/types/state/SourceState.d.ts.map +1 -0
  164. package/dist/types/state/index.d.ts +1 -0
  165. package/dist/types/state/index.d.ts.map +1 -1
  166. package/dist/types/testkit/WixPatternsContainer.driver.d.ts +4 -0
  167. package/dist/types/testkit/WixPatternsContainer.driver.d.ts.map +1 -1
  168. package/dist/types/testkit/createInMemorySchemaSource.d.ts +43 -0
  169. package/dist/types/testkit/createInMemorySchemaSource.d.ts.map +1 -0
  170. package/dist/types/testkit/hubJobImportMocks.d.ts +11 -1
  171. package/dist/types/testkit/hubJobImportMocks.d.ts.map +1 -1
  172. package/dist/types/testkit/index.d.ts +1 -0
  173. package/dist/types/testkit/index.d.ts.map +1 -1
  174. package/dist/types/types/Field.d.ts +3 -0
  175. package/dist/types/types/Field.d.ts.map +1 -1
  176. package/dist/types/types/Schema.d.ts +10 -6
  177. package/dist/types/types/Schema.d.ts.map +1 -1
  178. package/dist/types/types/SchemaSource.d.ts +30 -13
  179. package/dist/types/types/SchemaSource.d.ts.map +1 -1
  180. package/package.json +4 -4
  181. package/dist/cjs/hooks/useImportConfig.js +0 -12
  182. package/dist/cjs/hooks/useImportConfig.js.map +0 -1
  183. package/dist/esm/hooks/useImportConfig.js +0 -6
  184. package/dist/esm/hooks/useImportConfig.js.map +0 -1
  185. package/dist/types/hooks/useImportConfig.d.ts +0 -5
  186. package/dist/types/hooks/useImportConfig.d.ts.map +0 -1
@@ -6,17 +6,71 @@ import { parseCsv, unparseCsv } from './parseCsv';
6
6
  import { rewriteParsedCsvForImport } from './rewriteCsv';
7
7
  import { createHubJobClient } from './hubJobClient';
8
8
  import { pollImportJob } from './importJobPoller';
9
- import { detectColumnType, TYPE_SAMPLE_SIZE, } from './detectFieldType';
10
- import { toFieldEdit } from './fieldEdit';
9
+ import { detectColumnType, toPatternsFieldType, TYPE_SAMPLE_SIZE, } from './detectFieldType';
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
+ // Builds the Field to create from a pending new column: import decides the key,
17
+ // and the detected NewFieldType maps to the schema's PatternsFieldType. Import
18
+ // only ever creates non-reference fields, so the cast is safe.
19
+ const toField = (def) => ({
20
+ id: def.key,
21
+ displayName: def.displayName,
22
+ type: toPatternsFieldType(def.type),
23
+ ...(def.config?.validation && { validation: def.config.validation }),
24
+ ...(def.config?.isPii && { isPii: true }),
25
+ ...(def.config?.helpText && { helpText: def.config.helpText }),
26
+ });
27
+ /**
28
+ * Assembles a `ResolvedImportConfig` from the table's live `fieldsSource` and the
29
+ * consumer `inputs`. The schema-derived members are getters so they read the
30
+ * source live — fields import creates and fields added through the
31
+ * field-management panel show up immediately. Empty before a source attaches;
32
+ * nothing imports until then.
33
+ *
34
+ * Exported for tests.
35
+ */
36
+ export const buildImportConfig = (fieldsSource, collection, inputs = {}) => {
37
+ const { destination, fields = [], backup, idFieldKey, jobApplicationErrorMap, } = inputs;
38
+ return {
39
+ get fields() {
40
+ const seen = new Set(fields.map((f) => f.id));
41
+ const live = (fieldsSource?.fields ?? [])
42
+ .filter((f) => !seen.has(f.id))
43
+ .map((f) => ({
44
+ id: f.id,
45
+ header: f.displayName,
46
+ required: f.validation?.required,
47
+ }));
48
+ return [...fields, ...live];
49
+ },
50
+ get dataSource() {
51
+ if (destination) {
52
+ return { destination };
53
+ }
54
+ const add = fieldsSource?.fieldManagement?.canAddFields
55
+ ? fieldsSource.fieldActions?.addFields
56
+ : undefined;
57
+ return {
58
+ collectionId: collection.queryName,
59
+ ...(add && {
60
+ updateSchema: (defs) => add(defs.map(toField)),
61
+ }),
62
+ };
63
+ },
64
+ mergeFields: (f) => fieldsSource?.mergeFields?.(f),
65
+ ...(backup && { backup }),
66
+ ...(idFieldKey && { idFieldKey }),
67
+ ...(jobApplicationErrorMap && { jobApplicationErrorMap }),
68
+ };
69
+ };
70
+ // How long to hold the completed (100%) progress bar on screen before moving to
71
+ // the summary step (CAIRO-4476). The job reports counts only when it finishes,
72
+ // so the bar sits at 0 for the whole import and would otherwise flip straight to
73
+ // the summary without the user ever seeing it reach completion.
20
74
  const COMPLETION_LINGER_MS = 600;
21
75
  // The Wix Data collection's id field key. A CSV column mapped to this field lets
22
76
  // the user choose how rows that collide on `_id` are written (writePolicy).
@@ -90,6 +144,8 @@ export class ImportState {
90
144
  this.result = {
91
145
  succeeded: 0,
92
146
  failed: 0,
147
+ created: 0,
148
+ updated: 0,
93
149
  };
94
150
  this.isBackingUp = false;
95
151
  this.isRestoring = false;
@@ -99,12 +155,17 @@ export class ImportState {
99
155
  // Id of the running data-movement job, set once submitted — used to cancel it
100
156
  // (`terminateJob`) if the user closes mid-import.
101
157
  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.config = props.config;
158
+ // The column whose pending new field the field editor is currently editing, or
159
+ // `null` when the editor is closed. Observable so the source's field-editor
160
+ // component (rendered via `renderFieldEditor`) opens/prefills itself when this
161
+ // is set and closes when it clears — the editor is decoupled from the layout.
162
+ this.editingHeader = null;
107
163
  this.collection = props.collection;
164
+ // A pre-built config wins (hand-built / tests); otherwise assemble it from
165
+ // the live source + inputs.
166
+ this.config =
167
+ props.config ??
168
+ buildImportConfig(props.fieldsSource, props.collection, props.importInputs);
108
169
  this.importModalState = props.importModalState;
109
170
  this.translate = props.translate;
110
171
  this.errorHandler = props.errorHandler;
@@ -133,7 +194,12 @@ export class ImportState {
133
194
  hasMappings: computed,
134
195
  hasNoMappedData: computed,
135
196
  selectedFieldIds: computed,
197
+ missingRequiredFields: computed,
198
+ hasMissingRequiredFields: computed,
136
199
  idMappedHeader: computed,
200
+ editingHeader: observable.ref,
201
+ editingMapping: computed,
202
+ endFieldEdit: action.bound,
137
203
  setWritePolicy: action.bound,
138
204
  onUploadContinue: action.bound,
139
205
  goToUpload: action.bound,
@@ -142,7 +208,10 @@ export class ImportState {
142
208
  setNewFieldConfig: action.bound,
143
209
  beginFieldEdit: action.bound,
144
210
  applyFieldEdit: action.bound,
211
+ applyDataExtensionFieldEdit: action.bound,
145
212
  close: action.bound,
213
+ done: action.bound,
214
+ downloadFailedRows: action.bound,
146
215
  onFileSelected: action.bound,
147
216
  startImport: action.bound,
148
217
  reset: action.bound,
@@ -156,19 +225,33 @@ export class ImportState {
156
225
  }
157
226
  /** Wix Data collection the import writes into (unset for platformized). */
158
227
  get collectionId() {
159
- return this.config.collectionId;
228
+ const ds = this.config.dataSource;
229
+ return 'collectionId' in ds ? ds.collectionId : undefined;
160
230
  }
161
231
  get destination() {
162
- return this.config.destination;
232
+ const ds = this.config.dataSource;
233
+ return 'destination' in ds ? ds.destination : undefined;
163
234
  }
164
235
  get backup() {
165
236
  return this.config.backup;
166
237
  }
238
+ /** CMS create-fields callback, present only on the `collectionId` branch. */
167
239
  get updateSchema() {
168
- return this.config.updateSchema;
240
+ const ds = this.config.dataSource;
241
+ return 'collectionId' in ds ? ds.updateSchema : undefined;
242
+ }
243
+ /** DataExtension create-fields callback, present only on the `destination` branch. */
244
+ get createDataExtensionFields() {
245
+ const ds = this.config.dataSource;
246
+ return 'destination' in ds ? ds.createFields : undefined;
247
+ }
248
+ /** DataExtension field-management for the editor, on the `destination` branch. */
249
+ get dataExtensionFieldManagement() {
250
+ const ds = this.config.dataSource;
251
+ return 'destination' in ds ? ds.fieldManagement : undefined;
169
252
  }
170
- get onJobError() {
171
- return this.config.onJobError;
253
+ get jobApplicationErrorMap() {
254
+ return this.config.jobApplicationErrorMap;
172
255
  }
173
256
  // The collection's id-field key; a schema-backed config may report a non-`_id`
174
257
  // identifier. Defaults to the Wix Data `_id`.
@@ -216,6 +299,30 @@ export class ImportState {
216
299
  .filter((m) => m?.kind === 'existing')
217
300
  .map((m) => m.fieldId));
218
301
  }
302
+ /**
303
+ * Required destination fields that no CSV column is mapped to (CAIRO-4452).
304
+ * A required field is an existing collection field, so only an `existing`
305
+ * mapping can satisfy it — a "create new field" column can't. Covers both
306
+ * ways a required field ends up unmapped: the CSV has no matching column at
307
+ * all, or the user set its column to "don't import". Drives the footer
308
+ * indicator and blocks `startImport`.
309
+ */
310
+ get missingRequiredFields() {
311
+ return this.fields.filter((field) => field.required &&
312
+ field.id != null &&
313
+ !this.selectedFieldIds.has(field.id));
314
+ }
315
+ get hasMissingRequiredFields() {
316
+ return this.missingRequiredFields.length > 0;
317
+ }
318
+ // Comma-separated display names of the missing required fields, for the block
319
+ // toast. Falls back to the field id when a field carries no header.
320
+ _missingRequiredFieldNames() {
321
+ return this.missingRequiredFields
322
+ .map((field) => field.header || field.id || '')
323
+ .filter(Boolean)
324
+ .join(', ');
325
+ }
219
326
  /**
220
327
  * The CSV header currently mapped to the collection's `_id` field, or `null`
221
328
  * when no column targets it. Drives both the "When items share the same ID"
@@ -232,12 +339,13 @@ export class ImportState {
232
339
  }
233
340
  /**
234
341
  * 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`).
342
+ * The opt-in is the presence of the active `dataSource` branch's create
343
+ * callback `updateSchema` (CMS) or `createFields` (DataExtension). A source
344
+ * that can't create fields (e.g. no create permission) supplies neither, so
345
+ * columns fall back to "don't import" (see `getDefaultMapping`).
238
346
  */
239
347
  get canCreateNewField() {
240
- return this.config.canAddFields ?? this.config.updateSchema != null;
348
+ return this.updateSchema != null || this.createDataExtensionFields != null;
241
349
  }
242
350
  /**
243
351
  * Whether the write-policy (id-collision) choice is meaningful for this import.
@@ -269,6 +377,8 @@ export class ImportState {
269
377
  this.result = {
270
378
  succeeded: 0,
271
379
  failed: 0,
380
+ created: 0,
381
+ updated: 0,
272
382
  };
273
383
  this._pendingFieldsMap = undefined;
274
384
  this.isBackingUp = false;
@@ -279,11 +389,10 @@ export class ImportState {
279
389
  }
280
390
  async close() {
281
391
  // 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.
392
+ // and restore the backup — UNLESS the import already succeeded. On success
393
+ // `startImport` moves straight to the 'summary' step, so a close from there
394
+ // just closes the modal (the summary's own `done` is the normal exit); this
395
+ // guard covers only closing mid-import.
287
396
  if (this.currentStep === 'progress' && !this.importStatus.isSuccess) {
288
397
  this._cancelled = true;
289
398
  this._abortController?.abort();
@@ -306,6 +415,13 @@ export class ImportState {
306
415
  }
307
416
  this.importModalState.close();
308
417
  }
418
+ /**
419
+ * Closes the summary step at the end of a completed import. The collection was
420
+ * already refreshed when the summary opened, so this only dismisses the modal.
421
+ */
422
+ done() {
423
+ this.importModalState.close();
424
+ }
309
425
  async _restoreBackup() {
310
426
  const restoreFn = this.restoreFn;
311
427
  if (!restoreFn) {
@@ -474,50 +590,44 @@ export class ImportState {
474
590
  },
475
591
  });
476
592
  }
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
593
  /**
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.
594
+ * Builds a re-importable CSV of the rows the server rejected and hands it to
595
+ * `downloadCsv`. Each failed row is prefixed with a leading "why it failed"
596
+ * column holding the server's error message, so the user can see and fix each
597
+ * reason. The failed rows (and their reasons) come from the job's movement
598
+ * logs, mapped back to the parsed `csvRows` we still hold in memory.
496
599
  */
497
- async _downloadFailedRows() {
600
+ async downloadFailedRows() {
498
601
  const jobId = this._jobId;
499
602
  if (!jobId) {
500
603
  return;
501
604
  }
502
- let indices;
605
+ let failures;
503
606
  try {
504
- indices = await this._fetchFailedRowIndices(jobId);
607
+ failures = await this._fetchFailedRows(jobId);
505
608
  }
506
609
  catch (error) {
507
610
  console.error('Fetching failed rows report failed', error);
508
611
  return;
509
612
  }
510
- const failedRows = indices
511
- .map((index) => this.csvRows[index])
613
+ const rows = failures
614
+ .map(({ index, reason }) => {
615
+ const row = this.csvRows[index];
616
+ return row != null ? [reason, ...row] : null;
617
+ })
512
618
  .filter((row) => row != null);
513
- if (failedRows.length === 0) {
619
+ if (rows.length === 0) {
514
620
  return;
515
621
  }
516
- const content = unparseCsv(this.csvHeaders, failedRows);
622
+ const headers = [
623
+ this.translate('cairo.import.failureReasonColumn'),
624
+ ...this.csvHeaders,
625
+ ];
626
+ const content = unparseCsv(headers, rows);
517
627
  this.downloadCsv?.(content, `failed-${this.fileName || 'import.csv'}`);
518
628
  }
519
- // Pages through the job's movement logs and returns the `csvRows` indices of
520
- // the rows that failed. data-hub's CSV decoder
629
+ // Pages through the job's movement logs and returns, for each row that failed,
630
+ // its `csvRows` index and the server's failure reason. data-hub's CSV decoder
521
631
  // (wix-data-hub: packages/wix-data-hub-executor-c/src/plugins/formats/csv/
522
632
  // csv-binary-decoder.ts) parses with the `csv-parse` library (`columns: true`)
523
633
  // and sets `sourceItemId = "row ${++index + 1}"`, incrementing once **per
@@ -526,8 +636,8 @@ export class ImportState {
526
636
  // `csvRows[0]`, i.e. `csvRows[N - 2]`. Because `csv-parse` is RFC-4180,
527
637
  // quoted cells containing embedded newlines are a single record, so they do
528
638
  // NOT shift the numbering. Out-of-range indices are guarded below.
529
- async _fetchFailedRowIndices(jobId) {
530
- const indices = [];
639
+ async _fetchFailedRows(jobId) {
640
+ const failures = [];
531
641
  let cursor;
532
642
  do {
533
643
  const { logs, pagingMetadata } = await this.hubJobClient.listMovementLogs(jobId, cursor ? { cursor } : undefined);
@@ -540,13 +650,13 @@ export class ImportState {
540
650
  if (Number.isInteger(index) &&
541
651
  index >= 0 &&
542
652
  index < this.csvRows.length) {
543
- indices.push(index);
653
+ failures.push({ index, reason: log.failure.description ?? '' });
544
654
  }
545
655
  }
546
656
  cursor =
547
657
  (pagingMetadata?.hasNext && pagingMetadata.cursors?.next) || undefined;
548
658
  } while (cursor);
549
- return indices;
659
+ return failures;
550
660
  }
551
661
  setMapping(header, fieldId) {
552
662
  this.mappingByHeader = {
@@ -584,23 +694,51 @@ export class ImportState {
584
694
  };
585
695
  }
586
696
  /**
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.
697
+ * Opens the field editor for a column's pending new field: sets
698
+ * `editingHeader`, which the source's editor component watches to open and
699
+ * prefill itself. No-op if the column isn't mapped to a new field.
590
700
  */
591
701
  beginFieldEdit(header) {
592
- this._editingHeader = header;
702
+ if (this.mappingByHeader[header]?.kind === 'new') {
703
+ this.editingHeader = header;
704
+ }
705
+ }
706
+ /** Closes the field editor (clears `editingHeader`). */
707
+ endFieldEdit() {
708
+ this.editingHeader = null;
709
+ }
710
+ /**
711
+ * The `new` mapping currently being edited, or `null` when the editor is
712
+ * closed. The editor component reads this to prefill its form.
713
+ */
714
+ get editingMapping() {
715
+ if (this.editingHeader == null) {
716
+ return null;
717
+ }
718
+ const mapping = this.mappingByHeader[this.editingHeader];
719
+ return mapping?.kind === 'new' ? mapping : null;
593
720
  }
594
721
  /**
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
722
+ * `onSubmit` handler for the CMS field editor: maps the edited field spec into
723
+ * the column being edited (set via `beginFieldEdit`). No-op if nothing is being
597
724
  * edited.
598
725
  */
599
726
  applyFieldEdit(field) {
600
- if (this._editingHeader == null) {
727
+ if (this.editingHeader == null) {
601
728
  return;
602
729
  }
603
- this.setNewFieldConfig(this._editingHeader, toFieldEdit(field));
730
+ this.setNewFieldConfig(this.editingHeader, toFieldEdit(field));
731
+ }
732
+ /**
733
+ * `onSubmit` handler for the DataExtension field editor: maps the edited
734
+ * DataExtension draft (which carries per-audience permissions) into the column
735
+ * being edited. The `destination`-branch counterpart to `applyFieldEdit`.
736
+ */
737
+ applyDataExtensionFieldEdit(draft) {
738
+ if (this.editingHeader == null) {
739
+ return;
740
+ }
741
+ this.setNewFieldConfig(this.editingHeader, toDataExtensionFieldEdit(draft));
604
742
  }
605
743
  /**
606
744
  * Runs the server-side import: align the CSV to the resolved field keys,
@@ -645,21 +783,30 @@ export class ImportState {
645
783
  // The job ran server-side, so a failure arrives on a 200 (the poll
646
784
  // succeeded; the job didn't) with no HTTP error to carry the application
647
785
  // 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.
786
+ // reads the code from `response.data.details.applicationError`), then
787
+ // resolve it through the consumer's per-code map via `withErrorHandler`
788
+ // the same way as `fetchData` etc. `job.error` is the consumer's
789
+ // application error (their import business logic), so only they can map
790
+ // its codes; they supply just the code→message map and we do the
791
+ // wrapping. `withErrorHandler` attaches the map to the error and
792
+ // re-rejects it, so the progress step resolves a consumer-specific
793
+ // message via `getResolvedError`. Without a map the same error still
794
+ // throws, falling back to the generic message.
657
795
  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);
796
+ const jobApplicationErrorMap = this.jobApplicationErrorMap;
797
+ if (jobApplicationErrorMap) {
798
+ // Adapt the consumer's code→message map into the error handler's
799
+ // `errorCodesMap`: each entry, when its code matches the job's
800
+ // application error, resolves with that error. `withErrorHandler`
801
+ // attaches the resolved message to `error` and re-rejects it, so the
802
+ // progress step surfaces it via `getResolvedError` — the consumer only
803
+ // supplies the map, not the wrapping.
804
+ const appError = (job.error ?? {});
805
+ const errorCodesMap = Object.fromEntries(Object.entries(jobApplicationErrorMap).map(([code, resolve]) => [
806
+ code,
807
+ () => resolve(appError),
808
+ ]));
809
+ await this.errorHandler.withErrorHandler(() => Promise.reject(error), errorCodesMap);
663
810
  }
664
811
  throw error;
665
812
  }
@@ -688,13 +835,21 @@ export class ImportState {
688
835
  const succeeded = progress?.itemsSuccessful ?? 0;
689
836
  const failed = progress?.itemsRejected ?? 0;
690
837
  const current = progress?.current ?? succeeded + failed;
838
+ // The server splits successful items into created (new) and updated
839
+ // (existing) — the summary step reports each. Absent on older jobs, in
840
+ // which case both stay 0 and only the total `succeeded` is shown.
841
+ const created = progress?.itemsSuccessfulInfo?.itemsCreated ?? 0;
842
+ const updated = progress?.itemsSuccessfulInfo?.itemsUpdated ?? 0;
691
843
  // `totalCount` is fixed up front in `startImport` from the CSV row count
692
844
  // (one uploaded row per CSV row); polling only advances `processedCount`.
693
845
  runInAction(() => {
694
846
  this.processedCount = Math.min(current, this.totalCount);
695
- this.result = { succeeded, failed };
847
+ this.result = { succeeded, failed, created, updated };
696
848
  });
697
849
  }
850
+ _lingerOnCompletion() {
851
+ return new Promise((resolve) => setTimeout(resolve, COMPLETION_LINGER_MS));
852
+ }
698
853
  async _runBackup() {
699
854
  const backup = this.backup;
700
855
  if (!backup) {
@@ -727,9 +882,6 @@ export class ImportState {
727
882
  });
728
883
  return false;
729
884
  }
730
- _lingerOnCompletion() {
731
- return new Promise((resolve) => setTimeout(resolve, COMPLETION_LINGER_MS));
732
- }
733
885
  /**
734
886
  * Resolves every column to the final destination key used in the upload
735
887
  * payload: an existing field id, a new field's (Cairo-decided) key, or `null`
@@ -739,13 +891,16 @@ export class ImportState {
739
891
  */
740
892
  async _resolveFieldMapping() {
741
893
  const resolved = {};
742
- const newFields = [];
894
+ // Pending new columns with their deduped key + display name. Source-neutral;
895
+ // the per-source field def is built from these in `_createPendingFields`.
896
+ const pending = [];
743
897
  // Seed with existing field ids/display names so derived keys and names never
744
898
  // collide with them or with each other.
745
899
  const usedKeys = new Set(this.fields.map((field) => field.id).filter((id) => !!id));
746
900
  const usedNames = new Set(this.fields
747
901
  .map((field) => field.header)
748
902
  .filter((header) => !!header));
903
+ const canCreate = this.canCreateNewField;
749
904
  for (const header of this.csvHeaders) {
750
905
  const mapping = this.mappingByHeader[header];
751
906
  if (mapping == null) {
@@ -754,41 +909,75 @@ export class ImportState {
754
909
  else if (mapping.kind === 'existing') {
755
910
  resolved[header] = mapping.fieldId;
756
911
  }
757
- else if (this.updateSchema) {
912
+ else if (canCreate) {
758
913
  // The user's name override (from the field editor) seeds the unique
759
914
  // name/key derivation; otherwise fall back to the column header.
760
915
  const baseName = mapping.displayName?.trim() || header;
761
916
  const { displayName, key } = uniqueFieldNameAndKey(baseName, usedNames, usedKeys);
762
917
  usedKeys.add(key);
763
918
  usedNames.add(displayName);
764
- newFields.push({
765
- key,
766
- type: mapping.fieldType,
767
- displayName,
768
- ...(mapping.config && { config: mapping.config }),
769
- });
919
+ pending.push({ key, displayName, mapping });
770
920
  resolved[header] = key;
771
921
  }
772
922
  else {
773
- // "new" mapping without an `updateSchema` callback — nothing to map into.
923
+ // "new" mapping with no create callback on this source — nothing to map into.
774
924
  resolved[header] = null;
775
925
  }
776
926
  }
777
- if (newFields.length > 0 && this.updateSchema) {
927
+ if (pending.length > 0) {
778
928
  // 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;
929
+ // the observable until then so columns don't update mid-import.
930
+ this._pendingFieldsMap = await this._createPendingFields(pending);
785
931
  }
786
932
  return resolved;
787
933
  }
934
+ /**
935
+ * Creates the pending new fields via the active `dataSource` branch's callback
936
+ * — `updateSchema` (CMS) or `createFields` (DataExtension) — building the
937
+ * source-specific field def from the shared, already-deduped pending columns.
938
+ * The callback may throw; `startImport` surfaces that as the error state.
939
+ */
940
+ async _createPendingFields(pending) {
941
+ const updateSchema = this.updateSchema;
942
+ const createDataExtensionFields = this.createDataExtensionFields;
943
+ let updated;
944
+ if (updateSchema) {
945
+ updated = await updateSchema(pending.map(({ key, displayName, mapping }) => ({
946
+ key,
947
+ type: mapping.fieldType,
948
+ displayName,
949
+ ...(mapping.config && { config: mapping.config }),
950
+ })));
951
+ }
952
+ else if (createDataExtensionFields) {
953
+ // The DataExtension source builds its own field shape from the neutral
954
+ // pending columns — no DataExtension-specific def-building in core.
955
+ updated = await createDataExtensionFields(pending);
956
+ }
957
+ else {
958
+ return undefined;
959
+ }
960
+ // Guard the empty map: SchemaState.mergeFields is a full replace, so merging
961
+ // `{}` would wipe every column — treat a missing/empty return as "nothing to
962
+ // merge".
963
+ return updated && Object.keys(updated).length > 0 ? updated : undefined;
964
+ }
788
965
  async startImport() {
789
966
  if (!this.hasMappings) {
790
967
  return;
791
968
  }
969
+ // Block import while any required destination field is unmapped and tell the
970
+ // user which (CAIRO-4452). The footer already flags this; the Import button
971
+ // stays enabled, so this is where the click is stopped.
972
+ if (this.hasMissingRequiredFields) {
973
+ this.showToast?.({
974
+ type: 'ERROR',
975
+ message: this.translate('cairo.import.missingRequiredMessage', {
976
+ fields: this._missingRequiredFieldNames(),
977
+ }),
978
+ });
979
+ return;
980
+ }
792
981
  this._cancelled = false;
793
982
  this._abortController = new AbortController();
794
983
  // One uploaded row per CSV row (the rewrite drops columns, not rows), so the
@@ -805,6 +994,8 @@ export class ImportState {
805
994
  this.result = {
806
995
  succeeded: 0,
807
996
  failed: 0,
997
+ created: 0,
998
+ updated: 0,
808
999
  };
809
1000
  this._jobId = null;
810
1001
  // Drop any fields map from a prior attempt so a no-new-fields "Try again"
@@ -857,28 +1048,30 @@ export class ImportState {
857
1048
  ...queryStatus.success,
858
1049
  data: this.result,
859
1050
  };
1051
+ // The job reports counts only when it finishes, so the bar sat at 0 for
1052
+ // the whole import (CAIRO-4476). Snap it to 100% and hold it (below)
1053
+ // before the summary step replaces the progress step, so the user sees
1054
+ // the import reach completion.
1055
+ this.processedCount = this.totalCount;
860
1056
  });
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
- }
1057
+ // Keep the completed bar on screen briefly before moving on. `importStatus`
1058
+ // is already success, so a close during this window just dismisses the
1059
+ // modal (the `close()` guard doesn't treat it as a cancel).
1060
+ await this._lingerOnCompletion();
1061
+ if (this._cancelled) {
1062
+ return;
870
1063
  }
871
1064
  // Success path only. `_pendingFieldsMap` holds this run's returned map — it's
872
1065
  // cleared at the start of `startImport` (the stale-guard) and set by
873
1066
  // `_resolveFieldMapping`; snapshotting here keeps the merge success-gated.
874
1067
  createdFields = this._pendingFieldsMap;
875
1068
  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();
1069
+ // Import finished: show the summary step (created / updated / failed
1070
+ // counts, plus a "Download CSV" of the failed rows when any failed).
1071
+ // The modal stays open; the user dismisses it from the summary via
1072
+ // `done`. Refresh the collection now so the table behind the modal is
1073
+ // up to date by the time it closes.
1074
+ this.currentStep = 'summary';
882
1075
  this.collection.clearResultAndMoveToStart({ force: true });
883
1076
  });
884
1077
  }