@commercetools-frontend-extensions/operations 2.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +138 -71
  3. package/dist/commercetools-frontend-extensions-operations.cjs.dev.js +979 -143
  4. package/dist/commercetools-frontend-extensions-operations.cjs.prod.js +979 -143
  5. package/dist/commercetools-frontend-extensions-operations.esm.js +929 -140
  6. package/dist/declarations/src/@api/file-import-jobs.d.ts +7 -0
  7. package/dist/declarations/src/@api/index.d.ts +1 -0
  8. package/dist/declarations/src/@api/test-fixtures.d.ts +8 -1
  9. package/dist/declarations/src/@api/urls.d.ts +30 -0
  10. package/dist/declarations/src/@components/uploading-modal/uploading-modal.d.ts +3 -2
  11. package/dist/declarations/src/@constants/file-import-job.d.ts +1 -0
  12. package/dist/declarations/src/@constants/import-limits.d.ts +6 -0
  13. package/dist/declarations/src/@constants/index.d.ts +2 -1
  14. package/dist/declarations/src/@errors/index.d.ts +5 -4
  15. package/dist/declarations/src/@errors/polling-aborted-error.d.ts +3 -0
  16. package/dist/declarations/src/@hooks/index.d.ts +4 -0
  17. package/dist/declarations/src/@hooks/use-fetch-file-import-job-records.d.ts +18 -0
  18. package/dist/declarations/src/@hooks/use-fetch-file-import-job.d.ts +17 -0
  19. package/dist/declarations/src/@hooks/use-file-import-job-upload.d.ts +18 -0
  20. package/dist/declarations/src/@hooks/use-file-upload.d.ts +29 -0
  21. package/dist/declarations/src/@hooks/use-import-container-upload.d.ts +2 -1
  22. package/dist/declarations/src/@types/export-operation.d.ts +3 -1
  23. package/dist/declarations/src/@types/file-import-job.d.ts +99 -0
  24. package/dist/declarations/src/@types/file-upload-result.d.ts +21 -0
  25. package/dist/declarations/src/@types/file-upload.d.ts +2 -2
  26. package/dist/declarations/src/@types/index.d.ts +2 -0
  27. package/dist/declarations/src/@utils/file-import-job-helpers.d.ts +12 -0
  28. package/dist/declarations/src/@utils/file-upload.d.ts +8 -0
  29. package/dist/declarations/src/@utils/index.d.ts +2 -0
  30. package/dist/declarations/src/@utils/poll-job-until-validated.d.ts +11 -0
  31. package/package.json +12 -13
  32. package/src/@api/fetcher.ts +10 -0
  33. package/src/@api/file-import-jobs.ts +217 -0
  34. package/src/@api/file-upload.spec.ts +4 -2
  35. package/src/@api/index.ts +1 -0
  36. package/src/@api/test-fixtures.ts +127 -5
  37. package/src/@api/urls.ts +77 -1
  38. package/src/@components/uploading-modal/uploading-modal.tsx +7 -5
  39. package/src/@constants/file-import-job.ts +1 -0
  40. package/src/@constants/import-limits.ts +13 -0
  41. package/src/@constants/index.ts +2 -1
  42. package/src/@errors/index.ts +5 -4
  43. package/src/@errors/polling-aborted-error.ts +6 -0
  44. package/src/@hooks/index.ts +4 -0
  45. package/src/@hooks/use-fetch-file-import-job-records.ts +58 -0
  46. package/src/@hooks/use-fetch-file-import-job.spec.ts +131 -0
  47. package/src/@hooks/use-fetch-file-import-job.ts +38 -0
  48. package/src/@hooks/use-fetch.spec.ts +1 -9
  49. package/src/@hooks/use-fetch.ts +4 -8
  50. package/src/@hooks/use-file-import-job-upload.spec.ts +273 -0
  51. package/src/@hooks/use-file-import-job-upload.ts +101 -0
  52. package/src/@hooks/use-file-upload.ts +231 -0
  53. package/src/@hooks/use-import-container-upload.spec.ts +16 -13
  54. package/src/@hooks/use-import-container-upload.ts +6 -2
  55. package/src/@types/export-operation.ts +3 -0
  56. package/src/@types/file-import-job.ts +165 -0
  57. package/src/@types/file-upload-result.ts +23 -0
  58. package/src/@types/file-upload.ts +2 -2
  59. package/src/@types/index.ts +2 -0
  60. package/src/@utils/error-mapping.ts +10 -9
  61. package/src/@utils/file-import-job-helpers.spec.ts +147 -0
  62. package/src/@utils/file-import-job-helpers.ts +47 -0
  63. package/src/@utils/file-upload.ts +39 -0
  64. package/src/@utils/index.ts +2 -0
  65. package/src/@utils/poll-job-until-validated.ts +76 -0
  66. package/dist/declarations/src/@constants/upload-limits.d.ts +0 -10
  67. package/src/@constants/upload-limits.ts +0 -11
  68. package/src/@hooks/messages.ts +0 -11
@@ -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
+ }
@@ -131,6 +131,45 @@ export const countJsonFileItems = async (
131
131
  }
132
132
  }
133
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
+
134
173
  /**
135
174
  * Map file upload errors to upload file error rows with unique IDs
136
175
  * @param uploadFileErrors Array of file upload errors
@@ -1,6 +1,8 @@
1
1
  export * from './error-mapping'
2
+ export * from './file-import-job-helpers'
2
3
  export * from './file-upload'
3
4
  export * from './form'
4
5
  export * from './format'
5
6
  export * from './import-container'
7
+ export * from './poll-job-until-validated'
6
8
  export * from './url'
@@ -0,0 +1,76 @@
1
+ import { getFileImportJob } from '../@api'
2
+ import { PollingAbortedError } from '../@errors'
3
+ import type { FileImportJob } from '../@types'
4
+ import { isImportJobTerminal } from './file-import-job-helpers'
5
+
6
+ export type PollJobUntilValidatedConfig = {
7
+ projectKey: string
8
+ jobId: string
9
+ importContainerKey: string
10
+ pollingInterval?: number
11
+ maxAttempts?: number
12
+ onJobUpdate?: (job: FileImportJob) => void
13
+ abortSignal?: AbortSignal
14
+ }
15
+
16
+ export const pollJobUntilValidated = async ({
17
+ projectKey,
18
+ jobId,
19
+ importContainerKey,
20
+ pollingInterval = 5000,
21
+ maxAttempts = 120,
22
+ onJobUpdate,
23
+ abortSignal,
24
+ }: PollJobUntilValidatedConfig): Promise<FileImportJob> => {
25
+ let attempts = 0
26
+
27
+ while (attempts < maxAttempts) {
28
+ if (abortSignal?.aborted) {
29
+ throw new PollingAbortedError()
30
+ }
31
+
32
+ const job = await getFileImportJob({
33
+ projectKey,
34
+ importContainerKey,
35
+ jobId,
36
+ })
37
+
38
+ if (abortSignal?.aborted) {
39
+ throw new PollingAbortedError()
40
+ }
41
+
42
+ onJobUpdate?.(job)
43
+
44
+ if (isImportJobTerminal(job)) {
45
+ return job
46
+ }
47
+
48
+ await new Promise<void>((resolve, reject) => {
49
+ let timeoutId: ReturnType<typeof setTimeout>
50
+
51
+ const onAbort = () => {
52
+ clearTimeout(timeoutId)
53
+ reject(new PollingAbortedError())
54
+ }
55
+
56
+ if (abortSignal?.aborted) {
57
+ reject(new PollingAbortedError())
58
+ return
59
+ }
60
+
61
+ timeoutId = setTimeout(() => {
62
+ abortSignal?.removeEventListener('abort', onAbort)
63
+ resolve()
64
+ }, pollingInterval)
65
+
66
+ abortSignal?.addEventListener('abort', onAbort)
67
+ })
68
+ attempts++
69
+ }
70
+
71
+ throw new Error(
72
+ `Job validation timeout after ${maxAttempts} attempts (${
73
+ (maxAttempts * pollingInterval) / 1000
74
+ }s)`
75
+ )
76
+ }
@@ -1,10 +0,0 @@
1
- /**
2
- * Maximum file size for imports.
3
- * Recommended by backend, enforced in frontend validation.
4
- */
5
- export declare const MAX_FILE_SIZE_MB = 35;
6
- /**
7
- * Maximum row count for imports.
8
- * Recommended by backend, enforced in frontend validation.
9
- */
10
- export declare const MAX_ROW_COUNT = 80000;
@@ -1,11 +0,0 @@
1
- /**
2
- * Maximum file size for imports.
3
- * Recommended by backend, enforced in frontend validation.
4
- */
5
- export const MAX_FILE_SIZE_MB = 35
6
-
7
- /**
8
- * Maximum row count for imports.
9
- * Recommended by backend, enforced in frontend validation.
10
- */
11
- export const MAX_ROW_COUNT = 80_000
@@ -1,11 +0,0 @@
1
- import { defineMessages } from 'react-intl'
2
-
3
- export default defineMessages({
4
- unexpectedError: {
5
- id: 'operations.fetch.unexpectedError',
6
- description:
7
- 'Generic error message displayed when an unexpected error occurs during data fetching',
8
- defaultMessage:
9
- 'An unexpected error occurred while fetching the data. Please try again. If the problem persists, please contact support.',
10
- },
11
- })