Add fixture components (#8860)

* add fixture components

* add a few more options and styles

* Test fixture updates

- Pull in React from window global
- Add field to TestCase for fix PR links
- Update some styles

* Remove unused Fixture.Result comment

* Remove leading # from resolvedBy link

* Implement tag loading utility that caches response

Don't bust the cache doing feature detection

COME ON

* Use 'local' without version for option
This commit is contained in:
Jason Quense 2017-03-02 14:38:27 -05:00 committed by Brandon Dail
parent a190cfce29
commit 2757a53fa5
15 changed files with 701 additions and 18 deletions

View File

@ -6,9 +6,11 @@
"react-scripts": "0.8.4"
},
"dependencies": {
"classnames": "^2.2.5",
"query-string": "^4.2.3",
"react": "^15.4.1",
"react-dom": "^15.4.1"
"react-dom": "^15.4.1",
"semver": "^5.3.0"
},
"scripts": {
"start": "react-scripts start",

View File

@ -0,0 +1,21 @@
const React = window.React;
const propTypes = {
children: React.PropTypes.node.isRequired,
};
class Fixture extends React.Component {
render() {
const { children } = this.props;
return (
<div className="test-fixture">
{children}
</div>
);
}
}
Fixture.propTypes = propTypes;
export default Fixture

View File

@ -0,0 +1,28 @@
import React from 'react';
const propTypes = {
title: React.PropTypes.node.isRequired,
description: React.PropTypes.node.isRequired,
};
class FixtureSet extends React.Component {
render() {
const { title, description, children } = this.props;
return (
<div>
<h1>{title}</h1>
{description && (
<p>{description}</p>
)}
{children}
</div>
);
}
}
FixtureSet.propTypes = propTypes;
export default FixtureSet

View File

@ -1,4 +1,5 @@
import { parse, stringify } from 'query-string';
import getVersionTags from '../tags';
const React = window.React;
const Header = React.createClass({
@ -9,13 +10,12 @@ const Header = React.createClass({
return { version, versions };
},
componentWillMount() {
fetch('https://api.github.com/repos/facebook/react/tags', { mode: 'cors' })
.then(res => res.json())
getVersionTags()
.then(tags => {
let versions = tags.map(tag => tag.name.slice(1));
versions = ['local', ...versions];
versions = [`local`, ...versions];
this.setState({ versions });
});
})
},
handleVersionChange(event) {
const query = parse(window.location.search);
@ -46,6 +46,7 @@ const Header = React.createClass({
<option value="/text-inputs">Text Inputs</option>
<option value="/selects">Selects</option>
<option value="/textareas">Textareas</option>
<option value="/input-change-events">Input change events</option>
</select>
</label>
<label htmlFor="react_version">

View File

@ -0,0 +1,145 @@
import cn from 'classnames';
import semver from 'semver';
import React from 'react';
import { parse } from 'query-string';
import { semverString } from './propTypes'
const propTypes = {
children: React.PropTypes.node.isRequired,
title: React.PropTypes.node.isRequired,
resolvedIn: semverString,
resolvedBy: React.PropTypes.string
};
class TestCase extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
complete: false,
};
}
handleChange = (e) => {
this.setState({
complete: e.target.checked
})
};
render() {
const {
title,
description,
resolvedIn,
resolvedBy,
affectedBrowsers,
children,
} = this.props;
let { complete } = this.state;
const { version } = parse(window.location.search);
const isTestRelevant = (
!version ||
!resolvedIn ||
semver.gte(version, resolvedIn)
);
complete = !isTestRelevant || complete;
return (
<section
className={cn(
"test-case",
complete && 'test-case--complete'
)}
>
<h2 className="test-case__title type-subheading">
<label>
<input
className="test-case__title__check"
type="checkbox"
checked={complete}
onChange={this.handleChange}
/>
{' '}{title}
</label>
</h2>
<dl className="test-case__details">
{resolvedIn && (
<dt>First supported in: </dt>)}
{resolvedIn && (
<dd>
<a href={'https://github.com/facebook/react/tag/v' + resolvedIn}>
<code>{resolvedIn}</code>
</a>
</dd>
)}
{resolvedBy && (
<dt>Fixed by: </dt>)}
{resolvedBy && (
<dd>
<a href={'https://github.com/facebook/react/pull/' + resolvedBy.slice(1)}>
<code>{resolvedBy}</code>
</a>
</dd>
)}
{affectedBrowsers &&
<dt>Affected browsers: </dt>}
{affectedBrowsers &&
<dd>{affectedBrowsers}</dd>
}
</dl>
<p className="test-case__desc">
{description}
</p>
<div className="test-case__body">
{!isTestRelevant &&(
<p className="test-case__invalid-version">
<strong>Note:</strong> This test case was fixed in a later version of React.
This test is not expected to pass for the selected version, and that's ok!
</p>
)}
{children}
</div>
</section>
);
}
}
TestCase.propTypes = propTypes;
TestCase.Steps = class extends React.Component {
render() {
const { children } = this.props;
return (
<div>
<h3>Steps to reproduce:</h3>
<ol>
{children}
</ol>
</div>
)
}
}
TestCase.ExpectedResult = class extends React.Component {
render() {
const { children } = this.props
return (
<div>
<h3>Expected Result:</h3>
<p>
{children}
</p>
</div>
)
}
}
export default TestCase

View File

@ -2,7 +2,8 @@ const React = window.React;
import RangeInputFixtures from './range-inputs';
import TextInputFixtures from './text-inputs';
import SelectFixtures from './selects';
import TextAreaFixtures from './textareas/';
import TextAreaFixtures from './textareas';
import InputChangeEvents from './input-change-events';
/**
* A simple routing component that renders the appropriate
@ -19,6 +20,8 @@ const FixturesPage = React.createClass({
return <SelectFixtures />;
case '/textareas':
return <TextAreaFixtures />;
case '/input-change-events':
return <InputChangeEvents />;
default:
return <p>Please select a test fixture.</p>;
}

View File

@ -0,0 +1,61 @@
import React from 'react';
import Fixture from '../../Fixture';
class InputPlaceholderFixture extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
placeholder: 'A placeholder',
changeCount: 0,
};
}
handleChange = () => {
this.setState(({ changeCount }) => {
return {
changeCount: changeCount + 1
}
})
}
handleGeneratePlaceholder = () => {
this.setState({
placeholder: `A placeholder: ${Math.random() * 100}`
})
}
handleReset = () => {
this.setState({
changeCount: 0,
})
}
render() {
const { placeholder, changeCount } = this.state;
const color = changeCount === 0 ? 'green' : 'red';
return (
<Fixture>
<input
type='text'
placeholder={placeholder}
onChange={this.handleChange}
/>
{' '}
<button onClick={this.handleGeneratePlaceholder}>
Change placeholder
</button>
<p style={{ color }}>
<code>onChange</code>{' calls: '}<strong>{changeCount}</strong>
</p>
<button onClick={this.handleReset}>Reset count</button>
</Fixture>
)
}
}
export default InputPlaceholderFixture;

View File

@ -0,0 +1,52 @@
import React from 'react';
import Fixture from '../../Fixture';
class RadioClickFixture extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
changeCount: 0,
};
}
handleChange = () => {
this.setState(({ changeCount }) => {
return {
changeCount: changeCount + 1
}
})
}
handleReset = () => {
this.setState({
changeCount: 0,
})
}
render() {
const { changeCount } = this.state;
const color = changeCount === 0 ? 'green' : 'red';
return (
<Fixture>
<label>
<input
defaultChecked
type='radio'
onChange={this.handleChange}
/>
Test case radio input
</label>
{' '}
<p style={{ color }}>
<code>onChange</code>{' calls: '}<strong>{changeCount}</strong>
</p>
<button onClick={this.handleReset}>Reset count</button>
</Fixture>
)
}
}
export default RadioClickFixture;

View File

@ -0,0 +1,75 @@
import React from 'react';
import Fixture from '../../Fixture';
class RangeKeyboardFixture extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
keydownCount: 0,
changeCount: 0,
};
}
componentDidMount() {
this.input.addEventListener('keydown', this.handleKeydown, false)
}
componentWillUnmount() {
this.input.removeEventListener('keydown', this.handleKeydown, false)
}
handleChange = () => {
this.setState(({ changeCount }) => {
return {
changeCount: changeCount + 1
}
})
}
handleKeydown = (e) => {
// only interesting in arrow key events
if (![37, 38, 39, 40].includes(e.keyCode))
return;
this.setState(({ keydownCount }) => {
return {
keydownCount: keydownCount + 1
}
})
}
handleReset = () => {
this.setState({
keydownCount: 0,
changeCount: 0,
})
}
render() {
const { keydownCount, changeCount } = this.state;
const color = keydownCount === changeCount ? 'green' : 'red';
return (
<Fixture>
<input
type='range'
ref={r => this.input = r}
onChange={this.handleChange}
/>
{' '}
<p style={{ color }}>
<code>onKeyDown</code>{' calls: '}<strong>{keydownCount}</strong>
{' vs '}
<code>onChange</code>{' calls: '}<strong>{changeCount}</strong>
</p>
<button onClick={this.handleReset}>Reset counts</button>
</Fixture>
)
}
}
export default RangeKeyboardFixture;

View File

@ -0,0 +1,81 @@
import React from 'react';
import FixtureSet from '../../FixtureSet';
import TestCase from '../../TestCase';
import RangeKeyboardFixture from './RangeKeyboardFixture';
import RadioClickFixture from './RadioClickFixture';
import InputPlaceholderFixture from './InputPlaceholderFixture';
class InputChangeEvents extends React.Component {
render() {
return (
<FixtureSet
title="Input change events"
description="Tests proper behavior of the onChange event for inputs"
>
<TestCase
title="Range keyboard changes"
description={`
Range inputs should fire onChange events for keyboard events
`}
>
<TestCase.Steps>
<li>Focus range input</li>
<li>change value via the keyboard arrow keys</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
The <code>onKeyDown</code> call count should be equal to
the <code>onChange</code> call count.
</TestCase.ExpectedResult>
<RangeKeyboardFixture />
</TestCase>
<TestCase
title="Radio input clicks"
description={`
Radio inputs should only fire change events when the checked
state changes.
`}
resolvedIn="16.0.0"
>
<TestCase.Steps>
<li>Click on the Radio input (or label text)</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
The <code>onChange</code> call count should remain at 0
</TestCase.ExpectedResult>
<RadioClickFixture />
</TestCase>
<TestCase
title="Inputs with placeholders"
description={`
Text inputs with placeholders should not trigger changes
when the placeholder is altered
`}
resolvedIn="15.0.0"
resolvedBy="#5004"
affectedBrowsers="IE9+"
>
<TestCase.Steps>
<li>Click on the Text input</li>
<li>Click on the "Change placeholder" button</li>
</TestCase.Steps>
<TestCase.ExpectedResult>
The <code>onChange</code> call count should remain at 0
</TestCase.ExpectedResult>
<InputPlaceholderFixture />
</TestCase>
</FixtureSet>
);
}
}
export default InputChangeEvents

View File

@ -24,7 +24,7 @@ const TextInputFixtures = React.createClass({
return (
<div key={type} className="field">
<label htmlFor={id}>{type}</label>
<label className="control-label" htmlFor={id}>{type}</label>
<input id={id} type={type} value={state} onChange={onChange} />
&nbsp; &rarr; {JSON.stringify(state)}
</div>
@ -35,7 +35,7 @@ const TextInputFixtures = React.createClass({
let id = `uncontrolled_${type}`;
return (
<div key={type} className="field">
<label htmlFor={id}>{type}</label>
<label className="control-label" htmlFor={id}>{type}</label>
<input id={id} type={type} />
</div>
);

View File

@ -0,0 +1,16 @@
import semver from 'semver';
const React = window.React;
export function semverString (props, propName, componentName) {
let version = props[propName];
let error = React.PropTypes.string(...arguments);
if (!error && version != null && !semver.valid(version))
error = new Error(
`\`${propName}\` should be a valid "semantic version" matching ` +
'an existing React version'
);
return error || null;
};

View File

@ -4,22 +4,28 @@
box-sizing: border-box;
}
html {
font-size: 10px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size: 1.4rem;
margin: 0;
padding: 0;
}
select {
width: 120px;
width: 12rem;
}
.header {
background: #222;
box-shadow: inset 0 -1px 3px #000;
line-height: 32px;
font-size: 1.6rem;
line-height: 3.2rem;
overflow: hidden;
padding: 8px 16px;
padding: .8rem 1.6rem;
}
.header__inner {
@ -40,7 +46,7 @@ select {
.header__logo img {
display: inline-block;
margin-right: 8px;
margin-right: 0.8rem;
vertical-align: middle;
}
@ -63,24 +69,142 @@ select {
margin: 0 auto;
max-width: 900px;
overflow: hidden;
padding: 20px;
padding: 2rem;
}
label {
.control-label {
display: block;
font-size: 12px;
font-size: 1.2rem;
letter-spacing: 0.01em;
margin-bottom: 4px;
margin-bottom: 0.4rem;
text-transform: uppercase;
}
.field {
padding: 8px;
padding: 0.8rem;
}
fieldset {
border: 1px solid #aaa;
float: left;
padding: 16px;
padding: 1.6rem;
width: 49%;
}
.control-box {
overflow: hidden;
}
ul, ol {
margin: 0 0 2rem 0;
}
li {
margin-bottom: 0.4rem;
}
.type-subheading {
font-size: 1.8rem;
font-weight: 600;
line-height: 1.5;
margin: 0 0 1.6rem;
}
.hint {
font-style: italic;
line-height: 1.5;
text-size: 1.4rem;
}
.footnote {
border-left: 4px solid #aaa;
color: #444;
font-style: italic;
line-height: 1.5;
margin-bottom: 2.4rem;
margin-left: 0.4rem;
padding-left: 1.6rem;
text-size: 1.3rem;
}
.test-case {
border-radius: 0.2rem;
border: 1px solid #d9d9d9;
margin: 3.2rem 0 3.2rem;
}
.test-case__title {
padding: 10px 15px 8px;
line-height: 16px;
font-size: 18px;
border-bottom: 1px dashed #d9d9d9;
margin: 0 0 -1px;
}
.test-case__title__check {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.test-case__body {
padding: 0 15px;
}
.test-case__desc {
font-style: italic;
margin: 15px 0;
padding: 0 15px;
}
.test-case--complete {
border-color: #5cb85c;
}
.test-case--complete .test-case__title {
color: #5cb85c;
}
.test-case__details {
border-bottom: 1px dashed #d9d9d9;
font-size: 80%;
text-transform: uppercase;
letter-spacing: 0.02em;
margin: 0;
padding: 0 15px;
}
.test-case__details > * {
display: inline-block;
}
.test-case__details > dt,
.test-case__details > dd {
padding: 8px 0 6px;
}
.test-case__details > dt {
color: #464a4c;
font-weight: 600;
}
.test-case__details > dd {
margin-left: 1rem;
}
.test-case__details > dd + dt {
margin-left: 1.5rem;
}
.test-case__invalid-version {
font-style: italic;
font-size: 1.6rem;
color: #5cb85c;
}
.test-fixture {
padding: 20px;
margin: 0 -15px; /* opposite of .test-case padding */
background-color: #f4f4f4;
border-top: 1px solid #d9d9d9;
}

70
fixtures/dom/src/tags.js Normal file
View File

@ -0,0 +1,70 @@
/**
* Version tags are loaded from the Github API. Since the Github API is rate-limited
* we attempt to save and load the tags in sessionStorage when possible. Since its unlikely
* that versions will change during a single session this should be safe.
*/
const TAGS_CACHE_KEY = '@react-dom-fixtures/tags';
/**
* Its possible that users will be testing changes frequently
* in a browser that does not support sessionStorage. If the API does
* get rate limited this hardcoded fallback will be loaded instead.
* This way users can still switch between ~some versions while testing.
* If there's a specific version they need to test that is not here, they
* can manually load it by editing the URL (`?version={whatever}`)
*/
const fallbackTags = [
'15.4.2',
'15.3.2',
'15.2.1',
'15.1.0',
'15.0.2',
'0.14.8',
'0.13.0'
].map(version => ({
name: `v${version}`
}))
let canUseSessionStorage = true;
try {
sessionStorage.setItem('foo', '')
} catch (err) {
canUseSessionStorage = false;
}
/**
* Attempts to load tags from sessionStorage. In cases where
* sessionStorage is not available (Safari private browsing) or the
* tags are cached a fetch request is made to the Github API.
*
* Returns a promise so that the consuming module can always assume
* the request is async, even if its loaded from sessionStorage.
*/
export default function getVersionTags() {
return new Promise((resolve) => {
let cachedTags;
if (canUseSessionStorage) {
cachedTags = sessionStorage.getItem(TAGS_CACHE_KEY);
}
if (cachedTags) {
cachedTags = JSON.parse(cachedTags);
resolve(cachedTags);
} else {
fetch('https://api.github.com/repos/facebook/react/tags', { mode: 'cors' })
.then(res => res.json())
.then(tags => {
// A message property indicates an error was sent from the API
if (tags.message) {
return resolve(fallbackTags)
}
if (canUseSessionStorage) {
sessionStorage.setItem(TAGS_CACHE_KEY, JSON.stringify(tags))
}
resolve(tags)
})
.catch(() => resolve(fallbackTags))
}
})
}

View File

@ -1136,6 +1136,10 @@ clap@^1.0.9:
dependencies:
chalk "^1.1.3"
classnames@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
clean-css@3.4.x:
version "3.4.23"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-3.4.23.tgz#604fbbca24c12feb59b02f00b84f1fb7ded6d001"