From 3c8f00b69f9968d957212750c7d7b010d35782ec Mon Sep 17 00:00:00 2001 From: Marlon Parizzotto Date: Sun, 15 Jan 2017 20:22:27 -0800 Subject: [PATCH 1/2] This PR implements 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. --- .babelrc | 12 ++ .editorconfig | 13 ++ .eslintrc | 34 +++ .gitignore | 3 +- README.md | 83 +++++++- client/actions/actionTypes.js | 12 ++ client/actions/appActions.js | 33 +++ client/actions/appActions.test.js | 56 +++++ client/actions/artworkActions.js | 49 +++++ client/actions/artworkActions.test.js | 71 +++++++ client/api/ArtworkService.js | 11 + client/api/RestApi.js | 43 ++++ client/components/App.js | 23 ++ client/components/artwork/ArtworkDetail.js | 58 ++++++ .../components/artwork/ArtworkDetail.test.js | 24 +++ client/components/artwork/ArtworkGrouping.js | 30 +++ .../artwork/ArtworkGrouping.test.js | 14 ++ client/components/artwork/ArtworkList.js | 129 ++++++++++++ client/components/artwork/ArtworkList.test.js | 50 +++++ client/components/artwork/ArtworkListItem.js | 40 ++++ .../artwork/ArtworkListItem.test.js | 32 +++ client/components/artwork/ArtworksPage.js | 196 ++++++++++++++++++ client/components/common/Header.js | 38 ++++ client/components/common/Header.test.js | 13 ++ client/img/shortcut-icon.png | Bin 0 -> 7828 bytes client/index.html | 19 ++ client/index.js | 29 +++ client/index.test.js | 8 + client/reducers/appReducer.js | 25 +++ client/reducers/appReducer.test.js | 66 ++++++ client/reducers/artworkReducer.js | 43 ++++ client/reducers/artworkReducer.test.js | 20 ++ client/reducers/index.js | 13 ++ client/reducers/initialState.js | 9 + client/routes.js | 18 ++ client/store/configureStore.dev.js | 23 ++ client/store/configureStore.js | 7 + client/store/configureStore.prod.js | 21 ++ client/stubs/index.js | 112 ++++++++++ package.json | 85 +++++++- .../data/artworks.json | 2 +- server/index.js | 38 ++++ tools/build.js | 35 ++++ tools/testSetup.js | 51 +++++ webpack.config.dev.babel.js | 44 ++++ webpack.config.prod.js | 43 ++++ 46 files changed, 1763 insertions(+), 15 deletions(-) create mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 client/actions/actionTypes.js create mode 100644 client/actions/appActions.js create mode 100644 client/actions/appActions.test.js create mode 100644 client/actions/artworkActions.js create mode 100644 client/actions/artworkActions.test.js create mode 100644 client/api/ArtworkService.js create mode 100644 client/api/RestApi.js create mode 100644 client/components/App.js create mode 100644 client/components/artwork/ArtworkDetail.js create mode 100644 client/components/artwork/ArtworkDetail.test.js create mode 100644 client/components/artwork/ArtworkGrouping.js create mode 100644 client/components/artwork/ArtworkGrouping.test.js create mode 100644 client/components/artwork/ArtworkList.js create mode 100644 client/components/artwork/ArtworkList.test.js create mode 100644 client/components/artwork/ArtworkListItem.js create mode 100644 client/components/artwork/ArtworkListItem.test.js create mode 100644 client/components/artwork/ArtworksPage.js create mode 100644 client/components/common/Header.js create mode 100644 client/components/common/Header.test.js create mode 100644 client/img/shortcut-icon.png create mode 100644 client/index.html create mode 100644 client/index.js create mode 100644 client/index.test.js create mode 100644 client/reducers/appReducer.js create mode 100644 client/reducers/appReducer.test.js create mode 100644 client/reducers/artworkReducer.js create mode 100644 client/reducers/artworkReducer.test.js create mode 100644 client/reducers/index.js create mode 100644 client/reducers/initialState.js create mode 100644 client/routes.js create mode 100644 client/store/configureStore.dev.js create mode 100644 client/store/configureStore.js create mode 100644 client/store/configureStore.prod.js create mode 100644 client/stubs/index.js rename mock-data/data-model.json => server/data/artworks.json (99%) create mode 100644 tools/build.js create mode 100644 tools/testSetup.js create mode 100644 webpack.config.dev.babel.js create mode 100644 webpack.config.prod.js 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..11a7522 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/. + +```node 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 0000000000000000000000000000000000000000..11c33efd13e5a504e9194ca2d1cb3180a6df210b GIT binary patch literal 7828 zcmch6XH-)`*Dgeg(v&V;Kzfx9Qj}h#2m}I=CLksBA|N$NuSTkX6cG>-AQT0p1r%x0 z1cG#=3ZX*?-S7CmKkohW{k!X)wX(9#o_%I!@7a6L%=09e80k<`uu>2a5mD>uYMK%e z5sUu&T_*!1A>pZkz#oafx}N!U;0e3#6i-CNX`rY1&^$PQdk&iH)Dyn2zuRFnEFP3? zxo?E{`6fd!p5m?+yaZGCQ8$6R$0Yw&>Z=F+>8~C<&bL_cli!^#CI9`Y<#`P!YdC<%7;o_o-5AzZ~Wc33XdKg z8uA)u_Zzx^fYo#wFwF>{nZ#th@AgO9m+Uy>j5 z72^UYhqFbo!I$GM0<~85Y{=$GhCmC~+Nr`wb2zZf%0Dn?AVt)U>bDwhp7wn1IBX63 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 literal 0 HcmV?d00001 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' } + ] + } +}; From 86d976595c17086349803c8e634fe2f7c1d53bd1 Mon Sep 17 00:00:00 2001 From: marlonpp Date: Mon, 16 Jan 2017 10:57:56 -0800 Subject: [PATCH 2/2] Fix typo This commit fixes a typo at Production instructions. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11a7522..75be935 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ESLint will run and tests will be in watch mode. This will run eslint, tests and bundle the application in a single file called bundle.js under .dist/. -```node run server``` +```npm run server``` This will start the Express server responsible for both serving Application files and Artwork APIs.