第17章:制作一个简单的网络应用程序,第二部分¶
在上一章中,你学会了如何查看类别以及如何创建、编辑和删除首字母缩写。在这一章中,你将学习如何以用户友好的方式让用户为首字母缩写添加类别。
创建有类别的缩略语¶
网络应用的最后一项实现任务是允许用户管理缩写的类别。当使用REST
客户端(如iOS
应用)的API
时,你可以发送多个请求,每个类别一个。然而,这在网络浏览器中是不可行的。
网络应用必须接受一个请求中的所有信息,并将该请求翻译成适当的Fluent
操作。此外,在用户可以选择类别之前必须先创建类别,这并不能创造良好的用户体验。
打开Category.swift
,在底部添加以下扩展:
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)
}
}
}
}
}
以下是这个新扩展的作用:
- 进行查询,用所提供的名称搜索一个类别。
- 如果该类别存在,则建立关系。
- 如果类别不存在,用提供的名称创建一个新的
Category
对象。 - 保存新的类别,并解开返回的
future
。 - 使用保存的缩略语设置关系。
打开WebsiteController.swift
,在文件底部添加一个新的Content
类型来处理接受的类别:
struct CreateAcronymFormData: Content {
let userID: UUID
let short: String
let long: String
let categories: [String]?
}
这与AcronymsController.swift
中现有的CreateAcronymData
类似。CreateAcronymFormData
增加了一个可选的String
数组来表示类别。这允许用户提交现有的和新的类别,而不是只有现有的类别。
接下来,将createAcronymPostHandler(_:)
替换为以下内容:
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)
}
}
以下是你改变的内容:
- 改变
Content
类型以解码CreateAcronymFormData
。 - 使用
flatMap(_:)
而不是map(:_)
,因为你现在在闭包中返回一个EventLoopFuture
。 - 如果缩写保存失败,返回一个失败的
EventLoopFuture
,而不是抛出错误,因为你不能在flatMap(_:)
中抛出。 - 定义一个期货数组来存储保存操作。
- 循环浏览请求中提供的所有类别,并将
Category.addCategory(_:to:on:)
的结果添加到期货数组中。 - 扁平化数组以完成所有的
Fluent
操作,并将结果转化为Response
。将页面重定向到新的首字母缩写的页面。
接下来,你需要允许用户在创建首字母缩略时指定类别。打开createAcronym.leaf
,在<button>
部分上方,添加以下内容:
<!-- 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>
这是它的作用:
- 为类别定义一个新的
<div>
,用form-group
类的样式。 - 为输入指定一个标签。
- 定义一个
<select>
输入,让用户指定类别。multiple
属性让用户指定多个选项。名称categories[]
允许表单将类别作为URL
编码的数组发送。
目前,该表格显示没有任何类别。使用<select>
输入法只允许用户选择预先定义的类别。为了让用户有良好的体验,你将使用Select2 JavaScript library。
打开base.leaf
,在<link rel=stylesheet...
下为Bootstrap
样式表添加以下内容:
#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
这将Select2
的样式表添加到创建和编辑缩略语页面。注意复杂的Leaf
语句。在base.leaf
的底部,删除jQuery
的第一个<script>
标签,用以下内容代替:
<!-- 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:
下面是这个的作用:
- 包括完整的
jQuery
库。Bootstrap
只需要薄的版本,但Select2
需要的功能不包括在薄的版本中,所以必须包括全库。 - 如果该页面是创建或编辑首字母缩写的页面,则包括
Select2
的JavaScript
。 - 同时包括本地的
createAcronym.js
。
在Public
创建一个名为scripts
的目录,用于存放你的本地JavaScript文件。在这个新目录中,创建createAcronym.js
。打开新文件并插入以下内容:
// 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
});
});
以下是该脚本的作用:
- 在页面加载时,向
/api/categories
发送一个GET
请求。这将获得TIL
应用程序中的所有类别。 - 循环浏览每个返回的类别,将其变成一个
JSON
对象,并将其添加到dataToReturn
中。这个JSON
对象看起来像:
{
"id": <id of the category>,
"text": <name of the category>
}
- 获取
ID
为categories
的HTML
元素并对其调用select2()
。这样就可以在表单中的<select>
上启用Select2
。 - 在
Select2
输入上设置占位符文本。 - 在
Select2
中启用标签。这允许用户动态地创建在输入中不存在的新类别。 - 设置
Select2
的分隔符。当用户输入时,Select2
会根据输入的文本创建一个新的类别。这允许用户用空格来创建类别。 - 将数据--用户可以选择的选项--设置为现有的类别。
保存文件,然后在Xcode
中构建并运行该应用程序。导航到创建首字母缩写页面。类别列表允许您输入现有的类别或创建新的类别。该列表还允许您以一种用户友好的方式添加和删除"标签":
显示类别¶
现在,打开acronym.leaf
。在"创建者"段落下添加以下内容:
<!-- 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
下面是这个的作用:
- 检查模板上下文是否有任何类别。
- 如果有,创建一个标题和一个
<ul>
列表。 - 循环浏览所提供的类别,为每个类别添加一个链接。
保存文件并打开WebsiteController.swift
。在AcronymContext
的底部为类别添加一个新属性:
let categories: [Category]
在acronymHandler(_:)
中,替换:
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)
}
为下面内容:
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)
}
这可以得到首字母缩写的类别以及它的用户。建立并运行,然后在浏览器中打开创建首字母缩写的页面。在浏览器中创建一个带有类别的首字母缩写,然后进入该首字母缩写的页面。你会在页面上看到该首字母缩写的类别:
编辑缩略语¶
为了在编辑首字母缩写时允许添加和编辑类别,请打开createAcronym.leaf
。在类别<div>
中,在<select>
和</select>
标签之间,添加以下内容:
#if(editing):
<!-- 1 -->
#for(category in categories):
<!-- 2 -->
<option value="#(category.name)" selected="selected">
#(category.name)
</option>
#endfor
#endif
下面是这个的作用:
- 如果
editing
标志被设置,循环浏览所提供的类别阵列。 - 将每个类别添加为
<选项>
,并设置selected
属性。这允许在编辑表格时预先填入类别标签。
保存该文件。打开WebsiteController.swift
,在EditAcronymContext
的底部添加一个新属性:
let categories: [Category]
在editAcronymHandler(_:)
替换:
let context = EditAcronymContext(acronym: acronym, users: users)
return req.view.render("createAcronym", context)
为下面的内容:
acronym.$categories.get(on: req.db).flatMap { categories in
let context = EditAcronymContext(
acronym: acronym,
users: users,
categories: categories)
return req.view.render("createAcronym", context)
}
这将获得缩写的类别,并将它们传递给你新的EditAcronymContext
。最后,将editAcronymPostHandler(_:)
替换为以下内容:
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)
}
}
}
这个新版本中的重要内容是:
- 将请求解码的内容类型改为
CreateAcronymFormData
。 - 在
save(on:)
上使用flatMap(_:)
,但返回所有首字母缩写的类别。注意期货的链式结构,而不是嵌套式结构。这有助于提高你代码的可读性。 - 从数据库中获取所有类别。
- 从数据库中的类别创建一个类别名称数组。
- 为数据库中的类别创建一个
Set
,为请求中提供的类别创建另一个Set
。 - 计算要添加到缩略语中的类别和要删除的类别。
- 创建一个类别操作结果的数组。
- 循环浏览所有要添加的类别,并调用
Category.addCategory(_:to:on:)
来建立关系。将每个结果添加到结果数组中。 - 循环浏览所有要从缩写中删除的类别名称。
- 从要删除的类别名称中获取
Category
对象。 - 如果
Category
对象存在,使用detach(_:on:)
来删除关系并删除枢纽。 - 扁平化所有未来的类别结果。将结果转化为重定向到更新的缩略语页面。
建立并运行,然后在浏览器中打开一个首字母缩写页面。
点击Edit
,你会看到表格中填充了现有的类别:
添加一个新的类别,然后点击Update
。页面会重定向到首字母缩写的页面,并显示更新后的首字母缩写。现在试着从一个缩写中删除一个类别。
接下来去哪?¶
在本节中,你学习了如何创建一个全功能的Web
应用程序,执行与iOS
应用程序相同的功能。你学会了如何使用Leaf
来显示不同类型的数据并与future
一起工作。你还学习了如何接受来自Web
表单的数据,并为处理数据提供良好的用户体验。
TIL
应用包含API
和网络应用。这对小型应用来说效果很好,但对于非常大的应用,你可以考虑将它们拆分成自己的应用。然后,网络应用像任何其他客户端一样与API
对话,比如iOS
应用。这使你可以单独扩展不同的部分。大型应用甚至可以由不同的团队开发。将它们分割开来,可以让应用程序成长和变化,而不需要依赖其他团队。
在本书的下一节,你将学习如何将认证应用于你的应用程序。目前,任何人都可以在iOS
应用和Web
应用中创建任何首字母缩写。这并不可取,特别是对于大型系统来说。接下来的章节会告诉你如何用认证来保护API
和Web
应用。