Преглед изворни кода

Added fetcher component and tests. Improved style.

Snow пре 8 година
родитељ
комит
601cfad0a8

+ 3 - 1
package.json

@@ -10,7 +10,9 @@
     "react-motion": "^0.5.0",
     "react-redux": "^5.0.5",
     "react-router-dom": "4.1.1",
-    "redux": "^3.7.0",
+    "redux": "^3.7.1",
+    "redux-actions": "^2.0.3",
+    "redux-saga": "^0.15.4",
     "styled-components": "^2.1.0"
   },
   "devDependencies": {

+ 84 - 0
src/__test__/Fetcher.test.js

@@ -0,0 +1,84 @@
+import fetch from 'isomorphic-fetch';
+import { put, call } from 'redux-saga/effects';
+
+import actions from '../components/Fetcher/actions/';
+import * as c from '../components/Fetcher/actions/constants';
+import { fetchPost } from '../components/Fetcher/reducers/index';
+import reducer from '../components/Fetcher/reducers';
+
+describe('Check action creator', () => {
+  const testPayload = 'testPayload';
+
+  it('Check select subreddit action is correctly setup', () => {
+    expect(actions.selectSubreddit(testPayload)).toEqual({
+      type: c.SELECT_SUBREDDIT,
+      payload: testPayload
+    });
+  });
+
+  it('Check receive posts action is working', () => {
+    const targetReddit = 'darksouls';
+    const targetURL = `https://www.reddit.com/r/${targetReddit}.json`;
+    const json = fetch(targetURL)
+      .then(response => response.json())
+      .then(json => {
+        const postLength = json.data.children.length;
+        expect(
+          actions.receivePosts(targetReddit, json).payload.posts.length
+        ).toBe(postLength);
+      });
+  });
+});
+
+describe('Select works', () => {
+  it('Default state is set', () => {
+    expect(reducer(undefined, {}).selectedSubreddit).toBe('reactjs');
+  });
+  it('Selected reddit should change on new selections', () => {
+    const testReddit = 'Darksouls';
+    const action = actions.selectSubreddit(testReddit);
+    const newState = reducer(undefined, action);
+    expect(newState.selectedSubreddit).toEqual(testReddit);
+  });
+});
+
+describe('Test fetch posts async action', () => {
+  const testPayload = 'Darksouls';
+  // note that gen is shared by all tests
+  // so .next() will excetute the actual next yield
+  // in fetchPost
+  const gen = fetchPost(actions.fetchPosts(testPayload));
+
+  it('Fetch posts start by setting isFetching to true', () => {
+    expect(gen.next().value).toEqual(put(actions.requestPost(testPayload)));
+  });
+
+  it('Fetch posts then fetch from the reditt', () => {
+    const targetURL = `https://www.reddit.com/r/${testPayload}.json`;
+    const reponse = gen.next();
+    expect(reponse.value).toEqual(call(fetch, targetURL));
+  });
+});
+
+describe('Request works', () => {
+  it('Default post state is empty', () => {
+    const testReddit = 'Darksouls';
+    const defaultState = reducer(undefined, []).postsBySubreddit;
+    expect(defaultState).toEqual({});
+  });
+  it('Request a subreddit should change isFetching status to true', () => {
+    const testReddit = 'Darksouls';
+    const action = actions.requestPost(testReddit);
+    const result = {
+      [testReddit]: {
+        isFetching: true,
+        didInvalidate: false,
+        items: []
+      }
+    };
+    const posts = reducer(undefined, action).postsBySubreddit;
+    expect(posts).toEqual(result);
+    const isFetching = posts[testReddit].isFetching;
+    expect(isFetching).toBeTrue;
+  });
+});

+ 1 - 0
src/components/App/index.js

@@ -7,6 +7,7 @@ export default class App extends Component {
     return (
       <Header>
         <Logo src={logo} alt="logo" />
+        <h2>Simple UI demos</h2>
       </Header>
     );
   }

+ 6 - 0
src/components/App/style.js

@@ -6,10 +6,16 @@ const Header = styled.div`
   height: 100px;
   color: white;
   flex: 0 1 100%;
+  
+  h2 {
+    display: inline-block;
+    margin: 0 10px;
+}
 `;
 
 const Logo = styled.img`
   height: 100px;
+  vertical-align: middle;
 `;
 
 export { Header, Logo };

+ 25 - 0
src/components/Fetcher/FunctionalComponents/Picker.js

@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { SelectButton } from '../style';
+
+export default function Picker({ value, onChange, options }) {
+  return (
+    <span>
+      <h1>{value.replace(/^./g, x => x.toUpperCase())}</h1>
+      <SelectButton onChange={e => onChange(e.target.value)} value={value}>
+        {options.map(option =>
+          <option value={option} key={option}>
+            {option}
+          </option>
+        )}
+      </SelectButton>
+    </span>
+  );
+}
+
+Picker.propTypes = {
+  options: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired
+};

+ 22 - 0
src/components/Fetcher/FunctionalComponents/Posts.js

@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { PostItem } from '../style';
+
+export default function Posts({ posts }) {
+  return (
+    <ul style={{ listStyle: 'none', padding: 0 }}>
+      {posts.map((post, i) =>
+        <PostItem key={i}>
+          <a href={post.url} target="_blank">
+            {post.title}
+          </a>
+        </PostItem>
+      )}
+    </ul>
+  );
+}
+
+Posts.propTypes = {
+  posts: PropTypes.array.isRequired
+};

+ 4 - 0
src/components/Fetcher/FunctionalComponents/index.js

@@ -0,0 +1,4 @@
+import Picker from './Picker.js';
+import Posts from './Posts.js';
+
+export { Picker, Posts };

+ 83 - 0
src/components/Fetcher/RedditFetcher.js

@@ -0,0 +1,83 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+import actions from './actions/';
+import { Picker, Posts } from './FunctionalComponents';
+import { RefreshButton } from './style.js';
+function mapStateToProps(state) {
+  const { selectedSubreddit, postsBySubreddit } = state;
+  const { isFetching, lastUpdated, items: posts } = postsBySubreddit[
+    selectedSubreddit
+  ] || {
+    isFetching: true,
+    items: []
+  };
+  return {
+    selectedSubreddit,
+    posts,
+    isFetching,
+    lastUpdated
+  };
+}
+
+class RedditFetcher extends Component {
+  componentDidMount() {
+    const { dispatch, selectedSubreddit } = this.props;
+    dispatch(actions.fetchPosts(selectedSubreddit));
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.selectedSubreddit !== prevProps.selectedSubreddit) {
+      const { dispatch, selectedSubreddit } = this.props;
+      dispatch(actions.fetchPosts(selectedSubreddit));
+    }
+  }
+
+  handleChange = nextSubreddit => {
+    this.props.dispatch(actions.selectSubreddit(nextSubreddit));
+    this.props.dispatch(actions.fetchPosts(nextSubreddit));
+  };
+
+  handleRefreshClick = e => {
+    e.preventDefault();
+
+    const { dispatch, selectedSubreddit } = this.props;
+    dispatch(actions.invalidateSubreddit(selectedSubreddit));
+    dispatch(actions.fetchPosts(selectedSubreddit));
+  };
+
+  static propTypes = {
+    selectedSubreddit: PropTypes.string.isRequired,
+    posts: PropTypes.array.isRequired,
+    isFetching: PropTypes.bool.isRequired,
+    lastUpdated: PropTypes.number,
+    dispatch: PropTypes.func.isRequired
+  };
+
+  render() {
+    const { selectedSubreddit, posts, isFetching, lastUpdated } = this.props;
+    const options = ['Reactjs', 'Darksouls'];
+    return (
+      <div>
+        <Picker
+          value={selectedSubreddit}
+          onChange={this.handleChange}
+          options={options}
+        />
+        <RefreshButton isFetching onClick={this.handleRefreshClick}>
+          {isFetching ? '...' : 'Refresh'}
+        </RefreshButton>
+        {lastUpdated &&
+          <p>Last updated at {new Date(lastUpdated).toLocaleTimeString()}.</p>}
+        {!isFetching && posts.length === 0 && <h2>Empty.</h2>}
+        {posts.length > 0 &&
+          <div style={{ opacity: isFetching ? 0.5 : 1 }}>
+            <Posts posts={posts} />
+          </div>}
+      </div>
+    );
+  }
+}
+
+export default connect(mapStateToProps)(RedditFetcher);

+ 5 - 0
src/components/Fetcher/actions/constants.js

@@ -0,0 +1,5 @@
+export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT',
+  INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT',
+  REQUEST_POST = 'REQUEST_POST',
+  RECEIVE_POSTS = 'RECEIVE_POSTS',
+  FETCH_POSTS = 'FETCH_POSTS';

+ 18 - 0
src/components/Fetcher/actions/index.js

@@ -0,0 +1,18 @@
+import { createActions } from 'redux-actions';
+import * as C from './constants';
+
+const actions = createActions(
+  {
+    [C.RECEIVE_POSTS]: (subreddit, json) => ({
+      subreddit,
+      posts: json.data.children.map(child => child.data),
+      receivedAt: Date.now()
+    }),
+    [C.INVALIDATE_SUBREDDIT]: subreddit => ({ subreddit }),
+    [C.REQUEST_POST]: subreddit => ({ subreddit })
+  },
+  C.SELECT_SUBREDDIT,
+  C.FETCH_POSTS
+);
+
+export default actions;

+ 16 - 0
src/components/Fetcher/configureStore.js

@@ -0,0 +1,16 @@
+import { createStore, applyMiddleware } from 'redux';
+import createSagaMiddleware from 'redux-saga';
+
+import reducer, { watchFetchPosts } from './reducers/index';
+
+const sagaMiddleware = createSagaMiddleware();
+
+export default function configureStore(initialState) {
+  const store = createStore(
+    reducer,
+    initialState,
+    applyMiddleware(sagaMiddleware)
+  );
+  sagaMiddleware.run(watchFetchPosts);
+  return store;
+}

+ 28 - 0
src/components/Fetcher/index.js

@@ -0,0 +1,28 @@
+import React, { Component } from 'react';
+import { Provider } from 'react-redux';
+
+import SharedTitle from '../Utils/SharedTitle';
+import configureStore from './configureStore';
+import RedditFetcher from './RedditFetcher';
+
+const store = configureStore();
+
+export default class FetcherDemo extends Component {
+  render() {
+    return (
+      <div>
+        <SharedTitle />
+        <p>
+          This is a reddit posts fetcher managed by Redux. It fetches the latest
+          posts from the selected subreddit. The code is very similar to the one
+          in the Redux official doc, expect that the middleware manages async
+          actions is Redux-Saga instead of Redux-Thunk.
+        </p>
+        <hr />
+        <Provider store={store}>
+          <RedditFetcher />
+        </Provider>
+      </div>
+    );
+  }
+}

+ 69 - 0
src/components/Fetcher/reducers/index.js

@@ -0,0 +1,69 @@
+import { handleAction, handleActions, combineActions } from 'redux-actions';
+import { combineReducers } from 'redux';
+import fetch from 'isomorphic-fetch';
+import { put, call, takeEvery } from 'redux-saga/effects';
+
+import * as C from '../actions/constants';
+import actions from '../actions/';
+
+const selectedSubreddit = handleAction(
+  C.SELECT_SUBREDDIT,
+  (state, { payload }) => payload,
+  'reactjs'
+);
+
+export function* fetchPost({ payload }) {
+  const targetURL = `https://www.reddit.com/r/${payload}.json`;
+  yield put(actions.requestPost(payload));
+  try {
+    const response = yield call(fetch, targetURL);
+    const json = yield response.json();
+    yield put(actions.receivePosts(payload, json));
+  } catch (error) {
+    error => console.log('An error occured.', error);
+  }
+}
+
+export function* watchFetchPosts() {
+  yield takeEvery(C.FETCH_POSTS, fetchPost);
+}
+
+const posts = handleActions(
+  {
+    [C.INVALIDATE_SUBREDDIT]: (state, { payload }) => ({
+      ...state,
+      didInvalidate: true
+    }),
+    [C.REQUEST_POST]: (state, { payload }) => ({
+      ...state,
+      isFetching: true,
+      didInvalidate: false
+    }),
+    [C.RECEIVE_POSTS]: (state, { payload }) => ({
+      ...state,
+      isFetching: false,
+      didInvalidate: false,
+      items: payload.posts,
+      lastUpdated: payload.receivedAt
+    })
+  },
+  {
+    isFetching: false,
+    didInvalidate: false,
+    items: []
+  }
+);
+
+const postsBySubreddit = handleAction(
+  combineActions(C.INVALIDATE_SUBREDDIT, C.REQUEST_POST, C.RECEIVE_POSTS),
+  (state, action) => {
+    const subreddit = action.payload.subreddit;
+    return {
+      [subreddit]: posts(state[subreddit], action)
+    };
+  },
+  {}
+);
+const reducer = combineReducers({ selectedSubreddit, postsBySubreddit });
+
+export default reducer;

+ 43 - 0
src/components/Fetcher/style.js

@@ -0,0 +1,43 @@
+import styled from 'styled-components';
+
+export const RefreshButton = styled.button`
+  background-color: white;
+  border: 2px solid #999;
+  border-radius: 5px;
+  padding: 8px 16px;
+  text-align: center;
+  text-decoration: none;
+  display: inline-block;
+  font-size: 90%;
+  margin: 4px 10px;
+  width: 100px;
+  outline:none;
+  &:hover {
+    background-color: #70C1B3;
+    color: white;
+    border: 2px solid #70C1B3;
+    transition: background-color 0.2s, color 0.2s;
+  }
+`;
+
+export const SelectButton = RefreshButton.withComponent('select').extend`
+  width: auto;
+  margin-left: 0;
+`;
+
+export const PostItem = styled.li`
+  margin: 10px;
+  border: 1px solid #ddd;
+  box-shadow: 2px 2px 5px #ddd;
+  padding: 10px;
+  a {
+    text-decoration: none;
+    color: #2A5DB0 ;
+  }
+
+  a:hover {
+    color: #da3f3d;
+    text-decoration: auto;
+    transition: color 0.2s;
+  }
+`;

+ 1 - 0
src/components/Nav/index.js

@@ -12,6 +12,7 @@ const Nav = () =>
     <NavLi><NavLink to="/modal">Modal</NavLink></NavLi>
     <NavLi><NavLink to="/slideshow">Slide</NavLink></NavLi>
     <NavLi><NavLink to="/form">Form</NavLink></NavLi>
+    <NavLi><NavLink to="/fetcher">Fetcher</NavLink></NavLi>
     <NavLi><NavLink to="/tabss">Test 404</NavLink></NavLi>
   </NavUl>;
 

+ 1 - 1
src/components/Nav/style.js

@@ -15,7 +15,7 @@ const NavUl = styled.ul`
 `;
 
 const NavLi = styled.li`
-  flex: 1;
+  flex: 1 1 20%;
   border-bottom: 1px solid grey;
   a {
     display: block;

+ 2 - 0
src/routes.js

@@ -11,6 +11,7 @@ import ModalDemo from './components/Modal';
 import SlideShowDemo from './components/SlideShow';
 import CounterDemo from './components/Counter';
 import FormDemo from './components/Form';
+import FetcherDemo from './components/Fetcher';
 import NotFound from './components/NotFound';
 
 const Routes = props =>
@@ -28,6 +29,7 @@ const Routes = props =>
             <Route path="/modal" component={ModalDemo} />
             <Route path="/slideshow" component={SlideShowDemo} />
             <Route path="/form" component={FormDemo} />
+            <Route path="/fetcher" component={FetcherDemo} />
             <Route component={NotFound} />
           </Switch>
         </ComponentWrapper>

+ 20 - 3
yarn.lock

@@ -3993,7 +3993,7 @@ locate-path@^2.0.0:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
-lodash-es@^4.2.0, lodash-es@^4.2.1:
+lodash-es@^4.17.4, lodash-es@^4.2.0, lodash-es@^4.2.1:
   version "4.17.4"
   resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7"
 
@@ -4098,7 +4098,7 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
-"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0:
+"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0:
   version "4.17.4"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
 
@@ -5512,7 +5512,24 @@ reduce-function-call@^1.0.1:
   dependencies:
     balanced-match "^0.4.2"
 
-redux@^3.7.0:
+reduce-reducers@^0.1.0:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b"
+
+redux-actions@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.0.3.tgz#1550aba9def179166ccd234d07672104a736d889"
+  dependencies:
+    invariant "^2.2.1"
+    lodash "^4.13.1"
+    lodash-es "^4.17.4"
+    reduce-reducers "^0.1.0"
+
+redux-saga@^0.15.4:
+  version "0.15.4"
+  resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-0.15.4.tgz#27982a947280053b7ecbb5d3170c837a5fe6b261"
+
+redux@^3.7.1:
   version "3.7.1"
   resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.1.tgz#bfc535c757d3849562ead0af18ac52122cd7268e"
   dependencies: