UNPKG

semantic-ui-react

Version:
370 lines (316 loc) 11.4 kB
import _ from 'lodash' import PropTypes from 'prop-types' import leven from './leven' const typeOf = (...args) => Object.prototype.toString.call(...args) /** * Ensure a prop is a valid DOM node. */ export const domNode = (props, propName) => { // skip if prop is undefined if (props[propName] === undefined) return // short circle for SSR env if (typeof Element === 'undefined') return // skip if prop is valid if (props[propName] instanceof Element) return return new Error(`Invalid prop "${propName}" supplied, expected a DOM node.`) } /** * Similar to PropTypes.oneOf but shows closest matches. * Word order is ignored allowing `left chevron` to match `chevron left`. * Useful for very large lists of options (e.g. Icon name, Flag name, etc.) * @param {string[]} suggestions An array of allowed values. */ export const suggest = (suggestions) => { if (!Array.isArray(suggestions)) { throw new Error('Invalid argument supplied to suggest, expected an instance of array.') } /* eslint-disable max-nested-callbacks */ const findBestSuggestions = _.memoize((str) => { const propValueWords = str.split(' ') return _.take( _.sortBy( _.map(suggestions, (suggestion) => { const suggestionWords = suggestion.split(' ') const propValueScore = _.sum( _.map( _.map(propValueWords, (x) => _.map(suggestionWords, (y) => leven(x, y))), _.min, ), ) const suggestionScore = _.sum( _.map( _.map(suggestionWords, (x) => _.map(propValueWords, (y) => leven(x, y))), _.min, ), ) return { suggestion, score: propValueScore + suggestionScore } }), ['score', 'suggestion'], ), 3, ) }) /* eslint-enable max-nested-callbacks */ // Convert the suggestions list into a hash map for O(n) lookup times. Ensure // the words in each key are sorted alphabetically so that we have a consistent // way of looking up a value in the map, i.e. we can sort the words in the // incoming propValue and look that up without having to check all permutations. const suggestionsLookup = suggestions.reduce((acc, key) => { acc[key.split(' ').sort().join(' ')] = true return acc }, {}) return (props, propName, componentName) => { const propValue = props[propName] // skip if prop is undefined or is included in the suggestions if (!propValue || suggestionsLookup[propValue]) return // check if the words were correct but ordered differently. // Since we're matching for classNames we need to allow any word order // to pass validation, e.g. `left chevron` vs `chevron left`. const propValueSorted = propValue.split(' ').sort().join(' ') if (suggestionsLookup[propValueSorted]) return // find best suggestions const bestMatches = findBestSuggestions(propValue) // skip if a match scored 0 if (bestMatches.some((x) => x.score === 0)) return return new Error( [ `Invalid prop \`${propName}\` of value \`${propValue}\` supplied to \`${componentName}\`.`, `\n\nInstead of \`${propValue}\`, did you mean:`, bestMatches.map((x) => `\n - ${x.suggestion}`).join(''), '\n', ].join(''), ) } } /** * Disallow other props from being defined with this prop. * @param {string[]} disallowedProps An array of props that cannot be used with this prop. */ export const disallow = (disallowedProps) => (props, propName, componentName) => { if (!Array.isArray(disallowedProps)) { throw new Error( [ 'Invalid argument supplied to disallow, expected an instance of array.', ` See \`${propName}\` prop in \`${componentName}\`.`, ].join(''), ) } // skip if prop is undefined if (_.isNil(props[propName]) || props[propName] === false) { return } // find disallowed props with values const disallowed = disallowedProps.reduce((acc, disallowedProp) => { if (!_.isNil(props[disallowedProp]) && props[disallowedProp] !== false) { return [...acc, disallowedProp] } return acc }, []) if (disallowed.length > 0) { return new Error( [ `Prop \`${propName}\` in \`${componentName}\` conflicts with props: \`${disallowed.join( '`, `', )}\`.`, 'They cannot be defined together, choose one or the other.', ].join(' '), ) } } /** * Ensure a prop adherers to multiple prop type validators. * @param {function[]} validators An array of propType functions. */ export const every = (validators) => (props, propName, componentName, ...rest) => { if (!Array.isArray(validators)) { throw new Error( [ 'Invalid argument supplied to every, expected an instance of array.', `See \`${propName}\` prop in \`${componentName}\`.`, ].join(' '), ) } const errors = [] validators.forEach((validator) => { if (typeof validator !== 'function') { throw new Error( `every() argument "validators" should contain functions, found: ${typeOf(validator)}.`, ) } const error = validator(props, propName, componentName, ...rest) if (error) { errors.push(error) } }) // we can only return one error at a time return errors[0] } /** * Ensure a validator passes only when a component has a given propsShape. * @param {object} propsShape An object describing the prop shape. * @param {function} validator A propType function. */ export const givenProps = (propsShape, validator) => (props, propName, componentName, ...rest) => { if (!_.isPlainObject(propsShape)) { throw new Error( [ 'Invalid argument supplied to givenProps, expected an object.', `See \`${propName}\` prop in \`${componentName}\`.`, ].join(' '), ) } if (typeof validator !== 'function') { throw new Error( [ 'Invalid argument supplied to givenProps, expected a function.', `See \`${propName}\` prop in \`${componentName}\`.`, ].join(' '), ) } const shouldValidate = _.keys(propsShape).every((key) => { const val = propsShape[key] // require propShape validators to pass or prop values to match return typeof val === 'function' ? !val(props, key, componentName, ...rest) : val === props[propName] }) if (!shouldValidate) return const error = validator(props, propName, componentName, ...rest) if (error) { // poor mans shallow pretty print, prevents JSON circular reference errors const prettyProps = `{ ${_.keys(_.pick(_.keys(propsShape), props)) .map((key) => { const val = props[key] let renderedValue = val if (typeof val === 'string') renderedValue = `"${val}"` else if (Array.isArray(val)) renderedValue = `[${val.join(', ')}]` else if (_.isObject(val)) renderedValue = '{...}' return `${key}: ${renderedValue}` }) .join(', ')} }` error.message = `Given props ${prettyProps}: ${error.message}` return error } } /** * Define prop dependencies by requiring other props. * @param {string[]} requiredProps An array of required prop names. */ export const demand = (requiredProps) => (props, propName, componentName) => { if (!Array.isArray(requiredProps)) { throw new Error( [ 'Invalid `requiredProps` argument supplied to require, expected an instance of array.', ` See \`${propName}\` prop in \`${componentName}\`.`, ].join(''), ) } // skip if prop is undefined if (props[propName] === undefined) return const missingRequired = requiredProps.filter((requiredProp) => props[requiredProp] === undefined) if (missingRequired.length > 0) { return new Error( `\`${propName}\` prop in \`${componentName}\` requires props: \`${missingRequired.join( '`, `', )}\`.`, ) } } /** * Ensure an multiple prop contains a string with only possible values. * @param {string[]} possible An array of possible values to prop. */ export const multipleProp = (possible) => (props, propName, componentName) => { if (!Array.isArray(possible)) { throw new Error( [ 'Invalid argument supplied to some, expected an instance of array.', `See \`${propName}\` prop in \`${componentName}\`.`, ].join(' '), ) } const propValue = props[propName] // skip if prop is undefined if (_.isNil(propValue) || propValue === false) return const values = propValue .replace('large screen', 'large-screen') .replace(/ vertically/g, '-vertically') .split(' ') .map((val) => _.trim(val).replace('-', ' ')) const invalid = _.difference(values, possible) // fail only if there are invalid values if (invalid.length > 0) { return new Error( `\`${propName}\` prop in \`${componentName}\` has invalid values: \`${invalid.join( '`, `', )}\`.`, ) } } /** * Ensure a component can render as a node passed as a prop value in place of children. */ export const contentShorthand = (...args) => every([disallow(['children']), PropTypes.node])(...args) /** * Item shorthand is a description of a component that can be a literal, * a props object, or an element. */ export const itemShorthand = (...args) => every([ disallow(['children']), PropTypes.oneOfType([ PropTypes.func, PropTypes.node, PropTypes.object, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.node, PropTypes.object])), ]), ])(...args) /** * Collection shorthand ensures a prop is an array of item shorthand. */ export const collectionShorthand = (...args) => every([disallow(['children']), PropTypes.arrayOf(itemShorthand)])(...args) /** * Show a deprecated warning for component props with a help message and optional validator. * @param {string} help A help message to display with the deprecation warning. * @param {function} [validator] A propType function. */ export const deprecate = (help, validator) => (props, propName, componentName, ...args) => { if (typeof help !== 'string') { throw new Error( [ 'Invalid `help` argument supplied to deprecate, expected a string.', `See \`${propName}\` prop in \`${componentName}\`.`, ].join(' '), ) } // skip if prop is undefined if (props[propName] === undefined) return // deprecation error and help const error = new Error(`The \`${propName}\` prop in \`${componentName}\` is deprecated.`) if (help) error.message += ` ${help}` // add optional validation error message if (validator) { if (typeof validator === 'function') { const validationError = validator(props, propName, componentName, ...args) if (validationError) { error.message = `${error.message} ${validationError.message}` } } else { throw new Error( [ 'Invalid argument supplied to deprecate, expected a function.', `See \`${propName}\` prop in \`${componentName}\`.`, ].join(' '), ) } } return error } /** A checker that matches the React.RefObject type. */ export const refObject = PropTypes.shape({ current: PropTypes.object, }) /** A checker that matches the React.Ref type. */ export const ref = PropTypes.oneOfType([PropTypes.func, refObject])