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
.
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:
/* 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:
// 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:
<!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:
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:
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.
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:
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:
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:
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
.
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
:
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
:
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:
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:
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:
<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:
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:
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).
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:
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:
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:
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:
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:
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:
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.
...
<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.
...
<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:
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:
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
:
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.
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:
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
.
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!
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:
import React from 'react'
We can structure our initial Airline
component like so:
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:
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"
:
<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:
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:
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.
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:
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
:
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
.
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 yourassets/application.js
- remove
'data-turbolinks-track': 'reload'
lines from inside oflayouts/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:
import React from 'react'
import axios from 'axios'
import Airline from './Airline'
Then, let's replace this:
const grid = airlines.map( (airline, index) => {
return (<li key={index} >{airline.data.attributes.name}</li>)
})
With this:
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:
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:
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:
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.
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.
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:
Let's go ahead and also create some constants to style our header and subheader.
const Header = styled.div``
const Subheader = styled.p``
Then let's add some simple styling for each.
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:
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.
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:
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:
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.
...
<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:
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
import styled from 'styled-components'
Then I'm going to create a new variable for Card
, AirlineLogo
, AirlineName
, and LinkWrapper
:
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:
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:
...
<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:
...
<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:
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:
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 theincluded
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:
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:
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
.
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.
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
.
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
.
<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.
<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:
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:
...
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
.
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:
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:
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.
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.
<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.
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.
<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.
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
.
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:
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:
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:
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.
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.
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.
...
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:
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:
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:
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:
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
.
# 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.
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.
Then, inside of Rating.js
, let's start by importing React and creating a new functional component.
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
:
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:
...
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
:
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:
.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:
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:
<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:
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.
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!
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.
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.
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:
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:
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.
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:
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:
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:
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!