├── .babelrc ├── .eslintrc.js ├── .gitignore ├── README.md ├── config ├── webpack.dev.js └── webpack.prod.js ├── package-lock.json ├── package.json └── src ├── css └── styles.css ├── index.html └── js ├── app.js ├── components ├── App.js ├── MovieDetailsContainer.js ├── MovieInputForm.js ├── MovieList.js ├── MoviesContainer.js ├── MoviesContainer.test.js └── __snapshots__ │ └── MoviesContainer.test.js.snap ├── enzyme.js ├── hoc └── ErrorHandler.js └── service ├── http.js └── movie-api.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react" 5 | ] 6 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended" 9 | ], 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true, 13 | "jsx": true 14 | }, 15 | "sourceType": "module" 16 | }, 17 | "plugins": [ 18 | "react" 19 | ], 20 | "rules": { 21 | "indent": [ 22 | "error", 23 | 2 24 | ], 25 | "linebreak-style": [ 26 | "error", 27 | "unix" 28 | ], 29 | "quotes": [ 30 | "error", 31 | "single" 32 | ], 33 | "semi": [ 34 | "error", 35 | "never" 36 | ] 37 | } 38 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | static/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fabio Vedovelli Movie Finder 2 | 3 | Search for movies to read additional information about them! 4 | 5 | ## Getting Started 6 | 7 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. 8 | 9 | ### Prerequisites 10 | 11 | **GIT** 12 | 13 | You'll need GIT in order to clone this project. Head to [GIT's website](https://git-scm.com/) to get download and installation instructions to your operating system. 14 | 15 | **Node.js and NPM** 16 | 17 | In order to install project's dependencies, start development server and build for production, you'll need Node and NPM. Head to [Node.js website](https://nodejs.org/en/) to get download and installation instructions to your operating system. 18 | 19 | ### Installing 20 | 21 | First you have to use your Terminal to get to the directory you want to store the project. Then you run: 22 | 23 | ``` 24 | git clone git@github.com:vedovelli/movie-finder-react.git 25 | ``` 26 | 27 | Access the created directory and you'll be in the project's root directory: 28 | 29 | ``` 30 | cd movie-finder-react 31 | ``` 32 | 33 | Install project's dependencies: 34 | 35 | ``` 36 | npm install 37 | ```` 38 | 39 | Finally run the development server and the application should be available in the following URL: http://localhost:8000 40 | 41 | ``` 42 | npm run dev 43 | ``` 44 | 45 | ## Deployment 46 | 47 | To build the application for production, just run **npm run prod** and the compiled assets will be saved in the `dist/` directory: 48 | 49 | ``` 50 | npm run prod 51 | ``` 52 | 53 | ## Screenshots 54 | 55 | ![Screenshot 1](http://ludicrous-pocket.surge.sh/movie-finder-screenshot-1.png) 56 | ![Screenshot 2](http://ludicrous-pocket.surge.sh/movie-finder-screenshot-2.png) 57 | 58 | ## Project's Details 59 | 60 | ![Application Diagram](http://ludicrous-pocket.surge.sh/FabioVedovelliMovieFinder.png) 61 | 62 | It is a Single Page Application that consumes data from the TheMovieDB.org's API. 63 | 64 | There are 2 routes available: `/movies` for the search form and the results list and `/movie/:id` for the movie details screen. 65 | 66 | On the first access user will be automatically redirected from the main route `/` to the `/movies` route. This allows the movie feature to be sitting in its final URL, leaving the main one ready to receive a new feature, such as a Dashboard, for instance. 67 | 68 | When a search is performed, the search term is also included in the URL, in a query string. This allows the search results to be shared with others and also ensure that during navigation the feature states doesn't get lost. 69 | 70 | ## Development decisions 71 | 72 | **Single Source of Truth** 73 | 74 | Despite being highly desirable to abstract application main state to a Single Source of Truth, I thought including Redux or Mobx in such a small project would be an overkill, so I'd like to mention it crossed my mind but I've made the decision to keep it out. 75 | 76 | For the record if I was to include it I'd go for [Redux Zero](https://matheusml1.gitbooks.io/redux-zero-docs/content/) which I consider a very good solution for small a project. 77 | 78 | ## Application Evolution 79 | 80 | During development an idea came to my mind: to abstract all domain specific logic to a NPM package. For instance: the service layer which performs all the API calls. 81 | 82 | Then, using React Native, build a separate application and consume this library. 83 | 84 | ## Built With 85 | 86 | * [React](https://reactjs.org/) - The web framework used 87 | * [The MovieDB API](https://www.themoviedb.org/documentation/api) - The data source 88 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | 2 | const path = require("path") 3 | const HtmlWebPackPlugin = require('html-webpack-plugin') 4 | 5 | module.exports = { 6 | devServer: { 7 | contentBase: "../dist", 8 | historyApiFallback: true 9 | }, 10 | entry: [path.resolve(__dirname, "../src/js/app.js")], 11 | output: { 12 | path: path.resolve(__dirname, "../dist"), 13 | publicPath: '/', 14 | filename: "js/[name].js" 15 | }, 16 | mode: 'development', 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.css$/, 21 | use: ['style-loader', 'css-loader'] 22 | }, 23 | { 24 | test: /\.(js|jsx)$/, 25 | exclude: /node_modules/, 26 | use: { 27 | loader: "babel-loader" 28 | } 29 | }, 30 | { 31 | test: /\.html$/, 32 | use: [ 33 | { 34 | loader: "html-loader" 35 | } 36 | ] 37 | } 38 | ] 39 | }, 40 | plugins: [ 41 | new HtmlWebPackPlugin({ 42 | template: "./src/index.html", 43 | filename: "./index.html" 44 | }) 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var merge = require('webpack-merge') 3 | var dev = require('./webpack.dev') 4 | var UglifyJsPlugin = require('uglifyjs-webpack-plugin') 5 | 6 | module.exports = merge(dev, { 7 | plugins: [ 8 | new UglifyJsPlugin({ 9 | test: /\.js($|\?)/i 10 | }) 11 | ] 12 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fabio-vedovelli-movie-finder", 3 | "version": "1.0.0", 4 | "description": "Fabio Vedovelli Movie Finder", 5 | "main": "src/js/app.js", 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=development webpack-dev-server --config ./config/webpack.dev.js", 8 | "prod": "cross-env NODE_ENV=production webpack --config ./config/webpack.prod.js" 9 | }, 10 | "keywords": [], 11 | "author": "Fabio Vedovelli ", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "babel-core": "^6.26.0", 15 | "babel-loader": "^7.1.3", 16 | "babel-polyfill": "^6.26.0", 17 | "babel-preset-env": "^1.6.1", 18 | "babel-preset-react": "^6.24.1", 19 | "cross-env": "^5.1.3", 20 | "css-loader": "^0.28.11", 21 | "enzyme": "^3.3.0", 22 | "enzyme-adapter-react-16": "^1.1.1", 23 | "enzyme-to-json": "^3.3.4", 24 | "eslint": "^4.19.0", 25 | "eslint-plugin-react": "^7.7.0", 26 | "html-loader": "^0.5.5", 27 | "html-webpack-plugin": "^3.0.3", 28 | "jest": "^23.4.1", 29 | "style-loader": "^0.20.3", 30 | "uglifyjs-webpack-plugin": "^1.2.4", 31 | "webpack": "^4.16.1", 32 | "webpack-cli": "^2.0.9", 33 | "webpack-dev-server": "^3.1.4", 34 | "webpack-merge": "^4.1.2" 35 | }, 36 | "dependencies": { 37 | "axios": "^0.18.0", 38 | "query-string": "^6.0.0", 39 | "react": "^16.2.0", 40 | "react-dom": "^16.2.0", 41 | "react-router": "^4.2.0", 42 | "react-router-dom": "^4.2.2", 43 | "react-toastify": "^3.4.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin-top: 70px; 3 | } 4 | .movie-card { 5 | background-position: center; 6 | background-size: 100%; 7 | height: 450px; 8 | } 9 | @media (min-width: 1200px) { 10 | .movie-card { 11 | height: 500px; 12 | } 13 | } 14 | .panel-no-border { 15 | border: none; 16 | } 17 | @media (min-width: 1200px) { 18 | .container-details::before { 19 | border-bottom: #000 1px solid; 20 | z-index: -1; 21 | content: ' '; 22 | position: absolute; 23 | left: 0; 24 | top: 0; 25 | right: 0; 26 | height: 520px; 27 | width: 100%; 28 | background-size: cover; 29 | opacity: 0.2; 30 | } 31 | } 32 | .container-details img{ 33 | max-width: 280px; 34 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fabio Vedovelli Movie Finder 8 | 9 | 11 | 13 | 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { BrowserRouter, Route, Redirect, Switch } from 'react-router-dom' 5 | import App from './components/App' 6 | import Movies from './components/MoviesContainer' 7 | import MovieDetails from './components/MovieDetailsContainer' 8 | 9 | const markup = ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | 23 | ReactDOM.render(markup, document.getElementById('app')) 24 | -------------------------------------------------------------------------------- /src/js/components/App.js: -------------------------------------------------------------------------------- 1 | 2 | import PropTypes from 'prop-types' 3 | import '../../css/styles.css' 4 | 5 | const App = props => { 6 | return props.children 7 | } 8 | 9 | App.propTypes = { 10 | children: PropTypes.object 11 | } 12 | 13 | export default App 14 | -------------------------------------------------------------------------------- /src/js/components/MovieDetailsContainer.js: -------------------------------------------------------------------------------- 1 | 2 | import React, {Component} from 'react' 3 | import PropTypes from 'prop-types' 4 | import withErrorHandler from '../hoc/ErrorHandler' 5 | import { fetchMovie, POSTER_BASE_URL, BACKDROP_BASE_URL } from '../service/movie-api' 6 | 7 | class MovieDetailsContainer extends Component { 8 | // 9 | constructor (props) { 10 | super(props) 11 | this.state = { 12 | movie: {} 13 | } 14 | } 15 | 16 | // 17 | componentDidMount() { 18 | const { id } = this.props.match.params 19 | fetchMovie(id) 20 | .then(res => this.setState({ movie: res.data })) 21 | .catch(() => this.props.errorHandler(`Error fetching movie with the ID of ${id}`)) 22 | } 23 | 24 | // 25 | render() { 26 | const { history: { goBack } } = this.props 27 | const { movie } = this.state 28 | const hasMovie = Object.keys(movie).length > 0 29 | if (!hasMovie) return '' 30 | const hasPoster = movie.poster_path != null 31 | const hasWebsite = movie.homepage != null 32 | const data = formatMovieData(movie) 33 | 34 | addBackdrop(movie) 35 | 36 | return ( 37 |
38 | 43 | {hasMovie &&
44 |
45 | {hasPoster &&
46 | {data.imgSrc != null && } 47 |
} 48 |
49 |

{data.original_title}

50 |

{movie.overview}

51 |

Genres: {data.genres}

52 |

Release Date: {data.release_date}

53 |

Popularity: {data.popularity} ({data.votes} votes)

54 | {hasWebsite &&

Website: {data.website}

} 55 | 56 |
57 |
58 |
} 59 |
60 | ) 61 | } 62 | } 63 | 64 | MovieDetailsContainer.propTypes = { 65 | match: PropTypes.object, 66 | history: PropTypes.object, 67 | errorHandler: PropTypes.func 68 | } 69 | 70 | export default withErrorHandler(MovieDetailsContainer) 71 | 72 | const formatMovieData = movie => ({ 73 | genres: movie.genres.map(genre => genre.name).join(', '), 74 | imgSrc: `${POSTER_BASE_URL}${movie.poster_path}`, 75 | original_title: movie.original_title, 76 | popularity: movie.vote_average, 77 | votes: movie.vote_count, 78 | website: movie.homepage, 79 | release_date: movie.release_date.split('-').reverse().join('/') 80 | }) 81 | 82 | const markup = document.getElementsByTagName('head')[0].innerHTML 83 | const addBackdrop = movie => { 84 | document.getElementsByTagName('head')[0].innerHTML = markup + ` 85 | ` 90 | } 91 | -------------------------------------------------------------------------------- /src/js/components/MovieInputForm.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import PropTypes from 'prop-types' 4 | 5 | const MovieInputForm = props => { 6 | return ( 7 |
8 | 9 | 16 |
17 | ) 18 | } 19 | 20 | MovieInputForm.propTypes = { 21 | searchTerm: PropTypes.string, 22 | submitHandler: PropTypes.func, 23 | changeHandler: PropTypes.func 24 | } 25 | 26 | export default MovieInputForm 27 | -------------------------------------------------------------------------------- /src/js/components/MovieList.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import PropTypes from 'prop-types' 4 | import { Link } from 'react-router-dom' 5 | import { POSTER_BASE_URL } from '../service/movie-api' 6 | 7 | const renderMoviesWithoutPoster = (movies) => ( 8 |
9 | 19 |
20 | ) 21 | 22 | const renderMoviesWithPoster = (movies) => ( 23 | movies.map(movie => { 24 | const styles = { 25 | backgroundImage: `url(${POSTER_BASE_URL}${movie.poster_path})` 26 | } 27 | return ( 28 |
29 | 30 |
31 |
32 |

click to see movie info

33 |
34 | 35 |
36 | ) 37 | }) 38 | ) 39 | 40 | const MovieList = props => { 41 | // 42 | const { moviesWithPoster, moviesWithoutPoster } = props 43 | return ( 44 |
45 |
46 | {renderMoviesWithPoster(moviesWithPoster)} 47 |
48 | {moviesWithoutPoster.length > 0 && renderMoviesWithoutPoster(moviesWithoutPoster)} 49 |
50 | ) 51 | } 52 | 53 | MovieList.propTypes = { 54 | moviesWithPoster: PropTypes.array, 55 | moviesWithoutPoster: PropTypes.array 56 | } 57 | 58 | export default MovieList 59 | -------------------------------------------------------------------------------- /src/js/components/MoviesContainer.js: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react' 3 | import queryString from 'query-string' 4 | import PropTypes from 'prop-types' 5 | import { fetchMovies } from '../service/movie-api' 6 | import MovieList from './MovieList' 7 | import withErrorHandler from '../hoc/ErrorHandler' 8 | import MovieInputForm from './MovieInputForm' 9 | 10 | class MoviesContainer extends Component { 11 | 12 | // 13 | constructor(props) { 14 | super(props) 15 | 16 | this.submitHandler = this.submitHandler.bind(this) 17 | this.changeHandler = this.changeHandler.bind(this) 18 | 19 | /* 20 | * Performs service calls during navigation 21 | */ 22 | this.unlisten = this.props.history.listen(() => { 23 | this.queryString() 24 | }) 25 | 26 | this.state = { 27 | searchTerm: '', 28 | hasMovies: false, 29 | searchWasPerformed: false, 30 | moviesWithPoster: [], 31 | moviesWithoutPoster: [] 32 | } 33 | } 34 | 35 | // 36 | componentDidMount() { 37 | this.queryString() 38 | } 39 | 40 | // 41 | componentWillUnmount() { 42 | /* 43 | * Prevents multiple listeners to perform 44 | * multiple service calls during navigation. 45 | */ 46 | this.unlisten() 47 | } 48 | 49 | // 50 | queryString() { 51 | const data = queryString.parse(window.location.search) 52 | if (data.query != null && data.query !== '') { 53 | this.setState({ searchTerm: data.query }) 54 | this.fetch(data.query) 55 | } 56 | } 57 | 58 | // 59 | changeHandler (ev) { 60 | if (ev.target.value === '') { 61 | this.setState({ 62 | hasMovies: false, 63 | searchWasPerformed: false, 64 | moviesWithPoster:[], 65 | moviesWithoutPoster: [] 66 | }) 67 | } 68 | this.setState({ searchTerm: ev.target.value }) 69 | } 70 | 71 | // 72 | submitHandler (ev) { 73 | ev.preventDefault() 74 | 75 | /** 76 | * This will append the search term to URL query string 77 | * thus triggering the listener with queryString() attached. 78 | * The method will check the input and if valid, will request 79 | * the data for the API. 80 | */ 81 | const { history } = this.props 82 | const query = queryString.stringify({ query: this.state.searchTerm }) 83 | history.push(`${history.location.pathname}?${query}`) 84 | } 85 | 86 | // 87 | fetch(term) { 88 | fetchMovies(term) 89 | .then(res => { 90 | const movies = res.data.results 91 | this.setState({ 92 | hasMovies: movies.length > 0, 93 | searchWasPerformed: true, 94 | moviesWithPoster: movies.filter(movie => movie.poster_path != null), 95 | moviesWithoutPoster: movies.filter(movie => movie.poster_path == null) 96 | }) 97 | }) 98 | .catch(() => this.props.errorHandler('Error fetching the movie\'s list.')) 99 | } 100 | 101 | // 102 | render() { 103 | const { hasMovies, moviesWithPoster, moviesWithoutPoster, searchWasPerformed } = this.state 104 | return ( 105 |
106 | 111 |
112 | 116 | {hasMovies && } 117 | {searchWasPerformed && !hasMovies &&

Your search returned no results!

} 118 |
119 |
120 | ) 121 | } 122 | } 123 | 124 | MoviesContainer.propTypes = { 125 | history: PropTypes.object, 126 | errorHandler: PropTypes.func 127 | } 128 | 129 | export default withErrorHandler(MoviesContainer) 130 | -------------------------------------------------------------------------------- /src/js/components/MoviesContainer.test.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import { shallow } from 'enzyme'; 4 | import toJson from 'enzyme-to-json'; 5 | import MoviesContainer from './MoviesContainer'; 6 | 7 | import '../enzyme'; 8 | 9 | describe('MoviesContainer', () => { 10 | it('renders component properly', () => { 11 | expect(toJson(shallow())).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/js/components/__snapshots__/MoviesContainer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MoviesContainer renders component properly 1`] = ` 4 |
5 | 13 | } 14 | closeOnClick={true} 15 | hideProgressBar={false} 16 | newestOnTop={false} 17 | pauseOnHover={true} 18 | position="top-right" 19 | progressClassName={null} 20 | rtl={false} 21 | style={null} 22 | toastClassName={null} 23 | transition={[Function]} 24 | /> 25 | 28 |
29 | `; 30 | -------------------------------------------------------------------------------- /src/js/enzyme.js: -------------------------------------------------------------------------------- 1 | 2 | import { configure } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /src/js/hoc/ErrorHandler.js: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react' 3 | import { ToastContainer, toast } from 'react-toastify' 4 | 5 | const withErrorHandler = (ReceivedComponent) => { 6 | const errorHandler = errorMessage => { 7 | toast.error(errorMessage, { 8 | position: toast.POSITION.TOP_CENTER 9 | }) 10 | } 11 | 12 | class ErrorHandlerHOC extends Component { 13 | render() { 14 | return ( 15 |
16 | 17 | 20 |
21 | ) 22 | } 23 | } 24 | return ErrorHandlerHOC 25 | } 26 | 27 | export default withErrorHandler 28 | -------------------------------------------------------------------------------- /src/js/service/http.js: -------------------------------------------------------------------------------- 1 | 2 | import axios from 'axios' 3 | 4 | export const API_KEY = 'a1279933de606b4374a2c93a1d0127a9' 5 | 6 | export const http = axios.create({ 7 | baseURL: 'https://api.themoviedb.org/3/' 8 | }) 9 | -------------------------------------------------------------------------------- /src/js/service/movie-api.js: -------------------------------------------------------------------------------- 1 | 2 | import { http, API_KEY } from './http' 3 | 4 | export const POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500' 5 | export const BACKDROP_BASE_URL = 'https://image.tmdb.org/t/p/original' 6 | 7 | export const fetchMovies = term => http.get(`search/movie?api_key=${API_KEY}&query=${term}`) 8 | export const fetchMovie = id => http.get(`movie/${id}?api_key=${API_KEY}`) 9 | 10 | --------------------------------------------------------------------------------