diff --git a/package.json b/package.json index 8334977d..60ebefcc 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,14 @@ "express": "^4.14.0", "helmet": "^3.3.0", "lodash": "^4.17.4", + "qs": "^6.3.0", "react": "^15.4.2", "react-dom": "^15.4.2", "react-helmet": "^4.0.0", + "react-redux": "^5.0.2", "react-router": "4.0.0-alpha.6", "react-test-renderer": "^15.4.2", + "redux": "^3.6.0", "sharetribe-scripts": "0.8.6", "source-map-support": "^0.4.10" }, diff --git a/public/index.html b/public/index.html index f7b8f2dc..d095f837 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,7 @@ +
diff --git a/server/index.js b/server/index.js index 5b486f2a..a2cc2d6c 100644 --- a/server/index.js +++ b/server/index.js @@ -21,12 +21,13 @@ const helmet = require('helmet'); const compression = require('compression'); const path = require('path'); const fs = require('fs'); -const auth = require('./auth'); +const qs = require('qs'); const _ = require('lodash'); const React = require('react'); const { createServerRenderContext } = require('react-router'); +const auth = require('./auth'); -// Construct the bundle path where the server side renering function +// Construct the bundle path where the server side rendering function // can be imported. const buildPath = path.resolve(__dirname, '..', 'build'); const manifestPath = path.join(buildPath, 'asset-manifest.json'); @@ -65,9 +66,18 @@ const template = _.template(indexHtml, { escape: reNoMatch, }); -function render(url, context) { - const { head, body } = renderApp(url, context); - return template({ title: head.title.toString(), body }); +function render(url, context, preloadedState) { + const { head, body } = renderApp(url, context, preloadedState); + + // Preloaded state needs to be passed for client side too. + // For security reasons we ensure that preloaded state is considered as a string + // by replacing '<' character with its unicode equivalent. + // http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations + const preloadedStateScript = ` + window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/ { const context = createServerRenderContext(); - const html = render(req.url, context); + const filters = qs.parse(req.query); + + // TODO fetch this asynchronously + const preloadedState = { search: { filters } }; + + const html = render(req.url, context, preloadedState); const result = context.getResult(); if (result.redirect) { @@ -100,7 +115,7 @@ app.get('*', (req, res) => { // Do a second render pass with the context to clue // components into rendering this time. // See: https://react-router.now.sh/ServerRouter - res.status(404).send(render(req.url, context)); + res.status(404).send(render(req.url, context, preloadedState)); } else { res.send(html); } diff --git a/src/app.js b/src/app.js index 33bb6b69..4c82629b 100644 --- a/src/app.js +++ b/src/app.js @@ -2,30 +2,41 @@ import React, { PropTypes } from 'react'; import ReactDOMServer from 'react-dom/server'; import Helmet from 'react-helmet'; import { BrowserRouter, ServerRouter } from 'react-router'; +import { Provider } from 'react-redux'; +import configureStore from './store'; import Routes from './Routes'; -const RoutesWithRouterProp = ({ router }) => ; +export const ClientApp = props => { + const { store } = props; + return ( + + {({ router }) => ( + + + + )} + + ); +}; const { any, string } = PropTypes; -RoutesWithRouterProp.propTypes = { router: any.isRequired }; - -export const ClientApp = () => ( - - {RoutesWithRouterProp} - -); +ClientApp.propTypes = { store: any.isRequired }; export const ServerApp = props => { - const { url, context } = props; + const { url, context, store } = props; return ( - {RoutesWithRouterProp} + {({ router }) => ( + + + + )} ); }; -ServerApp.propTypes = { url: string.isRequired, context: any.isRequired }; +ServerApp.propTypes = { url: string.isRequired, context: any.isRequired, store: any.isRequired }; /** * Render the given route. @@ -37,8 +48,11 @@ ServerApp.propTypes = { url: string.isRequired, context: any.isRequired }; * - {String} body: Rendered application body of the given route * - {Object} head: Application head metadata from react-helmet */ -export const renderApp = (url, serverContext) => { - const body = ReactDOMServer.renderToString(); +export const renderApp = (url, serverContext, preloadedState) => { + const store = configureStore(preloadedState); + const body = ReactDOMServer.renderToString( + , + ); const head = Helmet.rewind(); return { head, body }; }; diff --git a/src/app.test.js b/src/app.test.js index ad415995..b5707d7a 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -4,14 +4,17 @@ import ReactDOMServer from 'react-dom/server'; import { forEach } from 'lodash'; import { createServerRenderContext } from 'react-router'; import { ClientApp, ServerApp } from './app'; +import configureStore from './store'; + +const store = configureStore({}); const render = (url, context) => - ReactDOMServer.renderToString(); + ReactDOMServer.renderToString(); describe('Application', () => { it('renders in the client without crashing', () => { const div = document.createElement('div'); - ReactDOM.render(, div); + ReactDOM.render(, div); }); it('renders in the server without crashing', () => { diff --git a/src/components/Page/Page.js b/src/components/Page/Page.js deleted file mode 100644 index 6be3286f..00000000 --- a/src/components/Page/Page.js +++ /dev/null @@ -1,23 +0,0 @@ -import React, { PropTypes } from 'react'; -import Helmet from 'react-helmet'; -import { Topbar } from '../../containers'; - -const Page = props => { - const { className, title, children } = props; - return ( -
- - -

{title}

- {children} -
- ); -}; - -const { string, any } = PropTypes; - -Page.defaultProps = { className: '', children: null }; - -Page.propTypes = { className: string, title: string.isRequired, children: any }; - -export default Page; diff --git a/src/components/PageLayout/PageLayout.js b/src/components/PageLayout/PageLayout.js new file mode 100644 index 00000000..381c08a5 --- /dev/null +++ b/src/components/PageLayout/PageLayout.js @@ -0,0 +1,40 @@ +import React, { Component, PropTypes } from 'react'; +import Helmet from 'react-helmet'; +import { Topbar } from '../../containers'; + +const scrollToTop = () => { + // Todo: this might need fine tuning later + window.scrollTo(0, 0); +}; + +class PageLayout extends Component { + componentDidMount() { + this.historyUnlisten = this.context.history.listen(() => scrollToTop()); + } + + componentWillUnmount() { + this.historyUnlisten(); + } + + render() { + const { className, title, children } = this.props; + return ( +
+ + +

{title}

+ {children} +
+ ); + } +} + +const { any, object, string } = PropTypes; + +PageLayout.contextTypes = { history: object }; + +PageLayout.defaultProps = { className: '', children: null }; + +PageLayout.propTypes = { className: string, title: string.isRequired, children: any }; + +export default PageLayout; diff --git a/src/components/index.js b/src/components/index.js index 151ee0f4..3c747810 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,4 +1,4 @@ /* eslint-disable import/prefer-default-export */ -import Page from './Page/Page'; +import PageLayout from './PageLayout/PageLayout'; -export { Page }; +export { PageLayout }; diff --git a/src/containers/AuthenticationPage/AuthenticationPage.js b/src/containers/AuthenticationPage/AuthenticationPage.js index 648a4b42..452b9faf 100644 --- a/src/containers/AuthenticationPage/AuthenticationPage.js +++ b/src/containers/AuthenticationPage/AuthenticationPage.js @@ -1,6 +1,6 @@ import React, { Component, PropTypes } from 'react'; import { Link, Redirect } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; import { fakeAuth } from '../../Routes'; class AuthenticationPage extends Component { @@ -38,22 +38,22 @@ class AuthenticationPage extends Component { ) : null; return ( - + {redirectToReferrer ? : null} {fromLoginMsg}

or {alternativeMethod}

-
+ ); } } AuthenticationPage.defaultProps = { location: {}, tab: 'signup' }; -const { shape, string, oneOf } = PropTypes; +const { any, oneOf, shape } = PropTypes; AuthenticationPage.propTypes = { - location: shape({ state: shape({ from: string }) }), + location: shape({ state: shape({ from: any }) }), tab: oneOf([ 'login', 'signup' ]), }; diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js index c4aac069..9f2f1a3d 100644 --- a/src/containers/CheckoutPage/CheckoutPage.js +++ b/src/containers/CheckoutPage/CheckoutPage.js @@ -1,11 +1,11 @@ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const CheckoutPage = ({ params }) => ( - + - + ); const { shape, string } = PropTypes; diff --git a/src/containers/ContactDetailsPage/ContactDetailsPage.js b/src/containers/ContactDetailsPage/ContactDetailsPage.js index c66b62af..7ab15bea 100644 --- a/src/containers/ContactDetailsPage/ContactDetailsPage.js +++ b/src/containers/ContactDetailsPage/ContactDetailsPage.js @@ -1,4 +1,4 @@ import React from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; -export default () => +export default () => diff --git a/src/containers/ConversationPage/ConversationPage.js b/src/containers/ConversationPage/ConversationPage.js index 8803ef02..5365123a 100644 --- a/src/containers/ConversationPage/ConversationPage.js +++ b/src/containers/ConversationPage/ConversationPage.js @@ -1,12 +1,12 @@ import React, { PropTypes } from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const ConversationPage = props => { const { params } = props; return ( - +

Conversation id: {params.id}

-
+
); }; diff --git a/src/containers/EditProfilePage/EditProfilePage.js b/src/containers/EditProfilePage/EditProfilePage.js index d92cbe7c..99a1da1c 100644 --- a/src/containers/EditProfilePage/EditProfilePage.js +++ b/src/containers/EditProfilePage/EditProfilePage.js @@ -1,9 +1,9 @@ import React, { PropTypes } from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const EditProfilePage = props => { const { params } = props; - return ; + return ; }; const { shape, string } = PropTypes; diff --git a/src/containers/InboxPage/InboxPage.js b/src/containers/InboxPage/InboxPage.js index f94605a9..01035ee9 100644 --- a/src/containers/InboxPage/InboxPage.js +++ b/src/containers/InboxPage/InboxPage.js @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const toPath = (filter, id) => { switch (filter) { @@ -16,11 +16,11 @@ const toPath = (filter, id) => { const InboxPage = props => { const { filter } = props; return ( - +
  • Single thread
-
+
); }; @@ -28,6 +28,6 @@ InboxPage.defaultProps = { filter: 'conversation' }; const { oneOf } = PropTypes; -InboxPage.propTypes = { filter: oneOf([ 'orders', 'sales', 'conversation' ]) }; +InboxPage.propTypes = { filter: oneOf([ 'orders', 'sales', 'inbox' ]) }; export default InboxPage; diff --git a/src/containers/InboxPage/InboxPage.test.js b/src/containers/InboxPage/InboxPage.test.js index 7076f86e..81f4669e 100644 --- a/src/containers/InboxPage/InboxPage.test.js +++ b/src/containers/InboxPage/InboxPage.test.js @@ -8,7 +8,7 @@ describe('InboxPage', () => { const component = renderer.create( ( - + ), ); diff --git a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap index 3c315f29..41351df5 100644 --- a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap +++ b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap @@ -135,7 +135,7 @@ exports[`InboxPage matches snapshot 1`] = `

- conversation page + inbox page

  • diff --git a/src/containers/LandingPage/LandingPage.js b/src/containers/LandingPage/LandingPage.js index 2c0b297a..3d583503 100644 --- a/src/containers/LandingPage/LandingPage.js +++ b/src/containers/LandingPage/LandingPage.js @@ -1,9 +1,9 @@ import React from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; export default () => ( - + Find studios - + ) diff --git a/src/containers/ListingPage/ListingPage.js b/src/containers/ListingPage/ListingPage.js index 31e10d6f..c0ab09e8 100644 --- a/src/containers/ListingPage/ListingPage.js +++ b/src/containers/ListingPage/ListingPage.js @@ -1,6 +1,6 @@ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const ListingPage = ({ params }) => { // Listing id should be located either in the end of slug @@ -11,10 +11,10 @@ const ListingPage = ({ params }) => { // TODO: Fetch data from SDK if no data is passed through props return ( - +

    Slug: {params.slug}

    -
    + ); }; diff --git a/src/containers/ManageListingsPage/ManageListingsPage.js b/src/containers/ManageListingsPage/ManageListingsPage.js index 5c1d1c11..6a76103e 100644 --- a/src/containers/ManageListingsPage/ManageListingsPage.js +++ b/src/containers/ManageListingsPage/ManageListingsPage.js @@ -1,11 +1,11 @@ import React from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; export default () => ( - +
    • Listing 1234
    -
    + ) diff --git a/src/containers/NotFoundPage/NotFoundPage.js b/src/containers/NotFoundPage/NotFoundPage.js index 1b6cc3f9..50aa9350 100644 --- a/src/containers/NotFoundPage/NotFoundPage.js +++ b/src/containers/NotFoundPage/NotFoundPage.js @@ -1,9 +1,9 @@ import React from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; export default () => ( - + Index page - + ) diff --git a/src/containers/NotificationSettingsPage/NotificationSettingsPage.js b/src/containers/NotificationSettingsPage/NotificationSettingsPage.js index a7116bf9..b1cf74e1 100644 --- a/src/containers/NotificationSettingsPage/NotificationSettingsPage.js +++ b/src/containers/NotificationSettingsPage/NotificationSettingsPage.js @@ -1,4 +1,4 @@ import React from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; -export default () => +export default () => diff --git a/src/containers/OrderPage/OrderPage.js b/src/containers/OrderPage/OrderPage.js index 0bf79296..fe6c2762 100644 --- a/src/containers/OrderPage/OrderPage.js +++ b/src/containers/OrderPage/OrderPage.js @@ -1,12 +1,12 @@ /* eslint-disable react/no-unescaped-entities */ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const OrderPage = props => { const { params } = props; return ( - +

    Order id: {params.id}

    Discussion tab
    @@ -17,12 +17,14 @@ const OrderPage = props => { /order/1234 )

    -
    +
    ); }; -const { shape, number } = PropTypes; +const { number, oneOfType, shape, string } = PropTypes; -OrderPage.propTypes = { params: shape({ id: number.isRequired }).isRequired }; +OrderPage.propTypes = { + params: shape({ id: oneOfType([ number, string ]).isRequired }).isRequired, +}; export default OrderPage; diff --git a/src/containers/PasswordChangePage/PasswordChangePage.js b/src/containers/PasswordChangePage/PasswordChangePage.js index 89b637d6..a65c64cf 100644 --- a/src/containers/PasswordChangePage/PasswordChangePage.js +++ b/src/containers/PasswordChangePage/PasswordChangePage.js @@ -1,4 +1,4 @@ import React from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; -export default () => +export default () => diff --git a/src/containers/PasswordForgottenPage/PasswordForgottenPage.js b/src/containers/PasswordForgottenPage/PasswordForgottenPage.js index f779933f..446a8c64 100644 --- a/src/containers/PasswordForgottenPage/PasswordForgottenPage.js +++ b/src/containers/PasswordForgottenPage/PasswordForgottenPage.js @@ -1,4 +1,4 @@ import React from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; -export default () => +export default () => diff --git a/src/containers/PaymentMethodsPage/PaymentMethodsPage.js b/src/containers/PaymentMethodsPage/PaymentMethodsPage.js index 40a09b48..de4e76f6 100644 --- a/src/containers/PaymentMethodsPage/PaymentMethodsPage.js +++ b/src/containers/PaymentMethodsPage/PaymentMethodsPage.js @@ -1,4 +1,4 @@ import React from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; -export default () => +export default () => diff --git a/src/containers/PayoutPreferencesPage/PayoutPreferencesPage.js b/src/containers/PayoutPreferencesPage/PayoutPreferencesPage.js index 65c4fd17..735d8c25 100644 --- a/src/containers/PayoutPreferencesPage/PayoutPreferencesPage.js +++ b/src/containers/PayoutPreferencesPage/PayoutPreferencesPage.js @@ -1,4 +1,4 @@ import React from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; -export default () => +export default () => diff --git a/src/containers/ProfilePage/ProfilePage.js b/src/containers/ProfilePage/ProfilePage.js index 6696d72a..d20de21e 100644 --- a/src/containers/ProfilePage/ProfilePage.js +++ b/src/containers/ProfilePage/ProfilePage.js @@ -1,8 +1,8 @@ import React, { PropTypes } from 'react'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const ProfilePage = ({ params }) => ( - + ); const { shape, string } = PropTypes; diff --git a/src/containers/SalesConversationPage/SalesConversationPage.js b/src/containers/SalesConversationPage/SalesConversationPage.js index 313dfeb4..6a80e93e 100644 --- a/src/containers/SalesConversationPage/SalesConversationPage.js +++ b/src/containers/SalesConversationPage/SalesConversationPage.js @@ -1,12 +1,12 @@ /* eslint-disable react/no-unescaped-entities */ import React, { PropTypes } from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { PageLayout } from '../../components'; const SalesConversationPage = props => { const { params } = props; return ( - +

    Sale id: {params.id}

    Discussion tab
    @@ -17,7 +17,7 @@ const SalesConversationPage = props => { /order/1234 )

    -
    +
    ); }; diff --git a/src/containers/SearchPage/SearchPage.ducks.js b/src/containers/SearchPage/SearchPage.ducks.js new file mode 100644 index 00000000..7dc216eb --- /dev/null +++ b/src/containers/SearchPage/SearchPage.ducks.js @@ -0,0 +1,25 @@ +/** + * This file contains Action constants, Action creators, and reducer of a page + * container. We are following Ducks module proposition: + * https://github.com/erikras/ducks-modular-redux + */ +import { unionWith, isEqual } from 'lodash'; + +// Actions +export const ADD_FILTER = 'app/SearchPage/ADD_FILTER'; + +// Reducer +export default function reducer(state = {}, action = {}) { + const { type, payload } = action; + switch (type) { + case ADD_FILTER: { + const stateFilters = state.filters || []; + return { ...state, ...{ filters: unionWith(stateFilters, [ payload ], isEqual) } }; + } + default: + return state; + } +} + +// Action Creators +export const addFilter = (key, value) => ({ type: ADD_FILTER, payload: { [key]: value } }); diff --git a/src/containers/SearchPage/SearchPage.js b/src/containers/SearchPage/SearchPage.js index 8b0ba9dd..6863e17c 100644 --- a/src/containers/SearchPage/SearchPage.js +++ b/src/containers/SearchPage/SearchPage.js @@ -1,9 +1,30 @@ import React from 'react'; import { Link } from 'react-router'; -import { Page } from '../../components'; +import { connect } from 'react-redux'; +import { PageLayout } from '../../components'; +import { addFlashNotification } from '../../ducks/FlashNotification.ducks'; +import { addFilter } from './SearchPage.ducks'; -export default () => ( - +export const SearchPageComponent = () => ( + Nice studio in Helsinki - -) +
    +); + +/** + * Container functions. + * Since we add this to global store state with combineReducers, this will only get partial state + * which is page specific. + */ +const mapStateToProps = function mapStateToProps(state) { + return state; +}; + +const mapDispatchToProps = function mapDispatchToProps(dispatch) { + return { + addNotice: msg => dispatch(addFlashNotification('notice', msg)), + addFilter: (k, v) => dispatch(addFilter(k, v)), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(SearchPageComponent) diff --git a/src/containers/SearchPage/SearchPage.test.js b/src/containers/SearchPage/SearchPage.test.js index 893ea3c5..9926ee98 100644 --- a/src/containers/SearchPage/SearchPage.test.js +++ b/src/containers/SearchPage/SearchPage.test.js @@ -1,14 +1,15 @@ import React from 'react'; import { BrowserRouter } from 'react-router'; import renderer from 'react-test-renderer'; -import SearchPage from './SearchPage'; +import { SearchPageComponent } from './SearchPage'; +import reducer, { ADD_FILTER, addFilter } from './SearchPage.ducks'; -describe('SearchPage', () => { +describe('SearchPageComponent', () => { it('matches snapshot', () => { const component = renderer.create( ( - + ), ); @@ -16,3 +17,35 @@ describe('SearchPage', () => { expect(tree).toMatchSnapshot(); }); }); + +describe('SearchPageDucs', () => { + describe('actions', () => { + it('should create an action to add a filter', () => { + const expectedAction = { type: ADD_FILTER, payload: { location: 'helsinki' } }; + expect(addFilter('location', 'helsinki')).toEqual(expectedAction); + }); + }); + + describe('reducer', () => { + it('should return the initial state', () => { + const initial = reducer(undefined, {}); + expect(initial).toEqual({}); + }); + + it('should handle ADD_FILTER', () => { + const addFilter1 = addFilter('location', 'helsinki'); + const addFilter2 = addFilter('gears', 3); + const reduced = reducer([], addFilter1); + const reducedWithInitialContent = reducer({ filters: [ addFilter1.payload ] }, addFilter2); + expect(reduced).toEqual({ filters: [ addFilter1.payload ] }); + expect(reducedWithInitialContent).toEqual({ filters: [ addFilter1.payload, addFilter2.payload ] }); + }); + + it('should handle duplicates ADD_FILTER', () => { + const filter = { location: 'helsinki' }; + const addFilter = { type: ADD_FILTER, payload: filter }; + const reducedWithInitialContent = reducer({ filters: [ filter ] }, addFilter); + expect(reducedWithInitialContent).toEqual({ filters: [ filter ] }); + }); + }); +}); diff --git a/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap b/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap index cf17ef7e..3009aeb6 100644 --- a/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap +++ b/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap @@ -1,4 +1,4 @@ -exports[`SearchPage matches snapshot 1`] = ` +exports[`SearchPageComponent matches snapshot 1`] = `
    ( - +

    Change password, delete account

    -
    + ) diff --git a/src/containers/reducers.js b/src/containers/reducers.js new file mode 100644 index 00000000..9c6fcc69 --- /dev/null +++ b/src/containers/reducers.js @@ -0,0 +1,10 @@ +/** + * Export reducers from ducks modules of different containers (i.e. default export) + * We are following Ducks module proposition: + * https://github.com/erikras/ducks-modular-redux + */ + +/* eslint-disable import/prefer-default-export */ +import SearchPage from './SearchPage/SearchPage.ducks'; + +export { SearchPage }; diff --git a/src/ducks/FlashNotification.ducks.js b/src/ducks/FlashNotification.ducks.js new file mode 100644 index 00000000..27eb27fe --- /dev/null +++ b/src/ducks/FlashNotification.ducks.js @@ -0,0 +1,51 @@ +/** + * This file contains Action constants, Action creators, and reducer of global + * FlashMessages. Global actions can be used in multiple pages. + * We are following Ducks module proposition: + * https://github.com/erikras/ducks-modular-redux + */ + +import { find, findIndex } from 'lodash'; + +// Actions: system notifications +export const ADD_FLASH_NOTIFICATION = 'app/FlashNotification/ADD_NOTIFICATION'; +export const REMOVE_FLASH_NOTIFICATION = 'app/FlashNotification/REMOVE_NOTIFICATION'; + +const initialState = []; + +// Reducer +export default (state = initialState, action) => { + const { type, payload } = action; + switch (type) { + case ADD_FLASH_NOTIFICATION: + if (!find(state, n => n.content === payload.content && !n.isRead)) { + return state.concat([ + { id: payload.id, type: payload.type, content: payload.content, isRead: false }, + ]); + } + return state; + + case REMOVE_FLASH_NOTIFICATION: + return state.map( + findIndex(state, msg => msg.id === payload.id), + msg => msg.set('isRead', true), + ); + + default: + return state; + } +} + +// Action Creators +let nextMessageId = 1; + +export const addFlashNotification = (type, content) => { + const id = nextMessageId; + nextMessageId += 1; + return { + type: ADD_FLASH_NOTIFICATION, + payload: { id: `note_${id}`, type, content, isRead: false }, + }; +}; + +export const removeFlashNotification = id => ({ type: REMOVE_FLASH_NOTIFICATION, payload: { id } }); diff --git a/src/ducks/FlashNotification.test.js b/src/ducks/FlashNotification.test.js new file mode 100644 index 00000000..3a2064c8 --- /dev/null +++ b/src/ducks/FlashNotification.test.js @@ -0,0 +1,49 @@ +import reducer, { + ADD_FLASH_NOTIFICATION, + REMOVE_FLASH_NOTIFICATION, + addFlashNotification, + removeFlashNotification, +} from './FlashNotification.ducks'; + +describe('FlashNotification', () => { + describe('actions', () => { + it('should create an action to add a filter', () => { + const content = 'Error message'; + const type = 'error'; + const expectedAction = { + type: ADD_FLASH_NOTIFICATION, + payload: { id: 'note_1', type, content, isRead: false }, + }; + const received = addFlashNotification(type, content); + expect(received).toEqual(expectedAction); + }); + + it('should create an action to remove a notification', () => { + const expectedAction = { type: REMOVE_FLASH_NOTIFICATION, payload: { id: 1 } }; + + expect(removeFlashNotification(1)).toEqual(expectedAction); + }); + }); + + describe('reducer', () => { + it('should return the initial state', () => { + const initial = reducer(undefined, {}); + expect(initial).toEqual([]); + }); + + it('should handle ADD_FLASH_NOTIFICATION', () => { + const addFlashNote1 = addFlashNotification('error', 'Run the tests'); + const addFlashNote2 = addFlashNotification('error', 'Run the tests again'); + const reduced = reducer([], addFlashNote1); + const reducedWithInitialContent = reducer([ addFlashNote1.payload ], addFlashNote2); + expect(reduced).toEqual([ addFlashNote1.payload ]); + expect(reducedWithInitialContent).toEqual([ addFlashNote1.payload, addFlashNote2.payload ]); + }); + + it('should handle duplicates ADD_FILTER', () => { + const addFlashNote = addFlashNotification('error', 'Run the tests'); + const reducedWithInitialContent = reducer([ addFlashNote.payload ], addFlashNote); + expect(reducedWithInitialContent).toEqual([ addFlashNote.payload ]); + }); + }); +}); diff --git a/src/ducks/index.js b/src/ducks/index.js new file mode 100644 index 00000000..739c6c30 --- /dev/null +++ b/src/ducks/index.js @@ -0,0 +1,9 @@ +/** + * Import reducers from shared ducks modules (default export) + * We are following Ducks module proposition: + * https://github.com/erikras/ducks-modular-redux + */ + +import FlashNotification from './FlashNotification.ducks'; + +export { FlashNotification }; // eslint-disable-line import/prefer-default-export diff --git a/src/index.js b/src/index.js index d5c92c40..90e229be 100644 --- a/src/index.js +++ b/src/index.js @@ -14,12 +14,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { ClientApp, renderApp } from './app'; +import configureStore from './store'; import './index.css'; // If we're in a browser already, render the client application. if (typeof window !== 'undefined') { - ReactDOM.render(, document.getElementById('root')); + const preloadedState = window.__PRELOADED_STATE__ || {}; // eslint-disable-line no-underscore-dangle + const store = configureStore(preloadedState); + + ReactDOM.render(, document.getElementById('root')); } // Export the function for server side rendering. diff --git a/src/reducers.js b/src/reducers.js new file mode 100644 index 00000000..6fa17e41 --- /dev/null +++ b/src/reducers.js @@ -0,0 +1,16 @@ +import { combineReducers } from 'redux'; +import * as globalReducers from './ducks'; +import * as pageReducers from './containers/reducers'; + +/** + * Function _createReducer_ combines global reducers (reducers that are used in + * multiple pages) and reducers that are handling actions happening inside one page container. + * Since we combineReducers, pageReducers will get page specific key (e.g. SearchPage) + * which is page specific. + * Future: this structure could take in asyncReducers, which are changed when you navigate pages. + */ +const createReducer = function createReducer() { + return combineReducers({ ...globalReducers, ...pageReducers }); +}; + +export default createReducer; diff --git a/src/store.js b/src/store.js new file mode 100644 index 00000000..2d0f79e3 --- /dev/null +++ b/src/store.js @@ -0,0 +1,10 @@ +import { createStore } from 'redux'; +import createReducer from './reducers'; + +/** + * configureStore creates a new store with initial state and possible enhancers + * (like redux-saga or redux-thunk middleware) + */ +export default function configureStore(initialState) { + return createStore(createReducer(), initialState); +} diff --git a/yarn.lock b/yarn.lock index 52556dc9..02c6ac0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2608,6 +2608,10 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hoist-non-react-statics@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -2801,7 +2805,7 @@ interpret@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" -invariant@^2.2.0, invariant@^2.2.1: +invariant@^2.0.0, invariant@^2.2.0, invariant@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" dependencies: @@ -3455,6 +3459,14 @@ loader-utils@0.2.x, loader-utils@^0.2.11, loader-utils@^0.2.16, loader-utils@^0. json5 "^0.5.0" object-assign "^4.0.1" +lodash, "lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.16.2, lodash@^4.16.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" + +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" + lodash._arraycopy@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz#76e7b7c1f1fb92547374878a562ed06a3e50f6e1" @@ -3600,10 +3612,6 @@ lodash.uniq@^4.3.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.16.2, lodash@^4.16.4, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0: - version "4.17.4" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" - longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" @@ -4792,7 +4800,7 @@ qs@6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b" -qs@~6.3.0: +qs@^6.3.0, qs@~6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" @@ -4869,6 +4877,16 @@ react-helmet@^4.0.0: object-assign "^4.0.1" react-side-effect "^1.1.0" +react-redux@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.2.tgz#3d9878f5f71c6fafcd45de1fbb162ea31f389814" + dependencies: + hoist-non-react-statics "^1.0.3" + invariant "^2.0.0" + lodash "^4.2.0" + lodash-es "^4.2.0" + loose-envify "^1.1.0" + react-router@4.0.0-alpha.6: version "4.0.0-alpha.6" resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.0.0-alpha.6.tgz#239fcf9a6ba7997021022c9b51d72d370f7b6bf4" @@ -5010,6 +5028,15 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^0.4.2" +redux@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-3.6.0.tgz#887c2b3d0b9bd86eca2be70571c27654c19e188d" + dependencies: + lodash "^4.2.1" + lodash-es "^4.2.1" + loose-envify "^1.1.0" + symbol-observable "^1.0.2" + referrer-policy@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.1.0.tgz#35774eb735bf50fb6c078e83334b472350207d79" @@ -5598,6 +5625,10 @@ svgo@^0.7.0: sax "~1.2.1" whet.extend "~0.9.9" +symbol-observable@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d" + "symbol-tree@>= 3.1.0 < 4.0.0": version "3.2.1" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.1.tgz#8549dd1d01fa9f893c18cc9ab0b106b4d9b168cb"