@oslokommune/punkt-react 15.0.3 → 15.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/CHANGELOG.md +31 -0
- package/dist/index.d.ts +173 -0
- package/dist/punkt-react.es.js +8216 -7134
- package/dist/punkt-react.umd.js +435 -435
- package/package.json +4 -4
- package/src/components/fileupload/DropZone.tsx +236 -0
- package/src/components/fileupload/FileUpload.test.tsx +584 -0
- package/src/components/fileupload/FileUpload.tsx +425 -0
- package/src/components/fileupload/QueueDisplay.test.tsx +222 -0
- package/src/components/fileupload/QueueDisplay.tsx +155 -0
- package/src/components/fileupload/QueueItemContent.tsx +200 -0
- package/src/components/fileupload/Subcomponents.tsx +363 -0
- package/src/components/fileupload/Truncate.test.tsx +256 -0
- package/src/components/fileupload/Truncate.tsx +137 -0
- package/src/components/fileupload/extensions/Comments.tsx +188 -0
- package/src/components/fileupload/extensions/Remove.tsx +10 -0
- package/src/components/fileupload/extensions/Rename.tsx +79 -0
- package/src/components/fileupload/hooks/index.ts +3 -0
- package/src/components/fileupload/hooks/useFileAttributes.ts +46 -0
- package/src/components/fileupload/hooks/useImagePreview.ts +42 -0
- package/src/components/fileupload/hooks/useOperationState.ts +30 -0
- package/src/components/fileupload/hooks.ts +4 -0
- package/src/components/fileupload/texts.ts +20 -0
- package/src/components/fileupload/types.ts +94 -0
- package/src/components/fileupload/utils.ts +56 -0
- package/src/components/index.ts +1 -0
- package/src/components/interfaces.ts +1 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Prinsipper:
|
|
3
|
+
- Fileupload-komponenten er stateless. All state må håndteres utenfor.
|
|
4
|
+
- Dette er en controlled component, så når filer legges til, fjernes eller oppdateres, blir den nye fil-lista sendt ut via onFilesChanged. Komponenten over må dermed oppdatere value-prop'en.
|
|
5
|
+
- Når brukeren velger en fil for opplasting, gir komponenten fila en unik ID.
|
|
6
|
+
- `value`-typen til denne komponenten er en FileItemList
|
|
7
|
+
- Støtte for to opplastingsstrategier:
|
|
8
|
+
- 'form': Filene legges til i et skjema og lastes opp når skjemaet sendes inn.
|
|
9
|
+
- 'custom': Når en fil legges til, kalles en callback-funksjon for å håndtere opplastingen manuelt. Når skjemaet sendes inn, postes bare fil-IDene. Server-applikasjonen kan dermed knytte de allerede opplastede filene til fil-IDene.
|
|
10
|
+
- Støtte for flere filvalg (multiple) og enkel filvalg.
|
|
11
|
+
- Støtte for tilleggsfunksjoner som å legge til kommentarer og endre filnavn via utvidelser.
|
|
12
|
+
- Støtte for tilpassede renderer-komponenter for visning av filer i køen.
|
|
13
|
+
*/
|
|
14
|
+
import classNames from 'classnames'
|
|
15
|
+
import { FC, forwardRef, HTMLAttributes, InputHTMLAttributes, ReactNode, useCallback, useMemo, useState } from 'react'
|
|
16
|
+
|
|
17
|
+
import { PktAlert } from '../alert/Alert'
|
|
18
|
+
import { PktInputWrapper } from '../inputwrapper/InputWrapper'
|
|
19
|
+
import { DropZone } from './DropZone'
|
|
20
|
+
import { addCommentOperation } from './extensions/Comments'
|
|
21
|
+
import { removeFileOperation } from './extensions/Remove'
|
|
22
|
+
import { renameFileOperation } from './extensions/Rename'
|
|
23
|
+
import { useFileAttributes } from './hooks'
|
|
24
|
+
import { ItemRenderers, QueueDisplay } from './QueueDisplay'
|
|
25
|
+
import {
|
|
26
|
+
TFileId,
|
|
27
|
+
FileItem,
|
|
28
|
+
TFileItemList,
|
|
29
|
+
TFileTransfer,
|
|
30
|
+
TItemRenderer,
|
|
31
|
+
PktFileUploadContext,
|
|
32
|
+
TQueueItemExtension,
|
|
33
|
+
TUploadStrategy,
|
|
34
|
+
} from './types'
|
|
35
|
+
import { formatFileSize, parseFileSize } from './utils'
|
|
36
|
+
|
|
37
|
+
// Re-export for external use
|
|
38
|
+
export { formatFileSize, parseFileSize } from './utils'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Shared props for `PktFileUpload` (both `form` and `custom` strategies).
|
|
42
|
+
*
|
|
43
|
+
* The component can be used as:
|
|
44
|
+
* - **controlled**: pass `value` + `onFilesChanged`
|
|
45
|
+
* - **uncontrolled**: pass `defaultValue` (internal state is not stored; you still receive new lists via callbacks)
|
|
46
|
+
*/
|
|
47
|
+
interface IBaseFileUploadProps
|
|
48
|
+
extends Omit<
|
|
49
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
50
|
+
'checked' | 'size' | 'type' | 'value' | 'onChange' | 'width' | 'defaultValue'
|
|
51
|
+
> {
|
|
52
|
+
/** Called with the next file list when files are added/removed/updated. */
|
|
53
|
+
onFilesChanged?: (files: TFileItemList) => void
|
|
54
|
+
/** Allow selecting/dropping multiple files. */
|
|
55
|
+
multiple?: boolean
|
|
56
|
+
/** Controlled value (recommended). */
|
|
57
|
+
value?: TFileItemList
|
|
58
|
+
/** Upload mode. Defaults to `"form"`. */
|
|
59
|
+
uploadStrategy?: TUploadStrategy
|
|
60
|
+
/** Field name. Used for native input (`form`) or hidden ID inputs (`custom`). */
|
|
61
|
+
name: string
|
|
62
|
+
/** Enable comment operation (disabled automatically in thumbnail view). */
|
|
63
|
+
addCommentsEnabled?: boolean
|
|
64
|
+
/** Enable rename operation (disabled automatically in thumbnail view). */
|
|
65
|
+
renameFilesEnabled?: boolean
|
|
66
|
+
/** Renderer for queue items (`filename`, `thumbnail`, or custom function). */
|
|
67
|
+
itemRenderer?: keyof typeof ItemRenderers | TItemRenderer
|
|
68
|
+
/** Number of trailing characters to keep when truncating long filenames. */
|
|
69
|
+
truncateTail?: number
|
|
70
|
+
/**
|
|
71
|
+
* Called immediately when a file is added in `uploadStrategy="custom"`.
|
|
72
|
+
* Use it to start uploading the file and update `transfers`.
|
|
73
|
+
*/
|
|
74
|
+
onFileUploadRequested?: (fileItem: FileItem) => void
|
|
75
|
+
/** Uncontrolled initial value (use either `value` or `defaultValue`, not both). */
|
|
76
|
+
defaultValue?: TFileItemList
|
|
77
|
+
/** Extra operations appended after built-ins (rename/comment). */
|
|
78
|
+
extraOperations?: Array<TQueueItemExtension>
|
|
79
|
+
/** Stretch to full container width (drop zone + queue). */
|
|
80
|
+
fullWidth?: boolean
|
|
81
|
+
/** Allowed formats for built-in validation (extensions like `pdf`, or MIME patterns like `image/*`). */
|
|
82
|
+
allowedFormats?: string[]
|
|
83
|
+
/** Custom message for invalid format. Use `{formats}` placeholder. */
|
|
84
|
+
formatErrorMessage?: string
|
|
85
|
+
/** Max file size (e.g. `"5MB"` or bytes). */
|
|
86
|
+
maxFileSize?: string | number
|
|
87
|
+
/** Custom message for size validation. Use `{maxSize}` placeholder. */
|
|
88
|
+
sizeErrorMessage?: string
|
|
89
|
+
/** Optional additional validation hook (runs after built-in format/size checks). Return string to block. */
|
|
90
|
+
onFileValidation?: (file: File) => string | null
|
|
91
|
+
/** External/programmatic error message shown under the component. */
|
|
92
|
+
errorMessage?: string
|
|
93
|
+
/** External error flag (combined with internal validation errors). */
|
|
94
|
+
hasError?: boolean
|
|
95
|
+
/** Disable the whole component (no interaction). */
|
|
96
|
+
disabled?: boolean
|
|
97
|
+
/** Optional label/title (wraps the component in `PktInputWrapper`). */
|
|
98
|
+
label?: string
|
|
99
|
+
/** Help text under the label. */
|
|
100
|
+
helptext?: string | ReactNode
|
|
101
|
+
/** Show "Valgfritt" tag in wrapper. */
|
|
102
|
+
optionalTag?: boolean
|
|
103
|
+
/** Show "Må fylles ut" tag in wrapper. */
|
|
104
|
+
requiredTag?: boolean
|
|
105
|
+
/** Enable image preview modal (only applies to thumbnail renderer). */
|
|
106
|
+
enableImagePreview?: boolean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Props for `uploadStrategy="form"` (native file input submission). */
|
|
110
|
+
interface IFileInputFileUploadProps
|
|
111
|
+
extends IBaseFileUploadProps,
|
|
112
|
+
Omit<HTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'defaultValue'> {
|
|
113
|
+
uploadStrategy?: 'form'
|
|
114
|
+
onFileUploadRequested?: never
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Props for `uploadStrategy="custom"` (upload handled externally).
|
|
119
|
+
*
|
|
120
|
+
* Requirements:
|
|
121
|
+
* - `transfers` must be provided (to render status/progress)
|
|
122
|
+
* - `onFileUploadRequested` is called for each added file
|
|
123
|
+
*/
|
|
124
|
+
interface ImmediateFileUploadProps extends IBaseFileUploadProps {
|
|
125
|
+
id: string
|
|
126
|
+
uploadStrategy: 'custom'
|
|
127
|
+
transfers: Array<TFileTransfer>
|
|
128
|
+
onFileUploadRequested: (fileItem: FileItem) => void
|
|
129
|
+
onTransferCancelled?: (fileItemId: string) => void
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type IPktFileUpload = IFileInputFileUploadProps | ImmediateFileUploadProps
|
|
133
|
+
|
|
134
|
+
export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IPktFileUpload>(
|
|
135
|
+
(
|
|
136
|
+
{
|
|
137
|
+
value: valueProp,
|
|
138
|
+
defaultValue,
|
|
139
|
+
id = 'fileupload-id',
|
|
140
|
+
multiple = false,
|
|
141
|
+
uploadStrategy = 'form',
|
|
142
|
+
addCommentsEnabled = false,
|
|
143
|
+
renameFilesEnabled = false,
|
|
144
|
+
truncateTail,
|
|
145
|
+
onFilesChanged,
|
|
146
|
+
onFileUploadRequested,
|
|
147
|
+
extraOperations = [],
|
|
148
|
+
itemRenderer: itemRendererProp = ItemRenderers.filename,
|
|
149
|
+
fullWidth = false,
|
|
150
|
+
allowedFormats,
|
|
151
|
+
formatErrorMessage,
|
|
152
|
+
maxFileSize, // default to 5 MB
|
|
153
|
+
sizeErrorMessage,
|
|
154
|
+
onFileValidation,
|
|
155
|
+
errorMessage: externalErrorMessage,
|
|
156
|
+
hasError: externalHasError = false,
|
|
157
|
+
disabled = false,
|
|
158
|
+
label,
|
|
159
|
+
helptext,
|
|
160
|
+
optionalTag,
|
|
161
|
+
requiredTag,
|
|
162
|
+
enableImagePreview = false,
|
|
163
|
+
...props
|
|
164
|
+
}: IPktFileUpload,
|
|
165
|
+
forwardedRef,
|
|
166
|
+
) => {
|
|
167
|
+
const transfers = 'transfers' in props ? props.transfers : undefined
|
|
168
|
+
const [validationError, setValidationError] = useState<string | null>(null)
|
|
169
|
+
|
|
170
|
+
// Combine external error with internal validation error
|
|
171
|
+
const hasError = externalHasError || !!validationError
|
|
172
|
+
const errorMessage = externalErrorMessage || validationError
|
|
173
|
+
|
|
174
|
+
// Parse maxFileSize once (supports "5MB" strings or raw bytes)
|
|
175
|
+
const maxFileSizeBytes = maxFileSize ? parseFileSize(maxFileSize) : undefined
|
|
176
|
+
|
|
177
|
+
// Built-in validation function
|
|
178
|
+
const validateFile = useCallback(
|
|
179
|
+
(file: File): string | null => {
|
|
180
|
+
// 1. Check format if allowedFormats is specified
|
|
181
|
+
if (allowedFormats && allowedFormats.length > 0) {
|
|
182
|
+
const fileExtension = file.name.split('.').pop()?.toLowerCase() || ''
|
|
183
|
+
const fileMimeType = file.type.toLowerCase()
|
|
184
|
+
|
|
185
|
+
const isAllowed = allowedFormats.some((format) => {
|
|
186
|
+
const normalizedFormat = format.toLowerCase().replace(/^\./, '') // Remove leading dot if present
|
|
187
|
+
|
|
188
|
+
// Check MIME type patterns (e.g. 'image/*', 'application/pdf')
|
|
189
|
+
if (normalizedFormat.includes('/')) {
|
|
190
|
+
if (normalizedFormat.endsWith('/*')) {
|
|
191
|
+
const category = normalizedFormat.replace('/*', '')
|
|
192
|
+
return fileMimeType.startsWith(category + '/')
|
|
193
|
+
}
|
|
194
|
+
return fileMimeType === normalizedFormat
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check file extension (e.g. 'pdf', 'jpg', '.png')
|
|
198
|
+
return fileExtension === normalizedFormat
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (!isAllowed) {
|
|
202
|
+
const formatsDisplay = allowedFormats.join(', ')
|
|
203
|
+
const defaultMessage = `Ugyldig filtype. Tillatte formater: ${formatsDisplay}`
|
|
204
|
+
return formatErrorMessage?.replace('{formats}', formatsDisplay) || defaultMessage
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 2. Check file size if maxFileSize is specified
|
|
209
|
+
if (maxFileSizeBytes && file.size > maxFileSizeBytes) {
|
|
210
|
+
const maxSizeDisplay = formatFileSize(maxFileSizeBytes)
|
|
211
|
+
const defaultMessage = `Filen er for stor. Maks størrelse er ${maxSizeDisplay}.`
|
|
212
|
+
return sizeErrorMessage?.replace('{maxSize}', maxSizeDisplay) || defaultMessage
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. Run custom validation if provided
|
|
216
|
+
if (onFileValidation) {
|
|
217
|
+
return onFileValidation(file)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null
|
|
221
|
+
},
|
|
222
|
+
[allowedFormats, formatErrorMessage, maxFileSizeBytes, sizeErrorMessage, onFileValidation],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if (valueProp !== undefined && defaultValue !== undefined) {
|
|
226
|
+
// eslint-disable-next-line no-console
|
|
227
|
+
console.warn(
|
|
228
|
+
"PktFileupload: Både value og defaultValue er angitt. Komponenten kan være enten 'controlled' eller 'uncontrolled', ikke begge deler. Bruk kun én av dem. Se https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable",
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
if (valueProp !== undefined && onFilesChanged === undefined) {
|
|
232
|
+
// eslint-disable-next-line no-console
|
|
233
|
+
console.warn(
|
|
234
|
+
"PktFileupload: value-prop er angitt uten onFilesChanged-callback. Når en komponent er 'controlled', må endringer håndteres via en callback. Vennligst legg til onFilesChanged-callback for å håndtere endringer i fil-listen. Se https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable",
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const isControlled = valueProp !== undefined
|
|
239
|
+
const value = isControlled ? valueProp : defaultValue || []
|
|
240
|
+
|
|
241
|
+
const itemRenderer = typeof itemRendererProp === 'string' ? ItemRenderers[itemRendererProp] : itemRendererProp
|
|
242
|
+
const isThumbnailView = itemRendererProp === 'thumbnail'
|
|
243
|
+
|
|
244
|
+
const effectiveRenameEnabled = renameFilesEnabled && !isThumbnailView
|
|
245
|
+
const effectiveCommentsEnabled = addCommentsEnabled && !isThumbnailView
|
|
246
|
+
|
|
247
|
+
const onFilesAdded = useCallback(
|
|
248
|
+
(addedFileItems: TFileItemList) => {
|
|
249
|
+
// Run validation on each file
|
|
250
|
+
for (const fileItem of addedFileItems) {
|
|
251
|
+
const error = validateFile(fileItem.file)
|
|
252
|
+
if (error) {
|
|
253
|
+
setValidationError(error)
|
|
254
|
+
return // Don't add any files if validation fails
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Clear any previous validation error
|
|
259
|
+
setValidationError(null)
|
|
260
|
+
|
|
261
|
+
const newValue: TFileItemList = multiple ? [...value, ...addedFileItems] : [addedFileItems[0]]
|
|
262
|
+
|
|
263
|
+
if (onFilesChanged) {
|
|
264
|
+
onFilesChanged(newValue)
|
|
265
|
+
}
|
|
266
|
+
if (uploadStrategy === 'custom' && onFileUploadRequested) {
|
|
267
|
+
addedFileItems.forEach((fileItem) => {
|
|
268
|
+
onFileUploadRequested?.(fileItem)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
[onFilesChanged, value, multiple, uploadStrategy, onFileUploadRequested, validateFile],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const onFileUpdated = useCallback(
|
|
276
|
+
(fileId: TFileId, updates: Partial<FileItem>) => {
|
|
277
|
+
const newValue = value.map((fileItem) => (fileItem.fileId === fileId ? { ...fileItem, ...updates } : fileItem))
|
|
278
|
+
if (onFilesChanged) {
|
|
279
|
+
onFilesChanged(newValue)
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
[onFilesChanged, value],
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const onFileRemoved = useCallback(
|
|
286
|
+
(fileId: TFileId) => {
|
|
287
|
+
const newValue = value.filter((fileItem) => fileItem.fileId !== fileId)
|
|
288
|
+
if (onFilesChanged) {
|
|
289
|
+
onFilesChanged(newValue)
|
|
290
|
+
}
|
|
291
|
+
if ('onTransferCancelled' in props && props.onTransferCancelled) {
|
|
292
|
+
props.onTransferCancelled?.(fileId)
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
[onFilesChanged, value, props],
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const { fileAttributes } = useFileAttributes(value, onFileUpdated)
|
|
299
|
+
|
|
300
|
+
const queueItemExtensions = useMemo(
|
|
301
|
+
() =>
|
|
302
|
+
[
|
|
303
|
+
{
|
|
304
|
+
op: renameFileOperation,
|
|
305
|
+
enabled: effectiveRenameEnabled,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
op: (attributes: Parameters<typeof addCommentOperation>[0]) => addCommentOperation(attributes),
|
|
309
|
+
enabled: effectiveCommentsEnabled,
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
.filter(({ enabled }) => enabled)
|
|
313
|
+
.map(({ op }) => op)
|
|
314
|
+
.concat(...extraOperations),
|
|
315
|
+
[effectiveRenameEnabled, effectiveCommentsEnabled],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// Screen reader announcement IDs
|
|
319
|
+
const srAnnouncementIds = useMemo(
|
|
320
|
+
() => ({
|
|
321
|
+
uploaded: `${id}-uploaded`,
|
|
322
|
+
errors: `${id}-errors`,
|
|
323
|
+
}),
|
|
324
|
+
[id],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
// Compute counts for screen reader announcements.
|
|
328
|
+
// F.eks. "1 av 3 filer lastet opp" eller "2 av 3 filer feilet"
|
|
329
|
+
const { totalCount, uploadedCount, failedCount } = useMemo(() => {
|
|
330
|
+
const totalCount = value.length
|
|
331
|
+
if (!transfers) {
|
|
332
|
+
return { totalCount, uploadedCount: 0, failedCount: 0 }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let uploadedCount = 0
|
|
336
|
+
let failedCount = 0
|
|
337
|
+
|
|
338
|
+
for (const fileItem of value) {
|
|
339
|
+
const transfer = transfers.find((t) => t.fileId === fileItem.fileId)
|
|
340
|
+
if (transfer?.progress === 'done') uploadedCount++
|
|
341
|
+
if (transfer?.progress === 'error') failedCount++
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { totalCount, uploadedCount, failedCount }
|
|
345
|
+
}, [value, transfers])
|
|
346
|
+
|
|
347
|
+
const totalLabel = totalCount === 1 ? 'fil' : 'filer'
|
|
348
|
+
|
|
349
|
+
const fileUploadContent = (
|
|
350
|
+
<div
|
|
351
|
+
className={classNames('pkt-fileupload', {
|
|
352
|
+
'pkt-fileupload--full-width': fullWidth,
|
|
353
|
+
'pkt-fileupload--error': hasError,
|
|
354
|
+
'pkt-fileupload--disabled': disabled,
|
|
355
|
+
})}
|
|
356
|
+
aria-disabled={disabled}
|
|
357
|
+
{...(disabled ? { inert: '' as any } : {})}
|
|
358
|
+
>
|
|
359
|
+
<PktFileUploadContext.Provider value={{ name: props.name || '', multiple: !!multiple, id }}>
|
|
360
|
+
{/* Screen reader announcements - visually hidden */}
|
|
361
|
+
<div id={srAnnouncementIds.uploaded} className="pkt-sr-only" aria-live="polite" aria-atomic="true">
|
|
362
|
+
{totalCount > 0 && uploadedCount > 0 && `${uploadedCount} av ${totalCount} ${totalLabel} lastet opp`}
|
|
363
|
+
</div>
|
|
364
|
+
<div id={srAnnouncementIds.errors} className="pkt-sr-only" aria-live="assertive" aria-atomic="true">
|
|
365
|
+
{totalCount > 0 &&
|
|
366
|
+
failedCount > 0 &&
|
|
367
|
+
`Feil ved opplasting: ${failedCount} av ${totalCount} ${totalLabel} feilet`}
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<DropZone
|
|
371
|
+
id={id}
|
|
372
|
+
name={props.name || ''}
|
|
373
|
+
value={value}
|
|
374
|
+
onFilesAdded={onFilesAdded}
|
|
375
|
+
multiple={multiple}
|
|
376
|
+
uploadStrategy={uploadStrategy}
|
|
377
|
+
ref={forwardedRef}
|
|
378
|
+
accept={isThumbnailView ? '.jpeg, .jpg, .png, .gif, .webp, .heic' : props.accept}
|
|
379
|
+
isThumbnailView={isThumbnailView}
|
|
380
|
+
disabled={disabled}
|
|
381
|
+
srAnnouncementIds={srAnnouncementIds}
|
|
382
|
+
/>
|
|
383
|
+
{hasError && errorMessage && (
|
|
384
|
+
<PktAlert skin="error" aria-live="assertive" role="alert" compact className="pkt-fileupload__error-alert">
|
|
385
|
+
{errorMessage}
|
|
386
|
+
</PktAlert>
|
|
387
|
+
)}
|
|
388
|
+
<QueueDisplay
|
|
389
|
+
files={value}
|
|
390
|
+
cancelTransfer={onFileRemoved}
|
|
391
|
+
truncateTail={truncateTail}
|
|
392
|
+
transfers={transfers}
|
|
393
|
+
ItemRenderer={itemRenderer}
|
|
394
|
+
enableImagePreview={isThumbnailView && enableImagePreview}
|
|
395
|
+
queueItemOperations={queueItemExtensions
|
|
396
|
+
.map((ext) => ext(fileAttributes))
|
|
397
|
+
.concat(removeFileOperation(onFileRemoved))}
|
|
398
|
+
/>
|
|
399
|
+
</PktFileUploadContext.Provider>
|
|
400
|
+
</div>
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
// Wrap with InputWrapper if label is provided
|
|
404
|
+
if (label) {
|
|
405
|
+
return (
|
|
406
|
+
<PktInputWrapper
|
|
407
|
+
forId={id}
|
|
408
|
+
label={label}
|
|
409
|
+
helptext={helptext}
|
|
410
|
+
disabled={disabled}
|
|
411
|
+
hasError={hasError}
|
|
412
|
+
optionalTag={optionalTag}
|
|
413
|
+
requiredTag={requiredTag}
|
|
414
|
+
className="pkt-fileupload-wrapper"
|
|
415
|
+
>
|
|
416
|
+
{fileUploadContent}
|
|
417
|
+
</PktInputWrapper>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return fileUploadContent
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
PktFileUpload.displayName = 'PktFileUpload'
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import { vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { FileItem, TFileItemList, TFileTransfer } from './types'
|
|
7
|
+
import { PktFileUpload } from './FileUpload'
|
|
8
|
+
|
|
9
|
+
// Polyfill for ResizeObserver
|
|
10
|
+
if (!global.ResizeObserver) {
|
|
11
|
+
class ResizeObserverPolyfill {
|
|
12
|
+
observe() {}
|
|
13
|
+
unobserve() {}
|
|
14
|
+
disconnect() {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
global.ResizeObserver = ResizeObserverPolyfill as any
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NOOP = () => {}
|
|
21
|
+
|
|
22
|
+
const createMockFile = (name: string, type = 'text/plain'): File => new File(['content'], name, { type })
|
|
23
|
+
|
|
24
|
+
const createFileItem = (name: string, fileId?: string) => new FileItem(createMockFile(name), fileId)
|
|
25
|
+
|
|
26
|
+
const expectVisibleFilename = (filename: string) => {
|
|
27
|
+
// Truncate renders a hidden measuring element with the same text.
|
|
28
|
+
// We assert against the visible "first" part to avoid duplicate matches.
|
|
29
|
+
expect(
|
|
30
|
+
screen.getByText(
|
|
31
|
+
(content, element) =>
|
|
32
|
+
content === filename && element?.getAttribute('data-pkt-truncate-part') === 'first',
|
|
33
|
+
),
|
|
34
|
+
).toBeInTheDocument()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('QueueDisplay', () => {
|
|
38
|
+
describe('Progress states', () => {
|
|
39
|
+
it.each([
|
|
40
|
+
{
|
|
41
|
+
state: 'queued',
|
|
42
|
+
progress: 'queued' as TFileTransfer['progress'],
|
|
43
|
+
filename: 'queued-file.pdf',
|
|
44
|
+
description: 'when no transfer exists',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
state: 'done',
|
|
48
|
+
progress: 'done' as TFileTransfer['progress'],
|
|
49
|
+
filename: 'completed-file.pdf',
|
|
50
|
+
description: 'after successful upload',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
state: 'error',
|
|
54
|
+
progress: 'error' as TFileTransfer['progress'],
|
|
55
|
+
filename: 'failed-file.pdf',
|
|
56
|
+
description: 'after failed upload',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
state: 'canceled',
|
|
60
|
+
progress: 'canceled' as TFileTransfer['progress'],
|
|
61
|
+
filename: 'canceled-file.pdf',
|
|
62
|
+
description: 'when canceled',
|
|
63
|
+
},
|
|
64
|
+
// { state: 'in-progress', progress: 10 as FileTransfer['progress'], filename: 'in-progress-file.pdf', description: 'when canceled' },
|
|
65
|
+
])('should display file with $state state $description', ({ progress, filename, state }) => {
|
|
66
|
+
const initialValue: TFileItemList = [createFileItem(filename, 'file-1')]
|
|
67
|
+
const transfers = progress !== undefined ? [{ fileId: 'file-1', progress }] : []
|
|
68
|
+
|
|
69
|
+
const { container } = render(
|
|
70
|
+
<PktFileUpload
|
|
71
|
+
id={'pktFileUploadId'}
|
|
72
|
+
value={initialValue}
|
|
73
|
+
name={'pktFileUpload'}
|
|
74
|
+
uploadStrategy="custom"
|
|
75
|
+
onFileUploadRequested={vi.fn()}
|
|
76
|
+
transfers={transfers}
|
|
77
|
+
onFilesChanged={NOOP}
|
|
78
|
+
/>,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// File should be displayed
|
|
82
|
+
expectVisibleFilename(filename)
|
|
83
|
+
|
|
84
|
+
// Should have proper class when not in progress
|
|
85
|
+
const queueItem = container.querySelector('.pkt-fileupload__queue-display__item')
|
|
86
|
+
expect(queueItem).toHaveClass('pkt-fileupload__queue-display__item--' + state)
|
|
87
|
+
expect(queueItem).not.toHaveClass('pkt-fileupload__queue-display__item--in-progress')
|
|
88
|
+
|
|
89
|
+
// Should not show progress bar or cancel button
|
|
90
|
+
expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).not.toBeInTheDocument()
|
|
91
|
+
expect(screen.queryByText('Avbryt')).not.toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should display file with in-progress state and progress bar', () => {
|
|
95
|
+
const initialValue: TFileItemList = [createFileItem('uploading-file.pdf', 'file-1')]
|
|
96
|
+
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<PktFileUpload
|
|
99
|
+
id={'pktFileUploadId'}
|
|
100
|
+
value={initialValue}
|
|
101
|
+
name={'pktFileUpload'}
|
|
102
|
+
uploadStrategy="custom"
|
|
103
|
+
onFileUploadRequested={vi.fn()}
|
|
104
|
+
transfers={[{ fileId: 'file-1', progress: 0.45 }]}
|
|
105
|
+
onFilesChanged={NOOP}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// File should be displayed
|
|
110
|
+
expectVisibleFilename('uploading-file.pdf')
|
|
111
|
+
|
|
112
|
+
// Should have the 'in-progress' class
|
|
113
|
+
const queueItem = container.querySelector('.pkt-fileupload__queue-display__item')
|
|
114
|
+
expect(queueItem).toHaveClass('pkt-fileupload__queue-display__item--in-progress')
|
|
115
|
+
expect(queueItem).not.toHaveClass('pkt-fileupload__queue-display__item--done')
|
|
116
|
+
|
|
117
|
+
// Should show progress bar
|
|
118
|
+
expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).toBeInTheDocument()
|
|
119
|
+
|
|
120
|
+
// Should show percentage
|
|
121
|
+
expect(screen.getByText('45%')).toBeInTheDocument()
|
|
122
|
+
|
|
123
|
+
// Should show cancel button
|
|
124
|
+
expect(screen.getByText('Avbryt')).toBeInTheDocument()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should display multiple files with different progress states', () => {
|
|
128
|
+
const initialValue: TFileItemList = [
|
|
129
|
+
createFileItem('queued-file.pdf', 'file-1'),
|
|
130
|
+
createFileItem('uploading-file.pdf', 'file-2'),
|
|
131
|
+
createFileItem('completed-file.pdf', 'file-3'),
|
|
132
|
+
createFileItem('failed-file.pdf', 'file-4'),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
const { container } = render(
|
|
136
|
+
<PktFileUpload
|
|
137
|
+
id={'pktFileUploadId'}
|
|
138
|
+
value={initialValue}
|
|
139
|
+
name={'pktFileUpload'}
|
|
140
|
+
uploadStrategy="custom"
|
|
141
|
+
onFileUploadRequested={vi.fn()}
|
|
142
|
+
transfers={[
|
|
143
|
+
{ fileId: 'file-2', progress: 0.75 },
|
|
144
|
+
{ fileId: 'file-3', progress: 'done' },
|
|
145
|
+
{ fileId: 'file-4', progress: 'error' },
|
|
146
|
+
]}
|
|
147
|
+
onFilesChanged={NOOP}
|
|
148
|
+
/>,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// All files should be displayed
|
|
152
|
+
expectVisibleFilename('queued-file.pdf')
|
|
153
|
+
expectVisibleFilename('uploading-file.pdf')
|
|
154
|
+
expectVisibleFilename('completed-file.pdf')
|
|
155
|
+
expectVisibleFilename('failed-file.pdf')
|
|
156
|
+
|
|
157
|
+
// Should have 4 queue items
|
|
158
|
+
const queueItems = container.querySelectorAll('.pkt-fileupload__queue-display__item')
|
|
159
|
+
expect(queueItems).toHaveLength(4)
|
|
160
|
+
|
|
161
|
+
// Only the uploading file should have in-progress class
|
|
162
|
+
const inProgressItems = container.querySelectorAll('.pkt-fileupload__queue-display__item--in-progress')
|
|
163
|
+
expect(inProgressItems).toHaveLength(1)
|
|
164
|
+
|
|
165
|
+
// Only the uploading file should show progress bar and percentage
|
|
166
|
+
expect(screen.getByText('75%')).toBeInTheDocument()
|
|
167
|
+
expect(container.querySelectorAll('.pkt-fileupload__queue-display__item__progress')).toHaveLength(1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should update progress percentage when transfer progresses', () => {
|
|
171
|
+
const initialValue: TFileItemList = [createFileItem('uploading-file.pdf', 'file-1')]
|
|
172
|
+
|
|
173
|
+
const { container, rerender } = render(
|
|
174
|
+
<PktFileUpload
|
|
175
|
+
id={'pktFileUploadId'}
|
|
176
|
+
value={initialValue}
|
|
177
|
+
name={'pktFileUpload'}
|
|
178
|
+
uploadStrategy="custom"
|
|
179
|
+
onFileUploadRequested={vi.fn()}
|
|
180
|
+
transfers={[{ fileId: 'file-1', progress: 0.25 }]}
|
|
181
|
+
onFilesChanged={NOOP}
|
|
182
|
+
/>,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
// Initially at 25%
|
|
186
|
+
expect(screen.getByText('25%')).toBeInTheDocument()
|
|
187
|
+
|
|
188
|
+
// Update to 50%
|
|
189
|
+
rerender(
|
|
190
|
+
<PktFileUpload
|
|
191
|
+
id={'pktFileUploadId'}
|
|
192
|
+
value={initialValue}
|
|
193
|
+
name={'pktFileUpload'}
|
|
194
|
+
uploadStrategy="custom"
|
|
195
|
+
onFileUploadRequested={vi.fn()}
|
|
196
|
+
transfers={[{ fileId: 'file-1', progress: 0.5 }]}
|
|
197
|
+
onFilesChanged={NOOP}
|
|
198
|
+
/>,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
expect(screen.queryByText('25%')).not.toBeInTheDocument()
|
|
202
|
+
expect(screen.getByText('50%')).toBeInTheDocument()
|
|
203
|
+
|
|
204
|
+
// Update to done
|
|
205
|
+
rerender(
|
|
206
|
+
<PktFileUpload
|
|
207
|
+
id={'pktFileUploadId'}
|
|
208
|
+
value={initialValue}
|
|
209
|
+
name={'pktFileUpload'}
|
|
210
|
+
uploadStrategy="custom"
|
|
211
|
+
onFileUploadRequested={vi.fn()}
|
|
212
|
+
transfers={[{ fileId: 'file-1', progress: 'done' }]}
|
|
213
|
+
onFilesChanged={NOOP}
|
|
214
|
+
/>,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
// Progress percentage should no longer be visible
|
|
218
|
+
expect(screen.queryByText('50%')).not.toBeInTheDocument()
|
|
219
|
+
expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).not.toBeInTheDocument()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
})
|