@spree/dashboard-core 0.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.
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Attachment,
|
|
3
|
+
AttachmentAction,
|
|
4
|
+
AttachmentActions,
|
|
5
|
+
AttachmentContent,
|
|
6
|
+
AttachmentDescription,
|
|
7
|
+
AttachmentMedia,
|
|
8
|
+
AttachmentTitle,
|
|
9
|
+
cn,
|
|
10
|
+
Field,
|
|
11
|
+
FieldDescription,
|
|
12
|
+
FieldLabel,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import { FileIcon, UploadCloudIcon, XIcon } from 'lucide-react'
|
|
15
|
+
import { type ReactNode, useState } from 'react'
|
|
16
|
+
import { useTranslation } from 'react-i18next'
|
|
17
|
+
import { toast } from 'sonner'
|
|
18
|
+
import { useDirectUpload } from '../hooks/use-direct-upload'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Controlled upload state. A field is in exactly one of three states:
|
|
22
|
+
* - untouched: `signedId == null && !cleared` → caller omits it on save
|
|
23
|
+
* - uploaded: `signedId` set → caller sends the signed_id
|
|
24
|
+
* - cleared: `cleared == true` → caller sends null to purge
|
|
25
|
+
*
|
|
26
|
+
* `previewUrl` is a transient object URL for the just-picked file (image
|
|
27
|
+
* uploads); `filename`/`byteSize` describe the picked file for display.
|
|
28
|
+
*/
|
|
29
|
+
export interface FileUploadValue {
|
|
30
|
+
signedId: string | null
|
|
31
|
+
previewUrl: string | null
|
|
32
|
+
cleared: boolean
|
|
33
|
+
filename?: string | null
|
|
34
|
+
byteSize?: number | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const EMPTY_FILE_UPLOAD_VALUE: FileUploadValue = {
|
|
38
|
+
signedId: null,
|
|
39
|
+
previewUrl: null,
|
|
40
|
+
cleared: false,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatFileSize(bytes: number): string {
|
|
44
|
+
if (bytes < 1024) return `${bytes} B`
|
|
45
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
46
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FileUploadFieldProps {
|
|
50
|
+
/** Current upload state. */
|
|
51
|
+
value: FileUploadValue
|
|
52
|
+
onChange: (value: FileUploadValue) => void
|
|
53
|
+
/** Accepted file types (input `accept` attribute). */
|
|
54
|
+
accept: string
|
|
55
|
+
/** `image` renders a thumbnail preview; `file` renders a type icon. */
|
|
56
|
+
variant?: 'image' | 'file'
|
|
57
|
+
/** Icon shown in the attachment media for the `file` variant. */
|
|
58
|
+
icon?: ReactNode
|
|
59
|
+
/** URL of the already-persisted attachment, shown until replaced/removed. */
|
|
60
|
+
serverUrl?: string | null
|
|
61
|
+
/** Display name for the persisted attachment (file variant). */
|
|
62
|
+
serverFilename?: string | null
|
|
63
|
+
label?: string
|
|
64
|
+
/** Helper text under the dropzone. */
|
|
65
|
+
help?: string
|
|
66
|
+
/** Copy inside the dropzone. Defaults to the generic "drop a file" string. */
|
|
67
|
+
dropLabel?: string
|
|
68
|
+
/** Copy on the browse chip inside the dropzone. */
|
|
69
|
+
browseLabel?: string
|
|
70
|
+
/** Extra classes for the attachment media (e.g. a larger logo preview). */
|
|
71
|
+
mediaClassName?: string
|
|
72
|
+
disabled?: boolean
|
|
73
|
+
/** Re-wrap the picked file before upload (e.g. force a content type). */
|
|
74
|
+
transformFile?: (file: File) => File
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Universal single-file upload field backed by ActiveStorage direct upload.
|
|
79
|
+
* Picking or dropping a file uploads it immediately; the attached file (or
|
|
80
|
+
* the persisted server-side one) renders as an `Attachment` with a remove
|
|
81
|
+
* action. Fully controlled — the consumer maps {@link FileUploadValue} onto
|
|
82
|
+
* its form and decides how to serialize it (send `signed_id`, send `null`
|
|
83
|
+
* to clear, or omit).
|
|
84
|
+
*/
|
|
85
|
+
export function FileUploadField({
|
|
86
|
+
value,
|
|
87
|
+
onChange,
|
|
88
|
+
accept,
|
|
89
|
+
variant = 'file',
|
|
90
|
+
icon,
|
|
91
|
+
serverUrl,
|
|
92
|
+
serverFilename,
|
|
93
|
+
label,
|
|
94
|
+
help,
|
|
95
|
+
dropLabel,
|
|
96
|
+
browseLabel,
|
|
97
|
+
mediaClassName,
|
|
98
|
+
disabled = false,
|
|
99
|
+
transformFile,
|
|
100
|
+
}: FileUploadFieldProps) {
|
|
101
|
+
const { t } = useTranslation()
|
|
102
|
+
const directUpload = useDirectUpload()
|
|
103
|
+
const [pending, setPending] = useState<File | null>(null)
|
|
104
|
+
|
|
105
|
+
const isImage = variant === 'image'
|
|
106
|
+
|
|
107
|
+
// The input's `accept` only hints the OS picker and does nothing for
|
|
108
|
+
// drag-and-drop — enforce it for both paths. Tokens are extensions
|
|
109
|
+
// (".csv"), exact MIME types ("text/csv") or wildcards ("image/*").
|
|
110
|
+
function matchesAccept(file: File): boolean {
|
|
111
|
+
const tokens = accept
|
|
112
|
+
.split(',')
|
|
113
|
+
.map((token) => token.trim().toLowerCase())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
if (tokens.length === 0) return true
|
|
116
|
+
|
|
117
|
+
const name = file.name.toLowerCase()
|
|
118
|
+
const type = file.type.toLowerCase()
|
|
119
|
+
return tokens.some((token) => {
|
|
120
|
+
if (token.startsWith('.')) return name.endsWith(token)
|
|
121
|
+
if (token.endsWith('/*')) return type.startsWith(token.slice(0, -1))
|
|
122
|
+
return type === token
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function handlePicked(picked: File) {
|
|
127
|
+
if (disabled || pending) return
|
|
128
|
+
if (!matchesAccept(picked)) {
|
|
129
|
+
toast.error(t('admin.components.file_upload.invalid_type', { accept }))
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const file = transformFile ? transformFile(picked) : picked
|
|
134
|
+
setPending(file)
|
|
135
|
+
try {
|
|
136
|
+
const result = await directUpload.mutateAsync(file)
|
|
137
|
+
// Revoke a preview we're replacing in-place; the object URL otherwise
|
|
138
|
+
// lives in caller-owned state, so its lifetime is their responsibility
|
|
139
|
+
// (a hidden-then-shown card must not remount with a dead URL).
|
|
140
|
+
if (value.previewUrl) URL.revokeObjectURL(value.previewUrl)
|
|
141
|
+
onChange({
|
|
142
|
+
signedId: result.signedId,
|
|
143
|
+
previewUrl: isImage ? result.previewUrl : null,
|
|
144
|
+
cleared: false,
|
|
145
|
+
filename: file.name,
|
|
146
|
+
byteSize: file.size,
|
|
147
|
+
})
|
|
148
|
+
} catch (err) {
|
|
149
|
+
toast.error(
|
|
150
|
+
t('admin.components.file_upload.upload_failed', {
|
|
151
|
+
message: err instanceof Error ? err.message : String(err),
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
} finally {
|
|
155
|
+
setPending(null)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleRemove() {
|
|
160
|
+
if (value.previewUrl) URL.revokeObjectURL(value.previewUrl)
|
|
161
|
+
onChange({ signedId: null, previewUrl: null, cleared: true, filename: null, byteSize: null })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// What the attachment row shows: the in-flight pick, the uploaded file, or
|
|
165
|
+
// the persisted server-side attachment (until the user clears it).
|
|
166
|
+
const attached = pending
|
|
167
|
+
? { state: 'uploading' as const, name: pending.name, size: pending.size, preview: null }
|
|
168
|
+
: value.signedId
|
|
169
|
+
? {
|
|
170
|
+
state: 'done' as const,
|
|
171
|
+
name: value.filename ?? null,
|
|
172
|
+
size: value.byteSize ?? null,
|
|
173
|
+
preview: value.previewUrl,
|
|
174
|
+
}
|
|
175
|
+
: !value.cleared && (serverUrl || serverFilename)
|
|
176
|
+
? {
|
|
177
|
+
state: 'done' as const,
|
|
178
|
+
name: serverFilename ?? null,
|
|
179
|
+
size: null,
|
|
180
|
+
preview: serverUrl ?? null,
|
|
181
|
+
}
|
|
182
|
+
: null
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Field>
|
|
186
|
+
{label && <FieldLabel>{label}</FieldLabel>}
|
|
187
|
+
<div className="flex flex-col gap-2">
|
|
188
|
+
{/* A <label> wrapping the file input: clicking anywhere opens the
|
|
189
|
+
picker natively. The input is sr-only (not display:none) so it
|
|
190
|
+
stays in the tab order — keyboard users Tab to it and press
|
|
191
|
+
Enter/Space; the label paints a focus ring via has-[:focus-visible]. */}
|
|
192
|
+
<label
|
|
193
|
+
className={cn(
|
|
194
|
+
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-border border-dashed bg-muted/40 px-4 py-8 text-center transition-colors hover:bg-muted',
|
|
195
|
+
'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
|
|
196
|
+
(disabled || !!pending) && 'pointer-events-none opacity-60',
|
|
197
|
+
)}
|
|
198
|
+
onDragOver={(e) => e.preventDefault()}
|
|
199
|
+
onDrop={(e) => {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
const dropped = e.dataTransfer.files?.[0]
|
|
202
|
+
if (dropped) void handlePicked(dropped)
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<UploadCloudIcon className="size-6 text-muted-foreground" />
|
|
206
|
+
<span className="text-muted-foreground text-sm">
|
|
207
|
+
{dropLabel ?? t('admin.components.file_upload.drop_label')}
|
|
208
|
+
</span>
|
|
209
|
+
<span className="mt-1 inline-flex h-8 items-center rounded-md border border-border bg-background px-3 font-medium text-sm shadow-xs">
|
|
210
|
+
{browseLabel ?? t('admin.components.file_upload.browse')}
|
|
211
|
+
</span>
|
|
212
|
+
<input
|
|
213
|
+
type="file"
|
|
214
|
+
accept={accept}
|
|
215
|
+
className="sr-only"
|
|
216
|
+
disabled={disabled || !!pending}
|
|
217
|
+
onChange={(e) => {
|
|
218
|
+
const picked = e.target.files?.[0]
|
|
219
|
+
if (picked) void handlePicked(picked)
|
|
220
|
+
e.target.value = ''
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
</label>
|
|
224
|
+
|
|
225
|
+
{attached && (
|
|
226
|
+
<Attachment className="w-full" state={attached.state}>
|
|
227
|
+
<AttachmentMedia
|
|
228
|
+
variant={isImage && attached.preview ? 'image' : 'icon'}
|
|
229
|
+
className={mediaClassName}
|
|
230
|
+
>
|
|
231
|
+
{isImage && attached.preview ? (
|
|
232
|
+
<img src={attached.preview} alt="" className="size-full object-contain" />
|
|
233
|
+
) : (
|
|
234
|
+
(icon ?? <FileIcon />)
|
|
235
|
+
)}
|
|
236
|
+
</AttachmentMedia>
|
|
237
|
+
<AttachmentContent>
|
|
238
|
+
<AttachmentTitle>
|
|
239
|
+
{attached.name ?? t('admin.components.file_upload.attached')}
|
|
240
|
+
</AttachmentTitle>
|
|
241
|
+
<AttachmentDescription>
|
|
242
|
+
{attached.state === 'uploading'
|
|
243
|
+
? t('admin.components.file_upload.uploading')
|
|
244
|
+
: attached.size != null
|
|
245
|
+
? formatFileSize(attached.size)
|
|
246
|
+
: null}
|
|
247
|
+
</AttachmentDescription>
|
|
248
|
+
</AttachmentContent>
|
|
249
|
+
<AttachmentActions>
|
|
250
|
+
<AttachmentAction
|
|
251
|
+
aria-label={t('admin.actions.remove')}
|
|
252
|
+
onClick={handleRemove}
|
|
253
|
+
disabled={disabled || !!pending}
|
|
254
|
+
>
|
|
255
|
+
<XIcon />
|
|
256
|
+
</AttachmentAction>
|
|
257
|
+
</AttachmentActions>
|
|
258
|
+
</Attachment>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{help && <FieldDescription>{help}</FieldDescription>}
|
|
262
|
+
</div>
|
|
263
|
+
</Field>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { FileUploadField, type FileUploadValue } from './file-upload-field'
|
|
3
|
+
|
|
4
|
+
/** @deprecated Use {@link FileUploadValue} — same shape, universal name. */
|
|
5
|
+
export type ImageUploadValue = FileUploadValue
|
|
6
|
+
|
|
7
|
+
export interface ImageUploadFieldProps {
|
|
8
|
+
value: ImageUploadValue
|
|
9
|
+
onChange: (value: ImageUploadValue) => void
|
|
10
|
+
/** URL of the already-persisted image, shown until the user replaces/removes it. */
|
|
11
|
+
serverUrl?: string | null
|
|
12
|
+
label?: string
|
|
13
|
+
/** Helper text under the dropzone. */
|
|
14
|
+
help?: string
|
|
15
|
+
/** Square preview (avatars); default is a wider logo-friendly box. */
|
|
16
|
+
square?: boolean
|
|
17
|
+
/** Accepted MIME types. Defaults to PNG/JPEG/WebP. */
|
|
18
|
+
accept?: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single-image preset of {@link FileUploadField} — logos, avatars, category
|
|
24
|
+
* images. Kept as a named export so existing call sites stay drop-in.
|
|
25
|
+
*/
|
|
26
|
+
export function ImageUploadField({
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
serverUrl,
|
|
30
|
+
label,
|
|
31
|
+
help,
|
|
32
|
+
square = false,
|
|
33
|
+
accept = 'image/png,image/jpeg,image/webp',
|
|
34
|
+
disabled = false,
|
|
35
|
+
}: ImageUploadFieldProps) {
|
|
36
|
+
const { t } = useTranslation()
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<FileUploadField
|
|
40
|
+
variant="image"
|
|
41
|
+
value={value}
|
|
42
|
+
onChange={onChange}
|
|
43
|
+
accept={accept}
|
|
44
|
+
serverUrl={serverUrl}
|
|
45
|
+
label={label}
|
|
46
|
+
help={help}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
dropLabel={t('admin.components.file_upload.drop_image_label')}
|
|
49
|
+
browseLabel={t('admin.image_upload.upload_cta')}
|
|
50
|
+
mediaClassName={square ? 'size-16' : 'h-16 w-28'}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Import, ImportType } from '@spree/admin-sdk'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Field,
|
|
5
|
+
FieldLabel,
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
Sheet,
|
|
12
|
+
SheetContent,
|
|
13
|
+
SheetDescription,
|
|
14
|
+
SheetFooter,
|
|
15
|
+
SheetHeader,
|
|
16
|
+
SheetTitle,
|
|
17
|
+
} from '@spree/dashboard-ui'
|
|
18
|
+
import { DownloadIcon, FileSpreadsheetIcon, UploadIcon } from 'lucide-react'
|
|
19
|
+
import { useState } from 'react'
|
|
20
|
+
import { useTranslation } from 'react-i18next'
|
|
21
|
+
import { toast } from 'sonner'
|
|
22
|
+
import { useCreateImport, useDownloadImportTemplate } from '../hooks/use-import'
|
|
23
|
+
import type { SubjectName } from '../lib/permissions'
|
|
24
|
+
import { Can } from './can'
|
|
25
|
+
import { EMPTY_FILE_UPLOAD_VALUE, FileUploadField, type FileUploadValue } from './file-upload-field'
|
|
26
|
+
|
|
27
|
+
const DELIMITERS = [
|
|
28
|
+
{ value: ',', labelKey: 'comma' },
|
|
29
|
+
{ value: ';', labelKey: 'semicolon' },
|
|
30
|
+
{ value: '|', labelKey: 'pipe' },
|
|
31
|
+
{ value: '\t', labelKey: 'tab' },
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
type Delimiter = (typeof DELIMITERS)[number]['value']
|
|
35
|
+
|
|
36
|
+
interface ImportButtonProps {
|
|
37
|
+
/** Which dataset to import. Server validates against `Spree::Import.available_types`. */
|
|
38
|
+
type: ImportType
|
|
39
|
+
/**
|
|
40
|
+
* CanCanCan subject gating the button — the *imported resource*
|
|
41
|
+
* (e.g. `Subject.Product`), mirroring the server's `write_<resource>`
|
|
42
|
+
* scope model. Purely UX; the backend authorizes every request.
|
|
43
|
+
*/
|
|
44
|
+
subject: SubjectName
|
|
45
|
+
/**
|
|
46
|
+
* Receives the created import (in `mapping` state). The consumer opens the
|
|
47
|
+
* wizard — typically a full-window dialog driven by an `?import=` search
|
|
48
|
+
* param.
|
|
49
|
+
*/
|
|
50
|
+
onCreated: (imp: Import) => void
|
|
51
|
+
/** Label shown on the button. Defaults to the translated "Import" action. */
|
|
52
|
+
label?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Toolbar entry point for CSV imports: opens a Sheet with the upload form
|
|
57
|
+
* (file, delimiter, template download). The CSV direct-uploads on pick via
|
|
58
|
+
* `FileUploadField`; submitting creates the import from the signed blob id
|
|
59
|
+
* and hands it to `onCreated`.
|
|
60
|
+
*/
|
|
61
|
+
export function ImportButton({ type, subject, onCreated, label }: ImportButtonProps) {
|
|
62
|
+
const { t } = useTranslation()
|
|
63
|
+
const [open, setOpen] = useState(false)
|
|
64
|
+
const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
|
|
65
|
+
const [delimiter, setDelimiter] = useState<Delimiter>(',')
|
|
66
|
+
const createImport = useCreateImport()
|
|
67
|
+
const downloadTemplate = useDownloadImportTemplate()
|
|
68
|
+
|
|
69
|
+
const delimiterOptions = DELIMITERS.map(({ value, labelKey }) => ({
|
|
70
|
+
value,
|
|
71
|
+
label: t(`admin.components.import_button.delimiters.${labelKey}`),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
function handleOpenChange(next: boolean) {
|
|
75
|
+
setOpen(next)
|
|
76
|
+
if (!next) setFile(EMPTY_FILE_UPLOAD_VALUE)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleSubmit() {
|
|
80
|
+
if (!file.signedId || createImport.isPending) return
|
|
81
|
+
|
|
82
|
+
createImport.mutate(
|
|
83
|
+
{ type, signedId: file.signedId, preferredDelimiter: delimiter },
|
|
84
|
+
{
|
|
85
|
+
onSuccess: (imp) => {
|
|
86
|
+
handleOpenChange(false)
|
|
87
|
+
onCreated(imp)
|
|
88
|
+
},
|
|
89
|
+
onError: (err) => {
|
|
90
|
+
toast.error(
|
|
91
|
+
t('admin.components.import_button.failed', {
|
|
92
|
+
message: err instanceof Error ? err.message : String(err),
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handleTemplateDownload() {
|
|
101
|
+
downloadTemplate.mutate(type, {
|
|
102
|
+
onError: (err) => {
|
|
103
|
+
toast.error(
|
|
104
|
+
t('admin.components.import_button.template_failed', {
|
|
105
|
+
message: err instanceof Error ? err.message : String(err),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Can I="create" a={subject}>
|
|
114
|
+
<Button
|
|
115
|
+
size="sm"
|
|
116
|
+
variant="outline"
|
|
117
|
+
className="h-[2.125rem]"
|
|
118
|
+
onClick={() => setOpen(true)}
|
|
119
|
+
disabled={createImport.isPending}
|
|
120
|
+
>
|
|
121
|
+
<UploadIcon className="size-4" />
|
|
122
|
+
{label ?? t('admin.actions.import')}
|
|
123
|
+
</Button>
|
|
124
|
+
|
|
125
|
+
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
126
|
+
<SheetContent className="sm:max-w-xl">
|
|
127
|
+
<SheetHeader>
|
|
128
|
+
<SheetTitle>{t('admin.components.import_button.title')}</SheetTitle>
|
|
129
|
+
<SheetDescription>{t('admin.components.import_button.description')}</SheetDescription>
|
|
130
|
+
</SheetHeader>
|
|
131
|
+
|
|
132
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
133
|
+
<FileUploadField
|
|
134
|
+
value={file}
|
|
135
|
+
onChange={setFile}
|
|
136
|
+
accept=".csv,text/csv"
|
|
137
|
+
variant="file"
|
|
138
|
+
icon={<FileSpreadsheetIcon />}
|
|
139
|
+
dropLabel={t('admin.components.import_button.drop_label')}
|
|
140
|
+
// Windows browsers report `.csv` as `application/vnd.ms-excel`,
|
|
141
|
+
// which the server's content-type validation rejects.
|
|
142
|
+
transformFile={(picked) => new File([picked], picked.name, { type: 'text/csv' })}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
<Field>
|
|
146
|
+
<FieldLabel>{t('admin.components.import_button.delimiter_label')}</FieldLabel>
|
|
147
|
+
<Select
|
|
148
|
+
items={delimiterOptions}
|
|
149
|
+
value={delimiter}
|
|
150
|
+
onValueChange={(value) => setDelimiter(value as Delimiter)}
|
|
151
|
+
>
|
|
152
|
+
<SelectTrigger>
|
|
153
|
+
<SelectValue />
|
|
154
|
+
</SelectTrigger>
|
|
155
|
+
<SelectContent>
|
|
156
|
+
{delimiterOptions.map((option) => (
|
|
157
|
+
<SelectItem key={option.value} value={option.value}>
|
|
158
|
+
{option.label}
|
|
159
|
+
</SelectItem>
|
|
160
|
+
))}
|
|
161
|
+
</SelectContent>
|
|
162
|
+
</Select>
|
|
163
|
+
</Field>
|
|
164
|
+
|
|
165
|
+
<Button
|
|
166
|
+
type="button"
|
|
167
|
+
variant="ghost"
|
|
168
|
+
size="sm"
|
|
169
|
+
className="self-start"
|
|
170
|
+
onClick={handleTemplateDownload}
|
|
171
|
+
disabled={downloadTemplate.isPending}
|
|
172
|
+
>
|
|
173
|
+
<DownloadIcon className="size-4" />
|
|
174
|
+
{t('admin.components.import_button.download_template')}
|
|
175
|
+
</Button>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<SheetFooter>
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="outline"
|
|
182
|
+
size="sm"
|
|
183
|
+
onClick={() => handleOpenChange(false)}
|
|
184
|
+
>
|
|
185
|
+
{t('admin.actions.cancel')}
|
|
186
|
+
</Button>
|
|
187
|
+
<Button
|
|
188
|
+
type="button"
|
|
189
|
+
size="sm"
|
|
190
|
+
onClick={handleSubmit}
|
|
191
|
+
disabled={!file.signedId || createImport.isPending}
|
|
192
|
+
>
|
|
193
|
+
{createImport.isPending
|
|
194
|
+
? t('admin.actions.creating')
|
|
195
|
+
: t('admin.components.import_button.submit')}
|
|
196
|
+
</Button>
|
|
197
|
+
</SheetFooter>
|
|
198
|
+
</SheetContent>
|
|
199
|
+
</Sheet>
|
|
200
|
+
</Can>
|
|
201
|
+
)
|
|
202
|
+
}
|