diff --git a/docs/adding-a-page.md b/docs/adding-a-page.md new file mode 100644 index 00000000000..d8d4a835e65 --- /dev/null +++ b/docs/adding-a-page.md @@ -0,0 +1,325 @@ +# addons-frontend + +This will outline what is required to add a page to the project. A basic knowledge of +[react](https://facebook.github.io/react/docs/getting-started.html) and +[redux](http://redux.js.org/) is assumed. + +## Structure + +A basic app structure will look like this: + +``` +src/ + / + components/ + MyComponent/ + index.js + styles.scss + containers/ + MyContainer/ + index.js + styles.scss + reducers/ + client.js + routes.js + store.js +tests/ + client/ + / + components/ + TestMyComponent.js + containers/ + TestMyContainer.js + reducers/ +``` + +## Components vs Containers + +A component should have no usage of redux in it. It only operates on the data passed into it +through props. A container will use redux to connect data from the store to a component. A +container may or may not wrap a component, whatever makes sense to you. + +# How to Add a Page + +We'll make a basic user profile component that hits the API. We'll start by creating a basic +component with data set manually, then we'll hit the API to populate redux, then we'll update the +component to pull its data from the redux store. + +## Creating a Component + +We'll create our component in the search app since it will use the currently logged in user. Our +component is going to show the currently logged in user's email address and username. To start +we'll create a component without any outside data. + +### Basic component + +```jsx +// src/search/containers/UserPage/index.js +import React from 'react'; + +export default class UserPage extends React.Component { + render() { + return ( +
+

Hi there!

+
    +
  • username: my-username
  • +
  • email: me@example.com
  • +
+
+ ); + } +} +``` + +### Adding a route + +To render this component we'll tell [react-router](https://github.com/reactjs/react-router) to load +it at the `/user` path. + +```jsx +// src/search/routes.js +// ... omit imports + +export default ( + + + + + + + // Add this line to use the `UserPage` component at the `/user` path. + + + + +); +``` + +### Starting the server + +Now that the component is setup we can run `npm start dev:search` and navigate to +[http://localhost:3000/user](http://localhost:3000/user) to see the page. Since our component is +wrapped in the `LoginRequired` component you will need to be logged in to see the page. + +### Using props + +We want our component's data to be able to change based on the current user. For now we'll update +the component so that it uses props but we won't yet connect it to redux. We will however use +react-redux's `connect()` to set the props for us as if it were pulling the data from redux. + +```jsx +// src/search/containers/UserPage/index.js +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; + +class UserPage extends React.Component { + static propTypes = { + email: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + } + + render() { + const { email, username } = this.props; + return ( +
+

Hi there!

+
    +
  • username: {username}
  • +
  • email: {email}
  • +
+
+ ); + } +} + +function mapStateToProps() { + return { + email: 'me@example.com', + username: 'my-username', + }; +} + +export default connect(mapStateToProps)(UserPage); +``` + +Changing the values in `mapStateToProps` will now update the values shown on the page. + +NOTE: You may need to restart your server to see any changes as there is currently a bug that +does not update the server rendered code on the dev server. + +### Hitting the API + +To access the user's data we'll add a new API function and a +[normalizr](https://github.com/paularmstrong/normalizr) schema for user objects. Our API function +will be pretty basic since it will use our internal `callApi()` function to handle accessing the +API along with the normalizr schema to format the response data. + +```js +// src/core/api/index.js +// ... omit imports + +const addon = new Schema('addons', {idAttribute: 'slug'}); +// Tell normalizr we have "users" and they use the `username` property for their primary key. +const user = new Schema('users', {idAttribute: 'username'}); + +// Add this function. +export function fetchProfile({ api }) { + return callApi({ + endpoint: 'accounts/profile', + schema: user, + params: {lang: 'en-US'}, + auth: true, + state: api, + }); +} +``` + +Calling the `fetchProfile()` function will hit the API, now we need to get the data into redux. We +can use the `loadEntities()` action creator to dispatch a generic action for loading data from our +API but we'll need to add a users reducer to store the data. + +```js +// src/core/reducers/users.js +export default function users(state = {}, { payload = {} }) { + if (payload.entities && payload.entities.users) { + return {...state, ...payload.entities.users}; + } + return state; +} +``` + +No we need to tell the app to use the reducer by adding it to our store. + +```js +// src/search/store.js +import users from 'core/reducers/users'; + +export default function createStore(initialState = {}) { + return _createStore( + // Add the `users` reducer here. + combineReducers({addons, api, auth, search, reduxAsyncConnect, users}), + initialState, + middleware(), + ); +} +``` + +We also don't have any record of the user's username. We'll need that to pull the right user. We +can update the `auth` reducer to store it. + +```js +// src/core/reducers/authentication.js +export default function authentication(state = {}, action) { + const { payload, type } = action; + if (type === 'SET_JWT') { + return {token: payload.token}; + } else if (type === 'SET_CURRENT_USER') { + return {...state, username: payload.username}; + } + return state; +} +``` + +We'll also want to add an action creator to set the current user. The action creator just +simplifies interacting with redux to keep the code clean. + +```js +// src/search/actions/index.js + +// Add this at the bottom of the file. +export function setCurrentUser(username) { + return { + type: 'SET_CURRENT_USER', + payload: { + username, + }, + }; +} +``` + +### Combining redux and the API + +Now that we can hit the API and we can store that data in the redux store we need to update our +component to hit the API, update the store, and pull the data from the store. To do this we will +use [redux-async-connect](https://github.com/Rezonans/redux-async-connect)'s `asyncConnect()`. + +```jsx +// src/search/containers/AddonPage/index.js +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { asyncConnect } from 'redux-async-connect'; + +import { fetchProfile } from 'core/api'; +import { loadEntities, setCurrentUser } from 'search/actions'; + +class UserPage extends React.Component { + static propTypes = { + email: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + } + + render() { + const { email, username } = this.props; + return ( +
+

Hi there!

+
    +
  • username: {username}
  • +
  • email: {email}
  • +
+
+ ); + } +} + +function getUser({ auth, users }) { + return users[auth.username]; +} + +function mapStateToProps(state) { + return getUser(state); +} + +function loadProfileIfNeeded({ store: { getState, dispatch } }) { + const state = getState(); + const user = getUser(state); + if (!user) { + return fetchProfile({api: state.api}) + .then(({entities, result}) => { + dispatch(loadEntities(entities)); + dispatch(setCurrentUser(result)); + }); + } + return Promise.resolve(); +} + +export default asyncConnect([{ + deferred: true, + promise: loadProfileIfNeeded, +}])(connect(mapStateToProps)(UserPage)); +``` + +### Styling the page + +To style your page you just need to import your SCSS file in your component. All of the CSS will +be transpiled and minified into a single bundle in production so you will still need to namespace +your styles. + +```js +// src/search/containers/AddonPage/index.js +// Add this line with the other imports. +import './styles.scss'; +``` + +```scss +// src/search/containers/AddonPage/styles.scss +.user-page { + h1 { + text-decoration: underline; + } + li { + text-transform: uppercase; + } +} +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000000..332ed6f72a6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,4 @@ +addons-frontend +=============== + +* [How to Add a Page](./adding-a-page.md) diff --git a/src/core/api/index.js b/src/core/api/index.js index 6f1a514adcc..3118e02fb81 100644 --- a/src/core/api/index.js +++ b/src/core/api/index.js @@ -8,6 +8,7 @@ import 'isomorphic-fetch'; const API_BASE = `${config.get('apiHost')}${config.get('apiPath')}`; const addon = new Schema('addons', {idAttribute: 'slug'}); +const user = new Schema('users', {idAttribute: 'username'}); function makeQueryString(query) { return url.format({query}); @@ -77,3 +78,13 @@ export function login({ api, code, state }) { export function startLoginUrl() { return `${API_BASE}/internal/accounts/login/start/`; } + +export function fetchProfile({ api }) { + return callApi({ + endpoint: 'accounts/profile', + schema: user, + params: {lang: 'en-US'}, + auth: true, + state: api, + }); +} diff --git a/src/core/reducers/addons.js b/src/core/reducers/addons.js index 4f98efbfbaf..2bf38bd443b 100644 --- a/src/core/reducers/addons.js +++ b/src/core/reducers/addons.js @@ -3,11 +3,7 @@ const initialState = {}; export default function addon(state = initialState, action) { const { payload } = action; if (payload && payload.entities && payload.entities.addons) { - const newState = Object.assign({}, state); - Object.keys(payload.entities.addons).forEach((slug) => { - newState[slug] = payload.entities.addons[slug]; - }); - return newState; + return {...state, ...payload.entities.addons}; } return state; } diff --git a/src/core/reducers/authentication.js b/src/core/reducers/authentication.js index a29e2a03315..41e43d1d402 100644 --- a/src/core/reducers/authentication.js +++ b/src/core/reducers/authentication.js @@ -2,6 +2,8 @@ export default function authentication(state = {}, action) { const { payload, type } = action; if (type === 'SET_JWT') { return {token: payload.token}; + } else if (type === 'SET_CURRENT_USER') { + return {...state, username: payload.username}; } return state; } diff --git a/src/core/reducers/users.js b/src/core/reducers/users.js new file mode 100644 index 00000000000..098b03cf473 --- /dev/null +++ b/src/core/reducers/users.js @@ -0,0 +1,6 @@ +export default function users(state = {}, { payload = {} }) { + if (payload.entities && payload.entities.users) { + return {...state, ...payload.entities.users}; + } + return state; +} diff --git a/src/search/actions/index.js b/src/search/actions/index.js index 06dbfadb800..1a96c5e3d27 100644 --- a/src/search/actions/index.js +++ b/src/search/actions/index.js @@ -25,3 +25,12 @@ export function loadEntities(entities) { payload: {entities}, }; } + +export function setCurrentUser(username) { + return { + type: 'SET_CURRENT_USER', + payload: { + username, + }, + }; +} diff --git a/src/search/containers/UserPage/index.js b/src/search/containers/UserPage/index.js new file mode 100644 index 00000000000..d5541933f51 --- /dev/null +++ b/src/search/containers/UserPage/index.js @@ -0,0 +1,54 @@ +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { asyncConnect } from 'redux-async-connect'; + +import { fetchProfile } from 'core/api'; +import { loadEntities, setCurrentUser } from 'search/actions'; + +import './styles.scss'; + +export class UserPage extends React.Component { + static propTypes = { + email: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + } + + render() { + const { email, username } = this.props; + return ( +
+

Hi there!

+
    +
  • username: {username}
  • +
  • email: {email}
  • +
+
+ ); + } +} + +function getUser({ auth, users }) { + return users[auth.username]; +} + +export function mapStateToProps(state) { + return getUser(state); +} + +export function loadProfileIfNeeded({ store: { getState, dispatch } }) { + const state = getState(); + const user = getUser(state); + if (!user) { + return fetchProfile({api: state.api}) + .then(({entities, result}) => { + dispatch(loadEntities(entities)); + dispatch(setCurrentUser(result)); + }); + } + return Promise.resolve(); +} + +export default asyncConnect([{ + deferred: true, + promise: loadProfileIfNeeded, +}])(connect(mapStateToProps)(UserPage)); diff --git a/src/search/containers/UserPage/styles.scss b/src/search/containers/UserPage/styles.scss new file mode 100644 index 00000000000..f0fb9c330ed --- /dev/null +++ b/src/search/containers/UserPage/styles.scss @@ -0,0 +1,8 @@ +.user-page { + h1 { + text-decoration: underline; + } + li { + text-transform: uppercase; + } +} diff --git a/src/search/routes.js b/src/search/routes.js index 10656339072..21301a9f724 100644 --- a/src/search/routes.js +++ b/src/search/routes.js @@ -4,14 +4,18 @@ import { IndexRoute, Route } from 'react-router'; import App from './containers/App'; import CurrentSearchPage from './containers/CurrentSearchPage'; import AddonPage from './containers/AddonPage'; +import UserPage from './containers/UserPage'; import LoginRequired from 'core/containers/LoginRequired'; import HandleLogin from 'core/containers/HandleLogin'; export default ( - - - + + + + + + diff --git a/src/search/store.js b/src/search/store.js index 5c706dbae89..699cbc1461a 100644 --- a/src/search/store.js +++ b/src/search/store.js @@ -5,11 +5,12 @@ import addons from 'core/reducers/addons'; import api from 'core/reducers/api'; import auth from 'core/reducers/authentication'; import search from 'search/reducers/search'; +import users from 'core/reducers/users'; import { middleware } from 'core/store'; export default function createStore(initialState = {}) { return _createStore( - combineReducers({addons, api, auth, search, reduxAsyncConnect}), + combineReducers({addons, api, auth, search, reduxAsyncConnect, users}), initialState, middleware(), ); diff --git a/tests/client/core/api/test_api.js b/tests/client/core/api/test_api.js index 3f60788e7b4..34947415c45 100644 --- a/tests/client/core/api/test_api.js +++ b/tests/client/core/api/test_api.js @@ -143,4 +143,26 @@ describe('api', () => { }); }); }); + + describe('fetchProfile', () => { + it("requests the user's profile", () => { + const token = 'the.jwt.string'; + const user = {username: 'foo', email: 'foo@example.com'}; + mockWindow + .expects('fetch') + .withArgs('https://addons.mozilla.org/api/v3/accounts/profile/?lang=en-US', { + headers: {authorization: `Bearer ${token}`}, + method: 'get', + }) + .once() + .returns(Promise.resolve({ + ok: true, + json() { return user; }, + })); + return api.fetchProfile({api: {token}}).then((apiResponse) => { + assert.deepEqual(apiResponse, {entities: {users: {foo: user}}, result: 'foo'}); + mockWindow.verify(); + }); + }); + }); }); diff --git a/tests/client/core/reducers/test_authentication.js b/tests/client/core/reducers/test_authentication.js index c47006db49b..f0c43b5cc67 100644 --- a/tests/client/core/reducers/test_authentication.js +++ b/tests/client/core/reducers/test_authentication.js @@ -14,4 +14,16 @@ describe('authentication reducer', () => { const token = 'json.WEB.t0k3n'; assert.deepEqual(auth(undefined, {type: 'SET_JWT', payload: {token}}), {token}); }); + + it('sets the user on SET_CURRENT_USER', () => { + const username = 'my-username'; + assert.deepEqual(auth(undefined, {type: 'SET_CURRENT_USER', payload: {username}}), {username}); + }); + + it('maintains the token when adding a username', () => { + const username = 'name-of-user'; + assert.deepEqual( + auth({token: 'foo'}, {type: 'SET_CURRENT_USER', payload: {username}}), + {token: 'foo', username}); + }); }); diff --git a/tests/client/core/reducers/test_users.js b/tests/client/core/reducers/test_users.js new file mode 100644 index 00000000000..0ec162fd9be --- /dev/null +++ b/tests/client/core/reducers/test_users.js @@ -0,0 +1,28 @@ +import users from 'core/reducers/users'; + +describe('users reducer', () => { + let originalState; + + beforeEach(() => { + originalState = {foo: {username: 'foo'}, bar: {username: 'bar'}}; + }); + + it('returns the old state', () => { + assert.strictEqual(originalState, users(originalState, {type: 'BLAH'})); + }); + + it('stores users from entities', () => { + const state = users(originalState, { + payload: { + entities: { + users: { + baz: {username: 'baz'}, + }, + }, + }, + }); + assert.deepEqual( + state, + {foo: {username: 'foo'}, bar: {username: 'bar'}, baz: {username: 'baz'}}); + }); +}); diff --git a/tests/client/search/TestUserPage.js b/tests/client/search/TestUserPage.js new file mode 100644 index 00000000000..8c5647233d1 --- /dev/null +++ b/tests/client/search/TestUserPage.js @@ -0,0 +1,86 @@ +import React from 'react'; +import { renderIntoDocument } from 'react-addons-test-utils'; +import { findDOMNode } from 'react-dom'; + +import * as api from 'core/api'; +import { loadEntities, setCurrentUser } from 'search/actions'; +import { UserPage, mapStateToProps, loadProfileIfNeeded } from 'search/containers/UserPage'; + +describe('', () => { + it('renders the username and email', () => { + const root = findDOMNode(renderIntoDocument( + )); + assert.deepEqual( + Array.from(root.querySelectorAll('li')).map((li) => li.textContent), + ['username: my-username', 'email: me@example.com']); + }); + + it('pulls the user data from state', () => { + const user = {username: 'me', email: 'me@example.com'}; + assert.strictEqual( + mapStateToProps({auth: {username: user.username}, users: {[user.username]: user}}), + user); + }); + + describe('loadProfileIfNeeded', () => { + let sandbox; + let mockApi; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + mockApi = sandbox.mock(api); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('loads the profile when it is not loaded', () => { + const apiConfig = {api: 'config'}; + const entities = {'the-username': {username: 'the-username'}}; + const result = 'the-username'; + const dispatch = sandbox.stub(); + const store = { + dispatch, + getState() { + return { + api: apiConfig, + auth: {}, + users: {}, + }; + }, + }; + mockApi + .expects('fetchProfile') + .withArgs({api: apiConfig}) + .returns(Promise.resolve({entities, result})); + return loadProfileIfNeeded({store}).then(() => { + assert(dispatch.calledWith(loadEntities(entities))); + assert(dispatch.calledWith(setCurrentUser('the-username'))); + mockApi.verify(); + }); + }); + + it('does not load the profile when it is loaded', () => { + const apiConfig = {api: 'config'}; + const dispatch = sandbox.stub(); + const store = { + dispatch, + getState() { + return { + api: apiConfig, + auth: {username: 'me'}, + users: {me: {username: 'me'}}, + }; + }, + }; + mockApi + .expects('fetchProfile') + .never(); + return loadProfileIfNeeded({store}).then(() => { + assert(!dispatch.called, 'dispatch should not be called'); + mockApi.verify(); + }); + }); + }); +}); diff --git a/tests/client/search/test_store.js b/tests/client/search/test_store.js index ccba745d41a..063cf9e4f2e 100644 --- a/tests/client/search/test_store.js +++ b/tests/client/search/test_store.js @@ -5,7 +5,7 @@ describe('search createStore', () => { const store = createStore(); assert.deepEqual( Object.keys(store.getState()).sort(), - ['addons', 'api', 'auth', 'reduxAsyncConnect', 'search']); + ['addons', 'api', 'auth', 'reduxAsyncConnect', 'search', 'users']); }); it('creates an empty store', () => {