@wix/bex-core 2.327.0 → 2.329.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 (184) hide show
  1. package/dist/cjs/assets/locale/messages_ar.json +12 -0
  2. package/dist/cjs/assets/locale/messages_bg.json +12 -0
  3. package/dist/cjs/assets/locale/messages_ca.json +12 -0
  4. package/dist/cjs/assets/locale/messages_cs.json +12 -0
  5. package/dist/cjs/assets/locale/messages_da.json +17 -5
  6. package/dist/cjs/assets/locale/messages_de.json +12 -0
  7. package/dist/cjs/assets/locale/messages_el.json +12 -0
  8. package/dist/cjs/assets/locale/messages_en.json +12 -0
  9. package/dist/cjs/assets/locale/messages_es.json +16 -4
  10. package/dist/cjs/assets/locale/messages_fi.json +12 -0
  11. package/dist/cjs/assets/locale/messages_fr.json +12 -0
  12. package/dist/cjs/assets/locale/messages_he.json +12 -0
  13. package/dist/cjs/assets/locale/messages_hi.json +17 -5
  14. package/dist/cjs/assets/locale/messages_hu.json +12 -0
  15. package/dist/cjs/assets/locale/messages_id.json +17 -5
  16. package/dist/cjs/assets/locale/messages_it.json +17 -5
  17. package/dist/cjs/assets/locale/messages_ja.json +12 -0
  18. package/dist/cjs/assets/locale/messages_ko.json +17 -5
  19. package/dist/cjs/assets/locale/messages_lt.json +12 -0
  20. package/dist/cjs/assets/locale/messages_ms.json +12 -0
  21. package/dist/cjs/assets/locale/messages_nl.json +16 -4
  22. package/dist/cjs/assets/locale/messages_no.json +12 -0
  23. package/dist/cjs/assets/locale/messages_pl.json +12 -0
  24. package/dist/cjs/assets/locale/messages_pt.json +16 -4
  25. package/dist/cjs/assets/locale/messages_ro.json +12 -0
  26. package/dist/cjs/assets/locale/messages_ru.json +12 -0
  27. package/dist/cjs/assets/locale/messages_sk.json +12 -0
  28. package/dist/cjs/assets/locale/messages_sl.json +12 -0
  29. package/dist/cjs/assets/locale/messages_sv.json +17 -5
  30. package/dist/cjs/assets/locale/messages_th.json +17 -5
  31. package/dist/cjs/assets/locale/messages_tl.json +12 -0
  32. package/dist/cjs/assets/locale/messages_tr.json +17 -5
  33. package/dist/cjs/assets/locale/messages_uk.json +12 -0
  34. package/dist/cjs/assets/locale/messages_vi.json +17 -5
  35. package/dist/cjs/assets/locale/messages_zh.json +12 -0
  36. package/dist/cjs/assets/locale/version_file +1 -1
  37. package/dist/cjs/assets/visual-e2e-locale/messages_visuale2e.json +4 -1
  38. package/dist/cjs/hooks/useImport.js +7 -5
  39. package/dist/cjs/hooks/useImport.js.map +1 -1
  40. package/dist/cjs/hooks/useImportConfig.js.map +1 -1
  41. package/dist/cjs/services/translations.js.map +1 -1
  42. package/dist/cjs/state/ImportState/ImportState.js +363 -109
  43. package/dist/cjs/state/ImportState/ImportState.js.map +1 -1
  44. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js +536 -0
  45. package/dist/cjs/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -0
  46. package/dist/cjs/state/ImportState/__tests__/hubJobClient.spec.js +209 -0
  47. package/dist/cjs/state/ImportState/__tests__/hubJobClient.spec.js.map +1 -0
  48. package/dist/cjs/state/ImportState/__tests__/importJobPoller.spec.js +86 -0
  49. package/dist/cjs/state/ImportState/__tests__/importJobPoller.spec.js.map +1 -0
  50. package/dist/cjs/state/ImportState/__tests__/parseCsv.spec.js +52 -0
  51. package/dist/cjs/state/ImportState/__tests__/parseCsv.spec.js.map +1 -0
  52. package/dist/cjs/state/ImportState/__tests__/rewriteCsv.spec.js +81 -0
  53. package/dist/cjs/state/ImportState/__tests__/rewriteCsv.spec.js.map +1 -0
  54. package/dist/cjs/state/ImportState/fieldEdit.js +46 -0
  55. package/dist/cjs/state/ImportState/fieldEdit.js.map +1 -0
  56. package/dist/cjs/state/ImportState/hubJobClient.js +101 -0
  57. package/dist/cjs/state/ImportState/hubJobClient.js.map +1 -0
  58. package/dist/cjs/state/ImportState/importJobPoller.js +44 -0
  59. package/dist/cjs/state/ImportState/importJobPoller.js.map +1 -0
  60. package/dist/cjs/state/ImportState/index.js +12 -0
  61. package/dist/cjs/state/ImportState/index.js.map +1 -1
  62. package/dist/cjs/state/ImportState/parseCsv.js +39 -10
  63. package/dist/cjs/state/ImportState/parseCsv.js.map +1 -1
  64. package/dist/cjs/state/ImportState/rewriteCsv.js +46 -0
  65. package/dist/cjs/state/ImportState/rewriteCsv.js.map +1 -0
  66. package/dist/cjs/testkit/WixPatternsContainer.driver.js +4 -1
  67. package/dist/cjs/testkit/WixPatternsContainer.driver.js.map +1 -1
  68. package/dist/cjs/testkit/client.js +6 -0
  69. package/dist/cjs/testkit/client.js.map +1 -1
  70. package/dist/cjs/testkit/hubJobImportMocks.js +166 -0
  71. package/dist/cjs/testkit/hubJobImportMocks.js.map +1 -0
  72. package/dist/cjs/testkit/index.js +6 -0
  73. package/dist/cjs/testkit/index.js.map +1 -1
  74. package/dist/esm/assets/locale/messages_ar.json +12 -0
  75. package/dist/esm/assets/locale/messages_bg.json +12 -0
  76. package/dist/esm/assets/locale/messages_ca.json +12 -0
  77. package/dist/esm/assets/locale/messages_cs.json +12 -0
  78. package/dist/esm/assets/locale/messages_da.json +17 -5
  79. package/dist/esm/assets/locale/messages_de.json +12 -0
  80. package/dist/esm/assets/locale/messages_el.json +12 -0
  81. package/dist/esm/assets/locale/messages_en.json +12 -0
  82. package/dist/esm/assets/locale/messages_es.json +16 -4
  83. package/dist/esm/assets/locale/messages_fi.json +12 -0
  84. package/dist/esm/assets/locale/messages_fr.json +12 -0
  85. package/dist/esm/assets/locale/messages_he.json +12 -0
  86. package/dist/esm/assets/locale/messages_hi.json +17 -5
  87. package/dist/esm/assets/locale/messages_hu.json +12 -0
  88. package/dist/esm/assets/locale/messages_id.json +17 -5
  89. package/dist/esm/assets/locale/messages_it.json +17 -5
  90. package/dist/esm/assets/locale/messages_ja.json +12 -0
  91. package/dist/esm/assets/locale/messages_ko.json +17 -5
  92. package/dist/esm/assets/locale/messages_lt.json +12 -0
  93. package/dist/esm/assets/locale/messages_ms.json +12 -0
  94. package/dist/esm/assets/locale/messages_nl.json +16 -4
  95. package/dist/esm/assets/locale/messages_no.json +12 -0
  96. package/dist/esm/assets/locale/messages_pl.json +12 -0
  97. package/dist/esm/assets/locale/messages_pt.json +16 -4
  98. package/dist/esm/assets/locale/messages_ro.json +12 -0
  99. package/dist/esm/assets/locale/messages_ru.json +12 -0
  100. package/dist/esm/assets/locale/messages_sk.json +12 -0
  101. package/dist/esm/assets/locale/messages_sl.json +12 -0
  102. package/dist/esm/assets/locale/messages_sv.json +17 -5
  103. package/dist/esm/assets/locale/messages_th.json +17 -5
  104. package/dist/esm/assets/locale/messages_tl.json +12 -0
  105. package/dist/esm/assets/locale/messages_tr.json +17 -5
  106. package/dist/esm/assets/locale/messages_uk.json +12 -0
  107. package/dist/esm/assets/locale/messages_vi.json +17 -5
  108. package/dist/esm/assets/locale/messages_zh.json +12 -0
  109. package/dist/esm/assets/locale/version_file +1 -1
  110. package/dist/esm/assets/visual-e2e-locale/messages_visuale2e.json +4 -1
  111. package/dist/esm/hooks/useImport.js +5 -4
  112. package/dist/esm/hooks/useImport.js.map +1 -1
  113. package/dist/esm/hooks/useImportConfig.js.map +1 -1
  114. package/dist/esm/state/ImportState/ImportState.js +293 -101
  115. package/dist/esm/state/ImportState/ImportState.js.map +1 -1
  116. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js +385 -0
  117. package/dist/esm/state/ImportState/__tests__/ImportState.engine.spec.js.map +1 -0
  118. package/dist/esm/state/ImportState/__tests__/hubJobClient.spec.js +139 -0
  119. package/dist/esm/state/ImportState/__tests__/hubJobClient.spec.js.map +1 -0
  120. package/dist/esm/state/ImportState/__tests__/importJobPoller.spec.js +62 -0
  121. package/dist/esm/state/ImportState/__tests__/importJobPoller.spec.js.map +1 -0
  122. package/dist/esm/state/ImportState/__tests__/parseCsv.spec.js +59 -0
  123. package/dist/esm/state/ImportState/__tests__/parseCsv.spec.js.map +1 -0
  124. package/dist/esm/state/ImportState/__tests__/rewriteCsv.spec.js +68 -0
  125. package/dist/esm/state/ImportState/__tests__/rewriteCsv.spec.js.map +1 -0
  126. package/dist/esm/state/ImportState/fieldEdit.js +33 -0
  127. package/dist/esm/state/ImportState/fieldEdit.js.map +1 -0
  128. package/dist/esm/state/ImportState/hubJobClient.js +43 -0
  129. package/dist/esm/state/ImportState/hubJobClient.js.map +1 -0
  130. package/dist/esm/state/ImportState/importJobPoller.js +34 -0
  131. package/dist/esm/state/ImportState/importJobPoller.js.map +1 -0
  132. package/dist/esm/state/ImportState/index.js +2 -0
  133. package/dist/esm/state/ImportState/index.js.map +1 -1
  134. package/dist/esm/state/ImportState/parseCsv.js +34 -10
  135. package/dist/esm/state/ImportState/parseCsv.js.map +1 -1
  136. package/dist/esm/state/ImportState/rewriteCsv.js +39 -0
  137. package/dist/esm/state/ImportState/rewriteCsv.js.map +1 -0
  138. package/dist/esm/testkit/WixPatternsContainer.driver.js +4 -1
  139. package/dist/esm/testkit/WixPatternsContainer.driver.js.map +1 -1
  140. package/dist/esm/testkit/client.js +1 -0
  141. package/dist/esm/testkit/client.js.map +1 -1
  142. package/dist/esm/testkit/hubJobImportMocks.js +173 -0
  143. package/dist/esm/testkit/hubJobImportMocks.js.map +1 -0
  144. package/dist/esm/testkit/index.js +1 -0
  145. package/dist/esm/testkit/index.js.map +1 -1
  146. package/dist/types/hooks/useImport.d.ts +5 -3
  147. package/dist/types/hooks/useImport.d.ts.map +1 -1
  148. package/dist/types/hooks/useImportConfig.d.ts +2 -3
  149. package/dist/types/hooks/useImportConfig.d.ts.map +1 -1
  150. package/dist/types/services/translations.d.ts +1 -1
  151. package/dist/types/services/translations.d.ts.map +1 -1
  152. package/dist/types/state/ImportState/ImportState.d.ts +118 -20
  153. package/dist/types/state/ImportState/ImportState.d.ts.map +1 -1
  154. package/dist/types/state/ImportState/__tests__/ImportState.engine.spec.d.ts +2 -0
  155. package/dist/types/state/ImportState/__tests__/ImportState.engine.spec.d.ts.map +1 -0
  156. package/dist/types/state/ImportState/__tests__/hubJobClient.spec.d.ts +2 -0
  157. package/dist/types/state/ImportState/__tests__/hubJobClient.spec.d.ts.map +1 -0
  158. package/dist/types/state/ImportState/__tests__/importJobPoller.spec.d.ts +2 -0
  159. package/dist/types/state/ImportState/__tests__/importJobPoller.spec.d.ts.map +1 -0
  160. package/dist/types/state/ImportState/__tests__/parseCsv.spec.d.ts +2 -0
  161. package/dist/types/state/ImportState/__tests__/parseCsv.spec.d.ts.map +1 -0
  162. package/dist/types/state/ImportState/__tests__/rewriteCsv.spec.d.ts +2 -0
  163. package/dist/types/state/ImportState/__tests__/rewriteCsv.spec.d.ts.map +1 -0
  164. package/dist/types/state/ImportState/fieldEdit.d.ts +10 -0
  165. package/dist/types/state/ImportState/fieldEdit.d.ts.map +1 -0
  166. package/dist/types/state/ImportState/hubJobClient.d.ts +47 -0
  167. package/dist/types/state/ImportState/hubJobClient.d.ts.map +1 -0
  168. package/dist/types/state/ImportState/importJobPoller.d.ts +20 -0
  169. package/dist/types/state/ImportState/importJobPoller.d.ts.map +1 -0
  170. package/dist/types/state/ImportState/index.d.ts +2 -0
  171. package/dist/types/state/ImportState/index.d.ts.map +1 -1
  172. package/dist/types/state/ImportState/parseCsv.d.ts +7 -0
  173. package/dist/types/state/ImportState/parseCsv.d.ts.map +1 -1
  174. package/dist/types/state/ImportState/rewriteCsv.d.ts +25 -0
  175. package/dist/types/state/ImportState/rewriteCsv.d.ts.map +1 -0
  176. package/dist/types/testkit/WixPatternsContainer.driver.d.ts +22 -0
  177. package/dist/types/testkit/WixPatternsContainer.driver.d.ts.map +1 -1
  178. package/dist/types/testkit/client.d.ts +1 -0
  179. package/dist/types/testkit/client.d.ts.map +1 -1
  180. package/dist/types/testkit/hubJobImportMocks.d.ts +40 -0
  181. package/dist/types/testkit/hubJobImportMocks.d.ts.map +1 -0
  182. package/dist/types/testkit/index.d.ts +1 -0
  183. package/dist/types/testkit/index.d.ts.map +1 -1
  184. package/package.json +4 -4
@@ -1,22 +1,36 @@
1
1
  import { action, computed, makeObservable, observable, runInAction, } from 'mobx';
2
2
  import { queryStatus, } from '../../model';
3
3
  import { ConditionalModalState } from '../ConditionalModalState';
4
+ import { JobStatus, WritePolicy, } from '@wix/bex-utils/@wix/ambassador-hub-v1-job/types';
4
5
  import { parseCsv, unparseCsv } from './parseCsv';
6
+ import { rewriteParsedCsvForImport } from './rewriteCsv';
7
+ import { createHubJobClient } from './hubJobClient';
8
+ import { pollImportJob } from './importJobPoller';
5
9
  import { detectColumnType, TYPE_SAMPLE_SIZE, } from './detectFieldType';
6
- import set from 'lodash/set';
7
- const DEFAULT_BATCH_SIZE = 100;
10
+ import { toFieldEdit } from './fieldEdit';
11
+ // `NewFieldType`, `detectColumnType`, etc. are re-exported from the package via
12
+ // the `ImportState` barrel (`./index`), which also exports `./detectFieldType`.
13
+ // Re-export the write-policy enum so consumers (and the cairo UI) reference it
14
+ // from `@wix/bex-core` rather than the bundled ambassador path.
15
+ export { WritePolicy } from '@wix/bex-utils/@wix/ambassador-hub-v1-job/types';
8
16
  // How long to keep the completed progress bar (100%) on screen before closing
9
17
  // the modal on a clean import. Without it, an import of very few rows finishes
10
18
  // near-instantly and the modal appears to vanish without ever showing the bar
11
19
  // reaching completion.
12
20
  const COMPLETION_LINGER_MS = 600;
21
+ // The Wix Data collection's id field key. A CSV column mapped to this field lets
22
+ // the user choose how rows that collide on `_id` are written (writePolicy).
23
+ const ID_FIELD_KEY = '_id';
13
24
  function normalize(value) {
14
25
  return value.trim().toLowerCase();
15
26
  }
16
27
  // camelCase key for a field created from a column header
17
28
  // (e.g. "Product Name" -> "productName").
18
29
  function toFieldKey(header) {
19
- const words = header.trim().split(/[^a-zA-Z0-9]+/).filter(Boolean);
30
+ const words = header
31
+ .trim()
32
+ .split(/[^a-zA-Z0-9]+/)
33
+ .filter(Boolean);
20
34
  if (words.length === 0) {
21
35
  return 'field';
22
36
  }
@@ -44,19 +58,6 @@ function uniqueFieldNameAndKey(header, usedNames, usedKeys) {
44
58
  displayName = `${header}-${suffix}`;
45
59
  }
46
60
  }
47
- function toMappedRows(rows, headers, mappingByHeader) {
48
- return rows.map((row) => {
49
- const mapped = {};
50
- headers.forEach((header, index) => {
51
- const mappedField = mappingByHeader[header];
52
- if (mappedField == null) {
53
- return;
54
- }
55
- set(mapped, mappedField, row[index] ?? '');
56
- });
57
- return mapped;
58
- });
59
- }
60
61
  export class ImportState {
61
62
  constructor(props) {
62
63
  this.discardChangesModal = new ConditionalModalState();
@@ -68,25 +69,36 @@ export class ImportState {
68
69
  this.csvHeaders = [];
69
70
  this.csvRows = [];
70
71
  this.mappingByHeader = {};
72
+ // How rows that collide on `_id` are written. Only meaningful (and only sent
73
+ // to the server) when a CSV column is mapped to the `_id` field — see
74
+ // `idMappedHeader` and `_destination`. Defaults to the server default
75
+ // (`OVERWRITE` = upsert by `_id`).
76
+ //
77
+ // Retention: the choice is deliberately kept while editing mappings within a
78
+ // file — remapping `_id` away and back keeps the user's selection rather than
79
+ // silently reverting it. It resets to the default only on a *new file* upload
80
+ // (`applyParsedCsv`) or a full `reset()`.
81
+ this.writePolicy = WritePolicy.OVERWRITE;
71
82
  this.processedCount = 0;
72
83
  this.totalCount = 0;
73
84
  this.result = {
74
85
  succeeded: 0,
75
86
  failed: 0,
76
- errors: [],
77
87
  };
78
88
  this.isBackingUp = false;
79
89
  this.isRestoring = false;
80
90
  this.restoreFn = null;
81
91
  this._cancelled = false;
82
92
  this._abortController = null;
83
- // Global indices (into `csvRows`) of every row that was not imported: rows
84
- // the server rejected via a batch's returned `errors`, plus when a batch
85
- // throws after some rows succeeded — the throwing batch and all un-attempted
86
- // rows. Offered to the user as a downloadable CSV via the partial toast.
87
- this._failedRowIndices = [];
93
+ // Id of the running data-movement job, set once submitted used to cancel it
94
+ // (`terminateJob`) if the user closes mid-import.
95
+ this._jobId = null;
96
+ // The column whose pending new field the field editor is currently editing.
97
+ // Not observable — transient wiring between `beginFieldEdit` and the editor's
98
+ // `applyFieldEdit` callback, never rendered.
99
+ this._editingHeader = null;
88
100
  this.fields = (props.fields ?? []);
89
- this.bulkUploadItems = props.bulkUploadItems;
101
+ this.collectionId = props.collectionId;
90
102
  this.backup = props.backup;
91
103
  this.updateSchema = props.updateSchema;
92
104
  this.collection = props.collection;
@@ -95,8 +107,12 @@ export class ImportState {
95
107
  this.errorHandler = props.errorHandler;
96
108
  this.showToast = props.showToast;
97
109
  this.downloadCsv = props.downloadCsv;
98
- this.batchSize = props.batchSize ?? DEFAULT_BATCH_SIZE;
110
+ this.onJobError = props.onJobError;
99
111
  this._detectColumnType = props.detectColumnType ?? detectColumnType;
112
+ this.hubJobClient = createHubJobClient({
113
+ httpClient: props.httpClient,
114
+ errorHandler: props.errorHandler,
115
+ });
100
116
  makeObservable(this, {
101
117
  currentStep: observable.ref,
102
118
  importStatus: observable.ref,
@@ -104,6 +120,7 @@ export class ImportState {
104
120
  csvHeaders: observable.ref,
105
121
  csvRows: observable.ref,
106
122
  mappingByHeader: observable.ref,
123
+ writePolicy: observable.ref,
107
124
  processedCount: observable.ref,
108
125
  totalCount: observable.ref,
109
126
  result: observable.ref,
@@ -113,10 +130,15 @@ export class ImportState {
113
130
  hasMappings: computed,
114
131
  hasNoMappedData: computed,
115
132
  selectedFieldIds: computed,
133
+ idMappedHeader: computed,
134
+ setWritePolicy: action.bound,
116
135
  onUploadContinue: action.bound,
117
136
  goToUpload: action.bound,
118
137
  setMapping: action.bound,
119
138
  setNewFieldMapping: action.bound,
139
+ setNewFieldConfig: action.bound,
140
+ beginFieldEdit: action.bound,
141
+ applyFieldEdit: action.bound,
120
142
  close: action.bound,
121
143
  onFileSelected: action.bound,
122
144
  startImport: action.bound,
@@ -124,7 +146,15 @@ export class ImportState {
124
146
  });
125
147
  }
126
148
  init() {
127
- return () => undefined;
149
+ return () => {
150
+ // Unmounting mid-import stops the poll loop (it checks `_cancelled`) so it
151
+ // doesn't run to terminal holding references. We deliberately do NOT abort
152
+ // the AbortController: during a cancel-triggered restore it holds the
153
+ // restore controller, and aborting it would cancel an in-progress restore.
154
+ // The server-side job is also left running — an unmount isn't an explicit
155
+ // user cancel.
156
+ this._cancelled = true;
157
+ };
128
158
  }
129
159
  get hasParsedRows() {
130
160
  return this.csvHeaders.length > 0 && this.csvRows.length > 0;
@@ -156,6 +186,19 @@ export class ImportState {
156
186
  .filter((m) => m?.kind === 'existing')
157
187
  .map((m) => m.fieldId));
158
188
  }
189
+ /**
190
+ * The CSV header currently mapped to the collection's `_id` field, or `null`
191
+ * when no column targets it. Drives both the "When items share the same ID"
192
+ * control (only shown on this row) and whether `writePolicy` is sent at all —
193
+ * with no `_id` mapped, every row is a fresh insert and collisions can't occur.
194
+ */
195
+ get idMappedHeader() {
196
+ const entry = Object.entries(this.mappingByHeader).find(([, mapping]) => mapping?.kind === 'existing' && mapping.fieldId === ID_FIELD_KEY);
197
+ return entry ? entry[0] : null;
198
+ }
199
+ setWritePolicy(policy) {
200
+ this.writePolicy = policy;
201
+ }
159
202
  /**
160
203
  * Whether columns may be mapped to a brand-new field created during import.
161
204
  * Driven purely by the presence of the `updateSchema` callback — providing the
@@ -176,23 +219,39 @@ export class ImportState {
176
219
  this.csvHeaders = [];
177
220
  this.csvRows = [];
178
221
  this.mappingByHeader = {};
222
+ this.writePolicy = WritePolicy.OVERWRITE;
179
223
  this.processedCount = 0;
180
224
  this.totalCount = 0;
181
225
  this.result = {
182
226
  succeeded: 0,
183
227
  failed: 0,
184
- errors: [],
185
228
  };
186
229
  this.isBackingUp = false;
187
230
  this.isRestoring = false;
188
231
  this.restoreFn = null;
189
232
  this._cancelled = false;
190
- this._failedRowIndices = [];
233
+ this._jobId = null;
191
234
  }
192
235
  async close() {
193
- if (this.currentStep === 'progress') {
236
+ // While on the progress step we treat close as a cancel — terminate the job
237
+ // and restore the backup — UNLESS the import already succeeded. On the
238
+ // success path `startImport` lingers ~600ms on the completed bar with the
239
+ // step still 'progress'; closing during that window must NOT restore (it
240
+ // would undo a clean import). A successful close just closes the modal and
241
+ // lets the success path do its collection refresh.
242
+ if (this.currentStep === 'progress' && !this.importStatus.isSuccess) {
194
243
  this._cancelled = true;
195
244
  this._abortController?.abort();
245
+ // Stop the server-side job. It does not roll back rows already written —
246
+ // that's what the backup restore below is for.
247
+ if (this._jobId) {
248
+ try {
249
+ await this.hubJobClient.terminateJob(this._jobId);
250
+ }
251
+ catch (error) {
252
+ console.error('Terminate import job failed', error);
253
+ }
254
+ }
196
255
  if (!this.isBackingUp) {
197
256
  await this._restoreBackup();
198
257
  }
@@ -283,6 +342,10 @@ export class ImportState {
283
342
  this.csvHeaders = parsedCsv.headers;
284
343
  this.csvRows = parsedCsv.rows;
285
344
  this.mappingByHeader = this.getDefaultMapping(parsedCsv.headers);
345
+ // A new file is a fresh import: start from the default policy so a prior
346
+ // file's Skip choice doesn't silently carry over (only the new-file path
347
+ // resets — remapping `_id` within the same file keeps the user's choice).
348
+ this.writePolicy = WritePolicy.OVERWRITE;
286
349
  this.currentStep = 'configuration';
287
350
  }
288
351
  async onFileSelected(file) {
@@ -354,12 +417,11 @@ export class ImportState {
354
417
  });
355
418
  }
356
419
  _showPartialSuccessToast() {
357
- const failed = this._failedRowIndices.length;
358
420
  this.showToast?.({
359
421
  type: 'WARNING',
360
422
  message: this.translate('cairo.import.partialSuccessToast', {
361
- failed: String(failed),
362
- imported: String(this.totalCount - failed),
423
+ failed: String(this.result.failed),
424
+ imported: String(this.result.succeeded),
363
425
  }),
364
426
  action: {
365
427
  text: this.translate('cairo.import.downloadFailedRows'),
@@ -368,11 +430,66 @@ export class ImportState {
368
430
  },
369
431
  });
370
432
  }
371
- _downloadFailedRows() {
372
- const failedRows = this._failedRowIndices.map((index) => this.csvRows[index]);
433
+ /**
434
+ * Builds a plain, re-importable CSV of the rows the server rejected — the
435
+ * original headers plus just the failed rows — and hands it to `downloadCsv`.
436
+ * The failed rows come from the job's movement logs (which row failed), mapped
437
+ * back to the parsed `csvRows` we still hold in memory.
438
+ */
439
+ async _downloadFailedRows() {
440
+ const jobId = this._jobId;
441
+ if (!jobId) {
442
+ return;
443
+ }
444
+ let indices;
445
+ try {
446
+ indices = await this._fetchFailedRowIndices(jobId);
447
+ }
448
+ catch (error) {
449
+ console.error('Fetching failed rows report failed', error);
450
+ return;
451
+ }
452
+ const failedRows = indices
453
+ .map((index) => this.csvRows[index])
454
+ .filter((row) => row != null);
455
+ if (failedRows.length === 0) {
456
+ return;
457
+ }
373
458
  const content = unparseCsv(this.csvHeaders, failedRows);
374
459
  this.downloadCsv?.(content, `failed-${this.fileName || 'import.csv'}`);
375
460
  }
461
+ // Pages through the job's movement logs and returns the `csvRows` indices of
462
+ // the rows that failed. data-hub's CSV decoder
463
+ // (wix-data-hub: packages/wix-data-hub-executor-c/src/plugins/formats/csv/
464
+ // csv-binary-decoder.ts) parses with the `csv-parse` library (`columns: true`)
465
+ // and sets `sourceItemId = "row ${++index + 1}"`, incrementing once **per
466
+ // record** — not per physical line. So N is the 1-based record number with
467
+ // the header consumed as columns: the first data record is `"row 2"` →
468
+ // `csvRows[0]`, i.e. `csvRows[N - 2]`. Because `csv-parse` is RFC-4180,
469
+ // quoted cells containing embedded newlines are a single record, so they do
470
+ // NOT shift the numbering. Out-of-range indices are guarded below.
471
+ async _fetchFailedRowIndices(jobId) {
472
+ const indices = [];
473
+ let cursor;
474
+ do {
475
+ const { logs, pagingMetadata } = await this.hubJobClient.listMovementLogs(jobId, cursor ? { cursor } : undefined);
476
+ for (const log of logs ?? []) {
477
+ if (!log.failure || log.sourceItemId == null) {
478
+ continue;
479
+ }
480
+ const fileLine = Number(/\d+/.exec(log.sourceItemId)?.[0]);
481
+ const index = fileLine - 2;
482
+ if (Number.isInteger(index) &&
483
+ index >= 0 &&
484
+ index < this.csvRows.length) {
485
+ indices.push(index);
486
+ }
487
+ }
488
+ cursor =
489
+ (pagingMetadata?.hasNext && pagingMetadata.cursors?.next) || undefined;
490
+ } while (cursor);
491
+ return indices;
492
+ }
376
493
  setMapping(header, fieldId) {
377
494
  this.mappingByHeader = {
378
495
  ...this.mappingByHeader,
@@ -387,53 +504,133 @@ export class ImportState {
387
504
  [header]: { kind: 'new', fieldType },
388
505
  };
389
506
  }
390
- async _uploadBatches(batches) {
391
- let processedRows = 0;
392
- let receivedOkResponse = false;
393
- const failedRowIndices = [];
394
- for (const batch of batches) {
395
- if (this._cancelled) {
396
- break;
397
- }
398
- const batchStart = processedRows;
399
- try {
400
- const batchResult = await this.bulkUploadItems(batch);
401
- receivedOkResponse = true;
402
- this.result = {
403
- succeeded: this.result.succeeded + batchResult.succeeded,
404
- failed: this.result.failed + batchResult.failed,
405
- errors: [...this.result.errors, ...batchResult.errors],
406
- };
407
- // Rows the server rejected within an otherwise-OK response. `rowIndex`
408
- // is relative to the batch, so offset it to a global `csvRows` index.
409
- for (const { rowIndex } of batchResult.errors) {
410
- failedRowIndices.push(batchStart + rowIndex);
411
- }
412
- }
413
- catch (error) {
414
- // Full batch failure: a batch threw before any OK response came back.
415
- // Surface it as an error state with a "Try again" action by rejecting
416
- // up to `startImport`, instead of reporting a partial success.
417
- if (!receivedOkResponse) {
418
- throw error;
419
- }
420
- // A batch threw after some rows were already imported. Every row from
421
- // the throwing batch onward (including the batches we won't attempt)
422
- // counts as failed, on top of any rows already rejected via returned
423
- // errors in earlier batches.
424
- for (let index = batchStart; index < this.totalCount; index++) {
425
- failedRowIndices.push(index);
426
- }
427
- break;
507
+ /**
508
+ * Persists the result of the "Edit field" editor for a column's pending new
509
+ * field: the (possibly overridden) type, an optional display-name override,
510
+ * and the per-type configuration. No-op if the column isn't currently mapped
511
+ * to a new field.
512
+ */
513
+ setNewFieldConfig(header, edit) {
514
+ const mapping = this.mappingByHeader[header];
515
+ if (mapping?.kind !== 'new') {
516
+ return;
517
+ }
518
+ this.mappingByHeader = {
519
+ ...this.mappingByHeader,
520
+ [header]: {
521
+ kind: 'new',
522
+ fieldType: edit.fieldType,
523
+ ...(edit.displayName != null && { displayName: edit.displayName }),
524
+ ...(edit.config && { config: edit.config }),
525
+ },
526
+ };
527
+ }
528
+ /**
529
+ * Marks which column's pending new field the field editor is currently
530
+ * editing, so a later `applyFieldEdit` (the editor's `onSubmit`) knows where to
531
+ * write the result.
532
+ */
533
+ beginFieldEdit(header) {
534
+ this._editingHeader = header;
535
+ }
536
+ /**
537
+ * `onSubmit` handler for the field editor: maps the edited field spec into the
538
+ * column being edited (set via `beginFieldEdit`). No-op if nothing is being
539
+ * edited.
540
+ */
541
+ applyFieldEdit(field) {
542
+ if (this._editingHeader == null) {
543
+ return;
544
+ }
545
+ this.setNewFieldConfig(this._editingHeader, toFieldEdit(field));
546
+ }
547
+ /**
548
+ * Runs the server-side import: align the CSV to the resolved field keys,
549
+ * upload it, submit a data-movement job, then poll until terminal. Throws on
550
+ * a failed job so `startImport` surfaces the error state; partial success is
551
+ * reported via `result.failed` and the partial toast.
552
+ */
553
+ async _runImportJob(resolvedMapping) {
554
+ const csv = rewriteParsedCsvForImport(this.csvHeaders, this.csvRows, resolvedMapping);
555
+ const file = new File([csv], this.fileName || 'import.csv', {
556
+ type: 'text/csv',
557
+ });
558
+ const fileId = await this.hubJobClient.uploadFile(file);
559
+ if (this._cancelled) {
560
+ return this.result;
561
+ }
562
+ const jobId = await this.hubJobClient.submitImportJob({
563
+ fileId,
564
+ name: this.fileName,
565
+ destination: this._destination(),
566
+ });
567
+ runInAction(() => {
568
+ this._jobId = jobId;
569
+ });
570
+ if (this._cancelled) {
571
+ // The user cancelled while `submitImportJob` was in flight, so `close()`
572
+ // saw no `_jobId` and couldn't terminate it. Terminate it here, otherwise
573
+ // the job keeps importing server-side after cancel (and would overwrite a
574
+ // restored backup).
575
+ await this.hubJobClient.terminateJob(jobId).catch(() => undefined);
576
+ return this.result;
577
+ }
578
+ const job = await pollImportJob((id) => this.hubJobClient.getJob(id), jobId, {
579
+ isCancelled: () => this._cancelled,
580
+ onProgress: (polled) => this._applyJobProgress(polled),
581
+ });
582
+ // `null` means cancelled mid-poll — leave whatever progress we have.
583
+ if (!job) {
584
+ return this.result;
585
+ }
586
+ if (job.status === JobStatus.FAILED) {
587
+ // The job ran server-side, so a failure arrives on a 200 (the poll
588
+ // succeeded; the job didn't) with no HTTP error to carry the application
589
+ // error. Build a synthetic, HTTP-error-shaped error (the error handler
590
+ // reads the code from `response.data.details.applicationError`) and hand
591
+ // the throwing operation to the consumer's `onJobError` so THEY wrap it
592
+ // with their own `withErrorHandler` + error-code map — `job.error` is the
593
+ // consumer's application error (their import business logic), so only they
594
+ // can map its codes, and the map stays coupled to the `withErrorHandler`
595
+ // call as everywhere else (e.g. `fetchData`). The map is attached to the
596
+ // error object, which the progress step resolves via `getResolvedError`.
597
+ // Without a handler the same error still throws, falling back to the
598
+ // generic message.
599
+ const error = Object.assign(new Error(`ImportJob.${job.error?.code ?? 'failed'}`), { response: { data: { details: { applicationError: job.error } } } });
600
+ const run = () => Promise.reject(error);
601
+ if (this.onJobError) {
602
+ // The consumer wraps `run` with `withErrorHandler`; it re-rejects with
603
+ // `error` (now carrying their map), which propagates from here.
604
+ await this.onJobError(run);
428
605
  }
429
- processedRows += batch.length;
430
- runInAction(() => {
431
- this.processedCount = Math.min(processedRows, this.totalCount);
432
- });
606
+ throw error;
433
607
  }
434
- this._failedRowIndices = failedRowIndices;
608
+ this._applyJobProgress(job);
435
609
  return this.result;
436
610
  }
611
+ // Destination: the consumer's Wix Data collection. `writePolicy` is sent only
612
+ // when a CSV column is mapped to `_id` (the only case collisions can occur);
613
+ // otherwise it's omitted and the server applies its default (upsert by `_id`).
614
+ _destination() {
615
+ return {
616
+ wixDataCollection: {
617
+ collectionId: this.collectionId,
618
+ ...(this.idMappedHeader != null && { writePolicy: this.writePolicy }),
619
+ },
620
+ };
621
+ }
622
+ _applyJobProgress(job) {
623
+ const progress = job.progress;
624
+ const succeeded = progress?.itemsSuccessful ?? 0;
625
+ const failed = progress?.itemsRejected ?? 0;
626
+ const current = progress?.current ?? succeeded + failed;
627
+ // `totalCount` is fixed up front in `startImport` from the CSV row count
628
+ // (one uploaded row per CSV row); polling only advances `processedCount`.
629
+ runInAction(() => {
630
+ this.processedCount = Math.min(current, this.totalCount);
631
+ this.result = { succeeded, failed };
632
+ });
633
+ }
437
634
  async _runBackup() {
438
635
  const backup = this.backup;
439
636
  if (!backup) {
@@ -494,10 +691,18 @@ export class ImportState {
494
691
  resolved[header] = mapping.fieldId;
495
692
  }
496
693
  else if (this.updateSchema) {
497
- const { displayName, key } = uniqueFieldNameAndKey(header, usedNames, usedKeys);
694
+ // The user's name override (from the field editor) seeds the unique
695
+ // name/key derivation; otherwise fall back to the column header.
696
+ const baseName = mapping.displayName?.trim() || header;
697
+ const { displayName, key } = uniqueFieldNameAndKey(baseName, usedNames, usedKeys);
498
698
  usedKeys.add(key);
499
699
  usedNames.add(displayName);
500
- newFields.push({ key, type: mapping.fieldType, displayName });
700
+ newFields.push({
701
+ key,
702
+ type: mapping.fieldType,
703
+ displayName,
704
+ ...(mapping.config && { config: mapping.config }),
705
+ });
501
706
  resolved[header] = key;
502
707
  }
503
708
  else {
@@ -510,23 +715,14 @@ export class ImportState {
510
715
  }
511
716
  return resolved;
512
717
  }
513
- _createRowsBatches(rows) {
514
- return rows.reduce((acc, row, index) => {
515
- if (index % this.batchSize === 0) {
516
- acc.push([]);
517
- }
518
- acc[acc.length - 1].push(row);
519
- return acc;
520
- }, []);
521
- }
522
718
  async startImport() {
523
719
  if (!this.hasMappings) {
524
720
  return;
525
721
  }
526
722
  this._cancelled = false;
527
723
  this._abortController = new AbortController();
528
- // One mapped row is produced per CSV row, so the total is known up front and
529
- // doesn't depend on field creation below.
724
+ // One uploaded row per CSV row (the rewrite drops columns, not rows), so the
725
+ // total is known up front from the parsed CSV and stays fixed through polling.
530
726
  const totalCount = this.csvRows.length;
531
727
  runInAction(() => {
532
728
  this.currentStep = 'progress';
@@ -539,9 +735,8 @@ export class ImportState {
539
735
  this.result = {
540
736
  succeeded: 0,
541
737
  failed: 0,
542
- errors: [],
543
738
  };
544
- this._failedRowIndices = [];
739
+ this._jobId = null;
545
740
  });
546
741
  const backupFailed = await this._runBackup();
547
742
  if (backupFailed) {
@@ -551,8 +746,8 @@ export class ImportState {
551
746
  return;
552
747
  }
553
748
  // Create any new fields and resolve every column to its destination key
554
- // before building the payload. A creation failure surfaces as the error
555
- // state with a "Try again" CTA, like a first-batch upload failure.
749
+ // before uploading. A creation failure surfaces as the error state with a
750
+ // "Try again" CTA, like an upload/job failure.
556
751
  let resolvedMapping;
557
752
  try {
558
753
  resolvedMapping = await this._resolveFieldMapping();
@@ -569,9 +764,7 @@ export class ImportState {
569
764
  if (this._cancelled) {
570
765
  return;
571
766
  }
572
- const rows = toMappedRows(this.csvRows, this.csvHeaders, resolvedMapping);
573
- const batches = this._createRowsBatches(rows);
574
- const promise = this._uploadBatches(batches);
767
+ const promise = this._runImportJob(resolvedMapping);
575
768
  this.importStatus = {
576
769
  ...queryStatus.loading,
577
770
  promise,
@@ -591,17 +784,16 @@ export class ImportState {
591
784
  // briefly so the completion is perceptible even for very few rows. Skip
592
785
  // the wait when there are failed rows — that path closes and surfaces a
593
786
  // toast instead.
594
- if (this._failedRowIndices.length === 0) {
787
+ if (this.result.failed === 0) {
595
788
  await this._lingerOnCompletion();
596
789
  if (this._cancelled) {
597
790
  return;
598
791
  }
599
792
  }
600
793
  runInAction(() => {
601
- // Any rows that didn't make it in whether rejected via a batch's
602
- // returned `errors` or left unimported when a batch threw — are
603
- // reported via a toast that offers them as a downloadable CSV.
604
- if (this._failedRowIndices.length > 0) {
794
+ // Rows the server rejected are reported via a toast (the downloadable
795
+ // failed-rows report is added by the failed-rows-report task).
796
+ if (this.result.failed > 0) {
605
797
  this._showPartialSuccessToast();
606
798
  }
607
799
  this.importModalState.close();