Skip to content

Commit 4873895

Browse files
authored
feat(upload)!: replace element properties (#794)
BREAKING CHANGE: `init` parameter has been removed from `userEvent.upload`.
1 parent df75e5f commit 4873895

File tree

7 files changed

+175
-60
lines changed

7 files changed

+175
-60
lines changed

src/setup/directApi.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type {PointerOptions} from '../utils'
2-
import type {uploadInit} from '../utility'
32
import type {PointerInput} from '../pointer'
43
import type {UserEventApi} from '.'
54
import {setupDirect} from './setup'
@@ -91,10 +90,9 @@ export function unhover(element: Element, options: PointerOptions = {}) {
9190
export function upload(
9291
element: HTMLElement,
9392
fileOrFiles: File | File[],
94-
init?: uploadInit,
9593
options: Partial<Config> = {},
9694
) {
97-
return setupDirect(options).upload(element, fileOrFiles, init)
95+
return setupDirect(options).upload(element, fileOrFiles)
9896
}
9997

10098
export function tab(

src/utility/upload.ts

+7-36
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {fireEvent, createEvent} from '@testing-library/dom'
2-
import {blur, focus, isDisabled, isElementType} from '../utils'
1+
import {fireEvent} from '@testing-library/dom'
2+
import {blur, createFileList, focus, isDisabled, isElementType} from '../utils'
33
import {Config, UserEvent} from '../setup'
4+
import {setFiles} from '#src/utils/edit/setFiles'
45

56
export interface uploadInit {
67
changeInit?: EventInit
@@ -10,11 +11,10 @@ export async function upload(
1011
this: UserEvent,
1112
element: HTMLElement,
1213
fileOrFiles: File | File[],
13-
init?: uploadInit,
1414
) {
1515
const input = isElementType(element, 'label') ? element.control : element
1616

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

47-
// the event fired in the browser isn't actually an "input" or "change" event
48-
// but a new Event with a type set to "input" and "change"
49-
// Kinda odd...
50-
const inputFiles: FileList & Iterable<File> = {
51-
...files,
52-
length: files.length,
53-
item: (index: number) => files[index],
54-
[Symbol.iterator]() {
55-
let i = 0
56-
return {
57-
next: () => ({
58-
done: i >= files.length,
59-
value: files[i++],
60-
}),
61-
}
62-
},
63-
}
64-
65-
fireEvent(
66-
input,
67-
createEvent('input', input, {
68-
target: {files: inputFiles},
69-
bubbles: true,
70-
cancelable: false,
71-
composed: true,
72-
}),
73-
)
74-
75-
fireEvent.change(input, {
76-
target: {files: inputFiles},
77-
...init?.changeInit,
78-
})
47+
setFiles(input, createFileList(files))
48+
fireEvent.input(input)
49+
fireEvent.change(input)
7950
}
8051

8152
function isAcceptableFile(file: File, accept: string) {

src/utils/dataTransfer/FileList.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
// FileList can not be created per constructor.
22

33
export function createFileList(files: File[]): FileList {
4-
const f = [...files]
4+
const list: FileList & Iterable<File> = {
5+
...files,
6+
length: files.length,
7+
item: (index: number) => list[index],
8+
[Symbol.iterator]: function* nextFile() {
9+
for (let i = 0; i < list.length; i++) {
10+
yield list[i]
11+
}
12+
},
13+
}
14+
list.constructor = FileList
15+
Object.setPrototypeOf(list, FileList.prototype)
16+
Object.freeze(list)
517

6-
Object.setPrototypeOf(f, FileList.prototype)
7-
8-
return f as unknown as FileList
18+
return list
919
}

src/utils/edit/setFiles.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// It is not possible to create a real FileList programmatically.
2+
// Therefore assigning `files` property with a programmatically created FileList results in an error.
3+
// Just assigning the property (as per fireEvent) breaks the interweaving with the `value` property.
4+
5+
const fakeFiles = Symbol('files and value properties are mocked')
6+
7+
declare global {
8+
interface HTMLInputElement {
9+
[fakeFiles]?: {
10+
restore: () => void
11+
}
12+
}
13+
}
14+
15+
export function setFiles(
16+
el: HTMLInputElement & {type: 'file'},
17+
files: FileList,
18+
) {
19+
el[fakeFiles]?.restore()
20+
21+
const objectDescriptors = Object.getOwnPropertyDescriptors(el)
22+
const prototypeDescriptors = Object.getOwnPropertyDescriptors(
23+
Object.getPrototypeOf(el),
24+
)
25+
26+
function restore() {
27+
Object.defineProperties(el, {
28+
files: {
29+
...prototypeDescriptors.files,
30+
...objectDescriptors.files,
31+
},
32+
value: {
33+
...prototypeDescriptors.value,
34+
...objectDescriptors.value,
35+
},
36+
type: {
37+
...prototypeDescriptors.type,
38+
...objectDescriptors.type,
39+
},
40+
})
41+
}
42+
el[fakeFiles] = {restore}
43+
44+
Object.defineProperties(el, {
45+
files: {
46+
...prototypeDescriptors.files,
47+
...objectDescriptors.files,
48+
get: () => files,
49+
},
50+
value: {
51+
...prototypeDescriptors.value,
52+
...objectDescriptors.value,
53+
get: () => (files.length ? `C:\\fakepath\\${files[0].name}` : ''),
54+
set(v: string) {
55+
if (v === '') {
56+
restore()
57+
} else {
58+
objectDescriptors.value.set?.call(el, v)
59+
}
60+
},
61+
},
62+
// eslint-disable-next-line accessor-pairs
63+
type: {
64+
...prototypeDescriptors.type,
65+
...objectDescriptors.type,
66+
set(v: string) {
67+
if (v !== 'file') {
68+
restore()
69+
// In the browser the value will be empty.
70+
// In Jsdom the value will be the same as
71+
// before this element became file input - which might be empty.
72+
;(el as HTMLInputElement).type = v
73+
}
74+
},
75+
},
76+
})
77+
}

tests/setup.ts

+1
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ cases<APICase>(
203203
upload: {
204204
api: 'upload',
205205
elementArg: 0,
206+
args: [null, new File(['foo'], 'foo.txt')],
206207
},
207208
},
208209
)

tests/upload.ts

+6-17
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ test('should fire the correct events for input', async () => {
1111
// value of the input programmatically. The value in the browser
1212
// set by a user would be: `C:\\fakepath\\${file.name}`
1313
expect(getEventSnapshot()).toMatchInlineSnapshot(`
14-
Events fired on: input[value=""]
14+
Events fired on: input[value="C:\\\\fakepath\\\\hello.png"]
1515
1616
input[value=""] - pointerover
1717
input[value=""] - pointerenter
@@ -30,8 +30,8 @@ test('should fire the correct events for input', async () => {
3030
input[value=""] - focusout
3131
input[value=""] - focus
3232
input[value=""] - focusin
33-
input[value=""] - input
34-
input[value=""] - change
33+
input[value="C:\\\\fakepath\\\\hello.png"] - input
34+
input[value="C:\\\\fakepath\\\\hello.png"] - change
3535
`)
3636
})
3737

@@ -64,8 +64,8 @@ test('should fire the correct events with label', async () => {
6464
label[for="element"] - click: primary
6565
input#element[value=""] - click: primary
6666
input#element[value=""] - focusin
67-
input#element[value=""] - input
68-
input#element[value=""] - change
67+
input#element[value="C:\\\\fakepath\\\\hello.png"] - input
68+
input#element[value="C:\\\\fakepath\\\\hello.png"] - change
6969
`)
7070
})
7171

@@ -187,7 +187,7 @@ test.each([
187187
/>
188188
`)
189189

190-
await userEvent.upload(element, files, undefined, {applyAccept})
190+
await userEvent.upload(element, files, {applyAccept})
191191

192192
expect(element.files).toHaveLength(expectedLength)
193193
},
@@ -255,14 +255,3 @@ test('throw error if trying to use upload on an invalid element', async () => {
255255
`The associated INPUT element does not accept file uploads`,
256256
)
257257
})
258-
259-
test('apply init options', async () => {
260-
const {element, getEvents} = setup('<input type="file"/>')
261-
262-
await userEvent.upload(element, new File([], 'hello.png'), {
263-
changeInit: {cancelable: true},
264-
})
265-
266-
expect(getEvents('click')[0]).toHaveProperty('shiftKey', false)
267-
expect(getEvents('change')[0]).toHaveProperty('cancelable', true)
268-
})

tests/utils/edit/setFiles.ts

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {createFileList} from '#src/utils'
2+
import {setFiles} from '#src/utils/edit/setFiles'
3+
import {setup} from '#testHelpers/utils'
4+
5+
test('set files', () => {
6+
const {element} = setup<HTMLInputElement & {type: 'file'}>(
7+
`<input type="file"/>`,
8+
)
9+
10+
const list = createFileList([new File(['foo'], 'foo.txt')])
11+
setFiles(element, list)
12+
13+
expect(element).toHaveProperty('files', list)
14+
expect(element).toHaveValue('C:\\fakepath\\foo.txt')
15+
})
16+
17+
test('switching type resets value', () => {
18+
const {element} = setup<HTMLInputElement>(`<input type="text"/>`)
19+
20+
element.type = 'file'
21+
22+
expect(element).toHaveValue('')
23+
24+
const list = createFileList([new File(['foo'], 'foo.txt')])
25+
setFiles(element as HTMLInputElement & {type: 'file'}, list)
26+
27+
element.type = 'file'
28+
29+
expect(element).toHaveValue('C:\\fakepath\\foo.txt')
30+
31+
element.type = 'text'
32+
33+
expect(element).toHaveValue('')
34+
expect(element).toHaveProperty('type', 'text')
35+
})
36+
37+
test('setting value resets `files`', () => {
38+
const {element} = setup<HTMLInputElement & {type: 'file'}>(
39+
`<input type="file"/>`,
40+
)
41+
42+
const list = createFileList([new File(['foo'], 'foo.txt')])
43+
setFiles(element, list)
44+
45+
// Everything but an empty string throws an error in the browser
46+
expect(() => {
47+
element.value = 'foo'
48+
}).toThrow()
49+
50+
expect(element).toHaveProperty('files', list)
51+
52+
element.value = ''
53+
54+
expect(element).toHaveProperty('files', expect.objectContaining({length: 0}))
55+
})
56+
57+
test('is save to call multiple times', () => {
58+
const {element} = setup<HTMLInputElement & {type: 'file'}>(
59+
`<input type="file"/>`,
60+
)
61+
62+
const list = createFileList([new File(['foo'], 'foo.txt')])
63+
setFiles(element, list)
64+
setFiles(element, list)
65+
66+
expect(element).toHaveValue('C:\\fakepath\\foo.txt')
67+
element.value = ''
68+
expect(element).toHaveValue('')
69+
})

0 commit comments

Comments
 (0)