@oslokommune/punkt-react 15.0.4 → 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.
@@ -0,0 +1,584 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import userEvent from '@testing-library/user-event'
5
+ import { axe, toHaveNoViolations } from 'jest-axe'
6
+ import { useState } from 'react'
7
+ import { vi } from 'vitest'
8
+
9
+ import { PktFileUpload } from './FileUpload'
10
+ import { FileItem, TFileItemList } from './types'
11
+
12
+ // Polyfill DataTransfer for jsdom
13
+ if (!global.DataTransfer) {
14
+ class DataTransferPolyfill {
15
+ items: DataTransferItemList
16
+ // @ts-ignore
17
+ files: FileList
18
+
19
+ constructor() {
20
+ const files: File[] = []
21
+ const items: DataTransferItem[] = []
22
+
23
+ this.items = {
24
+ add: (file: File) => {
25
+ files.push(file)
26
+ items.push({
27
+ kind: 'file',
28
+ type: file.type,
29
+ getAsFile: () => file,
30
+ } as DataTransferItem)
31
+ },
32
+ length: items.length,
33
+ } as DataTransferItemList
34
+
35
+ Object.defineProperty(this, 'files', {
36
+ get: () => {
37
+ const fileList = {
38
+ length: files.length,
39
+ item: (index: number) => files[index],
40
+ [Symbol.iterator]: function* () {
41
+ yield* files
42
+ },
43
+ }
44
+ files.forEach((file, index) => {
45
+ Object.defineProperty(fileList, index, {
46
+ value: file,
47
+ enumerable: true,
48
+ })
49
+ })
50
+ return fileList as FileList
51
+ },
52
+ })
53
+ }
54
+ }
55
+
56
+ global.DataTransfer = DataTransferPolyfill as any
57
+ }
58
+
59
+ // Polyfill for ResizeObserver
60
+ if (!global.ResizeObserver) {
61
+ class ResizeObserverPolyfill {
62
+ observe() {}
63
+ unobserve() {}
64
+ disconnect() {}
65
+ }
66
+
67
+ global.ResizeObserver = ResizeObserverPolyfill as any
68
+ }
69
+
70
+ const NOOP = () => {}
71
+
72
+ const getVisibleFilenameNode = (filename: string) =>
73
+ screen.queryByText(
74
+ (content, element) => content === filename && element?.getAttribute('data-pkt-truncate-part') === 'first',
75
+ )
76
+
77
+ const expectVisibleFilename = (filename: string) => {
78
+ expect(getVisibleFilenameNode(filename)).toBeInTheDocument()
79
+ }
80
+
81
+ const makeFilesPropWritable = (fileInput: HTMLInputElement) => {
82
+ // Make the files property settable in jsdom
83
+ let filesValue: FileList | null = null
84
+ Object.defineProperty(fileInput, 'files', {
85
+ get: () => filesValue,
86
+ set: (value: FileList | null) => {
87
+ filesValue = value
88
+ },
89
+ configurable: true,
90
+ })
91
+ }
92
+
93
+ expect.extend(toHaveNoViolations)
94
+
95
+ const createMockFile = (name: string, type = 'text/plain'): File => {
96
+ return new File(['content'], name, { type })
97
+ }
98
+
99
+ function createFileItem(name: string, fileId?: string) {
100
+ return new FileItem(createMockFile(name), fileId)
101
+ }
102
+
103
+ describe('PktFileUpload', () => {
104
+ describe('Rendering', () => {
105
+ it('should render the drop zone with default placeholder text for multiple files', () => {
106
+ render(<PktFileUpload multiple name={'pktFileUpload'} />)
107
+
108
+ expect(screen.getByText(/Dra filer hit for å laste dem opp eller/)).toBeInTheDocument()
109
+ expect(screen.getByText('velg filer')).toBeInTheDocument()
110
+ expect(screen.getByText(/Format: .PDF, .JPEG, .JPG, .PNG, .HEIC, .DOC, .DOCX, .ODT/)).toBeInTheDocument()
111
+ })
112
+
113
+ it('should render the drop zone with single file text when multiple is false', () => {
114
+ render(<PktFileUpload name={'pktFileUpload'} />)
115
+
116
+ expect(screen.getByText(/Dra en fil for å laste den opp eller/)).toBeInTheDocument()
117
+ expect(screen.getByText('velg en fil')).toBeInTheDocument()
118
+ })
119
+
120
+ it('should render the attachment icon', () => {
121
+ const { container } = render(<PktFileUpload name={'pktFileUpload'} />)
122
+
123
+ expect(container.querySelector('.pkt-fileupload__drop-zone__placeholder__icon')).toBeInTheDocument()
124
+ })
125
+
126
+ it('should render with initial value', () => {
127
+ const initialValue: TFileItemList = [createFileItem('test.pdf', '1')]
128
+
129
+ render(<PktFileUpload value={initialValue} name={'pktFileUpload'} onFilesChanged={NOOP} />)
130
+
131
+ expectVisibleFilename('test.pdf')
132
+ })
133
+
134
+ it('should render multiple files in queue display', () => {
135
+ const initialValue: TFileItemList = [
136
+ createFileItem('file1.pdf', '1'),
137
+ createFileItem('file2.docx', '2'),
138
+ createFileItem('file3.png', '3'),
139
+ ]
140
+
141
+ render(<PktFileUpload value={initialValue} multiple name={'pktFileUpload'} onFilesChanged={NOOP} />)
142
+
143
+ expectVisibleFilename('file1.pdf')
144
+ expectVisibleFilename('file2.docx')
145
+ expectVisibleFilename('file3.png')
146
+ })
147
+ })
148
+
149
+ describe('File selection via dialog', () => {
150
+ it('should trigger file input when clicking "velg filer" button', async () => {
151
+ const user = userEvent.setup()
152
+ render(<PktFileUpload multiple name="pktFileUpload" />)
153
+
154
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
155
+ const clickSpy = vi.spyOn(fileInput, 'click')
156
+
157
+ const button = screen.getByRole('button', { name: /velg filer/i })
158
+ await user.click(button)
159
+
160
+ expect(clickSpy).toHaveBeenCalled()
161
+ })
162
+
163
+ it('should trigger file input when clicking the dropzone', async () => {
164
+ const user = userEvent.setup()
165
+ const { container } = render(<PktFileUpload name="pktFileUpload" />)
166
+
167
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
168
+ const clickSpy = vi.spyOn(fileInput, 'click')
169
+
170
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone')!
171
+ await user.click(dropZone)
172
+
173
+ expect(clickSpy).toHaveBeenCalled()
174
+ })
175
+
176
+ it('should call onFilesChanged when files are selected via dialog', () => {
177
+ const onFilesChanged = vi.fn()
178
+ render(<PktFileUpload onFilesChanged={onFilesChanged} multiple name={'pktFileUpload'} />)
179
+
180
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
181
+ const file1 = createMockFile('test1.pdf')
182
+ const file2 = createMockFile('test2.pdf')
183
+
184
+ fireEvent.change(fileInput, { target: { files: [file1, file2] } })
185
+
186
+ expect(onFilesChanged).toHaveBeenCalledTimes(1)
187
+ const calledWith = onFilesChanged.mock.calls[0][0]
188
+ expect(calledWith).toHaveLength(2)
189
+ expect(calledWith[0].file).toBe(file1)
190
+ expect(calledWith[1].file).toBe(file2)
191
+ })
192
+
193
+ it('should assign unique IDs to added files', () => {
194
+ const onFilesChanged = vi.fn()
195
+ render(<PktFileUpload onFilesChanged={onFilesChanged} multiple name={'pktFileUpload'} />)
196
+
197
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
198
+ const file1 = createMockFile('test1.pdf')
199
+ const file2 = createMockFile('test2.pdf')
200
+
201
+ fireEvent.change(fileInput, { target: { files: [file1, file2] } })
202
+
203
+ const calledWith = onFilesChanged.mock.calls[0][0]
204
+ expect(calledWith[0].fileId).toBeTruthy()
205
+ expect(calledWith[1].fileId).toBeTruthy()
206
+ expect(calledWith[0].fileId).not.toBe(calledWith[1].fileId)
207
+ })
208
+
209
+ it('should only keep one file when multiple is false', () => {
210
+ const onFilesChanged = vi.fn()
211
+ render(<PktFileUpload onFilesChanged={onFilesChanged} name={'pktFileUpload'} />)
212
+
213
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
214
+ const file1 = createMockFile('test1.pdf')
215
+ const file2 = createMockFile('test2.pdf')
216
+
217
+ fireEvent.change(fileInput, { target: { files: [file1, file2] } })
218
+
219
+ const calledWith = onFilesChanged.mock.calls[0][0]
220
+ expect(calledWith).toHaveLength(1)
221
+ expect(calledWith[0].file).toBe(file1)
222
+ })
223
+
224
+ it('should clear file input value after files are selected', () => {
225
+ const onFilesChanged = vi.fn()
226
+ render(<PktFileUpload onFilesChanged={onFilesChanged} multiple name={'pktFileUpload'} />)
227
+
228
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
229
+ const file = createMockFile('test.pdf')
230
+
231
+ fireEvent.change(fileInput, { target: { files: [file] } })
232
+
233
+ expect(fileInput.value).toBe('')
234
+ })
235
+ })
236
+
237
+ describe('Drag and drop functionality', () => {
238
+ it('should show active state when dragging over drop zone', () => {
239
+ const { container } = render(<PktFileUpload multiple name={'pktFileUpload'} />)
240
+
241
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
242
+
243
+ fireEvent.dragOver(dropZone)
244
+
245
+ expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active')
246
+ expect(screen.getByText(/Slipp filene her/)).toBeInTheDocument()
247
+ })
248
+
249
+ it('should show active state text for single file mode when dragging', () => {
250
+ const { container } = render(<PktFileUpload name={'pktFileUpload'} />)
251
+
252
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
253
+
254
+ fireEvent.dragOver(dropZone)
255
+
256
+ expect(screen.getByText(/Slipp filen her/)).toBeInTheDocument()
257
+ })
258
+
259
+ it('should remove active state when drag leaves', () => {
260
+ const { container } = render(<PktFileUpload multiple name={'pktFileUpload'} />)
261
+
262
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
263
+
264
+ fireEvent.dragOver(dropZone)
265
+ expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active')
266
+
267
+ fireEvent.dragLeave(dropZone)
268
+ expect(dropZone).not.toHaveClass('pkt-fileupload__drop-zone--drag-active')
269
+ })
270
+
271
+ it('should call onFilesChanged when files are dropped', () => {
272
+ const onFilesChanged = vi.fn()
273
+ const { container } = render(<PktFileUpload onFilesChanged={onFilesChanged} multiple name={'pktFileUpload'} />)
274
+
275
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
276
+ const file1 = createMockFile('dropped1.pdf')
277
+ const file2 = createMockFile('dropped2.pdf')
278
+
279
+ fireEvent.drop(dropZone, {
280
+ dataTransfer: {
281
+ files: [file1, file2],
282
+ },
283
+ })
284
+
285
+ expect(onFilesChanged).toHaveBeenCalledTimes(1)
286
+ const calledWith = onFilesChanged.mock.calls[0][0]
287
+ expect(calledWith).toHaveLength(2)
288
+ expect(calledWith[0].file).toBe(file1)
289
+ expect(calledWith[1].file).toBe(file2)
290
+ })
291
+
292
+ it('should remove active state after dropping files', () => {
293
+ const { container } = render(<PktFileUpload multiple name={'pktFileUpload'} />)
294
+
295
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
296
+ const file = createMockFile('dropped.pdf')
297
+
298
+ fireEvent.dragOver(dropZone)
299
+ expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active')
300
+
301
+ fireEvent.drop(dropZone, {
302
+ dataTransfer: {
303
+ files: [file],
304
+ },
305
+ })
306
+
307
+ expect(dropZone).not.toHaveClass('pkt-fileupload__drop-zone--drag-active')
308
+ })
309
+
310
+ it('should only keep one file when dropping in single file mode', () => {
311
+ const onFilesChanged = vi.fn()
312
+ const { container } = render(<PktFileUpload onFilesChanged={onFilesChanged} name={'pktFileUpload'} />)
313
+
314
+ const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
315
+ const file1 = createMockFile('dropped1.pdf')
316
+ const file2 = createMockFile('dropped2.pdf')
317
+
318
+ fireEvent.drop(dropZone, {
319
+ dataTransfer: {
320
+ files: [file1, file2],
321
+ },
322
+ })
323
+
324
+ const calledWith = onFilesChanged.mock.calls[0][0]
325
+ expect(calledWith).toHaveLength(1)
326
+ expect(calledWith[0].file).toBe(file1)
327
+ })
328
+ })
329
+
330
+ describe('Multiple file mode', () => {
331
+ it('should append new files to existing files when multiple is true', () => {
332
+ const onFilesChanged = vi.fn()
333
+ const initialValue: TFileItemList = [createFileItem('existing.pdf', '1')]
334
+
335
+ render(<PktFileUpload value={initialValue} onFilesChanged={onFilesChanged} multiple name={'pktFileUpload'} />)
336
+
337
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
338
+ const newFile = createMockFile('new.pdf')
339
+
340
+ fireEvent.change(fileInput, { target: { files: [newFile] } })
341
+
342
+ const calledWith = onFilesChanged.mock.calls[0][0]
343
+ expect(calledWith).toHaveLength(2)
344
+ expect(calledWith[0].file.name).toBe('existing.pdf')
345
+ expect(calledWith[1].file.name).toBe('new.pdf')
346
+ })
347
+
348
+ it('should set file input multiple attribute when multiple is true', () => {
349
+ render(<PktFileUpload multiple name={'pktFileUpload'} />)
350
+
351
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
352
+ expect(fileInput).toHaveAttribute('multiple')
353
+ })
354
+
355
+ it('should not set file input multiple attribute when multiple is false', () => {
356
+ render(<PktFileUpload name={'pktFileUpload'} />)
357
+
358
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
359
+ expect(fileInput).not.toHaveAttribute('multiple')
360
+ })
361
+ })
362
+
363
+ describe('Controlled component behavior', () => {
364
+ it('should update display when value prop changes', () => {
365
+ const initialValue: TFileItemList = [createFileItem('file1.pdf', '1')]
366
+
367
+ const { rerender } = render(<PktFileUpload value={initialValue} name={'pktFileUpload'} onFilesChanged={NOOP} />)
368
+
369
+ expectVisibleFilename('file1.pdf')
370
+
371
+ const updatedValue: TFileItemList = [createFileItem('file2.pdf', '2')]
372
+
373
+ rerender(<PktFileUpload value={updatedValue} name={'pktFileUpload'} onFilesChanged={NOOP} />)
374
+
375
+ expect(getVisibleFilenameNode('file1.pdf')).not.toBeInTheDocument()
376
+ expectVisibleFilename('file2.pdf')
377
+ })
378
+
379
+ it('should handle empty value prop', () => {
380
+ render(<PktFileUpload value={[]} name={'pktFileUpload'} onFilesChanged={NOOP} />)
381
+
382
+ expect(screen.queryByText(/.pdf/)).not.toBeInTheDocument()
383
+ })
384
+
385
+ it('should work as uncontrolled component without value prop', () => {
386
+ const onFilesChanged = vi.fn()
387
+ render(<PktFileUpload onFilesChanged={onFilesChanged} multiple name={'pktFileUpload'} />)
388
+
389
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
390
+ const file = createMockFile('test.pdf')
391
+
392
+ fireEvent.change(fileInput, { target: { files: [file] } })
393
+
394
+ expect(onFilesChanged).toHaveBeenCalledTimes(1)
395
+ })
396
+ })
397
+
398
+ describe('File removal', () => {
399
+ it('should render remove buttons for each file', () => {
400
+ const initialValue: TFileItemList = [createFileItem('file1.pdf', '1'), createFileItem('file2.pdf', '2')]
401
+
402
+ render(<PktFileUpload value={initialValue} name={'pktFileUpload'} onFilesChanged={NOOP} />)
403
+
404
+ const removeButtons = screen.getAllByRole('button', { name: /Slett/ })
405
+ expect(removeButtons).toHaveLength(2)
406
+ })
407
+
408
+ it.skip('should show close-circle icon on remove buttons', () => {
409
+ const initialValue: TFileItemList = [createFileItem('file1.pdf', '1')]
410
+
411
+ const { container } = render(<PktFileUpload value={initialValue} name={'pktFileUpload'} />)
412
+
413
+ const closeIcon = container.querySelector('pkt-icon[name="close-circle"]')
414
+ expect(closeIcon).toBeInTheDocument()
415
+ })
416
+ })
417
+
418
+ describe('Accessibility', () => {
419
+ it('should have no accessibility violations', async () => {
420
+ const { container } = render(<PktFileUpload multiple name={'pktFileUpload'} />)
421
+ // Note: The hidden file input doesn't have a label, but it's intentionally hidden
422
+ // and the visible UI provides the accessible interaction
423
+ const results = await axe(container, {
424
+ rules: {
425
+ label: { enabled: false },
426
+ },
427
+ })
428
+
429
+ expect(results).toHaveNoViolations()
430
+ })
431
+
432
+ it('should have no accessibility violations with files in queue', async () => {
433
+ const initialValue: TFileItemList = [createFileItem('file1.pdf', '1'), createFileItem('file2.pdf', '2')]
434
+
435
+ const { container } = render(
436
+ <PktFileUpload value={initialValue} multiple name={'pktFileUpload'} onFilesChanged={NOOP} />,
437
+ )
438
+ // Note: The hidden file input doesn't have a label, but it's intentionally hidden
439
+ // and the visible UI provides the accessible interaction
440
+ const results = await axe(container, {
441
+ rules: {
442
+ label: { enabled: false },
443
+ },
444
+ })
445
+
446
+ expect(results).toHaveNoViolations()
447
+ })
448
+ })
449
+
450
+ describe('Form submission values', () => {
451
+ it('should populate file input with selected files for form submission', () => {
452
+ const TestFormComponent = () => {
453
+ const [files, setFiles] = useState<TFileItemList>([])
454
+ return (
455
+ <form>
456
+ <PktFileUpload
457
+ multiple
458
+ name={'pktFileUpload'}
459
+ uploadStrategy="form"
460
+ value={files}
461
+ onFilesChanged={setFiles}
462
+ />
463
+ </form>
464
+ )
465
+ }
466
+
467
+ const { container } = render(<TestFormComponent />)
468
+
469
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
470
+ makeFilesPropWritable(fileInput)
471
+
472
+ const file1 = createMockFile('file1.pdf')
473
+ const file2 = createMockFile('file2.pdf')
474
+
475
+ // Simulate file selection
476
+ fireEvent.change(fileInput, { target: { files: [file1, file2] } })
477
+
478
+ // Verify that the file input contains the correct files that will be submitted
479
+ expect(fileInput.files).not.toBeNull()
480
+ expect(fileInput.files!.length).toBe(2)
481
+ expect(fileInput.files![0].name).toBe('file1.pdf')
482
+ expect(fileInput.files![1].name).toBe('file2.pdf')
483
+ expect(fileInput).toHaveAttribute('name', 'pktFileUpload')
484
+ })
485
+
486
+ it('should populate file input with single file for form submission', () => {
487
+ const TestFormComponent = () => {
488
+ const [files, setFiles] = useState<TFileItemList>([])
489
+ return (
490
+ <form>
491
+ <PktFileUpload name={'pktFileUpload'} uploadStrategy="form" value={files} onFilesChanged={setFiles} />
492
+ </form>
493
+ )
494
+ }
495
+
496
+ const { container } = render(<TestFormComponent />)
497
+
498
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
499
+ makeFilesPropWritable(fileInput)
500
+
501
+ const file = createMockFile('single-file.pdf')
502
+
503
+ // Simulate file selection
504
+ fireEvent.change(fileInput, { target: { files: [file] } })
505
+
506
+ // Verify that the file input contains the correct file that will be submitted
507
+ expect(fileInput.files).not.toBeNull()
508
+ expect(fileInput.files!.length).toBe(1)
509
+ expect(fileInput.files![0].name).toBe('single-file.pdf')
510
+ expect(fileInput).toHaveAttribute('name', 'pktFileUpload')
511
+ })
512
+
513
+ it('should include file IDs in form FormData with custom upload strategy', () => {
514
+ const initialValue: TFileItemList = [
515
+ createFileItem('file1.pdf', 'custom-id-1'),
516
+ createFileItem('file2.pdf', 'custom-id-2'),
517
+ ]
518
+
519
+ const { container } = render(
520
+ <form>
521
+ <PktFileUpload
522
+ value={initialValue}
523
+ name={'pktFileUpload'}
524
+ uploadStrategy="custom"
525
+ id="test-upload"
526
+ onFileUploadRequested={vi.fn()}
527
+ transfers={[]}
528
+ onFilesChanged={NOOP}
529
+ />
530
+ </form>,
531
+ )
532
+
533
+ const form = container.querySelector('form') as HTMLFormElement
534
+ const formData = new FormData(form)
535
+
536
+ const fileIds = formData.getAll('pktFileUpload')
537
+ expect(fileIds).toHaveLength(2)
538
+ expect(fileIds[0]).toBe('custom-id-1')
539
+ expect(fileIds[1]).toBe('custom-id-2')
540
+ })
541
+
542
+ it('should call onFileUploadRequested when files are selected with custom upload strategy', () => {
543
+ const onFileUploadRequested = vi.fn()
544
+ const TestFormComponent = () => {
545
+ const [files, setFiles] = useState<TFileItemList>([])
546
+ return (
547
+ <form>
548
+ <PktFileUpload
549
+ id={'pktFileUploadId'}
550
+ multiple
551
+ name={'pktFileUpload'}
552
+ uploadStrategy="custom"
553
+ value={files}
554
+ onFilesChanged={setFiles}
555
+ onFileUploadRequested={onFileUploadRequested}
556
+ transfers={[]}
557
+ />
558
+ </form>
559
+ )
560
+ }
561
+
562
+ const { container } = render(<TestFormComponent />)
563
+
564
+ const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
565
+ const file1 = createMockFile('upload-test.pdf')
566
+ const file2 = createMockFile('upload-test2.pdf')
567
+
568
+ // Simulate file selection
569
+ fireEvent.change(fileInput, { target: { files: [file1, file2] } })
570
+
571
+ // Verify that onFileUploadRequested was called for each file
572
+ expect(onFileUploadRequested).toHaveBeenCalledTimes(2)
573
+
574
+ // Check that it was called with FileItem objects containing the correct files
575
+ const firstCall = onFileUploadRequested.mock.calls[0][0]
576
+ const secondCall = onFileUploadRequested.mock.calls[1][0]
577
+
578
+ expect(firstCall.file).toBe(file1)
579
+ expect(firstCall.fileId).toBeTruthy()
580
+ expect(secondCall.file).toBe(file2)
581
+ expect(secondCall.fileId).toBeTruthy()
582
+ })
583
+ })
584
+ })