├── .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 | [](https://travis-ci.org/andela-rekemezie/media-library)
2 | [](https://coveralls.io/github/andela-rekemezie/media-library?branch=master)
3 |
4 | 
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 |

10 |
11 |
12 |
13 | {images.map(image => (
14 |
15 |

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 |
10 |
11 |
12 |
13 | {videos.map(video => (
14 |
15 |
16 |
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 ?
: '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 |
--------------------------------------------------------------------------------