⬅️ Return to course website

Lab 1: Nodes

Released: September 16th, 4:00pm ET
Due: September 23rd 3:00pm ET (Before Content Creation Lab)

Introduction

You’re overwhelmed by the vast collection of donuts available. You’re lost, and unfortunately there is no way to traverse the universe of donuts. You’re unable to manage the mess of hyperdonuts and find yourself lost in hyperspace! Looks like you’ll need to come up with a means of orienting yourself to find the most scrumptious donut.

In the Nodes unit, you will create a node file system - a standard way of managing and navigating between Hypertext nodes. At the end, you will have built a full stack application that will allow users to create, read, delete, and move Hypermedia nodes.

Lab Objective: The objective for this lab is to explore the MERN stack from frontend to backend. You will go through the entire process of connecting to a MongoDB database, creating and inserting a node into the Mongo database, using Express middleware to make calls between the frontend and backend, and lastly visualizing the node’s content and other relevant information as a React component in your web application.

Checklist

Accepting the Assignment

Accept the assignment here. Once you’ve accepted your GitHub repo, clone it locally into your desired root folder - and you should be ready to get going!

For Lab 1 and Assignment 1 you will be using the same nodes-<yourGitHubUsername> repository.

Demo

You can find a demo of the MyHypermedia application here.

Note: It is entirely possible that there will be some unexpected behavior in this application as it is not set up to handle concurrency properly. This means that the application might behave weirdly if multiple users are interacting with the demo at the same time.

Background Information

More Typescript (.ts) / Javascript

These are some important Typescript / Javascript technologies that you will come across during the lab. We understand that they can be confusing at first, so please ask if anything doesn’t make sense once you use it!

What are HTTP Requests?

The Hypertext Transfer Protocol (HTTP) is designed to enable communication between clients and servers. An HTTP request is made by a client, to a named host, which is located on a server. The aim of the request is to access a resource on the server.

In this assignment, you will use four different types of HTTP requests, they are as follows:

HTTP Request Explanation Example (let us say the URL/base endpoint is https://example.com)
GET Used for retrieving data https://example.com/node/get/:nodeid
POST Used for create methods https://example.com/node/create
PUT Used for update or replace methods https://example.com/node/move/:nodeId/:newParentId
DELETE Used for deletion of data. https://example.com/node/:nodeid

URL params: The : indicates that the following value is a URL parameter. In other words, :nodeId is a placeholder that indicates a nodeId should be placed in its stead.

Note: The definitions and use-cases for each of these request types are flexible. There are industry standards and guidelines but at this stage, we don’t need to know anything more than what is shown in the table above.

What is a Promise<T>?

JavaScript can have asynchronous functions that don’t return a value immediately, but instead return a Promise that they will eventually return a value. Sounds a bit tricky – let’s see some examples of asynchonous behavior.

What is “asynchronous”?

Simply put, it means that JavaScript can move on to the next line of code before the previous line finishes executing. Here is an example:

console.log('first print') // print to console setTimeout(() => console.log('second print'), 3000) // wait for 3 seconds console.log('third print') // print to console

If you run this snippet of code in an editor, the console would produce the following message:

first print third print second print

When we tell the JavaScript to hold off for 3 seconds to print the second print, JavaScript didn’t wait. JavaScript added it into its “task list”, marked it with execute this task 3 seconds later, and went ahead to execute the third task, which is printing third print. third print got printed immediately, and that’s why second print comes after the third print. With the same reasoning, do you think the following snippet would achieve it’s goal?

// takes 2s to return a list of GitHub repos in String const apiResult = fetch('https://api.github.com/repos') // printing it to console console.log(apiResult)

In fact, it doesn’t do what we want. Instead, it printed out
[object Promise] { ... } in the console. Why? Becuase JavaScript didn’t wait, and printed out whatever apiResult was before the fetch completed. You see, apiResult is a Promise - Promises are effectively a wrapper around the actual return type, denoting that JavaScript is expecting a object to be returned, and promises that the object will either be returned (resolved), or it will raise an error (rejected). It takes time to get the Promise resolved. In TypeScript, we denote a promise by Promise<T>, where T refers to some generic type (could be a string, object, INode, etc.).

In the next section, we will teach you how to print the API result properly. If you have time, you can learn more about the JavaScript event loop: What the heck is the event loop anyway?

async function & await keyword

async and await allow you to write asynchronous code the same way you write synchonous code

async function

First of all we have the async keyword, which you put in front of a function declaration to turn it into an async function.

Example of an async function vs regular function

Regular function:

const donutPun = (): string => { return "I donut know any puns..." }; donutPun();

async Typescript function:

const donutPun = async (): Promise<string> => { return "I donut know any puns..." }; } donutPun();

This is one of the traits of async functions — their return values are guaranteed to be converted to promises. In the second example, the return value would be of type Promise<string>. So the async keyword is added to functions to tell them to return a promise rather than directly returning the value.

await keyword

The advantage of an async function only becomes apparent when you combine it with the await keyword. await only works inside async functions.

await can be put in front of any async promise-based function to pause your code on that line until the promise fulfills, then return the resulting value.

Adding await in front of a function, for example when we are retrieving from the database, is essentially telling JavaScript to wait until you have a response from the promise.

Now, you should know how to properly print the data that the GitHub API returns:

function fetchAPI = async () => { // with async/await, JavaScript will not execute the next line // until the fetch is completed const apiResult = await fetch('https://api.github.com/repos') // printing the correct result! console.log(apiResult) }

Architecture

This diagram shows the hierarchy of our full stack application. The interactions between each of the components of our system may seem complex at first, so refer to this diagram when implementing different parts of the assignment.

Disclaimer: You will only have to implement the orange bordered sections when implementing this lab. In Assignment 1, you will be creating your own Mongo database!

Types

In the src folder on both the server and client you will find a types folder. This contains all of the interfaces that our hypermedia system will use.

Note: We will be using a naming convention where all typescript interfaces are prefaced with an I for interface. This helps code readability when you have both the interface and the implementation of that interface referenced in one file.

INode

A node is a generic representation of content in our hypertext corpus. In this assignment, we will only be implementing a generic node service which manages the location of nodes in a file tree, and supports basic content for text and image nodes. To be clear, we are making a notable design decision by choosing to organize nodes via a file tree. This isn’t the only option when building a hypertext system, but it is what you will be implementing in this assignment. At its core, any file tree is just a hierarchy of nodes. Let’s start with how we are defining “node” in the context of a our Hypertext corpus:

interface INode { title: string // user created node title type: NodeType // type of node that is created content: string // the content of the node nodeId: string // guid which contains the type as a prefix filePath: IFilePath // the location of node in file tree dateCreated?: Date // optional creation date }

The ? in dateCreated? means that it is an optional field in the INode interface. This means that an INode object can either have that field or not.

IFilePath

The file path type is a simple interface which allows you to easily access an INode’s location. This is important, as you will frequently interact with file paths. Here is the definition for the IFilePath interface:

// Note: all these strings are `nodeId`s interface IFilePath { path: string[] children: string[] }

Example:

a root node with nodeId root who has two children, child1 and child2 would have the following filePath:

{ path: ['root'], children: ['child1, child2'] }

and the child1 node will have the following filePath:

{ path: ['root', 'child1'], children: [] }

The children array will only contain the nodeIds of their immediate children - which excludes grandchildren or great grandchildren.

IServiceResponse

The IServiceResponse is an interface we’ve created for this course to make it easier to understand if a function succeeds or fails. If a function fails, the ServiceResponse will be a failureServiceResponse that contain an error message. If it succeeds, the ServiceResponse will be a successfulServiceResponse that contains a payload of type T, which is a generic type that could be anything (eg. string, INode, etc.) indicated in the angular brackets.

The IServiceResponse interface enables us to easily determine if a function succeeded, and if it fails, to pass an error message to the calling function.

interface IServiceResponse<T> { success: boolean; message: string; payload: T; }

Here we see that if createNode() executes successfully, it will return a ServiceResponse with a payload of the created node (of type INode).

function createNode(node: INode): Promise<IServiceResponse<INode>>;

Backend

Backend File Structure

Once you cd into the server folder, you will find the following file structure.

NodeRouter.ts

On the backend, you will be using the Express router found in NodeRouter.ts to receive HTTP requests from the frontend, make corresponding changes to the database, and give the frontend updated data to show the user. Once receiving an HTTP request, the NodeRouter will call on an appropriate method in NodeGateway.

Note: NodeRouter listens to all HTTP requests to /node, therefore MyRouter.post('/create', async...{}) actually corresponds to /node/create. For a more detailed explanation, please look at server/src/app.ts to see how NodeRouter is configured.

NodeGateway.ts

NodeGateway handles requests from NodeRouter, and calls on methods in NodeCollectionConnection to interact with the database. It contains the complex logic to check whether the request is valid, before modifying the database.

For example, before insertion, NodeGateway.createNode() will check whether the database already contains a node with the same nodeId, as well as the the validity of node’s file path. The NodeCollectionConnection.insertNode() method, on the otherhand, simply receives the node object, and inserts it into the database.

NodeCollectionConnection.ts

NodeCollectionConnection acts as an in-between communicator between the nodes collection in the MongoDB database and NodeGateway. NodeCollectionConnection defines methods that interact directly with MongoDB. That said, it does not include any of the complex logic that NodeGateway has.

For example, NodeCollectionConnection.deleteNode() will only delete a single node. Whereas NodeGateway.deleteNode() deletes all its children from the database as well.

Note: In the backend part of this lab you will implement /node/create in NodeRouter.ts, createNode in NodeGateway.ts and insertNode in nodeCollectionConnection.ts. This is to give you a better understanding of the Express, Node.js and MongoDB stack end-to-end. In Assignment 1 however, to make the workload more managable, you will only be required to implement methods in NodeGateway.ts as it handles the majority of the logic.

Step 1: Connect Backend to MongoDB

To permanently store our Hypermedia nodes such that they can be accessed using our web application no matter what computer they are on, we need to use a database service. In this class, we will be using MongoDB to store our data.

For this lab, you will be connecting to a database that we have created for you - a database that is shared with everyone in the course. In Assignment 1, you will create and deploy your own MongoDB database!

TODO:

  1. cd into your server directory and add a .env file such that its filepath is: server/.env
  2. Copy and paste the following text into this newly created .env file:
DB_URI = 'mongodb+srv://student:cs1951v@cs1951v-cluster.tjluq.mongodb.net/cs1951v-database?retryWrites=true&w=majority'
PORT=5000
TSC_COMPILE_ON_ERROR=true
ESLINT_NO_DEV_ERRORS=true

So what did we just do with this .env file? Well, to connect to a database, MongoDB requires a URI that contains the unique address of the database, a username and password. This way, only those with the correct login details and unique database address can access a database. DB_URI in this .env file is that URI - ‘student’ is the username, ‘cs1951v’ is the password and ‘@cs1951v-cluster.tjluq.mongodb.net/cs1951v-database.’ is our database’s unique address. We store this information in a .env file so that this information does not lie in source code, code that is pushed online for potentially many to see. We use the dotenv node package to access the URI stored in this .env. Note, we have added .env to our .gitignore file so that private security details are not pushed online.

Step 2: Verify that Stencil Code Runs

Before we start writing backend code, it’s important check if your backend server runs:

TODO:
Run the following yarn scripts to install the relevant dependencies and start the server:

yarn install && yarn start

To verify that your server is running, if you go to http://localhost:5000 in your browser where you should see MyHypermedia Backend Service. If you don’t see this message appear in your browser, please reach out to a TA!

Backend Implementation Roadmap

In this lab, you will be implementing the functionality for creating a node, starting with the backend and then finally rendering it as a React component on the frontend. The image below is a high level overview of how we will create a node on the backend.

In this lab, we want to insert both a text and image node into our database describing the most delicious donut in the world (debatable), the Boston Cream Donut.

Step 3: Creating an INode JSON Object

Based on the INode interface that we introduced above, this is how a node like this would look when written as an object:

{ "node": { "title": "Boston Cream Donut", "nodeId": "image.u1deL6", "type": "image", "content": "https://bakerbynature.com/wp-content/uploads/2021/08/Boston-Cream-Donuts-1-1-of-1.jpg", "filePath": { "path": [ "image.u1deL6" ], "children": [] } } }

Note: nodeId is normally a randomly generated globally unique id (guid) on the client side; In this course, we use image.<guid> to represent some such unique id on an image node.

TODO:

  1. Following the example above, create a INode object with "type": "text" (in an empty file), the content of it could be anything you would like, for example, it could be a bio that explains the Boston Cream Donut. The JSON should be formatted in the same way as the Boston Cream Donut INode shown above.
    • Generate an 8 character unique ID here to be used as the new guid.
  2. Give the Boston Cream Donut INode above a new guid such that:
    • "nodeId": "image.<new-guid>"
    • "filePath": {"path": ["image.<new-guid>"], ...}

Keep ahold of these JSON objects in a text file, on paper, or anywhere that is most convenient for you - we will be needing them soon.

So why did we just create these random JSON objects? Well, servers and clients communicate with each other over HTTP requests and responses all the time. Sometimes, they need to send data in the form of objects and we oftentimes use the JSON (JavaScript Object Notation) format to represent this data.

In this lab, we will be sending our backend these 2 JSON objects attached to separate HTTP requests. Our backend server will then read this data and convert these JSON objects into a proper INode object in our TypeScript environment.

Now, onto creating those HTTP requests to send to our backend!

Step 4: HTTP requests with Postman

Postman is a free tool which helps developers run and debug API requests - in other words, it simulates a client and send HTTP requests to our NodeRouter. This is a quick and easy way to make HTTP requests when you don’t have a client set up yet and want to interact with your backend server.

TODO:

  1. Create an account
  2. Download the Postman desktop app.
  3. Sign in to the Postman desktop app with the account you just created.
  4. Navigate to My Workspaces, create a new collection called cs1951v and add a new request to this collection. Then can copy the image INode JSON object from Step 3 and ensure your Postman setup looks as follows…
    • Note: remember to have changed the guid and have the nodeId and filePath fields reflect this change. Go back to TODO #2 in Step 3 if you have not done so yet.

Your Postman should be set up such that the following is true:

TODO:
Once everything is set up - click Send. This will send the HTTP POST request to the backend server that you are running locally at localhost:5000.

Oh no! You received a FailureServiceResponse object,[NodeRouter] node/create not implemented, move on to Step 5 to make sure that our server accepts the POST request!

Step 5: NodeRouter.ts

To implement the post method on the Express router for /node/create navigate to server/src/nodes/NodeRouter.ts. You can see that nearly all methods in the file take in two parameters, res and req:

As we saw in Postman (Step 4), we are sending a POST request to http://localhost:5000/node/create. Therefore we need to set up our router to listen to a POST request at http://localhost:5000/node/create:

MyRouter.post('/create', async (req: Request, res: Response) => { })

Note: NodeRouter listens to all HTTP requests to /node, therefore MyRouter.post('/create', async...{}) actually corresponds to /node/create. For a more detailed explanation, please look at server/src/app.ts to see how NodeRouter is configured.

Now that we have the router listening to the proper route, let’s read the JSON object that was attached to our Postman’s POST request:

MyRouter.post('/create', async (req: Request, res: Response) => { const node = req.body.node })

Using console.log() to verify that node has been received:

  1. insert console.log(node) after line 2
  2. start the server with cd server, yarn install and yarn start
  3. send the POST request via Postman, you should see the JSON object appear like this:
{ title: 'Boston Cream Donut', nodeId: 'image.guidasdf', type: 'image', content: 'https://bakerbynature.com/wp-content/uploads/2021/08/Boston-Cream-Donuts-1-1-of-1.jpg', filePath: { path: [ 'image.guidasdf' ], children: [] } }

Awesome! Our HTTP POST request sent via Postman was received and our NodeRouter has succesfully parsed the JSON INode object attached to the POST request.

Now let’s check to see if node is of type node. We use isINode() to do this.

MyRouter.post('/create', async (req: Request, res: Response) => { const node = req.body.node if (!isINode(node)) { // we want to send a failure status here } else { // we want to call a nodeGateway method to here help us // create and insert the node } })

Let’s fill in the HTTP status code into our response.

MyRouter.post('/create', async (req: Request, res: Response) => { const node = req.body.node if (!isINode(node)) { // 400 Bad Request res.status(400).send('not INode!') } else { // 200 OK res.status(200).send('is INode!') } })

Now, if the node is in the correct shape, we should call on NodeGateway method to insert it into database.

MyRouter.post('/create', async (req: Request, res: Response) => { const node = req.body.node console.log('[NodeRouter] create') if (!isINode(node)) { res.status(400).send('not INode!') } else { // call `NodeGateway` method const response = await this.nodeGateway.createNode(node) res.status(200).send(response) } })

Almost there - to make sure our router doesn’t fail on unexpected errors, let’s add a try/catch to make sure (try/catch is a common programming pattern that allows catching and handling errors).

MyRouter.post('/create', async (req: Request, res: Response) => { try { const node = req.body.node console.log('[NodeRouter] create') if (!isINode(node)) { res.status(400).send('not INode!') } else { const response = await this.nodeGateway.createNode(node) res.status(200).send(response) } } catch (e) { // 500 Internal Server Error res.status(500).send(e.message) } })

For our NodeRouter methods we want to use a try/catch block so we can catch any uncaught errors and return that error message to the frontend. Note, for all caught errors, our backend should return a failureServiceResponse.

Note: failureServiceResponse is a method that we have created in IServiceResponse.ts to return an IServiceResponse object where success: false. The same is true for successfulServiceResponse except that success: true.

Please have a look at server/src/types/IServiceResponse.ts for the actual schema of IServiceResponse objects.

Step 6: NodeGateway.ts

Now that we have written /create for NodeRouter and called NodeGateway.createNode(), let’s move onto the next step and write NodeGateway.createNode().

We start with an empty method:

async createNode(node: any): Promise<IServiceResponse<INode>> { return failureServiceResponse( 'TODO: [NodeGatway] createNode is not implemented' ) }

First, we want to check if our argument is of type INode. As a rule of thumb, each method is responsible for error checking its own inputs. Let’s return a failureServiceResponse with an appropriate failure message if node is not a valid INode.

async createNode(node: any): Promise<IServiceResponse<INode>> { // check if node is valid INode const isValidNode = isINode(node) if (!isValidNode) { return failureServiceResponse('Not a valid node.') } return null }

Next, we want to check if a node with node.nodeId already exists in the database. We don’t want duplicate nodeIds in the database. Let’s use NodeCollectionConnection’s findNodeById() method which has already been written for us to verify if nodeId already exists in the database. We return a failureServiceResponse if a duplicate already exists.

async createNode(node: any): Promise<IServiceResponse<INode>> { const isValidNode = isINode(node) if (!isValidNode) { return failureServiceResponse('Not a valid node.') } // check whether nodeId already in database const nodeResponse = await this.nodeCollectionConnection.findNodeById( node.nodeId ) if (nodeResponse.success) { return failureServiceResponse( 'Node with duplicate ID already exist in database.' ) } return failureServiceResponse( 'TODO: [NodeGatway] createNode is not implemented' ) }

The nodes are organized in a tree structure such that root nodes contain child nodes that then contain other child nodes, etc. This means that when we are creating a node, we want to make sure that the parent we will attach this new node exists. We can access the node’s parentId by indexing node.filePath.path[nodePath.path.length - 2]. Note that root nodes will not have a parentId. Let’s put this into code:

async createNode(node: any): Promise<IServiceResponse<INode>> { const isValidNode = isINode(node) if (!isValidNode) { return failureServiceResponse('Not a valid node.') } const nodeResponse = await this.nodeCollectionConnection.findNodeById( node.nodeId ) if (nodeResponse.success) { return failureServiceResponse( 'Node with duplicate ID already exist in database.' ) } // check if parent exists const nodePath = node.filePath // if node is not a root if (nodePath.path.length > 1) { const parentId = nodePath.path[nodePath.path.length - 2] const parentResponse = await this.nodeCollectionConnection.findNodeById( parentId ) if (!parentResponse.success) { return failureServiceResponse('Node has invalid parent') } } return failureServiceResponse( 'TODO: [NodeGatway] createNode is not implemented' ) }

Now if our node’s filePath disagrees with its parent’s filePath, we want to send a failure response:

async createNode(node: any): Promise<IServiceResponse<INode>> { const isValidNode = isINode(node) if (!isValidNode) { return failureServiceResponse('Not a valid node.') } const nodeResponse = await this.nodeCollectionConnection.findNodeById( node.nodeId ) if (nodeResponse.success) { return failureServiceResponse( 'Node with duplicate ID already exist in database.' ) } const nodePath = node.filePath if (nodePath.path.length > 1) { const parentId = nodePath.path[nodePath.path.length - 2] const parentResponse = await this.nodeCollectionConnection.findNodeById( parentId ) // if parent is not found, or parent has different file path if ( !parentResponse.success || parentResponse.payload.filePath.path.toString() !== nodePath.path.slice(0, -1).toString() ) { return failureServiceResponse('Node has invalid parent / file path.') } } return failureServiceResponse( 'TODO: [NodeGatway] createNode is not implemented' ) }

Now we need to add node.nodeId to the parent’s children field.

async createNode(node: any): Promise<IServiceResponse<INode>> { const isValidNode = isINode(node) if (!isValidNode) { return failureServiceResponse('Not a valid node.') } const nodeResponse = await this.nodeCollectionConnection.findNodeById( node.nodeId ) if (nodeResponse.success) { return failureServiceResponse( 'Node with duplicate ID already exist in database.' ) } const nodePath = node.filePath if (nodePath.path.length > 1) { const parentId = nodePath.path[nodePath.path.length - 2] const parentResponse = await this.nodeCollectionConnection.findNodeById( parentId ) if ( !parentResponse.success || parentResponse.payload.filePath.path.toString() !== nodePath.path.slice(0, -1).toString() ) { return failureServiceResponse('Node has invalid parent / file path.') } // add nodeId to parent's filePath.children field parentResponse.payload.filePath.children.push(node.nodeId) const updateParentResp = await this.updateNode( parentResponse.payload.nodeId, [{ fieldName: 'filePath', value: parentResponse.payload.filePath }] ) if (!updateParentResp.success) { return failureServiceResponse( 'Failed to update parent.filePath.children.' ) } } return failureServiceResponse( 'TODO: [NodeGatway] createNode is not implemented' ) }

And finally, once all checks have been completed and the parent node’s filePath.children field has been updated, we can insert this new node into the database using NodeCollectionConnection.insertNode(). We then return the resulting response so NodeCollectionConnection’s response is passed onto NodeRouter.

async createNode(node: any): Promise<IServiceResponse<INode>> { const isValidNode = isINode(node) if (!isValidNode) { return failureServiceResponse('Not a valid node.') } const nodeResponse = await this.nodeCollectionConnection.findNodeById( node.nodeId ) if (nodeResponse.success) { return failureServiceResponse( 'Node with duplicate ID already exist in database.' ) } const nodePath = node.filePath if (nodePath.path.length > 1) { const parentId = nodePath.path[nodePath.path.length - 2] const parentResponse = await this.nodeCollectionConnection.findNodeById( parentId ) if ( !parentResponse.success || parentResponse.payload.filePath.path.toString() !== nodePath.path.slice(0, -1).toString() ) { return failureServiceResponse('Node has invalid parent / file path.') } parentResponse.payload.filePath.children.push(node.nodeId) const updateParentResp = await this.updateNode( parentResponse.payload.nodeId, [{ fieldName: 'filePath', value: parentResponse.payload.filePath }] ) if (!updateParentResp.success) { return failureServiceResponse( 'Failed to update parent.filePath.children.' ) } } // if everything checks out, insert node const insertNodeResp = await this.nodeCollectionConnection.insertNode(node) return insertNodeResp }

Great! We just finished NodeGateway.createNode(). Let’s move onto NodeCollectionConnection.insertNode().

Step 7: NodeCollectionConnection.ts

Last thing we need to do in the backend, is to interface with the database! Let’s start by checking whether the input is a valid INode.

async insertNode(node: INode): Promise<IServiceResponse<INode>> { // check to see that node is valid INode if (!isINode(node)) { return failureServiceResponse( 'Failed to insert node due to improper input ' + 'to nodeCollectionConnection.insertNode' ) } }

After this, let’s call the relavant MongoDB function, and return the inserted document on success.

async insertNode(node: INode): Promise<IServiceResponse<INode>> { if (!isINode(node)) { return failureServiceResponse( 'Failed to insert node due to improper input ' + 'to nodeCollectionConnection.insertNode' ) } // call mongodb function to insert node and return the inserted node const insertResponse = await this.client .db() .collection(this.collectionName) .insertOne(node) if (insertResponse.insertedCount) { return successfulServiceResponse(insertResponse.ops[0]) } return failureServiceResponse( 'Failed to insert node, insertCount: ' + insertResponse.insertedCount ) }

Step 8: Run Backend Test

Great! So now that we have fully implemented our system’s create node functionality, let’s run some unit tests to see if our code works as intended.

Backend Tests: There are two types of tests: system tests and unit tests.

TODO:

  1. Run cd server and yarn labtest in your terminal to see whether it passes the tests!

If all tests pass, you are all set on the backend! Now let’s move onto the frontend portion of this lab.

Frontend

Introduction

Now that we have a working backend that accepts INode objects from HTTP requests, we should make sure that our client is able to send those HTTP requests.

Once we have a way of creating nodes, we will be implementing two simple React components in this lab to introduce a few key concepts: props, rendering, and components as functions.

Note: We assume a basic level of familarity with HTML going forward.

If you have no experience, we recommend approaching a TA who will be happy to point you towards additional resources!

Frontend Folder Structure

The blue elements are what you will be working on in this lab and assignment. You can search for a file in VSCode using Cmd+P (Mac) or Ctrl+P (Windows).

React

React is a frontend Javascript library for building isolated and state-aware UI components. This makes it ideal for building high performance modern web applications!

Some examples of webpages that you have all used that use React include:

Component Breakdown

A Component is one of the core building blocks of React. In other words, we can say that every application you will develop in React will be made up of pieces called components. Components make the task of building UIs much easier. You can see a UI broken down into multiple individual pieces called components and work on them independently and merge them all in a parent component which will be your final UI.

The beauty of React is that when we change the state of a Component, rather than re-rendering the entire UI’s state, it only updates the Component’s state. This is what we mean by Component isolation.

In this image we have broken down the React UI Components of the MyHypermedia Web App that you will be implementing.

In our stencil code, each React component is organized into a folder. Let us say we had a Donut component. Then it would be a folder called Donut which contains the following files:

Filename Case Style Description
Donut.tsx PascalCase Handles the rendering of the component itself and the creation of the JSX object.
donutUtils.ts/.tsx camelCase Code that does not depend on useState or useEffect is factored into a utils file.
Donut.scss PascalCase SCSS file with everything relevant to the Donut component that is rendered.
index.ts camelCase File that exports all of the relevant functions & components to avoid lengthy and messy import calls. (eg. import { oreo } from '../../../../../../../donuts/chocolate/oreo')

Verify that your frontend loads

First we should add the .env to your client folder so that ESLint works as we expect it to! It should look as follows:

TSC_COMPILE_ON_ERROR=true
ESLINT_NO_DEV_ERRORS=true

Before we start writing frontend code, it’s important to check that your client runs as expected.

TODO:
Run the following yarn scripts to install the relevant dependencies and start the React services:
cd client
yarn install && yarn start

To verify that your React service is running, if you go to https://localhost:3000 in your browser then you should see the client web app that we have built MyHypermedia. If the web app does not load, please shout “TAaaa!!” (in Slack or in CIT219)

Step 1: createNode in frontend NodeGateway.ts

We want to easily send and retrieve data from our database using our frontend app. Users will interact with data through buttons, forms etc. and we need a way to retrieve data. For that we will use a frontend NodeGateway to make HTTP requests.

We use Axios to make our HTTP requests, they are all contained in requests.ts. For createNode we want to use a POST method since we are sending data from the client to the server!

createNode: async (node: INode): Promise<IServiceResponse<INode>> => { try { /** * Make a HTTP POST request which includes: * - url: string * - data: the data attached to the HTTP request */ const url:string = ?? const body:any = ?? return await post<IServiceResponse<INode>>(url, body) } catch (exception) { return failureServiceResponse('[createNode] Unable to access backend') } }

Now we need to define the arguments that we are passing into the post request:

Finally, we arrive at the final method, which returns a IServiceResponse Promise.

createNode: async (node: INode): Promise<IServiceResponse<INode>> => { try { const url: string = baseEndpoint + servicePath + '/create' const body: any = { node: node } return await post<IServiceResponse<INode>>(url, body) } catch (exception) { return failureServiceResponse('[createNode] Unable to access backend') } }

We can now access that from anywhere in our client to make an API request to create a node!

import NodeGateway from '../../nodes/NodeGateway' const nodeResponse = await NodeGateway.createNode(newNode)

You can look into the createNodeFromModal method in createNodeUtils.ts to see an example!

Step 2: ReactHooks & opening the CreateNodeModal

Before we do this step, we will introduce two very important hooks in React!

We will be using Functional Components with React Hooks in this course as opposed to the traditional Class Components in React. We do this because Functional Component requires much less boilerplate code, and is now the new industry standard.

React Hooks

First, let’s talk about React Hooks. If you are not familiar with it, the name really doesn’t explain anything at all, but we hope you will gradually like it as you get to know it more.

You can identify a React Hook by its names - they usually have use as a prefix to their name. Essentially, React Hooks deal with manipulating the state, or the value, of the variable. With hooks, React will automatically rerender (update) the components that contain this variable. Some of the really popular React Hooks include useState and useEffect, and we will introduce them in this lab.

useState

useState is the most common hook, and it deals with the creating reactive variables - whenever reactive variables get updated, React will automatically rerender the component with the updated value. Imagine we want to have a hitCounter variable that represents the number of button clicks. We can declare it with the useState hook:

// variable mutator of variable initial value const [hitCounter, setHitCounter] = useState(0)

The first word, const, is the reserved keyword for declaring a new variable. However, we are actually declaring 2 things here: a hitCounter variable of type number, and a function setHitCounter that is used to mutate the value of hitCounter. On the right side of the equal sign, we are calling the useState hook with the parameter 0 - that indicates that the hitCounter will be initialized at the value of 0.

So we’ve used useState, is that it? Let’s see how we can use these 2 variables to achieve our goal - building a hit counter!

import React, { useState } from "react"; const myCounter = () => { // using the React Hook here const [hitCounter, setHitCounter] = useState(0); return ( <div> <div>Count: {hitCounter}</div> <button onClick={() => setHitCounter(hitCounter + 1)}> Hit Me! </button> <div> ); };

Can you visualize what it would look like? If rendered in browser, there would probably be a Count: 0 along with a button that says Hit Me!. Let’s observe what is written inside the <button>.

We see that there is an onClick event handler written inside the <button> div. Whatever is written inside the onClick will be called when user clicks the button. In this case, we are calling setHitCounter with the parameter hitCounter + 1 to incremenet hitCounter everytime this button is pressed. hitCounter’s current value is 0. With some math knowledge, we know that we are effectively putting 1 as an argument into the setHitCounter; not surprisingly, React will now update hitCounter’s value to 1, and the DOM will show Count: 1. If we keep clicking the button, this process will be repeated, and we can see the hitCounter value to steadily increase.

To recap, useState instantiates a variable with a default value, and React provides us with a mutator to change the variable’s value. Every update to variable will cause a rerender of the component.

Note that the value of the hitCounter will be restored to 0 when we refresh the page. That is the anticipated behavior. If we want to make the data persistent through page refresh, we can use a database, or utilize the browser’s local storage.

useEffect

Now, let’s learn another simple hook - useEffect. We can treat this hook as an if changed statement. Let’s see what the useEffect hook looks like.

// callback function dependency array useEffect(() => { console.log('Button Clicked!') }, [hitCounter])

For readability, we formatted the useEffect into one-line and commented on each part. A useEffect hook takes in 2 parameters, the first one being a callback function, and the second parameter is a dependency array.

Callback Function

A callback function is simply a function that’s passed as an argument to another function!

Dependency Array

Dependency array is a very important concept to grasp. Essentially, it is the if part of the useEffect hook. We can put variables of different types into the dependency array, and whenever any variable in the dependency array gets updated, the callback function will be executed. For example, in the code snippet above, whenever hitCounter is updated, the callback function will print to the console. You can put as many variables as you want into the dependency array, but make sure to put in the right ones and avoid endless loops!

There are 2 special cases for the dependency array:

With this knowlegde, what does this snippet of code to do?

import React, { useState, useEffect } from "react"; const myCounter = () => { // using the React Hook here const [hitCounter, setHitCounter] = useState(0); useEffect(() => { console.log("Clicked!") }, [hitCounter]) return ( <div> <div>Count: {hitCounter}</div> <button onClick={() => setHitCounter(hitCounter + 1)}> Hit Me! </button> <div> ); };

Whenever the button is clicked, the value of the hitCounter will be changed, triggering a useEffect call. The useEffect will print a string to console.

Basic Rules of React Hook

  1. The hook can only be used in Functional Component. It’s not compatible with the old Class Component.
  2. Hooks can only be called at top-level - they cannot be called in loops, if conditions, or nested functions.

Now that you have “mastered” React ;) let us move back to the codebase and implement the create button to open the modal. We use a UI package called Chakra to render our createNodeModal.

Navigate to MainView.tsx, this is where we render the components and handle logic to load all of the components and the nodes from our database. We have divided the file into 6 sections, they are as follows:

Step 1: Notice the following useState hook in [1]:

const [createNodeModalIsOpen, setCreateNodeModalIsOpen] = useState(false)

We use a state variable called createNodeModalIsOpen to keep track of state of the CreateNodeModal.

If the createNodeModalIsOpen variable changes, our MainView component will know to re-render the components that depend on the createNodeModalIsOpen, but how do we change the variable? For that we need to use the setCreateNodeModalIsOpen function from our useState hook which updates the variable!

Step 2: Go to [6], since the createNode button is located in the Header component we need the Header component to be able to access and call the setCreateNodeModalIsOpen function. How do we do this? We pass it in to the componant a a prop (Note: props stands for “properties”) to the Header component. You can even pass functions in as props!

We’ll go into more detail on components and props in Step 3!

We should update the onCreateButtonClick prop in the Header component so that it looks as follows:

<Header onHomeClick={handleHomeClick} onCreateButtonClick={() => setCreateNodeModalIsOpen(true)} />

Now our Header component takes in the method that updates our variable. It knows what to do when the create button is clicked! Try it out, and try creating a node with the CreateNodeModal!

Congratulations! You’ve successfully implemented Create Node, from the backend all the way to the frontend!

Step 3: Implementing ImageContent.tsx

Now that we are able to create a node, what about viewing it? Sure we have an INode object with fields, but how useful is that? We need to take the INode oject and turn it into something useful.

This is what our Boston Cream Donut currently looks like, not so delicious:

To start doing this, navigate to ImageContent.tsx.

Here, we see the basic structure of a React component: it’s just a function! It takes an input props that is an object of shape IContentProps, and it outputs what it should render (for now, null).

IContentProps is actually defined in a different file; you can Cmd+Click (Mac) or Ctrl+Click (Windows) on almost any expression in VSCode to see where it’s defined or used. A lot of people find this extremely useful; try it out!

You can see that IContentProps has a field called content of type string. For an image node, this string is the image url.

A common pattern when programming in React is to get the properties you need from a props object. Here are two ways to get the content field from our props:

/** Option 1: Directly access individual properties */ const content = props.content /** * Option 2: Use object destructuring. * This is equivalent to the above line! */ const { content } = props // You can use destructuring to access // multiple properties at once, so it's // generally preferred // For example: const { property1, property2 } = myObject

Once you have your content stored in a const, you will want to render an HTML image. You can do this by returning an <img /> HTML tag with the src set to our image url (which is our content!).

If you haven’t worked with React or JSX before, it might surprise you to see HTML in a Javascript function; essentially, we use (...) to denote HTML elements, and we use {...} to denote Javascript when we are within an HTML element.

For example:

return ( <img src={/** Put any JS value in these curly braces */} /> )

Did you notice that <img> tag can be written in 2 formats?

In JSX, which React uses, you can either use a traditional <img src='...'></img> to render an image, or to use a self-closing tag that looks like <img src='...' />. This also applies to other HTML tags as well. They are equivalent expressions and either format is accepted.

When you are done with your ImageContent component, you should see an image when you click on an image node. Ask a TA if you get stuck!

Step 4: Implementing TextContent.tsx

Now we can implement our text content component in TextContent.tsx. The content of text nodes is simpler to render: it’s just a string! You can render a Javascript string by wrapping it in curly braces and putting it in an HTML tag (like a div).

For example:

const myString = 'donut!' return ( <div>{myString}</div> )

Once you render your text content, you should be see the content text when you click on a text node. Now, you’ll get some practice styling react components using .scss files!

Styling with SCSS (.scss)
Throughout this class we will be using .scss files instead of .css files.

SCSS files allows us to write SASS, which is an extension of CSS (Cascading Style Sheets) that adds functionality like variables and nesting. Because of these additional features, SCSS files are often used instead of CSS files in industry.

Note: While there are other alternatives for styling your React components, we’ll be sticking with SCSS for this project. We will also give you the option to use other CSS styling methods such as tailwind and emotion later on in this class - or now if you are already familiar with them!

In the same directory, go to TextContent.scss. Define a SCSS class for your text content – the syntax is like this (though the class can be named anything):

.textContent { }

Now, hook up this SCSS class to your React component by adding a className string to the HTML element you are rendering. You can do this like so:

return ( <div className="<YOUR_CLASSNAME_HERE>">{myString}</div> )

Ok, you’re now ready to start styling! Some good places to start are font-size, font-weight, color, and padding. You can add styles like so:

.textContent { font-size: 10px; font-weight: bold; color: red; padding: 20px; }

Change these values and add more styles (See https://www.w3schools.com/html/html_styles.asp for some more examples, or do a quick Google search!) until you are happy with how your text is rendered.

Step 5: Rendering ImageContent.tsx and TextContent.tsx

Now that you have implemented TextContent.tsx and ImageContent.tsx, we need our NodeView.tsx to render either a TextContent or ImageContent component depending on the type of node selected.

In client/src/components/NodeView/NodeView.tsx we see this snippet of code returns what node content to render:

// ...more code above <div className="nodeView-content" style={{ maxHeight: hasBreadcrumb ? 'calc(100% - 118px)' : 'calc(100% - 72px)', }} > // this renders the node's content based on the node's type {renderContent(props, type, content)} </div> </div> ) } // end of file

Navigate to the implementation of renderContent by cmd + click on Mac and ctrl + click on Windows.

Implement the two TODOs in this file (nodeViewUtils.tsx). Pay attention to what props ImageContent and TextContent require!

Once done, your frontend should look like this!

Yay! Now you can render the content of text and image nodes from our Mongo database!

Congrats! You’re done with the lab; see a TA to get checked off or attend TA Hours before the next lab section (September 23rd 3:00pm ET)