03/01/2020

Let's Build a CRUD App with Ruby on Rails and React (Part 2)

A step-by-step walk through to building a CRUD app with react and ruby on rails. In this article we will be building a flight reviews app from scratch with basic CRUD functionality.

In part 1 of this series, we created a new rails app from scratch with react and built out or api. Then we tested our api to confirm that all of our endpoints are working correctly. In this part, we will start to build out the react portion of our app.

Building Our React Frontend

Recall that at the beginning of this, when we created our new rails app from our command line, we included a --webpack=react flag. Doing this set up our app with webpacker, and configured its defaults to be using React.

So now, if we navigate into our rails app we will see a javascript folder inside of app.

rails app javascript folder example

Inside of our javascript folder we will see a packs folder with two files inside of it - application.js and hello_react.jsx.

The app/packs/application.js file at this point is basically just comments, explaining how to use the javascript pack tag:

filepath: app/javascript/packs/application.js

/* eslint no-console:0 */
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
//
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.erb


// Uncomment to copy all static images under ../images to the output folder and reference
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)
// or the `imagePath` JavaScript helper below.
//
// const images = require.context('../images', true)
// const imagePath = (name) => images(name, true)

console.log('Hello World from Webpacker')

And our app/packs/hello_react.jsx file by default at this point will look like this:

filepath: app/javascript/packs/hello_react.jsx

// Run this example by adding <%= javascript_pack_tag 'hello_react' %> to the head of your layout file,
// like app/views/layouts/application.html.erb. All it does is render <div>Hello React</div> at the bottom
// of the page.

import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'

const Hello = props => (
  <div>Hello {props.name}!</div>
)

Hello.defaultProps = {
  name: 'David'
}

Hello.propTypes = {
  name: PropTypes.string
}

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <Hello name="React" />,
    document.body.appendChild(document.createElement('div')),
  )
})

In this file, we've been given a simple example that will output, in this particular case, 'Hello React' onto the page. However, we won't actually see this output anywhere yet because we still have not added react into the view layer of our app. So let's do that now!

The Javascript Pack Tag

In order to do this, we just need to add the javascript pack tag into our views, for example in views/layouts/application.html.erb. At the top of our hello_react.jsx file is a comment that includes an example of the javascript pack tag for this file:


<%= javascript_pack_tag 'hello_react' %>
      

Add this tag into the head of views/layouts/application.html.erb. After doing this your application.html.erb file should look something like this:

filepath: app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title>OpenFlights</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'hello_react' %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>
      

At this point I'm going to change the name of the file in our packs folder from hello_react.jsx to index.js. You don't need to do this if you don't want, but if you do, make sure that you additionally modify the value for the javascript pack tag so that instead of passing in 'hello_react' your pack tag will now look like this:


<%= javascript_pack_tag 'index' %>
      

At this point, if we fire up our rails server (rails s) and navigate to localhost:3000, we should now see the output from our javascript displaying to the page:

Hello React Javascript Pack Tag Example

If you see "Hello React" displaying out onto the page, then congratulations! You've successfully hooked everything up and are now using React for the view layer of your app. That's a pretty big step!

Adding a Components Folder

So now we are ready to start building out our own components. Let's start by adding a new components folder inside of our app/javascript folder. This folder should be at the same level as the packs folder, so your directory tree should look like this now:

react javascript components folder example

Inside of our components folder, let's create a new App.js file. Then, inside of this file, let's import React and create the skeleton structure for a new component. To do this we can simply import React from 'react' and then create a new component.

filepath: app/javascript/components/App.js

import React from 'react'

const App = () => {
  return ()
}

export default App

Inside of this component now, let's add a simple div element with a 'Hello World' message inside of it:

filepath: app/javascript/components/App.js

import React from 'react'

const App = () => {
  return (
    <div>Hello World</div>
  )
}

export default App

Then, back inside of index.js, let's now import our new App component. We can do this by adding this line to our imports at the top of the file:

filepath: app/javascript/packs/index.js
import App from '../components/App'

And then let's clear out the boilerplate code they gave us, and instead we can now replace it with App. After doing this our index.js file should look like this:

filepath: app/javascript/packs/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import App from '../components/App'

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <App />,
    document.body.appendChild(document.createElement('div')),
  )
})

Now, if we again fire up our rails server (rails s) and navigate to localhost:3000 we should see "Hello World" from our new App component getting rendered out onto the page!

React Router / React Router Dom

At this point, I think we are ready to add react-router to our project. Once we do this, we will be able to define a set of "navigational" components in our app and establish routes for each. For example, when a user navigates to /airlines in our app, we can use react-router to point to an Airlines component to render the view (using react) for what would traditionally be our Airlines#index view.

So let's go ahead and install react-router-dom; you can do this using either yarn or npm (I'll be using yarn in this example). From your terminal, go ahead and do the following (make sure you are inside of the directory for this app):

yarn add react-router-dom

BrowserRouter, Router, Route, and Switch

So now that we have react-router-dom installed, let's modify our index.js file inside of packs. What we can do here now is import BrowserRouter from react-router-dom as Router and Route. Then we can modify the code inside of index.js so that instead of passing our App component to our ReactDom.render method, we can now pass in Router and Route. So first let's import BrowserRouter as Router and Route into index.js.

filepath: app/javascript/packs/index.js
import { BrowserRouter as Router, Route } from 'react-router-dom'

And now instead of passing in App, we will pass in Router and Route into ReactDom.render:

filepath: app/javascript/packs/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Route } from 'react-router-dom'
import App from '../components/App'

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <Router>
      <Route/>
    </Router>,
    document.body.appendChild(document.createElement('div')),
  )
})

Then, inside of our Route component, we can set a path with a value of '/', and set the component to point this to as App:

filepath: app/javascript/packs/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Route } from 'react-router-dom'
import App from '../components/App'

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <Router>
      <Route path="/" component="App"/>
    </Router>,
    document.body.appendChild(document.createElement('div')),
  )
})

By doing this we have essentially set up an inderect path for any route that will point initially to our App component. So now, inside of our App component we can establish all of the exact paths for our app using Route and Switch.

To do this, let's first import Route and Switch into our App component:

filepath: app/javascript/components/App
import { Route, Switch } from 'react-router-dom'

<Switch> is unique in that it renders a route exclusively. In contrast, every <Route> that matches the location renders inclusively. Read More Here.

Then, inside of our App component, let's remove our "Hello World" example and instead pass in Switch, which we are importing from react-router-dom:

filepath: app/javascript/components/App
import React from 'react'
import { Route, Switch } from 'react-router-dom'

const App = () => {
  return(
    <Switch></Switch>
  )
}

export default App

Now, inside of Switch we can set all of the routes for our app. To do this we can set the exact path we want to create and provide the component we want to route that path to. So as a starting point, I want to set our root path to point to an Airlines component (which we will create momentarily).

To do this, we can add a Route inside of Switch, set an exact path to our root path, and point it to our Airlines component. So inside of our Switch component we will add this:

filepath: app/javascript/components/App
<Route exact path="/" component={Airlines} />

Again, what we are doing here is basically setting up a route that will take exact matches to the root path of our app and direct them to our Airlines component.

So once we add that, our App component so far will look like this:

filepath: app/javascript/components/App
import React from 'react'
import { Route, Switch } from 'react-router-dom'

const App = () => {
  return(
    <Switch>
      <Route exact path="/" component={Airlines} />
    </Switch>
  )
}

export default App

Additionally, we want to create a route for displaying each individual airline. This would traditionally be the Airlines#show view in a typical rails app.

In order to do this with react router, we can pass a param on the url when we create our route for this. Recall that we are using :slug as the param for displaying our individual airlines in our rails api. To keep things consistent, I additionally will use slug in my routes here. So what we can do is create a new route with an exact path set to /:slug and point this to an Airline component. So our new Route will look like this:

<Route exact path="/:slug" component={Airline} />

Once we add this, our App component at this point should now look like this:

filepath: app/javascript/components/App
import React from 'react'
import { Route, Switch } from 'react-router-dom'

const App = () => {
  return(
    <Switch>
      <Route exact path="/" component={Airlines} />
      <Route exact path="/:slug" component="Airline" />
    </Switch>
  )
}

export default App

At this point, if you still have your server running you might notice that this change that we just made will break our app. This is because the Airlines and Airline components we set in our routes don't actually exist yet. So let's fix this!

Inside of our components folder, let's create two new folders - an Airlines folder (plural), and an Airline folder (singular).

React Airlines Components Folder Example

You can think of Airlines as being the equivalent of our Airlines#index view. We will add components in here focused on displaying a list of all of our airlines to the page when a user navigates to, in this case, the homepage/rootpath of our app ("/").

Similarly, you can think of Airline as our Airlines#show view, the components we add here will focus on displaying a single airline when a user navigates to /:slug, where slug will be a specific airline's slug, for example delta or united-airlines.

As a starting point, let's add a new Airlines.js file into our Airlines folder, and a new Airline.js file into our Airline folder. Then in each one let's create the basic skeleton for a react class component.

After doing this, my Airlines.js file will look like this:

filepath: app/javascript/components/Airlines/Airlines.js
import React from 'react'

const Airlines = () => {
  return(
    <div>This is the Airlines#index page for our app.</div>
  )
}

export default Airlines

And my Airline.js file will look like this:

filepath: app/javascript/components/Airline/Airline.js
import React from 'react'

const Airline = () => {
  return(
    <div>This is the Airlines#show page for our app.</div>
  )
}

export default Airline

Now, back inside of our App component, we can import our Airlines and Airline components:

filepath: app/javascript/components/App.js
import React from 'react'
import Airlines from './Airlines/Airlines'
import Airline from './Airline/Airline'

After adding this our App component should now look like this:

filepath: app/javascript/components/App.js
import React from 'react'
import { Route, Switch } from 'react-router-dom'
import Airlines from './Airlines/Airlines'
import Airline from './Airline/Airline'

const App = () => {
  return(
    <Switch>
      <Route exact path="/" component="Airlines" />
      <Route exact path="/:slug" component="Airline" />
    </Switch>
  )
}

export default App

With this update, the routes we established within our App component are now pointing to the correct components. So now, if we fire our server back up (rails s) and navigate to localhost:3000, we should be able to see our new Airlines#index path. Additionally, if we navigate to, for example, localhost:3000/delta, we should also see our Airlines#show page as well:

If you've made it this far and everything is working - then congratulations! You now have routing set up on the react side of your app with react-router. This is a huge step, so go ahead and pat yourself on the back.

Sidenote: The reason we are able to set up routing like this in both our rails api and with react router is because we added get '*path', to: 'pages#index', via: :all in our routes.rb file. If for any reason you missed this step, make sure to revisit that section in part 1 and get that set up!

With our routing set up, we can now begin building out our Airlines and Airline components for our App.

Building Out Our Airlines#index View

So now, I think we are ready to start building out our Airlines#index view. Again, recall that this is basically going to be the home page of our app, and will contain a list of all of our airlines.

As a starting point, I want to set a default state in this component. In a moment, we will be getting a list of all of our airlines from our database to display as a grid here using our api from part 1.

So let's create an airlines object in our state and give it an initial state that is an empty array. We can import useState from react to set this up using hooks:

filepath: app/javascript/components/Airlines/Airlines.js
import React, { useState } from 'react'

const Airlines = () => {
  const [airlines, setAirlines] = useState([])

  return(
    <div>This is the Airlines#index page.</div>
  )
}

export default Airlines

Homepage Layout

Before we handle our api request, let's go ahead and remove the placeholder jsx ("This is the Airlines#index page for our app.") from inside of our render method and instead, let's now add some real content. For now our layout will be fairly simple. I want to add two main sections to our homepage - a header section that will display the name and tagline for our site, and a grid of all of the airlines we have in our database.

Here is an absolutely beautiful hand drawn diagram of this layout in detail:

Homepage Layout Diagram

So for this I'm going to start by adding a div element with a class of home to wrap all of our content. Then inside of it, I'll include 2 main sections; a header section where we will add the header and tagline for the site, and a grid section where we will include the table of airlines.

filepath: app/javascript/components/Airlines/Airlines.js
...
<div className="home">
  <div className="header"></div>
  <div className="grid"></div>
</div>
...

Note: In react, we have to use className instead of class when setting classes on our html elements (actually jsx) because class is a keyword in javascript.

Then, inside of our header I'm going to add an h1 tag with the header as well as a subheader, and inside of our grid for now I'm just going to add a placeholder for where we will add our full list of airlines momentarily.

filepath: app/javascript/components/Airlines/Airlines.js
...
<div className="home">
  <div className="header">
    <h1>OpenFlights</h1>
    <p className="subheader">Honest, unbiased airline reviews. Share your experience.</p>
  </div>
  <div className="grid">
    AIRLINES GRID GOES HERE
  </div>
</div>
...

After adding this, our Airlines.js component so far should look like this:

filepath: app/javascript/components/Airlines/Airlines.js

import React, {useState} from 'react'

const Airlines = () => {
  const [airlines, setAirlines] = useState([])

  return (
    <div className="home">
      <div className="header">
        <h1>OpenFlights</h1>
        <p className="subheader">Honest, unbiased airline reviews. Share your experience.</p>
      </div>
      <div className="grid">
        AIRLINES GRID GOES HERE
      </div>
    </div>
  )
}

export default Airlines 

If we check out the homepage of our app now in the browser you should see this on the page:

Homepage with basic text

That's a good lookin' page right there!

Making API requests to our rails api with axios

Within our airlines component, we want to now get a list of all of the airlines from our database. We can get this list as we saw earlier by making a request to our api endpoint /api/v1/airlines.json. We can do this from our react code easily with the help of an http client library called axios.

Once we add axios to our app, we can start making requests to all of our rails api endpoint that we established earlier. Specifically in this case, I want to make a request from inside useEffect within our Airlines component. Then we can take the response from our api request and use it to update our state to replace our empty airlines array with an array of the actual airline data from our database.

Let's start by adding axios to our project. We can do this from our terminal using yarn or npm:

yarn add axios

Then, back in our Airlines component, let's import axios, and let's also add useEffect:

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const Airlines = () => {
    const [airlines, setAirlines] = useState([])

  useEffect(() => {
    // Our code will go here
  }, [])

  return (
    <div className="home">
      <div className="header">
        <h1>OpenFlights</h1>
        <p className="subheader">Honest, unbiased airline reviews. Share your experience.</p>
      </div>
      <div className="grid">
        {grid}
      </div>
    </div>
  )
}

export default Airlines

Now, inside of our useEffect hook, I'm going to add a GET request using axios to our /api/v1/airlines.json endpoint in our rails api. In both the success and failure case I'm simply going to add a debugger for now.

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const Airlines = () => {
  const [airlines, setAirlines] = useState([])
  
  useEffect( () => {
    axios.get('/api/v1/airlines.json')
    .then( resp => {
      debugger
    })
    .catch( data => {
      debugger
    })
  }, [])

  return (
    ...
  )
}

export default Airlines

With that added, at this point go ahead and hop back over to your browser and reload the page. However before you do, open the console in your browser (e.g. cmd + option + i in chrome). When the page reloads, our request should ping our api endpoint and then we should hit the debugger in either our success or failure case.

When we hit our debugger now, we should have access to a json result with an array of airline objects from our api. You can check this out by entering the following into your browser console:

resp.data.data

If everything is working, you should see an array of airline objects in your response data.

Now in our success case, let's remove the debugger and instead update our state to add the data we get back from our server to our airlines array. We can do this using the setAirlines method inside of our Airlines component:

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const Airlines = () => {
  const [airlines, setAirlines] = useState([])
  
  useEffect( () => {
    axios.get('/api/v1/airlines.json')
    .then( resp => {
      setAirlines(resp.data)
    })
    .catch( data => {
      debugger
    })
  }, [])

  return (
    ...
  )
}

export default Airlines

Recall that based off the structure we gave our json api, the data structure for each airline within our airlines array in our state will now look something like this:


{
  id: "1",
  type: "airline",
  attributes: {
    name: "United Airlines",
    slug: "united-airlines",
    image_url: "https://open-flights.s3.amazonaws.com/United-Airlines.png"
  },
  relationships: {
    reviews: []
  },
  includes: []
}

Inside of our component, let's create a new variable grid that lists all of our airline's names. For this, let's map over the airline data we now have access to in our state and for each one we will create an li tag that wraps the name of the airline.


const grid = airlines.map((airline, index) => {
  return (<li key={index}>{airline.data.attributes.name}</li>)
})

Note: Keys help React identify which items have changed, are added, or are removed. Keys should be given to the elements inside the array to give the elements a stable identity. Source

Then we will add this variable to an unorganized list (ul) inside of our jsx in our component. This will go inside of our div element with the class grid.

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, usEffect } from 'react'
import axios from 'axios'
import Airline from './Airline'

const Airlines = () => {
  const [airlines, setAirlines] = useState([])
  
  useEffect( () => {
    axios.get('/api/v1/airlines.json')
    .then( resp => {
      setAirlines(resp.data)
    })
    .catch( data => {
      debugger
    })
  }, [])

  const grid = airlines.map( (airline, index) => {
      return (<li key={index} >{airline.data.attributes.name}</li>)
  })
  
  return (
    <div className="home">
      <div className="header">
        <h1>OpenFlights</h1>
        <p className="subheader">Honest, unbiased airline reviews. Share your experience.</p>
      </div>
      <div className="grid">
        <ul>
          {grid}
        </ul>
      </div>
    </div>
  )
}

export default Airlines

At this point now, if you fire back up your rails server and navigate to localhost:3000, you should see a list of airline names rendering out onto our page!

Airlines Unorganized List

Let's actually create a new Airline component inside of our Airlines folder that we can use instead of rendering a list within our Airlines component.

let's start by creating a new file called Airline.js inside of our Airilines folder.

Don't confuse the Airline component we are creating here with the Airline component we will be working on below for our individual Airline view. This Airline component will exist inside of our Airlines (plural) folder at Airlines/Airline.js. The component we created earlier and that we will work on momentarily for our Airlines#show view is located inside of the Airline folder (singular) at Airline/Airline.js. If it's less confusing, feel free to use different naming conventions from what I've chosen.

Inside of our Airline.js file, let's start by importing React:

filepath: app/javascript/components/Airlines/Airline.js
import React from 'react'

We can structure our initial Airline component like so:

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'

const Airline = (props) => {
  return ()
}

export default Airline

Props

Notice that our Airline takes in an argument of props. For each object we have in the airlines array in our state, we wil be using this component and passing down some data as props. The main data I want to pass down for each airline will be the name, image_url and slug values that exist inside of attributes in our api data structure. An example of how we might pass down this data could look something like this:

<Airline attributes={airline.data.attributes}/>

Once we do this, we will be able to access these values from inside this component as props.attributes.name, props.attributes.image_url, and props.attributes.slug.

We can simplify this further within our Airline component using object destructuring:

filepath: app/javascript/components/Airlines/Airline.js
const {name, image_url, slug} = props.attributes

So now inside of return, we can pass in some jsx along with the prop values we will be passing down momentarily. So for this component, I want to start by wrapping everything in a div element with a class of "card":

filepath: app/javascript/components/Airlines/Airline.js
<div className="card"></div>

Then, inside of this wrapper div I want to add another div that wraps an img element inside of it that will take in image_url from our props and use it as the image's src value. Additionally we can use name from our props as the alt tag:

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'

const Airline = (props) => {
  const {name, image_url, slug} = props.attributes

  return (
    <div className="card">

      <div className="airline-logo">
        <img src={image_url} alt={name} width="50" />
      </div>

    </div>
  )
}

export default Airline

I also want to create a div with a class of airline-name that wraps the name of our airline:

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'

const Airline = (props) => {
  const {name, image_url, slug} = props.attributes

  return (
    <div className="card">
      
      <div className="airline-logo">
        <img src={image_url} alt={name} width="50" />
      </div>

      <div className="airline-name">
        {name}
      </div>

    </div>
  )
}

export default Airline

I also want to create a div with a class of link-wrapper that wraps an anchor tag. This anchor tag will take the slug for an airline and link out to our Airlines#show view.

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'

const Airline = (props) => {
  const {name, image_url, slug} = props.attributes

  return (
    <div className="card">

      <div className="airline-logo">
        <img src={image_url} alt={name} width="50" />
      </div>

      <div className="airline-name">
        {name}
      </div>

      <div className="link-wrapper">
        <a href={"/" + slug}>View Airline</a>
      </div>

    </div>
  )
}

export default Airline

So our full Airline component now should look like this:

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'

const Airline = (props) => {
  const {name, image_url, slug} = props.attributes

  return (
    <div className="card">
      <div className="airline-logo">
        <img src={image_url} alt={name} width="50" />
      </div>
      <div className="airline-name">
        {name}
      </div>
      <div className="link-wrapper">
        <a href={"/" + slug}>View Airline</a>
      </div>
    </div>
  )
}

export default Airline

React Router & Link

Because we are using react-router, we need to use Link as a drop in replacement for our anchor tags. To do this, we can import Link from react-router-dom and then simply replace our anchor tag with it. In our Airline component, let's start by importing Router and Link from react-router-dom:

filepath: app/javascript/components/Airlines/Airline.js
import { BrowserRouter as Router, Link } from 'react-router-dom'

Then we can use this to replace our anchor tag. We also need to make sure that we use to instead of href.

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'
import { BrowserRouter as Router, Link } from 'react-router-dom'

const Airline = (props) => {
  const {name, image_url, slug} = props.attributes

  return (
    <div className="card">
      <div className="airline-logo">
        <img src={image_url} alt={name} width="50" />
      </div>
      <div className="airline-name">
        {name}
      </div>
      <div className="link-wrapper">
        <Link to={"/" + slug}>View Airline</Link>
      </div>
    </div>
  )
}

export default Airline
Disabling Turbolinks
I've found that with this current setup, after making this change, clicking the link will work correctly, however in some cases clicking the back button will result in a blank page getting rendered instead of going back to the previos page. For me, simply disabling turbolinks fixes this issue immediately.

To disable turbolinks, you can do the following things:

  • remove //= require turbolinks from your assets/application.js
  • remove 'data-turbolinks-track': 'reload' lines from inside of layouts/application.html.erb
  • remove turbolinks from your Gemfile

Now, with our Airline component created, back in Airlines.js let's import our new component and replace our unorganized list. First, we can add Airline to our imports:

filepath: app/javascript/components/Airlines/Airlines.js

import React from 'react'
import axios from 'axios'
import Airline from './Airline'

Then, let's replace this:

filepath: app/javascript/components/Airlines/Airlines.js

const grid = airlines.map( (airline, index) => {
   return (<li key={index} >{airline.data.attributes.name}</li>)
})  

With this:

filepath: app/javascript/components/Airlines/Airlines.js

const grid = airlines.map( (airline, index) => { 
  return (
    <Airline 
      key={index} 
      attributes={airline.data.attributes}
    />
  )
})

And now with this change our updated Airlines component should look like this:

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'
import Airline from './Airline'

const Airlines = () => {
  const [airlines, setAirlines] = useState([])
  
  useEffect( () => {
    axios.get('/api/v1/airlines.json')
    .then( resp => setAirlines(resp.data) )
    .catch( resp => console.log(resp) )
  }, [])

  const grid = airlines.map( (airline, index) => { 
    return (
      <Airline 
        key={index} 
        attributes={airline.data.attributes}
      />
    )
  })

  return (
    <div className="home">
      <div className="header">
        <h1>OpenFlights</h1>
        <p className="subheader">Honest, unbiased airline reviews. Share your experience.</p>
      </div>
      <div className="grid">
        {grid}
      </div>
    </div>
  )
}

export default Airlines

So now if we again fire up our rails server, navigate to localhost:3000, and load the page, we should see that our list now populates with each airline, displaying the name, image url, and a link to each airline's unique slug:

Homepage Airlines List Example

Styled Components

I think this is probably a good point to start styling our homepage. For this, I'm going to use styled-components. Some people don't like styled components. Feel free to do styling your own way if you have a preference. Otherwise feel free to follow along!

Let's start by adding styled components to our project:

yarn add styled-components

Then, let's import styled components into our Airlines component:

filepath: app/javascript/components/Airlines/Airlines.js
import styled from 'styled-components'

The way styled components works is fairly straightfoward. Basically, we can create a new variable that we set equal to styled.tagname``, where tagname is the specific html tag (e.g. div, p, a). Then we can set all of our css inside of the backticks. We can then replace the element in our jsx with the new variable we have created for our styling changes to be applied to that element.

So to get started, let's create a new variable Home that we will use to replace the div tag with class of home currently being used.

const Home = styled.div``

Then, inside of the backticks, I want to add some css to center this element.

filepath: app/javascript/components/Airlines/Airlines.js

const Home = styled.div`
  text-align:center;
`

So now let's go ahead and replace this div in our component with our new Home variable.

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'
import Airline from './Airline'
import styled from 'styled-components'

const Home = styled.div`
  text-align:center;
`

const Airlines = () => {
  ...

  return (
    <Home>
      <div className="header">
        <h1>OpenFlights</h1>
        <p className="subheader">Honest, unbiased airline reviews. Share your experience.</p>
      </div>
      <div className="grid">
        {grid}
      </div>
    </Home>
  )
}

export default Airlines

Now if we reload the homepage, we should see our changes take effect:

Homepage Styling #1

Let's go ahead and also create some constants to style our header and subheader.

filepath: app/javascript/components/Airlines/Airlines.js

  const Header = styled.div``
  const Subheader = styled.p``

Then let's add some simple styling for each.

filepath: app/javascript/components/Airlines/Airlines.js

const Header = styled.div`
  padding:100px 100px 10px 100px;
  
  h1 {
    font-size:42px;
  }
`

const Subheader = styled.p`
  font-weight:300;
  font-size:26px;
`

Note: styled components supports scss, so in the above example you can see I'm applying styles to the h1 inside of our header without needing to actually create a separate variable for it.

Then we can drop these in to replace our html elements in our jsx:

filepath: app/javascript/components/Airlines/Airlines.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'
import Airline from './Airline'
import styled from 'styled-components'

const Home = styled.div`
  text-align:center;
`

const Header = styled.div`
  padding:100px 100px 10px 100px;
  
  h1 {
    font-size:42px;
  }
`

const Subheader = styled.p`
  font-weight:300;
  font-size:26px;
`

const Airlines = () => {
  ...

  return (
    <Home>
      <Header>
        <h1>OpenFlights</h1>
        <Subheader>Honest, unbiased airline reviews. Share your experience.</Subheader>
      </Header>
      <div className="grid">
        {grid}
      </div>
    </Home>
  )
}

export default Airlines

If we again refresh the page, we should see our styling changes now.

Homepage Styling #2

Styling Our Grid

Next up let's style our airlines grid. For this part, I want to create a 4 column grid that we will use to display our airlines. We can do this using css grid. First, let's create a new Grid variable:

filepath: app/javascript/components/Airlines/Airlines.js
const Grid = styled.div``

Then let's add css grid and create a 4 column layout. We can do this by setting the display for our element to grid and setting grid-template-columns to be repeat(4, 1fr). I'm aslo going to set grid-gaps to 20px and add some additional padding:

filepath: app/javascript/components/Airlines/Airlines.js

const Grid = styled.div`
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-gap: 20px;
  width: 100%;
  padding:20px;
`

Then let's replace our div with the class of grid with our new Grid variable.

filepath: app/javascript/components/Airlines/Airlines.js

...
<Home>
  <Header>
    <h1>OpenFlights</h1>
    <Subheader>Honest, unbiased airline reviews. Share your experience.</Subheader>
  </Header>
  <Grid>
   {grid}
  </Grid>
</Home>
...

If we again refresh our homepage now we should be able to see our changes:

Homepage Styling #3

Styling Our Airlines

Recall that each airline displaying in our grid is coming from our Airline component. So I additionally want to add some style into that file as well. Let's jump into Airlines/Airline.js now and start by importing styled components

filepath: app/javascript/components/Airlines/Airline.js
import styled from 'styled-components'

Then I'm going to create a new variable for Card, AirlineLogo, AirlineName, and LinkWrapper:

filepath: app/javascript/components/Airlines/Airline.js

const Card = styled.div``
const AirlineLogo = styled.div``
const AirlineName = styled.div``
const LinkWrapper = styled.div``

Then I'm going to add some css to each of these to style how our data will look for each airline card in our grid:

filepath: app/javascript/components/Airlines/Airline.js


const Card = styled.div`
  border: 1px solid #efefef;
  background: #fff;
  font-family: sans-serif;
`

const AirlineLogo = styled.div`
  height: 50px;
`

const AirlineName = styled.div`
  padding:20px;
`

const LinkWrapper = styled.div`
  margin: 20px 0;

  a {
    color: #fff;
    background-color: #71b406;
    border-radius: 4px;
    padding: 10px 30px;
    cursor: pointer;
    border-radius: 3px;
    border: 1px solid #71b406;
    text-align: center;
    line-height: 20px;
    min-height: 40px;
    margin: 7px;
    font-weight: 600;
    text-decoration: none;
  }
`

Then we again need to replace the html elements in our component with our new variables. So basically we can replace this:

filepath: app/javascript/components/Airlines/Airline.js

...
<div className="card">
  <div className="airline-logo">
    <img src={image_url} alt={name} width="50" />
  </div>
  <div className="airline-name">
    {name}
  </div>
  <div className="link-wrapper">
    <Link to={"/" + slug}>View Airline</Link>
  </div>
</div>
...

With this:

filepath: app/javascript/components/Airlines/Airline.js

...
<Card>
  <AirlineLogo>
    <img src={image_url} alt={name} width="50"/>
  </AirlineLogo>
  <AirlineName>
    {name}
  </AirlineName>
  <LinkWrapper>
    <Link to={"/" + slug}>View Airline</Link>
  </LinkWrapper>
</Card>
...

After adding these changes, our full Airline component at this point should look like this:

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'
import styled from 'styled-components'

const Card = styled.div`
  border: 1px solid #efefef;
  background: #fff;
`

const AirlineLogo = styled.div`
  height: 50px;
`

const AirlineName = styled.div`
  padding:20px;
`

const LinkWrapper = styled.div`
  margin: 20px 0;
  height:50px;

  a {
    color: #fff;
    background-color: #71b406;
    border-radius: 4px;
    padding: 10px 30px;
    cursor: pointer;
    border-radius: 3px;
    border: 1px solid #71b406;
    text-align: center;
    line-height: 20px;
    min-height: 40px;
    margin: 7px;
    font-weight: 600;
    text-decoration: none;
  }
`

const Airline = (props) => {
  const {name, image_url, slug} = props.attributes

  return (
    <Card>
      <AirlineLogo>
        <img src={image_url} alt={name} width="50"/>
      </AirlineLogo>
      <AirlineName>
        {name}
      </AirlineName>
      <LinkWrapper>
        <Link to={"/" + slug}>View Airline</Link>
      </LinkWrapper>
    </Card>
  )
}

export default Airline 

And now if we again refresh our homepage, we should see our changes applied to our airlines grid:

Homepage Styling Airlines Grid

With that set up, I think we are now ready to build out our Airlines#show view.

Building our Airlines#show View


Inside of our Airline folder (singular), let's go now to our Airline.js file. I want to start by setting our default state here. For this, I want our state to have 2 things:

  • airline - This will be an object that we will populate with data from our api for a specific airline. This will also contain our reviews for an airline as an array under the included key in our json.
  • review - will be an object that holds the information for when a user creates and submits a new review for an airline.

Initially, I'm going to set airline and review both to be empty objects:

filepath: app/javascript/components/Airline/Airline.js

import React, { useState, useEffect } from 'react'

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})

  return(
    ...
  )
}

export default Airline

Then, inside of this component, I want to add the useEffect hook. In here we can again use axios to make a request to our api to get the data for a specific airline. Let's start by adding this method to our component:

filepath: app/javascript/components/Airline/Airline.js

import React from 'react'

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})

  useEffect( () =>{
    // add code here
  }, [])

  return(
    ...
  )
}

export default Airline

Then, inside of this method, we can add axios and make a GET request to our api endpoint at /api/v1/airlines/:slug.

Getting A Specific Airline Slug

We need to get the unique airline slug in order to make the request to our api. At this point, the url in the browser should be /:slug. If we add console.log(this.props) to our component when it mounts, we can see that we can get our slug by doing this.props.match.params.slug.

Homepage Styling Airlines Grid

So we should be able to get the specific airline slug using this, and once we do we can build our api request with axios.

filepath: app/javascript/components/Airline/Airline.js

import React, { useState } from 'react'

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})

  useEffect( () => {
    const slug = this.props.match.params.slug
    // Note: you can use string interpolation for this (`api/v1/${slug}`), 
    // my code highlighter is struggling with it so I'm doing this
    const url = '/api/v1/airlines/' + slug

    axios.get(url)
    .then( (resp) => {
      debugger
    })
    .catch( data => {
      debugger
    })
  }, [])

  return(
    ...
  )
}

export default Airline

When we get a successful response back from our api, let's console log the response to see what our data looks like. Add this inside of the .then case for our get request:


axios.get(url)
.then( (resp) => {
  console.log(resp)
})

Then let's jump into our browser, go to the home page of our app, and try clicking on an airline link.

You should see this data being logged to the console if everything is working correctly:

So now, when we get a succesful response from our api, let's update airline in our state by passing in the airline data from our api resp.data. We can do this using setAirline.

filepath: app/javascript/components/Airline/Airline.js

import React from 'react'

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})

  useEffect( () => { 
    const slug = props.match.params.slug
    const url = '/api/v1/airlines/' + slug

    axios.get(url)
    .then( (resp) => {
      setAirline(resp.data)
    })
    .catch( data => {
      debugger
    })
  }, [])

  return(
    ...
  )
}

export default Airline

Building our Airlines#show page layout

So now let's create our page layout with jsx. For my Airlines#show layout, I'm going to split the page into 2 columns. On the left, I want to have information for the airline and a list of existing reviews. Then on the right I want to create a form where you can write a new review. So let's start by adding a wrapper div with two divs inside of it, each will have a class of column.

filepath: app/javascript/components/Airline/Airline.js

<div>
  <div className="column"></div>
  <div className="column"></div>
</div>

In the left column now, I'm going to add another div tag with a class of header. Inside of this I'll add an h1 tag where we will add the airline name. I'll also add an img tag where we will add the airline logo. I'm also going to add a div tag below my header section that will include a list of existing reviews for the airline. For my right column, we will simply add a comment for now to remind us that our review form will go there once we create it.

filepath: app/javascript/components/Airline/Airline.js

<div>
  <div className="column">
    <div className="header">
      <img src="" alt="airline-img" width="50"/>
      <h1>[airline name will go here]</h1>
    </div>

    <div className="reviews">
      [reviews will go here]
    </div>

  </div>
  <div className="column">
    [new review form will go here]
  </div>
</div>

Let's actually move our Header content out to it's own component and then bring it back into Airline.js. I'm going to create a new file inside of the Airline directory and title it 'Header.js'. We can pass down attributes from our airline object in our state as props to this component and then use object destructuring to pull out the name and image_url to use. So I'm going to add the following:

filepath: app/javascript/components/Airline/Header.js
import React from 'react'

const Header = (props) => {
  const {name, image_url} = props.attributes

  return(
    <div className="header">
      <img src={image_url} alt={name} width="50"/>
      <h1>{name}</h1>
    </div>
  )
}

So now we can additionally update Airline to add our Header component like so:

filepath: app/javascript/components/Airline/Airline.js
...
import Header from './Header'
...

return(
  <div>
    <div className="column">
      <Header 
        attributes={airline.data.attributes}
      />
      <div className="reviews">
        [reviews will go here]
      </div>

    </div>
    <div className="column">
      [new review form will go here]
    </div>
  </div>
)

...

At this point, dealing with these nested attributes in our airline object can potentially start to get annoying, as if we try to call airline.data.attributes before we've actually set the airline data from our api request, we will end up getting an error as data won't exist yet.

To avoid this, I'm going to create a loaded boolean value in our state in our Airline component that we can use to let us know when we have the necessary data.

Initially this will be false, and then when we get a successful response from our api and update our airline object in our state, I'm also going to update our loaded object to switch it to true.

filepath: app/javascript/components/Airline/Airline.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})
  const [loaded, setLoaded] = useState(false)

  useEffect( () => {
    const slug = this.props.match.params.slug
    const url = '/api/v1/airlines/' + slug

    axios.get(url)
    .then( (resp) => {
      setAirline(resp.data)
      setLoaded(true)
    })
    .catch( resp => console.log(resp) )
  }, [])

  return(
    <div>
      <div className="column">
        {
          loaded &&
          <Header attributes={airline.data.attributes}/>
        }

        <div className="reviews">
          [reviews will go here]
        </div>

      </div>
      <div className="column">
        [new review form will go here]
      </div>
    </div>
  )
}

export default Airline

Now if we refresh the page, we should see our changes take place.

At this point, so far our full Airline component should look like this:

filepath: app/javascript/components/Airline/Airline.js

import React, { useState, useEffect } from 'react'
import axios from 'axios'
import Header from './Header'

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})
  const [loaded, setLoaded] = useState(false)

  useEffect( () => {
    const slug = this.props.match.params.slug
    const url = '/api/v1/airlines/' + slug

    axios.get(url)
    .then( (resp) => {
      setAirline(resp.data)
      setLoaded(true)
    })
    .catch( resp => console.log(resp) )
  }, [])

  return(
    <div>
      <div className="column">
        {
          loaded &&
          <Header attributes={airline.data.attributes} />
        }

        <div className="reviews">
          [reviews will go here]
        </div>

      </div>
      <div className="column">
        [new review form will go here]
      </div>
    </div>
  )
}

export default Airline

Styling Our Airline Component

I think this is a good point to add some style to our Airline component. Let's start by importing styled components:

filepath: app/javascript/components/Airline/Airline.js
import React, { useState, useEffect } from 'react'
import axios from 'axios'      
import styled from 'styled-components'

Then, let's start by creating a new Column variable that I'll use to create our two column layout.

filepath: app/javascript/components/Airline/Airline.js

const Column = styled.div`
  background: #fff; 
  max-width: 50%;
  width: 50%;
  float: left; 
  height: 100vh;
  overflow-x: scroll;
  overflow-y: scroll; 
  overflow: scroll;
`

Then we can replace the column divs in our jsx with this new variable.

filepath: app/javascript/components/Airline/Airline.js

<div>
  <Column>
    <div className="header">
      <img src={image_url} alt={name} width="50"/>
      <h1>{name}</h1>
    </div>

    <div className="reviews">
      [reviews will go here]
    </div>

  </Column>
  <Column>
    [new review form will go here]
  </Column>
</div>

Now let's additionally add a Header variable. For this I'm going to set some padding, the font-size, and center our content.

filepath: app/javascript/components/Airline/Airline.js

const Header = styled.div`
  padding: 100px 100px 10px 100px;
  font-size: 30px;
  text-align: center;
`

And then let's replace the div with class header in our jsx.

filepath: app/javascript/components/Airline/Airline.js

<div>
  <Column>
    <Header>
      <img src={image_url} alt={name} width="50"/>
      <h1>{name}</h1>
    </Header>

    <div className="reviews">
      [reviews will go here]
    </div>

  </Column>
  <Column>
    [new review form will go here]
  </Column>
</div>

Building Our Review Component

Next up, let's build a Review component. In our Airline folder, let's add a new file and name it Review.js. Then let's import React and create a new functional component.

filepath: app/javascript/components/Airline/Review.js

import React from 'react'

const Review = (props) => {
  return ()
}

export default Review

For this components layout, let's start by adding a div tag to wrap everything. I'll give this tag a class of review-card.

filepath: app/javascript/components/Airline/Review.js

import React from 'react'

const Review = (props) => {
  return (
    <div className="review-card"></div>
  )
}

export default Review

Then inside of it, I'm going to add a div tag with a class of review-title where we will place the title of the review:

filepath: app/javascript/components/Airline/Review.js

import React from 'react'

const Review = (props) => {
  return (
    <div className="review-card">
      <div className="review-title">
        title will go here
      </div>
    </div>
  )
}

export default Review

I also want to add a div with the class review-description for the description:

filepath: app/javascript/components/Airline/Review.js

import React from 'react'

const Review = (props) => {
  return (
    <div className="review-card">
      <div className="review-title">
        title will go here
      </div>
      <div className="review-description">
        description will go here
      </div>
    </div>
  )
}

export default Review

And finally, we need to include a div to wrap the actual score/rating for each review:

filepath: app/javascript/components/Airline/Review.js

import React from 'react'

const Review = (props) => {
  return (
    <div className="review-card">
      <div className="review-title">
        title will go here
      </div>
      <div className="review-description">
        description will go here
      </div>
      <div className="review-rating">
        rating will go here
      </div>
    </div>
  )
}

export default Review

Then let's import styled components and style our Review component. I'm going to start by creating new Card, Title and Description constants.

filepath: app/javascript/components/Airline/Review.js

import React from 'react'
import styled from 'styled-components'

const Card = styled.div``
const Title = styled.div``
const Description = styled.div``

const Review = (props) => {
  return (
    <div className="review-card">
      <div className="review-title">
        title will go here
      </div>
      <div className="review-description">
        description will go here
      </div>
      <div className="review-rating">
        rating will go here
      </div>
    </div>
  )
}

export default Review

Then we can add some simple css styling to each, and then replace the corresponding div tags in our jsx.

filepath: app/javascript/components/Airline/Review.js

import React from 'react'
import styled from 'styled-components'

const Card = styled.div`
  border-radius: 4px;
  border: 1px solid #E6E6E6;
  padding: 20px;
  margin: 0px 0px 20px 0px;
`

const Title = styled.div`
  padding: 20px 0px;
  font-weight: bold;
`

const Description = styled.div`
  padding: 0 0 20px 0;
`

const Review = (props) => {
  return (
    <Card>
      <Title>
        title will go here
      </Title>
      <Description>
        description will go here
      </Description>
      <div className="review-rating">
        rating will go here
      </div>
    </Card>
  )
}

export default Review

Review Props

For our props, we will be passing in a title, description and score for each review. So we can go ahead and replace the placeholder text in our component with these values at this point.

filepath: app/javascript/components/Airline/Review.js

...
const Review = (props) => {
  return (
    <Card>
      <Title>
        {props.title}
      </Title>
      <Description>
        {props.description}
      </Description>
      <div className="review-rating">
        {props.score}
      </div>
    </Card>
  )
}
...

So now, our full Review component should look like this:

filepath: app/javascript/components/Airline/Review.js

import React from 'react'
import styled from 'styled-components'

const Card = styled.div`
  border-radius: 4px;
  border: 1px solid #E6E6E6;
  padding: 20px;
  margin: 0px 0px 20px 0px;
`

const Title = styled.div`
  padding: 20px 0px;
  font-weight: bold;
`

const Description = styled.div`
  padding: 0 0 20px 0;
`

const Review = (props) => {
  return (
    <Card>
      <Title>
        {props.title}
      </Title>
      <Description>
        {props.description}
      </Description>
      <div className="review-rating">
        {props.score}
      </div>
    </Card>
  )
}

export default Review

Back in our Airline component, let's now import our Review component:

filepath: app/javascript/components/Airline/Airline.js
import Review from './Review'

Then, inside of our render method, before return, we can create a new reviews variable and map reviews from our state to our Review component. Similar to what I did in Airlines.js earilier, I'm going to first set reviews to be an undefined variable. Then I want to check to make sure we have at least 1 review in our reviews array. If we do, we will map over the data and pass prop values into our Review component for each review we have in our array. The resulting code will look like this:

filepath: app/javascript/components/Airline/Airline.js

let reviews
if (airlines.included.length > 0) {
  reviews = airlines.included.map( (review, index) => {
    return (
      <Review 
        key={index} 
        title={review.attributes.title} 
        description={review.attributes.description} 
        score={review.attributes.score} 
      />
    )
  })
}

At this point, our full Airline component should look like this:

filepath: app/javascript/components/Airline/Airline.js

import React from 'react'
import axios from 'axios'
import styled from 'styled-components'
import Review from './Review'

const Column = styled.div`
  background: #fff; 
  max-width: 50%;
  width: 50%;
  float: left; 
  height: 100vh;
  overflow-x: scroll;
  overflow-y: scroll; 
  overflow: scroll;
`

const Header = styled.div`
  padding:100px 100px 10px 100px;
  font-size:30px;
  text-align:center;
`

const Airline = () => {
  const [airline, setAirline] = useState({})
  const [review, setReview] = useState({})
  const [loaded, setLoaded] = useState(false)

  useEfect( () => {
    const slug = this.props.match.params.slug
    const url = '/api/v1/airlines/' + slug

    axios.get(url)
    .then( resp => {
      setAirline(resp.data)
      setLoaded(true)
    })
    .catch( resp => console.log(resp))
  }, [])

  let reviews
  if (airlines.included.length > 0) {
    reviews = airlines.included.map( (review, index) => {
      return (
        <Review 
          key={index} 
          title={review.attributes.title} 
          description={review.attributes.description} 
          score={review.attributes.score} 
        />
      )
    })
  }

  return(
    <div>
      <Column>
        { 
          loaded &&
          <Header attributes={airline.data.attributes}/>
        }
        <div className="reviews">
          {reviews}
        </div>
      </Column>
      <Column>
        [review form will go here.]
      </Column>
    </div>
  )
}

export default Airline

Let's actually jump into our rails console now, and let's create some new dummy reviews for one of our airlines. I'm going to use the first airline in our database which should be United Airlines.

rails console

# Let's grab our first airline and create 2 new reviews for it
airline = Airline.first
airline.reviews.create([
  {
    title: 'Great Experience!', 
    description: 'I really enjoyed this airline. Great snacks and high quality service!', 
    score: 5
  },
  {
    title: 'Bad Experience.', 
    description: 'I really did not enjoy this airline. Bad snacks and low quality service!', 
    score: 1
  }
])

Now, if we fire back up our rails server (rails s) and navigate to http://localhost:3000/united-airlines, we should now see our reviews for this airline displaying out onto the page.

Airline With Reviews

Star Rating Component

In the example above, you can see we are currently displaying a number (1-5) to show the score for each review. What I want to do now is instead create a star rating component we can use to replace this. This component will need to be fairly flexible as I want to use it in both our Review component and in the actual Airline component itself in order to show the overall average score for an airline.

In fact, I also want to use this rating component inside of our Airlines component to show the rating for each airline in our grid on the homepage. So for this, I'm actually going to create a new folder in my components folder at the same level as our Airlines and Airline directories. Let's call this folder Rating, and let's also create a new Rating.js file inside of it.

Rating Javascript Component Folder

Then, inside of Rating.js, let's start by importing React and creating a new functional component.

filepath: app/javascript/components/Rating/Rating.js

import React from 'react'

const Rating = (props) => {
  return ()
}

export default Rating

Then, inside of this component, let's create a span tag with another span tag inside of it. I'm going to give the outer span a class of rating-wrapper and the inner span a class of rating:

filepath: app/javascript/components/Rating/Rating.js

import React from 'react'

const Rating = (props) => {
  return (
    <span className="rating-wrapper">
      <span className="rating"><span>
    </span>
  )
}

export default Rating

For this component, I'm going to use a few tricks based off of this codepen to create our star ratings. What we will be doing is passing down the score for a review in our props, and then converting it into a percentage score out of 100. Then we can set that percentage as the width of our inner span element with the class rating.

So let's create a new variable inside of our Rating component that will convert the score for a review from a number into a percentage. Recall that our scoring system is going to be out of 5, so we should be able to divide our score by 5 and then multiply the result by 100 to get the percentage value:

filepath: app/javascript/components/Rating/Rating.js
...
const score = (props.score / 5) * 100
...

And now, we can take our score variable and use it to set the width for our span with the class of rating:

filepath: app/javascript/components/Rating/Rating.js

import React from 'react'

const Rating = (props) => {
  const score = (props.score / 5) * 100

  return (
    <span className="rating-wrapper">
      <span className="rating" <span class="code--highlight">style={{ width: score + "%" }}></span>
    </span>
  )
}

export default Rating

In order for this trick to work, we need to add some css styling to our spans, including using the ::before pseudo-selector. I found this to be a little tricky to get working with styled components, so I'm actually just going to create a separate css file for this component.

Inside of our new Rating folder, let's create a new Rating.css file. Then go ahead and copy and paste in the following css:

filepath: app/javascript/components/Rating/Rating.css

.rating-wrapper {
  position: relative;
  display: inline-block;
}

.rating {
  color: #fcc201;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
  white-space: nowrap;
}

.rating-wrapper::before, .rating::before {
  font-family: "FontAwesome";
  content: "\f005 \0020 \f005 \0020 \f005 \0020 \f005 \0020 \f005";
}

Then, back inside of our Rating.js file, make sure to import this new css file:

filepath: app/javascript/components/Rating/Rating.js

import React from 'react'
import './Rating.css'

const Rating = (props) => {
  const score = (props.score / 5) * 100

  return (
    <span className="rating-wrapper">
      <span className="rating" style={{ width: score + "%" }}></span>
    </span>
  )
}

export default Rating

In order for this trick to work, we will also need to use font awesome. Back inside of layouts/application.html.erb, you can add the font awesome cdn into your html head like so:

filepath: app/views/layouts/application.html.erb
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">

Now, back inside of our Review component, let's import our new Rating component:

filepath: app/javascript/components/Airline/Review.js

import React from 'react'
import styled from 'styled-components'
import Rating from '../Rating/Rating'

Then, where we were before just showing the score, let's now add in our new component and pass in score as a prop.

filepath: app/javascript/components/Airline/Review.js

import React from 'react'
import styled from 'styled-components'
import Rating from '../Rating/Rating'

const Card = styled.div`
  border-radius: 4px;
  border: 1px solid #E6E6E6;
  padding: 20px;
  margin: 0px 0px 20px 0px;
`

const Title = styled.div`
  padding: 20px 0px;
  font-weight: bold;
`

const Description = styled.div`
  padding: 0 0 20px 0;
`

const Review = (props) => {
  return (
    <Card>
      <Title>
        {props.title}
      </Title>
      <Description>
        {props.description}
      </Description>
      <Rating score={props.score}/>
    </Card>
  )
}

export default Review

Finally, if you now fire back up your server (rails s) and again navigate to localhost:3000/united-airlines, where we previously had reviews with a numbered score of 5 and 1, you should now see a 5 star rating and a 1 star rating!

Star Reviews

Getting The Average Score For Our Airlines

We should be able to re-use this rating component we just created to set the overall average score for an airline as well. However, in order to do so we will first need to modify our AirlineSerializer back in the rails side of our app.

Recall that earlier when we built our Airline model, we added an avg_score method to get the average score for an airline. Let's now add this method to our list of attributes in our AirlineSerializer to expose this value in our api for our React components to use.

filepath: app/serializers/airline_serializer.rb

class AirlineSerializer
  include FastJsonapi::ObjectSerializer
  attributes :name, :slug, :image_url, :avg_score
  has_many :reviews
end

Now, when we make requests to our rails api for airlines endpoints, we should have an additional attribute in our airline data of avg_score. We can of course test this out from our rails console. Let's jump into our console and grab the first airline record in our database and then pass it into our serializer to test this out.

rails console

airline = Airline.first

AirlineSerializer.new(airline).as_json
=> {
  "data"=> { 
    "id" => "563",
    "type" => "airline",
    "attributes" => {
      "name" => "United Airlines",
      "slug" => "united-airlines",
      "image_url" => "https://open-flights.s3.amazonaws.com/United-Airlines.png",
      "avg_score" => 3.0
    }, 
    "relationships" => {
      "reviews" => {
        "data" => [
          {"id" => "5", "type" => "review"}, 
          {"id" => "6", "type" => "review"}
        ]
      }
    }
  }
}

So now back in our Header component, let's import our Rating component:

filepath: app/javascript/components/Airline/Header.js

import React from 'react'
import Rating from '../Rating/Rating'

Then, inside of our Header component, we can add our Rating component, passing in our avg_sore value that we should now have within attributes:

filepath: app/javascript/components/Airline/Header.js
import React from 'react'
import Rating from '../Rating/Rating'

const Header = (props) => {
  const {name, image_url, avg_score} = props.attributes

  return(
    <div className="header">
      <img src={image_url} alt={name} width="50"/>
      <h1>{name}</h1>
      <Rating score={average}/>
    </div>
  )
}

With this change, if we again navigate to localhost:3000/united-airlines, underneath the airlines name and logo we should now see another star rating that displays the overall average rating for the airline.

Show With Star Reviews

We can make a similar change in our Airline#index view with a few tiny tweaks. Inside of our Airlines/Airlines.js file, we should now have avg_score value getting passed down within our attributes to each airline component for our grid. Let's import our Rating component and add it to our component:

filepath: app/javascript/components/Airlines/Airline.js

import React from 'react'
import styled from 'styled-components'
import { BrowserRouter as Router, Link} from 'react-router-dom'
import Rating from '../Rating/Rating'

const Card = styled.div`
  border: 1px solid #efefef;
  background: #fff;
`

const AirlineLogo = styled.div`
  height: 50px;
`

const AirlineName = styled.div`
  padding:20px;
`

const LinkWrapper = styled.div`
  margin: 20px 0;
  height:50px;

  a {
    color: #fff;
    background-color: #71b406;
    border-radius: 4px;
    padding: 10px 30px;
    cursor: pointer;
    border-radius: 3px;
    border: 1px solid #71b406;
    text-align: center;
    line-height: 20px;
    min-height: 40px;
    margin: 7px;
    font-weight: 600;
    text-decoration: none;
  }
`

const Airline = (props) => {
  const { image_url, name, slug, avg_score } = props.attributes

  return (
    <Card>
      <AirlineLogo>
        <img src={image_url} alt={name} width="50"/>
      </AirlineLogo>
      <AirlineName>
        {name}
      </AirlineName>
      <Rating score={avg_score} />
      <LinkWrapper>
        <Link to={"/" + slug}>View Airline</Link>
      </LinkWrapper>
    </Card>
  )
}

export default Airline

And now, if we reload the homepage of our website we should see the average rating displaying for all of our airlines:

Show With Star Reviews

Creating Our Reviews Form

Next up, we want to create a component for our review form to create new reviews for an airline. Inside of my Airline folder, I'm going to create a new ReviewForm.js file and add the basic structure for the form I want to create. This will be a form with inputs for the review title and description as well as a score, which for now will just be place holder, but in a moment we will add star ratings.

Additionally, for each input, I want to add a handleChange method, and I also want to add a handleSubmit method on the form element for when the form is submitted. So here's what all of this will look like initially:

filepath: app/javascript/components/Airline/ReviewForm.js

import React from 'react'

const ReviewForm = (props) =>{
  return (
    <div>
      <form onSubmit={props.handleSubmit}>
        <div>Have An Experience with {props.name}? Add Your Review!</div>
        <div>
          <input onChange={props.handleChange} type="text" name="title" placeholder="Review Title" value={props.review.title}/>
        </div>
        <div>
          <input onChange={props.handleChange} type="text" name="description" placeholder="Review Description" value={props.review.description}/>
        </div>
        <div>
          <div>
            <div>Rate This Airline</div>
            <div>
              [Rating options will go here.]
            </div>
          </div>
        </div>
        <button type="Submit">Create Review</button>
      </form>
    </div>
  )
}

export default ReviewForm

For the star rating peice of our form, I'm going to try to acheive this for now by building an array of all the possible scores and then mapping over it to create a set of input options with the corresponding score. I'm also going to import Fragment to help me achieve the structure I want:


import React, { Fragment } from "react";
import styled from 'styled-components'

const ReviewForm = (props) => {
  const ratingOptions = [5,4,3,2,1].map((score, index) => {
    return (
      <Fragment key={index}>
        <input type="radio" value={score} checked={props.review.score == score} onChange={()=>console.log('onChange')} name="rating" id={`rating-${score}`}/>
        <label onClick={props.setRating.bind(this, score)}></label>
      </Fragment>
    )
  })

  ...

  return (
    ...
      {ratingOptions}
    ...
  )
}

export default ReviewForm

For a more in-depth interactive example, check out the codepen this approach will be based on.

Then, I want to import styled components and use it to style this form. I'm not going to dive into the details of all the style I'm adding here, but the important part to pay attention to is the styling of the input and label elements inside of RatingBox, which will set up our star ratings in the form. Here's what my full component looks like after all of these changes:


import React, { Fragment } from "react";
import styled from 'styled-components'

const RatingContainer = styled.div`
  text-align: center;
  border-radius: 4px;
  font-size:20px;
  padding: 40px 0 10px 0;
  border: 1px solid #e6e6e6;
  margin: 20px 0;
  padding:20px;
  background: #fff;
`

const RatingBox = styled.div`
  background: #fff;
  display: flex;
  width: 100%;
  justify-content: center;
  overflow: hidden;
  flex-direction: row-reverse;
  position: relative;
  input { display: none; }
  label {
    cursor: pointer;
    width: 40px;
    height: 40px;
    margin-top: auto;
    background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='126.729' height='126.73'%3e%3cpath fill='%23e3e3e3' d='M121.215 44.212l-34.899-3.3c-2.2-.2-4.101-1.6-5-3.7l-12.5-30.3c-2-5-9.101-5-11.101 0l-12.4 30.3c-.8 2.1-2.8 3.5-5 3.7l-34.9 3.3c-5.2.5-7.3 7-3.4 10.5l26.3 23.1c1.7 1.5 2.4 3.7 1.9 5.9l-7.9 32.399c-1.2 5.101 4.3 9.3 8.9 6.601l29.1-17.101c1.9-1.1 4.2-1.1 6.1 0l29.101 17.101c4.6 2.699 10.1-1.4 8.899-6.601l-7.8-32.399c-.5-2.2.2-4.4 1.9-5.9l26.3-23.1c3.8-3.5 1.6-10-3.6-10.5z'/%3e%3c/svg%3e");
    background-repeat: no-repeat;
    background-position: center;
    background-size: 76%;
    transition: .3s;
  }
  input:checked ~ label, input:checked ~ label ~ label {
    background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='126.729' height='126.73'%3e%3cpath fill='%23fcd93a' d='M121.215 44.212l-34.899-3.3c-2.2-.2-4.101-1.6-5-3.7l-12.5-30.3c-2-5-9.101-5-11.101 0l-12.4 30.3c-.8 2.1-2.8 3.5-5 3.7l-34.9 3.3c-5.2.5-7.3 7-3.4 10.5l26.3 23.1c1.7 1.5 2.4 3.7 1.9 5.9l-7.9 32.399c-1.2 5.101 4.3 9.3 8.9 6.601l29.1-17.101c1.9-1.1 4.2-1.1 6.1 0l29.101 17.101c4.6 2.699 10.1-1.4 8.899-6.601l-7.8-32.399c-.5-2.2.2-4.4 1.9-5.9l26.3-23.1c3.8-3.5 1.6-10-3.6-10.5z'/%3e%3c/svg%3e");
  }
  input:not(:checked) ~ label:hover,
  input:not(:checked) ~ label:hover ~ label {
    background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' width='126.729' height='126.73'%3e%3cpath fill='%23d8b11e' d='M121.215 44.212l-34.899-3.3c-2.2-.2-4.101-1.6-5-3.7l-12.5-30.3c-2-5-9.101-5-11.101 0l-12.4 30.3c-.8 2.1-2.8 3.5-5 3.7l-34.9 3.3c-5.2.5-7.3 7-3.4 10.5l26.3 23.1c1.7 1.5 2.4 3.7 1.9 5.9l-7.9 32.399c-1.2 5.101 4.3 9.3 8.9 6.601l29.1-17.101c1.9-1.1 4.2-1.1 6.1 0l29.101 17.101c4.6 2.699 10.1-1.4 8.899-6.601l-7.8-32.399c-.5-2.2.2-4.4 1.9-5.9l26.3-23.1c3.8-3.5 1.6-10-3.6-10.5z'/%3e%3c/svg%3e");
  }
`

const Field = styled.div`
  border-radius: 4px;
  input {
    width: 96%;
    min-height:50px;
    border-radius: 4px;
    border: 1px solid #E6E6E6;
    margin: 12px 0;
    padding: 12px;
  }
  
  textarea {
    width: 100%;
    min-height:80px;
    border-radius: 4px;
    border: 1px solid #E6E6E6;
    margin: 12px 0;
    padding: 12px;      
  }
`

const SubmitBtn = styled.button`
  color: #fff;
  background-color: #71b406;
  border-radius: 4px;   
  padding:12px 12px;  
  border: 1px solid #71b406;
  width:100%;
  font-size:18px;
  cursor: pointer;
  transition: ease-in-out 0.2s;
  &:hover {
    background: #71b406;
    border-color: #71b406;
  }
`

const ReviewWrapper = styled.div`
  background:white;
  padding:20px;
  margin-left: 15px;
  border-radius: 0;
  padding-bottom:80px;
  border-left: 1px solid rgba(0,0,0,0.1);
  height: 100vh;
  padding-top: 100px;
  background: black;
  padding-right: 80px;
`

const ReviewHeadline = styled.div`
  font-size:20px;
  padding: 15px 0;
  font-weight: bold;
  color: #fff;
`

const RatingBoxTitle = styled.div`
  font-size: 20px;
  padding-bottom: 20px;
  font-weight: bold;
`

const ReviewForm = (props) =>{
  const ratingOptions = [5,4,3,2,1].map((score, index) => {
    return (
      <Fragment key={index}>
        <input type="radio" value={score} checked={props.review.score == score} onChange={()=>console.log('onChange')} name="rating" id={`rating-${score}`}/>
        <label onClick={props.setRating.bind(this, score)}></label>
      </Fragment>
    )
  })

  return (
    <ReviewWrapper>
      <form onSubmit={props.handleSubmit}>
        <ReviewHeadline>Have An Experience with {props.name}? Add Your Review!</ReviewHeadline>
        <Field>
          <input onChange={props.handleChange} type="text" name="title" placeholder="Review Title" value={props.review.title}/>
        </Field>
        <Field>
          <input onChange={props.handleChange} type="text" name="description" placeholder="Review Description" value={props.review.description}/>
        </Field>
        <Field>
          <RatingContainer>
            <RatingBoxTitle>Rate This Airline</RatingBoxTitle>
            <RatingBox>
              {ratingOptions}
            </RatingBox>
          </RatingContainer>
        </Field>
        <SubmitBtn type="Submit">Create Review</SubmitBtn>
      </form>
    </ReviewWrapper>
  )
}

export default ReviewForm

In our Airline component, we need to create handleChange and handleSubmit methods that we can pass down to our ReviewForm. The handleChange method will update attributes of the review in our state. The handleSubmit will cover submitting the review data in a post request via our api to create a new review.

Let's start by creating our handleChange method:


handleChange = (e) => {
  e.preventDefault()

  setReview(Object.assign({}, review, {[e.target.name]: e.target.value}))
}

This will update each field of the review in our state as it is modified in the form.

For our handleSubmit method, let's start by adding this method, which will take the event as an argument, and preventing the default action:


handleSubmit = (e) => {
  e.preventDefault()
}

Then, we can use axios to create a new POST request to our review create endpoint which will be /api/v1/reviews. We can pass along the review from our state and the airline id this review is for as our parameters:


handleSubmit = (e) => {
  e.preventDefault()

  const airline_id = airline.data.id
  axios.post('/api/v1/reviews', { ...review, airline_id })
  .then( (resp) => {})
  .catch( resp => console.log(resp))
}

Then, inside of our success case after making our POST request, we can add the newly created review to our array of reviews we already have under the included key in our airline data:


handleSubmit = (e) => {
  e.preventDefault()

  const airline_id = airline.data.id
  axios.post('/api/v1/reviews', { ...review, airline_id })
  .then( (resp) => {
      const included = [ ...airline.included, resp.data.data ]
      setAirline({ ...airline, included })
  })
  .catch( resp => console.log(resp))
}

Now, back in our Airline component we need to import our ReviewForm, add it into our jsx and pass in the corresponding props:


import ReviewForm from './ReviewForm'

...
  return(
    <Wrapper>
      <Column>
        <Main>
          <Header 
            image_url={image_url}
            name={name}
            reviews={airline.included}
            average={average}
          />
          {reviews}
        </Main>
      </Column>
      <Column>
        <ReviewForm
          name={name}
          review={review}
          handleChange={handleChange}
          handleSubmit={handleSubmit}
          setRating={setRating}
         />
      </Column>
    </Wrapper>
  )
...

This blog post is still being written. Check back soon!