@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,223 @@
1
+ import React from 'react'
2
+ import type { ResourceTypeId } from '@commercetools/importapi-sdk'
3
+ import { useImportContainerUpload } from './use-import-container-upload'
4
+ import { useFileImportJobUpload } from './use-file-import-job-upload'
5
+ import { getFileImportJobRecords, deleteImportContainer } from '../@api'
6
+ import { HttpError, PollingAbortedError } from '../@errors'
7
+ import { pollJobUntilValidated } from '../@utils'
8
+ import type {
9
+ ExtendedImportContainerDraft,
10
+ FileUploadResult,
11
+ FileImportJob,
12
+ } from '../@types'
13
+
14
+ export type ValidationProgress = {
15
+ processed: number
16
+ isValidating: boolean
17
+ }
18
+
19
+ export type FileUploadConfig = {
20
+ file: File
21
+ resourceType: ResourceTypeId
22
+ settings?: ExtendedImportContainerDraft['settings']
23
+ onSuccess: (result: FileUploadResult) => void
24
+ onError?: (error: unknown) => void
25
+ onProgress?: (progress: number) => void
26
+ onValidationProgress?: (job: FileImportJob) => void
27
+ abortSignal?: AbortSignal
28
+ }
29
+
30
+ export type FileUploadOptions = {
31
+ projectKey: string
32
+ useJobBasedFlow?: boolean
33
+ pollingInterval?: number
34
+ maxPollingAttempts?: number
35
+ }
36
+
37
+ const safeDeleteContainer = async ({
38
+ projectKey,
39
+ containerKey,
40
+ }: {
41
+ projectKey: string
42
+ containerKey: string
43
+ }) => {
44
+ try {
45
+ await deleteImportContainer({
46
+ projectKey,
47
+ importContainerKey: containerKey,
48
+ })
49
+ } catch {}
50
+ }
51
+
52
+ export const useFileUpload = ({
53
+ projectKey,
54
+ useJobBasedFlow = false,
55
+ pollingInterval = 5000,
56
+ maxPollingAttempts = 120,
57
+ }: FileUploadOptions) => {
58
+ const [isUploading, setIsUploading] = React.useState(false)
59
+ const [progress, setProgress] = React.useState(0)
60
+ const [validationProgress, setValidationProgress] =
61
+ React.useState<ValidationProgress>({
62
+ processed: 0,
63
+ isValidating: false,
64
+ })
65
+
66
+ const containerUpload = useImportContainerUpload({ projectKey })
67
+ const jobUpload = useFileImportJobUpload({ projectKey })
68
+
69
+ const resetState = React.useCallback(() => {
70
+ setIsUploading(false)
71
+ setProgress(0)
72
+ setValidationProgress({ processed: 0, isValidating: false })
73
+ }, [])
74
+
75
+ const upload = React.useCallback(
76
+ async (config: FileUploadConfig) => {
77
+ setIsUploading(true)
78
+ setProgress(0)
79
+
80
+ try {
81
+ if (useJobBasedFlow) {
82
+ await jobUpload.upload({
83
+ file: config.file,
84
+ resourceType: config.resourceType,
85
+ settings: config.settings,
86
+ abortSignal: config.abortSignal,
87
+ onSuccess: async (jobId, containerKey) => {
88
+ try {
89
+ setValidationProgress({ processed: 0, isValidating: true })
90
+
91
+ const validatedJob = await pollJobUntilValidated({
92
+ projectKey,
93
+ jobId,
94
+ importContainerKey: containerKey,
95
+ pollingInterval,
96
+ maxAttempts: maxPollingAttempts,
97
+ abortSignal: config.abortSignal,
98
+ onJobUpdate: (job) => {
99
+ const processed = job.summary?.total ?? 0
100
+ setValidationProgress({ processed, isValidating: true })
101
+ config.onValidationProgress?.(job)
102
+ },
103
+ })
104
+
105
+ // Handle rejected job with jobError for the new flow (like MissingCsvFieldIdentifier error)
106
+ // We wrap it in HttpError to reuse existing error handling (for the old flow) in consumers until BE supports this error type
107
+ if (validatedJob.jobError) {
108
+ throw new HttpError(
109
+ 400,
110
+ validatedJob.jobError.message,
111
+ validatedJob.jobError
112
+ )
113
+ }
114
+
115
+ let results: FileUploadResult['summary']['results'] = []
116
+ if (validatedJob.summary.invalid > 0) {
117
+ const recordsResponse = await getFileImportJobRecords({
118
+ projectKey,
119
+ importContainerKey: containerKey,
120
+ jobId,
121
+ limit: 500,
122
+ isValid: false,
123
+ })
124
+ results = recordsResponse.results
125
+ await safeDeleteContainer({ projectKey, containerKey })
126
+ }
127
+
128
+ const result: FileUploadResult = {
129
+ containerKey,
130
+ summary: {
131
+ ...validatedJob.summary,
132
+ results,
133
+ },
134
+ jobId,
135
+ job: validatedJob,
136
+ }
137
+
138
+ setIsUploading(false)
139
+ setValidationProgress({ processed: 0, isValidating: false })
140
+ config.onSuccess(result)
141
+ } catch (error) {
142
+ await safeDeleteContainer({ projectKey, containerKey })
143
+ resetState()
144
+
145
+ if (!(error instanceof PollingAbortedError)) {
146
+ config.onError?.(error)
147
+ }
148
+ }
149
+ },
150
+ onProgress: (prog) => {
151
+ setProgress(prog)
152
+ config.onProgress?.(prog)
153
+ },
154
+ onError: (error) => {
155
+ resetState()
156
+ config.onError?.(error)
157
+ },
158
+ })
159
+ } else {
160
+ await containerUpload.upload({
161
+ file: config.file,
162
+ resourceType: config.resourceType,
163
+ settings: config.settings,
164
+ abortSignal: config.abortSignal,
165
+ onSuccess: async (fileUploadResponse, containerKey) => {
166
+ if (config.abortSignal?.aborted) {
167
+ await safeDeleteContainer({ projectKey, containerKey })
168
+ resetState()
169
+ return
170
+ }
171
+
172
+ if (fileUploadResponse.invalid > 0) {
173
+ await safeDeleteContainer({ projectKey, containerKey })
174
+ }
175
+
176
+ const result: FileUploadResult = {
177
+ containerKey,
178
+ summary: {
179
+ total: fileUploadResponse.itemsCount,
180
+ valid: fileUploadResponse.valid,
181
+ invalid: fileUploadResponse.invalid,
182
+ fieldsCount: fileUploadResponse.columnsCount,
183
+ fields: fileUploadResponse.fields || [],
184
+ ignoredFields: fileUploadResponse.ignoredFields || [],
185
+ results: fileUploadResponse.results || [],
186
+ },
187
+ }
188
+
189
+ setIsUploading(false)
190
+ config.onSuccess(result)
191
+ },
192
+ onProgress: (prog) => {
193
+ setProgress(prog)
194
+ config.onProgress?.(prog)
195
+ },
196
+ onError: (error) => {
197
+ resetState()
198
+ config.onError?.(error)
199
+ },
200
+ })
201
+ }
202
+ } catch (error) {
203
+ resetState()
204
+ config.onError?.(error)
205
+ }
206
+ },
207
+ [
208
+ projectKey,
209
+ useJobBasedFlow,
210
+ pollingInterval,
211
+ maxPollingAttempts,
212
+ containerUpload,
213
+ jobUpload,
214
+ ]
215
+ )
216
+
217
+ return {
218
+ upload,
219
+ isUploading,
220
+ progress,
221
+ validationProgress,
222
+ }
223
+ }
@@ -0,0 +1,297 @@
1
+ import { renderHook, waitFor, act } from '@testing-library/react'
2
+ import {
3
+ createImportContainerForFileUpload,
4
+ deleteImportContainer,
5
+ uploadFileForImport,
6
+ } from '../@api'
7
+ import { ProjectKeyNotAvailableError } from '../@errors'
8
+ import { encodeFileNameWithTimestampToContainerKey } from '../@utils'
9
+ import {
10
+ useImportContainerUpload,
11
+ type UseImportContainerUploadConfig,
12
+ } from './use-import-container-upload'
13
+
14
+ jest.mock('../@api')
15
+ jest.mock('../@utils')
16
+
17
+ const mockCreateImportContainerForFileUpload =
18
+ createImportContainerForFileUpload as jest.MockedFunction<
19
+ typeof createImportContainerForFileUpload
20
+ >
21
+ const mockUploadFileForImport = uploadFileForImport as jest.MockedFunction<
22
+ typeof uploadFileForImport
23
+ >
24
+ const mockDeleteImportContainer = deleteImportContainer as jest.MockedFunction<
25
+ typeof deleteImportContainer
26
+ >
27
+ const mockEncodeFileNameWithTimestampToContainerKey =
28
+ encodeFileNameWithTimestampToContainerKey as jest.MockedFunction<
29
+ typeof encodeFileNameWithTimestampToContainerKey
30
+ >
31
+
32
+ describe('useImportContainerUpload', () => {
33
+ const projectKey = 'test-with-big-data'
34
+ const importContainerKey = 'test-container-key'
35
+ const mockFile = new File(['test content'], 'test.csv', { type: 'text/csv' })
36
+ const mockXhr = { abort: jest.fn() } as unknown as XMLHttpRequest
37
+ const mockFileUploadResponse = {
38
+ results: [],
39
+ invalid: 0,
40
+ valid: 10,
41
+ fileName: 'test.csv',
42
+ itemsCount: 10,
43
+ rowsCount: 10,
44
+ columnsCount: 2,
45
+ fields: ['key', 'value'],
46
+ ignoredFields: [],
47
+ }
48
+
49
+ beforeEach(() => {
50
+ jest.clearAllMocks()
51
+ mockEncodeFileNameWithTimestampToContainerKey.mockReturnValue(
52
+ importContainerKey
53
+ )
54
+ mockCreateImportContainerForFileUpload.mockResolvedValue(undefined)
55
+ mockDeleteImportContainer.mockResolvedValue(undefined)
56
+ })
57
+
58
+ it('should throw "ProjectKeyNotAvailableError" if "projectKey" is not available', async () => {
59
+ const { result } = renderHook(() =>
60
+ // @ts-ignore
61
+ useImportContainerUpload({ projectKey: undefined })
62
+ )
63
+
64
+ await expect(
65
+ result.current.upload({
66
+ file: mockFile,
67
+ resourceType: 'product',
68
+ onSuccess: jest.fn(),
69
+ })
70
+ ).rejects.toThrow(ProjectKeyNotAvailableError)
71
+ })
72
+
73
+ it('should create container and upload file successfully', async () => {
74
+ const onSuccess = jest.fn()
75
+ const onProgress = jest.fn()
76
+
77
+ mockUploadFileForImport.mockImplementation(({ onSuccess: _onSuccess }) => {
78
+ _onSuccess(mockFileUploadResponse)
79
+ return mockXhr
80
+ })
81
+
82
+ const { result } = renderHook(() =>
83
+ useImportContainerUpload({ projectKey })
84
+ )
85
+
86
+ const uploadConfig: UseImportContainerUploadConfig = {
87
+ file: mockFile,
88
+ resourceType: 'product',
89
+ settings: { format: 'CSV' },
90
+ onSuccess,
91
+ onProgress,
92
+ }
93
+
94
+ await act(async () => {
95
+ await result.current.upload(uploadConfig)
96
+ })
97
+
98
+ expect(mockCreateImportContainerForFileUpload).toHaveBeenCalledWith({
99
+ importContainerDraft: {
100
+ key: importContainerKey,
101
+ resourceType: 'product',
102
+ tags: ['source:file-upload'],
103
+ settings: { format: 'CSV' },
104
+ },
105
+ projectKey,
106
+ })
107
+
108
+ expect(mockUploadFileForImport).toHaveBeenCalledWith({
109
+ projectKey,
110
+ importContainerKey,
111
+ resourceType: 'product',
112
+ file: mockFile,
113
+ onSuccess: expect.any(Function),
114
+ onProgress: expect.any(Function),
115
+ onError: expect.any(Function),
116
+ })
117
+
118
+ expect(onSuccess).toHaveBeenCalledWith(
119
+ mockFileUploadResponse,
120
+ importContainerKey
121
+ )
122
+ })
123
+
124
+ it('should delete container on upload error', async () => {
125
+ const onError = jest.fn()
126
+ const uploadError = new Error('Upload failed')
127
+
128
+ mockUploadFileForImport.mockImplementation(({ onError: _onError }) => {
129
+ _onError?.(uploadError)
130
+ return mockXhr
131
+ })
132
+
133
+ const { result } = renderHook(() =>
134
+ useImportContainerUpload({ projectKey })
135
+ )
136
+
137
+ await act(async () => {
138
+ await result.current.upload({
139
+ file: mockFile,
140
+ resourceType: 'product',
141
+ onSuccess: jest.fn(),
142
+ onError,
143
+ })
144
+ })
145
+
146
+ await waitFor(() => {
147
+ expect(mockDeleteImportContainer).toHaveBeenCalledWith({
148
+ projectKey,
149
+ importContainerKey,
150
+ })
151
+ })
152
+ expect(onError).toHaveBeenCalledWith(uploadError)
153
+ })
154
+
155
+ it('should delete container if upload throws before starting', async () => {
156
+ const onError = jest.fn()
157
+ const error = new Error('Container creation failed')
158
+ mockCreateImportContainerForFileUpload.mockRejectedValue(error)
159
+
160
+ const { result } = renderHook(() =>
161
+ useImportContainerUpload({ projectKey })
162
+ )
163
+
164
+ await act(async () => {
165
+ await result.current.upload({
166
+ file: mockFile,
167
+ resourceType: 'product',
168
+ onSuccess: jest.fn(),
169
+ onError,
170
+ })
171
+ })
172
+
173
+ await waitFor(() => {
174
+ expect(mockDeleteImportContainer).toHaveBeenCalledWith({
175
+ projectKey,
176
+ importContainerKey,
177
+ })
178
+ })
179
+ expect(onError).toHaveBeenCalledWith(error)
180
+ })
181
+
182
+ it('should update progress during upload', async () => {
183
+ const onProgress = jest.fn()
184
+
185
+ mockUploadFileForImport.mockImplementation(
186
+ ({ onProgress: _onProgress }) => {
187
+ _onProgress?.(50)
188
+ _onProgress?.(100)
189
+ return mockXhr
190
+ }
191
+ )
192
+
193
+ const { result } = renderHook(() =>
194
+ useImportContainerUpload({ projectKey })
195
+ )
196
+
197
+ await act(async () => {
198
+ await result.current.upload({
199
+ file: mockFile,
200
+ resourceType: 'product',
201
+ onSuccess: jest.fn(),
202
+ onProgress,
203
+ })
204
+ })
205
+
206
+ expect(onProgress).toHaveBeenCalledWith(50)
207
+ expect(onProgress).toHaveBeenCalledWith(100)
208
+ })
209
+
210
+ it('should update isUploading state correctly', async () => {
211
+ mockUploadFileForImport.mockImplementation(({ onSuccess }) => {
212
+ setTimeout(() => onSuccess(mockFileUploadResponse), 100)
213
+ return mockXhr
214
+ })
215
+
216
+ const { result } = renderHook(() =>
217
+ useImportContainerUpload({ projectKey })
218
+ )
219
+
220
+ expect(result.current.isUploading).toBe(false)
221
+
222
+ act(() => {
223
+ result.current.upload({
224
+ file: mockFile,
225
+ resourceType: 'product',
226
+ onSuccess: jest.fn(),
227
+ })
228
+ })
229
+
230
+ expect(result.current.isUploading).toBe(true)
231
+
232
+ await waitFor(() => {
233
+ expect(result.current.isUploading).toBe(false)
234
+ })
235
+ })
236
+
237
+ it('should abort upload when abort is called', async () => {
238
+ mockUploadFileForImport.mockReturnValue(mockXhr)
239
+
240
+ const { result } = renderHook(() =>
241
+ useImportContainerUpload({ projectKey })
242
+ )
243
+
244
+ act(() => {
245
+ result.current.upload({
246
+ file: mockFile,
247
+ resourceType: 'product',
248
+ onSuccess: jest.fn(),
249
+ })
250
+ })
251
+
252
+ await waitFor(() => {
253
+ expect(mockUploadFileForImport).toHaveBeenCalled()
254
+ })
255
+
256
+ act(() => {
257
+ result.current.abort()
258
+ })
259
+
260
+ expect(mockXhr.abort).toHaveBeenCalled()
261
+ })
262
+
263
+ it('should work without optional settings', async () => {
264
+ const onSuccess = jest.fn()
265
+
266
+ mockUploadFileForImport.mockImplementation(({ onSuccess: _onSuccess }) => {
267
+ _onSuccess(mockFileUploadResponse)
268
+ return mockXhr
269
+ })
270
+
271
+ const { result } = renderHook(() =>
272
+ useImportContainerUpload({ projectKey })
273
+ )
274
+
275
+ await act(async () => {
276
+ await result.current.upload({
277
+ file: mockFile,
278
+ resourceType: 'product',
279
+ onSuccess,
280
+ })
281
+ })
282
+
283
+ expect(mockCreateImportContainerForFileUpload).toHaveBeenCalledWith({
284
+ importContainerDraft: {
285
+ key: importContainerKey,
286
+ resourceType: 'product',
287
+ tags: ['source:file-upload'],
288
+ },
289
+ projectKey,
290
+ })
291
+
292
+ expect(onSuccess).toHaveBeenCalledWith(
293
+ mockFileUploadResponse,
294
+ importContainerKey
295
+ )
296
+ })
297
+ })
@@ -0,0 +1,130 @@
1
+ import React from 'react'
2
+ import type { ResourceTypeId } from '@commercetools/importapi-sdk'
3
+ import {
4
+ createImportContainerForFileUpload,
5
+ deleteImportContainer,
6
+ uploadFileForImport,
7
+ } from '../@api'
8
+ import { ProjectKeyNotAvailableError } from '../@errors'
9
+ import { TAG_KEY_SOURCE_FILE_UPLOAD } from '../@constants'
10
+ import { encodeFileNameWithTimestampToContainerKey } from '../@utils'
11
+ import type {
12
+ FileUploadResponse,
13
+ ExtendedImportContainerDraft,
14
+ } from '../@types'
15
+
16
+ export type UseImportContainerUploadConfig = {
17
+ file: File
18
+ resourceType: ResourceTypeId
19
+ settings?: ExtendedImportContainerDraft['settings']
20
+ onSuccess: (response: FileUploadResponse, importContainerKey: string) => void
21
+ onError?: (error: unknown) => void
22
+ onProgress?: (progress: number) => void
23
+ abortSignal?: AbortSignal
24
+ }
25
+
26
+ export const useImportContainerUpload = ({
27
+ projectKey,
28
+ }: {
29
+ projectKey: string
30
+ }) => {
31
+ const [isUploading, setIsUploading] = React.useState(false)
32
+ const [progress, setProgress] = React.useState(0)
33
+ const xhrRef = React.useRef<XMLHttpRequest | null>(null)
34
+
35
+ const upload = async ({
36
+ file,
37
+ resourceType,
38
+ settings,
39
+ onSuccess,
40
+ onError,
41
+ onProgress,
42
+ abortSignal,
43
+ }: UseImportContainerUploadConfig): Promise<XMLHttpRequest | undefined> => {
44
+ if (!projectKey) {
45
+ throw new ProjectKeyNotAvailableError()
46
+ }
47
+
48
+ setIsUploading(true)
49
+ setProgress(0)
50
+
51
+ const importContainerKey = encodeFileNameWithTimestampToContainerKey(
52
+ file.name
53
+ )
54
+
55
+ try {
56
+ await createImportContainerForFileUpload({
57
+ importContainerDraft: {
58
+ key: importContainerKey,
59
+ resourceType,
60
+ tags: [TAG_KEY_SOURCE_FILE_UPLOAD],
61
+ ...(settings ? { settings } : {}),
62
+ },
63
+ projectKey,
64
+ })
65
+
66
+ const xhr = uploadFileForImport({
67
+ projectKey,
68
+ importContainerKey,
69
+ resourceType,
70
+ file,
71
+ abortSignal,
72
+ onSuccess: (response) => {
73
+ setIsUploading(false)
74
+ setProgress(100)
75
+ onSuccess(response, importContainerKey)
76
+ },
77
+ onProgress: (prog) => {
78
+ setProgress(prog)
79
+ onProgress?.(prog)
80
+ },
81
+ onError: async (error) => {
82
+ try {
83
+ await deleteImportContainer({
84
+ projectKey,
85
+ importContainerKey,
86
+ })
87
+ } catch {
88
+ // Ignore cleanup errors - container will be cleaned up by TTL retention policy
89
+ // Cleanup errors are unlikely unless there is a network issue or container was removed externally
90
+ }
91
+
92
+ setIsUploading(false)
93
+ setProgress(0)
94
+ onError?.(error)
95
+ },
96
+ })
97
+
98
+ xhrRef.current = xhr
99
+ return xhr
100
+ } catch (error) {
101
+ try {
102
+ await deleteImportContainer({
103
+ projectKey,
104
+ importContainerKey,
105
+ })
106
+ } catch {
107
+ // Ignore cleanup errors - container will be cleaned up by TTL retention policy
108
+ // Cleanup errors are unlikely unless there is a network issue or container was removed externally
109
+ }
110
+
111
+ setIsUploading(false)
112
+ setProgress(0)
113
+ onError?.(error)
114
+ return undefined
115
+ }
116
+ }
117
+
118
+ const abort = () => {
119
+ xhrRef.current?.abort()
120
+ setIsUploading(false)
121
+ setProgress(0)
122
+ }
123
+
124
+ return {
125
+ upload,
126
+ abort,
127
+ isUploading,
128
+ progress,
129
+ }
130
+ }
@@ -0,0 +1,14 @@
1
+ type METHOD = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
2
+
3
+ export type ApiConfig = {
4
+ headers?: Record<string, string | null>
5
+ method?: METHOD
6
+ proxy?: string
7
+ abortSignal?: AbortSignal
8
+ }
9
+
10
+ export type Fetcher = {
11
+ url: string
12
+ payload?: BodyInit
13
+ config?: ApiConfig
14
+ }
@@ -0,0 +1,5 @@
1
+ export interface BasicErrorDataType {
2
+ message: string
3
+ code: string
4
+ [key: string]: unknown
5
+ }