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(customPropTypes): add suggest prop type checker #944

Merged
merged 10 commits into from
Nov 27, 2016
3 changes: 2 additions & 1 deletion docs/app/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/Faker/<%= htmlWebpackPlugin.options.versions.faker %>/faker.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/js-beautify/<%= htmlWebpackPlugin.options.versions.jsBeautify %>/beautify-html.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/react/<%= htmlWebpackPlugin.options.versions.react %>/react.min.js"></script>
<!-- Use unminified React when not in production so we get errors and warnings -->
<script src="//cdnjs.cloudflare.com/ajax/libs/react/<%= htmlWebpackPlugin.options.versions.react %>/react<%= __PROD__ ? '.min' : '' %>.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/react/<%= htmlWebpackPlugin.options.versions.reactDOM %>/react-dom.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/react/<%= htmlWebpackPlugin.options.versions.reactDOM %>/react-dom-server.min.js"></script>
<style>
Expand Down
2 changes: 1 addition & 1 deletion src/elements/Flag/Flag.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Flag.propTypes = {
className: PropTypes.string,

/** Flag name, can use the two digit country code, the full name, or a common alias */
name: PropTypes.oneOf(Flag._meta.props.name).isRequired,
name: customPropTypes.suggest(Flag._meta.props.name),
}

Flag.defaultProps = {
Expand Down
2 changes: 1 addition & 1 deletion src/elements/Icon/Icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Icon.propTypes = {
inverted: PropTypes.bool,

/** Name of the icon */
name: PropTypes.oneOf(Icon._meta.props.name),
name: customPropTypes.suggest(Icon._meta.props.name),

/** Icon can be formatted as a link */
link: PropTypes.bool,
Expand Down
301 changes: 168 additions & 133 deletions src/lib/SUI.js

Large diffs are not rendered by default.

80 changes: 68 additions & 12 deletions src/lib/customPropTypes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PropTypes } from 'react'
import _ from 'lodash/fp'
import leven from './leven'

const typeOf = (...args) => Object.prototype.toString.call(...args)

Expand All @@ -11,6 +12,66 @@ export const as = (...args) => PropTypes.oneOfType([
PropTypes.func,
])(...args)

/**
* 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 => {
return (props, propName, componentName) => {
if (!Array.isArray(suggestions)) {
throw new Error([
'Invalid argument supplied to suggest, expected an instance of array.',
` See \`${propName}\` prop in \`${componentName}\`.`,
].join(''))
}
const propValue = props[propName]

// skip if prop is undefined or is included in the suggestions
if (_.isNil(propValue) || propValue === false || _.includes(propValue, suggestions)) return

// find best suggestions
const propValueWords = propValue.split(' ')

/* eslint-disable max-nested-callbacks */
const bestMatches = _.flow(
_.map(suggestion => {
const suggestionWords = suggestion.split(' ')

const propValueScore = _.flow(
_.map(x => _.map(y => leven(x, y), suggestionWords)),
_.map(_.min),
_.sum
)(propValueWords)

const suggestionScore = _.flow(
_.map(x => _.map(y => leven(x, y), propValueWords)),
_.map(_.min),
_.sum
)(suggestionWords)

return { suggestion, score: propValueScore + suggestionScore }
}),
_.sortBy(['score', 'suggestion']),
_.take(3)
)(suggestions)
/* eslint-enable max-nested-callbacks */

// skip if a match scored 0
// since we're matching on words (classNames) this allows any word order to pass validation
// e.g. `left chevron` vs `chevron left`
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 form being defined with this prop.
* @param {string[]} disallowedProps An array of props that cannot be used with this prop.
Expand All @@ -19,8 +80,8 @@ export const disallow = disallowedProps => {
return (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}\`.`,
'Invalid argument supplied to disallow, expected an instance of array.',
` See \`${propName}\` prop in \`${componentName}\`.`,
].join(''))
}

Expand All @@ -35,7 +96,6 @@ export const disallow = disallowedProps => {
return acc
}, [])


if (disallowed.length > 0) {
return new Error([
`Prop \`${propName}\` in \`${componentName}\` conflicts with props: \`${disallowed.join('`, `')}\`.`,
Expand All @@ -61,9 +121,7 @@ export const every = (validators) => {
const errors = _.flow(
_.map(validator => {
if (typeof validator !== 'function') {
throw new Error(
`every() argument "validators" should contain functions, found: ${typeOf(validator)}.`
)
throw new Error(`every() argument "validators" should contain functions, found: ${typeOf(validator)}.`)
}
return validator(props, propName, componentName, ...rest)
}),
Expand All @@ -90,9 +148,7 @@ export const some = (validators) => {

const errors = _.compact(_.map(validators, validator => {
if (!_.isFunction(validator)) {
throw new Error(
`some() argument "validators" should contain functions, found: ${typeOf(validator)}.`
)
throw new Error(`some() argument "validators" should contain functions, found: ${typeOf(validator)}.`)
}
return validator(props, propName, componentName, ...rest)
}))
Expand Down Expand Up @@ -163,8 +219,8 @@ export const demand = (requiredProps) => {
return (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}\`.`,
'Invalid `requiredProps` argument supplied to require, expected an instance of array.',
` See \`${propName}\` prop in \`${componentName}\`.`,
].join(''))
}

Expand All @@ -174,7 +230,7 @@ export const demand = (requiredProps) => {
const missingRequired = requiredProps.filter(requiredProp => props[requiredProp] === undefined)
if (missingRequired.length > 0) {
return new Error(
`\`${propName}\` prop in \`${componentName}\` requires props: \`${missingRequired.join('`, `')}\`.`,
`\`${propName}\` prop in \`${componentName}\` requires props: \`${missingRequired.join('`, `')}\`.`
)
}
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './factories'
export { default as getUnhandledProps } from './getUnhandledProps'
export { default as getElementType } from './getElementType'
export { default as isBrowser } from './isBrowser'
export { default as leven } from './leven'
export * as META from './META'
export * as SUI from './SUI'

Expand Down
48 changes: 48 additions & 0 deletions src/lib/leven.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copy of sindre's leven, wrapped in dead code elimination for production
// https://github.com/sindresorhus/leven/blob/master/index.js

let leven = () => 0

if (process.env.NODE_ENV !== 'production') {
/* eslint-disable complexity, no-nested-ternary */
const arr = []
const charCodeCache = []

leven = (a, b) => {
if (a === b) return 0

const aLen = a.length
const bLen = b.length

if (aLen === 0) return bLen
if (bLen === 0) return aLen

let bCharCode
let ret
let tmp
let tmp2
let i = 0
let j = 0

while (i < aLen) {
charCodeCache[i] = a.charCodeAt(i)
arr[i] = ++i
}

while (j < bLen) {
bCharCode = b.charCodeAt(j)
tmp = j++
ret = j

for (i = 0; i < aLen; i++) {
tmp2 = bCharCode === charCodeCache[i] ? tmp : tmp + 1
tmp = arr[i]
ret = arr[i] = tmp > ret ? tmp2 > ret ? ret + 1 : tmp2 : tmp2 > tmp ? tmp + 1 : tmp2
}
}

return ret
}
}

export default leven