@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.
- package/CHANGELOG.md +61 -0
- package/README.md +178 -0
- package/babel.config.js +6 -0
- package/dist/commercetools-frontend-extensions-operations.cjs.d.ts +2 -0
- package/dist/commercetools-frontend-extensions-operations.cjs.dev.js +3273 -0
- package/dist/commercetools-frontend-extensions-operations.cjs.js +7 -0
- package/dist/commercetools-frontend-extensions-operations.cjs.prod.js +3265 -0
- package/dist/commercetools-frontend-extensions-operations.esm.js +3074 -0
- package/dist/declarations/src/@api/export-operations.d.ts +5 -0
- package/dist/declarations/src/@api/fetcher.d.ts +17 -0
- package/dist/declarations/src/@api/file-import-jobs.d.ts +7 -0
- package/dist/declarations/src/@api/file-upload.d.ts +3 -0
- package/dist/declarations/src/@api/import-containers.d.ts +35 -0
- package/dist/declarations/src/@api/import-operations.d.ts +6 -0
- package/dist/declarations/src/@api/index.d.ts +9 -0
- package/dist/declarations/src/@api/process-file.d.ts +3 -0
- package/dist/declarations/src/@api/test-fixtures.d.ts +279 -0
- package/dist/declarations/src/@api/urls.d.ts +74 -0
- package/dist/declarations/src/@components/file-drop-area/active-drag-drop-area.d.ts +10 -0
- package/dist/declarations/src/@components/file-drop-area/disabled-drop-area.d.ts +5 -0
- package/dist/declarations/src/@components/file-drop-area/drop-area-wrapper.d.ts +11 -0
- package/dist/declarations/src/@components/file-drop-area/enabled-drop-area.d.ts +7 -0
- package/dist/declarations/src/@components/file-drop-area/file-drop-area.d.ts +14 -0
- package/dist/declarations/src/@components/file-drop-area/file-dropped-area.d.ts +6 -0
- package/dist/declarations/src/@components/file-drop-area/index.d.ts +7 -0
- package/dist/declarations/src/@components/file-drop-area/styles.d.ts +9 -0
- package/dist/declarations/src/@components/icons/file-icon.d.ts +2 -0
- package/dist/declarations/src/@components/icons/index.d.ts +2 -0
- package/dist/declarations/src/@components/icons/lock-icon.d.ts +2 -0
- package/dist/declarations/src/@components/index.d.ts +6 -0
- package/dist/declarations/src/@components/info-box/index.d.ts +1 -0
- package/dist/declarations/src/@components/info-box/info-box.d.ts +7 -0
- package/dist/declarations/src/@components/upload-separator/index.d.ts +1 -0
- package/dist/declarations/src/@components/upload-separator/upload-separator.d.ts +12 -0
- package/dist/declarations/src/@components/upload-settings/index.d.ts +1 -0
- package/dist/declarations/src/@components/upload-settings/upload-settings.d.ts +11 -0
- package/dist/declarations/src/@components/uploading-modal/index.d.ts +1 -0
- package/dist/declarations/src/@components/uploading-modal/uploading-modal.d.ts +13 -0
- package/dist/declarations/src/@constants/delimiters.d.ts +8 -0
- package/dist/declarations/src/@constants/file-import-job.d.ts +1 -0
- package/dist/declarations/src/@constants/import-limits.d.ts +6 -0
- package/dist/declarations/src/@constants/import-tags.d.ts +7 -0
- package/dist/declarations/src/@constants/index.d.ts +5 -0
- package/dist/declarations/src/@constants/resource-links.d.ts +10 -0
- package/dist/declarations/src/@errors/http-error.d.ts +6 -0
- package/dist/declarations/src/@errors/index.d.ts +9 -0
- package/dist/declarations/src/@errors/invalid-response-error.d.ts +3 -0
- package/dist/declarations/src/@errors/no-resources-to-export-error.d.ts +3 -0
- package/dist/declarations/src/@errors/polling-aborted-error.d.ts +3 -0
- package/dist/declarations/src/@errors/project-key-not-available-error.d.ts +3 -0
- package/dist/declarations/src/@errors/query-predicate-error.d.ts +4 -0
- package/dist/declarations/src/@errors/unexpected-column-error.d.ts +3 -0
- package/dist/declarations/src/@errors/unexpected-operation-state-error.d.ts +4 -0
- package/dist/declarations/src/@errors/unexpected-resource-type-error.d.ts +3 -0
- package/dist/declarations/src/@hooks/index.d.ts +8 -0
- package/dist/declarations/src/@hooks/use-fetch-export-operations.d.ts +15 -0
- package/dist/declarations/src/@hooks/use-fetch-file-import-job.d.ts +17 -0
- package/dist/declarations/src/@hooks/use-fetch-import-container-details.d.ts +15 -0
- package/dist/declarations/src/@hooks/use-fetch-import-operations.d.ts +16 -0
- package/dist/declarations/src/@hooks/use-fetch-import-summaries.d.ts +20 -0
- package/dist/declarations/src/@hooks/use-file-import-job-upload.d.ts +18 -0
- package/dist/declarations/src/@hooks/use-file-upload.d.ts +28 -0
- package/dist/declarations/src/@hooks/use-import-container-upload.d.ts +19 -0
- package/dist/declarations/src/@types/api.d.ts +13 -0
- package/dist/declarations/src/@types/basic-error-data-type.d.ts +5 -0
- package/dist/declarations/src/@types/export-operation.d.ts +97 -0
- package/dist/declarations/src/@types/file-import-job.d.ts +99 -0
- package/dist/declarations/src/@types/file-upload-result.d.ts +21 -0
- package/dist/declarations/src/@types/file-upload.d.ts +63 -0
- package/dist/declarations/src/@types/import-container.d.ts +53 -0
- package/dist/declarations/src/@types/import-operation.d.ts +13 -0
- package/dist/declarations/src/@types/import-states.d.ts +9 -0
- package/dist/declarations/src/@types/import-summary.d.ts +15 -0
- package/dist/declarations/src/@types/index.d.ts +11 -0
- package/dist/declarations/src/@types/shared.d.ts +7 -0
- package/dist/declarations/src/@utils/error-mapping.d.ts +19 -0
- package/dist/declarations/src/@utils/file-import-job-helpers.d.ts +12 -0
- package/dist/declarations/src/@utils/file-upload.d.ts +54 -0
- package/dist/declarations/src/@utils/form.d.ts +1 -0
- package/dist/declarations/src/@utils/format.d.ts +5 -0
- package/dist/declarations/src/@utils/import-container.d.ts +8 -0
- package/dist/declarations/src/@utils/index.d.ts +8 -0
- package/dist/declarations/src/@utils/poll-job-until-validated.d.ts +11 -0
- package/dist/declarations/src/@utils/url.d.ts +6 -0
- package/dist/declarations/src/index.d.ts +26 -0
- package/index.js +1 -0
- package/jest.test.config.js +11 -0
- package/package.json +62 -0
- package/src/@api/export-operations.ts +26 -0
- package/src/@api/fetcher.spec.ts +51 -0
- package/src/@api/fetcher.ts +137 -0
- package/src/@api/file-import-jobs.ts +217 -0
- package/src/@api/file-upload.spec.ts +85 -0
- package/src/@api/file-upload.ts +46 -0
- package/src/@api/import-containers.ts +256 -0
- package/src/@api/import-operations.ts +33 -0
- package/src/@api/index.ts +9 -0
- package/src/@api/process-file.spec.ts +74 -0
- package/src/@api/process-file.ts +53 -0
- package/src/@api/test-fixtures.ts +894 -0
- package/src/@api/urls.ts +194 -0
- package/src/@components/file-drop-area/active-drag-drop-area.tsx +33 -0
- package/src/@components/file-drop-area/disabled-drop-area.tsx +17 -0
- package/src/@components/file-drop-area/drop-area-wrapper.tsx +38 -0
- package/src/@components/file-drop-area/enabled-drop-area.tsx +27 -0
- package/src/@components/file-drop-area/file-drop-area.tsx +74 -0
- package/src/@components/file-drop-area/file-dropped-area.tsx +29 -0
- package/src/@components/file-drop-area/index.ts +7 -0
- package/src/@components/file-drop-area/styles.ts +67 -0
- package/src/@components/icons/file-icon.tsx +30 -0
- package/src/@components/icons/index.ts +2 -0
- package/src/@components/icons/lock-icon.tsx +34 -0
- package/src/@components/index.ts +6 -0
- package/src/@components/info-box/index.ts +1 -0
- package/src/@components/info-box/info-box.tsx +23 -0
- package/src/@components/upload-separator/index.ts +1 -0
- package/src/@components/upload-separator/upload-separator.tsx +61 -0
- package/src/@components/upload-settings/index.ts +1 -0
- package/src/@components/upload-settings/upload-settings.tsx +36 -0
- package/src/@components/uploading-modal/index.ts +1 -0
- package/src/@components/uploading-modal/uploading-modal.tsx +66 -0
- package/src/@constants/delimiters.ts +14 -0
- package/src/@constants/file-import-job.ts +1 -0
- package/src/@constants/import-limits.ts +13 -0
- package/src/@constants/import-tags.ts +9 -0
- package/src/@constants/index.ts +5 -0
- package/src/@constants/resource-links.ts +61 -0
- package/src/@errors/http-error.ts +17 -0
- package/src/@errors/index.ts +9 -0
- package/src/@errors/invalid-response-error.ts +6 -0
- package/src/@errors/no-resources-to-export-error.ts +6 -0
- package/src/@errors/polling-aborted-error.ts +6 -0
- package/src/@errors/project-key-not-available-error.ts +6 -0
- package/src/@errors/query-predicate-error.ts +10 -0
- package/src/@errors/unexpected-column-error.ts +6 -0
- package/src/@errors/unexpected-operation-state-error.ts +8 -0
- package/src/@errors/unexpected-resource-type-error.ts +6 -0
- package/src/@hooks/index.ts +8 -0
- package/src/@hooks/use-fetch-export-operations.ts +34 -0
- package/src/@hooks/use-fetch-file-import-job.spec.ts +131 -0
- package/src/@hooks/use-fetch-file-import-job.ts +38 -0
- package/src/@hooks/use-fetch-import-container-details.ts +31 -0
- package/src/@hooks/use-fetch-import-operations.ts +42 -0
- package/src/@hooks/use-fetch-import-summaries.ts +47 -0
- package/src/@hooks/use-fetch.spec.ts +68 -0
- package/src/@hooks/use-fetch.ts +76 -0
- package/src/@hooks/use-file-import-job-upload.spec.ts +273 -0
- package/src/@hooks/use-file-import-job-upload.ts +101 -0
- package/src/@hooks/use-file-upload.ts +223 -0
- package/src/@hooks/use-import-container-upload.spec.ts +297 -0
- package/src/@hooks/use-import-container-upload.ts +130 -0
- package/src/@types/api.ts +14 -0
- package/src/@types/basic-error-data-type.ts +5 -0
- package/src/@types/export-operation.ts +147 -0
- package/src/@types/file-import-job.ts +165 -0
- package/src/@types/file-upload-result.ts +23 -0
- package/src/@types/file-upload.ts +81 -0
- package/src/@types/import-container.ts +104 -0
- package/src/@types/import-operation.ts +31 -0
- package/src/@types/import-states.ts +9 -0
- package/src/@types/import-summary.ts +22 -0
- package/src/@types/index.ts +11 -0
- package/src/@types/shared.ts +52 -0
- package/src/@utils/error-mapping.spec.ts +126 -0
- package/src/@utils/error-mapping.ts +40 -0
- package/src/@utils/file-import-job-helpers.spec.ts +147 -0
- package/src/@utils/file-import-job-helpers.ts +47 -0
- package/src/@utils/file-upload.spec.ts +151 -0
- package/src/@utils/file-upload.ts +189 -0
- package/src/@utils/form.ts +20 -0
- package/src/@utils/format.spec.ts +62 -0
- package/src/@utils/format.ts +53 -0
- package/src/@utils/import-container.spec.ts +26 -0
- package/src/@utils/import-container.ts +34 -0
- package/src/@utils/index.ts +8 -0
- package/src/@utils/poll-job-until-validated.ts +76 -0
- package/src/@utils/url.spec.ts +75 -0
- package/src/@utils/url.ts +18 -0
- package/src/index.ts +27 -0
- 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
|
+
}
|