@commercetools-frontend-extensions/operations 0.0.0-canary-20251209161906

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 (180) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +178 -0
  3. package/babel.config.js +6 -0
  4. package/dist/commercetools-frontend-extensions-operations.cjs.d.ts +2 -0
  5. package/dist/commercetools-frontend-extensions-operations.cjs.dev.js +3273 -0
  6. package/dist/commercetools-frontend-extensions-operations.cjs.js +7 -0
  7. package/dist/commercetools-frontend-extensions-operations.cjs.prod.js +3265 -0
  8. package/dist/commercetools-frontend-extensions-operations.esm.js +3074 -0
  9. package/dist/declarations/src/@api/export-operations.d.ts +5 -0
  10. package/dist/declarations/src/@api/fetcher.d.ts +17 -0
  11. package/dist/declarations/src/@api/file-import-jobs.d.ts +7 -0
  12. package/dist/declarations/src/@api/file-upload.d.ts +3 -0
  13. package/dist/declarations/src/@api/import-containers.d.ts +35 -0
  14. package/dist/declarations/src/@api/import-operations.d.ts +6 -0
  15. package/dist/declarations/src/@api/index.d.ts +9 -0
  16. package/dist/declarations/src/@api/process-file.d.ts +3 -0
  17. package/dist/declarations/src/@api/test-fixtures.d.ts +279 -0
  18. package/dist/declarations/src/@api/urls.d.ts +74 -0
  19. package/dist/declarations/src/@components/file-drop-area/active-drag-drop-area.d.ts +10 -0
  20. package/dist/declarations/src/@components/file-drop-area/disabled-drop-area.d.ts +5 -0
  21. package/dist/declarations/src/@components/file-drop-area/drop-area-wrapper.d.ts +11 -0
  22. package/dist/declarations/src/@components/file-drop-area/enabled-drop-area.d.ts +7 -0
  23. package/dist/declarations/src/@components/file-drop-area/file-drop-area.d.ts +14 -0
  24. package/dist/declarations/src/@components/file-drop-area/file-dropped-area.d.ts +6 -0
  25. package/dist/declarations/src/@components/file-drop-area/index.d.ts +7 -0
  26. package/dist/declarations/src/@components/file-drop-area/styles.d.ts +9 -0
  27. package/dist/declarations/src/@components/icons/file-icon.d.ts +2 -0
  28. package/dist/declarations/src/@components/icons/index.d.ts +2 -0
  29. package/dist/declarations/src/@components/icons/lock-icon.d.ts +2 -0
  30. package/dist/declarations/src/@components/index.d.ts +6 -0
  31. package/dist/declarations/src/@components/info-box/index.d.ts +1 -0
  32. package/dist/declarations/src/@components/info-box/info-box.d.ts +7 -0
  33. package/dist/declarations/src/@components/upload-separator/index.d.ts +1 -0
  34. package/dist/declarations/src/@components/upload-separator/upload-separator.d.ts +12 -0
  35. package/dist/declarations/src/@components/upload-settings/index.d.ts +1 -0
  36. package/dist/declarations/src/@components/upload-settings/upload-settings.d.ts +11 -0
  37. package/dist/declarations/src/@components/uploading-modal/index.d.ts +1 -0
  38. package/dist/declarations/src/@components/uploading-modal/uploading-modal.d.ts +13 -0
  39. package/dist/declarations/src/@constants/delimiters.d.ts +8 -0
  40. package/dist/declarations/src/@constants/file-import-job.d.ts +1 -0
  41. package/dist/declarations/src/@constants/import-limits.d.ts +6 -0
  42. package/dist/declarations/src/@constants/import-tags.d.ts +7 -0
  43. package/dist/declarations/src/@constants/index.d.ts +5 -0
  44. package/dist/declarations/src/@constants/resource-links.d.ts +10 -0
  45. package/dist/declarations/src/@errors/http-error.d.ts +6 -0
  46. package/dist/declarations/src/@errors/index.d.ts +9 -0
  47. package/dist/declarations/src/@errors/invalid-response-error.d.ts +3 -0
  48. package/dist/declarations/src/@errors/no-resources-to-export-error.d.ts +3 -0
  49. package/dist/declarations/src/@errors/polling-aborted-error.d.ts +3 -0
  50. package/dist/declarations/src/@errors/project-key-not-available-error.d.ts +3 -0
  51. package/dist/declarations/src/@errors/query-predicate-error.d.ts +4 -0
  52. package/dist/declarations/src/@errors/unexpected-column-error.d.ts +3 -0
  53. package/dist/declarations/src/@errors/unexpected-operation-state-error.d.ts +4 -0
  54. package/dist/declarations/src/@errors/unexpected-resource-type-error.d.ts +3 -0
  55. package/dist/declarations/src/@hooks/index.d.ts +8 -0
  56. package/dist/declarations/src/@hooks/use-fetch-export-operations.d.ts +15 -0
  57. package/dist/declarations/src/@hooks/use-fetch-file-import-job.d.ts +17 -0
  58. package/dist/declarations/src/@hooks/use-fetch-import-container-details.d.ts +15 -0
  59. package/dist/declarations/src/@hooks/use-fetch-import-operations.d.ts +16 -0
  60. package/dist/declarations/src/@hooks/use-fetch-import-summaries.d.ts +20 -0
  61. package/dist/declarations/src/@hooks/use-file-import-job-upload.d.ts +18 -0
  62. package/dist/declarations/src/@hooks/use-file-upload.d.ts +28 -0
  63. package/dist/declarations/src/@hooks/use-import-container-upload.d.ts +19 -0
  64. package/dist/declarations/src/@types/api.d.ts +13 -0
  65. package/dist/declarations/src/@types/basic-error-data-type.d.ts +5 -0
  66. package/dist/declarations/src/@types/export-operation.d.ts +97 -0
  67. package/dist/declarations/src/@types/file-import-job.d.ts +99 -0
  68. package/dist/declarations/src/@types/file-upload-result.d.ts +21 -0
  69. package/dist/declarations/src/@types/file-upload.d.ts +63 -0
  70. package/dist/declarations/src/@types/import-container.d.ts +53 -0
  71. package/dist/declarations/src/@types/import-operation.d.ts +13 -0
  72. package/dist/declarations/src/@types/import-states.d.ts +9 -0
  73. package/dist/declarations/src/@types/import-summary.d.ts +15 -0
  74. package/dist/declarations/src/@types/index.d.ts +11 -0
  75. package/dist/declarations/src/@types/shared.d.ts +7 -0
  76. package/dist/declarations/src/@utils/error-mapping.d.ts +19 -0
  77. package/dist/declarations/src/@utils/file-import-job-helpers.d.ts +12 -0
  78. package/dist/declarations/src/@utils/file-upload.d.ts +54 -0
  79. package/dist/declarations/src/@utils/form.d.ts +1 -0
  80. package/dist/declarations/src/@utils/format.d.ts +5 -0
  81. package/dist/declarations/src/@utils/import-container.d.ts +8 -0
  82. package/dist/declarations/src/@utils/index.d.ts +8 -0
  83. package/dist/declarations/src/@utils/poll-job-until-validated.d.ts +11 -0
  84. package/dist/declarations/src/@utils/url.d.ts +6 -0
  85. package/dist/declarations/src/index.d.ts +26 -0
  86. package/index.js +1 -0
  87. package/jest.test.config.js +11 -0
  88. package/package.json +62 -0
  89. package/src/@api/export-operations.ts +26 -0
  90. package/src/@api/fetcher.spec.ts +51 -0
  91. package/src/@api/fetcher.ts +137 -0
  92. package/src/@api/file-import-jobs.ts +217 -0
  93. package/src/@api/file-upload.spec.ts +85 -0
  94. package/src/@api/file-upload.ts +46 -0
  95. package/src/@api/import-containers.ts +256 -0
  96. package/src/@api/import-operations.ts +33 -0
  97. package/src/@api/index.ts +9 -0
  98. package/src/@api/process-file.spec.ts +74 -0
  99. package/src/@api/process-file.ts +53 -0
  100. package/src/@api/test-fixtures.ts +894 -0
  101. package/src/@api/urls.ts +194 -0
  102. package/src/@components/file-drop-area/active-drag-drop-area.tsx +33 -0
  103. package/src/@components/file-drop-area/disabled-drop-area.tsx +17 -0
  104. package/src/@components/file-drop-area/drop-area-wrapper.tsx +38 -0
  105. package/src/@components/file-drop-area/enabled-drop-area.tsx +27 -0
  106. package/src/@components/file-drop-area/file-drop-area.tsx +74 -0
  107. package/src/@components/file-drop-area/file-dropped-area.tsx +29 -0
  108. package/src/@components/file-drop-area/index.ts +7 -0
  109. package/src/@components/file-drop-area/styles.ts +67 -0
  110. package/src/@components/icons/file-icon.tsx +30 -0
  111. package/src/@components/icons/index.ts +2 -0
  112. package/src/@components/icons/lock-icon.tsx +34 -0
  113. package/src/@components/index.ts +6 -0
  114. package/src/@components/info-box/index.ts +1 -0
  115. package/src/@components/info-box/info-box.tsx +23 -0
  116. package/src/@components/upload-separator/index.ts +1 -0
  117. package/src/@components/upload-separator/upload-separator.tsx +61 -0
  118. package/src/@components/upload-settings/index.ts +1 -0
  119. package/src/@components/upload-settings/upload-settings.tsx +36 -0
  120. package/src/@components/uploading-modal/index.ts +1 -0
  121. package/src/@components/uploading-modal/uploading-modal.tsx +66 -0
  122. package/src/@constants/delimiters.ts +14 -0
  123. package/src/@constants/file-import-job.ts +1 -0
  124. package/src/@constants/import-limits.ts +13 -0
  125. package/src/@constants/import-tags.ts +9 -0
  126. package/src/@constants/index.ts +5 -0
  127. package/src/@constants/resource-links.ts +61 -0
  128. package/src/@errors/http-error.ts +17 -0
  129. package/src/@errors/index.ts +9 -0
  130. package/src/@errors/invalid-response-error.ts +6 -0
  131. package/src/@errors/no-resources-to-export-error.ts +6 -0
  132. package/src/@errors/polling-aborted-error.ts +6 -0
  133. package/src/@errors/project-key-not-available-error.ts +6 -0
  134. package/src/@errors/query-predicate-error.ts +10 -0
  135. package/src/@errors/unexpected-column-error.ts +6 -0
  136. package/src/@errors/unexpected-operation-state-error.ts +8 -0
  137. package/src/@errors/unexpected-resource-type-error.ts +6 -0
  138. package/src/@hooks/index.ts +8 -0
  139. package/src/@hooks/use-fetch-export-operations.ts +34 -0
  140. package/src/@hooks/use-fetch-file-import-job.spec.ts +131 -0
  141. package/src/@hooks/use-fetch-file-import-job.ts +38 -0
  142. package/src/@hooks/use-fetch-import-container-details.ts +31 -0
  143. package/src/@hooks/use-fetch-import-operations.ts +42 -0
  144. package/src/@hooks/use-fetch-import-summaries.ts +47 -0
  145. package/src/@hooks/use-fetch.spec.ts +68 -0
  146. package/src/@hooks/use-fetch.ts +76 -0
  147. package/src/@hooks/use-file-import-job-upload.spec.ts +273 -0
  148. package/src/@hooks/use-file-import-job-upload.ts +101 -0
  149. package/src/@hooks/use-file-upload.ts +223 -0
  150. package/src/@hooks/use-import-container-upload.spec.ts +297 -0
  151. package/src/@hooks/use-import-container-upload.ts +130 -0
  152. package/src/@types/api.ts +14 -0
  153. package/src/@types/basic-error-data-type.ts +5 -0
  154. package/src/@types/export-operation.ts +147 -0
  155. package/src/@types/file-import-job.ts +165 -0
  156. package/src/@types/file-upload-result.ts +23 -0
  157. package/src/@types/file-upload.ts +81 -0
  158. package/src/@types/import-container.ts +104 -0
  159. package/src/@types/import-operation.ts +31 -0
  160. package/src/@types/import-states.ts +9 -0
  161. package/src/@types/import-summary.ts +22 -0
  162. package/src/@types/index.ts +11 -0
  163. package/src/@types/shared.ts +52 -0
  164. package/src/@utils/error-mapping.spec.ts +126 -0
  165. package/src/@utils/error-mapping.ts +40 -0
  166. package/src/@utils/file-import-job-helpers.spec.ts +147 -0
  167. package/src/@utils/file-import-job-helpers.ts +47 -0
  168. package/src/@utils/file-upload.spec.ts +151 -0
  169. package/src/@utils/file-upload.ts +189 -0
  170. package/src/@utils/form.ts +20 -0
  171. package/src/@utils/format.spec.ts +62 -0
  172. package/src/@utils/format.ts +53 -0
  173. package/src/@utils/import-container.spec.ts +26 -0
  174. package/src/@utils/import-container.ts +34 -0
  175. package/src/@utils/index.ts +8 -0
  176. package/src/@utils/poll-job-until-validated.ts +76 -0
  177. package/src/@utils/url.spec.ts +75 -0
  178. package/src/@utils/url.ts +18 -0
  179. package/src/index.ts +27 -0
  180. package/tsconfig.json +9 -0
@@ -0,0 +1,126 @@
1
+ import { mapUploadFileErrorsResponseToUploadFileErrorRows } from './error-mapping'
2
+
3
+ type RowErrorsResponse = {
4
+ row?: number
5
+ index?: number
6
+ errors: Array<{ field: string; code: string; message: string }>
7
+ }
8
+
9
+ describe('mapUploadFileErrorsResponseToUploadFileErrorRows', () => {
10
+ it('should return an empty array if uploadFileErrorsResponse is undefined', () => {
11
+ const result = mapUploadFileErrorsResponseToUploadFileErrorRows(undefined)
12
+ expect(result).toEqual([])
13
+ })
14
+ it('should map the response errors correctly', () => {
15
+ const sampleErrorsResponse: Array<RowErrorsResponse> = [
16
+ {
17
+ row: 1,
18
+ errors: [
19
+ {
20
+ code: 'InvalidField',
21
+ message: '"name.de2" is not allowed',
22
+ field: 'name.de2',
23
+ },
24
+ {
25
+ code: 'InvalidField',
26
+ message: '"name.enn" is not allowed',
27
+ field: 'name.enn',
28
+ },
29
+ ],
30
+ },
31
+ {
32
+ row: 2,
33
+ errors: [
34
+ {
35
+ code: 'InvalidField',
36
+ message: '"slug.RU" is not allowed',
37
+ field: 'slug.RU',
38
+ },
39
+ ],
40
+ },
41
+ ]
42
+ const result =
43
+ mapUploadFileErrorsResponseToUploadFileErrorRows(sampleErrorsResponse)
44
+ expect(result).toMatchInlineSnapshot(`
45
+ [
46
+ {
47
+ "code": "InvalidField",
48
+ "field": "name.de2",
49
+ "id": "1",
50
+ "row": 1,
51
+ "validationMessage": ""name.de2" is not allowed",
52
+ },
53
+ {
54
+ "code": "InvalidField",
55
+ "field": "name.enn",
56
+ "id": "2",
57
+ "row": 1,
58
+ "validationMessage": ""name.enn" is not allowed",
59
+ },
60
+ {
61
+ "code": "InvalidField",
62
+ "field": "slug.RU",
63
+ "id": "3",
64
+ "row": 2,
65
+ "validationMessage": ""slug.RU" is not allowed",
66
+ },
67
+ ]
68
+ `)
69
+ })
70
+ it('should map the response errors correctly for json payload', () => {
71
+ const sampleErrorsResponse: Array<RowErrorsResponse> = [
72
+ {
73
+ index: 0,
74
+ errors: [
75
+ {
76
+ code: 'InvalidField',
77
+ message: '"name.de2" is not allowed',
78
+ field: 'name.de2',
79
+ },
80
+ {
81
+ code: 'InvalidField',
82
+ message: '"name.enn" is not allowed',
83
+ field: 'name.enn',
84
+ },
85
+ ],
86
+ },
87
+ {
88
+ index: 1,
89
+ errors: [
90
+ {
91
+ code: 'InvalidField',
92
+ message: '"slug.RU" is not allowed',
93
+ field: 'slug.RU',
94
+ },
95
+ ],
96
+ },
97
+ ]
98
+ const result =
99
+ mapUploadFileErrorsResponseToUploadFileErrorRows(sampleErrorsResponse)
100
+ expect(result).toMatchInlineSnapshot(`
101
+ [
102
+ {
103
+ "code": "InvalidField",
104
+ "field": "name.de2",
105
+ "id": "1",
106
+ "index": 0,
107
+ "validationMessage": ""name.de2" is not allowed",
108
+ },
109
+ {
110
+ "code": "InvalidField",
111
+ "field": "name.enn",
112
+ "id": "2",
113
+ "index": 0,
114
+ "validationMessage": ""name.enn" is not allowed",
115
+ },
116
+ {
117
+ "code": "InvalidField",
118
+ "field": "slug.RU",
119
+ "id": "3",
120
+ "index": 1,
121
+ "validationMessage": ""slug.RU" is not allowed",
122
+ },
123
+ ]
124
+ `)
125
+ })
126
+ })
@@ -0,0 +1,40 @@
1
+ export function getFileUploadErrorsCount(
2
+ errors?: Array<{ errors: Array<unknown> }>
3
+ ): number {
4
+ if (!errors || !Array.isArray(errors)) return 0
5
+ return errors.reduce((acc, curr) => (acc += curr.errors.length), 0)
6
+ }
7
+
8
+ // TODO: After fully migrating to new flow, remove `row` and only use `index`
9
+ export function mapUploadFileErrorsResponseToUploadFileErrorRows(
10
+ uploadFileErrorsResponse?: Array<{
11
+ row?: number
12
+ index?: number
13
+ errors: Array<{ field: string; code: string; message: string }>
14
+ }>
15
+ ): Array<{
16
+ id: string
17
+ row?: number
18
+ index?: number
19
+ field: string
20
+ code: string
21
+ validationMessage: string
22
+ }> {
23
+ if (!uploadFileErrorsResponse || !Array.isArray(uploadFileErrorsResponse))
24
+ return []
25
+ let idCounter = 1
26
+ return uploadFileErrorsResponse.flatMap((rowErrorsResponse) => {
27
+ // TODO: use `row` if available, otherwise fall back to `index`
28
+ // Old flow uses `row`, new flow uses `index`
29
+ const rowNumber = rowErrorsResponse.row ?? rowErrorsResponse.index
30
+
31
+ return rowErrorsResponse.errors.map((rowError) => ({
32
+ id: String(idCounter++),
33
+ row: rowNumber,
34
+ index: rowNumber,
35
+ field: rowError.field,
36
+ code: rowError.code,
37
+ validationMessage: rowError.message,
38
+ }))
39
+ })
40
+ }
@@ -0,0 +1,147 @@
1
+ import type { ResourceTypeId } from '@commercetools/importapi-sdk'
2
+ import type { FileImportJob } from '../@types'
3
+ import {
4
+ getFileImportJobFileType,
5
+ isImportJobQueued,
6
+ isImportJobProcessing,
7
+ isImportJobValidated,
8
+ isImportJobInitializing,
9
+ isImportJobReady,
10
+ shouldContinuePollingForImportValidation,
11
+ } from './file-import-job-helpers'
12
+
13
+ describe('file-import-job-helpers', () => {
14
+ describe('getFileImportJobFileType', () => {
15
+ it('should return "json" for custom-object resource type', () => {
16
+ expect(getFileImportJobFileType('custom-object' as ResourceTypeId)).toBe(
17
+ 'json'
18
+ )
19
+ })
20
+
21
+ it('should return "csv" for product resource type', () => {
22
+ expect(getFileImportJobFileType('product' as ResourceTypeId)).toBe('csv')
23
+ })
24
+
25
+ it('should return "csv" for category resource type', () => {
26
+ expect(getFileImportJobFileType('category' as ResourceTypeId)).toBe('csv')
27
+ })
28
+
29
+ it('should return "csv" for other resource types', () => {
30
+ expect(getFileImportJobFileType('price' as ResourceTypeId)).toBe('csv')
31
+ expect(
32
+ getFileImportJobFileType('inventory-entry' as ResourceTypeId)
33
+ ).toBe('csv')
34
+ expect(getFileImportJobFileType('customer' as ResourceTypeId)).toBe('csv')
35
+ })
36
+ })
37
+
38
+ const mockJob: FileImportJob = {
39
+ id: 'test-job-id',
40
+ fileName: 'test.csv',
41
+ importContainerKey: 'test-container',
42
+ state: 'validated',
43
+ summary: {
44
+ total: 100,
45
+ invalid: 5,
46
+ valid: 95,
47
+ fieldsCount: 10,
48
+ fields: ['field1', 'field2'],
49
+ ignoredFields: ['ignored1'],
50
+ },
51
+ }
52
+
53
+ describe('isImportJobQueued', () => {
54
+ it('should return true when job is queued', () => {
55
+ const queuedJob = { ...mockJob, state: 'queued' as const }
56
+ expect(isImportJobQueued(queuedJob)).toBe(true)
57
+ })
58
+
59
+ it('should return false when job is not queued', () => {
60
+ expect(isImportJobQueued(mockJob)).toBe(false)
61
+ })
62
+
63
+ it('should return false when job is undefined', () => {
64
+ expect(isImportJobQueued(undefined)).toBe(false)
65
+ })
66
+ })
67
+
68
+ describe('isImportJobProcessing', () => {
69
+ it('should return true when job is processing', () => {
70
+ const processingJob = { ...mockJob, state: 'processing' as const }
71
+ expect(isImportJobProcessing(processingJob)).toBe(true)
72
+ })
73
+
74
+ it('should return false when job is not processing', () => {
75
+ expect(isImportJobProcessing(mockJob)).toBe(false)
76
+ })
77
+ })
78
+
79
+ describe('isImportJobValidated', () => {
80
+ it('should return true when job is validated', () => {
81
+ expect(isImportJobValidated(mockJob)).toBe(true)
82
+ })
83
+
84
+ it('should return false when job is not validated', () => {
85
+ const processingJob = { ...mockJob, state: 'processing' as const }
86
+ expect(isImportJobValidated(processingJob)).toBe(false)
87
+ })
88
+
89
+ it('should return false when job is undefined', () => {
90
+ expect(isImportJobValidated(undefined)).toBe(false)
91
+ })
92
+ })
93
+
94
+ describe('isImportJobInitializing', () => {
95
+ it('should return true when job is initialising', () => {
96
+ const initialisingJob = { ...mockJob, state: 'initialising' as const }
97
+ expect(isImportJobInitializing(initialisingJob)).toBe(true)
98
+ })
99
+
100
+ it('should return false when job is not initialising', () => {
101
+ expect(isImportJobInitializing(mockJob)).toBe(false)
102
+ })
103
+ })
104
+
105
+ describe('isImportJobReady', () => {
106
+ it('should return true when job is ready', () => {
107
+ const readyJob = { ...mockJob, state: 'ready' as const }
108
+ expect(isImportJobReady(readyJob)).toBe(true)
109
+ })
110
+
111
+ it('should return false when job is not ready', () => {
112
+ expect(isImportJobReady(mockJob)).toBe(false)
113
+ })
114
+ })
115
+
116
+ describe('shouldContinuePollingForImportValidation', () => {
117
+ it('should return true when job is undefined', () => {
118
+ expect(shouldContinuePollingForImportValidation(undefined)).toBe(true)
119
+ })
120
+
121
+ it('should return true when job is queued', () => {
122
+ const queuedJob = { ...mockJob, state: 'queued' as const }
123
+ expect(shouldContinuePollingForImportValidation(queuedJob)).toBe(true)
124
+ })
125
+
126
+ it('should return true when job is processing', () => {
127
+ const processingJob = { ...mockJob, state: 'processing' as const }
128
+ expect(shouldContinuePollingForImportValidation(processingJob)).toBe(true)
129
+ })
130
+
131
+ it('should return false when job is validated', () => {
132
+ expect(shouldContinuePollingForImportValidation(mockJob)).toBe(false)
133
+ })
134
+
135
+ it('should return false when job is initialising', () => {
136
+ const initialisingJob = { ...mockJob, state: 'initialising' as const }
137
+ expect(shouldContinuePollingForImportValidation(initialisingJob)).toBe(
138
+ false
139
+ )
140
+ })
141
+
142
+ it('should return false when job is ready', () => {
143
+ const readyJob = { ...mockJob, state: 'ready' as const }
144
+ expect(shouldContinuePollingForImportValidation(readyJob)).toBe(false)
145
+ })
146
+ })
147
+ })
@@ -0,0 +1,47 @@
1
+ import type { ResourceTypeId } from '@commercetools/importapi-sdk'
2
+ import type { FileImportJob } from '../@types'
3
+
4
+ export function getFileImportJobFileType(
5
+ resourceType: ResourceTypeId
6
+ ): 'csv' | 'json' {
7
+ return resourceType === 'custom-object' ? 'json' : 'csv'
8
+ }
9
+
10
+ export function toImportApiResourceType(resourceType: string): string {
11
+ return resourceType === 'product' ? 'product-draft' : resourceType
12
+ }
13
+
14
+ export function isImportJobQueued(job?: FileImportJob): boolean {
15
+ return job?.state === 'queued'
16
+ }
17
+
18
+ export function isImportJobProcessing(job?: FileImportJob): boolean {
19
+ return job?.state === 'processing'
20
+ }
21
+
22
+ export function isImportJobValidated(job?: FileImportJob): boolean {
23
+ return job?.state === 'validated'
24
+ }
25
+
26
+ export function isImportJobInitializing(job?: FileImportJob): boolean {
27
+ return job?.state === 'initialising'
28
+ }
29
+
30
+ export function isImportJobReady(job?: FileImportJob): boolean {
31
+ return job?.state === 'ready'
32
+ }
33
+
34
+ export function isImportJobRejected(job?: FileImportJob): boolean {
35
+ return job?.state === 'rejected'
36
+ }
37
+
38
+ export function isImportJobTerminal(job?: FileImportJob): boolean {
39
+ return isImportJobValidated(job) || isImportJobRejected(job)
40
+ }
41
+
42
+ export function shouldContinuePollingForImportValidation(
43
+ job?: FileImportJob
44
+ ): boolean {
45
+ if (!job) return true
46
+ return isImportJobQueued(job) || isImportJobProcessing(job)
47
+ }
@@ -0,0 +1,151 @@
1
+ import { act } from '@testing-library/react'
2
+ import { validateDelimiter, getRowCount } from './file-upload'
3
+ import { COLUMN_DELIMITERS } from '../@constants'
4
+
5
+ const createFakeFile = (content: string) => {
6
+ const blob = new Blob([content], { type: 'text/csv' })
7
+ return new File([blob], 'test.csv', {
8
+ type: 'text/csv',
9
+ })
10
+ }
11
+
12
+ describe('validateDelimiter', () => {
13
+ it('returns true for an allowed delimiter ","', async () => {
14
+ const fileContent =
15
+ 'key,externalId,orderHint,name.de\nsample-category-key,sample-category-external-id,0.5,sample-category-name-de'
16
+ const file = createFakeFile(fileContent)
17
+ let result: boolean = false
18
+ await act(async () => {
19
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
20
+ })
21
+ expect(result).toBe(true)
22
+ })
23
+ it('returns true for an allowed delimiter ";"', async () => {
24
+ const fileContent =
25
+ 'key;externalId;orderHint;name.de\nsample-category-key;sample-category-external-id;0.5;sample-category-name-de'
26
+ const file = createFakeFile(fileContent)
27
+ let result: boolean = false
28
+ await act(async () => {
29
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
30
+ })
31
+ expect(result).toBe(true)
32
+ })
33
+ it('returns true for an allowed delimiter "|"', async () => {
34
+ const fileContent =
35
+ 'key|externalId|orderHint|name.de\nsample-category-key|sample-category-external-id|0.5|sample-category-name-de'
36
+ const file = createFakeFile(fileContent)
37
+ let result: boolean = false
38
+ await act(async () => {
39
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
40
+ })
41
+ expect(result).toBe(true)
42
+ })
43
+ it('returns true for an allowed delimiter "\\t"', async () => {
44
+ const fileContent =
45
+ 'key\texternalId\torderHint\tname.de\nsample-category-key\tsample-category-external-id\t0.5\tsample-category-name-de'
46
+ const file = createFakeFile(fileContent)
47
+ let result: boolean = false
48
+ await act(async () => {
49
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
50
+ })
51
+ expect(result).toBe(true)
52
+ })
53
+ it('returns false for a non-allowed delimiter "."', async () => {
54
+ const fileContent =
55
+ 'key.externalId.orderHint.name.de\nsample-category-key.sample-category-external-id.0.5.sample-category-name-de'
56
+ const file = createFakeFile(fileContent)
57
+ let result: boolean = false
58
+ await act(async () => {
59
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
60
+ })
61
+ expect(result).toBe(false)
62
+ })
63
+ it('returns false for a non-allowed delimiter " "', async () => {
64
+ const fileContent =
65
+ 'key externalId orderHint name.de\nsample-category-key sample-category-external-id 0.5 sample-category-name-de'
66
+ const file = createFakeFile(fileContent)
67
+ let result: boolean = false
68
+ await act(async () => {
69
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
70
+ })
71
+ expect(result).toBe(false)
72
+ })
73
+ it('returns false for a non-allowed delimiter "%"', async () => {
74
+ const fileContent =
75
+ 'key%externalId%orderHint%name.de\nsample-category-key%sample-category-external-id%0.5%sample-category-name-de'
76
+ const file = createFakeFile(fileContent)
77
+ let result: boolean = false
78
+ await act(async () => {
79
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
80
+ })
81
+ expect(result).toBe(false)
82
+ })
83
+ it('returns true for a single-column file', async () => {
84
+ const fileContent = 'key'
85
+ const file = createFakeFile(fileContent)
86
+ let result: boolean = false
87
+ await act(async () => {
88
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
89
+ })
90
+ expect(result).toBe(true)
91
+ })
92
+ it('returns false for a file with undetectable delimiter', async () => {
93
+ const fileContent =
94
+ 'key.externalId.orderHint.name\nvalue1.value2.value3.value4'
95
+ const file = createFakeFile(fileContent)
96
+ let result: boolean = false
97
+ await act(async () => {
98
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
99
+ })
100
+ expect(result).toBe(false)
101
+ })
102
+ it('returns false for a file with a non allowed column delimiter', async () => {
103
+ const fileContent =
104
+ 'key externalId orderHint name\nvalue1 value2 value3 value4'
105
+ const file = createFakeFile(fileContent)
106
+ let result: boolean = false
107
+ await act(async () => {
108
+ result = await validateDelimiter(file, COLUMN_DELIMITERS)
109
+ })
110
+ expect(result).toBe(false)
111
+ })
112
+ })
113
+
114
+ describe('getRowCount', () => {
115
+ it('returns count > 1 for a file with a header and at least one data row', async () => {
116
+ const fileContent = 'header1,header2\nsample-cell1,sample-cell2'
117
+ const file = createFakeFile(fileContent)
118
+ let rowCount: number
119
+ await act(async () => {
120
+ rowCount = await getRowCount(file)
121
+ })
122
+ expect(rowCount!).toBeGreaterThanOrEqual(1)
123
+ })
124
+ it('returns count of 0 for a file with only a header', async () => {
125
+ const fileContent = 'header1,header2'
126
+ const file = createFakeFile(fileContent)
127
+ let rowCount: number
128
+ await act(async () => {
129
+ rowCount = await getRowCount(file)
130
+ })
131
+ expect(rowCount!).toBe(0)
132
+ })
133
+ it('returns count of 0 for a file with only a header and empty line', async () => {
134
+ const fileContent = 'header1,header2\n'
135
+ const file = createFakeFile(fileContent)
136
+ let rowCount: number
137
+ await act(async () => {
138
+ rowCount = await getRowCount(file)
139
+ })
140
+ expect(rowCount!).toBe(0)
141
+ })
142
+ it('returns count of 0 for an empty file', async () => {
143
+ const fileContent = ''
144
+ const file = createFakeFile(fileContent)
145
+ let rowCount: number
146
+ await act(async () => {
147
+ rowCount = await getRowCount(file)
148
+ })
149
+ expect(rowCount!).toBe(0)
150
+ })
151
+ })
@@ -0,0 +1,189 @@
1
+ import Papa from 'papaparse'
2
+
3
+ /**
4
+ * Convert megabytes to bytes
5
+ */
6
+ export const toBytes = (megabytes: number): number => megabytes * 1024 * 1024
7
+
8
+ /**
9
+ * Returns the number of rows in a CSV file excluding the header
10
+ * @param file The CSV file to process
11
+ * @returns A promise that resolves to the number of rows
12
+ */
13
+ export const getRowCount = (file: File): Promise<number> => {
14
+ return new Promise((resolve) => {
15
+ let lineCount = 0
16
+ Papa.parse(file, {
17
+ step: ({ data }: { data: unknown[] }) => {
18
+ // empty lines at the end of the file should not be counted
19
+ if (Array.isArray(data) && data.find(Boolean)) lineCount++
20
+ },
21
+ complete: () => {
22
+ // Subtract 1 for the header row
23
+ // We use Math.max to make sure the count is not less than 0, this is needed for empty files
24
+ resolve(Math.max(0, lineCount - 1))
25
+ },
26
+ })
27
+ })
28
+ }
29
+
30
+ /**
31
+ * Check if a CSV file has a single key column
32
+ * @param file The CSV file to check
33
+ * @returns A promise that resolves to true if the file has only one column named 'key'
34
+ */
35
+ export const hasSingleKeyColumn = (file: File): Promise<boolean> => {
36
+ return new Promise((resolve) => {
37
+ Papa.parse(file, {
38
+ preview: 1,
39
+ complete: (result) => {
40
+ const headerRow = result.data?.[0]
41
+ const hasSingleColumn =
42
+ Array.isArray(headerRow) && headerRow.length === 1
43
+ const isKeyColumn =
44
+ hasSingleColumn && headerRow[0].toLowerCase() === 'key'
45
+ resolve(isKeyColumn)
46
+ },
47
+ })
48
+ })
49
+ }
50
+
51
+ /**
52
+ * Validate delimiter in a CSV file
53
+ * @param file The CSV file to validate
54
+ * @param allowedDelimiters Array of allowed delimiters
55
+ * @returns A promise that resolves to false if delimiter is invalid
56
+ */
57
+ export const validateDelimiter = (
58
+ file: File,
59
+ allowedDelimiters: string[]
60
+ ): Promise<boolean> => {
61
+ // Delimiters not included in this array may be treated as part of a column content
62
+ // potentially causing the file to be parsed as a single-column CSV and pass this validation
63
+ const NON_ALLOWED_COLUMN_DELIMITERS = ['%', '.', ' ']
64
+ const DELIMITERS_TO_GUESS = [
65
+ ...allowedDelimiters,
66
+ ...NON_ALLOWED_COLUMN_DELIMITERS,
67
+ ]
68
+
69
+ return new Promise((resolve) => {
70
+ Papa.parse<unknown[]>(file, {
71
+ delimitersToGuess: DELIMITERS_TO_GUESS,
72
+ preview: 10,
73
+ complete: (result) => {
74
+ const headerRow = result.data?.[0]
75
+ const isOnlyOneColumn =
76
+ Array.isArray(headerRow) && headerRow.length === 1
77
+
78
+ if (isOnlyOneColumn) {
79
+ resolve(true)
80
+ return
81
+ }
82
+
83
+ const detectedDelimiter = result.meta.delimiter
84
+ let isValid = false
85
+
86
+ if (
87
+ detectedDelimiter !== null &&
88
+ allowedDelimiters.includes(detectedDelimiter)
89
+ ) {
90
+ const isUndetectableDelimiter = result.errors.some(
91
+ (error) => error.code === 'UndetectableDelimiter'
92
+ )
93
+ if (!isUndetectableDelimiter) {
94
+ isValid = true
95
+ }
96
+ }
97
+ resolve(isValid)
98
+ },
99
+ })
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Returns columns from the provided `columns` excluding those specified in the `ignoredColumns`
105
+ */
106
+ export const getValidatedColumns = (
107
+ columns: string[],
108
+ ignoredColumns: string[]
109
+ ): string[] => {
110
+ return columns.filter((column) => !ignoredColumns.includes(column))
111
+ }
112
+
113
+ /**
114
+ * Count items in a JSON file
115
+ * @param file The JSON file to process
116
+ * @returns Object with isValid flag and optional itemsCount
117
+ */
118
+ export const countJsonFileItems = async (
119
+ file: File
120
+ ): Promise<{ isValid: false } | { isValid: true; itemsCount: number }> => {
121
+ const jsonContent = await file.text()
122
+ try {
123
+ const content = JSON.parse(jsonContent)
124
+ const isValid = Array.isArray(content)
125
+ if (isValid) {
126
+ return { isValid, itemsCount: content.length }
127
+ }
128
+ return { isValid }
129
+ } catch {
130
+ return { isValid: false }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Count unique resources in a CSV file by counting unique values in the "key" column.
136
+ * A single resource can span multiple rows (when it has array fields like variants, assets...),
137
+ * so we count unique keys rather than rows.
138
+ * @param file The CSV file to process
139
+ * @returns A promise that resolves to the number of unique resources
140
+ */
141
+ export const countUniqueResourcesInCsv = (file: File): Promise<number> => {
142
+ return new Promise((resolve) => {
143
+ const uniqueKeys = new Set<string>()
144
+ let keyColumnIndex = -1
145
+ let isFirstRow = true
146
+
147
+ Papa.parse<string[]>(file, {
148
+ step: ({ data }) => {
149
+ if (!Array.isArray(data)) return
150
+
151
+ if (isFirstRow) {
152
+ keyColumnIndex = data.findIndex(
153
+ (col) => col?.toLowerCase().trim() === 'key'
154
+ )
155
+ isFirstRow = false
156
+ return
157
+ }
158
+
159
+ if (keyColumnIndex === -1) return
160
+
161
+ const keyValue = data[keyColumnIndex]?.trim()
162
+ if (keyValue) {
163
+ uniqueKeys.add(keyValue)
164
+ }
165
+ },
166
+ complete: () => {
167
+ resolve(uniqueKeys.size)
168
+ },
169
+ })
170
+ })
171
+ }
172
+
173
+ /**
174
+ * Map file upload errors to upload file error rows with unique IDs
175
+ * @param uploadFileErrors Array of file upload errors
176
+ * @returns Array of upload file errors with unique id field
177
+ */
178
+ export const mapFileUploadErrorsToUploadFileErrorRows = <
179
+ T extends Record<string, unknown>
180
+ >(
181
+ uploadFileErrors: T[]
182
+ ): Array<T & { id: string }> => {
183
+ let idCounter = 1
184
+ return uploadFileErrors.map((uploadFileError) => ({
185
+ // DataTable component requires unique `id` field
186
+ ...uploadFileError,
187
+ id: String(idCounter++),
188
+ }))
189
+ }