Skip to content

Commit

Permalink
fix: relax typing and throw error for unsupported elements (#649)
Browse files Browse the repository at this point in the history
`userEvent.paste` and `userEvent.upload` accept `HTMLElement` as first parameter.
If the specific element is not supported, throw a runtime error.

Co-authored-by: Philipp Fritsche <ph.fritsche@gmail.com>
  • Loading branch information
GreenGremlin and ph-fritsche committed Apr 19, 2021
1 parent 5e6d7db commit dc13160
Show file tree
Hide file tree
Showing 5 changed files with 89 additions and 49 deletions.
10 changes: 5 additions & 5 deletions src/__tests__/paste.js
Expand Up @@ -100,9 +100,9 @@ test('should replace selected text all at once', () => {

test('should give error if we are trying to call paste on an invalid element', () => {
const {element} = setup('<div />')
expect(() =>
userEvent.paste(element, "I'm only a div :("),
).toThrowErrorMatchingInlineSnapshot(
`"the current element is of type DIV and doesn't have a valid value"`,
)
expect(() => userEvent.paste(element, "I'm only a div :("))
.toThrowErrorMatchingInlineSnapshot(`
"The given DIV element is currently unsupported.
A PR extending this implementation would be very much welcome at https://github.com/testing-library/user-event"
`)
})
22 changes: 19 additions & 3 deletions src/__tests__/upload.js
Expand Up @@ -180,9 +180,9 @@ test.each([
new File(['there'], 'there.jpg', {type: 'video/mp4'}),
]
const {element} = setup(`
<input
type="file"
accept="${acceptAttribute}" multiple
<input
type="file"
accept="${acceptAttribute}" multiple
/>
`)

Expand Down Expand Up @@ -235,3 +235,19 @@ test('input.files implements iterable', () => {

expect(Array.from(eventTargetFiles)).toEqual(files)
})

test('throw error if trying to use upload on an invalid element', () => {
const {elements} = setup('<div></div><label><input type="checkbox"/></label>')

expect(() =>
userEvent.upload(elements[0], "I'm only a div :("),
).toThrowErrorMatchingInlineSnapshot(
`"The given DIV element does not accept file uploads"`,
)

expect(() =>
userEvent.upload(elements[1], "I'm a checkbox :("),
).toThrowErrorMatchingInlineSnapshot(
`"The associated INPUT element does not accept file uploads"`,
)
})
30 changes: 23 additions & 7 deletions src/paste.ts
Expand Up @@ -5,29 +5,45 @@ import {
calculateNewValue,
eventWrapper,
isDisabled,
isElementType,
editableInputTypes,
} from './utils'

interface pasteOptions {
initialSelectionStart?: number
initialSelectionEnd?: number
}

function isSupportedElement(
element: HTMLElement,
): element is
| HTMLTextAreaElement
| (HTMLInputElement & {type: editableInputTypes}) {
return (
(isElementType(element, 'input') &&
Boolean(editableInputTypes[element.type as editableInputTypes])) ||
isElementType(element, 'textarea')
)
}

function paste(
element: HTMLInputElement | HTMLTextAreaElement,
element: HTMLElement,
text: string,
init?: ClipboardEventInit,
{initialSelectionStart, initialSelectionEnd}: pasteOptions = {},
) {
if (isDisabled(element)) {
return
}

// TODO: implement for contenteditable
if (typeof element.value === 'undefined') {
if (!isSupportedElement(element)) {
throw new TypeError(
`the current element is of type ${element.tagName} and doesn't have a valid value`,
`The given ${element.tagName} element is currently unsupported.
A PR extending this implementation would be very much welcome at https://github.com/testing-library/user-event`,
)
}

if (isDisabled(element)) {
return
}

eventWrapper(() => element.focus())

// by default, a new element has it's selection start and end at 0
Expand Down
15 changes: 10 additions & 5 deletions src/upload.ts
Expand Up @@ -14,19 +14,24 @@ interface uploadOptions {
}

function upload(
element: HTMLInputElement | HTMLLabelElement,
element: HTMLElement,
fileOrFiles: File | File[],
init?: uploadInit,
{applyAccept = false}: uploadOptions = {},
) {
const input = isElementType(element, 'label') ? element.control : element

if (!input || !isElementType(input, 'input', {type: 'file'})) {
throw new TypeError(
`The ${input === element ? 'given' : 'associated'} ${
input?.tagName
} element does not accept file uploads`,
)
}
if (isDisabled(element)) return

click(element, init?.clickInit)

const input = isElementType(element, 'label')
? (element.control as HTMLInputElement)
: element

const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles])
.filter(file => !applyAccept || isAcceptableFile(file, input.accept))
.slice(0, input.multiple ? undefined : 1)
Expand Down
61 changes: 32 additions & 29 deletions src/utils/edit/isEditable.ts
@@ -1,42 +1,45 @@
import { isElementType } from "../misc/isElementType";
import { isContentEditable } from './isContentEditable'
import {isElementType} from '../misc/isElementType'
import {isContentEditable} from './isContentEditable'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type GuardedType<T> = T extends (x: any) => x is (infer R) ? R : never
type GuardedType<T> = T extends (x: any) => x is infer R ? R : never

export function isEditable(
element: Element
element: Element,
): element is
GuardedType<typeof isContentEditable>
| GuardedType<typeof isEditableInput>
| HTMLTextAreaElement & {readOnly: false}
{
return isEditableInput(element)
|| isElementType(element, 'textarea', {readOnly: false})
|| isContentEditable(element)
| GuardedType<typeof isContentEditable>
| GuardedType<typeof isEditableInput>
| (HTMLTextAreaElement & {readOnly: false}) {
return (
isEditableInput(element) ||
isElementType(element, 'textarea', {readOnly: false}) ||
isContentEditable(element)
)
}

enum editableInputTypes {
'text' = 'text',
'date' = 'date',
'datetime-local' = 'datetime-local',
'email' = 'email',
'month' = 'month',
'number' = 'number',
'password' = 'password',
'search' = 'search',
'tel' = 'tel',
'time' = 'time',
'url' = 'url',
'week' = 'week',
export enum editableInputTypes {
'text' = 'text',
'date' = 'date',
'datetime-local' = 'datetime-local',
'email' = 'email',
'month' = 'month',
'number' = 'number',
'password' = 'password',
'search' = 'search',
'tel' = 'tel',
'time' = 'time',
'url' = 'url',
'week' = 'week',
}

export function isEditableInput(
element: Element
element: Element,
): element is HTMLInputElement & {
readOnly: false,
type: editableInputTypes
readOnly: false
type: editableInputTypes
} {
return isElementType(element, 'input', {readOnly: false})
&& Boolean(editableInputTypes[element.type as editableInputTypes])
return (
isElementType(element, 'input', {readOnly: false}) &&
Boolean(editableInputTypes[element.type as editableInputTypes])
)
}

0 comments on commit dc13160

Please sign in to comment.