diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index 7073fee4..99372a4d 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -86,7 +86,7 @@ test('get throws a useful error message', () => { " `) expect(() => getByRole('LucyRicardo')).toThrowErrorMatchingInlineSnapshot(` -"Unable to find an element by role=LucyRicardo +"Unable to find an element by [role=LucyRicardo] 
 
 diff --git a/src/__tests__/get-by-errors.js b/src/__tests__/get-by-errors.js new file mode 100644 index 00000000..335ccef5 --- /dev/null +++ b/src/__tests__/get-by-errors.js @@ -0,0 +1,86 @@ +import cases from 'jest-in-case' +import {render} from './helpers/test-utils' + +cases( + 'getBy* queries throw an error when there are multiple elements returned', + ({name, query, html}) => { + const utils = render(html) + expect(() => utils[name](query)).toThrow(/multiple elements/i) + }, + { + getByLabelText: { + query: /his/, + html: `
`, + }, + getByPlaceholderText: { + query: /his/, + html: ``, + }, + getByText: { + query: /his/, + html: `
his
history
`, + }, + getByAltText: { + query: /his/, + html: `hishistory`, + }, + getByTitle: { + query: /his/, + html: `
`, + }, + getByDisplayValue: { + query: /his/, + html: ``, + }, + getByRole: { + query: /his/, + html: `
`, + }, + getByTestId: { + query: /his/, + html: `
`, + }, + }, +) + +cases( + 'queryBy* queries throw an error when there are multiple elements returned', + ({name, query, html}) => { + const utils = render(html) + expect(() => utils[name](query)).toThrow(/multiple elements/i) + }, + { + queryByLabelText: { + query: /his/, + html: `
`, + }, + queryByPlaceholderText: { + query: /his/, + html: ``, + }, + queryByText: { + query: /his/, + html: `
his
history
`, + }, + queryByAltText: { + query: /his/, + html: `hishistory`, + }, + queryByTitle: { + query: /his/, + html: `
`, + }, + queryByDisplayValue: { + query: /his/, + html: ``, + }, + queryByRole: { + query: /his/, + html: `
`, + }, + queryByTestId: { + query: /his/, + html: `
`, + }, + }, +) diff --git a/src/__tests__/misc.js b/src/__tests__/misc.js new file mode 100644 index 00000000..62b7eb9c --- /dev/null +++ b/src/__tests__/misc.js @@ -0,0 +1,15 @@ +import {render} from './helpers/test-utils' +import {queryByAttribute} from '..' + +// we used to use queryByAttribute internally, but we don't anymore. Some people +// use it as an undocumented part of the API, so we'll keep it around. +test('queryByAttribute', () => { + const {container} = render( + '
', + ) + expect(queryByAttribute('data-foo', container, 'bar')).not.toBeNull() + expect(queryByAttribute('blah', container, 'sup')).toBeNull() + expect(() => queryByAttribute('data-foo', container, /bar/)).toThrow( + /multiple/, + ) +}) diff --git a/src/queries.js b/src/queries.js deleted file mode 100644 index 182baf96..00000000 --- a/src/queries.js +++ /dev/null @@ -1,393 +0,0 @@ -import {fuzzyMatches, matches, makeNormalizer} from './matches' -import {getNodeText} from './get-node-text' -import { - getElementError, - firstResultOrNull, - queryAllByAttribute, - queryByAttribute, -} from './query-helpers' -import {waitForElement} from './wait-for-element' -import {getConfig} from './config' - -// Here are the queries for the library. -// The queries here should only be things that are accessible to both users who are using a screen reader -// and those who are not using a screen reader (with the exception of the data-testid attribute query). - -function queryAllLabelsByText( - container, - text, - {exact = true, trim, collapseWhitespace, normalizer} = {}, -) { - const matcher = exact ? matches : fuzzyMatches - const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) - return Array.from(container.querySelectorAll('label')).filter(label => - matcher(label.textContent, label, text, matchNormalizer), - ) -} - -function queryAllByLabelText( - container, - text, - {selector = '*', exact = true, collapseWhitespace, trim, normalizer} = {}, -) { - const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) - const labels = queryAllLabelsByText(container, text, { - exact, - normalizer: matchNormalizer, - }) - const labelledElements = labels - .map(label => { - if (label.control) { - return label.control - } - /* istanbul ignore if */ - if (label.getAttribute('for')) { - // we're using this notation because with the # selector we would have to escape special characters e.g. user.name - // see https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector#Escaping_special_characters - // - - // .control support has landed in jsdom (https://github.com/jsdom/jsdom/issues/2175) - return container.querySelector(`[id="${label.getAttribute('for')}"]`) - } - if (label.getAttribute('id')) { - // - return container.querySelector( - `[aria-labelledby~="${label.getAttribute('id')}"]`, - ) - } - if (label.childNodes.length) { - // - return label.querySelector(selector) - } - return null - }) - .filter(label => label !== null) - .concat(queryAllByAttribute('aria-label', container, text, {exact})) - - const possibleAriaLabelElements = queryAllByText(container, text, { - exact, - normalizer: matchNormalizer, - }).filter(el => el.tagName !== 'LABEL') // don't reprocess labels - - const ariaLabelledElements = possibleAriaLabelElements.reduce( - (allLabelledElements, nextLabelElement) => { - const labelId = nextLabelElement.getAttribute('id') - - if (!labelId) return allLabelledElements - - // ARIA labels can label multiple elements - const labelledNodes = Array.from( - container.querySelectorAll(`[aria-labelledby~="${labelId}"]`), - ) - - return allLabelledElements.concat(labelledNodes) - }, - [], - ) - - return Array.from(new Set([...labelledElements, ...ariaLabelledElements])) -} - -function queryByLabelText(...args) { - return firstResultOrNull(queryAllByLabelText, ...args) -} - -function queryAllByText( - container, - text, - { - selector = '*', - exact = true, - collapseWhitespace, - trim, - ignore = 'script, style', - normalizer, - } = {}, -) { - const matcher = exact ? matches : fuzzyMatches - const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) - let baseArray = [] - if (typeof container.matches === 'function' && container.matches(selector)) { - baseArray = [container] - } - return [...baseArray, ...Array.from(container.querySelectorAll(selector))] - .filter(node => !ignore || !node.matches(ignore)) - .filter(node => matcher(getNodeText(node), node, text, matchNormalizer)) -} - -function queryByText(...args) { - return firstResultOrNull(queryAllByText, ...args) -} - -function queryAllByTitle( - container, - text, - {exact = true, collapseWhitespace, trim, normalizer} = {}, -) { - const matcher = exact ? matches : fuzzyMatches - const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) - return Array.from(container.querySelectorAll('[title], svg > title')).filter( - node => - matcher(node.getAttribute('title'), node, text, matchNormalizer) || - matcher(getNodeText(node), node, text, matchNormalizer), - ) -} - -function queryByTitle(...args) { - return firstResultOrNull(queryAllByTitle, ...args) -} - -function getTestIdAttribute() { - return getConfig().testIdAttribute -} - -const queryByPlaceholderText = queryByAttribute.bind(null, 'placeholder') -const queryAllByPlaceholderText = queryAllByAttribute.bind(null, 'placeholder') -const queryByTestId = (...args) => - queryByAttribute(getTestIdAttribute(), ...args) -const queryAllByTestId = (...args) => - queryAllByAttribute(getTestIdAttribute(), ...args) -const queryByRole = queryByAttribute.bind(null, 'role') -const queryAllByRole = queryAllByAttribute.bind(null, 'role') - -function queryAllByAltText( - container, - alt, - {exact = true, collapseWhitespace, trim, normalizer} = {}, -) { - const matcher = exact ? matches : fuzzyMatches - const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) - return Array.from(container.querySelectorAll('img,input,area')).filter(node => - matcher(node.getAttribute('alt'), node, alt, matchNormalizer), - ) -} - -function queryByAltText(...args) { - return firstResultOrNull(queryAllByAltText, ...args) -} - -function queryAllByDisplayValue( - container, - value, - {exact = true, collapseWhitespace, trim, normalizer} = {}, -) { - const matcher = exact ? matches : fuzzyMatches - const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) - return Array.from(container.querySelectorAll(`input,textarea,select`)).filter( - node => { - if (node.tagName === 'SELECT') { - const selectedOptions = Array.from(node.options).filter( - option => option.selected, - ) - return selectedOptions.some(optionNode => - matcher(getNodeText(optionNode), optionNode, value, matchNormalizer), - ) - } else { - return matcher(node.value, node, value, matchNormalizer) - } - }, - ) -} - -function queryByDisplayValue(...args) { - return firstResultOrNull(queryAllByDisplayValue, ...args) -} - -// getters -// the reason we're not dynamically generating these functions that look so similar: -// 1. The error messages are specific to each one and depend on arguments -// 2. The stack trace will look better because it'll have a helpful method name. - -function getAllByTestId(container, id, ...rest) { - const els = queryAllByTestId(container, id, ...rest) - if (!els.length) { - throw getElementError( - `Unable to find an element by: [${getTestIdAttribute()}="${id}"]`, - container, - ) - } - return els -} - -function getByTestId(...args) { - return firstResultOrNull(getAllByTestId, ...args) -} - -function getAllByTitle(container, title, ...rest) { - const els = queryAllByTitle(container, title, ...rest) - if (!els.length) { - throw getElementError( - `Unable to find an element with the title: ${title}.`, - container, - ) - } - return els -} - -function getByTitle(...args) { - return firstResultOrNull(getAllByTitle, ...args) -} - -function getAllByPlaceholderText(container, text, ...rest) { - const els = queryAllByPlaceholderText(container, text, ...rest) - if (!els.length) { - throw getElementError( - `Unable to find an element with the placeholder text of: ${text}`, - container, - ) - } - return els -} - -function getByPlaceholderText(...args) { - return firstResultOrNull(getAllByPlaceholderText, ...args) -} - -function getAllByLabelText(container, text, ...rest) { - const els = queryAllByLabelText(container, text, ...rest) - if (!els.length) { - const labels = queryAllLabelsByText(container, text, ...rest) - if (labels.length) { - throw getElementError( - `Found a label with the text of: ${text}, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.`, - container, - ) - } else { - throw getElementError( - `Unable to find a label with the text of: ${text}`, - container, - ) - } - } - return els -} - -function getByLabelText(...args) { - return firstResultOrNull(getAllByLabelText, ...args) -} - -function getAllByText(container, text, ...rest) { - const els = queryAllByText(container, text, ...rest) - if (!els.length) { - throw getElementError( - `Unable to find an element with the text: ${text}. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.`, - container, - ) - } - return els -} - -function getByText(...args) { - return firstResultOrNull(getAllByText, ...args) -} - -function getAllByAltText(container, alt, ...rest) { - const els = queryAllByAltText(container, alt, ...rest) - if (!els.length) { - throw getElementError( - `Unable to find an element with the alt text: ${alt}`, - container, - ) - } - return els -} - -function getByAltText(...args) { - return firstResultOrNull(getAllByAltText, ...args) -} - -function getAllByRole(container, id, ...rest) { - const els = queryAllByRole(container, id, ...rest) - if (!els.length) { - throw getElementError(`Unable to find an element by role=${id}`, container) - } - return els -} - -function getByRole(...args) { - return firstResultOrNull(getAllByRole, ...args) -} - -function getAllByDisplayValue(container, value, ...rest) { - const els = queryAllByDisplayValue(container, value, ...rest) - if (!els.length) { - throw getElementError( - `Unable to find an element with the value: ${value}.`, - container, - ) - } - return els -} - -function getByDisplayValue(...args) { - return firstResultOrNull(getAllByDisplayValue, ...args) -} - -function makeFinder(getter) { - return (container, text, options, waitForElementOptions) => - waitForElement( - () => getter(container, text, options), - waitForElementOptions, - ) -} - -export const findByLabelText = makeFinder(getByLabelText) -export const findAllByLabelText = makeFinder(getAllByLabelText) - -export const findByPlaceholderText = makeFinder(getByPlaceholderText) -export const findAllByPlaceholderText = makeFinder(getAllByPlaceholderText) - -export const findByText = makeFinder(getByText) -export const findAllByText = makeFinder(getAllByText) - -export const findByAltText = makeFinder(getByAltText) -export const findAllByAltText = makeFinder(getAllByAltText) - -export const findByTitle = makeFinder(getByTitle) -export const findAllByTitle = makeFinder(getAllByTitle) - -export const findByDisplayValue = makeFinder(getByDisplayValue) -export const findAllByDisplayValue = makeFinder(getAllByDisplayValue) - -export const findByRole = makeFinder(getByRole) -export const findAllByRole = makeFinder(getAllByRole) - -export const findByTestId = makeFinder(getByTestId) -export const findAllByTestId = makeFinder(getAllByTestId) - -export { - queryByPlaceholderText, - queryAllByPlaceholderText, - getByPlaceholderText, - getAllByPlaceholderText, - queryByText, - queryAllByText, - getByText, - getAllByText, - queryByLabelText, - queryAllByLabelText, - getByLabelText, - getAllByLabelText, - queryByAltText, - queryAllByAltText, - getByAltText, - getAllByAltText, - queryByTestId, - queryAllByTestId, - getByTestId, - getAllByTestId, - queryByTitle, - queryAllByTitle, - getByTitle, - getAllByTitle, - queryByDisplayValue, - queryAllByDisplayValue, - getByDisplayValue, - getAllByDisplayValue, - queryByRole, - queryAllByRole, - getAllByRole, - getByRole, -} - -/* eslint complexity:["error", 14] */ diff --git a/src/queries/all-utils.js b/src/queries/all-utils.js new file mode 100644 index 00000000..c4ce9c43 --- /dev/null +++ b/src/queries/all-utils.js @@ -0,0 +1,4 @@ +export * from '../matches' +export * from '../get-node-text' +export * from '../query-helpers' +export * from '../config' diff --git a/src/queries/alt-text.js b/src/queries/alt-text.js new file mode 100644 index 00000000..debb2b33 --- /dev/null +++ b/src/queries/alt-text.js @@ -0,0 +1,34 @@ +import {matches, fuzzyMatches, makeNormalizer, buildQueries} from './all-utils' + +function queryAllByAltText( + container, + alt, + {exact = true, collapseWhitespace, trim, normalizer} = {}, +) { + const matcher = exact ? matches : fuzzyMatches + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + return Array.from(container.querySelectorAll('img,input,area')).filter(node => + matcher(node.getAttribute('alt'), node, alt, matchNormalizer), + ) +} + +const getMultipleError = (c, alt) => + `Found multiple elements with the alt text: ${alt}` +const getMissingError = (c, alt) => + `Unable to find an element with the alt text: ${alt}` +const [ + queryByAltText, + getAllByAltText, + getByAltText, + findAllByAltText, + findByAltText, +] = buildQueries(queryAllByAltText, getMultipleError, getMissingError) + +export { + queryByAltText, + queryAllByAltText, + getByAltText, + getAllByAltText, + findAllByAltText, + findByAltText, +} diff --git a/src/queries/display-value.js b/src/queries/display-value.js new file mode 100644 index 00000000..01a95a96 --- /dev/null +++ b/src/queries/display-value.js @@ -0,0 +1,51 @@ +import { + getNodeText, + matches, + fuzzyMatches, + makeNormalizer, + buildQueries, +} from './all-utils' + +function queryAllByDisplayValue( + container, + value, + {exact = true, collapseWhitespace, trim, normalizer} = {}, +) { + const matcher = exact ? matches : fuzzyMatches + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + return Array.from(container.querySelectorAll(`input,textarea,select`)).filter( + node => { + if (node.tagName === 'SELECT') { + const selectedOptions = Array.from(node.options).filter( + option => option.selected, + ) + return selectedOptions.some(optionNode => + matcher(getNodeText(optionNode), optionNode, value, matchNormalizer), + ) + } else { + return matcher(node.value, node, value, matchNormalizer) + } + }, + ) +} + +const getMultipleError = (c, value) => + `Found multiple elements with the value: ${value}.` +const getMissingError = (c, value) => + `Unable to find an element with the value: ${value}.` +const [ + queryByDisplayValue, + getAllByDisplayValue, + getByDisplayValue, + findAllByDisplayValue, + findByDisplayValue, +] = buildQueries(queryAllByDisplayValue, getMultipleError, getMissingError) + +export { + queryByDisplayValue, + queryAllByDisplayValue, + getByDisplayValue, + getAllByDisplayValue, + findAllByDisplayValue, + findByDisplayValue, +} diff --git a/src/queries/index.js b/src/queries/index.js new file mode 100644 index 00000000..56b283fa --- /dev/null +++ b/src/queries/index.js @@ -0,0 +1,8 @@ +export * from './label-text' +export * from './placeholder-text' +export * from './text' +export * from './display-value' +export * from './alt-text' +export * from './title' +export * from './role' +export * from './test-id' diff --git a/src/queries/label-text.js b/src/queries/label-text.js new file mode 100644 index 00000000..7df781cd --- /dev/null +++ b/src/queries/label-text.js @@ -0,0 +1,129 @@ +import { + fuzzyMatches, + matches, + makeNormalizer, + getElementError, + queryAllByAttribute, + makeFindQuery, + makeSingleQuery, +} from './all-utils' +import {queryAllByText} from './text' + +function queryAllLabelsByText( + container, + text, + {exact = true, trim, collapseWhitespace, normalizer} = {}, +) { + const matcher = exact ? matches : fuzzyMatches + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + return Array.from(container.querySelectorAll('label')).filter(label => + matcher(label.textContent, label, text, matchNormalizer), + ) +} + +function queryAllByLabelText( + container, + text, + {selector = '*', exact = true, collapseWhitespace, trim, normalizer} = {}, +) { + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + const labels = queryAllLabelsByText(container, text, { + exact, + normalizer: matchNormalizer, + }) + const labelledElements = labels + .map(label => { + if (label.control) { + return label.control + } + /* istanbul ignore if */ + if (label.getAttribute('for')) { + // we're using this notation because with the # selector we would have to escape special characters e.g. user.name + // see https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector#Escaping_special_characters + // + + // .control support has landed in jsdom (https://github.com/jsdom/jsdom/issues/2175) + return container.querySelector(`[id="${label.getAttribute('for')}"]`) + } + if (label.getAttribute('id')) { + // + return container.querySelector( + `[aria-labelledby~="${label.getAttribute('id')}"]`, + ) + } + if (label.childNodes.length) { + // + return label.querySelector(selector) + } + return null + }) + .filter(label => label !== null) + .concat(queryAllByAttribute('aria-label', container, text, {exact})) + + const possibleAriaLabelElements = queryAllByText(container, text, { + exact, + normalizer: matchNormalizer, + }).filter(el => el.tagName !== 'LABEL') // don't reprocess labels + + const ariaLabelledElements = possibleAriaLabelElements.reduce( + (allLabelledElements, nextLabelElement) => { + const labelId = nextLabelElement.getAttribute('id') + + if (!labelId) return allLabelledElements + + // ARIA labels can label multiple elements + const labelledNodes = Array.from( + container.querySelectorAll(`[aria-labelledby~="${labelId}"]`), + ) + + return allLabelledElements.concat(labelledNodes) + }, + [], + ) + + return Array.from(new Set([...labelledElements, ...ariaLabelledElements])) +} + +// the getAll* query would normally look like this: +// const getAllByLabelText = makeGetAllQuery( +// queryAllByLabelText, +// (c, text) => `Unable to find a label with the text of: ${text}`, +// ) +// however, we can give a more helpful error message than the generic one, +// so we're writing this one out by hand. +function getAllByLabelText(container, text, ...rest) { + const els = queryAllByLabelText(container, text, ...rest) + if (!els.length) { + const labels = queryAllLabelsByText(container, text, ...rest) + if (labels.length) { + throw getElementError( + `Found a label with the text of: ${text}, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.`, + container, + ) + } else { + throw getElementError( + `Unable to find a label with the text of: ${text}`, + container, + ) + } + } + return els +} + +// the reason mentioned above is the same reason we're not using buildQueries +const getMultipleError = (c, text) => + `Found multiple elements with the text of: ${text}` +const queryByLabelText = makeSingleQuery(queryAllByLabelText, getMultipleError) +const getByLabelText = makeSingleQuery(getAllByLabelText, getMultipleError) + +const findAllByLabelText = makeFindQuery(getAllByLabelText) +const findByLabelText = makeFindQuery(getByLabelText) + +export { + queryAllByLabelText, + queryByLabelText, + getAllByLabelText, + getByLabelText, + findAllByLabelText, + findByLabelText, +} diff --git a/src/queries/placeholder-text.js b/src/queries/placeholder-text.js new file mode 100644 index 00000000..8e58ab5f --- /dev/null +++ b/src/queries/placeholder-text.js @@ -0,0 +1,25 @@ +import {queryAllByAttribute, buildQueries} from './all-utils' + +const queryAllByPlaceholderText = queryAllByAttribute.bind(null, 'placeholder') + +const getMultipleError = (c, text) => + `Found multiple elements with the placeholder text of: ${text}` +const getMissingError = (c, text) => + `Unable to find an element with the placeholder text of: ${text}` + +const [ + queryByPlaceholderText, + getAllByPlaceholderText, + getByPlaceholderText, + findAllByPlaceholderText, + findByPlaceholderText, +] = buildQueries(queryAllByPlaceholderText, getMultipleError, getMissingError) + +export { + queryByPlaceholderText, + queryAllByPlaceholderText, + getByPlaceholderText, + getAllByPlaceholderText, + findAllByPlaceholderText, + findByPlaceholderText, +} diff --git a/src/queries/role.js b/src/queries/role.js new file mode 100644 index 00000000..c493ea92 --- /dev/null +++ b/src/queries/role.js @@ -0,0 +1,23 @@ +import {queryAllByAttribute, buildQueries} from './all-utils' + +const queryAllByRole = queryAllByAttribute.bind(null, 'role') + +const getMultipleError = (c, id) => `Found multiple elements by [role=${id}]` +const getMissingError = (c, id) => `Unable to find an element by [role=${id}]` + +const [ + queryByRole, + getAllByRole, + getByRole, + findAllByRole, + findByRole, +] = buildQueries(queryAllByRole, getMultipleError, getMissingError) + +export { + queryByRole, + queryAllByRole, + getAllByRole, + getByRole, + findAllByRole, + findByRole, +} diff --git a/src/queries/test-id.js b/src/queries/test-id.js new file mode 100644 index 00000000..6d36c164 --- /dev/null +++ b/src/queries/test-id.js @@ -0,0 +1,28 @@ +import {queryAllByAttribute, getConfig, buildQueries} from './all-utils' + +const getTestIdAttribute = () => getConfig().testIdAttribute + +const queryAllByTestId = (...args) => + queryAllByAttribute(getTestIdAttribute(), ...args) + +const getMultipleError = (c, id) => + `Found multiple elements by: [${getTestIdAttribute()}="${id}"]` +const getMissingError = (c, id) => + `Unable to find an element by: [${getTestIdAttribute()}="${id}"]` + +const [ + queryByTestId, + getAllByTestId, + getByTestId, + findAllByTestId, + findByTestId, +] = buildQueries(queryAllByTestId, getMultipleError, getMissingError) + +export { + queryByTestId, + queryAllByTestId, + getByTestId, + getAllByTestId, + findAllByTestId, + findByTestId, +} diff --git a/src/queries/text.js b/src/queries/text.js new file mode 100644 index 00000000..18cf9cb7 --- /dev/null +++ b/src/queries/text.js @@ -0,0 +1,52 @@ +import { + fuzzyMatches, + matches, + makeNormalizer, + getNodeText, + buildQueries, +} from './all-utils' + +function queryAllByText( + container, + text, + { + selector = '*', + exact = true, + collapseWhitespace, + trim, + ignore = 'script, style', + normalizer, + } = {}, +) { + const matcher = exact ? matches : fuzzyMatches + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + let baseArray = [] + if (typeof container.matches === 'function' && container.matches(selector)) { + baseArray = [container] + } + return [...baseArray, ...Array.from(container.querySelectorAll(selector))] + .filter(node => !ignore || !node.matches(ignore)) + .filter(node => matcher(getNodeText(node), node, text, matchNormalizer)) +} + +const getMultipleError = (c, text) => + `Found multiple elements with the text: ${text}` +const getMissingError = (c, text) => + `Unable to find an element with the text: ${text}. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.` + +const [ + queryByText, + getAllByText, + getByText, + findAllByText, + findByText, +] = buildQueries(queryAllByText, getMultipleError, getMissingError) + +export { + queryByText, + queryAllByText, + getByText, + getAllByText, + findAllByText, + findByText, +} diff --git a/src/queries/title.js b/src/queries/title.js new file mode 100644 index 00000000..c696572f --- /dev/null +++ b/src/queries/title.js @@ -0,0 +1,43 @@ +import { + fuzzyMatches, + matches, + makeNormalizer, + getNodeText, + buildQueries, +} from './all-utils' + +function queryAllByTitle( + container, + text, + {exact = true, collapseWhitespace, trim, normalizer} = {}, +) { + const matcher = exact ? matches : fuzzyMatches + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + return Array.from(container.querySelectorAll('[title], svg > title')).filter( + node => + matcher(node.getAttribute('title'), node, text, matchNormalizer) || + matcher(getNodeText(node), node, text, matchNormalizer), + ) +} + +const getMultipleError = (c, title) => + `Found multiple elements with the title: ${title}.` +const getMissingError = (c, title) => + `Unable to find an element with the title: ${title}.` + +const [ + queryByTitle, + getAllByTitle, + getByTitle, + findAllByTitle, + findByTitle, +] = buildQueries(queryAllByTitle, getMultipleError, getMissingError) + +export { + queryByTitle, + queryAllByTitle, + getByTitle, + getAllByTitle, + findAllByTitle, + findByTitle, +} diff --git a/src/query-helpers.js b/src/query-helpers.js index f3b62e61..f91ba209 100644 --- a/src/query-helpers.js +++ b/src/query-helpers.js @@ -1,5 +1,6 @@ import {prettyDOM} from './pretty-dom' import {fuzzyMatches, matches, makeNormalizer} from './matches' +import {waitForElement} from './wait-for-element' /* eslint-disable complexity */ function debugDOM(htmlElement) { @@ -30,10 +31,11 @@ function getElementError(message, container) { return new Error([message, debugDOM(container)].filter(Boolean).join('\n\n')) } -function firstResultOrNull(queryFunction, ...args) { - const result = queryFunction(...args) - if (result.length === 0) return null - return result[0] +function getMultipleElementsFoundError(message, container) { + return getElementError( + `${message}\n\n(If this is intentional, then use the \`*AllBy*\` variant of the query (like \`queryAllByText\`, \`getAllByText\`, or \`findAllByText\`)).`, + container, + ) } function queryAllByAttribute( @@ -49,14 +51,73 @@ function queryAllByAttribute( ) } -function queryByAttribute(...args) { - return firstResultOrNull(queryAllByAttribute, ...args) +function queryByAttribute(attribute, container, text, ...args) { + const els = queryAllByAttribute(attribute, container, text, ...args) + if (els.length > 1) { + throw getMultipleElementsFoundError( + `Found multiple elements by [${attribute}=${text}]`, + container, + ) + } + return els[0] || null +} + +// this accepts a query function and returns a function which throws an error +// if more than one elements is returned, otherwise it returns the first +// element or null +function makeSingleQuery(allQuery, getMultipleError) { + return (container, ...args) => { + const els = allQuery(container, ...args) + if (els.length > 1) { + throw getMultipleElementsFoundError( + getMultipleError(container, ...args), + container, + ) + } + return els[0] || null + } +} + +// this accepts a query function and returns a function which throws an error +// if an empty list of elements is returned +function makeGetAllQuery(allQuery, getMissingError) { + return (container, ...args) => { + const els = allQuery(container, ...args) + if (!els.length) { + throw getElementError(getMissingError(container, ...args), container) + } + return els + } +} + +// this accepts a getter query function and returns a function which calls +// waitForElement and passing a function which invokes the getter. +function makeFindQuery(getter) { + return (container, text, options, waitForElementOptions) => + waitForElement( + () => getter(container, text, options), + waitForElementOptions, + ) +} + +function buildQueries(queryAllBy, getMultipleError, getMissingError) { + const queryBy = makeSingleQuery(queryAllBy, getMultipleError) + const getAllBy = makeGetAllQuery(queryAllBy, getMissingError) + const getBy = makeSingleQuery(getAllBy, getMultipleError) + const findAllBy = makeFindQuery(getAllBy) + const findBy = makeFindQuery(getBy) + + return [queryBy, getAllBy, getBy, findAllBy, findBy] } export { debugDOM, getElementError, - firstResultOrNull, + getMultipleElementsFoundError, queryAllByAttribute, queryByAttribute, + makeSingleQuery, + makeGetAllQuery, + makeFindQuery, + buildQueries, } diff --git a/typings/query-helpers.d.ts b/typings/query-helpers.d.ts index a4bff63f..b9e86b5d 100644 --- a/typings/query-helpers.d.ts +++ b/typings/query-helpers.d.ts @@ -20,11 +20,5 @@ export type AllByAttribute = ( export const queryByAttribute: QueryByAttribute export const queryAllByAttribute: AllByAttribute -export const firstResultOrNull: ( - fn: AllByAttribute, - container?: HTMLElement, - id?: Matcher, - options?: MatcherOptions, -) => HTMLElement | null export const debugDOM: (htmlElement: HTMLElement) => string export const getElementError: (message: string, container: HTMLElement) => Error