17.
Making a Simple Web App, Part 2
Written by Tim Condon
In the last chapter, you learned how to view categories and how to create, edit and delete acronyms. In this chapter, you’ll learn how to allow users to add categories to acronyms in a user-friendly way.
Creating acronyms with categories
The final implementation task for the web app is to allow users to manage categories on acronyms. When using the API with a REST client such as the iOS app, you send multiple requests, one per category. However, this isn’t feasible with a web browser.
The web app must accept all the information in one request and translate the request into the appropriate Fluent operations. Additionally, having to create categories before a user can select them doesn’t create a good user experience.
Open Category.swift and add the following extension at the bottom:
extension Category {
static func addCategory(
_ name: String,
to acronym: Acronym,
on req: Request
) -> EventLoopFuture<Void> {
// 1
return Category.query(on: req.db)
.filter(\.$name == name)
.first()
.flatMap { foundCategory in
if let existingCategory = foundCategory {
// 2
return acronym.$categories
.attach(existingCategory, on: req.db)
} else {
// 3
let category = Category(name: name)
// 4
return category.save(on: req.db).flatMap {
// 5
acronym.$categories
.attach(category, on: req.db)
}
}
}
}
}
Here’s what this new extension does:
- Perform a query to search for a category with the provided name.
- If the category exists, set up the relationship.
- If the category doesn’t exist, create a new
Categoryobject with the provided name. - Save the new category and unwrap the returned future.
- Set up the relationship using the saved acronym.
Open WebsiteController.swift and add a new Content type at the bottom of the file to handle the accepting categories:
struct CreateAcronymFormData: Content {
let userID: UUID
let short: String
let long: String
let categories: [String]?
}
This is similar to the existing CreateAcronymData in AcronymsController.swift. CreateAcronymFormData adds an optional array of Strings to represent the categories. This allows users to submit existing and new categories instead of only existing ones.
Next, replace createAcronymPostHandler(_:) with the following:
func createAcronymPostHandler(_ req: Request) throws
-> EventLoopFuture<Response> {
// 1
let data = try req.content.decode(CreateAcronymFormData.self)
let acronym = Acronym(
short: data.short,
long: data.long,
userID: data.userID)
// 2
return acronym.save(on: req.db).flatMap {
guard let id = acronym.id else {
// 3
return req.eventLoop
.future(error: Abort(.internalServerError))
}
// 4
var categorySaves: [EventLoopFuture<Void>] = []
// 5
for category in data.categories ?? [] {
categorySaves.append(
Category.addCategory(
category,
to: acronym,
on: req))
}
// 6
let redirect = req.redirect(to: "/acronyms/\(id)")
return categorySaves.flatten(on: req.eventLoop)
.transform(to: redirect)
}
}
Here’s what you changed:
- Change
Contenttype to decodeCreateAcronymFormData. - Use
flatMap(_:)instead ofmap(:_)as you now return anEventLoopFuturein the closure. - If the acronym save fails, return a failed
EventLoopFutureinstead of throwing the error as you can’t throw insideflatMap(_:). - Define an array of futures to store the save operations.
- Loop through all the categories provided in the request and add the results of
Category.addCategory(_:to:on:)to the array of futures. - Flatten the array to complete all the Fluent operations and transform the result to a
Response. Redirect the page to the new acronym’s page.
Next, you need to allow a user to specify categories when they create an acronym. Open createAcronym.leaf and, just above the <button> section, add the following:
<!-- 1 -->
<div class="form-group">
<!-- 2 -->
<label for="categories">Categories</label>
<!-- 3 -->
<select name="categories[]" class="form-control"
id="categories" placeholder="Categories" multiple="multiple">
</select>
</div>
Here’s what this does:
- Define a new
<div>for categories that’s styled with theform-groupclass. - Specify a label for the input.
- Define a
<select>input to allow a user to specify categories. Themultipleattribute lets a user specify multiple options. The namecategories[]allows the form to send the categories as a URL-encoded array.
Currently the form displays no categories. Using a <select> input only allows users to select pre-defined categories. To make this a nice user-experience, you’ll use the Select2 JavaScript library (https://select2.org).
Open base.leaf and under <link rel=stylesheet... for the Bootstrap stylesheet add the following:
#if(title == "Create An Acronym" || title == "Edit Acronym"):
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css" integrity="sha384-KZO2FRYNmIHerhfYMjCIUaJeGBRXP7CN24SiNSG+wdDzgwvxWbl16wMVtWiJTcMt" crossorigin="anonymous">
#endif
This adds the stylesheet for Select2 to the create and edit acronym pages. Note the complex Leaf statement. At the bottom of base.leaf, remove the first <script> tag for jQuery and replace it with the following:
<!-- 1 -->
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha384-ZvpUoO/+PpLXR1lu4jmpXWu80pZlYUAfxl5NsBMWOEPSjUn/6Z/hRTt8+pR6L4N2" crossorigin="anonymous"></script>
<!-- 2 -->
#if(title == "Create An Acronym" || title == "Edit Acronym"):
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js" integrity="sha384-JnbsSLBmv2/R0fUmF2XYIcAEMPHEAO51Gitn9IjL4l89uFTIgtLF1+jqIqqd9FSk" crossorigin="anonymous"></script>
<!-- 3 -->
<script src="/scripts/createAcronym.js"></script>
#endif
Here’s what this does:
- Include the full jQuery library. Bootstrap only requires the slim version, but Select2 requires functionality not included in the slim version, so must include the full library.
- If the page is the create or edit acronym page, include the JavaScript for Select2.
- Also include the local createAcronym.js.
Create a directory in Public called scripts for your local JavaScript file. In the new directory, create createAcronym.js. Open the new file and insert the following:
// 1
$.ajax({
url: "/api/categories/",
type: "GET",
contentType: "application/json; charset=utf-8"
}).then(function (response) {
var dataToReturn = [];
// 2
for (var i=0; i < response.length; i++) {
var tagToTransform = response[i];
var newTag = {
id: tagToTransform["name"],
text: tagToTransform["name"]
};
dataToReturn.push(newTag);
}
// 3
$("#categories").select2({
// 4
placeholder: "Select Categories for the Acronym",
// 5
tags: true,
// 6
tokenSeparators: [','],
// 7
data: dataToReturn
});
});
Here’s what the script does:
- On page load, send a GET request to /api/categories. This gets all the categories in the TIL app.
- Loop through each returned category and turn it into a JSON object and add it to
dataToReturn. The JSON object looks like:
{
"id": <id of the category>,
"text": <name of the category>
}
- Get the HTML element with the ID
categoriesand callselect2()on it. This enables Select2 on the<select>in the form. - Set the placeholder text on the Select2 input.
- Enable tags in Select2. This allows users to dynamically create new categories that don’t exist in the input.
- Set the separator for Select2. When a user types , Select2 creates a new category from the entered text. This allows users to create categories with spaces.
- Set the data — the options a user can choose from — to the existing categories.
Save the files, then build and run the app in Xcode. Navigate to the Create An Acronym page. The categories list allows you to input existing categories or create new ones. The list also allows you to add and remove the “tags” in a user-friendly way:
Displaying Categories
Now, open acronym.leaf. Under the “Created By” paragraph add the following:
<!-- 1 -->
#if(count(categories) > 0):
<!-- 2 -->
<h3>Categories</h3>
<ul>
<!-- 3 -->
#for(category in categories):
<li>
<a href="/categories/#(category.id)">
#(category.name)
</a>
</li>
#endfor
</ul>
#endif
Here’s what this does:
- Check if the template context has any categories.
- If so, create a heading and a
<ul>list. - Loop through the provided categories and add a link to each one.
Save the file and open WebsiteController.swift. Add a new property at the bottom of AcronymContext for the categories:
let categories: [Category]
In acronymHandler(_:), replace:
acronym.$user.get(on: req.db).flatMap { user in
let context = AcronymContext(
title: acronym.short,
acronym: acronym,
user: user)
return req.view.render("acronym", context)
}
With the following:
let userFuture = acronym.$user.get(on: req.db)
let categoriesFuture =
acronym.$categories.query(on: req.db).all()
return userFuture.and(categoriesFuture)
.flatMap { user, categories in
let context = AcronymContext(
title: acronym.short,
acronym: acronym,
user: user,
categories: categories)
return req.view.render("acronym", context)
}
This gets the acronym’s categories as well as its user. Build and run, then open the create acronym page in the browser. Create an acronym with categories in the browser and head to the acronym’s page. You’ll see the acronym’s categories on the page:
Editing acronyms
To allow adding and editing categories when editing an acronym, open createAcronym.leaf. In the categories <div>, between the <select> and </select> tags, add the following:
#if(editing):
<!-- 1 -->
#for(category in categories):
<!-- 2 -->
<option value="#(category.name)" selected="selected">
#(category.name)
</option>
#endfor
#endif
Here’s what this does:
-
If the
editingflag is set, loop through the array of provided categories. -
Add each category as an
<option>with theselectedattribute set. This allows the category tags to be pre-populated when editing a form.
Save the file. Open WebsiteController.swift and add a new property at the bottom of EditAcronymContext:
let categories: [Category]
In editAcronymHandler(_:) replace:
let context = EditAcronymContext(acronym: acronym, users: users)
return req.view.render("createAcronym", context)
with the following:
acronym.$categories.get(on: req.db).flatMap { categories in
let context = EditAcronymContext(
acronym: acronym,
users: users,
categories: categories)
return req.view.render("createAcronym", context)
}
This gets the acronyms categories and passes them to your new EditAcronymContext. Finally, replace editAcronymPostHandler(_:) with the following:
func editAcronymPostHandler(_ req: Request) throws
-> EventLoopFuture<Response> {
// 1
let updateData =
try req.content.decode(CreateAcronymFormData.self)
return Acronym
.find(req.parameters.get("acronymID"), on: req.db)
.unwrap(or: Abort(.notFound)).flatMap { acronym in
acronym.short = updateData.short
acronym.long = updateData.long
acronym.$user.id = updateData.userID
guard let id = acronym.id else {
return req.eventLoop
.future(error: Abort(.internalServerError))
}
// 2
return acronym.save(on: req.db).flatMap {
// 3
acronym.$categories.get(on: req.db)
}.flatMap { existingCategories in
// 4
let existingStringArray = existingCategories.map {
$0.name
}
// 5
let existingSet = Set<String>(existingStringArray)
let newSet = Set<String>(updateData.categories ?? [])
// 6
let categoriesToAdd = newSet.subtracting(existingSet)
let categoriesToRemove = existingSet
.subtracting(newSet)
// 7
var categoryResults: [EventLoopFuture<Void>] = []
// 8
for newCategory in categoriesToAdd {
categoryResults.append(
Category.addCategory(
newCategory,
to: acronym,
on: req))
}
// 9
for categoryNameToRemove in categoriesToRemove {
// 10
let categoryToRemove = existingCategories.first {
$0.name == categoryNameToRemove
}
// 11
if let category = categoryToRemove {
categoryResults.append(
acronym.$categories.detach(category, on: req.db))
}
}
let redirect = req.redirect(to: "/acronyms/\(id)")
// 12
return categoryResults.flatten(on: req.eventLoop)
.transform(to: redirect)
}
}
}
The important points in this new version are:
- Change the content type the request decodes to
CreateAcronymFormData. - Use
flatMap(_:)onsave(on:)but return all the acronym’s categories. Note the chaining of futures instead of nesting them. This helps improve the readability of your code. - Get all categories from the database.
- Create an array of category names from the categories in the database.
- Create a
Setfor the categories in the database and another for the categories supplied with the request. - Calculate the categories to add to the acronym and the categories to remove.
- Create an array of category operation results.
- Loop through all the categories to add and call
Category.addCategory(_:to:on:)to set up the relationship. Add each result to the results array. - Loop through all the category names to remove from the acronym.
- Get the
Categoryobject from the name of the category to remove. - If the
Categoryobject exists, usedetach(_:on:)to remove the relationship and delete the pivot. - Flatten all the future category results. Transform the result to redirect to the updated acronym’s page.
Build and run, then open an acronym page in the browser.
Click Edit and you’ll see the form populated with the existing categories:
Add a new category and click Update. The page redirects to the acronym’s page, with the updated acronym shown. Now try removing a category from an acronym.
Where to go from here?
In this section, you learned how to create a full-featured web app that performs the same functions as the iOS app. You learned how to use Leaf to display different types of data and work with futures. You also learned how to accept data from web forms and provide a good user-experience for handling data.
The TIL app contains both the API and the web app. This works well for small applications, but for very large applications you may consider splitting them up into their own apps. The web app then talks to the API like any other client would, such as the iOS app. This allows you to scale the different parts separately. Large applications may even be developed by different teams. Splitting them up lets the application grow and change, without reliance on the other team.
In the next section of the book, you’ll learn how to apply authentication to your application. Currently anyone can create any acronyms in both the iOS app and the web app. This isn’t desirable, especially for large systems. The next chapters show you how to protect both the API and web app with authentication.