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

Redux with stub data (fixes #8) #75

Merged
merged 1 commit into from
Mar 7, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
"express": "4.13.4",
"helmet": "1.3.0",
"react": "0.14.7",
"react-router": "2.0.0"
"react-redux": "4.4.0",
"react-router": "2.0.0",
"redux": "3.3.1"
},
"devDependencies": {
"babel-core": "6.6.5",
Expand All @@ -44,6 +46,7 @@
"babel-plugin-transform-class-properties": "6.6.0",
"babel-plugin-transform-decorators-legacy": "1.3.4",
"babel-plugin-transform-object-rest-spread": "6.6.5",
"babel-polyfill": "6.6.1",
"babel-preset-es2015": "6.6.0",
"babel-preset-react": "6.5.0",
"babel-preset-stage-2": "6.5.0",
Expand All @@ -68,6 +71,8 @@
"react-dom": "0.14.7",
"react-hot-loader": "1.3.0",
"react-transform-hmr": "1.0.4",
"react-transform-hmr": "1.0.2",
"redux-devtools": "3.1.1",
"semver": "5.1.0",
"shelljs": "0.6.0",
"sinon": "1.17.3",
Expand Down
15 changes: 15 additions & 0 deletions src/core/reducers/addons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const initialState = {
wat: {slug: 'wat', title: 'Wat is this?'},
foo: {slug: 'foo', title: 'The foo add-on'},
food: {slug: 'food', title: 'Find food'},
bar: {slug: 'bar', title: 'The bar add-on'},
};

export default function addon(state = initialState, action) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to tweak the style settings so we don have spaces around default args e.g. so we have state=initialState instead of state = initialState.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I found this a little weird at first. I don't totally hate it but not what I went with first.

switch (action.type) {
case 'ADDON_FETCHED':
return Object.assign({}, state, {[action.addon.slug]: action.addon});
default:
return state;
}
}
6 changes: 6 additions & 0 deletions src/search/actions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function setQuery(query) {
return {
type: 'SET_QUERY',
query,
};
}
1 change: 1 addition & 0 deletions src/search/client.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'babel-polyfill';
import React from 'react';
import { render } from 'react-dom';
import { Router, browserHistory } from 'react-router';
Expand Down
42 changes: 14 additions & 28 deletions src/search/components/App.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,15 @@
import React from 'react';

import SearchForm from './SearchForm';
import SearchResults from './SearchResults';
import { gettext as _ } from 'core/utils';

export default class App extends React.Component {

state = {
query: null,
results: [],
}

handleSearch = (query) => {
const results = [{title: 'Foo'}, {title: 'Bar'}, {title: 'Baz'}];
this.setState({ query, results });
}

render() {
const { query, results } = this.state;
return (
<div className="search-app">
<h1>{_('Add-on Search')}</h1>
<SearchForm onSearch={this.handleSearch} />
<SearchResults results={results} query={query} />
</div>
);
}
}
import { Provider } from 'react-redux';
import { createStore, combineReducers } from 'redux';
import CurrentSearchPage from '../containers/CurrentSearchPage';
import search from '../reducers/search';
import addons from 'core/reducers/addons';
const store = createStore(combineReducers({addons, search}));

const App = () => (
<Provider store={store}>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This redux stuff normally seems to go in an index.js file but we want to split it out per app. I'm not sure if this is the best place for it but apparently it works.

<CurrentSearchPage />
</Provider>
);

export default App;
24 changes: 24 additions & 0 deletions src/search/components/SearchPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React, { PropTypes } from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this file should be rolled into the container rather than be separate? Although there's going to be pros and cons for both approaches e.g. separation + testing being easer etc...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did feel like I was being forced to write it this way a little.

The docs suggest that you don't have any redux in your components (presentational components) and the containers (container components) are automatically generated to pass the redux data into your components. This seems like a reasonable approach so I went with it.

Now it’s time to hook up those presentational components to Redux by creating some containers. Technically, a container component is just a React component that uses store.subscribe() to read a part of the Redux state tree and supply props to a presentational component it renders. You could write a container component by hand but React Redux includes many useful optimizations so we suggest to generate container components with connect() function from the React Redux library.

From Usage with React (doesn't support linking very well, because JavaScript)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That all seems like solid reasoning to keep it as is then 👍


import SearchForm from './SearchForm';
import SearchResults from './SearchResults';
import { gettext as _ } from 'core/utils';

const SearchPage = ({ handleSearch, results, query }) => (
<div className="search-page">
<h1>{_('Add-on Search')}</h1>
<SearchForm onSearch={handleSearch} />
<SearchResults results={results} query={query} />
</div>
);

SearchPage.propTypes = {
handleSearch: PropTypes.func.isRequired,
results: PropTypes.arrayOf(PropTypes.shape({
slug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
})),
query: PropTypes.string,
};

export default SearchPage;
24 changes: 24 additions & 0 deletions src/search/containers/CurrentSearchPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import SearchPage from '../components/SearchPage';
import { setQuery } from '../actions';
import { getMatchingAddons } from 'search/reducers/search';

export function mapStateToProps(state) {
return {
results: getMatchingAddons(state.addons, state.search.query),
query: state.search.query,
};
}

export function mapDispatchToProps(dispatch) {
return {
handleSearch: (query) => dispatch(setQuery(query)),
};
}

const CurrentSearchPage = connect(
mapStateToProps,
mapDispatchToProps,
)(SearchPage);

export default CurrentSearchPage;
22 changes: 22 additions & 0 deletions src/search/reducers/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const initialState = {
query: null,
};

export default function search(state = initialState, action) {
switch (action.type) {
case 'SET_QUERY':
return Object.assign({}, state, {query: action.query});
default:
return state;
}
}

export function getMatchingAddons(addons, query) {
const matches = [];
for (const slug in addons) {
if (addons[slug].title.indexOf(query) >= 0) {
matches.push(addons[slug]);
}
}
return matches;
}
5 changes: 4 additions & 1 deletion test-runner.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
require('babel-polyfill');

const testsContext = require.context('./tests', true, /\.js$/);
const componentsContext = require.context('./src/', true, /components\/.*\.js$/);
const componentsContext = require.context(
'./src/', true, /(actions|components|containers|reducers)\/.*\.js$/);

testsContext.keys().forEach(testsContext);
componentsContext.keys().forEach(componentsContext);
17 changes: 17 additions & 0 deletions tests/core/reducers/test_addons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import addons from 'core/reducers/addons';

describe('addon reducer', () => {
it('returns the old state', () => {
const originalState = {foo: {slug: 'foo'}, bar: {slug: 'bar'}};
assert.strictEqual(originalState, addons(originalState, {type: 'BLAH'}));
});

it('adds an addon', () => {
const originalState = {foo: {slug: 'foo'}};
const baz = {slug: 'baz'};
const expectedState = {foo: {slug: 'foo'}, baz};
const newState = addons(originalState, {type: 'ADDON_FETCHED', addon: baz});
assert.notStrictEqual(originalState, newState);
assert.deepEqual(expectedState, newState);
});
});
5 changes: 2 additions & 3 deletions tests/search/components/TestSearchForm.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import ReactTestUtils from 'react-addons-test-utils';
import { renderIntoDocument as render } from 'react-addons-test-utils';
import { Simulate, renderIntoDocument as render } from 'react-addons-test-utils';

import SearchForm from 'search/components/SearchForm';

Expand Down Expand Up @@ -29,7 +28,7 @@ describe('<SearchForm />', () => {

it('calls onSearch with a search query', () => {
input.value = 'adblock';
ReactTestUtils.Simulate.submit(form);
Simulate.submit(form);
assert.ok(onSearch.calledWith('adblock'));
});
});
49 changes: 49 additions & 0 deletions tests/search/components/TestSearchPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import { createRenderer } from 'react-addons-test-utils';

import SearchPage from 'search/components/SearchPage';
import SearchResults from 'search/components/SearchResults';
import SearchForm from 'search/components/SearchForm';

function findByTag(root, tag) {
const matches = root.props.children.filter((child) => child.type === tag);
assert.equal(matches.length, 1, 'expected one match');
return matches[0];
}

describe('<SearchPage />', () => {
let root;
let state;

beforeEach(() => {
state = {
handleSearch: sinon.spy(),
results: [{title: 'Foo', slug: 'foo'}, {title: 'Bar', slug: 'bar'}],
query: 'foo',
};
// eslint-disable-next-line new-cap
const renderer = createRenderer();
renderer.render(<SearchPage {...state} />);
root = renderer.getRenderOutput();
});

it('has a nice heading', () => {
const heading = findByTag(root, 'h1');
assert.equal(heading.props.children, 'Add-on Search');
});

it('renders the results', () => {
const results = findByTag(root, SearchResults);
assert.strictEqual(results.props.results, state.results);
assert.strictEqual(results.props.query, state.query);
assert.deepEqual(
Object.keys(results.props).sort(),
['results', 'query'].sort());
});

it('renders the query', () => {
const form = findByTag(root, SearchForm);
assert.strictEqual(form.props.onSearch, state.handleSearch);
assert.deepEqual(Object.keys(form.props), ['onSearch']);
});
});
17 changes: 17 additions & 0 deletions tests/search/containers/TestCurrentSearchPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { mapStateToProps } from 'search/containers/CurrentSearchPage';

describe('CurrentSearchPage.mapStateToProps', () => {
const props = mapStateToProps({
addons: {ab: {slug: 'ab', title: 'ad-block'},
cd: {slug: 'cd', title: 'cd-block'}},
search: {query: 'ad-block'},
});

it('passes the query through', () => {
assert.equal(props.query, 'ad-block');
});

it('filters the add-ons', () => {
assert.deepEqual(props.results, [{slug: 'ab', title: 'ad-block'}]);
});
});
25 changes: 25 additions & 0 deletions tests/search/reducers/test_search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import search, { getMatchingAddons } from 'search/reducers/search';

describe('search reducer', () => {
it('defaults to a null query', () => {
const { query } = search(undefined, {type: 'unrelated'});
assert.strictEqual(query, null);
});

it('sets the query on SET_QUERY', () => {
const state = search(undefined, {type: 'SET_QUERY', query: 'foo'});
assert.equal(state.query, 'foo');
const newState = search(state, {type: 'SET_QUERY', query: 'bar'});
assert.equal(newState.query, 'bar');
});
});

describe('getMatchingAddons', () => {
it('matches on the title', () => {
assert.deepEqual(
getMatchingAddons(
[{title: 'Foo'}, {title: 'Bar'}, {title: 'Bar Food'}],
'Foo'),
[{title: 'Foo'}, {title: 'Bar Food'}]);
});
});