@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
@@ -13,9 +13,15 @@ jest.mock('../hubJobClient');
13
13
  const COMPLETED_JOB = {
14
14
  id: 'job-1',
15
15
  status: JobStatus.COMPLETED,
16
- progress: { itemsSuccessful: 2, itemsRejected: 0, total: 2, current: 2 },
16
+ progress: {
17
+ itemsSuccessful: 2,
18
+ itemsRejected: 0,
19
+ total: 2,
20
+ current: 2,
21
+ itemsSuccessfulInfo: { itemsCreated: 1, itemsUpdated: 1 },
22
+ },
17
23
  };
18
- function setup(clientOverrides = {}, extraProps = {}) {
24
+ function setup(clientOverrides = {}, extra = {}) {
19
25
  const client = {
20
26
  uploadFile: jest.fn(async (_file) => 'file-1'),
21
27
  submitImportJob: jest.fn(async () => 'job-1'),
@@ -32,9 +38,27 @@ function setup(clientOverrides = {}, extraProps = {}) {
32
38
  const showToast = jest.fn();
33
39
  const downloadCsv = jest.fn();
34
40
  const translate = (key, params) => params ? `${key}:${JSON.stringify(params)}` : key;
41
+ const { detectColumnType, config: configOverride, collectionId, destination, updateSchema, createFields, fieldManagement, mergeFields, fields, idFieldKey, backup, jobApplicationErrorMap, } = extra;
42
+ const dataSource = destination
43
+ ? {
44
+ destination,
45
+ ...(createFields && { createFields }),
46
+ ...(fieldManagement && { fieldManagement }),
47
+ }
48
+ : {
49
+ collectionId: collectionId ?? 'Movies',
50
+ ...(updateSchema && { updateSchema }),
51
+ };
52
+ const config = configOverride ?? {
53
+ fields: fields ?? [{ id: 'name', header: 'Name' }],
54
+ dataSource,
55
+ ...(mergeFields && { mergeFields }),
56
+ ...(idFieldKey && { idFieldKey }),
57
+ ...(backup && { backup }),
58
+ ...(jobApplicationErrorMap && { jobApplicationErrorMap }),
59
+ };
35
60
  const props = {
36
- fields: [{ id: 'name', header: 'Name' }],
37
- collectionId: 'Movies',
61
+ config,
38
62
  httpClient: {
39
63
  request: jest.fn(),
40
64
  },
@@ -46,7 +70,7 @@ function setup(clientOverrides = {}, extraProps = {}) {
46
70
  },
47
71
  showToast: showToast,
48
72
  downloadCsv,
49
- ...extraProps,
73
+ detectColumnType,
50
74
  };
51
75
  const state = new ImportState(props);
52
76
  // Seed a parsed CSV mapped to an existing field.
@@ -67,8 +91,8 @@ function setup(clientOverrides = {}, extraProps = {}) {
67
91
  }
68
92
  describe('ImportState engine (data-hub job)', () => {
69
93
  beforeEach(() => {
70
- // The success path awaits a real setTimeout (completion linger); make sure
71
- // timers are real even if the preset defaults to fake ones.
94
+ // The poll loop awaits real setTimeouts between polls; make sure timers are
95
+ // real even if the preset defaults to fake ones.
72
96
  jest.useRealTimers();
73
97
  });
74
98
  it('uploads the aligned file, submits a job, and reports success', async () => {
@@ -81,14 +105,53 @@ describe('ImportState engine (data-hub job)', () => {
81
105
  name: 'data.csv',
82
106
  destination: { wixDataCollection: { collectionId: 'Movies' } },
83
107
  });
84
- expect(state.result).toEqual({ succeeded: 2, failed: 0 });
108
+ // created/updated come from the job's itemsSuccessfulInfo.
109
+ expect(state.result).toEqual({
110
+ succeeded: 2,
111
+ failed: 0,
112
+ created: 1,
113
+ updated: 1,
114
+ });
85
115
  expect(state.importStatus.status).toBe('success');
86
- expect(importModalState.close).toHaveBeenCalled();
116
+ // The modal stays open on the summary step; the collection is refreshed now
117
+ // so the table is current once the user closes it via `done`.
118
+ expect(state.currentStep).toBe('summary');
119
+ expect(importModalState.close).not.toHaveBeenCalled();
87
120
  expect(collection.clearResultAndMoveToStart).toHaveBeenCalledWith({
88
121
  force: true,
89
122
  });
90
123
  });
91
- it('shows a partial-success toast when some rows are rejected', async () => {
124
+ it('holds the completed (100%) progress bar on the progress step before the summary (CAIRO-4476)', async () => {
125
+ const { state } = setup();
126
+ // The job reports counts only when it finishes, so the bar can only reach
127
+ // 100% at the very end. Capture the step + counts at the moment the linger
128
+ // starts to prove the user sees the completed bar before the summary
129
+ // replaces it.
130
+ let atLinger = null;
131
+ const spy = jest
132
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
133
+ .spyOn(state, '_lingerOnCompletion')
134
+ .mockImplementation(() => {
135
+ atLinger = {
136
+ step: state.currentStep,
137
+ processed: state.processedCount,
138
+ total: state.totalCount,
139
+ };
140
+ return Promise.resolve();
141
+ });
142
+ await state.startImport();
143
+ expect(atLinger).toEqual({ step: 'progress', processed: 2, total: 2 });
144
+ expect(state.currentStep).toBe('summary');
145
+ spy.mockRestore();
146
+ });
147
+ it('closes the modal only when the user dismisses the summary via done', async () => {
148
+ const { state, importModalState } = setup();
149
+ await state.startImport();
150
+ expect(importModalState.close).not.toHaveBeenCalled();
151
+ state.done();
152
+ expect(importModalState.close).toHaveBeenCalled();
153
+ });
154
+ it('shows the summary step (no toast) when some rows are rejected', async () => {
92
155
  const { state, showToast, importModalState } = setup({
93
156
  getJob: jest.fn(async () => ({
94
157
  id: 'job-1',
@@ -98,16 +161,24 @@ describe('ImportState engine (data-hub job)', () => {
98
161
  itemsRejected: 1,
99
162
  total: 2,
100
163
  current: 2,
164
+ itemsSuccessfulInfo: { itemsCreated: 1, itemsUpdated: 0 },
101
165
  },
102
166
  })),
103
167
  });
104
168
  await state.startImport();
105
- expect(state.result).toEqual({ succeeded: 1, failed: 1 });
106
- expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'WARNING' }));
107
- expect(importModalState.close).toHaveBeenCalled();
169
+ expect(state.result).toEqual({
170
+ succeeded: 1,
171
+ failed: 1,
172
+ created: 1,
173
+ updated: 0,
174
+ });
175
+ expect(state.currentStep).toBe('summary');
176
+ // The completion toast is gone — the summary step reports the outcome.
177
+ expect(showToast).not.toHaveBeenCalled();
178
+ expect(importModalState.close).not.toHaveBeenCalled();
108
179
  });
109
180
  it('maps the failed "row N" by record (unaffected by an embedded-newline cell)', async () => {
110
- const { state, downloadCsv, showToast } = setup({
181
+ const { state, downloadCsv } = setup({
111
182
  getJob: jest.fn(async () => ({
112
183
  id: 'job-1',
113
184
  status: JobStatus.PARTIALLY_SUCCESSFUL,
@@ -137,15 +208,14 @@ describe('ImportState engine (data-hub job)', () => {
137
208
  };
138
209
  });
139
210
  await state.startImport();
140
- const toast = showToast.mock.calls[0][0];
141
- await toast.action.onClick();
211
+ await state.downloadFailedRows();
142
212
  // "row 4" → csvRows[2] (Mango), NOT shifted by the embedded newline in row 0.
143
213
  const [content] = downloadCsv.mock.calls[0];
144
214
  expect(content).toContain('Mango');
145
215
  expect(content).not.toContain('Apple');
146
216
  });
147
- it('downloads a re-importable CSV of the failed rows from the partial toast action', async () => {
148
- const { state, client, showToast, downloadCsv } = setup({
217
+ it('downloads a re-importable CSV of the failed rows, each prefixed with its failure reason', async () => {
218
+ const { state, client, downloadCsv } = setup({
149
219
  getJob: jest.fn(async () => ({
150
220
  id: 'job-1',
151
221
  status: JobStatus.PARTIALLY_SUCCESSFUL,
@@ -154,18 +224,21 @@ describe('ImportState engine (data-hub job)', () => {
154
224
  // csvRows[1] (the "Pear" row) failed → file line 3 → "row 3".
155
225
  listMovementLogs: jest.fn(async () => ({
156
226
  logs: [
157
- { sourceItemId: 'row 3', failure: { code: 'bad', description: 'x' } },
227
+ {
228
+ sourceItemId: 'row 3',
229
+ failure: { code: 'bad', description: 'Invalid value' },
230
+ },
158
231
  ],
159
232
  pagingMetadata: { hasNext: false },
160
233
  })),
161
234
  });
162
235
  await state.startImport();
163
- // Trigger the toast's download action.
164
- const toast = showToast.mock.calls[0][0];
165
- await toast.action.onClick();
236
+ await state.downloadFailedRows();
166
237
  expect(client.listMovementLogs).toHaveBeenCalledWith('job-1', undefined);
167
- // Original headers + only the failed row, ready to fix and re-upload.
168
- expect(downloadCsv).toHaveBeenCalledWith('Name\r\nPear', 'failed-data.csv');
238
+ // A leading reason column (the translated header) + original headers, then
239
+ // the failed row prefixed with the server's failure reason — ready to fix
240
+ // and re-upload. `translate` here echoes the key for the column header.
241
+ expect(downloadCsv).toHaveBeenCalledWith('cairo.import.failureReasonColumn,Name\r\nInvalid value,Pear', 'failed-data.csv');
169
242
  });
170
243
  it('surfaces an error state with an application-error-shaped error when the job fails', async () => {
171
244
  const { state, importModalState, collection } = setup({
@@ -185,26 +258,21 @@ describe('ImportState engine (data-hub job)', () => {
185
258
  expect(importModalState.close).not.toHaveBeenCalled();
186
259
  expect(collection.clearResultAndMoveToStart).not.toHaveBeenCalled();
187
260
  });
188
- it('hands a failed job to onJobError, which surfaces the wrapped error', async () => {
189
- // Wiring only: the consumer forwards `run` to their withErrorHandler exactly
190
- // as documented, and the error it re-rejects is the one surfaced to the UI
191
- // (with the application-error-shaped code intact). End-to-end resolution
192
- // against the real error handler is covered in the cairo
193
- // Table.importButton spec, which has a real errorHandler instance.
194
- // Mirror `(run) => errorHandler.withErrorHandler(run, errorCodesMap)`;
195
- // a passthrough stub, so this just runs `run` and re-rejects.
196
- const withErrorHandler = (fn) => fn();
197
- const onJobError = jest.fn((run) => withErrorHandler(run));
261
+ it('resolves a failed job through the consumer jobApplicationErrorMap', async () => {
262
+ // ImportState wraps the failure with `errorHandler.withErrorHandler(run,
263
+ // jobApplicationErrorMap)` itself; the consumer only supplies the per-code
264
+ // map. The surfaced error keeps the application-error-shaped code intact.
265
+ // End-to-end resolution against the real error handler (getResolvedError) is
266
+ // covered in the cairo Table.importButton spec.
267
+ const jobApplicationErrorMap = { duplicate_key: () => ({ message: 'Bad rows' }) };
198
268
  const { state } = setup({
199
269
  getJob: jest.fn(async () => ({
200
270
  id: 'job-1',
201
271
  status: JobStatus.FAILED,
202
272
  error: { code: 'duplicate_key', description: 'Bad rows' },
203
273
  })),
204
- }, { onJobError });
274
+ }, { jobApplicationErrorMap });
205
275
  await state.startImport();
206
- expect(onJobError).toHaveBeenCalledTimes(1);
207
- expect(typeof onJobError.mock.calls[0][0]).toBe('function');
208
276
  expect(state.importStatus.status).toBe('error');
209
277
  const error = state.importStatus.error;
210
278
  expect(error.response?.data?.details?.applicationError?.code).toBe('duplicate_key');
@@ -246,30 +314,20 @@ describe('ImportState engine (data-hub job)', () => {
246
314
  // forever; it must stop after the first fetch.
247
315
  expect(client.getJob).toHaveBeenCalledTimes(1);
248
316
  });
249
- it('does not restore when closed during the success completion linger', async () => {
317
+ it('does not restore a completed import when the summary is closed', async () => {
250
318
  const restoreFn = jest.fn().mockResolvedValue(undefined);
251
319
  const { state, importModalState, collection } = setup({}, { backup: async () => ({ restore: restoreFn }) });
252
- // A 0ms macrotask drains the pending async chain without firing the 600ms
253
- // linger timer.
254
- const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
255
- const promise = state.startImport();
256
- // Run backup → upload → submit → poll (terminal) → success, parking on the
257
- // 600ms completion linger (still on the 'progress' step) without firing it.
258
- await flush();
259
- await flush();
260
- await flush();
320
+ await state.startImport();
321
+ // The import finished on the summary step and the collection was refreshed.
261
322
  expect(state.importStatus.isSuccess).toBe(true);
262
- expect(state.currentStep).toBe('progress');
263
- // User closes the modal mid-linger.
264
- await state.close();
265
- await promise;
266
- // A successful import must not be reverted; the modal closes and the
267
- // collection refreshes.
268
- expect(restoreFn).not.toHaveBeenCalled();
269
- expect(importModalState.close).toHaveBeenCalled();
323
+ expect(state.currentStep).toBe('summary');
270
324
  expect(collection.clearResultAndMoveToStart).toHaveBeenCalledWith({
271
325
  force: true,
272
326
  });
327
+ // Closing the summary must not revert a successful import.
328
+ await state.close();
329
+ expect(restoreFn).not.toHaveBeenCalled();
330
+ expect(importModalState.close).toHaveBeenCalled();
273
331
  });
274
332
  });
275
333
  describe('ImportState writePolicy (id-collision handling)', () => {
@@ -470,5 +528,334 @@ describe('ImportState platformized destination (non–Wix-Data)', () => {
470
528
  const wixData = setup().state;
471
529
  expect(wixData.canConfigureWritePolicy).toBe(true);
472
530
  });
531
+ describe('DataExtension create fields (destination branch)', () => {
532
+ it('enables create-new-field when the destination branch supplies createFields', () => {
533
+ const withCreate = setup({}, {
534
+ destination: { storesCatalogProducts: {} },
535
+ createFields: jest.fn(async () => undefined),
536
+ }).state;
537
+ expect(withCreate.canCreateNewField).toBe(true);
538
+ const withoutCreate = setup({}, { destination: { storesCatalogProducts: {} } }).state;
539
+ expect(withoutCreate.canCreateNewField).toBe(false);
540
+ });
541
+ it('hands createFields the deduped pending columns (source builds its own def)', async () => {
542
+ const createFields = jest.fn(async () => undefined);
543
+ const { state, client } = setup({}, { destination: { storesCatalogProducts: {} }, createFields });
544
+ runInAction(() => {
545
+ state.csvHeaders = ['Name', 'Color'];
546
+ state.csvRows = [
547
+ ['Apple', 'Red'],
548
+ ['Pear', 'Green'],
549
+ ];
550
+ state.mappingByHeader = {
551
+ Name: { kind: 'existing', fieldId: 'name' },
552
+ Color: { kind: 'new', fieldType: 'text' },
553
+ };
554
+ });
555
+ await state.startImport();
556
+ expect(createFields).toHaveBeenCalledTimes(1);
557
+ // Core passes the neutral pending column (deduped key + name + the mapping);
558
+ // the DataExtension source turns it into its own field shape.
559
+ expect(createFields).toHaveBeenCalledWith([
560
+ {
561
+ key: 'color',
562
+ displayName: 'Color',
563
+ mapping: { kind: 'new', fieldType: 'text' },
564
+ },
565
+ ]);
566
+ // The consumer's destination is still sent verbatim.
567
+ expect(client.submitImportJob).toHaveBeenCalledWith(expect.objectContaining({
568
+ destination: { storesCatalogProducts: {} },
569
+ }));
570
+ });
571
+ it('carries the edited mapping config (precise type, permissions, PII) in the pending column', async () => {
572
+ const createFields = jest.fn(async () => undefined);
573
+ const { state } = setup({}, { destination: { storesCatalogProducts: {} }, createFields });
574
+ const config = {
575
+ deType: 'decimal',
576
+ isPii: true,
577
+ permissions: {
578
+ installedApps: { defaultRead: true, defaultWrite: false },
579
+ },
580
+ };
581
+ runInAction(() => {
582
+ state.csvHeaders = ['Name', 'Count'];
583
+ state.csvRows = [['Apple', '3']];
584
+ state.mappingByHeader = {
585
+ Name: { kind: 'existing', fieldId: 'name' },
586
+ Count: {
587
+ kind: 'new',
588
+ fieldType: 'number',
589
+ displayName: 'Count',
590
+ config,
591
+ },
592
+ };
593
+ });
594
+ await state.startImport();
595
+ expect(createFields).toHaveBeenCalledWith([
596
+ {
597
+ key: 'count',
598
+ displayName: 'Count',
599
+ mapping: {
600
+ kind: 'new',
601
+ fieldType: 'number',
602
+ displayName: 'Count',
603
+ config,
604
+ },
605
+ },
606
+ ]);
607
+ });
608
+ });
609
+ describe('created fields (CAIRO-4400)', () => {
610
+ const RETURNED_FIELDS = {
611
+ name: { id: 'name', displayName: 'Name', type: 'SHORT_TEXT' },
612
+ color: { id: 'color', displayName: 'Color', type: 'SHORT_TEXT' },
613
+ };
614
+ // Maps an extra column to a NEW field so `_resolveFieldMapping` calls
615
+ // `updateSchema`. `updateSchema`'s presence is also what enables new mappings.
616
+ function withNewFieldMapping(state) {
617
+ runInAction(() => {
618
+ state.csvHeaders = ['Name', 'Color'];
619
+ state.csvRows = [
620
+ ['Apple', 'Red'],
621
+ ['Pear', 'Green'],
622
+ ];
623
+ state.mappingByHeader = {
624
+ Name: { kind: 'existing', fieldId: 'name' },
625
+ Color: { kind: 'new', fieldType: 'text' },
626
+ };
627
+ });
628
+ }
629
+ it('calls mergeFields with updateSchema’s returned map after a successful import', async () => {
630
+ const mergeFields = jest.fn();
631
+ const updateSchema = jest.fn(async () => RETURNED_FIELDS);
632
+ const { state, collection } = setup({}, { updateSchema, mergeFields });
633
+ withNewFieldMapping(state);
634
+ await state.startImport();
635
+ expect(updateSchema).toHaveBeenCalledTimes(1);
636
+ expect(mergeFields).toHaveBeenCalledTimes(1);
637
+ expect(mergeFields).toHaveBeenCalledWith(RETURNED_FIELDS);
638
+ // The merge runs alongside the data refetch on the success path.
639
+ expect(collection.clearResultAndMoveToStart).toHaveBeenCalled();
640
+ });
641
+ it('does not call mergeFields for a plain import (no new fields)', async () => {
642
+ const mergeFields = jest.fn();
643
+ const updateSchema = jest.fn(async () => RETURNED_FIELDS);
644
+ const { state } = setup({}, { updateSchema, mergeFields });
645
+ await state.startImport();
646
+ expect(updateSchema).not.toHaveBeenCalled();
647
+ expect(mergeFields).not.toHaveBeenCalled();
648
+ });
649
+ it('does not call mergeFields when updateSchema returns nothing', async () => {
650
+ const mergeFields = jest.fn();
651
+ const updateSchema = jest.fn(async () => undefined);
652
+ const { state } = setup({}, { updateSchema, mergeFields });
653
+ withNewFieldMapping(state);
654
+ await state.startImport();
655
+ expect(updateSchema).toHaveBeenCalledTimes(1);
656
+ expect(mergeFields).not.toHaveBeenCalled();
657
+ });
658
+ it('does not call mergeFields when updateSchema returns an empty map', async () => {
659
+ // A `{}` return is truthy; without a size guard it would reach a full-replace
660
+ // mergeFields({}) and wipe every column. Treat it as "nothing to merge".
661
+ const mergeFields = jest.fn();
662
+ const updateSchema = jest.fn(async () => ({}));
663
+ const { state } = setup({}, { updateSchema, mergeFields });
664
+ withNewFieldMapping(state);
665
+ await state.startImport();
666
+ expect(updateSchema).toHaveBeenCalledTimes(1);
667
+ expect(mergeFields).not.toHaveBeenCalled();
668
+ });
669
+ it('does not call mergeFields when the import fails after creating fields', async () => {
670
+ const mergeFields = jest.fn();
671
+ const updateSchema = jest.fn(async () => RETURNED_FIELDS);
672
+ const { state } = setup({
673
+ uploadFile: jest.fn(async () => {
674
+ throw new Error('upload failed');
675
+ }),
676
+ }, { updateSchema, mergeFields });
677
+ withNewFieldMapping(state);
678
+ await state.startImport();
679
+ // Fields were created pre-upload, but a failed import must not merge them.
680
+ expect(updateSchema).toHaveBeenCalledTimes(1);
681
+ expect(state.importStatus.status).toBe('error');
682
+ expect(mergeFields).not.toHaveBeenCalled();
683
+ });
684
+ it('keeps the import successful when mergeFields throws (no void → duplicate-rows)', async () => {
685
+ const consoleError = jest
686
+ .spyOn(console, 'error')
687
+ .mockImplementation(() => { });
688
+ const mergeFields = jest.fn(() => {
689
+ throw new Error('merge boom');
690
+ });
691
+ const updateSchema = jest.fn(async () => RETURNED_FIELDS);
692
+ const { state, collection } = setup({}, { updateSchema, mergeFields });
693
+ withNewFieldMapping(state);
694
+ await state.startImport();
695
+ expect(mergeFields).toHaveBeenCalledTimes(1);
696
+ // A throwing merge is swallowed — the completed import is untouched.
697
+ expect(state.importStatus.status).toBe('success');
698
+ expect(state.currentStep).toBe('summary');
699
+ expect(collection.clearResultAndMoveToStart).toHaveBeenCalled();
700
+ consoleError.mockRestore();
701
+ });
702
+ });
703
+ describe('schema-backed config (live getters + create-field permission)', () => {
704
+ // A schema-backed config: the CMS `dataSource` branch a SchemaImportRef
705
+ // satisfies. It supplies `updateSchema` only when create is permitted — its
706
+ // presence is the create-new-field opt-in (no separate flag).
707
+ const schemaConfig = (over = {}) => ({
708
+ fields: over.fields ?? [{ id: 'name', header: 'Name' }],
709
+ dataSource: {
710
+ collectionId: over.collectionId ?? 'RefCollection',
711
+ ...(over.updateSchema && { updateSchema: over.updateSchema }),
712
+ },
713
+ ...(over.idFieldKey && { idFieldKey: over.idFieldKey }),
714
+ ...(over.mergeFields && { mergeFields: over.mergeFields }),
715
+ });
716
+ it('reads fields live from the config rather than snapshotting them', () => {
717
+ const config = schemaConfig();
718
+ const { state } = setup({}, { config });
719
+ expect(state.fields).toBe(config.fields);
720
+ // A later change to the config is reflected — values are read on demand,
721
+ // so a live (getter-backed) config needn't be resolved before the ctor.
722
+ const updated = [
723
+ { id: 'name', header: 'Name' },
724
+ { id: 'year', header: 'Year' },
725
+ ];
726
+ config.fields = updated;
727
+ expect(state.fields).toBe(updated);
728
+ });
729
+ it('gates create-new-field on the presence of a create callback (updateSchema)', () => {
730
+ // No create callback ⇒ create-new-field off (the source didn't permit it).
731
+ const off = setup({}, { config: schemaConfig() });
732
+ expect(off.state.canCreateNewField).toBe(false);
733
+ // A create callback present ⇒ on.
734
+ const on = setup({}, { config: schemaConfig({ updateSchema: jest.fn() }) });
735
+ expect(on.state.canCreateNewField).toBe(true);
736
+ });
737
+ it('builds the destination from the config collectionId', async () => {
738
+ const { state, client } = setup({}, { config: schemaConfig({ collectionId: 'RefCollection' }) });
739
+ await state.startImport();
740
+ expect(client.submitImportJob).toHaveBeenCalledWith(expect.objectContaining({
741
+ destination: { wixDataCollection: { collectionId: 'RefCollection' } },
742
+ }));
743
+ });
744
+ it('creates new fields through the config updateSchema', async () => {
745
+ const updateSchema = jest.fn(async () => ({
746
+ name: { id: 'name', displayName: 'Name', type: 'SHORT_TEXT' },
747
+ year: { id: 'year', displayName: 'Year', type: 'NUMBER' },
748
+ }));
749
+ const { state } = setup({}, { config: schemaConfig({ updateSchema }) });
750
+ runInAction(() => {
751
+ state.csvHeaders = ['Name', 'Year'];
752
+ state.csvRows = [['Apple', '1999']];
753
+ state.mappingByHeader = {
754
+ Name: { kind: 'existing', fieldId: 'name' },
755
+ Year: { kind: 'new', fieldType: 'number' },
756
+ };
757
+ });
758
+ await state.startImport();
759
+ expect(updateSchema).toHaveBeenCalledTimes(1);
760
+ });
761
+ it('detects the id-mapped column using the config idFieldKey', () => {
762
+ const { state } = setup({}, { config: schemaConfig({ idFieldKey: 'movieId' }) });
763
+ runInAction(() => {
764
+ state.mappingByHeader = {
765
+ Ref: { kind: 'existing', fieldId: 'movieId' },
766
+ };
767
+ });
768
+ expect(state.idMappedHeader).toBe('Ref');
769
+ });
770
+ });
771
+ });
772
+ describe('ImportState required fields (CAIRO-4452)', () => {
773
+ beforeEach(() => {
774
+ jest.useRealTimers();
775
+ });
776
+ it('is satisfied when a required field has a column mapped to it', () => {
777
+ const { state } = setup({}, {
778
+ config: {
779
+ fields: [{ id: 'name', header: 'Name', required: true }],
780
+ dataSource: { collectionId: 'Movies' },
781
+ },
782
+ });
783
+ // The seeded mapping maps the "Name" column to the `name` field.
784
+ expect(state.hasMissingRequiredFields).toBe(false);
785
+ expect(state.missingRequiredFields).toEqual([]);
786
+ });
787
+ it('flags a required field the user set to "don\'t import"', () => {
788
+ const { state } = setup({}, {
789
+ config: {
790
+ fields: [{ id: 'name', header: 'Name', required: true }],
791
+ dataSource: { collectionId: 'Movies' },
792
+ },
793
+ });
794
+ state.setMapping('Name', null);
795
+ expect(state.hasMissingRequiredFields).toBe(true);
796
+ expect(state.missingRequiredFields.map((f) => f.id)).toEqual(['name']);
797
+ });
798
+ it('ignores unmapped fields that are not required', () => {
799
+ const { state } = setup({}, {
800
+ config: {
801
+ fields: [
802
+ { id: 'name', header: 'Name' },
803
+ { id: 'year', header: 'Year', required: false },
804
+ ],
805
+ dataSource: { collectionId: 'Movies' },
806
+ },
807
+ });
808
+ state.setMapping('Name', null);
809
+ expect(state.hasMissingRequiredFields).toBe(false);
810
+ });
811
+ it('is not satisfied by a create-new-field mapping', () => {
812
+ const { state } = setup({}, {
813
+ config: {
814
+ fields: [{ id: 'name', header: 'Name', required: true }],
815
+ dataSource: {
816
+ collectionId: 'Movies',
817
+ updateSchema: async () => undefined,
818
+ },
819
+ },
820
+ });
821
+ // Mapping the column to a brand-new field can't fill the existing required
822
+ // field, so it stays missing.
823
+ state.setNewFieldMapping('Name');
824
+ expect(state.hasMissingRequiredFields).toBe(true);
825
+ });
826
+ it('blocks startImport and shows an error toast when a required field is missing', async () => {
827
+ const { state, client, showToast } = setup({}, {
828
+ config: {
829
+ fields: [
830
+ { id: 'name', header: 'Name', required: true },
831
+ { id: 'year', header: 'Year' },
832
+ ],
833
+ dataSource: { collectionId: 'Movies' },
834
+ },
835
+ });
836
+ // Keep a mapping (so the import isn't blocked by "no mappings") but leave the
837
+ // required `name` field unmapped.
838
+ runInAction(() => {
839
+ state.csvHeaders = ['Name', 'Year'];
840
+ state.csvRows = [['Apple', '2020']];
841
+ });
842
+ state.setMapping('Name', null);
843
+ state.setMapping('Year', 'year');
844
+ await state.startImport();
845
+ expect(client.uploadFile).not.toHaveBeenCalled();
846
+ expect(client.submitImportJob).not.toHaveBeenCalled();
847
+ expect(state.currentStep).not.toBe('progress');
848
+ expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'ERROR' }));
849
+ });
850
+ it('proceeds with import when all required fields are mapped', async () => {
851
+ const { state, client } = setup({}, {
852
+ config: {
853
+ fields: [{ id: 'name', header: 'Name', required: true }],
854
+ dataSource: { collectionId: 'Movies' },
855
+ },
856
+ });
857
+ await state.startImport();
858
+ expect(client.submitImportJob).toHaveBeenCalledTimes(1);
859
+ });
473
860
  });
474
861
  //# sourceMappingURL=ImportState.engine.spec.js.map