Try   HackMD

Lab 2: Anchors and Links

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

⚠️ Do not clone the Assignment 2 stencil code until you have submitted Assignment 1 for the last time to Gradescope. We will be checking to make sure you do not submit Assignment 1 after cloning Assignment 2. ⚠️

Introduction

In the first assignment, we created a basic hypertext system. Now we want to have more in-depth ways of interacting with our nodes. One of the features common to all hypertext systems is the ability to create two objects we will be adding to our system: anchors and links.

As with the node objects you created in the last assignment, each part of the stack will have a way of interacting with these new objects. Your completed assignment will contain the following for both anchors and links: database connections, gateways, routers, and frontend components.

In this lab, we are laying out the foundations to implement linking and anchoring. Now that you have all completed a basic file system and gone through the complexities of understanding the backend structure for our nodes, this unit will be more focused on frontend work, and will involve many more design decisions!

As always, if you have any questions about anything please reach out to a TA!

Objective: Introduce you to the new types and some techniques for frontend development that you will be working with for this assignment.

Checklist

  • Complete the Assignment 1 feedback form
  • Clone the repo for this lab
  • Implement linkCollectionConnection.deleteLinks()
  • Working with text, images, and selected extents!
  • Get checked off by the due date

Reflecting on Assignment 1

You successfully prepped your donut! Now it's time to look over the recipe to see where you're at. Please complete this quick feedback form for Assignment 1. We want to get a good sense of what has gone well and what hasn't, so we can improve on future assignments.

Demo

Note: The demo may take up to 30 seconds to load due to the free backend we used for 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 unit2 MyHypermedia application here.

Cloning the GitHub Repo

You can clone the repository here.

Note: The same repo will be used for Lab 2 and Assignment 2!

New Types - ILink & IAnchor

We highly recommend that you look over the files in server/src/types as all of the following types are defined there:

  • IServiceResponse
  • INode
  • INodeProperties
  • INodePath
  • RecursiveNodeTree
  • IAnchor
  • Extent
  • ILink

We also provide and define useful helper methods related to these types in each of the files. Looking over these now will make your life much easier when you're looking for helper functions in the future.

Anchors

In the previous assignment, you implemented the file system for nodes, but didn’t actually implement anything regarding selecting content on a node. In order for anchors to work properly, you need to be able to make selections on the content of a node, whether that node is a text document, an image, or a video. Conceptually, you can think about implementing anchors via two distinct components:

  1. a generic component that manages all types of anchors and stores the associated node and other metadata about the anchor (IAnchor), and
  2. specific implementations of anchors that define the location of that anchor for the given node type (Extent).

We have defined a schema for anchors as the IAnchor interface:

export interface IAnchor { anchorId: string nodeId: string // Defines the extent of the anchor in the document, // e.g. start / end characters in a text node. // If extent is null, the anchor points to the node as a whole. extent: Extent | null }

and theExtent interfaces:

// Extent is 1 of 2 interfaces export type Extent = ITextExtent | IImageExtent // Defines the extent of an anchor on a text node export interface ITextExtent { type: 'text' text: string startCharacter: number endCharacter: number } // Defines the extent of an anchor on an image node export interface IImageExtent { type: 'image' // the following numbers will be explained later on left: number top: number width: number height: number }

Once we have a way of creating anchors on our nodes, we need an intuitive way to navigate between them. This is where links come in! Whenever we place an anchor on the content of a node, we want to have the ability to link this anchor to another location. For example, if there is a statistic or quote within the content of a specific node, we want to have the ability to create an anchor on this section, and then link it to an anchor on the node from where this information comes.

The new ILink interface:

export interface ILink { anchor1Id: string; anchor2Id: string; anchor1NodeId: string; anchor2NodeId: string; dateCreated?: Date; explainer: string; linkId: string; title: string; }

Backend

Setting Up Environment Variables

You should know how to do this by now! Use the same .env file that you did for your own MongoDB connection in Unit 1 and add it into your server folder.

DB_URI = <YOUR OWN URI> PORT=8000

If you change the port, make sure to also change the endpoint in the client's endpoint.ts file.

MongoDB Queries

In Unit 1, you got your hands dirty with BackendNodeGateway.ts methods. In Unit 2, you will familiarize yourself with AnchorCollectionConnection.ts and LinkCollectionConnection.ts. These collection connection classes use the mongodb node package to interact with the MongoDB database we created in Assignment 1.

But first, what exactly is MongoDB?

MongoDB is a cross-platform (runs on multiple operating systems), document-oriented database management system (DBMS). MongoDB is also a non-relational, NoSQL database. (SQL is a query language for relational databases, not a database architecture itself, so the NoSQL name is confusing, arguably the category should have been called non-relational.)

It is important to know at a high-level how this type of database operates, as the structure of a MongoDB database is inherently different from relational databases that use SQL. Traditional relational databases that use SQL to perform operations store data with predefined relationships. This is why these types of databases consist of a set of tables with columns and rows - to organize data points using defined relationships for easy access.

A non-relational, NoSQL database such as Mongo is different. Non-relational databases do not use the tabular schema of rows and columns found in most traditional database systems. Instead, data is stored as JSON-like objects with indexes that allow for constant lookup for certain fields. As a result, these types of databases tend to be more flexible by allowing data to be stored in myriad ways.

MongoDB uses documents that are in JSON-like format, known as BSON, which is the binary encoding of JSON. Node.js and MongoDB work very well together, in part because Mongo uses a JavaScript engine built into the database (JavaScript is good at handling JSON objects).

With Mongo being a non-relational database, it has its own way of storing data. Here are some of the constructs that make up the database structure:

  • Document: A JSON-like object with a unique identifier that contains fields, which can store anything from strings and ints to arrays and complex objects. You can tell MongoDB which fields of a document to index for constant-time lookup and range queries. Documents do not have to have the same fields as one another, which is an advantage that Mongo databases have in flexibility over traditional, relational databases. [As Norm has said, the word “document” is confusing, since it has nothing to do with the types of documents (text, spreadsheets, etc.) that people are used to. We will continue to call them documents, but think of them as JSON-like objects.]
    • _id: Mandatory unique field in every document. It separates one document from another, so we can identify each document independently. If this value is not provided, MongoDB automatically assigns a random value for the field.
  • Collection: A set of documents. This is comparable to a table in a SQL database. However, unlike a SQL database, a collection does not have a set structure or pre-configured data types.
  • Database: The container that holds a set of collections.

Task 1: First, navigate to server/src/app.ts. This file is what runs when you npm run dev. As you see, we configure a MongoClient in this file as so:

// access MongoDB URI from .env file const uri = process.env.DB_URI; // instantiate new MongoClient with uri and our recommended settings const mongoClient = new MongoClient(uri, { useNewUrlParser: true, useUnifiedTopology: true, }); // connect mongoClient to a MongoDB server mongoClient.connect();

A few lines below this, we pass this mongoClient into each of our routers:

// instantiate new NodeRouter and pass mongoClient const myNodeRouter = new NodeRouter(mongoClient); // (ignore this: it attaches a new router to our express application) app.use('/node', myNodeRouter.getExpressRouter()); // the same is done for the anchor and link routers // [more code for anchor and link routers follow...]

This mongoClient is then passed down from the router > gateway > collectionConnection via their respective constructors. For example for links:

// In LinkRouter.ts: constructor(mongoClient: MongoClient) { this.linkGateway = new LinkGateway(mongoClient); //... } // In BackendLinkGateway.ts: constructor(mongoClient: MongoClient, collectionName?: string) { this.linkCollectionConnection = new LinkCollectionConnection( mongoClient, // If collectionName is undefined, collectionName will be // 'links' by default. We use this for e2e testing when we // create a separate 'e2e-testing-links' collection. You can // think of the '??' operator as a safer alternative to '||' collectionName ?? 'links' ); } // In LinkCollectionConnection.ts constructor(mongoClient: MongoClient, collectionName?: string) { // store mongoClient in this.client as an instance variable this.client = mongoClient; // store collectionName in this.collectionName as an instance variable this.collectionName = collectionName ?? 'links'; } // We can now use the MongoClient from App.ts in LinkCollectionConnection

Now that we have the MongoClient, let's try to write LinkCollectionConnection.deleteLinks().

Task 2: We will now implement LinkCollectionConnection.deleteLinks()

First, we need to get the links collection:

/** * Deletes links when given a list of linkIds. * * @param {string[]} linkIds * @return successfulServiceResponse<{}> on success * failureServiceResponse on failure */ async deleteLinks(linkIds: string[]): Promise<IServiceResponse<null>> { // note: earlier, in the constructor of linkCollectionConnection, // we assign this.collectionName to 'links' and mongoClient to this.client const collection = await this.client.db().collection(this.collectionName); }

Next, we need to form a MongoDB query.

/** * Deletes links when given a list of linkIds. * * @param {string[]} linkIds * @return successfulServiceResponse<{}> on success * failureServiceResponse on failure */ async deleteLinks(linkIds: string[]): Promise<IServiceResponse<null>> { const collection = await this.client.db().collection(this.collectionName); // this query requests all documents where the field 'linkId' matches // some element in 'linkIds' const myQuery = { linkId: { $in: linkIds } }; }

Learning MongoDB queries
Over the duration of the semester, you will become familiar with MongoDB queries and will hopefully be able to form basic ones on your own. Here are some basic ones to get you started, but feel free to consult MongoDB Documentation at anytime!

Some Basic MongoDB Query Functions
The stencil code already calls getCollection and stores the response in a variable. Below are some of the functions that you can call on the collection variable to interact with the MongoDB database. Feel free to reference the previous assignment’s backend NodeCollectionConnection.ts file to help with your implementation.
collection.findOne(query) - Docs
collection.find(query) - Docs
collection.deleteOne(query) - Docs
collection.deleteMany(query) - Docs
collection.insertMany(query) - Docs
collection.insertOne(query) - Docs

Next, we need to call a MongoDB API call with our newly formed query. Notice that we call await on this method because we are sending a request to the MongoDB server that we connected to using mongoClient.connect(). Because they are hosted on a remote server, we must then await a response from the MongoDB server before continuing our program.

/** * Deletes links when given a list of linkIds. * * @param {string[]} linkIds * @return successfulServiceResponse<ILink> on success * failureServiceResponse on failure */ async deleteLinks(linkIds: string[]): Promise<IServiceResponse<null>> { const collection = await this.client.db().collection(this.collectionName); const myQuery = { linkId: { $in: linkIds } }; // Here we use the 'deleteMany' function as we want to delete multiple // documents that meet our query const deleteResponse = await collection.deleteMany(myQuery); }

Now that we have stored the deleteResponse, we have to verify whether or not the deletion was successful and return an IServiceResponse accordingly.

/** * Deletes links when given a list of linkIds. * @param {string[]} linkIds * @return successfulServiceResponse<ILink> on success * failureServiceResponse on failure */ async deleteLinks(linkIds: string[]): Promise<IServiceResponse<null>> { const collection = await this.client.db().collection(this.collectionName); const myQuery = { linkId: { $in: linkIds } }; const deleteResponse = await collection.deleteMany(myQuery); // we use result.ok to error check deleteMany if (deleteResponse.result.ok) { return successfulServiceResponse(null); } return failureServiceResponse('Failed to delete links'); }
Error checking MongoDB responses

Each MongoDB method call will return a differently shaped response. This means, that the method for error checking a deleteMany reponse may be different to the method for error checking a findOneAndUpdate method. For example, this is how we error check findOneAndUpdate in NodeCollectionConnection.updateNode():

async updateNode( nodeId: string, updatedProperties: Object ): Promise<IServiceResponse<INode>> { const collection = await this.client.db().collection(this.collectionName) const updateResponse = collection.findOneAndUpdate( { nodeId: nodeId }, { $set: updatedProperties } ) if (updateResponse.ok && updateResponse.lastErrorObject.n) { return successfulServiceResponse(updateResponse.value) } return failureServiceResponse( 'Failed to update node, lastErrorObject: ' + updateResponse.lastErrorObject.toString() ) }

We highly recommend looking at NodeCollectionConnection.ts for tips on how to form MongoDB queries, e.g. how to query a nested field, how to update documents, etc.

Frontend

Improving Performance in React

In this section, we will introduce two more built-in React hooks that help reduce unnecessary rerendering. They essentially memoize your functions (useCallback) or your values (useMemo).

There are also a few "Code Review" info blocks that give software engineering tips and best practices, sort of like what you might receive from a code review at a company.

useCallback

You may remember that a callback is simply a function passed as an argument. Whenever you pass a function as an argument, it's best to wrap it in a useCallback to make sure it only updates when needed.

// Example without useCallback const MyParentComponent = () => { const [selectedNode, setSelectedNode] = useState<INode>(...); const handleNodeClick = (node: INode) => { setSelectedNode(node) } return ( <MyChildComponent onNodeClick={handleNodeClick} /> ) }
// Example with useCallback const MyParentComponent = () => { const [selectedNode, setSelectedNode] = useState<INode>(...); const handleNodeClick = useCallback((node: INode) => { setSelectedNode(node) }, [setSelectedNode]) return ( <MyChildComponent onNodeClick={handleNodeClick} /> ) }

In the first example, a new reference to handleNodeClick is created every time MyParentComponent rerenders. Since we pass handleNodeClick as a prop to MyChildComponent, we will also make MyChildComponent rerender, which is inefficient. That's where useCallback comes in!

The first argument to useCallback is the function you want to wrap. The second argument is the dependency array, which is important and required. In the example above, the dependency array tells React to only change handleNodeClick if setSelectedNode changes.

Code Review: The example above also illustrates a common naming pattern in React. Our child component has a prop with a name like onNounVerb that will be called when some event happens in the child component (like a button click). We then write a function with a name like handleNounVerb that handles the event, which we pass into the child component.

useMemo

If we want to memoize a value instead of a callback function, we can use useMemo. It has a similar interface, including the dependency array:

const selectedNodeTitle: string = useMemo(() => { return selectedNode.title; }, [selectedNode])

In this example, selectedNode is a state variable, and we only want selectedNodeTitle to change when selectedNode changes. Often times, we will use useMemo to store values that we derive from state variables.

Code Review: As a rule of thumb, you should store as little data as possible in state variables, and derive your other data from the state. For instance, in the above example, we wouldn't want to store selectedNodeTitle as its own state variable, since we'd then have to remember to update it.

Dependency arrays are very important! We have enabled a linter rule called exhaustive-deps that will make sure that every value that's used in the function is included in the dependency array, preventing a wide array of bugs.

For example, the following code would error:

const selectedNodeTitle: string = useMemo(() => { return selectedNode.title; }, []) // eslint error, since selectedNodeTitle will not update // if selectedNode changes

useRef React Hook

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue in the example below). The returned object will persist for the full lifetime of the component.

const refDonut = useRef(initialValue);

Note that the initial value we pass into useRef is often null.

Here's a common use case for useRef, which you be using in later steps of this lab:

// this is a functional component called ChocolateDonut function ChocolateDonut() { const refDonut = useRef(null); const handleMakeChocolateClick = () => { // `current` points to the mounted div with className="donut" refDonut.current.style.backgroundColor = "chocolate" }; return ( <> <div className="donut" ref={refDonut}/> <Button icon={<fa.FaDonut/>} onClick={handleMakeChocolateClick}> Focus the input </Button> </> ); }

Essentially, useRef is like a “box” that can hold a mutable value in its .current property.

You might be familiar with refs primarily as a way to access the DOM. If you pass a ref object to React with <div ref={myRef} />, React will set its .current property to the corresponding DOM node whenever that node changes.

To what extent is an Extent

When we make a link from a document, we don't necessarily always want to link from the entire document. For example if we are linking from a PVDonut's menu, we may want to link from just one particular donut - rather than the entire menu. For that, we need to create extents.

In this lab you will be implementing selecting an extent on both text and image nodes. In order to create links, you need to know how to create the anchor that you are going to link from!

Extents in Images: IImageExtent

An extent is basically the terminology that we use to say we want to specify this part of a node. For an image node, that could be a selection of a particular person. For example we can see in the image below we have a selection over the two table tennis tables. These are anchors, but within anchors the extent is what handles where exactly on the image the anchor should be!

Given a list of anchors, you will be generating these visual indicators of where exactly the anchor is. Note that if extent is null, then we are linking from the entire image.

// Defines the extent of an anchor on an image node export interface IImageExtent { type: 'image' left: number top: number width: number height: number }

Since anchor.extent can be both an image or text type, we want to check that it is the correct type before we access the properties (eg. top, width) which will exist on IImageExtent but not ITextExtent. We can do that with the following condition:
if (anchor.extent?.type === 'image')

Task 3: We will now load the existing randomly generated anchors onto your image using the extent!
Go to ImageContent.tsx, and add the div below to the list we created called anchorElementList.

Note: If selecting a region on an image does not work, then change the divider in the onMove method. This is annoyance of having screens with different resolutions!

Here is an example of what we might want to add into the anchorElementList array. Feel free to edit this!

<div
  id={anchor.anchorId}
  key={'image.' + anchor.anchorId}
  className="image-anchor"
  onPointerDown={(e) => {
    e.preventDefault()
    e.stopPropagation()
  }}
  style={{
    height: anchor.extent.height,
    left: anchor.extent.left,
    top: anchor.extent.top,
    width: anchor.extent.width,
  }}
/>

Notice that we have a key property; this is because when we render the same property over again in our HTML DOM, it needs to have a unique key.

Extents in Text: ITextExtent

First let us look at what an ITextExtent object looks like.

// Defines the extent of an anchor on a text node export interface ITextExtent { type: 'text' startCharacter: number endCharacter: number text: string }

Imagine we have the following piece of text from PVDonuts.

We’re not your typical donut shop. We’re a bit over the top.
We officially hit the scene when we opened our doors in 2016, but we’ve been experimenting since 2014. We’re most popular for introducing brioche style donuts to the New England area, and they’re what makes us unique. We also offer old fashioned, filled brioche, cake, crullers, fritters, and more. All of our signature styles are made, rolled, cut, dipped and decorated by hand each day.

As you can see, there are already existing links that are underlined and blue. In our implementation, we would store this as an anchor with the extent defined as ITextExtent. This has a startCharacter and an endCharacter relative to paragraph. We want our anchors to have access to that information so that when we load our text we can visually show where our anchors are.

Task 4:
Go to TextContent.tsx, and manage the extent for when we want to create a new anchor - which would be when we click Start Link.

Here are the TODOs:

  1. Add an onPointerUp method which updates the selected extent
  2. Set the selected extent to null when this component loads
  3. Update the textContent-wrapper HTML Element so that it works with useRef and onPointerUp

Extents on other document types

Task 5: Discuss these questions with a partner first, and try to write out an interface for one of the following:

  • What does an extent look like in a PDF? (Maybe we would want to combine image extents and text extents?)
  • What would an extent look like in an audio recording?
  • What would an extent look like for a video?
  • What about a webpage?

Discuss your findings with the TA!

Styling

Just a note that there are other ways to be stylish when writing HTML 😎 - feel free to use any you'd like!

SCSS

This is what you have used so far - but there are many other ways to style - feel free to switch it up, or continue using scss. We just want to introduce you to some other ways of styling your web app!

If you decide to use an SCSS alternative, you are responsible for converting all .scss files to the file format of your choice.

Styling with Bootstrap

Sometimes, we want to be able to style our elements without creating an entire new .scss file. For many common styles like padding, margin, and flex-box, you can just add a Bootstrap utility class.

If you want to use Bootstrap on any of the assignments, all you need to do is install it on your client app! Just search online for how to install Bootstrap on Next.js for a tutorial. For padding and margin, there are defined levels (i.e. 1, 2, 3, 4, 5) that correspond to consistent values (i.e. 4px, 8px, 16px, 24px, 32px).
Having consistently scaling spacing values is generally good practice in design!

Padding

// Padding level 3 on all sides <div className="p-3">Hello!</div> // Padding level 2 on the left <div className="pl-2">Hello!</div> // Padding level 4 on the right <div className="pr-4">Hello!</div> // Padding level 1 on the left and right <div className="px-1">Hello!</div> // Padding level 1 on the top and bottom <div className="py-1">Hello!</div>

Margin

// Margin level 3 on all sides <div className="m-3">Hello!</div> // Margin level 2 on the left <div className="ml-2">Hello!</div> // Margin level 4 on the right <div className="mr-4">Hello!</div> // Margin level 1 on the left and right <div className="mx-1">Hello!</div> // Margin level 1 on the top and bottom <div className="my-1">Hello!</div>

Flexbox

Flexbox allows you to create responsive containers that nicely lay out your elements. You can create flexboxs that are horizontal (flex-row, the default) or vertical (flex-column).

For a great explanation and resource on flexbox, see this guide:
https://css-tricks.com/snippets/css/a-guide-to-flexbox/

Bootstrap flexbox classnames are pretty straightforward, Here's some common ones:

d-flex (this means display: flex)
flex-row
flex-column
align-items-center
justify-content-center

Checkoff

  • Show a TA your linkCollectionConnection.deleteLinks()
  • Show a TA that you have implemented text extent and image extent!
  • Have the best day ever 😎