Chapters

Hide chapters

Server-Side Swift with Vapor

Third Edition - Early Acess 1 · iOS 13 · Swift 5.2 - Vapor 4 Framework · Xcode 11.4

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: Creating a Simple Web API

Section 1: 13 chapters
Show chapters Hide chapters

17. Making a Simple Web App, Part 2
Written by Tim Condon

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Note: This update is an early-access release. This chapter has not yet been updated to Vapor 4.

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 function at the bottom of the extension below var acronyms:

static func addCategory(
  _ name: String,
  to acronym: Acronym,
  on req: Request
) throws -> Future<Void> {
  // 1
  return Category.query(on: req)
    .filter(\.name == name)
    .first()
    .flatMap(to: Void.self) { foundCategory in
      if let existingCategory = foundCategory {
        // 2
        return acronym.categories
          .attach(existingCategory, on: req)
          .transform(to: ())
      } else {
        // 3
        let category = Category(name: name)
        // 4
        return category.save(on: req)
          .flatMap(to: Void.self) { savedCategory in
          // 5
          return acronym.categories
            .attach(savedCategory, on: req)
            .transform(to: ())
        }
      }
  }
}

Here’s what this new function does:

  1. Perform a query to search for a category with the provided name.
  2. If the category exists, set up the relationship and transform the result to Void. () is shorthand for Void().
  3. If the category doesn’t exist, create a new Category object with the provided name.
  4. Save the new category and unwrap the returned future.
  5. Set up the relationship and transform the result to Void.

Open WebsiteController.swift and add a new Content type at the bottom of the file to handle the new data:

struct CreateAcronymData: Content {
  let userID: User.ID
  let short: String
  let long: String
  let categories: [String]?
}

This takes the existing information required for an acronym and 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:

// 1
func createAcronymPostHandler(
  _ req: Request,
  data: CreateAcronymData
) throws -> Future<Response> {
  // 2
  let acronym = Acronym(
    short: data.short,
    long: data.long,
    userID: data.userID)
  // 3
  return acronym.save(on: req)
    .flatMap(to: Response.self) { acronym in
      guard let id = acronym.id else {
        throw Abort(.internalServerError)
      }

      // 4
      var categorySaves: [Future<Void>] = []
      // 5
      for category in data.categories ?? [] {
        try categorySaves.append(
          Category.addCategory(category, to: acronym, on: req))
      }
      // 6
      let redirect = req.redirect(to: "/acronyms/\(id)")
      return categorySaves.flatten(on: req)
        .transform(to: redirect)
  }
}

Here’s what you changed:

  1. Change the Content type of route handler to accept CreateAcronymData.
  2. Create an Acronym object to save as it’s no longer passed into the route.
  3. Call flatMap(to:) instead of map(to:) as you now return a Future<Response> in the closure.
  4. Define an array of futures to store the save operations.
  5. Loop through all the categories provided to the request and add the results of Category.addCategory(_:to:on:) to the array.
  6. 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.

Finally, in boot(router:), replace the create acronym POST route with the following:

router.post(
  CreateAcronymData.self,
  at: "acronyms", "create",
  use: createAcronymPostHandler)

This changes the content type to CreateAcronymData.

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:

  1. Define a new <div> for categories that’s styled with the form-group class.
  2. Specify a label for the input.
  3. Define a <select> input to allow a user to specify categories. The multiple attribute lets a user specify multiple options. The name categories[] 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.

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.6-rc.0/css/select2.min.css" integrity="sha384-RdQbeSCGSeSdSlTMGnUr2oDJZzOuGjJAkQy1MbKMu8fZT5G0qlBajY0n0sY/hKMK" crossorigin="anonymous">
}

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.3.1.min.js" integrity="sha384-tsQFqpEReu7ZLhBV2VZlAu7zcOV+rXbYlF2cqB8txI/8aZajjp4Bqd+V6D5IgvKT" crossorigin="anonymous"></script>
#// 2
#if(title == "Create An Acronym" || title == "Edit Acronym") {
  <script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js" integrity="sha384-222hzbb8Z8ZKe6pzP18nTSltQM3PdcAwxWKzGOKOIF+Y3bROr5n9zdQ8yTRHgQkQ" crossorigin="anonymous"></script>
  #// 3
  <script src="/scripts/createAcronym.js"></script>
}

Here’s what this does:

  1. Include the full jQuery library. Bootstrap only requires the slim version, but Select2 requires functionality not included in the slim version, so the full library is required.
  2. If the page is the create or edit acronym page, include the JavaScript for Select2.
  3. Also include the local createAcronym.js.

In Terminal, enter the following commands to create your local JavaScript file.

mkdir Public/scripts
touch Public/scripts/createAcronym.js

Open the 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:

  1. On page load, send a GET request to /api/categories. This gets all the categories in the TIL app.
  2. Loop through each returned category and turn it into a JSON object and add it to dataToReturn. The JSON object looks like:
{
  "id": <name of the category>,
  "text": <name of the category>
}
  1. Get the HTML element with the ID categories and call select2() on it. This enables Select2 on the <select> in the form.
  2. Set the placeholder text on the Select2 input.
  3. Enable tags in Select2. This allows users to dynamically create new categories that don’t exist in the input.
  4. Set the separator for Select2. When a user types , Select2 creates a new category from the entered text. This allows users to categories with spaces.
  5. 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>
    }
  </ul>
}
let categories: Future<[Category]>
let context = AcronymContext(
  title: acronym.short,
  acronym: acronym,
  user: user)
let categories = try acronym.categories.query(on: req).all()
let context = AcronymContext(
  title: acronym.short,
  acronym: acronym,
  user: user,
  categories: categories)

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>
  }
}
let categories: Future<[Category]>
let context = EditAcronymContext(
  acronym: acronym,
  users: User.query(on: req).all())
let users = User.query(on: req).all()
let categories = try acronym.categories.query(on: req).all()
let context = EditAcronymContext(
  acronym: acronym,
  users: users,
  categories: categories)
func editAcronymPostHandler(_ req: Request) throws
  -> Future<Response> {
  // 1
  return try flatMap(
    to: Response.self,
    req.parameters.next(Acronym.self),
    req.content
      .decode(CreateAcronymData.self)) { acronym, data in
        acronym.short = data.short
        acronym.long = data.long
        acronym.userID = data.userID

        guard let id = acronym.id else {
          throw Abort(.internalServerError)
        }

        // 2
        return acronym.save(on: req)
          .flatMap(to: [Category].self) { _ in
            // 3
            try acronym.categories.query(on: req).all()
        }.flatMap(to: Response.self) { existingCategories in
          // 4
          let existingStringArray = existingCategories.map {
            $0.name
          }

          // 5
          let existingSet = Set<String>(existingStringArray)
          let newSet = Set<String>(data.categories ?? [])

          // 6
          let categoriesToAdd = newSet.subtracting(existingSet)
          let categoriesToRemove = existingSet
            .subtracting(newSet)

          // 7
          var categoryResults: [Future<Void>] = []
          // 8
          for newCategory in categoriesToAdd {
            categoryResults.append(
              try 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))
            }
          }

          let redirect = req.redirect(to: "/acronyms/\(id)")
          // 12
          return categoryResults.flatten(on: req)
            .transform(to: redirect)
        }
    }
}

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now