@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,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
|
+
}
|