Skip to content

Commit

Permalink
feat(upload)!: replace element properties (#794)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `init` parameter has been removed from `userEvent.upload`.
  • Loading branch information
ph-fritsche committed Nov 25, 2021
1 parent df75e5f commit 4873895
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 60 deletions.
4 changes: 1 addition & 3 deletions src/setup/directApi.ts
@@ -1,5 +1,4 @@
import type {PointerOptions} from '../utils'
import type {uploadInit} from '../utility'
import type {PointerInput} from '../pointer'
import type {UserEventApi} from '.'
import {setupDirect} from './setup'
Expand Down Expand Up @@ -91,10 +90,9 @@ export function unhover(element: Element, options: PointerOptions = {}) {
export function upload(
element: HTMLElement,
fileOrFiles: File | File[],
init?: uploadInit,
options: Partial<Config> = {},
) {
return setupDirect(options).upload(element, fileOrFiles, init)
return setupDirect(options).upload(element, fileOrFiles)
}

export function tab(
Expand Down
43 changes: 7 additions & 36 deletions src/utility/upload.ts
@@ -1,6 +1,7 @@
import {fireEvent, createEvent} from '@testing-library/dom'
import {blur, focus, isDisabled, isElementType} from '../utils'
import {fireEvent} from '@testing-library/dom'
import {blur, createFileList, focus, isDisabled, isElementType} from '../utils'
import {Config, UserEvent} from '../setup'
import {setFiles} from '#src/utils/edit/setFiles'

export interface uploadInit {
changeInit?: EventInit
Expand All @@ -10,11 +11,10 @@ export async function upload(
this: UserEvent,
element: HTMLElement,
fileOrFiles: File | File[],
init?: uploadInit,
) {
const input = isElementType(element, 'label') ? element.control : element

if (!input || !isElementType(input, 'input', {type: 'file'})) {
if (!input || !isElementType(input, 'input', {type: 'file' as const})) {
throw new TypeError(
`The ${input === element ? 'given' : 'associated'} ${
input?.tagName
Expand Down Expand Up @@ -44,38 +44,9 @@ export async function upload(
return
}

// the event fired in the browser isn't actually an "input" or "change" event
// but a new Event with a type set to "input" and "change"
// Kinda odd...
const inputFiles: FileList & Iterable<File> = {
...files,
length: files.length,
item: (index: number) => files[index],
[Symbol.iterator]() {
let i = 0
return {
next: () => ({
done: i >= files.length,
value: files[i++],
}),
}
},
}

fireEvent(
input,
createEvent('input', input, {
target: {files: inputFiles},
bubbles: true,
cancelable: false,
composed: true,
}),
)

fireEvent.change(input, {
target: {files: inputFiles},
...init?.changeInit,
})
setFiles(input, createFileList(files))
fireEvent.input(input)
fireEvent.change(input)
}

function isAcceptableFile(file: File, accept: string) {
Expand Down
18 changes: 14 additions & 4 deletions src/utils/dataTransfer/FileList.ts
@@ -1,9 +1,19 @@
// FileList can not be created per constructor.

export function createFileList(files: File[]): FileList {
const f = [...files]
const list: FileList & Iterable<File> = {
...files,
length: files.length,
item: (index: number) => list[index],
[Symbol.iterator]: function* nextFile() {
for (let i = 0; i < list.length; i++) {
yield list[i]
}
},
}
list.constructor = FileList
Object.setPrototypeOf(list, FileList.prototype)
Object.freeze(list)

Object.setPrototypeOf(f, FileList.prototype)

return f as unknown as FileList
return list
}
77 changes: 77 additions & 0 deletions src/utils/edit/setFiles.ts
@@ -0,0 +1,77 @@
// It is not possible to create a real FileList programmatically.
// Therefore assigning `files` property with a programmatically created FileList results in an error.
// Just assigning the property (as per fireEvent) breaks the interweaving with the `value` property.

const fakeFiles = Symbol('files and value properties are mocked')

declare global {
interface HTMLInputElement {
[fakeFiles]?: {
restore: () => void
}
}
}

export function setFiles(
el: HTMLInputElement & {type: 'file'},
files: FileList,
) {
el[fakeFiles]?.restore()

const objectDescriptors = Object.getOwnPropertyDescriptors(el)
const prototypeDescriptors = Object.getOwnPropertyDescriptors(
Object.getPrototypeOf(el),
)

function restore() {
Object.defineProperties(el, {
files: {
...prototypeDescriptors.files,
...objectDescriptors.files,
},
value: {
...prototypeDescriptors.value,
...objectDescriptors.value,
},
type: {
...prototypeDescriptors.type,
...objectDescriptors.type,
},
})
}
el[fakeFiles] = {restore}

Object.defineProperties(el, {
files: {
...prototypeDescriptors.files,
...objectDescriptors.files,
get: () => files,
},
value: {
...prototypeDescriptors.value,
...objectDescriptors.value,
get: () => (files.length ? `C:\\fakepath\\${files[0].name}` : ''),
set(v: string) {
if (v === '') {
restore()
} else {
objectDescriptors.value.set?.call(el, v)
}
},
},
// eslint-disable-next-line accessor-pairs
type: {
...prototypeDescriptors.type,
...objectDescriptors.type,
set(v: string) {
if (v !== 'file') {
restore()
// In the browser the value will be empty.
// In Jsdom the value will be the same as
// before this element became file input - which might be empty.
;(el as HTMLInputElement).type = v
}
},
},
})
}
1 change: 1 addition & 0 deletions tests/setup.ts
Expand Up @@ -203,6 +203,7 @@ cases<APICase>(
upload: {
api: 'upload',
elementArg: 0,
args: [null, new File(['foo'], 'foo.txt')],
},
},
)
Expand Down
23 changes: 6 additions & 17 deletions tests/upload.ts
Expand Up @@ -11,7 +11,7 @@ test('should fire the correct events for input', async () => {
// value of the input programmatically. The value in the browser
// set by a user would be: `C:\\fakepath\\${file.name}`
expect(getEventSnapshot()).toMatchInlineSnapshot(`
Events fired on: input[value=""]
Events fired on: input[value="C:\\\\fakepath\\\\hello.png"]
input[value=""] - pointerover
input[value=""] - pointerenter
Expand All @@ -30,8 +30,8 @@ test('should fire the correct events for input', async () => {
input[value=""] - focusout
input[value=""] - focus
input[value=""] - focusin
input[value=""] - input
input[value=""] - change
input[value="C:\\\\fakepath\\\\hello.png"] - input
input[value="C:\\\\fakepath\\\\hello.png"] - change
`)
})

Expand Down Expand Up @@ -64,8 +64,8 @@ test('should fire the correct events with label', async () => {
label[for="element"] - click: primary
input#element[value=""] - click: primary
input#element[value=""] - focusin
input#element[value=""] - input
input#element[value=""] - change
input#element[value="C:\\\\fakepath\\\\hello.png"] - input
input#element[value="C:\\\\fakepath\\\\hello.png"] - change
`)
})

Expand Down Expand Up @@ -187,7 +187,7 @@ test.each([
/>
`)

await userEvent.upload(element, files, undefined, {applyAccept})
await userEvent.upload(element, files, {applyAccept})

expect(element.files).toHaveLength(expectedLength)
},
Expand Down Expand Up @@ -255,14 +255,3 @@ test('throw error if trying to use upload on an invalid element', async () => {
`The associated INPUT element does not accept file uploads`,
)
})

test('apply init options', async () => {
const {element, getEvents} = setup('<input type="file"/>')

await userEvent.upload(element, new File([], 'hello.png'), {
changeInit: {cancelable: true},
})

expect(getEvents('click')[0]).toHaveProperty('shiftKey', false)
expect(getEvents('change')[0]).toHaveProperty('cancelable', true)
})
69 changes: 69 additions & 0 deletions tests/utils/edit/setFiles.ts
@@ -0,0 +1,69 @@
import {createFileList} from '#src/utils'
import {setFiles} from '#src/utils/edit/setFiles'
import {setup} from '#testHelpers/utils'

test('set files', () => {
const {element} = setup<HTMLInputElement & {type: 'file'}>(
`<input type="file"/>`,
)

const list = createFileList([new File(['foo'], 'foo.txt')])
setFiles(element, list)

expect(element).toHaveProperty('files', list)
expect(element).toHaveValue('C:\\fakepath\\foo.txt')
})

test('switching type resets value', () => {
const {element} = setup<HTMLInputElement>(`<input type="text"/>`)

element.type = 'file'

expect(element).toHaveValue('')

const list = createFileList([new File(['foo'], 'foo.txt')])
setFiles(element as HTMLInputElement & {type: 'file'}, list)

element.type = 'file'

expect(element).toHaveValue('C:\\fakepath\\foo.txt')

element.type = 'text'

expect(element).toHaveValue('')
expect(element).toHaveProperty('type', 'text')
})

test('setting value resets `files`', () => {
const {element} = setup<HTMLInputElement & {type: 'file'}>(
`<input type="file"/>`,
)

const list = createFileList([new File(['foo'], 'foo.txt')])
setFiles(element, list)

// Everything but an empty string throws an error in the browser
expect(() => {
element.value = 'foo'
}).toThrow()

expect(element).toHaveProperty('files', list)

element.value = ''

expect(element).toHaveProperty('files', expect.objectContaining({length: 0}))
})

test('is save to call multiple times', () => {
const {element} = setup<HTMLInputElement & {type: 'file'}>(
`<input type="file"/>`,
)

const list = createFileList([new File(['foo'], 'foo.txt')])
setFiles(element, list)
setFiles(element, list)

expect(element).toHaveValue('C:\\fakepath\\foo.txt')
element.value = ''
expect(element).toHaveValue('')
})

0 comments on commit 4873895

Please sign in to comment.