Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(upload)!: replace element properties #794

Merged
merged 1 commit into from
Nov 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/setup/directApi.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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('')
})