├── logo ├── logo.png └── logo.svg ├── .flowconfig ├── .gitignore ├── src ├── index.js ├── babelConfig.js ├── cli.js ├── utils.js ├── Logger.js └── ReactTester.js ├── libdefs.js ├── .babelrc ├── test ├── fixtures │ ├── Valid.js │ └── NoDefaultExport.js └── ReactTester.test.js ├── .travis.yml ├── .editorconfig ├── CHANGELOG.md ├── .eslintrc ├── LICENSE ├── package.json └── README.md /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diegohaz/singel/HEAD/logo/logo.png -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/dist 3 | .*/coverage 4 | 5 | [libs] 6 | ./libdefs.js 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache 3 | node_modules 4 | coverage 5 | dist 6 | *.log 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ReactTester from "./ReactTester"; 2 | 3 | export { ReactTester }; 4 | -------------------------------------------------------------------------------- /libdefs.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare module "react-known-props" { 3 | declare module.exports: any; 4 | } 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "node": 6 6 | } 7 | }], 8 | "stage-2", 9 | "react", 10 | "flow" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/Valid.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // eslint-disable-next-line 4 | const Valid = ({ children, ...props }) =>
{children}
; 5 | 6 | export default Valid; 7 | -------------------------------------------------------------------------------- /test/fixtures/NoDefaultExport.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // eslint-disable-next-line 4 | export const NoDefaultExport = ({ children, ...props }) => ( 5 |
{children}
6 | ); 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v8 4 | script: 5 | - npm run lint && npm test -- --coverage && npm run test:cli 6 | cache: 7 | - yarn 8 | after_success: 9 | - bash <(curl -s https://codecov.io/bash) 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /src/babelConfig.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = "development"; 2 | process.env.BABEL_ENV = "development"; 3 | 4 | const extensions = [ 5 | "css", 6 | "scss", 7 | "sass", 8 | "less", 9 | "styl", 10 | "png", 11 | "jpg", 12 | "jpeg", 13 | "gif", 14 | "md", 15 | "svg" 16 | ]; 17 | 18 | export default { 19 | presets: [require.resolve("babel-preset-react-app")], 20 | plugins: [ 21 | require.resolve("babel-plugin-transform-es2015-modules-commonjs"), 22 | [require.resolve("babel-plugin-transform-require-stub"), { extensions }] 23 | ] 24 | }; 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.5.4](https://github.com/diegohaz/singel/compare/v0.5.3...v0.5.4) (2018-07-29) 3 | 4 | 5 | ### Features 6 | 7 | * All window keys are now available as globals inside the tested code ([#25](https://github.com/diegohaz/singel/issues/25)) ([6068364](https://github.com/diegohaz/singel/commit/6068364)) 8 | 9 | 10 | 11 | 12 | ## [0.5.3](https://github.com/diegohaz/singel/compare/v0.5.2...v0.5.3) (2018-07-08) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * Ignore SVG files and warn when there's no default export ([#22](https://github.com/diegohaz/singel/issues/22)) ([ff7be24](https://github.com/diegohaz/singel/commit/ff7be24)) 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb", 5 | "plugin:flowtype/recommended", 6 | "plugin:prettier/recommended", 7 | "prettier/flowtype" 8 | ], 9 | "plugins": [ 10 | "flowtype", 11 | "flowtype-errors" 12 | ], 13 | "env": { 14 | "jest": true 15 | }, 16 | "rules": { 17 | "global-require": "off", 18 | "lines-between-class-members": "off", 19 | "flowtype-errors/show-errors": "error", 20 | "react/jsx-filename-extension": "off", 21 | "react/prop-types": "off", 22 | "react/destructuring-assignment": "off", 23 | "react/jsx-one-expression-per-line": "off", 24 | "jsx-a11y/alt-text": "off", 25 | "jsx-a11y/click-events-have-key-events": "off", 26 | "jsx-a11y/no-static-element-interactions": "off", 27 | "import/no-dynamic-require": "off", 28 | "import/extensions": "off", 29 | "import/prefer-default-export": "off" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Diego Haz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { resolve, relative, isAbsolute } from "path"; 3 | import program from "commander"; 4 | import glob from "glob"; 5 | import findBabelConfig from "find-babel-config"; 6 | import ReactTester from "./ReactTester"; 7 | import Logger from "./Logger"; 8 | import babelConfig from "./babelConfig"; 9 | import { version } from "../package.json"; 10 | 11 | program 12 | .version(version, "-v, --version") 13 | .option("-i, --ignore ", "Path to ignore") 14 | .parse(process.argv); 15 | 16 | const run = (paths, { ignore }) => { 17 | Logger.writeln(); 18 | 19 | const realPaths = paths.reduce( 20 | (acc, path) => [...acc, ...glob.sync(path, { ignore, nodir: true })], 21 | [] 22 | ); 23 | 24 | const { file } = findBabelConfig.sync(realPaths[0]); 25 | const finalBabelConfig = file 26 | ? { plugins: babelConfig.plugins } 27 | : babelConfig; 28 | 29 | require("babel-register")(finalBabelConfig); 30 | 31 | let hasErrors = false; 32 | let lastHasError = false; 33 | 34 | const exit = () => { 35 | Logger.summary(!lastHasError); 36 | process.exit(hasErrors ? 1 : 0); 37 | }; 38 | 39 | realPaths.forEach((path, i) => { 40 | const absolutePath = isAbsolute(path) ? path : resolve(process.cwd(), path); 41 | const relativePath = relative(process.cwd(), absolutePath); 42 | const { default: Element } = require(absolutePath); 43 | const tester = new ReactTester(Element); 44 | const logger = new Logger(Element, relativePath); 45 | 46 | if (!logger.validateElement()) return; 47 | logger.start(); 48 | 49 | tester.on("error", message => { 50 | hasErrors = true; 51 | logger.addError(message); 52 | }); 53 | 54 | tester.on("end", failed => { 55 | if (failed) { 56 | logger.fail(i > 0 && !lastHasError); 57 | lastHasError = true; 58 | } else { 59 | logger.succeed(); 60 | lastHasError = false; 61 | } 62 | }); 63 | 64 | tester.run(); 65 | }); 66 | 67 | exit(); 68 | }; 69 | 70 | run(program.args, program); 71 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { all as cssProps } from "known-css-properties"; 3 | import voidElements from "void-elements"; 4 | import { spy } from "sinon"; 5 | import { camelCase, intersection, difference } from "lodash"; 6 | import { 7 | getElementProps, 8 | getEventProps, 9 | getGlobalProps 10 | } from "react-known-props"; 11 | 12 | const reducer = (acc, key) => ({ ...acc, [key]: key }); 13 | 14 | export const findHTMLTags = (wrapper: Object): Object => 15 | wrapper.findWhere(el => typeof el.type() === "string"); 16 | 17 | export const findHTMLTag = (wrapper: Object): Object => 18 | findHTMLTags(wrapper).first(); 19 | 20 | export const getHTMLTag = (wrapper: Object): string | void => { 21 | const tag = findHTMLTag(wrapper); 22 | return tag.length ? tag.type() : undefined; 23 | }; 24 | 25 | export const getStyleProps = (): Object => 26 | cssProps 27 | .filter(prop => !/^-/.test(prop)) 28 | .map(camelCase) 29 | .reduce(reducer, {}); 30 | 31 | export const getReactProps = (type?: string): Object => { 32 | const excludeProps = ["style", "className", "dangerouslySetInnerHTML"]; 33 | const excludePropsRegex = new RegExp(`^${excludeProps.join("|")}$`); 34 | const props = type ? getElementProps(type) : getGlobalProps(); 35 | 36 | return props 37 | .filter(prop => !excludePropsRegex.test(prop)) 38 | .reduce(reducer, {}); 39 | }; 40 | 41 | export const getReactEventHandlers = (): Object => 42 | getEventProps().reduce((acc, key) => ({ ...acc, [key]: spy() }), {}); 43 | 44 | export const getEventName = (prop: string = ""): string => { 45 | const eventName = prop.replace(/^on/, ""); 46 | return eventName.charAt(0).toLowerCase() + eventName.slice(1); 47 | }; 48 | 49 | export const isVoidElement = (wrapper: Object): boolean => 50 | !!voidElements[getHTMLTag(wrapper)]; 51 | 52 | export const getMissingClassName = ( 53 | originalClassName: string = "", 54 | renderedClassName: string = "" 55 | ): string => { 56 | const originalArray = originalClassName.split(" "); 57 | const renderedArray = renderedClassName.split(" "); 58 | const intersectionArray = intersection(originalArray, renderedArray); 59 | const differenceArray = difference(originalArray, intersectionArray); 60 | return differenceArray.filter(c => c !== "undefined").join(" "); 61 | }; 62 | -------------------------------------------------------------------------------- /src/Logger.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* eslint-disable no-console */ 3 | import type { ComponentType } from "react"; 4 | import chalk from "chalk"; 5 | import ora from "ora"; 6 | 7 | type Options = { 8 | maxErrors: number 9 | }; 10 | 11 | // $FlowFixMe 12 | console.error = () => {}; 13 | 14 | class Logger { 15 | element: ComponentType; 16 | path: string; 17 | options: Options; 18 | loader: Object; 19 | errors: string[] = []; 20 | 21 | static elementsCount = 0; 22 | static totalErrorCount = 0; 23 | 24 | static summary(lineBreakAtTop: boolean) { 25 | if (lineBreakAtTop) { 26 | Logger.writeln(); 27 | } 28 | Logger.writeln(`${Logger.elementsCount} elements`); 29 | Logger.writeln( 30 | Logger.totalErrorCount 31 | ? `${chalk.red(`${Logger.totalErrorCount} errors`)}` 32 | : `${chalk.green("0 errors")}` 33 | ); 34 | Logger.writeln(); 35 | } 36 | 37 | static writeln(text: string = "") { 38 | return process.stdout.write(`${text}\n`); 39 | } 40 | 41 | constructor( 42 | Element: ComponentType, 43 | path: string, 44 | { maxErrors = 10 }: Options = {} 45 | ) { 46 | this.element = Element; 47 | this.path = path; 48 | this.options = { maxErrors }; 49 | Logger.elementsCount += 1; 50 | } 51 | 52 | start() { 53 | const elementName = this.element.displayName || this.element.name; 54 | this.loader = ora({ 55 | text: `${chalk.bold(elementName)} ${chalk.gray( 56 | chalk.underline(this.path) 57 | )}`, 58 | hideCursor: false 59 | }).start(); 60 | } 61 | 62 | validateElement() { 63 | if (this.element === undefined) { 64 | Logger.writeln( 65 | chalk.red(`ERROR: You are missing a default export in ${this.path}`) 66 | ); 67 | Logger.elementsCount -= 1; 68 | return false; 69 | } 70 | return true; 71 | } 72 | 73 | addError(message: string) { 74 | this.errors.push(` ${chalk.red(message)}`); 75 | Logger.totalErrorCount += 1; 76 | } 77 | 78 | fail(lineBreakAtTop: boolean) { 79 | const { loader, errors, options } = this; 80 | 81 | loader.clear(); 82 | 83 | if (lineBreakAtTop) { 84 | Logger.writeln(); 85 | } 86 | 87 | loader.enabled = true; 88 | loader.fail(); 89 | errors.slice(0, options.maxErrors).forEach(error => Logger.writeln(error)); 90 | 91 | if (errors.length > options.maxErrors) { 92 | const remaining = errors.length - options.maxErrors; 93 | Logger.writeln(chalk.yellow(` ... and ${remaining} more errors.`)); 94 | } 95 | Logger.writeln(); 96 | } 97 | 98 | succeed() { 99 | this.loader.enabled = true; 100 | this.loader.succeed(); 101 | } 102 | } 103 | 104 | export default Logger; 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "singel", 3 | "version": "0.5.4", 4 | "description": "Test React components using the Single Element Pattern (Singel)", 5 | "license": "MIT", 6 | "repository": "diegohaz/singel", 7 | "main": "dist/index.js", 8 | "author": { 9 | "name": "Diego Haz", 10 | "email": "hazdiego@gmail.com", 11 | "url": "https://github.com/diegohaz" 12 | }, 13 | "engines": { 14 | "node": ">=6" 15 | }, 16 | "bin": { 17 | "singel": "dist/cli.js" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "test": "jest", 24 | "test:cli": "babel-node src/cli.js test/fixtures/*.js", 25 | "coverage": "npm test -- --coverage", 26 | "postcoverage": "opn coverage/lcov-report/index.html", 27 | "lint": "eslint src test", 28 | "flow": "flow", 29 | "clean": "rimraf dist", 30 | "flowbuild": "flow-copy-source src dist", 31 | "prebuild": "npm run clean && npm run flowbuild", 32 | "build": "babel src -d dist", 33 | "preversion": "npm run lint && npm test && npm run build", 34 | "version": "standard-changelog && git add CHANGELOG.md", 35 | "postpublish": "git push origin master --follow-tags" 36 | }, 37 | "keywords": [ 38 | "single element pattern", 39 | "singel", 40 | "react" 41 | ], 42 | "dependencies": { 43 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 44 | "babel-plugin-transform-require-stub": "^0.3.0", 45 | "babel-preset-react-app": "^3.1.2", 46 | "babel-register": "^6.26.0", 47 | "chalk": "^2.4.1", 48 | "commander": "^2.16.0", 49 | "enzyme": "^3.3.0", 50 | "enzyme-adapter-react-16": "^1.1.1", 51 | "find-babel-config": "^1.1.0", 52 | "glob": "^7.1.2", 53 | "jsdom": "^11.12.0", 54 | "known-css-properties": "^0.6.1", 55 | "lodash": "^4.17.10", 56 | "ora": "^3.0.0", 57 | "raf": "^3.4.0", 58 | "react": "^16.4.1", 59 | "react-dom": "^16.4.1", 60 | "react-known-props": "^2.3.1", 61 | "sinon": "^6.1.4", 62 | "void-elements": "^3.1.0" 63 | }, 64 | "devDependencies": { 65 | "babel-cli": "^6.26.0", 66 | "babel-core": "^6.26.0", 67 | "babel-eslint": "^8.2.6", 68 | "babel-jest": "^23.4.2", 69 | "babel-preset-env": "^1.6.1", 70 | "babel-preset-flow": "^6.23.0", 71 | "babel-preset-react": "^6.24.1", 72 | "babel-preset-stage-2": "^6.24.1", 73 | "eslint": "^5.2.0", 74 | "eslint-config-airbnb": "^17.0.0", 75 | "eslint-config-prettier": "^2.9.0", 76 | "eslint-plugin-flowtype": "^2.50.0", 77 | "eslint-plugin-flowtype-errors": "^3.5.1", 78 | "eslint-plugin-import": "^2.13.0", 79 | "eslint-plugin-jsx-a11y": "^6.1.1", 80 | "eslint-plugin-prettier": "^2.6.2", 81 | "eslint-plugin-react": "^7.10.0", 82 | "flow-bin": "^0.77.0", 83 | "flow-copy-source": "^2.0.2", 84 | "jest-cli": "^23.4.2", 85 | "opn-cli": "^3.1.0", 86 | "prettier": "^1.14.0", 87 | "rimraf": "^2.6.2", 88 | "standard-changelog": "^2.0.0" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # singel 2 | 3 | [![Generated with nod](https://img.shields.io/badge/generator-nod-2196F3.svg?style=flat-square)](https://github.com/diegohaz/nod) 4 | [![NPM version](https://img.shields.io/npm/v/singel.svg?style=flat-square)](https://npmjs.org/package/singel) 5 | [![Build Status](https://img.shields.io/travis/diegohaz/singel/master.svg?style=flat-square)](https://travis-ci.org/diegohaz/singel) [![Coverage Status](https://img.shields.io/codecov/c/github/diegohaz/singel/master.svg?style=flat-square)](https://codecov.io/gh/diegohaz/singel/branch/master) 6 | 7 | **Single Element Pattern** (Singel) is a set of rules/best practices to create consistent, reliable and maintainable components in React and other component-based libraries. This is based on the idea that the **building blocks** of an application should resemble as most as possible native HTML elements. [**Read full article**](https://medium.freecodecamp.org/introducing-the-single-element-pattern-dfbd2c295c5d) 8 | 9 | This repo is a CLI tool for checking whether React components conform to the Singel pattern. 10 | 11 |
12 | Example 13 |

14 | 15 | ## Installation 16 | 17 | ```sh 18 | $ npm i -g singel 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```sh 24 | $ singel path/to/**/Component.js --ignore "path/to/**/ignored/Component.js" 25 | ``` 26 | 27 | ## Projects applying Singel 28 | 29 | > Feel free to send a PR adding your open source project 30 | 31 | - [Reakit](https://github.com/diegohaz/reakit) 32 | 33 | ## Rules 34 | 35 | ### Render only one element 36 | 37 | ```jsx 38 | // bad - 2 elements 39 | const Element = props => ( 40 |
41 | 42 |
43 | ); 44 | 45 | // good 46 | const Element = props => ( 47 |
48 | ); 49 | 50 | // good - if Element is good 51 | const Element2 = props => ( 52 | 53 | ); 54 | ``` 55 | 56 | ### Never break the app 57 | 58 | ```jsx 59 | // good 60 | const Element = props => ( 61 |
62 | ); 63 | 64 | // bad - will break if getId wasn't provided 65 | const Element = ({ getId, ...props }) => ( 66 |
67 | ); 68 | 69 | // bad - will break if foo wasn't provided 70 | const Element = ({ foo, ...props }) => ( 71 |
72 | ); 73 | ``` 74 | 75 | ### Render all HTML attributes passed as props 76 | 77 | ```jsx 78 | // good 79 | const Element = props => ( 80 |
81 | ); 82 | 83 | // bad - not rendering id 84 | const Element = ({ id, ...props }) => ( 85 |
86 | ); 87 | 88 | // good 89 | const Element = ({ id, ...props }) => ( 90 |
91 | ); 92 | ``` 93 | 94 | ### Always merge the styles passed as props 95 | 96 | ```jsx 97 | // good 98 | const Element = props => ( 99 |
100 | ); 101 | 102 | // bad - not rendering className 103 | const Element = ({ className, ...props }) => ( 104 |
105 | ); 106 | 107 | // bad - not rendering style 108 | const Element = ({ style, ...props }) => ( 109 |
110 | ); 111 | 112 | // bad - replacing className instead of appending 113 | const Element = props => ( 114 |
115 | ); 116 | 117 | // bad - replacing style instead of merging 118 | const Element = props => ( 119 |
120 | ); 121 | 122 | // good 123 | const Element = ({ className, ...props }) => ( 124 |
125 | ); 126 | 127 | // good 128 | const Element = ({ style, ...props }) => ( 129 |
130 | ); 131 | ``` 132 | 133 | ### Add all the event handlers passed as props 134 | 135 | ```jsx 136 | // good 137 | const Element = props => ( 138 |
139 | ); 140 | 141 | // bad - not passing onClick 142 | const Element = ({ onClick, ...props }) => ( 143 |
144 | ); 145 | 146 | // bad - replacing onClick prop 147 | const Element = props => ( 148 |
149 | ); 150 | 151 | // good 152 | const Element = ({ onClick, ...props }) => ( 153 |
154 | ); 155 | 156 | // good - it's ok to replace internal event handlers 157 | const Element = props => ( 158 |
159 | ); 160 | 161 | // good - calling internal and prop 162 | const callAll = (...fns) => (...args) => 163 | fns.forEach(fn => fn && fn(...args)); 164 | 165 | const Element = ({ onClick, ...props }) => ( 166 |
167 | ); 168 | ``` 169 | 170 | ## FAQ 171 | 172 | ### How to handle nested elements? 173 | 174 | Say you have a `Button` element and you want to display a `Tooltip` when it's hovered. The first rule you'll want to break is rendering only one element. To handle that you have some options: 175 | 176 | - Use CSS pseudo-elements (such as `:after` and `:before`); 177 | - Create a non-singel element, which is fine; 178 | - Nest styles instead of components. 179 | 180 | Here's an example of how you can accomplish tha latter one: 181 | 182 | ```css 183 | /* could also be CSS-in-JS */ 184 | .button { 185 | position: relative; 186 | /* more button css */ 187 | } 188 | 189 | .button:hover .tooltip { 190 | display: block; 191 | } 192 | 193 | .button .tooltip { 194 | display: none; 195 | position: absolute; 196 | /* more tooltip css */ 197 | } 198 | ``` 199 | 200 | ```jsx 201 | const Button = ({ className, ...props }) => ( 202 | 219 | ``` 220 | 221 | Both `Button` and `Button.Tooltip` are single elements. You have all the benefits you would have by nesting them, but now with complete control over `Button.Tooltip` from outside. 222 | 223 | ## License 224 | 225 | MIT © [Diego Haz](https://github.com/diegohaz) 226 | -------------------------------------------------------------------------------- /src/ReactTester.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from "react"; 3 | import type { ComponentType } from "react"; 4 | import { omit } from "lodash"; 5 | import { configure, mount } from "enzyme"; 6 | import Adapter from "enzyme-adapter-react-16"; 7 | import { JSDOM } from "jsdom"; 8 | import EventEmitter from "events"; 9 | import { 10 | findHTMLTags, 11 | getHTMLTag, 12 | findHTMLTag, 13 | isVoidElement, 14 | getReactProps, 15 | getMissingClassName, 16 | getStyleProps, 17 | getReactEventHandlers, 18 | getEventName 19 | } from "./utils"; 20 | 21 | configure({ adapter: new Adapter() }); 22 | 23 | const { window } = new JSDOM("", { 24 | pretendToBeVisual: true 25 | }); 26 | 27 | global.window = window; 28 | 29 | Object.keys(window) 30 | .filter(key => ["localStorage", "sessionStorage"].indexOf(key) === -1) 31 | .forEach(key => { 32 | if (typeof global[key] === "undefined") { 33 | global[key] = window[key]; 34 | } 35 | }); 36 | 37 | class Tester extends EventEmitter { 38 | element: ComponentType; 39 | 40 | constructor(Element: ComponentType) { 41 | super(); 42 | this.element = Element; 43 | } 44 | 45 | mount(props?: Object): Object { 46 | return mount(React.createElement(this.element, props)); 47 | } 48 | 49 | run() { 50 | let shouldStop = false; 51 | let failed = false; 52 | 53 | this.once("break", () => { 54 | shouldStop = true; 55 | }); 56 | 57 | this.once("error", () => { 58 | failed = true; 59 | }); 60 | 61 | const tests = [ 62 | this.testBreak, 63 | this.testOneElement, 64 | this.testChildren, 65 | this.testHTMLProps, 66 | this.testClassName, 67 | this.testStyle, 68 | this.testInternalStyle, 69 | this.testEventHandlers 70 | ]; 71 | 72 | this.emit("start"); 73 | tests.forEach(test => shouldStop || test()); 74 | this.emit("end", failed); 75 | } 76 | 77 | testBreak = () => { 78 | try { 79 | this.mount(); 80 | } catch (e) { 81 | this.emit("error", `Don't break: ${e.message}`); 82 | this.emit("break"); 83 | } 84 | }; 85 | 86 | testOneElement = () => { 87 | const wrapper = this.mount(); 88 | const tags = findHTMLTags(wrapper); 89 | const tagsString = tags.map(t => t.type()).join(" > "); 90 | if (tags.length > 1) { 91 | this.emit("error", `Render only one element: ${tagsString}`); 92 | } 93 | }; 94 | 95 | testChildren = () => { 96 | const wrapper = this.mount(); 97 | if (!getHTMLTag(wrapper)) return; 98 | if (!isVoidElement(wrapper)) { 99 | wrapper.setProps({ children: "children" }); 100 | if (!wrapper.contains("children")) { 101 | this.emit("error", "Render children passed as prop."); 102 | } 103 | } 104 | }; 105 | 106 | testHTMLProps = () => { 107 | const originalWrapper = this.mount(); 108 | if (!getHTMLTag(originalWrapper)) return; 109 | const type = findHTMLTag(originalWrapper).type(); 110 | const reactProps = getReactProps(type); 111 | const wrapper = this.mount(reactProps); 112 | const props = findHTMLTag(wrapper).props(); 113 | 114 | Object.keys(reactProps).forEach(prop => { 115 | if (!props[prop]) { 116 | this.emit("error", `Render HTML attributes passed as props: ${prop}`); 117 | } else if (props[prop] !== reactProps[prop]) { 118 | this.emit( 119 | "error", 120 | `Override internal HTML attributes with props: ${prop}` 121 | ); 122 | } 123 | }); 124 | }; 125 | 126 | testClassName = () => { 127 | const originalWrapper = this.mount(); 128 | if (!getHTMLTag(originalWrapper)) return; 129 | 130 | const originalClassName = findHTMLTag(originalWrapper).prop("className"); 131 | 132 | const className = "foobarbaz"; 133 | const wrapper = this.mount({ className }); 134 | const renderedClassName = findHTMLTag(wrapper).prop("className") || ""; 135 | const renderedClassNames = renderedClassName.split(" "); 136 | 137 | if (!renderedClassNames.includes(className)) { 138 | this.emit("error", "Render className passed as prop."); 139 | } 140 | 141 | const missingClassName = getMissingClassName( 142 | originalClassName, 143 | renderedClassName 144 | ); 145 | 146 | if (missingClassName) { 147 | this.emit( 148 | "error", 149 | `Don't override internal className: ${missingClassName}` 150 | ); 151 | } 152 | }; 153 | 154 | testStyle = () => { 155 | const style = getStyleProps(); 156 | const wrapper = this.mount({ style }); 157 | if (!getHTMLTag(wrapper)) return; 158 | 159 | const renderedStyle = findHTMLTag(wrapper).prop("style") || {}; 160 | 161 | Object.keys(style).forEach(prop => { 162 | if (typeof renderedStyle[prop] === "undefined") { 163 | this.emit("error", `Render style passed as prop: ${prop}`); 164 | } else if (renderedStyle[prop] !== style[prop]) { 165 | this.emit("error", `Override internal style prop with props: ${prop}`); 166 | } 167 | }); 168 | }; 169 | 170 | testInternalStyle = () => { 171 | const originalWrapper = this.mount(); 172 | if (!getHTMLTag(originalWrapper)) return; 173 | 174 | const originalStyle = findHTMLTag(originalWrapper).prop("style") || {}; 175 | const style = omit(getStyleProps(), Object.keys(originalStyle)); 176 | const wrapper = this.mount({ style }); 177 | const renderedStyle = findHTMLTag(wrapper).prop("style") || {}; 178 | 179 | Object.keys(originalStyle).forEach(prop => { 180 | if (!renderedStyle[prop]) { 181 | this.emit( 182 | "error", 183 | `Don't override the entire internal style with props: ${prop}` 184 | ); 185 | } 186 | }); 187 | }; 188 | 189 | testEventHandlers = () => { 190 | const eventHandlers = getReactEventHandlers(); 191 | const wrapper = this.mount(eventHandlers); 192 | if (!getHTMLTag(wrapper)) return; 193 | 194 | Object.keys(eventHandlers).forEach(prop => { 195 | const event = eventHandlers[prop]; 196 | try { 197 | wrapper.simulate(getEventName(prop)); 198 | } catch (e) { 199 | this.emit("error", `Don't break: ${e.message}`); 200 | } 201 | 202 | if (!event.called) { 203 | this.emit("error", `Call event handlers passed as props: ${prop}`); 204 | } else { 205 | const [argument] = event.getCall(0).args; 206 | if (!argument || argument.constructor.name !== "SyntheticEvent") { 207 | this.emit( 208 | "error", 209 | `Pass SyntheticEvent to event handlers passed as props: ${prop}` 210 | ); 211 | } 212 | } 213 | }); 214 | }; 215 | } 216 | 217 | export default Tester; 218 | -------------------------------------------------------------------------------- /test/ReactTester.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactTester from "../src/ReactTester"; 3 | 4 | // eslint-disable-next-line no-console 5 | console.error = jest.fn(); 6 | 7 | const expectError = (element, methodName = "run", not = false) => { 8 | const listener = jest.fn(); 9 | const tester = new ReactTester(element); 10 | tester.on("error", listener); 11 | tester[methodName](); 12 | if (not) { 13 | expect(listener).not.toHaveBeenCalled(); 14 | } else { 15 | expect(listener).toHaveBeenCalledWith(expect.any(String)); 16 | } 17 | }; 18 | 19 | const expectNoError = (element, methodName) => 20 | expectError(element, methodName, true); 21 | 22 | describe("testBreak", () => { 23 | test("good", () => { 24 | const Element = ({ getId, ...props }) => { 25 | const id = getId ? getId() : "id"; 26 | return
; 27 | }; 28 | expectNoError(Element); 29 | expectNoError(Element, "testBreak"); 30 | }); 31 | 32 | test("bad", () => { 33 | const Element = ({ getId, ...props }) =>
; 34 | expectError(Element); 35 | expectError(Element, "testBreak"); 36 | }); 37 | }); 38 | 39 | describe("testOneElement", () => { 40 | test("good", () => { 41 | const Element = props =>
; 42 | expectNoError(Element); 43 | expectNoError(Element, "testOneElement"); 44 | }); 45 | 46 | test("good - component renders null", () => { 47 | const Element = () => null; 48 | expectNoError(Element); 49 | expectNoError(Element, "testOneElement"); 50 | }); 51 | 52 | test("good - composing", () => { 53 | const Element = props =>
; 54 | const Element2 = props => ; 55 | expectNoError(Element2); 56 | expectNoError(Element2, "testOneElement"); 57 | }); 58 | 59 | test("bad", () => { 60 | const Element = props => ( 61 |
62 | 63 |
64 | ); 65 | expectError(Element); 66 | expectError(Element, "testOneElement"); 67 | }); 68 | }); 69 | 70 | describe("testChildren", () => { 71 | test("good", () => { 72 | const Element = props =>
; 73 | expectNoError(Element); 74 | expectNoError(Element, "testChildren"); 75 | }); 76 | 77 | test("good - void element", () => { 78 | const Element = ({ children, ...props }) => ; 79 | expectNoError(Element); 80 | expectNoError(Element, "testChildren"); 81 | }); 82 | 83 | test("good - composing", () => { 84 | const Element = props =>
; 85 | const Element2 = props => ; 86 | expectNoError(Element2); 87 | expectNoError(Element2, "testChildren"); 88 | }); 89 | 90 | test("bad", () => { 91 | const Element = ({ children, ...props }) =>
; 92 | expectError(Element); 93 | expectError(Element, "testChildren"); 94 | }); 95 | }); 96 | 97 | describe("testHTMLProps", () => { 98 | test("good", () => { 99 | const Element = props =>
; 100 | expectNoError(Element); 101 | expectNoError(Element, "testHTMLProps"); 102 | }); 103 | 104 | test("bad", () => { 105 | const Element = ({ children }) =>
{children}
; 106 | expectError(Element); 107 | expectError(Element, "testHTMLProps"); 108 | }); 109 | }); 110 | 111 | describe("testClassName", () => { 112 | test("good", () => { 113 | const Element = props =>
; 114 | expectNoError(Element); 115 | expectNoError(Element, "testClassName"); 116 | }); 117 | 118 | test("good - appending className", () => { 119 | const Element = ({ className, ...props }) => ( 120 |
121 | ); 122 | expectNoError(Element); 123 | expectNoError(Element, "testClassName"); 124 | }); 125 | 126 | test("bad - not rendering className", () => { 127 | const Element = ({ className, ...props }) =>
; 128 | expectError(Element); 129 | expectError(Element, "testClassName"); 130 | }); 131 | 132 | test("bad - replacing instead of appending", () => { 133 | const Element = props =>
; 134 | expectError(Element); 135 | expectError(Element, "testClassName"); 136 | }); 137 | }); 138 | 139 | describe("testStyle", () => { 140 | test("good", () => { 141 | const Element = props =>
; 142 | expectNoError(Element); 143 | expectNoError(Element, "testStyle"); 144 | }); 145 | 146 | test("good - appending style", () => { 147 | const Element = ({ style, ...props }) => ( 148 |
149 | ); 150 | expectNoError(Element); 151 | expectNoError(Element, "testStyle"); 152 | }); 153 | 154 | test("bad - override style props", () => { 155 | const Element = ({ style, ...props }) => ( 156 |
157 | ); 158 | expectError(Element); 159 | expectError(Element, "testStyle"); 160 | }); 161 | 162 | test("bad - not rendering style", () => { 163 | const Element = ({ style, ...props }) =>
; 164 | expectError(Element); 165 | expectError(Element, "testStyle"); 166 | }); 167 | }); 168 | 169 | describe("testInternalStyle", () => { 170 | test("bad - replacing instead of appending", () => { 171 | const Element = props =>
; 172 | expectError(Element); 173 | expectError(Element, "testInternalStyle"); 174 | }); 175 | }); 176 | 177 | describe("testEventHandlers", () => { 178 | test("good", () => { 179 | const Element = props =>
; 180 | expectNoError(Element); 181 | expectNoError(Element, "testEventHandlers"); 182 | }); 183 | 184 | test("good - replacing original onClick", () => { 185 | const Element = props =>
; 186 | expectNoError(Element); 187 | expectNoError(Element, "testEventHandlers"); 188 | }); 189 | 190 | test("good - composing event handler", () => { 191 | const callAll = (...fns) => (...args) => 192 | fns.forEach(fn => fn && fn(...args)); 193 | 194 | const Element = ({ onClick, ...props }) => ( 195 |
196 | ); 197 | expectNoError(Element); 198 | expectNoError(Element, "testEventHandlers"); 199 | }); 200 | 201 | test("bad - not passing onClick", () => { 202 | const Element = ({ onClick, ...props }) =>
; 203 | expectError(Element); 204 | expectError(Element, "testEventHandlers"); 205 | }); 206 | 207 | test("bad - not passing args to onClick", () => { 208 | const Element = ({ onClick, ...props }) => ( 209 |
onClick()} {...props} /> 210 | ); 211 | expectError(Element); 212 | expectError(Element, "testEventHandlers"); 213 | }); 214 | 215 | test("bad - replacing onClick prop", () => { 216 | const Element = props =>
; 217 | expectError(Element); 218 | expectError(Element, "testEventHandlers"); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Untitled 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 42 | 43 | --------------------------------------------------------------------------------