In my previous article I tried to create an application that interacts with an existing GraphQL API to fetch some film data based on user query.
Let's see if I can extend the app's functionality a little so that it does some more stuff for us, by being more than just a bare bones "search and see" tool.
What I'd like to add is a system that would enable users to write reviews for movies and rate them. That means I'd need a backend to save those user data. That can easily be done with Slash GraphQL which gives us a backend with a /graphql
endpoint. I'll show how one can be deployed shortly. There's a free tier available so you can just go here, sign up, upload a schema that accurately represents your API and you'd have backend ready to be used.
So here's how the app should behave after I'm done with the new implementations:
- Just like before, we can search by a word or term for a movie, the results would appear in a table with all the films and their directors
- Clicking on a movie that we'd like write a review about would take us to someplace where we can type in a username, give that movie a rating, write our review and hit that satisfying submit button...
- Our submission details would get added to the database. There would be a separate page/route where we can see all the review details.
Alright then, let's start writing some code!
The Schema
It's obvious that I need to add (and store) some information to a database considering the second point above. In GraphQL terms, that's called mutation. A mutation would be run each time a user hits that submit button and the details would get written to our database.
Secondly, since I'm hoping that the app would nicely show all the information that are currently in the database, I need to "fetch" (no not the fetch
API!) them. You can smell it right? Yeah, I'm talking about sending "queries" to our database...
So I need a schema to define exactly what "types" of information would constitute my backend. One of the coolest things about Slash GraphQL is that all I need to do in order to have a working API, is to do just that: creating a schema. The rest is taken care of automatically; I'd have a fully-working GraphQL service that can accept queries, mutations and all that stuff.
Here's the schema:
type User {
username: String! @id
posted_reviews: [Review] @hasInverse(field: posted_by)
}
type Review {
id: ID!
text: String!
rating: Int!
posted_by: User!
reviewed_film: FilmData @hasInverse(field: reviews)
}
type Film @remote {
id: ID!
name: String!
directed_by: [Director!]!
}
type FilmData {
id: String! @id
reviews: [Review]
data: Film @custom(
http: {
url: "https://play.dgraph.io/graphql"
method: "POST"
forwardHeaders: ["Content-Type"]
graphql: "query($id: ID!) { getFilm(id: $id) }"
skipIntrospection: true
}
)
}
type Director @remote {
name: String!
id: ID!
}
type Query {
getMovieNames(name: String): [Film] @custom(
http: {
url: "https://play.dgraph.io/graphql"
method: "POST"
forwardHeaders: ["Content-Type"]
graphql: "query($name: String!) { queryFilm(filter: {name: {alloftext: $name}}) }"
skipIntrospection: true
}
)
}
Let's break it down by each type:
User
type
The User
type is for us users. The fields inside the user type (or object) defines the properties/attributes of that object. In this case, each user would have a username
and some reviews that he/she's written about films.
The username
is a String
type which is a built-in scalar type of the GraphQL query language; beside String
you have Int
for integers, float
for floating-point values and so on. It's obvious that they're pretty much the same thing as the primitive data types various programming language offer. Each type ultimately represents actual valid data so that makes sense.
The exclamation mark indicates that the field is non-nullable, which means that the API would always give a value when I query for a user's username
.
@id
is called a directive that says that each username is going to be unique and hence will be used as an ID of that user.
The posted_reivews
field is an array of Review
types (which I'll discuss next): this field signifies the fact that a user has written some reviews that is accessible by querying for this field.
@hasInverse
is another directive establishes connection between a review and the posted_by
field of the Review
type, in both directions. What this means is that I'm associating a review with the the user who wrote it. Since it establishes a bi-directional edge between two nodes, I can also get from a review to the person who wrote it. This is neat; remember that a GraphQL API can give you quite the flexibility on how you set up your data and able to interact with them. This directive is a neat proof of that.
It isn't a native GraphQL thing though, but rather provided by Dgraph. You can look at the other directives that Dgraph supports here.
Review
type
This type represents a user's reviews. So what fields does it contain?
- The
id
field that just attaches a unique identifier (theID
is another default scalar type of GraphQL) to each review - The
text
field is the textual content of the review, which is of course aString
-
Rating
represents the rating given to a film by a user (my app would employ a 5-star rating system), which would be an integer -
posted_by
field, as I told before, is for associating a review with a user. We're representing users under theUser
type right? So that's the value of this field - Lastly,
reviewed_film
represents which film the review is about. I'm associating it with thereviews
field of theFilmData
type. This would become clearer when I talk about that field, but basically doing this would enable me to get info about the reviewed film, like its name and director.
Now the the juicy stuff begins. Notice that I need to work with two kinds of dataset here corresponding to two GraphQL APIs: one that is "remote", i.e. the information that I'd get from the remote server (https://play.dgraph.io/graphql), and the other that's going to reside in the app's own database. My app is using remote data for processing. We need to establish connection between that and what the users would supply (the usernames, ratings and reviews), since after processing I'm storing the final result in our backend by running mutations; I'd also need the ability to run useful queries. So I'm talking about a kind of "combination" of data, part of which comes from "outside" the app, part of which is the result of user interaction with that outside data.
Let's discuss about the next types and discuss how they're going to play the key role in this scenario
Film
type
This is a remote type, indicated by the @remote
directive, meaning that this field represents data that comes from somewhere else, not the native API this schema is belongs to. You guessed it right, this type is for holding the data fetched from the remote Dgraph server. We have to write our own resolver for this type, since it's a remote one.
The fields are pretty obvious; name
is for the film name, and id
is an associated unique ID. Notice the field directed_by
has the value [Director!]!
. The outer exclamation mark means the same thing: the field is non-nullable, i.e. I can always expect an array of Director
objects, with zero or more items. The Director!
being also non-nullable, ensures that each item of this array is going to be a Director
object. It being a remote type, Director
is also going to be the of the same type.
FilmData
type
This is the type inside whicn I'm going to be establishing a connection between our local data and the remote one. Notice that this doesn't have any @remote
attached, so this would get stored in our Dgraph backend.
First I have the id
field which is a String
and also works as a unique identifier.
Then there's the reviews
field that we saw in the previously discussed Review
type where I established a two-way edge between this and the reviewed_film
node. This would enable me to do a query like the following:
queryReview {
reviewed_film {
id
data {
name
}
reviews {
posted_by {
username
}
id
rating
text
}
}
}
So I'd be able to get all reviews of each film in our database.
In fact, this would be the exact query that I use later to implement a route where the app would show all the reviews arranged by films.
Since a film might have multiple reviews by multiple users, here I've defined an array of Review
objects as the value.
The data
field is the "custom" field, where we write our resolver for the remote Film
type, making a connection between the remote data and local. The syntax is quite understandable; an http POST request would send a graphql
call to the the remote https://play.dgraph.io/graphql
by id
(which I'm going to supply from within the app based on what film the user selected, as we'll see soon). The result would be a JSON response object with data matching the fields of the Film
type. As you can see from the above query structure, I can access that through this custom data
field. Hence I've effectively established my desired connection; basically I now have a node that holds a copy of my remote data so I can traverse it for meaningful queries.
Director
type
This, as I mentioned, is also a remote type and part of Film
that represents the director's name and ID.
Query
type
This is the type responsible for managing the search functionality of the app. Let's go over that again a bit more:
- We would type in a word or term, which is just a
String
, and a query should be fired towards the remote server, fetching all the film's whose names contain our search term. - The response would consist of the film names and their directors' names. I also need to get the IDs of those films since I need that for the custom
data
field ofFilmData
.
I give the query a name, getMovieNames
(this is the name I'd use inside our app to fire the query, with variables that would hold the user's search term, just like we saw in the first version of the app), which has an argument called name
, which is a String
, corresponding to the search term . We've already seen the remote Film
type that contains fields that would suit our needs for the response we're hoping to get. So that's what I use here; we might get multiple results, which means I have to use an array of Film
objects, and hence I use [Film]
. In the graphql
field of the HTTP request object, I pass in the search term using the variable name
and define the custom query.
Deploying a backend
With the schema ready, it just needs to be uploaded to Slash GraphQL to get a production-ready service up and running.
First we need to head over to https://slash.dgraph.io. There'll be a a log in/sign up page.
After registering, we're presented with the following:
Just click on the Launch a New Backend button.
As you can see there's a free tier available. Just give your backend a name and click on Launch.
Soon you'll have a live backend ready to be used. Note down your endpoint (which as you can see is given a randomly unique name; I'm particularly feeling good about this one...) since that's where the app would be making all the requests.
You can later access it though from the Overview section of your sidebar on the top-left, along with some other statistics about your service.
Now to upload the schema, click on Create your Schema.
Paste it inside the area and hit Deploy. That's it, you're done setting up our backend. You can now calmly just focus on building your application.
In case you want to feast your eyes on all the goodies Slash auto-generated from the schema to serve all your needs, you can download the generated schema, by clicking on the Schema section of the sidebar, as shown below:
The UI
The UI needs to be customized to account for the new functionalities. There are going to be two new components:
AddReviews
ShowReviews
The first one is where we can submit our review details and the second one is where the app will show all of the reviews. These are going to be implemented by two routes using React Router.
So let's install it:
npm install --save react-router-dom
I'm going to set up the routes in the App.js
file so let's import the necessary modules for that:
import {
BrowserRouter as Router,
useHistory,
Route } from "react-router-dom";
And the new components too:
import AddReviews from "./Components/Pages/AddReviews";
import ShowReviews from "./Components/Pages/ShowReviews";
Now let's set up those two routes:
<Route path="/add-reviews/:movieid/:moviename">
<AddReviews />
</Route>
<Route path="/reviews">
<ShowReviews />
</Route>
The add-reviews
route would serve the AddReviews
component and reviews
would serve ShowReviews
. Now when using React router in a React app, the return
body of App.js
needs to be wrapped in Router
, which I imported earlier. Also, I'm going to designate /
to indicate my app's home page. Notice that the home page, i.e. the App
component itself renders multiple components: Container
, UserInput
and MaterialTable
. These can be conceived as children of the parent component App
. In this scenario, it makes sense to use something called React.Fragment
to wrap all of them. What this basically does is that no extra nodes aren't created in the DOM; it's just one component App
. You can find out more about fragments here.
So the return
body looks like this:
return (
<Router>
<div>
<Header />
<Route
exact
path="/"
render={() => (
<React.Fragment>
<br></br>
<Container maxWidth="xs" style={getContainerStyle}>
<Typography
variant="h5"
style={{ marginTop: 50, marginBottom: 50 }}
>
Enter a film name or phrase:
</Typography>
<UserInput
handleInputChange={handleInputChange}
handleSubmit={handleSubmit}
/>
</Container>
<MaterialTable
title=""
columns={[
{
title: "Name",
field: "name",
headerStyle: {
backgroundColor: "#A5B2FC",
},
},
{
title: "Director",
field: "director",
headerStyle: {
backgroundColor: "#A5B2FC",
},
},
]}
// TODO: should add a progress bar or skeleton
data={dataForRender}
options={{
search: true,
actionsColumnIndex: -1,
headerStyle: {
backgroundColor: "#A5B2FC",
},
}}
actions={[
{
icon: () => <BorderColorIcon />,
tooltip: "Write a review",
// just using the window object to take to that route
// with the movie ID and name passed for running mutation
onClick: (event, rowData) =>
(window.location.pathname =
"/add-reviews/" +
rowData.id +
"/" +
rowData.name.split(" ").join("-")),
},
]}
style={{ margin: "5rem" }}
></MaterialTable>
</React.Fragment>
)}
></Route>
{/* we need some dynamic part in our URL here */}
<Route path="/add-reviews/:movieid/:moviename">
<AddReviews />
</Route>
<Route path="/reviews">
<ShowReviews />
</Route>
</div>
</Router>
);
You'll notice that I didn't place Header
inside the fragment. That's because it's a fixed stateless component that is going to be rendered every time in all of the routes. Also, I've used Material UI's typography instead of plain HTMLh5
just as a design sugar; we could do just as well with a plain <h5>Enter a film name or phrase:</h5>
like before. Typography
can be imported with the following:
import Typography from "@material-ui/core/Typography";
I'm using URL parameters (the one's starting with the colon, i.e. movieid
and moviename
) to make the movie ID and name available in AddReviews
page. The ID is going to be necessary in mutation and the moviename
is strictly for displaying a text saying what film the user is writing a review of.
Also, it'd be nice if there were navigation links in the application header so that we can go back and forth from the reviews page to our home page.
That can be done easily by tweaking our Header
component a bit.
First I need to import the following:
import { Link } from "react-router-dom";
I need two navigation links to navigate to two places: Home and Reviews corresponding to the route /
and reviews
. So inside the Toolbar
I add the following:
<Link id="navlink" to="/">
Home
</Link>
<Link id="navlink" to="/reviews">
Reviews
</Link>
Below is our tweaked return
body:
return (
<AppBar position="static">
<Toolbar className="header-toolbar">
<h2>Film Information</h2>
<Link id="navlink" to="/">
Home
</Link>
<Link id="navlink" to="/reviews">
Reviews
</Link>
</Toolbar>
</AppBar>
);
A bit of CSS styling on Toolbar
is involved here, in index.js
:
.header-toolbar {
display: flex;
flex-direction: row;
justify-content: flex-start;
/* background-color: #828fd8; */
color: white;
}
.header-toolbar #navlink {
margin-left: 3em;
color: white;
text-decoration: none;
}
And here's the Header
in all its new glories:
Also, in index.js
, I need to replace the uri
field of the ApolloClient
constructor object with the new backend for my app that Slash GraphQL deployed for me:
const APOLLO_CLIENT = new ApolloClient({
uri: "https://hip-spring.us-west-2.aws.cloud.dgraph.io/graphql",
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
queryFilm: {
merge(_ignored, incoming) {
return incoming;
},
},
},
},
},
}),
});
So, requests of every kind would now go there instead of what the app previously had, https://play.dgraph.io/graphql
.
Let's go back and take a look at the return
body of App.js
.
We need a way so that upon clicking on a film the user is taken to the AddReviews
component to write a review for that particular film. That's what I do that with the actions
prop of MaterialTable
:
actions={[
{
icon: () => <BorderColorIcon />,
tooltip: "Write a review",
// just using the window object to take to that route
// with the movie ID and name passed for running mutation
onClick: (event, rowData) => (window.location.pathname =
"/add-reviews/" +
rowData.id +
"/" +
rowData.name.split(" ").join("-")),
},
]}
actions
is just going to be another column in the table. Each row is basically a clickable icon, which is given through the icon property, the value of which is just a component for the icon. Upon hovering, a tooltip is going to give the user a useful prompt.
BorderColorIcon
is imported like this:
import BorderColorIcon from "@material-ui/icons/BorderColor";
I add an onClick
event handler that would take us to the add-reviews
route while adding the film ID corresponding to the row the user clicked on to the URL, along with the film name (the film name is just for the UI it won't play any role in the logic). So here we've basically set up a dynamic URL routing for our app! Cool isn't it?
After all this the table looks like the following after a search:
Let's look at the two components now.
AddReviews
This component is all about mutations. Basically there are going to be two mutations: one where I'd add info about the film that's getting a review written about, and the other are review details--rating and review text. Now, taking into the fact that a film already has a review by a user, that film's data is already in the database so I just need to run mutation for the review. So I set up two constants for each of the scenarios:
const ADD_REVIEW = gql`
mutation($review: AddReviewInput!) {
addReview(input: [$review]) {
review {
text
rating
posted_by {
username
}
reviewed_film {
id
data {
name
id
}
}
}
}
}
`;
const ADD_FILMDATA_AND_REVIEW = gql`
mutation($filmData: [AddFilmDataInput!]!, $review: AddReviewInput!) {
addFilmData(input: $filmData) {
filmData {
id
data {
name
id
}
}
}
addReview(input: [$review]) {
review {
text
rating
posted_by {
username
}
reviewed_film {
id
data {
name
id
}
}
}
}
}
`;
ADD_REVIEW
is just for adding a review, while the other is going to add film data too, in case that film doesn't already exist in the database. Notice that AddFilmDataInput
and AddReviewInput
are GraphQL input types automatically generated by Dgraph based on the schema, representing the local types FilmData
and Review
, corresponding to the variables $filmData
and $review
. $filmData
would need to be supplied with the film ID that we pass from the home page to this component by the dynamic URL. $review
, you guessed it right, would hold the review details. These are inputs for mutations represented as objects, by those two types AddFilmDataInput
and AddReviewInput
. Naturally one would have to write them on his/her own, but since I'm using Dgraph, I don't have to. That's another load out of my mind...
Wait... how would I know whether a film is present in my database and make the decision of running either one of those two mutations? I guess I have to check by ID by running a query. If I get a null
response back, that means there are no films with that ID, i.e. I have to run ADD_FILMDATA_AND_REVIEW
; otherwise, ADD_REVIEW
.
Here's the query I'd need:
const CHECK_FILM_ID = gql`
query($id: String!) {
getFilmData(id: $id) {
id
}
}
`;
I set it up using Apollo's userQuery
hook, just like the search function of App.js
:
const { loading, error, data } = useQuery(CHECK_FILM_ID, {
variables: { id: movieid },
});
Now I set up the states for the review details that would be submitted by the user:
const [reviewText, setReviewText] = useState("");
const [userName, setUserName] = useState("");
const [userRating, setUserRating] = useState(0);
Next up is getting an executable mutation using Apollo's useMutation
hook, a counterpart of the useQuery
hook:
const [addFilmDataAndReview] = useMutation(ADD_FILMDATA_AND_REVIEW);
const [addReview] = useMutation(ADD_REVIEW);
I need four event handlers for keeping track of what the user enters as username, rating, review text and not to mention the submission handler...
// event handlers
const handleReviewChange = (event) => setReviewText(event.target.value);
const handleNameChange = (event) => setUserName(event.target.value);
const handleRatingChange = (event) => setUserRating(event.target.value * 1);
const handleSubmit = (event) => {
event.preventDefault();
// we add filmData only if that film doesn't already exist
if (data.getFilmData === null) {
addFilmDataAndReview({
variables: {
filmData: [
{
id: movieid,
},
],
review: {
text: reviewText,
rating: userRating,
posted_by: {
username: userName,
},
reviewed_film: {
id: movieid,
},
},
},
});
} else {
addReview({
variables: {
review: {
text: reviewText,
rating: userRating,
posted_by: {
username: userName,
},
reviewed_film: {
id: movieid,
},
},
},
});
}
// TODO: timeout could be removed
setTimeout(() => (window.location.pathname = "/"), 1000);
};
I check for a null
response and let the app decide what mutation to run based on that.
Go back and take a look the addFilmData
mutation again; the value of the variable $filmData
looks like an array of AddFilmDataInput
, right? So notice how I'm supplying it as a GraphQL variable here, as an array which contains the movie ID as object's key-value pair. I supply the movie ID as the value of a variable called movieid
, which is none other than the dynamic part of the URL that contains it. That, and moviename
, are easily accessible by using the useParams
hook of React Router that extracts the URL parameters. I store that in the variable movieid
. It can be imported with:
import { useParams } from "react-router-dom";
And then I can get get the params using:
let { movieid, moviename } = useParams();
The rest is pretty straightforward, I have all the user inputs stored in state variables so I'm using them to give the variables their necessary values.
After the mutations have been run, I redirect back to the home page, that is /
. The setTimeout
is just for debugging purposes in case something goes wrong and this would allow me to see the error screen before the URL changes.
Next, to set up the necessary "fields" for the user to submit his review, I import the following components from the material-ui
package:
import TextField from "@material-ui/core/TextField";
import TextareaAutosize from "@material-ui/core/TextareaAutosize";
import Button from "@material-ui/core/Button";
import Radio from "@material-ui/core/Radio";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import FormLabel from "@material-ui/core/FormLabel";
import RadioGroup from "@material-ui/core/RadioGroup";
The return
body of AddReviews
looks like the following:
return (
<div className="container">
<Typography variant="h4" style={getPageHeaderStyle}>
Write your review of <em>{movieName}</em>
</Typography>
<Container maxWidth="xs" style={getContainerStyle}>
<form
className={styleClass.root}
noValidate
autoComplete="off"
onSubmit={handleSubmit}
>
<div>
<TextField
label="Username"
required
value={userName}
onChange={handleNameChange}
/>
<div className="rating-input">
<FormLabel component="legend" required>
Rating
</FormLabel>
<RadioGroup
aria-label="movie-rating"
name="rating"
value={userRating.toString()}
onChange={handleRatingChange}
>
<FormControlLabel value="1" control={<Radio />} label="1" />
<FormControlLabel value="2" control={<Radio />} label="2" />
<FormControlLabel value="3" control={<Radio />} label="3" />
<FormControlLabel value="4" control={<Radio />} label="4" />
<FormControlLabel value="5" control={<Radio />} label="5" />
</RadioGroup>
</div>
<TextareaAutosize
id="review-textarea"
required
aria-label="review-text"
rowsMin={10}
placeholder="Review..."
onChange={handleReviewChange}
/>
</div>
<div>
<Button
type="submit"
variant="contained"
color="primary"
style={{ marginTop: 20 }}
>
Submit
</Button>
</div>
</form>
</Container>
</div>
);
I need to make moviename
displayable as a space separated string:
let movieName = moviename.split("-").join(" ");
All this, as I said before, is just for displaying a nice header that says what film is getting reviewed.
Next is just plain HTML form
, inside which I make use of the components that I imported earlier. TextField
is where one types in his/her username, a bunch of radio buttons for the 5-star rating system, a re-sizable textarea for where we write our thoughts on the film, and finally the submit button. The container works just like before, placing the whole thing at the centre of the page.
So, after clicking on a film, the user gets greeted with this page:
ShowReviews
This component renders all the information stored in the database, arranged by films, i.e. for each film I show all the reviews submitted by various users.
Here's the query that gets the job done (it's the same as I mentioned when we discussed the schema):
const GET_REVIEWS = gql`
query q2 {
queryReview {
reviewed_film {
id
data {
id
name
}
reviews {
posted_by {
username
}
rating
text
}
}
}
}
`;
I don't need to explicitly define any state here though, because each time this page is accessed the query would automatically be run and the data we're rendering through the return
body would change accordingly. So the following is pretty standard stuff:
function ShowReviews() {
const { loading, error, data } = useQuery(GET_REVIEWS);
if (loading) {
return <CircularProgress />;
} else if (error) {
console.log(error);
return (
<Alert severity="error">
<AlertTitle>Error</AlertTitle>
Sorry, something might not be working at the moment!
</Alert>
);
}
return (
<div className="review-content">
<Typography id="page-title" variant="h2" align="center">
Reviews
</Typography>
{/* map over to render the review details */}
{data.queryReview.map((content) => (
<div id="review-details">
<Typography variant="h4" align="left">
{content.reviewed_film.data.name}
</Typography>
<Divider />
<br></br>
{content.reviewed_film.reviews.map((reviewObj) => (
<Typography variant="subtitle2" align="left">
{reviewObj.posted_by.username}
<Typography variant="subtitle1" align="left">
Rating: {reviewObj.rating}
</Typography>
<Typography variant="body1" align="left">
{reviewObj.text}
</Typography>
<br></br>
<Divider light />
<br></br>
</Typography>
))}
</div>
))}
</div>
);
}
I just use JavaScript's map
method to iterate over the the JSON response tree and render the details.
And Divider
is just a Material UI component that's nothing but HTML's <hr>
tag under the hood, strictly for decorative purposes so that the "Reviews" are a bit nicely displayed.
This is how the page looks:
Here's a GIF showing the flow of the app:
Conclusions
Whew! That was a lot of work wasn't it? But Dgraph took most of the pains away; I just had to focus on the data my app would be handling and how that could be represented by a GraphQL schema. "Thinking in terms of graph" is a saying that goes when building something with GraphQL. I just had to do that; when those pieces are put together and a couple of types
are nicely defined in my schema, I just needed to deploy it using Slash GraphQL and I had a working API up and running that could handle my data perfectly and allow me to use it however I chose. The rest is just JavaScript and some rudimentary front-end tooling.
Another rewarding experience that can be taken from here is that this a pretty close experiment that gives a peek at a real-world application that functions by handling remote and local data. We're using utilities like that everyday, and through this small app, this has been a gentle introduction into the whole orchestration of a large-scale app.
You can check out the entire code of this project that lives on the repo here.
Top comments (0)