import '@testing-library/jest-dom' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe, toHaveNoViolations } from 'jest-axe' import { useState } from 'react' import { vi } from 'vitest' import { PktFileUpload } from './FileUpload' import { FileItem, TFileItemList } from './types' // Polyfill DataTransfer for jsdom if (!global.DataTransfer) { class DataTransferPolyfill { items: DataTransferItemList // @ts-ignore files: FileList constructor() { const files: File[] = [] const items: DataTransferItem[] = [] this.items = { add: (file: File) => { files.push(file) items.push({ kind: 'file', type: file.type, getAsFile: () => file, } as DataTransferItem) }, length: items.length, } as DataTransferItemList Object.defineProperty(this, 'files', { get: () => { const fileList = { length: files.length, item: (index: number) => files[index], [Symbol.iterator]: function* () { yield* files }, } files.forEach((file, index) => { Object.defineProperty(fileList, index, { value: file, enumerable: true, }) }) return fileList as FileList }, }) } } global.DataTransfer = DataTransferPolyfill as any } // Polyfill for ResizeObserver if (!global.ResizeObserver) { class ResizeObserverPolyfill { observe() {} unobserve() {} disconnect() {} } global.ResizeObserver = ResizeObserverPolyfill as any } const NOOP = () => {} const getVisibleFilenameNode = (filename: string) => screen.queryByText( (content, element) => content === filename && element?.getAttribute('data-pkt-truncate-part') === 'first', ) const expectVisibleFilename = (filename: string) => { expect(getVisibleFilenameNode(filename)).toBeInTheDocument() } const makeFilesPropWritable = (fileInput: HTMLInputElement) => { // Make the files property settable in jsdom let filesValue: FileList | null = null Object.defineProperty(fileInput, 'files', { get: () => filesValue, set: (value: FileList | null) => { filesValue = value }, configurable: true, }) } expect.extend(toHaveNoViolations) const createMockFile = (name: string, type = 'text/plain'): File => { return new File(['content'], name, { type }) } function createFileItem(name: string, fileId?: string) { return new FileItem(createMockFile(name), fileId) } describe('PktFileUpload', () => { describe('Rendering', () => { it('should render the drop zone with default placeholder text for multiple files', () => { render() expect(screen.getByText(/Dra filer hit for å laste dem opp eller/)).toBeInTheDocument() expect(screen.getByText('velg filer')).toBeInTheDocument() expect(screen.getByText(/Format: .PDF, .JPEG, .JPG, .PNG, .HEIC, .DOC, .DOCX, .ODT/)).toBeInTheDocument() }) it('should render the drop zone with single file text when multiple is false', () => { render() expect(screen.getByText(/Dra en fil for å laste den opp eller/)).toBeInTheDocument() expect(screen.getByText('velg en fil')).toBeInTheDocument() }) it('should render the attachment icon', () => { const { container } = render() expect(container.querySelector('.pkt-fileupload__drop-zone__placeholder__icon')).toBeInTheDocument() }) it('should render with initial value', () => { const initialValue: TFileItemList = [createFileItem('test.pdf', '1')] render() expectVisibleFilename('test.pdf') }) it('should render multiple files in queue display', () => { const initialValue: TFileItemList = [ createFileItem('file1.pdf', '1'), createFileItem('file2.docx', '2'), createFileItem('file3.png', '3'), ] render() expectVisibleFilename('file1.pdf') expectVisibleFilename('file2.docx') expectVisibleFilename('file3.png') }) }) describe('File selection via dialog', () => { it('should trigger file input when clicking "velg filer" button', async () => { const user = userEvent.setup() render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') const button = screen.getByRole('button', { name: /velg filer/i }) await user.click(button) expect(clickSpy).toHaveBeenCalled() }) it('should trigger file input when clicking the dropzone', async () => { const user = userEvent.setup() const { container } = render() const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') const dropZone = container.querySelector('.pkt-fileupload__drop-zone')! await user.click(dropZone) expect(clickSpy).toHaveBeenCalled() }) it('should call onFilesChanged when files are selected via dialog', () => { const onFilesChanged = vi.fn() render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const file1 = createMockFile('test1.pdf') const file2 = createMockFile('test2.pdf') fireEvent.change(fileInput, { target: { files: [file1, file2] } }) expect(onFilesChanged).toHaveBeenCalledTimes(1) const calledWith = onFilesChanged.mock.calls[0][0] expect(calledWith).toHaveLength(2) expect(calledWith[0].file).toBe(file1) expect(calledWith[1].file).toBe(file2) }) it('should assign unique IDs to added files', () => { const onFilesChanged = vi.fn() render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const file1 = createMockFile('test1.pdf') const file2 = createMockFile('test2.pdf') fireEvent.change(fileInput, { target: { files: [file1, file2] } }) const calledWith = onFilesChanged.mock.calls[0][0] expect(calledWith[0].fileId).toBeTruthy() expect(calledWith[1].fileId).toBeTruthy() expect(calledWith[0].fileId).not.toBe(calledWith[1].fileId) }) it('should only keep one file when multiple is false', () => { const onFilesChanged = vi.fn() render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const file1 = createMockFile('test1.pdf') const file2 = createMockFile('test2.pdf') fireEvent.change(fileInput, { target: { files: [file1, file2] } }) const calledWith = onFilesChanged.mock.calls[0][0] expect(calledWith).toHaveLength(1) expect(calledWith[0].file).toBe(file1) }) it('should clear file input value after files are selected', () => { const onFilesChanged = vi.fn() render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const file = createMockFile('test.pdf') fireEvent.change(fileInput, { target: { files: [file] } }) expect(fileInput.value).toBe('') }) }) describe('Drag and drop functionality', () => { it('should show active state when dragging over drop zone', () => { const { container } = render() const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement fireEvent.dragOver(dropZone) expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active') expect(screen.getByText(/Slipp filene her/)).toBeInTheDocument() }) it('should show active state text for single file mode when dragging', () => { const { container } = render() const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement fireEvent.dragOver(dropZone) expect(screen.getByText(/Slipp filen her/)).toBeInTheDocument() }) it('should remove active state when drag leaves', () => { const { container } = render() const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement fireEvent.dragOver(dropZone) expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active') fireEvent.dragLeave(dropZone) expect(dropZone).not.toHaveClass('pkt-fileupload__drop-zone--drag-active') }) it('should call onFilesChanged when files are dropped', () => { const onFilesChanged = vi.fn() const { container } = render() const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement const file1 = createMockFile('dropped1.pdf') const file2 = createMockFile('dropped2.pdf') fireEvent.drop(dropZone, { dataTransfer: { files: [file1, file2], }, }) expect(onFilesChanged).toHaveBeenCalledTimes(1) const calledWith = onFilesChanged.mock.calls[0][0] expect(calledWith).toHaveLength(2) expect(calledWith[0].file).toBe(file1) expect(calledWith[1].file).toBe(file2) }) it('should remove active state after dropping files', () => { const { container } = render() const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement const file = createMockFile('dropped.pdf') fireEvent.dragOver(dropZone) expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active') fireEvent.drop(dropZone, { dataTransfer: { files: [file], }, }) expect(dropZone).not.toHaveClass('pkt-fileupload__drop-zone--drag-active') }) it('should only keep one file when dropping in single file mode', () => { const onFilesChanged = vi.fn() const { container } = render() const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement const file1 = createMockFile('dropped1.pdf') const file2 = createMockFile('dropped2.pdf') fireEvent.drop(dropZone, { dataTransfer: { files: [file1, file2], }, }) const calledWith = onFilesChanged.mock.calls[0][0] expect(calledWith).toHaveLength(1) expect(calledWith[0].file).toBe(file1) }) }) describe('Multiple file mode', () => { it('should append new files to existing files when multiple is true', () => { const onFilesChanged = vi.fn() const initialValue: TFileItemList = [createFileItem('existing.pdf', '1')] render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const newFile = createMockFile('new.pdf') fireEvent.change(fileInput, { target: { files: [newFile] } }) const calledWith = onFilesChanged.mock.calls[0][0] expect(calledWith).toHaveLength(2) expect(calledWith[0].file.name).toBe('existing.pdf') expect(calledWith[1].file.name).toBe('new.pdf') }) it('should set file input multiple attribute when multiple is true', () => { render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement expect(fileInput).toHaveAttribute('multiple') }) it('should not set file input multiple attribute when multiple is false', () => { render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement expect(fileInput).not.toHaveAttribute('multiple') }) }) describe('Controlled component behavior', () => { it('should update display when value prop changes', () => { const initialValue: TFileItemList = [createFileItem('file1.pdf', '1')] const { rerender } = render() expectVisibleFilename('file1.pdf') const updatedValue: TFileItemList = [createFileItem('file2.pdf', '2')] rerender() expect(getVisibleFilenameNode('file1.pdf')).not.toBeInTheDocument() expectVisibleFilename('file2.pdf') }) it('should handle empty value prop', () => { render() expect(screen.queryByText(/.pdf/)).not.toBeInTheDocument() }) it('should work as uncontrolled component without value prop', () => { const onFilesChanged = vi.fn() render() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const file = createMockFile('test.pdf') fireEvent.change(fileInput, { target: { files: [file] } }) expect(onFilesChanged).toHaveBeenCalledTimes(1) }) }) describe('File removal', () => { it('should render remove buttons for each file', () => { const initialValue: TFileItemList = [createFileItem('file1.pdf', '1'), createFileItem('file2.pdf', '2')] render() const removeButtons = screen.getAllByRole('button', { name: /Slett/ }) expect(removeButtons).toHaveLength(2) }) it.skip('should show close-circle icon on remove buttons', () => { const initialValue: TFileItemList = [createFileItem('file1.pdf', '1')] const { container } = render() const closeIcon = container.querySelector('pkt-icon[name="close-circle"]') expect(closeIcon).toBeInTheDocument() }) }) describe('Accessibility', () => { it('keeps label and input associations isolated across multiple instances without explicit ids', () => { render( <> , ) const firstInput = screen.getByLabelText('Første vedlegg') as HTMLInputElement const secondInput = screen.getByLabelText('Andre vedlegg') as HTMLInputElement expect(firstInput.id).toBeTruthy() expect(secondInput.id).toBeTruthy() expect(firstInput.id).not.toBe(secondInput.id) expect(firstInput).toHaveAttribute('aria-describedby', `${firstInput.id}-helptext`) expect(secondInput).toHaveAttribute('aria-describedby', `${secondInput.id}-helptext`) expect(document.getElementById(`${firstInput.id}-helptext`)).toHaveTextContent('Hjelpetekst A') expect(document.getElementById(`${secondInput.id}-helptext`)).toHaveTextContent('Hjelpetekst B') }) it('associates the label with the native file input and wires help text + error message ids', () => { render( 'Ugyldig fil.'} />, ) const fileInput = screen.getByLabelText('Last opp vedlegg') as HTMLInputElement const file = createMockFile('test.pdf') fireEvent.change(fileInput, { target: { files: [file] } }) expect(fileInput).toHaveAttribute('id', 'test-upload') expect(fileInput).toHaveAttribute('aria-invalid', 'true') expect(fileInput).toHaveAttribute('aria-describedby', 'test-upload-helptext test-upload-error') expect(screen.getByText('Ugyldig fil.')).toBeInTheDocument() }) it('applies native required only for form strategy', () => { const { rerender } = render( , ) let fileInput = screen.getByLabelText('Vedlegg') as HTMLInputElement expect(fileInput).toHaveAttribute('required') expect(fileInput).toHaveAttribute('aria-required', 'true') rerender( , ) fileInput = screen.getByLabelText('Vedlegg') as HTMLInputElement expect(fileInput).not.toHaveAttribute('required') expect(fileInput).toHaveAttribute('aria-required', 'true') }) it('should have no accessibility violations', async () => { const { container } = render() // Note: The hidden file input doesn't have a label, but it's intentionally hidden // and the visible UI provides the accessible interaction const results = await axe(container, { rules: { label: { enabled: false }, }, }) expect(results).toHaveNoViolations() }) it('should have no accessibility violations with files in queue', async () => { const initialValue: TFileItemList = [createFileItem('file1.pdf', '1'), createFileItem('file2.pdf', '2')] const { container } = render( , ) // Note: The hidden file input doesn't have a label, but it's intentionally hidden // and the visible UI provides the accessible interaction const results = await axe(container, { rules: { label: { enabled: false }, }, }) expect(results).toHaveNoViolations() }) }) describe('Form submission values', () => { it('blocks custom strategy submit when required and no files are selected', () => { const { container } = render(
, ) const form = container.querySelector('form') as HTMLFormElement let submitWasPrevented = false form.addEventListener('submit', (event) => { submitWasPrevented = event.defaultPrevented }) fireEvent.submit(form) const fileInput = screen.getByLabelText('Last opp vedlegg') as HTMLInputElement expect(submitWasPrevented).toBe(true) expect(fileInput).toHaveAttribute('aria-invalid', 'true') expect(fileInput).toHaveAttribute('aria-describedby', 'custom-required-upload-error') expect(screen.getByText('Velg minst én fil før du sender inn skjemaet.')).toBeInTheDocument() }) it('should populate file input with selected files for form submission', () => { const TestFormComponent = () => { const [files, setFiles] = useState([]) return (
) } const { container } = render() const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement makeFilesPropWritable(fileInput) const file1 = createMockFile('file1.pdf') const file2 = createMockFile('file2.pdf') // Simulate file selection fireEvent.change(fileInput, { target: { files: [file1, file2] } }) // Verify that the file input contains the correct files that will be submitted expect(fileInput.files).not.toBeNull() expect(fileInput.files!.length).toBe(2) expect(fileInput.files![0].name).toBe('file1.pdf') expect(fileInput.files![1].name).toBe('file2.pdf') expect(fileInput).toHaveAttribute('name', 'pktFileUpload') }) it('should populate file input with single file for form submission', () => { const TestFormComponent = () => { const [files, setFiles] = useState([]) return (
) } const { container } = render() const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement makeFilesPropWritable(fileInput) const file = createMockFile('single-file.pdf') // Simulate file selection fireEvent.change(fileInput, { target: { files: [file] } }) // Verify that the file input contains the correct file that will be submitted expect(fileInput.files).not.toBeNull() expect(fileInput.files!.length).toBe(1) expect(fileInput.files![0].name).toBe('single-file.pdf') expect(fileInput).toHaveAttribute('name', 'pktFileUpload') }) it('should include file IDs in form FormData with custom upload strategy', () => { const initialValue: TFileItemList = [ createFileItem('file1.pdf', 'custom-id-1'), createFileItem('file2.pdf', 'custom-id-2'), ] const { container } = render(
, ) const form = container.querySelector('form') as HTMLFormElement const formData = new FormData(form) const fileIds = formData.getAll('pktFileUpload') expect(fileIds).toHaveLength(2) expect(fileIds[0]).toBe('custom-id-1') expect(fileIds[1]).toBe('custom-id-2') }) it('should call onFileUploadRequested when files are selected with custom upload strategy', () => { const onFileUploadRequested = vi.fn() const TestFormComponent = () => { const [files, setFiles] = useState([]) return (
) } const { container } = render() const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const file1 = createMockFile('upload-test.pdf') const file2 = createMockFile('upload-test2.pdf') // Simulate file selection fireEvent.change(fileInput, { target: { files: [file1, file2] } }) // Verify that onFileUploadRequested was called for each file expect(onFileUploadRequested).toHaveBeenCalledTimes(2) // Check that it was called with FileItem objects containing the correct files const firstCall = onFileUploadRequested.mock.calls[0][0] const secondCall = onFileUploadRequested.mock.calls[1][0] expect(firstCall.file).toBe(file1) expect(firstCall.fileId).toBeTruthy() expect(secondCall.file).toBe(file2) expect(secondCall.fileId).toBeTruthy() }) }) })