@haus-tech/product-import-export-plugin 3.0.29-0 → 3.0.29

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.
@@ -1,337 +1,25 @@
1
- import {
2
- Button,
3
- Dialog,
4
- DialogContent,
5
- DialogDescription,
6
- DialogFooter,
7
- DialogHeader,
8
- DialogTitle,
9
- Input,
10
- Label,
11
- RadioGroup,
12
- RadioGroupItem,
13
- DataTableBulkActionItem,
14
- } from '@vendure/dashboard'
15
- import { DownloadIcon } from 'lucide-react'
16
- import { useState, useEffect } from 'react'
17
- import { toast } from 'sonner'
18
- import {
19
- getServerLocation,
20
- getChannelHeader,
21
- type PluginInitOptions,
22
- type ExportFields,
23
- type ProductFields,
24
- type VariantFields,
25
- } from './utils'
1
+ import { Button } from '@vendure/dashboard'
2
+ import { useState } from 'react'
3
+ import { ExportDialog } from './export-dialog'
26
4
 
27
- // Export Dialog Component
28
- function ExportDialog({
29
- open,
30
- onOpenChange,
31
- productIds,
32
- }: {
33
- open: boolean
34
- onOpenChange: (open: boolean) => void
35
- productIds: string[]
36
- }) {
37
- const [fileName, setFileName] = useState('')
38
- const [customFields, setCustomFields] = useState<string[]>([])
39
- const [selectedFields, setSelectedFields] = useState<string[]>([])
40
- const [exportAssetsAs, setExportAssetsAs] = useState<'url' | 'json'>('url')
41
- const [selectedExportFields, setSelectedExportFields] = useState<ExportFields>([])
42
- const [availableExportFields] = useState<ExportFields>([
43
- 'productId',
44
- 'name',
45
- 'slug',
46
- 'description',
47
- 'assets',
48
- 'facets',
49
- 'optionGroups',
50
- 'sku',
51
- 'optionValues',
52
- 'price',
53
- 'taxCategory',
54
- 'stockOnHand',
55
- 'trackInventory',
56
- 'variantAssets',
57
- 'variantFacets',
58
- 'enabled',
59
- ])
60
- const [config, setConfig] = useState<PluginInitOptions['exportOptions'] | null>(null)
61
- const [toggleAllChecked, setToggleAllChecked] = useState(false)
62
-
63
- useEffect(() => {
64
- const fetchConfig = async () => {
65
- try {
66
- const serverPath = getServerLocation()
67
- const [configRes, customFieldsRes] = await Promise.all([
68
- fetch(`${serverPath}/product-import-export/config`, {
69
- method: 'GET',
70
- headers: {
71
- 'Content-Type': 'application/json',
72
- ...getChannelHeader(),
73
- },
74
- credentials: 'include',
75
- }),
76
- fetch(`${serverPath}/product-export/custom-fields`, {
77
- method: 'POST',
78
- headers: {
79
- 'Content-Type': 'application/json',
80
- ...getChannelHeader(),
81
- },
82
- credentials: 'include',
83
- body: JSON.stringify(productIds),
84
- }),
85
- ])
86
-
87
- const configData = await configRes.json()
88
- const customFieldsData = await customFieldsRes.json()
89
- setConfig(configData.exportOptions)
90
- setCustomFields(customFieldsData.map((field: { name: string }) => field.name))
91
- setSelectedFields([...customFieldsData.map((field: { name: string }) => field.name)])
92
- setExportAssetsAs(
93
- configData.exportOptions?.defaultExportAssetsAs ||
94
- configData.exportOptions?.exportAssetsAsOptions?.[0] ||
95
- 'url',
96
- )
97
- if (
98
- configData.exportOptions?.defaultExportFields ||
99
- configData.exportOptions?.requiredExportFields
100
- ) {
101
- setSelectedExportFields([
102
- ...(configData.exportOptions.defaultExportFields || []),
103
- ...(configData.exportOptions.requiredExportFields || []),
104
- ])
105
- }
106
- } catch (error) {
107
- console.error('Failed to fetch config:', error)
108
- }
109
- }
110
-
111
- if (open && productIds.length > 0) {
112
- fetchConfig()
113
- }
114
- }, [open, productIds])
115
-
116
- const toggleFieldSelection = (fieldName: string) => {
117
- setSelectedFields((prev) =>
118
- prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
119
- )
120
- }
121
-
122
- const toggleExportFieldSelection = (fieldName: ProductFields | VariantFields) => {
123
- setSelectedExportFields((prev) =>
124
- prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
125
- )
126
- }
127
-
128
- const toggleSelectAll = (checked: boolean) => {
129
- setToggleAllChecked(checked)
130
- if (checked) {
131
- setSelectedExportFields([...availableExportFields])
132
- setSelectedFields([...customFields])
133
- } else {
134
- setSelectedExportFields([...(config?.requiredExportFields || [])])
135
- setSelectedFields([])
136
- }
137
- }
138
-
139
- const handleExport = async () => {
140
- const trimmedFileName = fileName.trim()
141
- const finalFileName = trimmedFileName || config?.defaultFileName || 'products_export.csv'
142
-
143
- toast.info(`Exporting ${productIds.length} products to ${finalFileName}`)
144
-
145
- try {
146
- const serverPath = getServerLocation()
147
- const res = await fetch(
148
- `${serverPath}/product-export/export?fileName=${encodeURIComponent(finalFileName)}&customFields=${encodeURIComponent(selectedFields.join(','))}&exportAssetsAs=${exportAssetsAs}&selectedExportFields=${encodeURIComponent(selectedExportFields.join(','))}`,
149
- {
150
- method: 'POST',
151
- headers: {
152
- 'Content-Type': 'application/json',
153
- ...getChannelHeader(),
154
- },
155
- credentials: 'include',
156
- body: JSON.stringify(productIds),
157
- },
158
- )
159
-
160
- if (!res.ok) {
161
- const json = await res.json()
162
- throw new Error(json?.message || 'Export failed')
163
- }
164
-
165
- console.log('res', res)
166
- const header = res.headers.get('Content-Disposition')
167
-
168
- const parts = header!.split(';')
169
- const filename = parts[1].split('=')[1]
170
- const blob = await res.blob()
171
-
172
- const blobUrl = window.URL.createObjectURL(blob)
173
- const a = document.createElement('a')
174
- document.body.appendChild(a)
175
- a.setAttribute('hidden', 'true')
176
- a.href = blobUrl
177
- a.download = filename
178
- a.setAttribute('target', '_blank')
179
- a.click()
180
-
181
- onOpenChange(false)
182
- toast.success('Products exported successfully')
183
- } catch (error: any) {
184
- console.error(error)
185
- toast.error(error.message || 'Failed to export products')
186
- }
187
- }
188
-
189
- return (
190
- <Dialog open={open} onOpenChange={onOpenChange}>
191
- <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
192
- <DialogHeader>
193
- <DialogTitle>Export products to CSV</DialogTitle>
194
- <DialogDescription>Configure export options</DialogDescription>
195
- </DialogHeader>
196
-
197
- <div className="space-y-4">
198
- <div>
199
- <Label htmlFor="file-name">File name (optional)</Label>
200
- <Input
201
- id="file-name"
202
- value={fileName}
203
- onChange={(e) => setFileName(e.target.value)}
204
- placeholder={config?.defaultFileName || 'products_export.csv'}
205
- className="mt-1"
206
- />
207
- </div>
208
-
209
- <div className="flex items-center space-x-2">
210
- <input
211
- type="checkbox"
212
- id="select-all"
213
- checked={toggleAllChecked}
214
- onChange={(e) => toggleSelectAll(e.target.checked)}
215
- />
216
- <Label htmlFor="select-all" className="font-normal">
217
- Select all
218
- </Label>
219
- </div>
220
-
221
- <div>
222
- <Label>Fields to export</Label>
223
- <div className="grid grid-cols-2 gap-2 mt-2">
224
- {availableExportFields.map((field) => (
225
- <div key={field} className="flex items-center space-x-2">
226
- <input
227
- type="checkbox"
228
- id={`field-${field}`}
229
- checked={selectedExportFields.includes(field)}
230
- onChange={() => toggleExportFieldSelection(field)}
231
- disabled={config?.requiredExportFields?.includes(field)}
232
- />
233
- <Label htmlFor={`field-${field}`} className="font-normal">
234
- {field}
235
- </Label>
236
- </div>
237
- ))}
238
- </div>
239
- </div>
240
-
241
- {customFields.length > 0 && (
242
- <div>
243
- <Label>Custom fields to include in the export</Label>
244
- <div className="grid grid-cols-2 gap-2 mt-2">
245
- {customFields.map((field) => (
246
- <div key={field} className="flex items-center space-x-2">
247
- <input
248
- type="checkbox"
249
- id={`custom-${field}`}
250
- checked={selectedFields.includes(field)}
251
- onChange={() => toggleFieldSelection(field)}
252
- />
253
- <Label htmlFor={`custom-${field}`} className="font-normal">
254
- {field}
255
- </Label>
256
- </div>
257
- ))}
258
- </div>
259
- </div>
260
- )}
261
-
262
- <div>
263
- <Label>Export assets as:</Label>
264
- <RadioGroup
265
- value={exportAssetsAs}
266
- onValueChange={(val) => setExportAssetsAs(val as 'url' | 'json')}
267
- >
268
- {config?.exportAssetsAsOptions?.includes('url') && (
269
- <div className="flex items-center space-x-2">
270
- <RadioGroupItem value="url" id="assets-url" />
271
- <Label htmlFor="assets-url" className="font-normal">
272
- URL
273
- </Label>
274
- </div>
275
- )}
276
- {config?.exportAssetsAsOptions?.includes('json') && (
277
- <div className="flex items-center space-x-2">
278
- <RadioGroupItem value="json" id="assets-json" />
279
- <Label htmlFor="assets-json" className="font-normal">
280
- JSON
281
- </Label>
282
- </div>
283
- )}
284
- </RadioGroup>
285
- </div>
286
- </div>
287
-
288
- <DialogFooter>
289
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
290
- Cancel
291
- </Button>
292
- <Button type="button" onClick={handleExport}>
293
- Export
294
- </Button>
295
- </DialogFooter>
296
- </DialogContent>
297
- </Dialog>
298
- )
299
- }
300
-
301
- // Export Bulk Action Component for Product List
302
- export function ExportProductsBulkAction({
303
- selection,
304
- table,
305
- }: {
306
- selection: Array<{ id: string }>
307
- table: any
308
- }) {
5
+ export function ProductExportBlock() {
309
6
  const [dialogOpen, setDialogOpen] = useState(false)
310
- const productIds = selection.map((product) => product.id)
311
7
 
312
- const handleClose = () => {
313
- setDialogOpen(false)
314
- table.resetRowSelection()
8
+ const handleExport = () => {
9
+ setDialogOpen(true)
315
10
  }
316
11
 
317
12
  return (
318
- <>
319
- <DataTableBulkActionItem
320
- onClick={() => setDialogOpen(true)}
321
- label="Export products to CSV"
322
- icon={DownloadIcon}
323
- />
13
+ <div className="space-y-4">
14
+ <div>
15
+ <Button onClick={handleExport}>Export all products</Button>
16
+ </div>
324
17
  <ExportDialog
325
18
  open={dialogOpen}
326
- onOpenChange={(open) => {
327
- if (open) {
328
- setDialogOpen(true)
329
- } else {
330
- handleClose()
331
- }
332
- }}
333
- productIds={productIds}
19
+ onOpenChange={setDialogOpen}
20
+ productIds={[]}
21
+ isExportAll={true}
334
22
  />
335
- </>
23
+ </div>
336
24
  )
337
25
  }
@@ -0,0 +1,234 @@
1
+ import {
2
+ Button,
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from '@vendure/dashboard'
10
+ import { getServerLocation, getChannelHeader } from './utils'
11
+ import { useState, useEffect, useMemo } from 'react'
12
+ import { DownloadIcon, ChevronLeftIcon, ChevronRightIcon, TrashIcon } from 'lucide-react'
13
+ import { toast } from 'sonner'
14
+
15
+ interface ExportedFile {
16
+ fileName: string
17
+ size: number
18
+ created: string
19
+ }
20
+
21
+ export function ExportedList() {
22
+ const [exportedFiles, setExportedFiles] = useState<ExportedFile[]>([])
23
+ const [error, setError] = useState<string | null>(null)
24
+ const [currentPage, setCurrentPage] = useState(1)
25
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
26
+ const [fileToDelete, setFileToDelete] = useState<string | null>(null)
27
+ const itemsPerPage = 10
28
+
29
+ const getExportFiles = async () => {
30
+ const serverPath = getServerLocation()
31
+ try {
32
+ const res = await fetch(`${serverPath}/product-export/exported-files`, {
33
+ method: 'GET',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ ...getChannelHeader(),
37
+ },
38
+ credentials: 'include',
39
+ })
40
+ if (!res.ok) {
41
+ setError('Could not get exported files, please try again later')
42
+ return
43
+ }
44
+ const data = await res.json()
45
+ setExportedFiles(data)
46
+ setError(null)
47
+ } catch (error) {
48
+ setError('Could not get exported files, please try again later')
49
+ console.error('Error getting exported files', error)
50
+ }
51
+ }
52
+
53
+ useEffect(() => {
54
+ getExportFiles()
55
+ }, [])
56
+
57
+ // Pagination calculations
58
+ const paginatedFiles = useMemo(() => {
59
+ const startIndex = (currentPage - 1) * itemsPerPage
60
+ const endIndex = startIndex + itemsPerPage
61
+ return exportedFiles.slice(startIndex, endIndex)
62
+ }, [exportedFiles, currentPage])
63
+
64
+ const totalPages = Math.ceil(exportedFiles.length / itemsPerPage)
65
+
66
+ const downLoadFile = async (fileName: string) => {
67
+ const serverPath = getServerLocation()
68
+ const res = await fetch(`${serverPath}/product-export/download/${fileName}`, {
69
+ method: 'GET',
70
+ headers: {
71
+ ...getChannelHeader(),
72
+ },
73
+ credentials: 'include',
74
+ })
75
+
76
+ const blob = await res.blob()
77
+ const url = URL.createObjectURL(blob)
78
+ const a = document.createElement('a')
79
+ a.href = url
80
+ a.download = fileName
81
+ document.body.appendChild(a)
82
+ a.click()
83
+ document.body.removeChild(a)
84
+ URL.revokeObjectURL(url)
85
+ }
86
+
87
+ const handleDeleteClick = (fileName: string) => {
88
+ setFileToDelete(fileName)
89
+ setDeleteDialogOpen(true)
90
+ }
91
+
92
+ const deleteFile = async () => {
93
+ if (!fileToDelete) return
94
+
95
+ const serverPath = getServerLocation()
96
+ try {
97
+ const res = await fetch(`${serverPath}/product-export/delete/${fileToDelete}`, {
98
+ method: 'DELETE',
99
+ headers: {
100
+ 'Content-Type': 'application/json',
101
+ ...getChannelHeader(),
102
+ },
103
+ credentials: 'include',
104
+ })
105
+
106
+ if (!res.ok) {
107
+ const json = await res.json()
108
+ throw new Error(json?.message || 'Failed to delete file')
109
+ }
110
+
111
+ toast.success('File deleted successfully')
112
+ setDeleteDialogOpen(false)
113
+ setFileToDelete(null)
114
+ await getExportFiles()
115
+ } catch (error: any) {
116
+ console.error('Delete error:', error)
117
+ toast.error(error.message || 'Failed to delete file')
118
+ }
119
+ }
120
+
121
+ const formatFileSize = (bytes: number): string => {
122
+ if (bytes < 1024) return bytes + ' B'
123
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
124
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
125
+ }
126
+
127
+ if (error) {
128
+ return (
129
+ <div className="text-center text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed">
130
+ <p className="text-red-500">{error}</p>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ return (
136
+ <div>
137
+ {exportedFiles.length > 0 && (
138
+ <div className="space-y-2">
139
+ <div className="border rounded-md divide-y">
140
+ {paginatedFiles.map((file) => (
141
+ <div key={file.fileName} className="p-4 hover:bg-muted/50 transition-colors">
142
+ <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
143
+ <div className="flex-1">
144
+ <p className="font-medium">{file.fileName}</p>
145
+ <div className="flex flex-col sm:flex-row gap-2 sm:gap-4 text-sm text-muted-foreground mt-1">
146
+ <span>Size: {formatFileSize(file.size)}</span>
147
+ <span>
148
+ Created:{' '}
149
+ {new Date(file.created).toLocaleString(navigator.language || undefined)}
150
+ </span>
151
+ </div>
152
+ </div>
153
+ <div className="flex items-center gap-2">
154
+ <Button variant="outline" size="sm" onClick={() => downLoadFile(file.fileName)}>
155
+ <DownloadIcon className="mr-2 h-4 w-4" />
156
+ Download
157
+ </Button>
158
+ <Button
159
+ variant="outline"
160
+ size="sm"
161
+ onClick={() => handleDeleteClick(file.fileName)}
162
+ className="text-destructive hover:text-destructive"
163
+ >
164
+ <TrashIcon className="h-4 w-4" /> Delete
165
+ </Button>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ ))}
170
+ </div>
171
+
172
+ {/* Pagination controls */}
173
+ {totalPages > 1 && (
174
+ <div className="flex items-center justify-between py-2 border-t">
175
+ <div className="text-sm text-muted-foreground">
176
+ Showing {(currentPage - 1) * itemsPerPage + 1} to{' '}
177
+ {Math.min(currentPage * itemsPerPage, exportedFiles.length)} of{' '}
178
+ {exportedFiles.length}
179
+ </div>
180
+ <div className="flex items-center gap-1">
181
+ <Button
182
+ variant="outline"
183
+ size="sm"
184
+ onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
185
+ disabled={currentPage === 1}
186
+ >
187
+ <ChevronLeftIcon className="h-4 w-4" />
188
+ </Button>
189
+ <div className="text-sm text-muted-foreground px-2">
190
+ {currentPage} / {totalPages}
191
+ </div>
192
+ <Button
193
+ variant="outline"
194
+ size="sm"
195
+ onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
196
+ disabled={currentPage === totalPages}
197
+ >
198
+ <ChevronRightIcon className="h-4 w-4" />
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ )}
203
+ </div>
204
+ )}
205
+
206
+ {exportedFiles.length === 0 && (
207
+ <div className="text-center py-8 text-muted-foreground text-sm">
208
+ <p>You have no exported files</p>
209
+ </div>
210
+ )}
211
+
212
+ <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
213
+ <DialogContent>
214
+ <DialogHeader>
215
+ <DialogTitle>Delete {fileToDelete}?</DialogTitle>
216
+ <DialogDescription>
217
+ Are you sure you want to delete this file? This action cannot be undone.
218
+ </DialogDescription>
219
+ </DialogHeader>
220
+ <DialogFooter>
221
+ <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
222
+ Cancel
223
+ </Button>
224
+ <Button variant="destructive" onClick={deleteFile}>
225
+ Delete
226
+ </Button>
227
+ </DialogFooter>
228
+ </DialogContent>
229
+ </Dialog>
230
+ </div>
231
+ )
232
+ }
233
+
234
+ export default ExportedList