diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..7ac79ca --- /dev/null +++ b/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ "es2015", "react" ], + "plugins": [ + "transform-object-rest-spread", + "transform-class-properties" + ], + "env": { + "development": { + "presets": ["react-hmre"] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d12634 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..bc9af38 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,34 @@ +{ + "plugins": [ + "react" + ], + "extends": [ + "airbnb-base" + ], + "parser": "babel-eslint", + "parserOptions": { + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + } + }, + "env": { + "es6": true, + "browser": true, + "node": true, + "mocha": true + }, + "rules": { + "react/jsx-uses-react": 2, + "react/react-in-jsx-scope": 2, + "react/jsx-uses-vars": 2, + "no-underscore-dangle": "off", + "comma-dangle": [ + "error", + "never" + ], + "import/no-extraneous-dependencies": "off", + "linebreak-style": "off" + } +} diff --git a/.gitignore b/.gitignore index 30bc162..0b6b7ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/node_modules \ No newline at end of file +/node_modules +/.dist diff --git a/README.md b/README.md index ee9c819..75be935 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,91 @@ Netflix is investing a lot of effort into acquiring and creating beautiful artwork which is used throughout all of member experience on various devices. With a large global catalog and number of different images for a title, managing at scale is important. A non-technical operations team is responsible for acquisition and setup of artwork. Additionally, we have image algorithms that further augment the image metadata to detect actors, background tone etc. -The operations team needs a easy to use user interface that would allow it to quickly manage artwork across titles and sort/group/filter on various attributes (actors, background tone, similarity group etc. As part of this exercise please implement a functional UI that will help solve some of these challenges. +The operations team needs a easy to use user interface that would allow it to quickly manage artwork across titles and sort/group/filter on various attributes (actors, background tone, similarity group etc. As part of this exercise please implement a functional UI that will help solve some of these challenges. -Sample data model (json) is in `mock-data/data-model.json`. You can fill up `server/index.js` to return the json payload and use it in your UI components. We have limited the data model to contain only 1 type of image (“sdp”). You should assume that the service may return large volume of data (upwards of 1000s). +Sample data model (json) is in `server/data/artwork.json`. You can fill up `server/index.js` to return the json payload and use it in your UI components. We have limited the data model to contain only 1 type of image (“sdp”). You should assume that the service may return large volume of data (upwards of 1000s). -## UI should have following features: +## UI should have following features: * View images (thumbnailURL) which are grouped by an attribute. There are 2 attributes on which user can select to group by - movieId & languageCode. Default would be group by movieId. * Ability to zoom (fullSizeImageURL) in on a particular image and be presented with details view where all information from the data model is displayed. ### The preferred tech stack is React running on Node.js Note: Please work and submit the exercise (including tests) similar to how you would send a pull-request to a fellow engineer in the team. + +# Netflix Artworks + +## Instructions + +**Install the dependencies:** + +```npm install``` + +**To run in Development mode use:** + +```npm start``` + +This will start the API Server, bundle the assets (js, jsx) in memory using Webpack and will start webpack-dev-server with hot reload capabilities. + +A new window will automatically open in your browser on http://localhost:9000, and all backend calls will be proxied to the API Server on http://localhost:3000. + +ESLint will run and tests will be in watch mode. + +**To run in Production mode use:** + +```npm run build``` + +This will run eslint, tests and bundle the application in a single file called bundle.js under .dist/. + +```npm run server``` + +This will start the Express server responsible for both serving Application files and Artwork APIs. + +Open your browser at http://localhost:3000. + +**To run tests use:** + +```npm run test```, ```npm run test:watch``` to be in watch mode. + +## Application Capabilities + +The Netflix Artworks App allows the user to see a list of Artworks paginated, and grouped by a category of his choice, currently supporting movieId and languageCode. + +Clicking in one of the Artworks in the list will allow the user to see the Artwork details containing: +* Full size image. +* Movie Id. +* Movie Name. +* Image Type. +* Language Code. + +The Artwork details page can also be accessed directly using the route http://SERVER_URL/artwork/:languageCode/:movieId. + +For example running in Production mode try accessing: http://localhost:3000/artwork/nl/70140358 + +**The application is responsive, being usable from smartphones, tablets, laptops and desktops.** + +## Application Architecture + +The Netflix Artwork Application uses Facebook's ReactJS to build its User Interface, based on Google's Material UI and Express as its WebServer to serve static files and RestAPIs. + +The Front-end piece of the Application is implemented using the Flux pattern throughout the state management library Redux. + +Application State: +``` +{ + app: { + groupBy: 'movieId', // artworks default grouped by movieId + offset: 1 // pagination + }, + artworks: {} + // artwork object is a map of objects where the key is the grouping + // information. The value is another object normalized by the grouping value + // for fast access to the data. +}; +``` + +Other important libraries: +* react-router for routing. +* webpack for bundling the application. +* babel for transpiling. +* enzyme for component testing. diff --git a/client/actions/actionTypes.js b/client/actions/actionTypes.js new file mode 100644 index 0000000..77c0516 --- /dev/null +++ b/client/actions/actionTypes.js @@ -0,0 +1,12 @@ +/** +* Action types constants +**/ + +// ARTWORK ACTIONS +export const LOAD_ARTWORKS_SUCCESS = 'LOAD_ARTWORKS_SUCCESS'; + +// APP ACTIONS +export const CHANGE_GROUPING = 'CHANGE_GROUPING'; +export const SHOW_MORE_ITEMS = 'SHOW_MORE_ITEMS'; +export const SHOW_LESS_ITEMS = 'SHOW_LESS_ITEMS'; +export const SHOW_ALL_ITEMS = 'SHOW_ALL_ITEMS'; diff --git a/client/actions/appActions.js b/client/actions/appActions.js new file mode 100644 index 0000000..705ee90 --- /dev/null +++ b/client/actions/appActions.js @@ -0,0 +1,33 @@ +import * as types from './actionTypes'; + +/** + * @param groupBy + * @return redux action responsible for updating artwork groupBy state. + **/ +export function changeGroupBy(groupBy) { + return { type: types.CHANGE_GROUPING, groupBy }; +} + +/** + * @param groupBy + * @return redux action responsible for increasing artwork offset state. + **/ +export function showMoreItems() { + return { type: types.SHOW_MORE_ITEMS }; +} + +/** + * @param groupBy + * @return redux action responsible for decreasing artwork offset state. + **/ +export function showLessItems() { + return { type: types.SHOW_LESS_ITEMS }; +} + +/** + * @param groupBy + * @return redux action responsible for updating artwork offset state. + **/ +export function showAllItems(offset) { + return { type: types.SHOW_ALL_ITEMS, offset }; +} diff --git a/client/actions/appActions.test.js b/client/actions/appActions.test.js new file mode 100644 index 0000000..f165b1c --- /dev/null +++ b/client/actions/appActions.test.js @@ -0,0 +1,56 @@ +import expect from 'expect'; +import * as types from './actionTypes'; +import * as appActions from './appActions'; + +// Test a sync action +describe('App Actions', () => { + describe('changeGroupBy', () => { + it('should create a CHANGE_GROUPING action', () => { + const expectedAction = { + type: types.CHANGE_GROUPING, + groupBy: 'movieId' + }; + + const action = appActions.changeGroupBy('movieId'); + + expect(action).toEqual(expectedAction); + }); + }); + + describe('showMoreItems', () => { + it('should create a SHOW_MORE_ITEMS action', () => { + const expectedAction = { + type: types.SHOW_MORE_ITEMS + }; + + const action = appActions.showMoreItems(); + + expect(action).toEqual(expectedAction); + }); + }); + + describe('showLessItems', () => { + it('should create a SHOW_LESS_ITEMS action', () => { + const expectedAction = { + type: types.SHOW_LESS_ITEMS + }; + + const action = appActions.showLessItems(); + + expect(action).toEqual(expectedAction); + }); + }); + + describe('showAllItems', () => { + it('should create a SHOW_ALL_ITEMS action', () => { + const expectedAction = { + type: types.SHOW_ALL_ITEMS, + offset: 5 + }; + + const action = appActions.showAllItems(5); + + expect(action).toEqual(expectedAction); + }); + }); +}); diff --git a/client/actions/artworkActions.js b/client/actions/artworkActions.js new file mode 100644 index 0000000..16c1685 --- /dev/null +++ b/client/actions/artworkActions.js @@ -0,0 +1,49 @@ +import * as types from './actionTypes'; +import artworkService from '../api/ArtworkService'; + +/** +* Utility function to check response status. +* @param {response} +* @return {response} in case of success. +**/ +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response; + } + throw new Error(response.statusText); +} + +/** +* Utility function to parse JSON response. +* @param {response} +* @return {JSON object}. +**/ +function parseJSON(response) { + return response.json(); +} + +/** + * @param {artworks} + * @return redux action responsible for updating artwork state. + **/ +export function loadArtworksSuccess(artworks) { + return { type: types.LOAD_ARTWORKS_SUCCESS, artworks }; +} + +/** + * Async action to fetch artworks from the API layer. + * Dispatches LOAD_ARTWORKS_SUCCESS when the artworks are received + * redux action responsible for updating artwork state. + **/ +export function loadArtworks() { + return dispatch => + artworkService.getAllArtworks() + .then(checkStatus) + .then(parseJSON) + .then((artworks) => { + dispatch(loadArtworksSuccess(artworks)); + }) + .catch((error) => { + throw (error); + }); +} diff --git a/client/actions/artworkActions.test.js b/client/actions/artworkActions.test.js new file mode 100644 index 0000000..0125003 --- /dev/null +++ b/client/actions/artworkActions.test.js @@ -0,0 +1,71 @@ +import expect from 'expect'; +import nock from 'nock'; +import thunk from 'redux-thunk'; +import configureMockStore from 'redux-mock-store'; +import * as types from './actionTypes'; +import * as artworkActions from './artworkActions'; + +// Test a sync action +describe('Artwork Actions', () => { + const artworks = [{ + movieId: 70242311, + movieName: 'Orange Is the New Black', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/673e9/b39fcc29b2ac668ee01343de9f21f611c8f673e9.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/78bc7/198343ed941f178d54878aa366a122e4e2e78bc7.jpg', + languageCode: 'it' + }, { + movieId: 70242311, + movieName: 'Orange Is the New Black', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/304d2/7da9da4ea90c6df7889b67137cb64737e65304d2.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/934f7/e76e738da86e04d9c388605789211d6a883934f7.jpg', + languageCode: 'de' + }]; + + describe('loadArtworksSuccess', () => { + it('should create a LOAD_ARTWORKS_SUCCESS action', () => { + const expectedAction = { + type: types.LOAD_ARTWORKS_SUCCESS, + artworks + }; + + const action = artworkActions.loadArtworksSuccess(artworks); + + expect(action).toEqual(expectedAction); + }); + }); + + const middleware = [thunk]; + const mockStore = configureMockStore(middleware); + + describe('loadArtworks', () => { + beforeEach(() => { + nock.disableNetConnect(); + nock.enableNetConnect('127.0.0.1'); + }); + + afterEach(() => { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + it('should create LOAD_ARTWORKS_SUCCESS when dispatching loadArtworks', (done) => { + nock('http://localhost/api') + .get('/movies') + .reply(200, { body: { artworks } }); + + const expectedActions = [ + { type: types.LOAD_ARTWORKS_SUCCESS, body: { artworks } } + ]; + + // Testing if success action was called + const store = mockStore({ artworks: [] }, expectedActions); + return store.dispatch(artworkActions.loadArtworks()).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toEqual(types.LOAD_ARTWORKS_SUCCESS); + done(); + }); + }); + }); +}); diff --git a/client/api/ArtworkService.js b/client/api/ArtworkService.js new file mode 100644 index 0000000..a2ce6ab --- /dev/null +++ b/client/api/ArtworkService.js @@ -0,0 +1,11 @@ +import RestApi from './RestApi'; + +/** +* Artwork service Layer for API communication +**/ +const artworkApi = new RestApi('/movies'); +export default class ArtworkService { + static getAllArtworks() { + return artworkApi.get(); + } +} diff --git a/client/api/RestApi.js b/client/api/RestApi.js new file mode 100644 index 0000000..4bb2f06 --- /dev/null +++ b/client/api/RestApi.js @@ -0,0 +1,43 @@ +/** +Utility RestApi layer. +Important if dealing with authentication, all requests pass through this layer. +**/ + +// polifill for Fetch API +import 'isomorphic-fetch'; + +export default class RestApi { + constructor(baseUrl) { + this.baseUrl = baseUrl; + } + + get() { + const options = { method: 'GET' }; + const apiEndpoint = this.formatApiEndpoint(); + return fetch(apiEndpoint, options); + } + + put(url, body) { + const apiEndpoint = this.formatApiEndpoint(url); + const options = { method: 'PUT', body: JSON.stringify(body) }; + return fetch(apiEndpoint, options); + } + + post(url, body) { + const apiEndpoint = this.formatApiEndpoint(url); + const options = { method: 'POST', body: JSON.stringify(body) }; + return fetch(apiEndpoint, options); + } + + delete(url) { + const apiEndpoint = this.formatApiEndpoint(url); + const options = { method: 'DELETE' }; + return fetch(apiEndpoint, options); + } + + formatApiEndpoint() { + const testing = process.env.NODE_ENV === 'test'; + const apiUrl = `${testing ? 'http://localhost' : ''}/api${this.baseUrl}`; + return apiUrl; + } +} diff --git a/client/components/App.js b/client/components/App.js new file mode 100644 index 0000000..da02a5a --- /dev/null +++ b/client/components/App.js @@ -0,0 +1,23 @@ +/** + * Root component handles the App Template and Theme used on every page. + **/ +import React, { PropTypes } from 'react'; +import darkBaseTheme from 'material-ui/styles/baseThemes/darkBaseTheme'; +import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; +import getMuiTheme from 'material-ui/styles/getMuiTheme'; + +const muiTheme = getMuiTheme(darkBaseTheme); + +const App = props => ( + +
+ {props.children} +
+
+); + +App.propTypes = { + children: PropTypes.object.isRequired +}; + +export default App; diff --git a/client/components/artwork/ArtworkDetail.js b/client/components/artwork/ArtworkDetail.js new file mode 100644 index 0000000..2de1ad8 --- /dev/null +++ b/client/components/artwork/ArtworkDetail.js @@ -0,0 +1,58 @@ +import React, { PropTypes } from 'react'; +import { Card, CardMedia } from 'material-ui/Card'; +import { ListItem } from 'material-ui/List'; +import { GridList } from 'material-ui/GridList'; + +/** + * Stateless Component responsible for rendering details of an Artwork. + */ + +const styles = { + gridList: { + width: '100%', + height: '100%', + overflowY: 'auto' + }, + list: { + cursor: 'default' + } +}; + +/** +* Method responsible for making the Artwork Detail responsive. +* @param {browser} containing window sizes +* @return Number of Columns. +**/ +function calculateNumCols(browser) { + let numCols = 2; + if (!browser) { + return numCols; + } else if (browser.is.extraSmall) { + numCols = 1; + } + return numCols; +} + +const ArtworkDetail = ({ artwork, browser }) => ( + + + + + + + + + + + +); + +ArtworkDetail.propTypes = { + artwork: PropTypes.object.isRequired, + browser: PropTypes.object +}; + +export default ArtworkDetail; diff --git a/client/components/artwork/ArtworkDetail.test.js b/client/components/artwork/ArtworkDetail.test.js new file mode 100644 index 0000000..17090a8 --- /dev/null +++ b/client/components/artwork/ArtworkDetail.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import expect from 'expect'; +import { shallow } from 'enzyme'; +import ArtworkDetail from './ArtworkDetail'; +import { artwork } from '../../stubs'; + +describe('Component - ArtworkDetail', () => { + it('should be rendered', () => { + const element = shallow(); + expect(element.find('Card').length).toBe(1); + expect(element.find('img').length).toBe(1); + expect(element.find('img').node.props.src).toBe(artwork.fullSizeImageUrl); + + const gridList = element.find('GridList'); + expect(gridList.length).toBe(1); + expect(gridList.find('ListItem').length).toBe(4); + + const ListItems = gridList.find('ListItem'); + expect(ListItems.at(0).props().secondaryText).toBe(artwork.movieId); + expect(ListItems.at(1).props().secondaryText).toBe(artwork.movieName); + expect(ListItems.at(2).props().secondaryText).toBe(artwork.imageType); + expect(ListItems.at(3).props().secondaryText).toBe(artwork.languageCode); + }); +}); diff --git a/client/components/artwork/ArtworkGrouping.js b/client/components/artwork/ArtworkGrouping.js new file mode 100644 index 0000000..192c8cc --- /dev/null +++ b/client/components/artwork/ArtworkGrouping.js @@ -0,0 +1,30 @@ +import React, { PropTypes } from 'react'; +import DropDownMenu from 'material-ui/DropDownMenu'; +import MenuItem from 'material-ui/MenuItem'; + +/** + * Stateless Component responsible for rendering Drop Down Menu + * for changing Artworks grouping + */ + +const dropDownItems = [ + { value: 'movieId', text: 'Movie ID' }, + { value: 'languageCode', text: 'Language Code' } +]; + +const ArtworkGrouping = ({ groupBy, onDropDownChange }) => ( + + { + dropDownItems.map((item, index) => + + ) + } + +); + +ArtworkGrouping.propTypes = { + groupBy: PropTypes.string.isRequired, + onDropDownChange: PropTypes.func.isRequired +}; + +export default ArtworkGrouping; diff --git a/client/components/artwork/ArtworkGrouping.test.js b/client/components/artwork/ArtworkGrouping.test.js new file mode 100644 index 0000000..1817dfb --- /dev/null +++ b/client/components/artwork/ArtworkGrouping.test.js @@ -0,0 +1,14 @@ +import React from 'react'; +import sinon from 'sinon'; +import expect from 'expect'; +import { shallow } from 'enzyme'; +import ArtworkGrouping from './ArtworkGrouping'; + +describe('Component - ArtworkGrouping', () => { + it('should be rendered', () => { + const onDropDownChange = sinon.spy(); + const element = shallow(); + expect((element.find('DropDownMenu').props().value)).toBe('movieId'); + expect(element.find('MenuItem').length).toBe(2); + }); +}); diff --git a/client/components/artwork/ArtworkList.js b/client/components/artwork/ArtworkList.js new file mode 100644 index 0000000..248d16a --- /dev/null +++ b/client/components/artwork/ArtworkList.js @@ -0,0 +1,129 @@ +import React, { PropTypes } from 'react'; +import { GridList } from 'material-ui/GridList'; +import Subheader from 'material-ui/Subheader'; +import RaisedButton from 'material-ui/RaisedButton'; +import ArtworkListItem from './ArtworkListItem'; + +/** + * Stateless Component responsible for rendering the a list of Artworks by Categories. + * This categories are movieId and languageCode. + * PAGE_SIZE controls how many sections will be shown per offset. + */ + +const PAGE_SIZE = 1; +const styles = { + container: { + display: 'flex', + marginTop: '60px', + flexWrap: 'wrap', + justifyContent: 'space-around' + }, + gridList: { + width: '100%', + height: '100%', + overflowY: 'auto' + }, + header: { + color: 'black' + }, + button: { + margin: 12 + } +}; + +/** +* Decoupled method responsible for generating the list of Artworks per section. +* @param groupedArtworks, groupBy, size, onExpand +* @return list of ArtworkListItem separated by Subheader (sections). +**/ +function generateList(groupedArtworks, groupBy, size, onExpand) { + const itemList = []; + Object.keys(groupedArtworks).slice(0, size).forEach((key) => { + const artworks = groupedArtworks[key]; + itemList.push( + +

{key}

+
+ ); + artworks.map((artwork, index) => + itemList.push( + ) + ); + }); + return itemList; +} + +/** +* Method responsible for making the Artwork List responsive. +* @param {browser} containing window sizes +* @return Number of Columns. +**/ +function calculateNumCols(browser) { + let numCols = 4; + if (!browser) { + return numCols; + } else if (browser.is.extraSmall) { + numCols = 1; + } else if (browser.is.small) { + numCols = 2; + } else if (browser.is.medium) { + numCols = 3; + } + return numCols; +} + +const ArtworkList = ( + { artworks, groupBy, offset, total, browser, onExpand, onShowMore, onShowLess, onShowAll }) => { + const size = PAGE_SIZE * offset; + return ( +
+
+ + {generateList(artworks, groupBy, size, onExpand)} + +
+ + {`${size} of ${total}`} Categories + + +
+ ); +}; + +ArtworkList.propTypes = { + artworks: PropTypes.object.isRequired, + groupBy: PropTypes.string.isRequired, + offset: PropTypes.number.isRequired, + total: PropTypes.number.isRequired, + browser: PropTypes.object, + onExpand: PropTypes.func.isRequired, + onShowLess: PropTypes.func.isRequired, + onShowMore: PropTypes.func.isRequired, + onShowAll: PropTypes.func.isRequired +}; + +export default ArtworkList; diff --git a/client/components/artwork/ArtworkList.test.js b/client/components/artwork/ArtworkList.test.js new file mode 100644 index 0000000..c949c1b --- /dev/null +++ b/client/components/artwork/ArtworkList.test.js @@ -0,0 +1,50 @@ +import React from 'react'; +import expect from 'expect'; +import sinon from 'sinon'; +import { shallow } from 'enzyme'; +import ArtworkList from './ArtworkList'; +import { normalizedArtworks } from '../../stubs'; + + +describe('Component - ArtworkList', () => { + const total = Object.keys(normalizedArtworks).length; // always showing all categories + + const createElement = (groupBy, onExpand, onShowMore, onShowLess, onShowAll) => ( + shallow() + ); + + it('should be rendered by movieId', () => { + const onExpand = sinon.spy(); + const onShowMore = sinon.spy(); + const onShowLess = sinon.spy(); + const onShowAll = sinon.spy(); + const element = createElement('movieId', onExpand, onShowMore, onShowLess, onShowAll); + expect(element.find('ArtworkListItem').length).toBe(4); + expect(element.find('Subheader').length).toBe(2); + + element.find('RaisedButton').at(0).simulate('click'); + expect(onShowMore.called).toBe(true); + element.find('RaisedButton').at(1).simulate('click'); + expect(onShowLess.called).toBe(true); + element.find('RaisedButton').at(2).simulate('click'); + expect(onShowAll.called).toBe(true); + }); + + it('should be rendered by languageCode', () => { + const onExpand = sinon.spy(); + const onShowMore = sinon.spy(); + const onShowLess = sinon.spy(); + const onShowAll = sinon.spy(); + const element = createElement('languageCode', onExpand, onShowMore, onShowLess, onShowAll); + expect(element.find('ArtworkListItem').length).toBe(2); + expect(element.find('Subheader').length).toBe(2); + }); +}); diff --git a/client/components/artwork/ArtworkListItem.js b/client/components/artwork/ArtworkListItem.js new file mode 100644 index 0000000..d7e8361 --- /dev/null +++ b/client/components/artwork/ArtworkListItem.js @@ -0,0 +1,40 @@ +import React, { PropTypes } from 'react'; +import { GridTile } from 'material-ui/GridList'; +import IconButton from 'material-ui/IconButton'; +import Expand from 'material-ui/svg-icons/navigation/fullscreen'; + +/** + * Stateless Component responsible for rendering an Artwork List Item. + * with small amount of information + */ + +const styles = { + img: { + cursor: 'pointer', + width: '100%' + } +}; + +const getSubtitle = (artwork, groupBy) => { + const label = groupBy === 'movieId' ? 'Language: ' : 'ID: '; + const value = groupBy === 'movieId' ? artwork.languageCode : artwork.movieId; + return `${label}${value}`; +}; + +const ArtworkListItem = ({ groupBy, artwork, onExpand }) => ( + { onExpand(artwork); }}>} + > + { onExpand(artwork); }} src={artwork.thumbnailUrl} /> + +); + +ArtworkListItem.propTypes = { + groupBy: PropTypes.string.isRequired, + artwork: PropTypes.object.isRequired, + onExpand: PropTypes.func.isRequired +}; + +export default ArtworkListItem; diff --git a/client/components/artwork/ArtworkListItem.test.js b/client/components/artwork/ArtworkListItem.test.js new file mode 100644 index 0000000..fcda126 --- /dev/null +++ b/client/components/artwork/ArtworkListItem.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import sinon from 'sinon'; +import expect from 'expect'; +import { shallow } from 'enzyme'; +import ArtworkListItem from './ArtworkListItem'; + +import { artwork } from '../../stubs'; + +describe('Component - ArtworkListItem', () => { + it('should be rendered', () => { + const onExpand = () => {}; + const element = shallow(); + expect(element.find('GridTile').length).toBe(1); + expect(element.find('GridTile').props().subtitle).toBe(`Language: ${artwork.languageCode}`); + expect(element.find('img').length).toBe(1); + expect(element.find('img').props().src).toBe(artwork.thumbnailUrl); + }); + + it('should call function when image is clicked', () => { + const onExpand = sinon.spy(); + const element = shallow(); + element.find('img').simulate('click'); + expect(onExpand.called).toBe(true); + }); + + it('should call function when expand icon is clicked', () => { + const onExpand = sinon.spy(); + const element = shallow(); + element.props().actionIcon.props.onClick(); + expect(onExpand.called).toBe(true); + }); +}); diff --git a/client/components/artwork/ArtworksPage.js b/client/components/artwork/ArtworksPage.js new file mode 100644 index 0000000..1e4533f --- /dev/null +++ b/client/components/artwork/ArtworksPage.js @@ -0,0 +1,196 @@ +import { bindActionCreators } from 'redux'; +import React, { Component, PropTypes } from 'react'; +import Dialog from 'material-ui/Dialog'; +import FlatButton from 'material-ui/FlatButton'; +import { connect } from 'react-redux'; + +import * as artworkActions from '../../actions/artworkActions'; +import * as appActions from '../../actions/appActions'; +import Header from '../common/Header'; +import ArtworkList from './ArtworkList'; +import ArtworkGrouping from './ArtworkGrouping'; +import ArtworkDetail from './ArtworkDetail'; + + +/** + * Main Artwork Component + * Container Component provides the data and behavior to presentational components. + */ + +const styles = { + container: { + maxWidth: '80%', + margin: '0 auto' + }, + actionsContainerStyle: { + border: 'none' + }, + dialogRoot: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingTop: 0 + }, + dialogContent: { + position: 'relative', + width: '80vw', + transform: '', + padding: 0 + }, + dialogBody: { + paddingBottom: 0 + } +}; + +class ArtworksPage extends Component { + + state = { + selectedArtwork: undefined + } + + onExpand = (selectedArtwork) => { + this.context.router.push(`/artwork/${selectedArtwork.languageCode}/${selectedArtwork.movieId}`); + } + + onClose = () => { + this.context.router.push('/'); + } + + onShowMore = () => { + this.props.actions.showMoreItems(); + } + + onShowLess = () => { + this.props.actions.showLessItems(); + } + + onShowAll = () => { + this.props.actions.showAllItems(this.props.total); + } + + onDropDownChange = (event, index, value) => { + this.props.actions.changeGroupBy(value); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.artwork) { + this.setState({ selectedArtwork: { ...nextProps.artwork } }); + } else { + this.setState({ selectedArtwork: undefined }); + } + } + + render() { + const { artworks, groupBy, offset, total, browser } = this.props; + return ( +
+
+ }/> +
+ + {this.state.selectedArtwork && + } + actionsContainerStyle={styles.actionsContainerStyle} + contentStyle={ styles.dialogContent } + bodyStyle={ styles.dialogBody } + style={ styles.dialogRoot } + repositionOnUpdate={ false } + modal={false} + open={this.state.selectedArtwork !== undefined} + onRequestClose={this.onClose} + autoScrollBodyContent + autoDetectWindowHeight={false} + > + + } +
+
+ ); + } +} + +ArtworksPage.propTypes = { + actions: PropTypes.object.isRequired, + artworks: PropTypes.object.isRequired, + groupBy: PropTypes.string.isRequired, + offset: PropTypes.number.isRequired +}; + +ArtworksPage.contextTypes = { + router: PropTypes.object +}; + +/** +* Method responsible for finding specific Artwork to show its details. +* if not found, redirects to root url and returns null +* @param artworks, groupBy, languageCode, movieId +* @return {artwork} or null if not found. +**/ +function getArtworkByLanguageCodeMovieId(artworks, groupBy, languageCode, movieId, router) { + let artwork; + if (groupBy === 'languageCode' && artworks[languageCode]) { + artwork = artworks[languageCode].filter(a => + a.movieId === movieId + ); + } else if (groupBy === 'movieId' && artworks[movieId]) { + artwork = artworks[movieId].filter(a => + a.languageCode === languageCode + ); + } + + if (artwork && artwork.length) { + return artwork[0]; + } + + // no Artwork found, redirecting to root + router.push('/'); + return null; +} + +function mapStateToProps(state, ownProps) { + const languageCode = ownProps.params.languageCode; + const movieId = ownProps.params.movieId; + const groupBy = state.app.groupBy; + const artworks = state.artworks[groupBy]; + const total = artworks ? Object.keys(artworks).length : 0; + let artwork = null; + + if (languageCode && artworks) { + artwork = getArtworkByLanguageCodeMovieId( + artworks, groupBy, languageCode, parseInt(movieId, 10), ownProps.router + ); + } + + return { + groupBy, + total, + artwork, + artworks: artworks || {}, + offset: state.app.offset, + browser: state.browser + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ ...artworkActions, ...appActions }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ArtworksPage); diff --git a/client/components/common/Header.js b/client/components/common/Header.js new file mode 100644 index 0000000..d5274d7 --- /dev/null +++ b/client/components/common/Header.js @@ -0,0 +1,38 @@ +import React, { PropTypes } from 'react'; +import AppBar from 'material-ui/AppBar'; + +/** + * Stateless Component responsible for rendering Application Header + */ + +const styles = { + appBar: { + position: 'fixed', + backgroundColor: '#000', + left: 0, + top: 0 + }, + img: { + width: 50, + height: 50 + }, + title: { + color: '#E50914' + } +}; + +const Header = ({ title, rightElement }) => ( + } + iconElementRight={rightElement} + /> +); + +Header.propTypes = { + rightElement: PropTypes.element +}; + +export default Header; diff --git a/client/components/common/Header.test.js b/client/components/common/Header.test.js new file mode 100644 index 0000000..70ee319 --- /dev/null +++ b/client/components/common/Header.test.js @@ -0,0 +1,13 @@ +import React from 'react'; +import expect from 'expect'; +import { shallow } from 'enzyme'; +import Header from './Header'; + + +describe('Component - Header', () => { + it('should be rendered', () => { + const element = shallow(
); + expect(element.find('AppBar').length).toBe(1); + expect(element.prop('title')).toEqual('Netflix Artworks'); + }); +}); diff --git a/client/img/shortcut-icon.png b/client/img/shortcut-icon.png new file mode 100644 index 0000000..11c33ef Binary files /dev/null and b/client/img/shortcut-icon.png differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..2feb349 --- /dev/null +++ b/client/index.html @@ -0,0 +1,19 @@ + + + + + + Netflix Artworks + + + + + + + + + +
+ + + diff --git a/client/index.js b/client/index.js new file mode 100644 index 0000000..f69b47f --- /dev/null +++ b/client/index.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { Provider } from 'react-redux'; +import { Router, browserHistory } from 'react-router'; +import routes from './routes'; +import configureStore from './store/configureStore'; +import initialState from './reducers/initialState'; +import { loadArtworks } from './actions/artworkActions'; + +// react-tap-event-plugin has an issue with clickable elements +// suggested fix: +// https://github.com/callemall/material-ui/issues/1011#issuecomment-187556854 +const injectTouchTapEvent = require('react-tap-event-plugin'); + +injectTouchTapEvent(); + +// Configure Redux Store with initial Artworks and App states +const store = configureStore(initialState); + +// Fetch Artworks from the API dispatching loadArtworks action. +store.dispatch(loadArtworks()); + +// Add the App with its specific routes to the DOM. +render( + + + , + document.getElementById('app') +); diff --git a/client/index.test.js b/client/index.test.js new file mode 100644 index 0000000..d9eb0fb --- /dev/null +++ b/client/index.test.js @@ -0,0 +1,8 @@ +import expect from 'expect'; + +// Just a checkpoint to make sure the TEST environment is functional +describe('Test environment', () => { + it('should be working', () => { + expect(true).toEqual(true); + }); +}); diff --git a/client/reducers/appReducer.js b/client/reducers/appReducer.js new file mode 100644 index 0000000..38e9bdd --- /dev/null +++ b/client/reducers/appReducer.js @@ -0,0 +1,25 @@ +import * as types from '../actions/actionTypes'; +import initialState from './initialState'; + +/** + * Reducer responsible for managing Application state. + */ + +export default function appReducer(state = initialState.app, action) { + switch (action.type) { + case types.CHANGE_GROUPING: + return { ...state, groupBy: action.groupBy, offset: 1 }; + + case types.SHOW_MORE_ITEMS: + return { ...state, offset: state.offset + 1 }; + + case types.SHOW_LESS_ITEMS: + return { ...state, offset: state.offset - 1 }; + + case types.SHOW_ALL_ITEMS: + return { ...state, offset: action.offset }; + + default: + return state; + } +} diff --git a/client/reducers/appReducer.test.js b/client/reducers/appReducer.test.js new file mode 100644 index 0000000..add2026 --- /dev/null +++ b/client/reducers/appReducer.test.js @@ -0,0 +1,66 @@ +import expect from 'expect'; +import * as types from '../actions/actionTypes'; +import appReducer from './appReducer'; +import initialState from './initialState'; + +describe('App Reducer', () => { + const previousState = { + groupBy: initialState.app.groupBy, + offset: initialState.app.offset + }; + + it('should return the initial state', () => { + expect(appReducer(undefined, {})).toEqual(initialState.app); + }); + + it('should handle CHANGE_GROUPING', () => { + const nextState = { + groupBy: 'languageCode', + offset: initialState.app.offset + }; + + const action = { + type: types.CHANGE_GROUPING, + groupBy: 'languageCode' + }; + + expect(appReducer(previousState, action)).toEqual(nextState); + }); + + it('should handle SHOW_MORE_ITEMS', () => { + const nextState = { + groupBy: 'movieId', + offset: 2 + }; + const action = { + type: types.SHOW_MORE_ITEMS + }; + + expect(appReducer(previousState, action)).toEqual(nextState); + }); + + it('should handle SHOW_LESS_ITEMS', () => { + const nextState = { + groupBy: 'movieId', + offset: 0 + }; + const action = { + type: types.SHOW_LESS_ITEMS + }; + + expect(appReducer(previousState, action)).toEqual(nextState); + }); + + it('should handle SHOW_ALL_ITEMS', () => { + const nextState = { + groupBy: 'movieId', + offset: 5 + }; + const action = { + type: types.SHOW_ALL_ITEMS, + offset: 5 + }; + + expect(appReducer(previousState, action)).toEqual(nextState); + }); +}); diff --git a/client/reducers/artworkReducer.js b/client/reducers/artworkReducer.js new file mode 100644 index 0000000..395bb42 --- /dev/null +++ b/client/reducers/artworkReducer.js @@ -0,0 +1,43 @@ +import sortBy from 'lodash/sortBy'; +import * as types from '../actions/actionTypes'; +import initialState from './initialState'; + +/** + * Reducer responsible for managing Artwork state. + * contains methods for data normalization + */ + +const groupByArray = ['languageCode', 'movieId']; + +/** + * Method responsible for data normalization and sorting. + * normalizes the data per group + * @param [{artworks}], groupBy + * @return {normalizedArtworks}, contaning group keys, with array of artworks. + **/ +function artworkNormalization(artworks, groupBy) { + return sortBy(artworks, [groupBy]).reduce((groupedArtworks, artwork) => { + const aggregator = groupedArtworks; + if (!groupedArtworks[artwork[groupBy]]) { + aggregator[artwork[groupBy]] = []; + } + groupedArtworks[artwork[groupBy]].push(artwork); + + return aggregator; + }, {}); +} + +export default function artworkReducer(state = initialState.artworks, action) { + const groupedArtworks = { ...state }; + switch (action.type) { + case types.LOAD_ARTWORKS_SUCCESS: + groupByArray.forEach((group) => { + groupedArtworks[group] = artworkNormalization(action.artworks, group); + }); + + return groupedArtworks; + + default: + return state; + } +} diff --git a/client/reducers/artworkReducer.test.js b/client/reducers/artworkReducer.test.js new file mode 100644 index 0000000..7eb23bb --- /dev/null +++ b/client/reducers/artworkReducer.test.js @@ -0,0 +1,20 @@ +import expect from 'expect'; +import * as types from '../actions/actionTypes'; +import artworkReducer from './artworkReducer'; +import initialState from './initialState'; +import { artworks, normalizedArtworks } from '../stubs'; + +describe('Artwork Reducer', () => { + it('should return the initial state', () => { + expect(artworkReducer(undefined, {})).toEqual(initialState.artworks); + }); + + it('should handle LOAD_ARTWORKS_SUCCESS', () => { + const action = { + type: types.LOAD_ARTWORKS_SUCCESS, + artworks + }; + + expect(artworkReducer({}, action)).toEqual(normalizedArtworks); + }); +}); diff --git a/client/reducers/index.js b/client/reducers/index.js new file mode 100644 index 0000000..5128f27 --- /dev/null +++ b/client/reducers/index.js @@ -0,0 +1,13 @@ +import { combineReducers } from 'redux'; +import { responsiveStateReducer } from 'redux-responsive'; +import app from './appReducer'; +import artworks from './artworkReducer'; + +const rootReducer = combineReducers({ + app, + artworks, + // reducer responsible for Application responsiveness + browser: responsiveStateReducer +}); + +export default rootReducer; diff --git a/client/reducers/initialState.js b/client/reducers/initialState.js new file mode 100644 index 0000000..51582e8 --- /dev/null +++ b/client/reducers/initialState.js @@ -0,0 +1,9 @@ +// Initial Aplication and Artwork states + +export default { + app: { + groupBy: 'movieId', // artworks default grouped by movieId + offset: 1 + }, + artworks: {} +}; diff --git a/client/routes.js b/client/routes.js new file mode 100644 index 0000000..ce6986d --- /dev/null +++ b/client/routes.js @@ -0,0 +1,18 @@ +/** + * Application routes + * "/" routes to Artwork List. + * "artwork/:languageCode/:movieId" routes to Artwork Detail page. + **/ + +import React from 'react'; +import { Route, IndexRoute } from 'react-router'; + +import App from './components/App'; +import ArtworksPage from './components/artwork/ArtworksPage'; + +export default ( + + + + +); diff --git a/client/store/configureStore.dev.js b/client/store/configureStore.dev.js new file mode 100644 index 0000000..189535f --- /dev/null +++ b/client/store/configureStore.dev.js @@ -0,0 +1,23 @@ +/** + * Configures the store for DEV environment with log capabilities + * and using redux-immutable-state-invariant making sure the state is not mutate. + **/ + +import reduxImmutableStateInvariant from 'redux-immutable-state-invariant'; +import createLogger from 'redux-logger'; +import thunk from 'redux-thunk'; +import { createStore, applyMiddleware, compose } from 'redux'; +import { responsiveStoreEnhancer } from 'redux-responsive'; + +import rootReducer from '../reducers'; + +export default function configureStore(initialState) { + return createStore( + rootReducer, + initialState, + compose( + responsiveStoreEnhancer, + applyMiddleware(thunk, reduxImmutableStateInvariant(), createLogger()) + ) + ); +} diff --git a/client/store/configureStore.js b/client/store/configureStore.js new file mode 100644 index 0000000..3a7aa17 --- /dev/null +++ b/client/store/configureStore.js @@ -0,0 +1,7 @@ +// Dynamic imports are not supported in ES6, using require instead of import. +/* eslint-disable global-require */ +if (process.env.NODE_ENV === 'production') { + module.exports = require('./configureStore.prod'); +} else { + module.exports = require('./configureStore.dev'); +} diff --git a/client/store/configureStore.prod.js b/client/store/configureStore.prod.js new file mode 100644 index 0000000..c02b653 --- /dev/null +++ b/client/store/configureStore.prod.js @@ -0,0 +1,21 @@ +/** + * Configures the store for PROD environment with just the needed libs + * no logs in this case. + **/ + +import thunk from 'redux-thunk'; +import { createStore, applyMiddleware, compose } from 'redux'; +import { responsiveStoreEnhancer } from 'redux-responsive'; + +import rootReducer from '../reducers'; + +export default function configureStore(initialState) { + return createStore( + rootReducer, + initialState, + compose( + responsiveStoreEnhancer, + applyMiddleware(thunk) + ) + ); +} diff --git a/client/stubs/index.js b/client/stubs/index.js new file mode 100644 index 0000000..d9d1faa --- /dev/null +++ b/client/stubs/index.js @@ -0,0 +1,112 @@ +/** + * Utility to stub test data. + **/ + +export const artwork = { + movieId: 70242311, + movieName: 'Orange Is the New Black', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/673e9/b39fcc29b2ac668ee01343de9f21f611c8f673e9.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/78bc7/198343ed941f178d54878aa366a122e4e2e78bc7.jpg', + languageCode: 'it' +}; + +export const artworks = [{ + movieId: 70242311, + movieName: 'Orange Is the New Black', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/673e9/b39fcc29b2ac668ee01343de9f21f611c8f673e9.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/78bc7/198343ed941f178d54878aa366a122e4e2e78bc7.jpg', + languageCode: 'it' +}, { + movieId: 70242311, + movieName: 'Orange Is the New Black', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/304d2/7da9da4ea90c6df7889b67137cb64737e65304d2.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/934f7/e76e738da86e04d9c388605789211d6a883934f7.jpg', + languageCode: 'de' +}, { + movieId: 70178217, + movieName: 'House of Cards', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/920d5/aa6acd66076eb1127521a3fff5dceddbbf7920d5.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/1ba7e/756e795d7469900eae82794a38f1942e6521ba7e.jpg', + languageCode: 'tr' +}, { + movieId: 70178217, + movieName: 'House of Cards', + imageType: 'sdp', + thumbnailUrl: 'http://art.nflximg.net/9f266/6216fd01988706ee545cf990e55a483620c9f266.jpg', + fullSizeImageUrl: 'http://art.nflximg.net/3ac1d/624005be3a3002650feb87c46391bab6a233ac1d.jpg', + languageCode: 'ja' +}]; + + +export const normalizedArtworks = { + languageCode: { + de: [{ + fullSizeImageUrl: 'http://art.nflximg.net/934f7/e76e738da86e04d9c388605789211d6a883934f7.jpg', + imageType: 'sdp', + languageCode: 'de', + movieId: 70242311, + movieName: 'Orange Is the New Black', + thumbnailUrl: 'http://art.nflximg.net/304d2/7da9da4ea90c6df7889b67137cb64737e65304d2.jpg' + }], + it: [{ + fullSizeImageUrl: 'http://art.nflximg.net/78bc7/198343ed941f178d54878aa366a122e4e2e78bc7.jpg', + imageType: 'sdp', + languageCode: 'it', + movieId: 70242311, + movieName: 'Orange Is the New Black', + thumbnailUrl: 'http://art.nflximg.net/673e9/b39fcc29b2ac668ee01343de9f21f611c8f673e9.jpg' + }], + ja: [{ + fullSizeImageUrl: 'http://art.nflximg.net/3ac1d/624005be3a3002650feb87c46391bab6a233ac1d.jpg', + imageType: 'sdp', + languageCode: 'ja', + movieId: 70178217, + movieName: 'House of Cards', + thumbnailUrl: 'http://art.nflximg.net/9f266/6216fd01988706ee545cf990e55a483620c9f266.jpg' + }], + tr: [{ + fullSizeImageUrl: 'http://art.nflximg.net/1ba7e/756e795d7469900eae82794a38f1942e6521ba7e.jpg', + imageType: 'sdp', + languageCode: 'tr', + movieId: 70178217, + movieName: 'House of Cards', + thumbnailUrl: 'http://art.nflximg.net/920d5/aa6acd66076eb1127521a3fff5dceddbbf7920d5.jpg' + }] + }, + movieId: { + 70178217: [{ + fullSizeImageUrl: 'http://art.nflximg.net/1ba7e/756e795d7469900eae82794a38f1942e6521ba7e.jpg', + imageType: 'sdp', + languageCode: 'tr', + movieId: 70178217, + movieName: 'House of Cards', + thumbnailUrl: 'http://art.nflximg.net/920d5/aa6acd66076eb1127521a3fff5dceddbbf7920d5.jpg' + }, { + fullSizeImageUrl: 'http://art.nflximg.net/3ac1d/624005be3a3002650feb87c46391bab6a233ac1d.jpg', + imageType: 'sdp', + languageCode: 'ja', + movieId: 70178217, + movieName: 'House of Cards', + thumbnailUrl: 'http://art.nflximg.net/9f266/6216fd01988706ee545cf990e55a483620c9f266.jpg' + }], + 70242311: [{ + fullSizeImageUrl: 'http://art.nflximg.net/78bc7/198343ed941f178d54878aa366a122e4e2e78bc7.jpg', + imageType: 'sdp', + languageCode: 'it', + movieId: 70242311, + movieName: 'Orange Is the New Black', + thumbnailUrl: 'http://art.nflximg.net/673e9/b39fcc29b2ac668ee01343de9f21f611c8f673e9.jpg' + }, { + fullSizeImageUrl: 'http://art.nflximg.net/934f7/e76e738da86e04d9c388605789211d6a883934f7.jpg', + imageType: 'sdp', + languageCode: 'de', + movieId: 70242311, + movieName: 'Orange Is the New Black', + thumbnailUrl: 'http://art.nflximg.net/304d2/7da9da4ea90c6df7889b67137cb64737e65304d2.jpg' + }] + } +}; diff --git a/package.json b/package.json index dc35595..042d301 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,82 @@ { - "name": "code-exercise", + "name": "netflix-artwork", "version": "1.0.0", - "description": "Image viewer code exersice", - "main": "server/index.js", - "license": "SEE LICENSE IN LICENSE", + "description": "Netflix Artwork", + "author": "Marlon Parizzotto", + "license": "MIT", "scripts": { - "start": "node server/index.js", - "build": "gulp build", - "test": "gulp tests" + "start": "npm-run-all --parallel dev lint test:watch server", + "dev": " webpack-dev-server --open --config webpack.config.dev.babel.js", + "server": "node server/index.js", + "lint": "eslint client server tools", + "test": "mocha --reporter spec tools/testSetup.js \"client/**/*.test.js\"", + "test:watch": "npm run test -- --watch", + "remove-dist": "node_modules/.bin/rimraf ./.dist", + "clean-dist": "npm run remove-dist && mkdir .dist", + "prebuild": "npm-run-all clean-dist test lint", + "build": "babel-node tools/build.js" }, - "author": "vinodkl", - "devDependencies": {}, "dependencies": { - + "colors": "^1.1.2", + "compression": "^1.6.1", + "express": "^4.13.4", + "install": "^0.8.4", + "isomorphic-fetch": "^2.2.1", + "material-ui": "^0.16.7", + "npm": "^4.1.1", + "react": "^15.4.2", + "react-dom": "^15.4.2", + "react-redux": "^5.0.2", + "react-router": "^3.0.0", + "react-router-redux": "^4.0.7", + "react-tap-event-plugin": "^2.0.1", + "redux": "^3.6.0", + "redux-responsive": "^4.1.1", + "redux-thunk": "^2.1.0" + }, + "devDependencies": { + "babel-cli": "^6.8.0", + "babel-core": "^6.8.0", + "babel-eslint": "^7.1.1", + "babel-loader": "^6.2.4", + "babel-plugin-react-display-name": "^2.0.0", + "babel-plugin-transform-class-properties": "^6.19.0", + "babel-plugin-transform-object-rest-spread": "^6.20.2", + "babel-preset-es2015": "^6.6.0", + "babel-preset-react": "^6.5.0", + "babel-preset-react-hmre": "^1.1.1", + "babel-register": "^6.8.0", + "copy-webpack-plugin": "^4.0.1", + "cross-env": "^1.0.7", + "enzyme": "^2.2.0", + "eslint": "3.13.1", + "eslint-config-airbnb-base": "^11.0.1", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-react": "^5.2.2", + "eslint-watch": "^2.1.11", + "eventsource-polyfill": "^0.9.6", + "expect": "^1.19.0", + "file-loader": "^0.8.5", + "jsdom": "^8.5.0", + "mocha": "^2.4.5", + "nock": "^8.0.0", + "npm-run-all": "^1.8.0", + "open": "0.0.5", + "react-addons-test-utils": "^15.4.2", + "redux-immutable-state-invariant": "^1.2.3", + "redux-logger": "^2.7.4", + "redux-mock-store": "^1.0.2", + "rimraf": "^2.5.2", + "sinon": "^1.17.7", + "style-loader": "^0.13.1", + "url-loader": "^0.5.7", + "webpack": "^1.13.0", + "webpack-dev-middleware": "^1.6.1", + "webpack-dev-server": "^1.16.2", + "webpack-hot-middleware": "^2.10.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/marlonpp/code-exercise" } } diff --git a/mock-data/data-model.json b/server/data/artworks.json similarity index 99% rename from mock-data/data-model.json rename to server/data/artworks.json index 3142b8b..480d933 100644 --- a/mock-data/data-model.json +++ b/server/data/artworks.json @@ -789,4 +789,4 @@ "thumbnailUrl": "http://art.nflximg.net/29431/c6bad706155b1e543afb4ea875ae0ad136029431.jpg", "fullSizeImageUrl": "http://art.nflximg.net/de7dc/8aa6a12931b3452bbacdede6fa7159f40f0de7dc.jpg", "languageCode": "ar" -}] \ No newline at end of file +}] diff --git a/server/index.js b/server/index.js index e69de29..3a14d84 100644 --- a/server/index.js +++ b/server/index.js @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +/* Using ES5, mostly to use node instead of babel-node */ + +const compression = require('compression'); +const express = require('express'); +const colors = require('colors'); +const path = require('path'); +const fs = require('fs'); + +const port = 3000; +const app = express(); +app.use(compression()); +app.use(express.static('.dist')); + +const moviesFilePath = path.join(__dirname, 'data/artworks.json'); +app.get('/api/movies', (req, res) => { + // fs.js says: + // var kPoolSize = 40 * 1024; + // Reading chunks of 40960 bytes + + const readable = fs.createReadStream(moviesFilePath); + readable.on('error', () => { + res.writeHead(404, 'Not Found'); + res.end(); + }); + readable.pipe(res); +}); + +app.get('/*', (req, res) => { + res.sendFile(path.join(__dirname, '../.dist/index.html')); +}); + +app.listen(port, (err) => { + if (err) { + console.log(colors.red(err)); + } + console.log(colors.green(`Server running at http://localhost:${port}`)); +}); diff --git a/tools/build.js b/tools/build.js new file mode 100644 index 0000000..63e65ec --- /dev/null +++ b/tools/build.js @@ -0,0 +1,35 @@ +/* eslint-disable no-console */ + +import colors from 'colors'; +import webpack from 'webpack'; + +import webpackConfig from '../webpack.config.prod'; + +process.env.NODE_ENV = 'production'; // no dev code apply + +console.log('Generating minified bundle for production via Webpack. This will take a moment ...'); + +webpack(webpackConfig).run((err, stats) => { + if (err) { + console.log(colors.red.bold(err)); + return 1; + } + + const jsonStats = stats.toJson(); + + if (jsonStats.hasErrors) { + return jsonStats.erros.map(error => console.log(error.red)); + } + + if (jsonStats.hasWarnings) { + console.log('Webpack generated the following warnings: '.bold.yellow); + jsonStats.warnings.map(warning => console.log(warning.yellow)); + } + + console.log(`Webpack stats: ${stats}`); + + // if we got this far, the build succeeded + console.log('Your app has been compiled in production mode and written to /.dist. It\'s ready to rooll!'.green); + + return 0; +}); diff --git a/tools/testSetup.js b/tools/testSetup.js new file mode 100644 index 0000000..9942f47 --- /dev/null +++ b/tools/testSetup.js @@ -0,0 +1,51 @@ +/* eslint-disable */ +// This file is written in ES5 since it's not transpiled by Babel. +// This file does the following: +// 1. Sets Node environment variable +// 2. Registers babel for transpiling our code for testing +// 3. Disables Webpack-specific features that Mocha doesn't understand. +// 4. Requires jsdom so we can test via an in-memory DOM in Node +// 5. Sets up global vars that mimic a browser. + +/* This setting assures the .babelrc dev config (which includes + hot module reloading code) doesn't apply for tests. + But also, we don't want to set it to production here for + two reasons: + 1. You won't see any PropType validation warnings when + code is running in prod mode. + 2. Tests will not display detailed error messages + when running against production version code + */ + + // Configure JSDOM and set global variables + // to simulate a browser environment for tests. +const jsdom = require('jsdom').jsdom; + +const exposedProperties = ['window', 'navigator', 'document']; + +process.env.NODE_ENV = 'test'; + +// Register babel so that it will transpile ES6 to ES5 +// before our tests run. +require('babel-register')(); + +// Disable webpack-specific features for tests since +// Mocha doesn't know what to do with them. +require.extensions['.css'] = function () { return null; }; +require.extensions['.png'] = function () { return null; }; +require.extensions['.jpg'] = function () { return null; }; + +global.document = jsdom(''); +global.window = document.defaultView; +Object.keys(document.defaultView).forEach((property) => { + if (typeof global[property] === 'undefined') { + exposedProperties.push(property); + global[property] = document.defaultView[property]; + } +}); + +global.navigator = { + userAgent: 'node.js' +}; + +documentRef = document; diff --git a/webpack.config.dev.babel.js b/webpack.config.dev.babel.js new file mode 100644 index 0000000..7f1d56c --- /dev/null +++ b/webpack.config.dev.babel.js @@ -0,0 +1,44 @@ +import path from 'path'; +import webpack from 'webpack'; + +export default { + debug: true, + devtool: 'cheap-module-eval-source-map', // generating .map file so it's possible to see the code in production + noInfo: false, + entry: [ + 'eventsource-polyfill', // necessary for hot reloading with IE + './client/index' + ], + target: 'web', + output: { + path: path.join(__dirname, '/.dist'), // Note: Physical files are only output by the production build task `npm run build`. + publicPath: '/', + filename: 'bundle.js' + }, + devServer: { + contentBase: './client', + port: 9000, + hot: true, + inline: true, + stats: { + colors: true + }, + historyApiFallback: true, + proxy: { // proxy all api requests to local server + '/api/*': 'http://localhost:3000' + } + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin() + ], + module: { + loaders: [ + { test: /\.js$/, include: path.join(__dirname, 'client'), loaders: ['babel'] }, + { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file' }, + { test: /\.(woff|woff2)$/, loader: 'url?prefix=font/&limit=5000' }, + { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream' }, + { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=image/svg+xml' } + ] + } +}; diff --git a/webpack.config.prod.js b/webpack.config.prod.js new file mode 100644 index 0000000..9988298 --- /dev/null +++ b/webpack.config.prod.js @@ -0,0 +1,43 @@ +import path from 'path'; +import webpack from 'webpack'; + +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +const GLOBALS = { + 'process.env.NODE_ENV': JSON.stringify('production') +}; + +export default { + debug: true, + devtool: 'source-map', + noInfo: false, + entry: './client/index', + target: 'web', + output: { + path: path.join(__dirname, '/.dist'), // Note: Physical files are only output by the production build task `npm run build`. + publicPath: '/', + filename: 'bundle.js' + }, + devServer: { + contentBase: './.dist' + }, + plugins: [ + new webpack.optimize.OccurenceOrderPlugin(), // optimize the order the files are bundled + new webpack.DefinePlugin(GLOBALS), // define variables for use in react + new webpack.optimize.DedupePlugin(), // eliminates duplicate packages + new webpack.optimize.UglifyJsPlugin(), // minifies javascript + new CopyWebpackPlugin([ + { from: './client/index.html', to: './index.html' }, // copying entry html to dist folder + { from: './client/img', to: './img' } + ]) + ], + module: { + loaders: [ + { test: /\.js$/, include: path.join(__dirname, 'client'), loaders: ['babel'] }, + { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: 'file' }, + { test: /\.(woff|woff2)$/, loader: 'url?prefix=font/&limit=5000' }, + { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=application/octet-stream' }, + { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: 'url?limit=10000&mimetype=image/svg+xml' } + ] + } +};