Technical Write-up for the Shopify UX Developer Intern & Web Developer Intern Challenge — Summer 2021 — The Shoppies

The final product deployed on Netlify

The Challenge

  • Search OMDB and display the results (movies only)
  • Add a movie from the search results to our nomination list
  • View the list of films already nominated
  • Remove a nominee from the nomination list

Technical requirements

  1. Search results should come from OMDB’s API (free API key: http://www.omdbapi.com/apikey.aspx).
  2. Each search result should list at least its title, year of release and a button to nominate that film.
  3. Updates to the search terms should update the result list
  4. Movies in search results can be added and removed from the nomination list.
  5. If a search result has already been nominated, disable its nominate button.
  6. Display a banner when the user has 5 nominations.

Making user stories from our requirements list

  • As a user I should be able to search for films to nominate
  • Users must be able to see at least a title, year of release and a nominate button
  • As a user I expect that the list of results will change as I update my search term
  • As a user I should be able to nominate a film which will appear in a nomination list
  • As a user I should be able to un-nominate a film or remove a nomination.
  • As a user I should be able to see a list of the films I have nominated
  • As a user I should not be able to nominate the same film twice
  • As a user I should be able to nominate a film if it is a remake or reboot and has a different year of release but the same title as a prior release
  • Users should have the ability to share a link with others that shows what films they have nominated
  • Users nominations should be remembered when they return to the page
  • As a user I should see a banner when I nominate 5 films
  • As a user I should not see a banner if I have nominated 5 films and remove one

Considerations

  • The project requirements call for remembering the state of entities such as nominations, search results, isSearching. useState is a perfect way to manage this.
  • UseEffect is a perfect candidate for pulling results on load, checking the number of nominations per render and conditionally checking other states of the app with guard clauses
  • Ways to pass properties down to child components such as movie results, user information etc.
  • Comes with a useDebounce hook that is a perfect candidate for the search bar.
  • Methods like hiddden, onClick and other props can give re-usable components more flexibility.
  • React Router is a good candidate for navigating between the / and /{GUID} for sharing nominations with other users.

Solutions

As a user I should be able to search for films to nominate

const mainURL = `http://www.omdbapi.com/?s=${term}&type=movie&page=1&apikey=${process.env.REACT_APP_API_KEY}`;
REACT_APP_API_KEY = 'YOUR_OMDB_KEY'
axios.get(mainURL).then((response) => {setResults([...response.data.Search]);
console.log(response)
}});}, [term]);
Limiting parameters to just returning movies

Users must be able to see at least a title, year of release and a nominate button

import { useState, useEffect } from "react";export default function useDebounce(input, ms) {const [debounced, setDebounced] = useState("");useEffect(() => {const timeout = setTimeout(() => setDebounced(input), ms);return () => clearTimeout(timeout);}, [input, ms]);return debounced;}
import React, { useState, useEffect, useCallback } from 'react';import useDebounce from '../Hooks/useDebounce';export default function SearchBar(props) {const [value, setValue] = useState('');const term = useDebounce(value, 400);const onSearch = useCallback(props.onSearch, [term]);useEffect(() => {onSearch(term);}, [term, onSearch]);return (<section className="search"><formclassName="search__form"onSubmit={(event) => event.preventDefault()}><inputclassName="radius"spellCheck="false"placeholder="Search Movies"name="search"type="text"value={value}onChange={(event) => setValue(event.target.value)}/></form></section>);}
<SearchBar onSearch={(term) => setTerm(term)} /><Results results={results} />
import React from 'react';import classnames from 'classnames';export default function Movie(props) {const movieInfoClass = classnames('movie__info', {'movie__info--explicit': props.collectionExplicitness === 'explicit',return (<article className="movie"><img className="movie__thumbnail" src={props.Poster} alt="Movie" /><div className={movieInfoClass}><div className="movie__name">{props.Title}</div><div className="movie__artist">{props.Year}</div>//the button below will be used for the nominations feature<button className="nominate__btn" onClick={handleClick}>Nominate</button></div></article>);}
import React from 'react';import Movie from './Movie';
export default function Results(props) {const { results } = props;return results.map((movie) => {
//guards against an empty movie object and nominate button
if (movie.Response === 'False') {return null;}
//return our movie or movies to display to the user
return (<><Movie key={movie.collectionId} {...movie} /></>);});}
The final result of this completing this user story

As a user I expect that the list of results will change as I update my search term

API Data Limitation

{
"Response": "False",
"Error": "Too many results."
}
axios.get(mainURL).then((response) => {console.log(response.data);console.log(response.data.Response);}
results: Array(1)
0: {Response: "False", Error: "Incorrect IMDb ID."}
length: 1
{Response: "False", Error: "Too many results."}
Error: "Too many results."
Response: "False"
__proto__: Object
response.data.Response === “False”
axios.get(mainURL).then((response) => {if (response.data.Response === 'True') {setResults([...response.data.Search]);console.log(response.data);console.log(response.data.Response);}
})
Response: "True"
Search: (10) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
totalResults: "872"
__proto__: Object

Solution Implemented

//triggers on term changing
useEffect(() => {
const mainURL = `http://www.omdbapi.com/?s=${term}&type=movie&page=1&apikey=${process.env.REACT_APP_API_KEY}`;const fallbackURL = `http://www.omdbapi.com/?t=${term}&type=movie&apikey=${process.env.REACT_APP_API_KEY}`;//our happy path GET request with the s={term} to get multiple resultsaxios.get(mainURL).then((response) => {if (response.data.Response === 'True') {setResults([...response.data.Search]);
//debug to check the structure of the return object
console.log(response.data);console.log(response.data.Response);
//if we don't meet happy path and we encounter "too many results"
} else if (response.data.Response === 'False') {
//hit our second get request using our fallbackURL with t={term} in
axios.get(fallbackURL).then((response) => {if (response.data.Response === 'True') console.log(response);
//we don't need to spread in this case since 1 or less results should come back.
setResults([response.data]);});}});}, [term]);
The search bar does not hang now as it did previously and utilises both search terms from the API documentation

Users must be able to add Movies in search results to their nomination list

//nominations component
import React from 'react';
import Nomination from './Nomination';export default function Nominations(props) {const { nominations } = props;//index is a number in this casereturn nominations.map((nomination, index) => {return (<><Nominationkey={index}index={index}{...nomination}deleteNomination={props.deleteNomination}/></>);});}
//nomination component
return (
<article className="nomination"><imgclassName="movie__thumbnail"src={props.movie_poster === 'N/A' ? filmThumbnail : props.movie_poster}alt="Movie"/><div className={movieInfoClass}><div className="movie__name">{props.movie_title}</div><div className="movie__year">{props.movie_year}</div><div><ButtonclassName={classes.button}onClick={handleClick}variant="contained"color="secondary"startIcon={<RemoveCircleIcon />}>Remove</Button></div></div></article>);}
const [nominations, setNominations] = useState([]);   //for when a user wants to add a nomination  
const addNomination = (movie) => { const newNomination = { Title: movie.Title, Year: movie.Year, Poster: movie.Poster, }; setNominations((prevNominations) => { return [...prevNominations, newNomination]; });

As a user I should be able to un-nominate a film or remove a nomination.

function deleteNomination(id) {
setNominations((prevNominations) => {
return prevNominations.filter((nomination, index) => {
return index !== id;
});
});
}

Users must not be able to nominate the same movie twice.

{isNominated() ? (<p className="nominated_label">Nominated</p>) : (<ButtonclassName={classes.button}onClick={handleClick}startIcon={<AddCircleIcon />}>Nominate</Button>)}

Users must be able to see a banner when they have 5 nominations in their list.

{props.numNominated !== 5 ? (<div>{isNominated() ? (<p className="nominated_label">Nominated</p>) : (<ButtonclassName={classes.button}onClick={handleClick}startIcon={<AddCircleIcon />}>Nominate</Button>)}</div>) : (<div>{isNominated() ? (<p className="nominated_label">Nominated</p>) : null}
nominations={nominations}
Nominated label for movies already nominated

As a user I should see a banner when I nominate 5 films

const numNominated = nominations.length;
useEffect(() => {getNominations();if (numNominated === 5) {setOpen(true);}}, [numNominated, deleteNomination, addNomination]);// handles close for Modalconst handleClose = () => {setOpen(false);};
const [open, setOpen] = useState(false);
First iteration of the modal popup when user has nominated 5 films

Tackling the Extras

Extras (Stretch Features)

  • Save nomination lists if the user leaves the page
  • Animations for loading, adding/deleting movies, notifications
  • Create shareable links
Users can have many nominations and movies can be nominated multiple times by different users
# SchemaActiveRecord::Schema.define(version: 2021_01_14_033303) do# These are extensions that must be enabled in order to support this databaseenable_extension "plpgsql"create_table "movies", force: :cascade do |t|t.string "movie_title"t.string "movie_year"t.string "movie_poster"t.datetime "created_at", precision: 6, null: falset.datetime "updated_at", precision: 6, null: falseendcreate_table "nominations", force: :cascade do |t|t.bigint "user_id", null: falset.bigint "movie_id", null: falset.datetime "created_at", precision: 6, null: falset.datetime "updated_at", precision: 6, null: falset.index ["movie_id"], name: "index_nominations_on_movie_id"t.index ["user_id"], name: "index_nominations_on_user_id"endcreate_table "users", force: :cascade do |t|t.string "access_token"t.string "slug", limit: 255, default: "0", null: falset.datetime "created_at", precision: 6, null: falset.datetime "updated_at", precision: 6, null: falset.index ["access_token"], name: "index_users_on_access_token", unique: trueendadd_foreign_key "nominations", "movies"add_foreign_key "nominations", "users"end
class User < ApplicationRecordhas_many :nominations, :dependent => :destroyhas_many :nominated_movies, through: :nominations, source: :moviedef self.authenticate_with_credentials(slug)if @user && @user.authenticate(slug)return @userendnilendend
import React, { useState, useEffect } from 'react';import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';import axios from 'axios';import './styles/App.css';import LiveSearch from './components/LiveSearch';import uuid from 'react-uuid';import Slug from './components/Slug';export default function App() {const [state, setState] = useState({loggedInStatus: 'NOT_LOGGED_IN',user: {},});//posts a user to database and generates unique slug via UUIDconst createUser = () => {axios.post('/api/registrations',{slug: uuid(),},{headers: {authorization: `Token token=${localStorage.getItem('access_token')}`,},},{ withCredentials: false }).then((response) => {if (response.data.status === 'created') {console.log(response.data);handleSuccessfulAuth(response.data);}}).catch((error) => {console.log('Error: ', error);});};//sets React state accordinglyconst handleSuccessfulAuth = (data) => {localStorage.setItem('access_token', data.user.access_token);setState({loggedInStatus: 'LOGGED_IN',user: data.user,});};//check if a user exists where access token === access token (in local storage)const checkLoginStatus = () => {axios.get('/api/logged_in', {headers: {authorization: `Token token=${localStorage.getItem('access_token')}`,},withCredentials: true,}).then((response) => {if (response.data.logged_in &&state.loggedInStatus === 'NOT_LOGGED_IN') {setState({loggedInStatus: 'LOGGED_IN',user: response.data.user,});//catch all clause for setting states correctly} else if (!response.data.logged_in &&state.loggedInStatus === 'LOGGED_IN') {setState({loggedInStatus: 'NOT_LOGGED_IN',user: {},});}}).catch((error) => {console.log('Error: ', error);});};useEffect(() => {checkLoginStatus();//check if there is an access token in the browserif (!localStorage.getItem('access_token')) {createUser();}}, [state]);return (<Router><Switch><Routeexactpath="/"render={() => <LiveSearch user={state} />}></Route><Route path="/:slug" component={Slug} /></Switch></Router>);}
class RegistrationsController < ApplicationControllerskip_before_action :restrict_access
#creates a user with the params sent from react
def create@user = User.create!(slug: params['slug'],access_token: SecureRandom.hex)# confirms creation of the userif @userrender json: {status: :created,user: @user}elserender json: { status: 500 }endendend
delete :logout, to: "sessions#logout"get :logged_in, to: "sessions#logged_in"
class SessionsController < ApplicationControllerskip_before_action :restrict_access, only: [:create, :destroy]def newenddef logged_inif @current_userrender json: {logged_in: true,user: @current_user,}elserender json: {logged_in: false}endenddef logout@current_user.update_attributes(access_token: nil)render json: { status: 200, logged_out: true }enddef createif @user = User.authenticate_with_credentials(params)@user.update_attributes(access_token: SecureRandom.hex)render json: {status: :created,logged_in: true,user: @user}endend#destroy cookie/session on logoutdef destroyendend
access_token generated for user

Adding Movies to the Database

const addNomination = useCallback((movie) => {const movieNomination = {movie_title: movie.Title,movie_year: movie.Year,movie_poster: movie.Poster,};const user = {userID: userID,};axios.post('/api/movies',{movieNomination,user,},{headers: {authorization: `Token token=${localStorage.getItem('access_token')}`,},}).then(() => {getNominations();});}, []);
The final state of the movie data being added to the local db
def createif !Movie.exists?(movie_params)@movie = Movie.create!(movie_params)end@movie = Movie.where(movie_title: movie_params[:movie_title], movie_year: movie_params[:movie_year] ).first@current_user.nominated_movies << @movieend

Retrieving a users nominations via the Nominations join table

#user.rb
has_many :nominations, :dependent => :destroy
# add a class nominated_movies onto users for easier referencehas_many :nominated_movies, through: :nominations, source: :movie
class Nomination < ApplicationRecordbelongs_to :userbelongs_to :movie#join nominations and users using the relationshsip with the user_id foreign keyscope :is_nominated, -> (nomination) {joins(:user).where(user_id: users.id)}end
class Movie < ApplicationRecordhas_many :nominations,:dependent => :destroyend
def createif !Movie.exists?(movie_params)@movie = Movie.create!(movie_params)end
#get the record from the query params coming into the route and post the id of that movie via the route established in routes.rb
@movie = Movie.where(movie_title: movie_params[:movie_title], movie_year: movie_params[:movie_year] ).first@current_user.nominated_movies << @movieend#in routes.rb
#when the movie/create route is hit make a record in nominations for that movie_id for that user_id.
resources :movies doput :nomination, on: :memberend
-Select all movies
select * from movies
- join nominations table
join nominations
- using foreign keys
on movies.id = nominations.movie_id
-join users table
join users
-using foreign keys of nominations table
on nominations.user_id = users.id
-for the current user
where users.slug = 'some-UUID-value' ;
SQL query written in DBeaver returning results from the many to many relationship
def index@movies = Movie.joins(:nominations).select('*').where(nominations: {user_id: @current_user.id})render json: @movies.to_jsonend
const getNominations = () => {axios.get('/api/movies', {headers: {authorization: `Token token=${localStorage.getItem('access_token')}`,},}).then((result) => {setNominations([...result.data]);});};//delete nominations by index from the arrayconst deleteNomination = useCallback(() => {getNominations();}, []);
//for when a user wants to add a nominationconst addNomination = useCallback((movie) => {const movieNomination = {movie_title: movie.Title,movie_year: movie.Year,movie_poster: movie.Poster,};const user = {userID: userID,};axios.post('/api/movies',{movieNomination,user,},{headers: {authorization: `Token token=${localStorage.getItem('access_token')}`,},}).then(() => {getNominations();});}, []);useEffect(() => {getNominations();if (numNominated === 5) {setOpen(true);}}, [numNominated, deleteNomination, addNomination]);
Revisiting and refreshing a page a user has already conducted their votes on

Implementing Shareable Links

//App.js return wrapped in Router and Switch routing between / and /:slugreturn (<Router><Switch><Routeexactpath="/"render={() => <LiveSearch user={state} />}></Route><Route path="/:slug" component={Slug} /></Switch></Router>);}
#routes.rb
#redirects users to get/Nominations (nominations controller) when a slug parameter is sent from the front end
get :slug, to: "nominations#slug"
#nominations_controller.rbdef show# takes in a slug from user e.g. http://localhost:3001/api/nominations/7c5dec-e47-4c4e-a3ce-280a3b38ce5bslug = params[:id]#joins from movies to nominations to user and gets all associated movies by slug@nominatedMoviesFromSlug = Movie.joins(nominations: :user).where("slug=?", slug).allrender json: @nominatedMoviesFromSlug.to_jsonend
<CompleteBanneropen={open}numNominated={numNominated}handleClose={handleClose}user={props.user}/>
<p>You can share your nominations by copying this link:<br></br><a href={`http://localhost:3000/${props.user.user.slug}`}><span className={classes.link}>localhost:3000/{props.user.user.slug}</span></a></p>
useEffect(() => {getTotal();axios.get(`api/nominations/${slug}`).then((response) => {setNominations([...response.data]);});}, []);
Shareable links via a slug can be distributed online and visited by other users.

Implementing a Spinner Animation

const [isSearching, setIsSearching] = useState(false);#UseEffect then triggers when the search term changesuseEffect(() => {if (term) {setIsSearching(true);}const mainURL = `http://www.omdbapi.com/?s=${term}&type=movie&page=1&apikey=${process.env.REACT_APP_API_KEY}`;const fallbackURL = `http://www.omdbapi.com/?t=${term}&type=movie&apikey=${process.env.REACT_APP_API_KEY}`;axios.get(mainURL).then((response) => {if (response.data.Response === 'True') {setResults([...response.data.Search]);
#when the setTimeout in the useDebounce hook is done and we either retrieve search results or no results are found
setIsSearching(false);} else if (response.data.Response === 'False') {axios.get(fallbackURL).then((response) => {if (response.data.Response === 'True') {setResults([response.data]);setIsSearching(false);} else {setResults([]);setIsSearching(false);}});}});}, [term]);
#if isSearching is true show the spinner{isSearching && (<div className="searching"><p>searching...</p><img className="spinner" src={status}></img></div>)}
@keyframes rotate {100% {-webkit-transform: rotate(360deg);transform: rotate(360deg);}}.spinner {animation: rotate 1s linear infinite;height: 2rem;margin-left: 1rem;}

Total number of people who have voted for 5 movies

Total people voted counter dynamically adjusts as people do or do not have 5 total voted movies
#get total number of people who have nominated 5 moviesdef total@totalPeopleWith5Nominations = Nomination.group(:user_id).having(count: 5).count@counted = @totalPeopleWith5Nominations.countrender json: @countedend
{total !== null ? (<p><span className="highlight">{total}</span> people have voted fortheir favorites.</p>) : null}

A fresh lick of paint

import React from 'react';import classnames from 'classnames';import Button from '@material-ui/core/Button';import filmThumbnail from '../images/filmdefault.png';import { makeStyles } from '@material-ui/core/styles';import AddCircleIcon from '@material-ui/icons/AddCircle';import Icon from '@material-ui/core/Icon';const useStyles = makeStyles((theme) => ({button: {margin: theme.spacing(1),backgroundColor: '#00e676',height: '40px','&:hover': {backgroundColor: 'rgb(0, 161, 82)',},},}));export default function Movie(props) {const movieInfoClass = classnames('movie__info', {'movie__info--explicit': props.collectionExplicitness === 'explicit',});const classes = useStyles();return (<article className="movie" key={props.id}><imgclassName="movie__thumbnail"src={props.Poster === 'N/A' ? filmThumbnail : props.Poster}alt="Movie"/><div className={movieInfoClass}><div className="movie__name">{props.Title}</div><div className="movie__year">{props.Year}</div>{props.numNominated !== 5 ? (<div>{isNominated() ? (<p className="nominated_label">Nominated</p>) : (<ButtonclassName={classes.button}onClick={handleClick}startIcon={<AddCircleIcon />}>Nominate</Button>)}</div>) : (<div>{isNominated() ? (<p className="nominated_label">Nominated</p>) : null}</div>)}</div></article>);}

Adding Opengraph cards

<head><meta charset="utf-8" /><link rel="icon" href="%PUBLIC_URL%/favicon.ico" /><meta name="viewport" content="width=device-width, initial-scale=1" /><meta name="theme-color" content="#000000" /><meta name="Nominate your favorite movies of the year here"content="Created for the Shopify Front-End Developer Intern (Remote) - Summer 2021 Challenge" /><link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /><title>The Shoppies Movie Awards</title><meta property="og:url" content=https://www.theshoppies.netlifyapp.com /><meta property="og:type" content="Movie & Entertainment" /><meta property="og:title" content="The Shoppies - Movie Awards" /><meta property="og:description" content="​Nominate your favorite movies of the year here." /><meta property="og:image" content="https://i.imgur.com/SUG2pGw.png" /><meta property="twitter:image" content="https://i.imgur.com/SUG2pGw.png" /><meta property="twitter:site" content='https://i.imgur.com/SUG2pGw.png' /><meta property="twitter:title" content='The Shoppies - Movie Awards' /></head>
OpenGraph cards appear when sharing the web links online

Making a default image if the movie poster is N/A

Movie Thumbnail from OMDB
Editing the default_thumbnail in Canva
Final Default Thumbnail
<imgclassName="movie__thumbnail"src={props.Poster === 'N/A' ? filmThumbnail : props.Poster}alt="Movie"/>
Movies would appear with a broken image and alt text before implementing the default image

Deployment

Cors and HTTP: issues on the requests
Rails.application.config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) doallow doorigins '*'resource '/cors',:headers => :any,:methods => [:post],:max_age => 0resource '*',:headers => :any,:methods => [:get, :post, :delete, :put, :patch, :options, :head],:max_age => 0endend
/api/*  https://myrubyapiandbackend.herokuapp.com/api/:splat  200/*    /index.html   200

Future Improvements

Data is sometimes inaccurate or mis-typed

Conclusion

  • HTML5
  • JavaScript
  • Ruby on Rails
  • CSS3
  • MySQL/PostgreSQL
  • Bootstrap
  • MaterialUI
  • React
  • Redux

--

--

www.arlmedia.ca https://github.com/AndrewRLloyd88

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store