mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
commit
24e44787c2
41 changed files with 433 additions and 119 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<!--!title-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<script><!--!preloadedStateScript--></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><!--!body--></div>
|
||||
|
|
|
|||
|
|
@ -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(/</g, '\\u003c')};
|
||||
`;
|
||||
|
||||
return template({ title: head.title.toString(), preloadedStateScript, body });
|
||||
}
|
||||
|
||||
const env = process.env.NODE_ENV;
|
||||
|
|
@ -91,7 +101,12 @@ app.use('/static', express.static(path.join(buildPath, 'static')));
|
|||
|
||||
app.get('*', (req, res) => {
|
||||
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 <Miss>
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
40
src/app.js
40
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 }) => <Routes router={router} />;
|
||||
export const ClientApp = props => {
|
||||
const { store } = props;
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{({ router }) => (
|
||||
<Provider store={store}>
|
||||
<Routes router={router} />
|
||||
</Provider>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
const { any, string } = PropTypes;
|
||||
|
||||
RoutesWithRouterProp.propTypes = { router: any.isRequired };
|
||||
|
||||
export const ClientApp = () => (
|
||||
<BrowserRouter>
|
||||
{RoutesWithRouterProp}
|
||||
</BrowserRouter>
|
||||
);
|
||||
ClientApp.propTypes = { store: any.isRequired };
|
||||
|
||||
export const ServerApp = props => {
|
||||
const { url, context } = props;
|
||||
const { url, context, store } = props;
|
||||
return (
|
||||
<ServerRouter location={url} context={context}>
|
||||
{RoutesWithRouterProp}
|
||||
{({ router }) => (
|
||||
<Provider store={store}>
|
||||
<Routes router={router} />
|
||||
</Provider>
|
||||
)}
|
||||
</ServerRouter>
|
||||
);
|
||||
};
|
||||
|
||||
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(<ServerApp url={url} context={serverContext} />);
|
||||
export const renderApp = (url, serverContext, preloadedState) => {
|
||||
const store = configureStore(preloadedState);
|
||||
const body = ReactDOMServer.renderToString(
|
||||
<ServerApp url={url} context={serverContext} store={store} />,
|
||||
);
|
||||
const head = Helmet.rewind();
|
||||
return { head, body };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(<ServerApp url={url} context={context} />);
|
||||
ReactDOMServer.renderToString(<ServerApp url={url} context={context} store={store} />);
|
||||
|
||||
describe('Application', () => {
|
||||
it('renders in the client without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<ClientApp />, div);
|
||||
ReactDOM.render(<ClientApp store={store} />, div);
|
||||
});
|
||||
|
||||
it('renders in the server without crashing', () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={className}>
|
||||
<Helmet title={title} />
|
||||
<Topbar />
|
||||
<h1>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { string, any } = PropTypes;
|
||||
|
||||
Page.defaultProps = { className: '', children: null };
|
||||
|
||||
Page.propTypes = { className: string, title: string.isRequired, children: any };
|
||||
|
||||
export default Page;
|
||||
40
src/components/PageLayout/PageLayout.js
Normal file
40
src/components/PageLayout/PageLayout.js
Normal file
|
|
@ -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 (
|
||||
<div className={className}>
|
||||
<Helmet title={title} />
|
||||
<Topbar />
|
||||
<h1>{title}</h1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import Page from './Page/Page';
|
||||
import PageLayout from './PageLayout/PageLayout';
|
||||
|
||||
export { Page };
|
||||
export { PageLayout };
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Page title={`Authentication page: ${this.props.tab} tab`}>
|
||||
<PageLayout title={`Authentication page: ${this.props.tab} tab`}>
|
||||
{redirectToReferrer ? <Redirect to={from || '/'} /> : null}
|
||||
{fromLoginMsg}
|
||||
<button onClick={this.login}>{currentMethod}</button>
|
||||
<p>or {alternativeMethod}</p>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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' ]),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<Page title={`Checkout page: ${params.listingId}`}>
|
||||
<PageLayout title={`Checkout page: ${params.listingId}`}>
|
||||
<Link to={`/order/12345`}><button>Pay</button></Link>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
const { shape, string } = PropTypes;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => <Page title="Contact details" />
|
||||
export default () => <PageLayout title="Contact details" />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
const ConversationPage = props => {
|
||||
const { params } = props;
|
||||
return (
|
||||
<Page title="Conversation page">
|
||||
<PageLayout title="Conversation page">
|
||||
<p>Conversation id: {params.id}</p>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
const EditProfilePage = props => {
|
||||
const { params } = props;
|
||||
return <Page title={`Edit profile page with display name: ${params.displayName}`} />;
|
||||
return <PageLayout title={`Edit profile page with display name: ${params.displayName}`} />;
|
||||
};
|
||||
|
||||
const { shape, string } = PropTypes;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Page title={`${filter} page`}>
|
||||
<PageLayout title={`${filter} page`}>
|
||||
<ul>
|
||||
<li><Link to={toPath(filter, 1234)}>Single thread</Link></li>
|
||||
</ul>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ describe('InboxPage', () => {
|
|||
const component = renderer.create(
|
||||
(
|
||||
<BrowserRouter>
|
||||
<InboxPage />
|
||||
<InboxPage filter="inbox" />
|
||||
</BrowserRouter>
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ exports[`InboxPage matches snapshot 1`] = `
|
|||
</button>
|
||||
</div>
|
||||
<h1>
|
||||
conversation page
|
||||
inbox page
|
||||
</h1>
|
||||
<ul>
|
||||
<li>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => (
|
||||
<Page title="Landing page">
|
||||
<PageLayout title="Landing page">
|
||||
<Link to="/s?location=helsinki">Find studios</Link>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Page title={`Listing page with listing id: #${id}`}>
|
||||
<PageLayout title={`Listing page with listing id: #${id}`}>
|
||||
<p>Slug: {params.slug}</p>
|
||||
<Link to={`/checkout/${id}`}><button>Book</button></Link>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => (
|
||||
<Page title="Manage listings">
|
||||
<PageLayout title="Manage listings">
|
||||
<ul>
|
||||
<li><Link to="/l/1234">Listing 1234</Link></li>
|
||||
</ul>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => (
|
||||
<Page title="Page not found">
|
||||
<PageLayout title="Page not found">
|
||||
<Link to="/">Index page</Link>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => <Page title="Notification settings" />
|
||||
export default () => <PageLayout title="Notification settings" />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Page title="Order page">
|
||||
<PageLayout title="Order page">
|
||||
<p>Order id: {params.id}</p>
|
||||
<Link to={`/order/${params.id}/discussion`}>Discussion tab</Link>
|
||||
<br />
|
||||
|
|
@ -17,12 +17,14 @@ const OrderPage = props => {
|
|||
/order/1234
|
||||
</i>)
|
||||
</p>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => <Page title="Type new password" />
|
||||
export default () => <PageLayout title="Type new password" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => <Page title="Request new password" />
|
||||
export default () => <PageLayout title="Request new password" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => <Page title="Payment methods" />
|
||||
export default () => <PageLayout title="Payment methods" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => <Page title="Payout preferences" />
|
||||
export default () => <PageLayout title="Payout preferences" />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
const ProfilePage = ({ params }) => (
|
||||
<Page title={`Profile page with display name: ${params.displayName}`} />
|
||||
<PageLayout title={`Profile page with display name: ${params.displayName}`} />
|
||||
);
|
||||
|
||||
const { shape, string } = PropTypes;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Page title="Sales conversation page">
|
||||
<PageLayout title="Sales conversation page">
|
||||
<p>Sale id: {params.id}</p>
|
||||
<Link to={`/sale/${params.id}/discussion`}>Discussion tab</Link>
|
||||
<br />
|
||||
|
|
@ -17,7 +17,7 @@ const SalesConversationPage = props => {
|
|||
/order/1234
|
||||
</i>)
|
||||
</p>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
25
src/containers/SearchPage/SearchPage.ducks.js
Normal file
25
src/containers/SearchPage/SearchPage.ducks.js
Normal file
|
|
@ -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 } });
|
||||
|
|
@ -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 () => (
|
||||
<Page title="Search page">
|
||||
export const SearchPageComponent = () => (
|
||||
<PageLayout title="Search page">
|
||||
<Link to="/l/Nice+studio-in-Helsinki-345">Nice studio in Helsinki</Link>
|
||||
</Page>
|
||||
)
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
(
|
||||
<BrowserRouter>
|
||||
<SearchPage />
|
||||
<SearchPageComponent />
|
||||
</BrowserRouter>
|
||||
),
|
||||
);
|
||||
|
|
@ -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 ] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
exports[`SearchPage matches snapshot 1`] = `
|
||||
exports[`SearchPageComponent matches snapshot 1`] = `
|
||||
<div
|
||||
className="">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Page } from '../../components';
|
||||
import { PageLayout } from '../../components';
|
||||
|
||||
export default () => (
|
||||
<Page title="Security">
|
||||
<PageLayout title="Security">
|
||||
<p>Change password, delete account</p>
|
||||
</Page>
|
||||
</PageLayout>
|
||||
)
|
||||
|
|
|
|||
10
src/containers/reducers.js
Normal file
10
src/containers/reducers.js
Normal file
|
|
@ -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 };
|
||||
51
src/ducks/FlashNotification.ducks.js
Normal file
51
src/ducks/FlashNotification.ducks.js
Normal file
|
|
@ -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 } });
|
||||
49
src/ducks/FlashNotification.test.js
Normal file
49
src/ducks/FlashNotification.test.js
Normal file
|
|
@ -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 ]);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
src/ducks/index.js
Normal file
9
src/ducks/index.js
Normal file
|
|
@ -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
|
||||
|
|
@ -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(<ClientApp />, document.getElementById('root'));
|
||||
const preloadedState = window.__PRELOADED_STATE__ || {}; // eslint-disable-line no-underscore-dangle
|
||||
const store = configureStore(preloadedState);
|
||||
|
||||
ReactDOM.render(<ClientApp store={store} />, document.getElementById('root'));
|
||||
}
|
||||
|
||||
// Export the function for server side rendering.
|
||||
|
|
|
|||
16
src/reducers.js
Normal file
16
src/reducers.js
Normal file
|
|
@ -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;
|
||||
10
src/store.js
Normal file
10
src/store.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
43
yarn.lock
43
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue