React @ Product Hunt (WAD)

React @ Product Hunt (WAD)

7a0e72a6f55811246bb5d9a946fd2e49?s=128

Radoslav Stankov

May 11, 2017
Tweet

Transcript

  1. 3.
  2. 5.

    October 2014 Backbone early 2014 jQuery spaghetti February 2014 React

    on Rails May 2014 custom Flux December 2015 Redux January 2016 React-Router April 2016 Redux Ducks January 2017 Frontend app February 2017 GraphQL March 2017 Code splitting
  3. 6.
  4. 7.
  5. 10.
  6. 11.
  7. 20.

    export type Topic = { id: number, image_uuid?: ?string, name:

    string, description?: string, followers_count: number, posts_count: number, slug: string, };
  8. 22.

    function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !==

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);
  9. 23.

    function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !==

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse);
  10. 24.

    function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !==

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse); const variable: number = isFollowing(user, topic);
  11. 25.

    function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !==

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse); const variable: number = isFollowing(user, topic);
  12. 26.

    function isFollowing(user: CurrentUser, topic: Topic): boolean { return user.followedTopicsIds.indexOf(topic.id) !==

    -1; } isFollowing(null, topic); isFollowing(user, null); isFollowing(user, somethingElse); const variable: number = isFollowing(user, topic); const variable: boolean = isFollowing(user, topic); ! "
  13. 27.

    function isFollowing( user: { followedTopicsIds: Array<number> }, topic: { id:

    number } ): boolean { return user.followedTopicsIds.indexOf(topic.id) !== -1; }
  14. 29.

    <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" /> <UserImage

    user={user} width={50} variant="small" /> <UserImage user={user} width={50} height={30} variant="small" />
  15. 30.

    <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" /> <UserImage

    user={user} width={50} variant="small" /> <UserImage user={user} width={50} height={30} variant="small" />
  16. 31.

    <UserImage user={user} width={50} height={30} /> <UserImage user={user} variant="small" /> <UserImage

    user={user} width={50} variant="small" /> <UserImage user={user} width={50} height={30} variant="small" /> class UserImage extends React.Component { props: {| user: User, width: number, height: number |} | {| user: User, variant: 'big' | 'medium' | 'small' |}; render() { /* ... */ } }
  17. 33.
  18. 39.

    import { createReducer } from 'ph/redux'; import type { Dispatchable,

    Reducer } from 'ph/types/redux'; import type { Notice } from 'ph/types/Notice'; // Constants const RECEIVE_NOTICE = 'NOTICE/RECEIVE'; // Actions export function receiveNotice(notice: Notice): Dispatchable { return { type: RECEIVE_NOTICE, payload: { notice } }; } export function clearNotice(): Dispatchable { return { type: RECEIVE_NOTICE, payload: { notice: null } }; } // Reducer
 const reducer: Reducer<?Notice> = function createReducer(null, { [RECEIVE_NOTICE]: (state, { payload: { notice } }) => notice, });
 export default reducer;
  19. 40.

    import createTestStore from 'ph/createTestStore'; import { clearNotice, receiveNotice } from

    'ph/modules/notice'; describe('notice', () => { const getState = (store) => store.getState().notice; describe(receiveNotice.name, () => { it('sets a notice object', () => { const store = createTestStore(); const notice = { type: 'notice', message: 'submitted' } store.dispatch(receiveNotice(notice)); expect(getState(store)).to.deep.equal(notice); }); }); describe(clearNotice.name, () => { it('sets a notice object', () => { const store = createTestStore(); store.dispatch(clearNotice()); expect(getState(store)).to.equal(null); }); }); });
  20. 42.
  21. 43.
  22. 44.
  23. 60.

    export default function TopicItem({ topic }: { topic: Topic })

    { return ( <div className={styles.item}> <Link to={paths.topics.show(topic)} className={styles.link}> <TopicImage topic={topic} size={50} /> <div className={styles.info}> <Featured>{topic.name}</Featured> <Text>{topic.description}</Text> </div> </TrackLink> <TopicFollowButton topic={topic} /> </div> ); }
  24. 61.

    export default function TopicItem({ topic }: { topic: Topic })

    { return ( <div className={styles.item}> <Link to={paths.topics.show(topic)} className={styles.link}> <TopicImage topic={topic} size={50} /> <div className={styles.info}> <Featured>{topic.name}</Featured> <Text>{topic.description}</Text> </div> </TrackLink> <TopicFollowButton topic={topic} /> </div> ); }
  25. 62.

    CSS // style.css .item { border-top: 1px solid $grey; }

    CSS // component.js
 import styles from './styles.css'; <div className={styles.item}> </div>
  26. 63.

    CSS // style.css .item { border-top: 1px solid $grey; }

    CSS // component.js
 import styles from './styles.css'; <div className={styles.item}> </div>
  27. 64.

    CSS // style.css .item { border-top: 1px solid $grey; }

    CSS // component.js
 import styles from './styles.css'; <div className={styles.item}> </div> CSS // style.css .item_3mRwv { border-top: 1px solid $grey; } CSS // result.js <div className="item_3mRwv"> </div>

  28. 65.

    export default function TopicItem({ topic }: { topic: Topic })

    { return ( <div className={styles.item}> <Link to={paths.topics.show(topic)} className={styles.link}> <TopicImage topic={topic} size={50} /> <div className={styles.info}> <Featured>{topic.name}</Featured> <Text>{topic.description}</Text> </div> </TrackLink> <TopicFollowButton topic={topic} /> </div> ); }
  29. 66.

    export default function TopicItem({ topic }: { topic: Topic })

    { return ( <div className={styles.item}> <Link to={paths.topics.show(topic)} className={styles.link}> <TopicImage topic={topic} size={50} /> <div className={styles.info}> <Featured>{topic.name}</Featured> <Text>{topic.description}</Text> </div> </TrackLink> <TopicFollowButton topic={topic} /> </div> ); }
  30. 68.

    export default function TopicItem({ topic }: { topic: Topic })

    { return ( <div className={styles.item}> <Link to={paths.topics.show(topic)} className={styles.link}> <TopicImage topic={topic} size={50} /> <div className={styles.info}> <Featured>{topic.name}</Featured> <Text>{topic.description}</Text> </div> </TrackLink> <TopicFollowButton topic={topic} /> </div> ); }
  31. 69.

    export default function TopicItem({ topic }: { topic: Topic })

    { return ( <div className={styles.item}> <Link to={paths.topics.show(topic)} className={styles.link}> <TopicImage topic={topic} size={50} /> <div className={styles.info}> <Featured>{topic.name}</Featured> <Text>{topic.description}</Text> </div> </TrackLink> <TopicFollowButton topic={topic} /> </div> ); }
  32. 70.

    export class TopicFollowButton extends React.Component { props: { topic: Topic,

    isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( <Button active={this.props.topic.isFollowed} onClick={this.handleClick} data-test="topic-follow"> {this.props.topic.isFollowed ? 'Following' : 'Follow'} </Button> ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }
  33. 71.

    export class TopicFollowButton extends React.Component { props: { topic: Topic,

    isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( <Button active={this.props.topic.isFollowed} onClick={this.handleClick} data-test="topic-follow"> {this.props.topic.isFollowed ? 'Following' : 'Follow'} </Button> ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }
  34. 72.

    export class TopicFollowButton extends React.Component { props: { topic: Topic,

    isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( <Button active={this.props.topic.isFollowed} onClick={this.handleClick} data-test="topic-follow"> {this.props.topic.isFollowed ? 'Following' : 'Follow'} </Button> ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }
  35. 73.

    export class TopicFollowButton extends React.Component { props: { topic: Topic,

    isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( <Button active={this.props.topic.isFollowed} onClick={this.handleClick} data-test="topic-follow"> {this.props.topic.isFollowed ? 'Following' : 'Follow'} </Button> ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }
  36. 74.

    export class TopicFollowButton extends React.Component { props: { topic: Topic,

    isLogged: boolean, openLoginFullscreen: Function, followTopic: Function, unfollowTopic: Function, }; render() { return ( <Button active={this.props.topic.isFollowed} onClick={this.handleClick} data-test="topic-follow"> {this.props.topic.isFollowed ? 'Following' : 'Follow'} </Button> ); } handleClick: Function = async (event: BrowserEvent) => { event.preventDefault(); if (!this.props.isLogged) { this.props.openLoginFullscreen({ reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed) { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; }
  37. 75.

    reason: `follow ${ this.props.topic.name }` }); } else if (this.props.topic.isFollowed)

    { await this.props.unfollowTopic(); } else { await this.props.followTopic(); } }; } export default createContainer({ renderComponent: TopicFollowButton, actionCreators: { openLoginFullscreen, }, mapStateToProps({ currentUser }) { return { isLogged: currentUser.isLogged }; }, decorators: [ graphql(CREATE_MUTATION, { /* ... */ }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }
  38. 76.

    export default createContainer({ renderComponent: Component, actionCreators: { action1, action2, action2

    }, mapStateToProps: ({ currentUser }) => ({ currentUser }), }); export connect(mapStateToProps, actionCreators)(Component)
  39. 78.

    export default createContainer({ renderComponent: TopicFollowButton, actionCreators: { openLoginFullscreen, }, mapStateToProps({

    currentUser }) { return { isLogged: currentUser.isLogged }; }, decorators: [ graphql(CREATE_MUTATION, { /* ... */ }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }
  40. 79.

    export default createContainer({ renderComponent: TopicFollowButton, actionCreators: { openLoginFullscreen, }, mapStateToProps({

    currentUser }) { return { isLogged: currentUser.isLogged }; }, decorators: [ graphql(CREATE_MUTATION, { /* ... */ }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }
  41. 81.

    
 query { topic(id: 1) { id name description isFollowed

    image } } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }
  42. 82.

    query { topic(id: 1) { id ...Item } } fragment

    Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }
  43. 83.

    query { topic(id: 1) { id ...Item } } fragment

    Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }
  44. 84.

    query { topic(id: 1) { id ...Item } } fragment

    Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }
  45. 85.

    query { topic(id: 1) { id ...Item } } fragment

    Item on Topic { id name description ...Button ...Image } fragment Button on Topic { id name isFollowed } fragment Image on Topic { image } POST /graphql { "data": { "topic": { "id": 1, "name": "Tech", "description": "Hardware or "isFollowed": true, "image": "assets.producthun } } }
  46. 86.

    
 mutation FollowTopic($input) { followTopic(input: $input) { node { id

    isFollowed } } } POST /graphql { "data": {
 "followTopic": { "node": { "id": 1, "isFollowed": true } } } }
  47. 88.

    import CREATE_MUTATION from './FollowTopic.graphql';
 import REMOVE_MUTATION from './UnfollowTopic.graphql';
 
 //

    ... 
 export default createContainer({ // ... decorators: [ graphql(CREATE_MUTATION, { props: ({ ownProps: { topic: { id } }, mutate }) => ({ followTopic() { return mutate({ variables: { input: { id }, }, optimisticResponse: { response: { node: { __typename: 'Topic', id, isFollowed: true }, }, } }); }, }), }), graphql(REMOVE_MUTATION, { /* ... */ }), ], }
  48. 90.

    import React from 'react'; import sinon from 'sinon'; import TopicFactory

    from 'factories/TopicFactory';
 import { Component } from 'ph/components/TopicFollowButton'; describe(Component.name, () => { const followed = new TopicFactory({ isFollowed: true }); const unfollowed = new TopicFactory({ isFollowed: false }); const defaults = { topic: unfollowed, isLogged: true, openLoginFullscreen() {}, followTopic() {}, unfollowTopic() {}, }; const call = sut(defaults, (opts) => <Component {...opts} />); it('renders "Follow" text when is not following', () => { const wrapper = shallow(call({ topic: followed })); expect(wrapper).to.contain('Follow'); }); it('renders "Following" text when is following', () => { const wrapper = shallow(call({ topic: unfollowed })); expect(wrapper).to.contain('Following'); }); it('can follow a topic', () => { const spy = sinon.spy(); const wrapper = shallow(call({ followTopic: spy, topic: followed }));
  49. 91.

    expect(wrapper).to.contain('Following'); }); it('can follow a topic', () => { const

    spy = sinon.spy(); const wrapper = shallow(call({ followTopic: spy, topic: followed })); wrapper.simulate('click', { preventDefault: () => {} }); expect(spy).to.have.been.calledOnce; }); it('can unfollow a topic', () => { const spy = sinon.spy(); const wrapper = shallow(call({ unfollowTopic: spy, topic: followed })); wrapper.simulate('click', { preventDefault: () => {} }); expect(spy).to.have.been.calledOnce; }); it('calls openLoginFullscreen when user is not logged', () => { const spy = sinon.spy(); const wrapper = shallow(call({ handleNotLogged: spy, isLogged: false, })); wrapper.simulate('click', { preventDefault: () => {} }); expect(spy).to.have.been.calledOnce; }); });
  50. 99.

    export default { component: Main, childRoutes: [ { path: '/',

    track: 'home', getComponent(_, cb) { require.ensure([], (require) => cb(null, require('ph/pages/Homepage').default)); }, }, { path: '/topics', track: 'topic_index', getComponent(_, cb) { require.ensure([], (require) => cb(null, require('ph/pages/Topics').default)); }, }, { path: '/topics/:slug', track: 'topic_detail', getComponent(_, cb) { require.ensure([], (require) => cb(null, require('ph/pages/Topic').default)); }, }, // ... ], };
  51. 100.

    export default function createReduxStore(initialState = {}, { apollo } =

    {}) { const middlewares = [ reduxReactRouter({ createHistory: canUseDOM ? createBrowserHistory : createMemoryHistory, routes, }), applyMiddleware(analytics), ]; if (apollo) { middlewares.push(applyMiddleware(apollo.middleware())); } if (canUseDOM && window.localStorage) { middlewares.push(applyMiddleware(persistState())); } if (canUseDOM && window.devToolsExtension) { middlewares.push(window.devToolsExtension()); } const finalReducers = apollo ? { ...reducers, apollo: apollo.reducer() } : reducers; const finalStore = compose(...middlewares)(createStore); return finalStore(combineReducers(finalReducers), initialState); }
  52. 102.

    export default { root(): string { return '/'; }, topics:

    { index(): string { return '/topics'; }, show({ slug }: Topic): string { return `/topics/${ slug }`; }, }, pages: { about(): string { return '/about'; }, // ... }
 // ... };
  53. 103.

    import paths from 'ph/paths';
 paths.topics.index(); // => /topics/ paths.topics.show(topic); //

    => /topics/developer-tools
 paths.pages.about(); // => /about/
  54. 105.
  55. 114.
  56. 115.
  57. 116.
  58. 119.

    // pages/Post/Discussion/Fragment.graphql
 fragment PostDiscussion on Post { comments { id

    created_at parent_comment_id state votes_count body user { id username name headline } } }
  59. 121.

    // pages/Post/PostPage.graphql #import "ph/lib/meta/MetaTags.graphql" #import "ph/pages/Post/Discussion/Fragment.graphql" #import "ph/pages/Post/Header/Fragment.graphql" #import "ph/pages/Post/Makers/Fragment.graphql"

    #import "ph/pages/Post/Media/Fragment.graphql" #import "ph/pages/Post/RecommendedPosts/Fragment.graphql" query PostPage($id: String!) { post(id: $id) { id
 ...MetaTags ...PostDiscussion ...PostHeader ...PostMedia ...PostRecommendedPosts } }
  60. 122.

    import QUERY from './Query.graphql'; import { graphql } from 'react-apollo';

    // ... export default createContainer({ renderComponent: Content, decorators: [ setTags(({ data: { post: { meta } } }) => meta), visibleIf(({ data }) => !!data.post),
 deferredRender(<Loading subject="post" />), graphql(QUERY, { options: ({ params: { id } }) => ({ variables: { id, }, }), }), ], });
  61. 124.
  62. 126.

    # https://github.com/acdlite/redux-router $ https://github.com/airbnb/enzyme/ % https://github.com/apollographql & https://github.com/chaijs/chai ' https://github.com/css-modules/css-modules

    ( https://github.com/DylanPiercey/require-ensure ) https://github.com/erikras/ducks-modular-redux * https://github.com/facebook/flow + https://github.com/graphql , https://github.com/producthunt/chai-enzyme - https://github.com/ReactTraining/react-router . https://webpack.js.org/guides/code-splitting-async
  63. 127.
  64. 128.