⬅️ Return to course website

Lab 1: Nodes

Released: September 13th, 2023
Due: September 20th, 2023 at 11:59 PM ET
To get checked off for this lab, meet with a TA at your lab section or TA hours

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.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

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

  • Fill out the Hypothesis Gradescope assignment
  • Accept the Nodes GitHub Classroom assignment
  • Read Background Information
  • Database
    • MongoDB setup - accessing the shared collection of nodes inside of the course MongoDB
  • Backend
    • Create Node - Implement backend functionality to support createNode by making changes in the following files
      • NodeRouter.ts
      • NodeGateway.ts
      • NodeCollectionConnection.ts
  • Frontend
    • CreateNode in Frontend NodeGateway.ts
    • Create React components to render the content of text and image nodes.
  • Get checked off by a TA during your lab section or at TA hours before the due date

Hypothesis Gradescope Assignment

Visit our course Gradescope page and submit the "Hypothesis Annotations" assignment with your username that you used for you annotations this past week.

Accepting the Assignment

For Lab 1 and Assignment 1, you will be using the same repository. Accept the assignment here. Once you’ve accepted your GitHub repo, clone it locally, and you should be ready to go!

Helpful Git CLI commands

Cloning your repo locally:

  • git clone <link>

Add all changes in the working directory to the staging area:

  • git add -A

Create new commit from the files in the staging area:

  • git commit -m '<commit message>'
    Make sure to use an informative commit message in case you need to revert your changes!

Update the remote branch with all local commits:

  • git push

Demo

Note: The demo may take up to 30 seconds to load due to the free backend we used fo hosting. Additionally, you might notice some strange behavior if there are multiple students interacting with the demo at the same time.

You can find a demo of the MyHypermedia application here.

Background Information

These are some important concepts 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!

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.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

The frontend code uses the React framework, written in TypeScript and models the interactive part of our web application that the user will see. The React application is compiled into plain HTML, CSS and Javascript and is then hosted by a server at a specific endpoint (i.e. "http/my-web-app.com") - in this course we will use Vercel to host our frontend.

Our frontend, like most frontends, must fetch relevant data to show the user. For example, when a user searches for a video on YouTube, YouTube's frontend will need to fetch the appropriate search results to render onto the users browser. This is where the backend comes into play.

The backend part of our web application is responsible for listening to requests from the frontend and responds by providing the relevant processed data. Our backend will use Express.js to configure the endpoints at which the backend can service specific requests. For example, "http/my-web-app.com/search" might be configured to respond so that the frontend can process all search requests by issuing a HTTP POST request to that endpoint. We will use Node.js with TypeScript to implement our backend logic.

Note: You will only implement the orange bordered sections in this lab. In Assignment 1, you will be creating your own Mongo database!

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:

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.

Asynchronous Functions

JavaScript is single-threaded, which means it only has one call stack. Using asynchronous functions, we can make time-intensive network requests without blocking the call stack.

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 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 - a Promise is 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.).

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 (): Promise<void> => { // 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) }

You can learn more about the JavaScript event loop here: What the heck is the event loop anyway?

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 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: INodePath // 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.

INodePath

The node 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 node paths. Here is the definition for the INodePath interface:

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

Example:

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

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

and the child1 node will have the following nodePath:

{ 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 BackendNodeGateway.

Note: NodeRouter listens to all HTTP requests to /node, therefore NodeExpressRouter.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.

BackendNodeGateway.ts

BackendNodeGateway 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, BackendNodeGateway.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 BackendNodeGateway.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 create a new .env file. Its filepath should be: 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=8000

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:
Make sure that you are in the server directory. Run the following npm scripts to install the relevant dependencies and start the server:

npm install
npm run dev

To verify that your server is running, if you go to http://localhost:8000 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.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

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

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

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 txt 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 a hold of these JSON objects in a text file 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 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.
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

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

  • You have POST selected as the request type
  • The request URL is: http://localhost:8000/node/create
  • You have Body, raw, and JSON selected in the respective places.

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:8000.

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:

  • req is a Request object that we receive from the client.
  • res is a Response object that we send back to the client indicating the status of the request along with an optional JSON payload that contains any requested information.

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

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

Note: NodeRouter listens to all HTTP requests to /node, therefore NodeExpressRouter.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:

NodeExpressRouter.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, npm install and npm run dev
  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. Make sure to add isINode from the import '../types' up at the top of the file.

NodeExpressRouter.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.

NodeExpressRouter.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 an INode, we should call on NodeGateway method to insert it into database.

NodeExpressRouter.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.BackendNodeGateway.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).

NodeExpressRouter.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.BackendNodeGateway.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: BackendNodeGateway.ts

Now that we have written /create for NodeRouter and called BackendNodeGateway.createNode(), let's move onto the next step and write BackendNodeGateway.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 nodeId's 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 that we are attaching this new node to 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 BackendNodeGateway.createNode(). Let's move onto NodeCollectionConnection.insertNode().

Step 7: NodeCollectionConnection.ts

Finally, head over to NodeCollectionConnection.ts and find the insertNode() method. 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 relevant 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 ) }

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.

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 may have used that use React include:

  • Facebook
  • Instagram
  • Netflix
  • Twitter
  • Reddit

If you're new to React or want a refresher, you might find this tutorial helpful.

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

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

TODO:
Run the following npm scripts to install the relevant dependencies and start the React services:
cd client
npm install
npm run dev

To verify that your React service is running, if you go to http://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 FrontendNodeGateway.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 FrontendNodeGateway to make HTTP requests.

We use Axios to make our HTTP requests, which offers an easier and more powerful way to make requests compared to the more basic Fetch API. Axios is a promise-based React library compatible with Node.js and your browser to make asynchronous JavaScript HTTP requests. All our requests are 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:

  • url:
    When you are running it locally the baseEndpoint variable http://localhost:8000.

    Since this NodeGateway only sends HTTP requests to /node... we use the servicePath variable to define the permanent prefix attached to the route.

    Thus, our URL should be:
    baseEndpoint + servicePath + '/create'

  • body:
    The body of the POST HTTP request should be the INode object that we are creating.

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 = { 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 components requires much less boilerplate code, and are now the industry standard.

React Hooks

First, let's talk about React hooks. 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.

useState

useState is the most common hook, and it deals with creating reactive variables - whenever reactive variables get updated, React will automatically re-render 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 constant 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. Both of these are returned from the useState hook. 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 increment 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.

Note: DOM stands for Document Object Model, and it's a structured representation of the HTML elements that are present in a web page. It essentially represents the entire UI of your application.

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.

Global State Management
You might notice that the stencil code sometimes uses useRecoilState instead of useState. This is because large applications with many state variables that need to be accessed by multiple components can quickly become cluttered and buggy.

That's where global state management comes in! The Recoil state management system uses the same basic principles as useState hooks, but allows for easy access to the same state variable across components. Global state variables are defined in atom.tsx, and to use them within a component, we can use:

  • useRecoilState to access the value and mutator
  • useRecoilValue to only access the value
  • useSetRecoilState to only access the mutator

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 a function that's passed as an argument to another function.

Dependency Array

A dependency array is essentially 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.

There are 2 special cases for the dependency array:

  • When the dependency array is empty, such as useEffect(() => { console.log("hello!) }, []), it means the callback function will only be called once on component creation. When we want to make an API call when the page / component loads, this mode comes in handy.
  • When the dependency array is not supplied, such as useEffect(() => { console.log("change!")}), the callback function will be executed whenever the component is re-rendered.

With this knowledge, what does this snippet of code below 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 Hooks

  1. Hooks can only be used in functional component; they're not compatible with the old class components.
  2. Hooks cannot be called in loops, if conditions, or nested functions.

Now that we've gone over useState and useEffect hooks, let's go back to the codebase and implement the create button to open the modal. We use a React 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:

  • [1] useState hooks
  • [2] Constant variables
  • [3] useEffect hooks
  • [4] Button handlers
  • [5] Helper functions
  • [6] JSX component

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

const [createNodeModalOpen, setCreateNodeModalOpen] = useState(false)

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

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

Step 2: Go to [6] JSX Component in the file. Since the createNode button is located in the Header component we need the Header component to be able to access and call the setCreateNodeModalOpen function. How do we do this? We pass it in to the component as a prop, one of the arguments of the component.

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

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

<Header onHomeClick={handleHomeClick} onCreateNodeButtonClick={() => setCreateNodeModalOpen(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 the create node functionality, 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?

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

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

We need to take the INode object and turn it into something useful. Start by navigating to ImageContent.tsx.

Here, we see the basic structure of a React component: it's just a function!

Within this file, you will see that we use a global state management tool called recoil in the form of useRecoilValue(). Here, we retrieve the currentNode, which is an INode that contains a list of INode properties that we can then access and use.

We will need currentNode.content to render our image. You can do this by returning an <img /> tag with the src set to our image url (which is our content!).

It might surprise you to see an 'HTML tag' in a Javascript function. This is really a JSX tag rather than an HTML tag. JSX is an extension of Javascript that is similar to HTML, but also allows for JavaScript expressions. We use {...} to denote JavaScript when we are within a JSX 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='...' />. These are equivalent expressions and either format is accepted.

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 outer 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 course. You can use other CSS styling methods such as tailwind and emotion if you choose, but the TAs won't be able to offer support for these.

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 NodeContent.tsx to render either a TextContent or ImageContent component depending on the type of node selected.

In client/src/components/NodeView/NodeContent/NodeContent.tsx, implement the two TODOs in this file.

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.