├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .setup.js ├── .travis.yml ├── Procfile ├── README.md ├── build ├── favicon.ico ├── index.html └── static │ ├── css │ ├── main.b686448c.css │ └── main.b686448c.css.map │ └── js │ ├── main.5068efe5.js │ └── main.5068efe5.js.map ├── favicon.ico ├── index.html ├── package.json ├── src ├── Api │ └── api.js ├── actions │ └── mediaActions.js ├── common │ └── Header.js ├── components │ ├── HomePage.js │ ├── PhotosPage.js │ └── VideosPage.js ├── constants │ └── actionTypes.js ├── containers │ ├── App.js │ └── MediaGalleryPage.js ├── index.js ├── reducers │ ├── imageReducer.js │ ├── index.js │ ├── initialState.js │ └── videoReducer.js ├── routes.js ├── sagas │ ├── index.js │ ├── mediaSagas.js │ └── watchers.js ├── stores │ └── configureStores.js └── styles │ └── style.css └── test ├── App.test.js ├── Header.test.js ├── HomePage.test.js ├── MediaGalleryPage.test.js ├── PhotoPage.test.js ├── flickr.test.js ├── imageReducers.test.js ├── mediaActions.test.js ├── mediaSagas.test.js ├── sagas.index.test.js ├── store.test.js ├── videoPage.test.js ├── videoReducer.test.js └── watchers.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015", 5 | "stage-2" 6 | ], 7 | "plugins": [ 8 | [ 9 | "transform-runtime", { 10 | "polyfill": false, 11 | "regenerator": true 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | object-curly-spacing = true 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | [{*.json, *.yml, .eslintrc}] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "globals": { 9 | "expect": true, 10 | "describe": true, 11 | "document": true, 12 | "fetch": true, 13 | "window": true 14 | }, 15 | "rules": { 16 | "padded-blocks": 0, 17 | "object-curly-spacing": [1, "always"], 18 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 19 | "comma-dangle": [2, "never"], 20 | "arrow-body-style": ["error", "as-needed"], 21 | "no-return-assign": 2, 22 | "no-use-before-define": [2, "nofunc"], 23 | "no-unused-expressions": 0 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | node_modules 39 | coverage 40 | .idea 41 | .env 42 | -------------------------------------------------------------------------------- /.setup.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register')(); 2 | 3 | var jsdom = require('jsdom').jsdom; 4 | 5 | var exposedProperties = ['window', 'navigator', 'document']; 6 | 7 | global.document = jsdom(''); 8 | global.window = document.defaultView; 9 | Object.keys(document.defaultView).forEach((property) => { 10 | if (typeof global[property] === 'undefined') { 11 | exposedProperties.push(property); 12 | global[property] = document.defaultView[property]; 13 | } 14 | }); 15 | 16 | global.navigator = { 17 | userAgent: 'node.js' 18 | }; 19 | 20 | documentRef = document; 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "5.0" 5 | env: 6 | - CXX=g++-4.8 7 | cache: 8 | directories: 9 | - node_modules 10 | notifications: 11 | email: false 12 | branches: 13 | only: 14 | - develop 15 | - master 16 | script: 17 | - npm run test:coverage 18 | after_success: 19 | - npm run coveralls 20 | deploy: 21 | provider: heroku 22 | api_key: 4fd3f4ce-2f6a-49c7-b238-21dd660db329 23 | app: 24 | develop: media-gallery 25 | master: media-gallery 26 | before_deploy: 27 | - npm i pushstate-server -g 28 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run deploy 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/andela-rekemezie/media-library.svg?branch=master)](https://travis-ci.org/andela-rekemezie/media-library) 2 | [![Coverage Status](https://coveralls.io/repos/github/andela-rekemezie/media-library/badge.svg?branch=master)](https://coveralls.io/github/andela-rekemezie/media-library?branch=master) 3 | 4 | ![Application UI](https://cloud.githubusercontent.com/assets/15085641/17646353/587e60d0-61bd-11e6-9403-82437ee3a6e6.png) 5 | 6 | 7 | - Click here for see the [Live demo](http://media-gallery.herokuapp.com). 8 | - Click here for the tutorial post. 9 | 10 | # Motivation 11 | media-gallery is a tutorial app to get all levels of React developers up to speed with how to architect a scalable, maintainable and testable react/redux application. 12 | 13 | # Technology used 14 | * [React](https://facebook.github.io/react/) as the core infrastructure. 15 | * [Redux](https://github.com/reactjs/redux) for state management. 16 | * [Redux-saga](https://github.com/yelouafi/redux-saga) for handling async tasks with agility. 17 | 18 | # Modules covered 19 | 1. Introduction. 20 | 2. Project setup. 21 | 2. Define action. 22 | 3. Setup state management system. 23 | 4. Define async task handlers. 24 | 5. Create a container component. 25 | 6. Create presentational component. 26 | 7. Connect React component to redux store. 27 | 8. Deploy app on Heroku. 28 | 29 | ## Credit 30 | Special credit goes to Facebook team for [create-react-app](https://facebook.github.io/react/blog/2016/07/22/create-apps-with-no-configuration.html) 31 | 32 | ## Sending Feedback 33 | I am always open to [your feedback](https://github.com/andela-rekemezie/media-gallery/issues). 34 | 35 | ## Contributing 36 | If you have ideas of how to make the app a good learning tool, open an [issue](https://github.com/andela-rekemezie/media-gallery/issues). 37 | 38 | * Follow me on [Twitter](https://twitter.com/row_net) 39 | 40 | -------------------------------------------------------------------------------- /build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scotch-io/react-redux-media-library/b8dbe8ef3e835badbe3fc30bb260b6a3529f069d/build/favicon.ico -------------------------------------------------------------------------------- /build/index.html: -------------------------------------------------------------------------------- 1 | React App
-------------------------------------------------------------------------------- /build/static/css/main.b686448c.css: -------------------------------------------------------------------------------- 1 | body{margin:0;padding:0;font-family:Helvetica,Arial,Sans-Serif,sans-serif;background:#fff}.title{padding:2px;text-overflow-ellipsis:overflow;overflow:hidden;display:block;text-overflow:ellipsis}.select-video,.selected-image{height:500px}.select-video video,.selected-image img{width:100%;height:450px}.image-thumbnail,.video-thumbnail{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around;overflow:auto;overflow-y:hidden}.image-thumbnail img,.video-thumbnail video{width:70px;height:70px;padding:1px;border:1px solid grey} 2 | /*# sourceMappingURL=main.b686448c.css.map*/ -------------------------------------------------------------------------------- /build/static/css/main.b686448c.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":[],"names":[],"mappings":"","file":"static/css/main.b686448c.css","sourceRoot":""} -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scotch-io/react-redux-media-library/b8dbe8ef3e835badbe3fc30bb260b6a3529f069d/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React App 8 | 9 | 10 |
11 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "media-library", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "babel-core": "^6.13.2", 7 | "babel-eslint": "^6.1.2", 8 | "babel-plugin-transform-runtime": "^6.12.0", 9 | "babel-polyfill": "^6.13.0", 10 | "babel-preset-es2015": "^6.13.2", 11 | "babel-preset-react": "^6.11.1", 12 | "babel-preset-stage-2": "^6.13.0", 13 | "coveralls": "^2.11.12", 14 | "eslint": "^3.2.2", 15 | "eslint-config-airbnb": "^10.0.0", 16 | "eslint-plugin-react": "^6.0.0", 17 | "ignore-styles": "^4.0.0", 18 | "istanbul": "^1.0.0-alpha.2", 19 | "mocha": "^3.0.2", 20 | "nock": "^8.0.0", 21 | "react-addons-test-utils": "^15.3.0", 22 | "react-scripts": "0.2.1", 23 | "redux-mock-store": "^1.1.2" 24 | }, 25 | "dependencies": { 26 | "react": "^15.2.1", 27 | "react-dom": "^15.2.1", 28 | "react-redux": "^4.4.5", 29 | "react-router": "^2.6.0", 30 | "redux": "^3.5.2", 31 | "redux-saga": "^0.11.0", 32 | "enzyme": "^2.4.1", 33 | "expect": "latest", 34 | "pushstate-server": "latest", 35 | "isomorphic-fetch": "^2.2.1" 36 | }, 37 | "bugs": { 38 | "url": "https://github.com/andela-rekemezie/media-gallery/issues" 39 | }, 40 | "scripts": { 41 | "start": "react-scripts start", 42 | "build": "rm -rf build/ && react-scripts build", 43 | "eject": "react-scripts eject", 44 | "test:coverage": "rm -rf coverage/ && istanbul cover _mocha -- --compilers css:ignore-styles ./.setup.js \"test\"", 45 | "test": "clear && _mocha --compilers css:ignore-styles ./.setup.js \"test\" --recursive", 46 | "test:watch": "npm run test -- --watch", 47 | "deploy": "pushstate-server build", 48 | "homepage": "https://media-gallery.herokuapp.com", 49 | "coveralls": "cat ./coverage/lcov.info | coveralls" 50 | }, 51 | "eslintConfig": { 52 | "extends": "./node_modules/react-scripts/config/eslint.js" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Api/api.js: -------------------------------------------------------------------------------- 1 | const FLICKR_API_KEY = 'a46a979f39c49975dbdd23b378e6d3d5'; 2 | const SHUTTER_CLIENT_ID = '3434a56d8702085b9226'; 3 | const SHUTTER_CLIENT_SECRET = '7698001661a2b347c2017dfd50aebb2519eda578'; 4 | 5 | const basicAuth = () => 'Basic '.concat(window.btoa(`${SHUTTER_CLIENT_ID}:${SHUTTER_CLIENT_SECRET}`)); 6 | const authParameters = { 7 | headers: { 8 | Authorization: basicAuth() 9 | } 10 | }; 11 | 12 | export const shutterStockVideos = (searchQuery) => { 13 | const SHUTTERSTOCK_API_ENDPOINT = `https://api.shutterstock.com/v2/videos/search?query=${searchQuery}&page=1&per_page=10`; 14 | return fetch(SHUTTERSTOCK_API_ENDPOINT, authParameters) 15 | .then(response => { 16 | return response.json(); 17 | }) 18 | .then(json => { 19 | return json.data.map(({ id, assets, description }) => ({ 20 | id, 21 | mediaUrl: assets.preview_mp4.url, 22 | description 23 | })); 24 | }); 25 | }; 26 | 27 | export const flickrImages = (searchQuery) => { 28 | const FLICKR_API_ENDPOINT = `https://api.flickr.com/services/rest/?method=flickr.photos.search&text=${searchQuery}&api_key=${FLICKR_API_KEY}&format=json&nojsoncallback=1&per_page=10`; 29 | return fetch(FLICKR_API_ENDPOINT) 30 | .then(response => { 31 | return response.json() 32 | }) 33 | .then(json => { 34 | return json.photos.photo.map(({ farm, server, id, secret, title }) => ({ 35 | id, 36 | title, 37 | mediaUrl: `https://farm${farm}.staticflickr.com/${server}/${id}_${secret}.jpg` 38 | })); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/actions/mediaActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | export const selectImageAction = (image) => ({ 4 | type: types.SELECTED_IMAGE, 5 | image 6 | }); 7 | 8 | export const selectVideoAction = (video) => ({ 9 | type: types.SELECTED_VIDEO, 10 | video 11 | }); 12 | 13 | export const searchMediaAction = (payload) => ({ 14 | type: types.SEARCH_MEDIA_REQUEST, 15 | payload 16 | }); 17 | -------------------------------------------------------------------------------- /src/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, IndexLink } from 'react-router'; 3 | 4 | const Header = () => ( 5 |
6 | 11 |
12 | ); 13 | 14 | export default Header; 15 | -------------------------------------------------------------------------------- /src/components/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | const HomePage = () => ( 5 |
6 |

Welcome to Media Library built with React, Redux, Redux-saga

7 |
8 | 9 | 10 | 11 |
12 |
13 | ); 14 | 15 | export default HomePage; 16 | -------------------------------------------------------------------------------- /src/components/PhotosPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const PhotosPage = ({ images, onHandleSelectImage, selectedImage }) => ( 4 |
5 |

Images

6 |
7 |
8 |
{selectedImage.title}
9 | {selectedImage.title} 10 |
11 |
12 |
13 | {images.map(image => ( 14 |
15 | {image.title} 16 |
17 | ))} 18 |
19 |
20 | ); 21 | 22 | PhotosPage.propTypes = { 23 | images: PropTypes.array.isRequired, 24 | selectedImage: PropTypes.object, 25 | onHandleSelectImage: PropTypes.func.isRequired 26 | }; 27 | 28 | export default PhotosPage; 29 | -------------------------------------------------------------------------------- /src/components/VideosPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const VideosPage = ({ videos, onHandleSelectVideo, selectedVideo }) => ( 4 |
5 |

Videos

6 |
7 |
8 |
{selectedVideo.description}
9 |
11 |
12 |
13 | {videos.map(video => ( 14 |
15 |
17 | ))} 18 |
19 |
20 | ); 21 | 22 | VideosPage.propTypes = { 23 | videos: PropTypes.array, 24 | selectedVideo: PropTypes.object, 25 | onHandleSelectVideo: PropTypes.func.isRequired 26 | }; 27 | 28 | export default VideosPage; 29 | -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SELECTED_IMAGE = 'SELECTED_IMAGE'; 2 | export const FLICKR_IMAGES_SUCCESS = 'FLICKR_IMAGES_SUCCESS'; 3 | export const SELECTED_VIDEO = 'SELECTED_VIDEO'; 4 | export const SHUTTER_VIDEOS_SUCCESS = 'SHUTTER_VIDEOS_SUCCESS'; 5 | export const SEARCH_MEDIA_REQUEST = 'SEARCH_MEDIA_REQUEST'; 6 | export const SEARCH_MEDIA_SUCCESS = 'SEARCH_MEDIA_SUCCESS'; 7 | export const SEARCH_MEDIA_FAILURE = 'SEARCH_MEDIA_FAILURE'; 8 | -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import Header from '../common/Header'; 3 | 4 | class App extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 | {this.props.children} 10 |
11 | ); 12 | } 13 | } 14 | App.propTypes = { 15 | children: PropTypes.object.isRequired 16 | }; 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/containers/MediaGalleryPage.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { 4 | selectImageAction, searchMediaAction, 5 | selectVideoAction 6 | } from '../actions/mediaActions'; 7 | import PhotosPage from '../components/PhotosPage'; 8 | import VideosPage from '../components/VideosPage'; 9 | import '../styles/style.css'; 10 | 11 | 12 | export class MediaGalleryPage extends Component { 13 | constructor() { 14 | super(); 15 | this.handleSearch = this.handleSearch.bind(this); 16 | this.handleSelectImage = this.handleSelectImage.bind(this); 17 | this.handleSelectVideo = this.handleSelectVideo.bind(this); 18 | } 19 | 20 | componentDidMount() { 21 | this.props.dispatch(searchMediaAction('rain')); 22 | } 23 | 24 | handleSelectImage(selectedImage) { 25 | this.props.dispatch(selectImageAction(selectedImage)); 26 | } 27 | 28 | handleSelectVideo(selectedVideo) { 29 | this.props.dispatch(selectVideoAction(selectedVideo)); 30 | } 31 | 32 | handleSearch(event) { 33 | event.preventDefault(); 34 | if (this.query !== null) { 35 | this.props.dispatch(searchMediaAction(this.query.value)); 36 | this.query.value = ''; 37 | } 38 | } 39 | 40 | render() { 41 | const { images, selectedImage, videos, selectedVideo } = this.props; 42 | return ( 43 |
44 | {images ?
45 | (this.query = ref)} 48 | /> 49 | 55 |
56 | 61 | 66 |
67 |
: 'loading ....'} 68 |
69 | ); 70 | } 71 | } 72 | 73 | MediaGalleryPage.propTypes = { 74 | images: PropTypes.array, 75 | selectedImage: PropTypes.object, 76 | videos: PropTypes.array, 77 | selectedVideo: PropTypes.object, 78 | dispatch: PropTypes.func.isRequired 79 | }; 80 | 81 | /* Subscribe component to redux store and merge the state into component\s props */ 82 | const mapStateToProps = ({ images, videos }) => ({ 83 | images: images[0], 84 | selectedImage: images.selectedImage, 85 | videos: videos[0], 86 | selectedVideo: videos.selectedVideo 87 | }); 88 | 89 | /* connect method from react-router connects the component with redux store */ 90 | export default connect( 91 | mapStateToProps)(MediaGalleryPage); 92 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import React from 'react'; 3 | import { Router, browserHistory } from 'react-router'; 4 | import { Provider } from 'react-redux'; 5 | import configureStore from './stores/configureStores'; 6 | import routes from './routes'; 7 | 8 | const store = configureStore(); 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , document.getElementById('root') 14 | ); 15 | -------------------------------------------------------------------------------- /src/reducers/imageReducer.js: -------------------------------------------------------------------------------- 1 | import initialState from './initialState'; 2 | import * as types from '../constants/actionTypes'; 3 | 4 | export default function (state = initialState.images, action) { 5 | switch (action.type) { 6 | case types.FLICKR_IMAGES_SUCCESS: 7 | return [...state, action.images]; 8 | case types.SELECTED_IMAGE: 9 | return { ...state, selectedImage: action.image }; 10 | default: 11 | return state; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import images from './imageReducer'; 3 | import videos from './videoReducer'; 4 | 5 | const rootReducer = combineReducers({ 6 | images, videos 7 | }); 8 | 9 | export default rootReducer; 10 | -------------------------------------------------------------------------------- /src/reducers/initialState.js: -------------------------------------------------------------------------------- 1 | export default { 2 | images: [], 3 | videos: [] 4 | }; 5 | -------------------------------------------------------------------------------- /src/reducers/videoReducer.js: -------------------------------------------------------------------------------- 1 | import initialState from './initialState'; 2 | import * as types from '../constants/actionTypes'; 3 | 4 | export default function (state = initialState.videos, action) { 5 | switch (action.type) { 6 | case types.SHUTTER_VIDEOS_SUCCESS: 7 | return [...state, action.videos]; 8 | case types.SELECTED_VIDEO: 9 | return { ...state, selectedVideo: action.video }; 10 | default: 11 | return state; 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, IndexRoute } from 'react-router'; 3 | import MediaGalleryPage from './containers/MediaGalleryPage'; 4 | import App from './containers/App'; 5 | import HomePage from './components/HomePage'; 6 | 7 | export default ( 8 | 9 | 10 | 11 | 12 | ); 13 | 14 | -------------------------------------------------------------------------------- /src/sagas/index.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'redux-saga/effects'; 2 | import watchSearchMedia from './watchers'; 3 | 4 | export default function* startForman() { 5 | yield fork(watchSearchMedia); 6 | } 7 | -------------------------------------------------------------------------------- /src/sagas/mediaSagas.js: -------------------------------------------------------------------------------- 1 | import { put, call } from 'redux-saga/effects'; 2 | import { flickrImages, shutterStockVideos } from '../Api/api'; 3 | import * as types from '../constants/actionTypes'; 4 | 5 | 6 | export default function* searchMediaSaga({ payload }) { 7 | try { 8 | const videos = yield call(shutterStockVideos, payload); 9 | const images = yield call(flickrImages, payload); 10 | yield [ 11 | put({ type: types.SHUTTER_VIDEOS_SUCCESS, videos }), 12 | put({ type: types.SELECTED_VIDEO, video: videos[0] }), 13 | put({ type: types.FLICKR_IMAGES_SUCCESS, images }), 14 | put({ type: types.SELECTED_IMAGE, image: images[0] }) 15 | ]; 16 | } catch (error) { 17 | yield put({ type: 'SEARCH_MEDIA_FAILURE', error }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/sagas/watchers.js: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga'; 2 | import searchMediaSaga from './mediaSagas'; 3 | import * as types from '../constants/actionTypes'; 4 | 5 | export default function* watchSearchMedia() { 6 | yield* takeLatest(types.SEARCH_MEDIA_REQUEST, searchMediaSaga); 7 | } 8 | -------------------------------------------------------------------------------- /src/stores/configureStores.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import createSagaMiddleware from 'redux-saga'; 3 | import rootReducer from '../reducers'; 4 | import rootSaga from '../sagas'; 5 | 6 | const configureStore = () => { 7 | const sagaMiddleware = createSagaMiddleware(); 8 | return { 9 | ...createStore(rootReducer, 10 | applyMiddleware(sagaMiddleware)), 11 | runSaga: sagaMiddleware.run(rootSaga) 12 | }; 13 | }; 14 | 15 | export default configureStore; 16 | -------------------------------------------------------------------------------- /src/styles/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: Helvetica, Arial, Sans-Serif, sans-serif; 5 | background: white; 6 | } 7 | 8 | .title { 9 | padding: 2px; 10 | text-overflow-ellipsis: overflow; 11 | overflow: hidden; 12 | display: block; 13 | text-overflow: ellipsis; 14 | } 15 | 16 | .selected-image, .select-video { 17 | height: 500px; 18 | } 19 | 20 | .selected-image img, .select-video video { 21 | width: 100%; 22 | height: 450px; 23 | } 24 | 25 | .image-thumbnail, .video-thumbnail { 26 | display: flex; 27 | justify-content: space-around; 28 | overflow: auto; 29 | overflow-y: hidden; 30 | } 31 | 32 | .image-thumbnail img, .video-thumbnail video { 33 | width: 70px; 34 | height: 70px; 35 | padding: 1px; 36 | border: 1px solid grey; 37 | } 38 | 39 | -------------------------------------------------------------------------------- /test/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import expect from 'expect'; 3 | import { shallow } from 'enzyme'; 4 | import App from '../src/containers/App'; 5 | 6 | 7 | describe('Test App', () => { 8 | const props = ['test1', 'test2']; 9 | const wrapper = shallow(); 10 | it('should render self', () => { 11 | expect(wrapper.find('Header').length).toEqual(1); 12 | expect(wrapper.find('div').hasClass('container-fluid')).toBe(true); 13 | }); 14 | 15 | it('should render children', () => { 16 | expect(typeof wrapper.props().children).toBe('object'); 17 | }); 18 | }); 19 | 20 | -------------------------------------------------------------------------------- /test/Header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import expect from 'expect'; 3 | import { Link, IndexLink } from 'react-router'; 4 | import { shallow } from 'enzyme'; 5 | import Header from '../src/common/Header'; 6 | 7 | describe('Test for Header component', () => { 8 | it('should render header component', () => { 9 | const wrapper = shallow(
); 10 | expect(wrapper.length).toEqual(true); 11 | expect(wrapper.is('.text-center')).toEqual(true); 12 | expect(wrapper.find(Link).length).toEqual(1); 13 | expect(wrapper.find(IndexLink).length).toEqual(1); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/HomePage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import expect from 'expect'; 3 | import { shallow } from 'enzyme'; 4 | import { Link } from 'react-router'; 5 | import HomePage from '../src/components/HomePage'; 6 | 7 | describe('Test for HomePage view', () => { 8 | it('should render home page', () => { 9 | const wrapper = shallow(); 10 | expect(wrapper.length).toEqual(true); 11 | expect(wrapper.find(Link).length).toEqual(1); 12 | expect(wrapper.find(Link).props().to).toEqual('library'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/MediaGalleryPage.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by rowland on 8/15/16. 3 | */ 4 | import React from 'react'; 5 | import expect from 'expect'; 6 | import { shallow, mount } from 'enzyme'; 7 | import { MediaGalleryPage } from '../src/containers/MediaGalleryPage'; 8 | 9 | 10 | const setup = () => { 11 | const props = { 12 | handleSearch: expect.createSpy(), 13 | handleSelectImage: expect.createSpy(), 14 | handleSelectVideo: expect.createSpy(), 15 | dispatch: expect.createSpy(), 16 | ref: expect.createSpy(), 17 | value: 'ref', 18 | images: [{ id: 1, mediaUrl: 'test image url' }], 19 | videos: [{ id: 1, mediaUrl: 'test video url' }], 20 | selectedVideo: { id: 1, mediaUrl: 'test video url' }, 21 | selectedImage: { id: 1, mediaUrl: 'test image url' } 22 | }; 23 | 24 | const Wrapper = shallow(); 25 | return { Wrapper, props }; 26 | }; 27 | 28 | describe('Test for MediaGalleryPage', () => { 29 | it('should render self and subcomponents', () => { 30 | const { Wrapper } = setup(); 31 | 32 | expect(Wrapper.find('div').length).toEqual(3); 33 | const PhotoPageWrapper = Wrapper.find('PhotosPage').props(); 34 | const VideoPageWrapper = Wrapper.find('VideosPage').props(); 35 | expect(PhotoPageWrapper.images).toEqual([{ id: 1, mediaUrl: 'test image url' }]); 36 | expect(PhotoPageWrapper.selectedImage).toEqual({ id: 1, mediaUrl: 'test image url' }); 37 | expect(typeof PhotoPageWrapper.onHandleSelectImage).toBe('function'); 38 | expect(VideoPageWrapper.videos).toEqual([{ id: 1, mediaUrl: 'test video url' }]); 39 | expect(VideoPageWrapper.selectedVideo).toEqual({ id: 1, mediaUrl: 'test video url' }); 40 | expect(typeof VideoPageWrapper.onHandleSelectVideo).toBe('function'); 41 | }); 42 | 43 | it('should call dispatch on onHandleSelectImage', () => { 44 | const { Wrapper, props } = setup(); 45 | 46 | const input = Wrapper.find('PhotosPage'); 47 | input.props().onHandleSelectImage({ id: 1, mediaUrl: 'test image url' }); 48 | expect(props.dispatch.calls.length).toBe(1); 49 | }); 50 | 51 | it('should call dispatch onHandleSelectVideo', () => { 52 | const { Wrapper, props } = setup(); 53 | 54 | const input = Wrapper.find('VideosPage'); 55 | input.props().onHandleSelectVideo({ id: 1, mediaUrl: 'test video url' }); 56 | expect(props.dispatch.calls.length).toBe(1); 57 | }); 58 | 59 | it('should call dispatch on onClick event', () => { 60 | const { props } = setup(); 61 | const testWrapper = mount() 62 | const input = testWrapper.find('input').last(); 63 | const event = { preventDefault: () => {} }; 64 | input.simulate('click', event); 65 | expect(props.dispatch.calls.length).toBe(2); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/PhotoPage.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by rowland on 8/15/16. 3 | */ 4 | import expect from 'expect'; 5 | import React from 'react'; 6 | import { mount } from 'enzyme'; 7 | import PhotoPage from '../src/components/PhotosPage'; 8 | 9 | 10 | describe('Test for Image Page component', () => { 11 | const setUp = () => { 12 | const props = { 13 | images: [{ id: 1, test: 'test image' }], 14 | onHandleSelectImage: expect.createSpy(), 15 | selectedImage: { id: 1, test: 'test image' } 16 | }; 17 | const Wrapper = mount(); 18 | return { Wrapper }; 19 | }; 20 | const { Wrapper } = setUp(); 21 | 22 | it('should assert that Component exist', () => { 23 | expect(Wrapper).toExist(); 24 | }); 25 | 26 | it('should have render props', () => { 27 | expect(Wrapper.props().images).toEqual([{ id: 1, test: 'test image' }]); 28 | expect(typeof Wrapper.props().onHandleSelectImage).toEqual('function'); 29 | expect(Wrapper.props().selectedImage).toEqual({ id: 1, test: 'test image' }); 30 | }); 31 | 32 | it('should render self', () => { 33 | expect(Wrapper.find('h2').text()).toEqual('Images'); 34 | expect(Wrapper.find('h6').hasClass('title')).toBe(true); 35 | expect(Wrapper.find('img').length).toEqual(2); 36 | expect(Wrapper.find('div').length).toEqual(5); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/flickr.test.js: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import 'isomorphic-fetch'; 3 | import expect from 'expect'; 4 | import { flickrImages, shutterStockVideos } from '../src/Api/api'; 5 | 6 | describe('Test for Api', () => { 7 | it('should call flickr Api', () => { 8 | flickrImages('rain').then((res) => { 9 | expect(res.length).toEqual(10); 10 | }); 11 | }); 12 | 13 | it('should call shutterStock Api', () => { 14 | shutterStockVideos('sun').then((res) => { 15 | expect(res.length).toEqual(10); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/imageReducers.test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import imageReducer from '../src/reducers/imageReducer'; 3 | import * as types from '../src/constants/actionTypes'; 4 | 5 | 6 | describe('Test for image Reducers', () => { 7 | const initialState = { 8 | images: [{ 9 | id: 1, 10 | link: 'www.test.com/1.jpg' 11 | }] 12 | }; 13 | 14 | it('should return the initial state', () => { 15 | expect(imageReducer(undefined, [])).toEqual([]); 16 | }); 17 | 18 | it('should return the all images in the store', () => { 19 | const testAction = { type: types.FLICKR_IMAGES_SUCCESS, images: 'www.test.com/1.jpg' }; 20 | expect(imageReducer(initialState, testAction)).toEqual(['www.test.com/1.jpg']); 21 | }); 22 | 23 | it('should return the selected image', () => { 24 | const testAction = { type: types.SELECTED_IMAGE, image: 'www.test.com/1.jpg' }; 25 | const expectValue = { images: [{ id: 1, link: 'www.test.com/1.jpg' }], selectedImage: 'www.test.com/1.jpg' }; 26 | expect(imageReducer(initialState, testAction)).toEqual(expectValue); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/mediaActions.test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { 3 | selectImageAction, 4 | searchMediaAction, 5 | selectVideoAction 6 | } from '../src/actions/mediaActions'; 7 | import * as types from '../src/constants/actionTypes'; 8 | 9 | 10 | describe('Test for Action creators', () => { 11 | 12 | it('should return selected images action object', () => { 13 | const image = { id: 1, link: 'great.com/1.jpg' }; 14 | expect(selectImageAction(image)).toEqual({ type: types.SELECTED_IMAGE, image }); 15 | }); 16 | 17 | it('should return selected video action object', () => { 18 | const video = { id: 1, link: 'great.com/1.mp4' }; 19 | expect(selectVideoAction(video)).toEqual({ type: types.SELECTED_VIDEO, video }); 20 | }); 21 | 22 | it('should return searchMediaAction action object', () => { 23 | const test = { id: 1, link: 'great.com/1.jpg' }; 24 | expect(searchMediaAction(test)).toEqual({ type: types.SEARCH_MEDIA_REQUEST, payload: test }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/mediaSagas.test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { put, call } from 'redux-saga/effects'; 3 | import searchMediaSaga from '../src/sagas/mediaSagas'; 4 | import { flickrImages, shutterStockVideos } from '../src/Api/api'; 5 | 6 | 7 | describe('Test for searchMediaSaga', () => { 8 | const payload = 'test'; 9 | const gen = searchMediaSaga({ payload }); 10 | 11 | it('should call shutterStockVideos API', () => { 12 | expect(gen.next(payload).value).toEqual(call(shutterStockVideos, payload)); 13 | }); 14 | 15 | it('should call flickrImages API ', () => { 16 | expect(gen.next(payload).value).toEqual(call(flickrImages, payload)); 17 | }); 18 | 19 | it('should yield array of objects', () => { 20 | const videos = []; 21 | expect(gen.next(videos).value.length).toEqual(4); 22 | }); 23 | 24 | it('should dispatch failure effect', () => { 25 | const error = 'error'; 26 | expect(gen.throw(error).value).toEqual(put({ type: 'SEARCH_MEDIA_FAILURE', error })); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/sagas.index.test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { fork } from 'redux-saga/effects'; 3 | import startForeman from '../src/sagas'; 4 | import watchSearchMedia from '../src/sagas/watchers'; 5 | 6 | 7 | describe('Test startForeman saga', () => { 8 | it('should yield array watchers saga', () => { 9 | expect(startForeman().next().value).toEqual(fork(watchSearchMedia)); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/store.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by rowland on 8/15/16. 3 | */ 4 | import React from 'react'; 5 | import expect from 'expect'; 6 | import configureMockStore from 'redux-mock-store'; 7 | import createSagaMiddleware from 'redux-saga'; 8 | import { searchMediaAction } from '../src/actions/mediaActions'; 9 | 10 | const sagaMiddleware = createSagaMiddleware(); 11 | const mockStore = configureMockStore([sagaMiddleware]); 12 | 13 | describe('Test store', () => { 14 | it('should return dispatch action to the api', () => { 15 | const store = mockStore({}); 16 | const expectedValue = { 17 | type: 'SEARCH_MEDIA_REQUEST', 18 | payload: 'rain' 19 | }; 20 | store.dispatch(searchMediaAction('rain')); 21 | expect(store.getActions()).toEqual([expectedValue]); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/videoPage.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by rowland on 8/15/16. 3 | */ 4 | import expect from 'expect'; 5 | import React from 'react'; 6 | import { mount } from 'enzyme'; 7 | import VideoPage from '../src/components/VideosPage'; 8 | 9 | describe('Test for Video Page component', () => { 10 | const setUp = () => { 11 | const props = { 12 | videos: [{ id: 1, test: 'test video' }], 13 | onHandleSelectVideo: expect.createSpy(), 14 | selectedVideo: { id: 1, test: 'test video' } 15 | }; 16 | const Wrapper = mount(); 17 | return { Wrapper }; 18 | }; 19 | const { Wrapper } = setUp(); 20 | 21 | it('should assert that Component exist', () => { 22 | expect(Wrapper).toExist(); 23 | }); 24 | it('should have render props', () => { 25 | expect(Wrapper.props().videos).toEqual([{ id: 1, test: 'test video' }]); 26 | expect(typeof Wrapper.props().onHandleSelectVideo).toEqual('function'); 27 | expect(Wrapper.props().selectedVideo).toEqual({ id: 1, test: 'test video' }); 28 | }); 29 | 30 | it('should render self', () => { 31 | expect(Wrapper.find('h2').text()).toEqual('Videos'); 32 | expect(Wrapper.find('h6').hasClass('title')).toBe(true); 33 | expect(Wrapper.find('video').length).toEqual(2); 34 | expect(Wrapper.find('div').length).toEqual(5); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/videoReducer.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by rowland on 8/15/16. 3 | */ 4 | 5 | import expect from 'expect'; 6 | import videoReducer from '../src/reducers/videoReducer'; 7 | import * as types from '../src/constants/actionTypes'; 8 | 9 | describe('Test for image Reducers', () => { 10 | const initialState = { 11 | videos: [{ 12 | id: 1, 13 | link: 'www.test.com/1.mp4' 14 | }] 15 | }; 16 | 17 | it('should return the initial state', () => { 18 | expect(videoReducer(undefined, [])).toEqual([]); 19 | }); 20 | 21 | it('should return the all videos in the stores tree', () => { 22 | const testAction = { type: types.SHUTTER_VIDEOS_SUCCESS, videos: 'www.test.com/1.mp4' }; 23 | expect(videoReducer(initialState, testAction)).toEqual([ 'www.test.com/1.mp4' ]); 24 | }); 25 | 26 | it('should return the selected video', () => { 27 | const testAction = { type: types.SELECTED_VIDEO, video: 'www.test.com/1.mp4' }; 28 | const expectValue = { videos: [{ id: 1, link: 'www.test.com/1.mp4' }], selectedVideo: 'www.test.com/1.mp4' }; 29 | expect(videoReducer(initialState, testAction)).toEqual(expectValue); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/watchers.test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { fork, take } from 'redux-saga/effects'; 3 | import searchMediaSaga from '../src/sagas/mediaSagas'; 4 | import watchSearchMedia from '../src/sagas/watchers'; 5 | 6 | describe('Test for watchLoadFlickrImages', () => { 7 | 8 | describe('Test for watchSearchMedia', () => { 9 | it('should call searchMediaSaga', () => { 10 | const gen = watchSearchMedia(); 11 | const action = { type: 'SEARCH_MEDIA_REQUEST' }; 12 | expect(gen.next().value).toEqual(take('SEARCH_MEDIA_REQUEST')); 13 | expect(gen.next(action).value).toEqual(fork(searchMediaSaga, action)); 14 | }); 15 | }); 16 | }); 17 | --------------------------------------------------------------------------------