添加分类
本章给缩略语加上「分类标签」:先在模型层实现给缩略语挂分类的方法,再用 Select2 美化多选框,最后在创建与编辑时保存并同步分类。
给 Category 模型加扩展方法
为 Category 添加一个扩展方法 addCategory(_:to:on:):先按名字查找分类,若已存在就直接建立关联;否则新建分类并保存后再关联。返回一个 Future<Void>。
extension Category {
...
static func addCategory(_ name: String,
to acronym: Acronym,
on req: Request
) throws -> Future<Void> {
return Category.query(on: req).filter(\.name == name).first()
.flatMap(to: Void.self) { foundCategory in
if let existingCategory = foundCategory {
return acronym.categories.attach(existingCategory, on: req)
.transform(to: ())
}
else {
let category = Category(name: name)
return category.save(on: req).flatMap(to: Void.self) { savedCategory in
return acronym.categories.attach(savedCategory, on: req).transform(to: ())
}
}
}
}
}
创建时接收并保存分类
定义一个 CreateAcronymData 结构体接收表单,其中 categories 是可选的字符串数组。
struct CreateAcronymData: Content {
let userID: User.ID
let short: String
let long: String
let categories: [String]?
}
把创建路由改成解码 CreateAcronymData,保存缩略语后遍历分类逐个调用 addCategory,用 flatten(on:) 等全部完成后重定向。
router.post(CreateAcronymData.self, at: "acronyms", "create", use: createAcronymPostHandler)
func createAcronymPostHandler(_ req: Request, data: CreateAcronymData) throws -> Future<Response> {
let acronym = Acronym(short: data.short, long: data.long, userID: data.userID)
return acronym.save(on: req).flatMap(to: Response.self) { acronym in
guard let id = acronym.id else {
throw Abort(HTTPStatus.internalServerError)
}
var categorySaves: [Future<Void>] = []
for category in data.categories ?? [] {
try categorySaves.append(Category.addCategory(category, to: acronym, on: req))
}
let redirect = req.redirect(to: "/acronyms/\(id)")
return categorySaves.flatten(on: req).transform(to: redirect)
}
}
用 Select2 美化多选框
在 createAcronym.leaf 中把分类做成一个 multiple 的多选下拉框,name="categories[]" 可发送经 URL 编码的数组数据。
...
<div class="form-group">
<label for="categories">Categories</label>
<select name="categories[]" class="form-control" id="categories" placeholder="Categories" multiple="multiple">
</select>
</div>
<button type="submit" class = "btn btn-primary">
#if(editing) { Update } else { Submit }
</button>
</form>
}
...
由于现有缩略语还没有分类数据,下拉框是空的也不好看,所以引入 Select2 这个 JS 库来美化。因为 jQuery 的 slim 版本缺少 Select2 所需函数,需换用完整版 jQuery,并在模板里引入本地 JS。
<head>
...
#if(title=="Create An Acronym") {
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.css" />
}
#if(title=="Edit Acronym"){
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/css/select2.css" />
}
<title>#(title) | Acronyms</title>
</head>
...
<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
#if(title=="Create An Acronym") {
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/js/select2.min.js"></script>
<script src="/scripts/createAcronym.js"></script>
}
#if(title == "Edit Acronym"){
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.5/js/select2.min.js"></script>
<script src="/scripts/createAcronym.js"></script>
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
</body>
本地 Public/scripts/createAcronym.js 通过 AJAX 拉取分类列表,喂给 Select2 渲染成可搜索、可新建标签的下拉框。
$.ajax({
url: "/api/categories/",
type: "GET",
contentType: "application/json; charset=utf-8"
})
.then(function (response) {
var dataToReturn = [];
for(var i = 0; i < response.length; i++) {
var tagToTransform = response[i];
var newTag = {
id: tagToTransform["name"],
text: tagToTransform["name"]
};
dataToReturn.push(newTag);
}
$("#categories").select2({
placeholder: "Select Categories for the Acronym",
tags: true,
tokenSeparator: [','],
data: dataToReturn
});
});
详情页显示分类
在 acronym.leaf 里用 #if(count(categories) > 0) 列出该缩略语的分类,每个分类链接到分类详情页。
#set("content"){
...
#if(count(categories) > 0) {
<h3>Categories</h3>
<ul>
#for(category in categories) {
<li>
<a href="/category/#(category.id)">
#(category.name)
</a>
</li>
}
</ul>
}
<form method="post" action="/acronyms/#(acronym.id)/delete">
<a class="btn btn-primary" href="/acronyms/#(acronym.id)/edit" role="button">Edit</a>
<input class="btn btn-danger" type="submit" value="Delete" />
</form>
}
#embed("base")
详情页控制器 acronymHandler 也需把分类查出来放进上下文。
struct AcronymContext: Encodable {
let title: String
let acronym: Acronym
let user: User
let categories: Future<[Category]>
}
...
func acronymHandler(_ req: Request) throws -> Future<View> {
return try req.parameters.next(Acronym.self)
.flatMap(to: View.self) { acronym in
return acronym.user.get(on: req)
.flatMap(to: View.self) { user in
let categories = try acronym.categories.query(on: req).all()
let context = AcronymContext(
title: acronym.short,
acronym: acronym,
user: user,
categories: categories)
return try req.view().render("acronym", context)
}
}
}
编辑时同步分类
目前编辑时选中的分类不会被保存,需要改进。先给 EditAcronymContext 加上 categories,编辑页方法查询出已有分类。
struct EditAcronymContext: Encodable {
let title = "Edit Acronym"
let acronym: Acronym
let users: Future<[User]>
let editing = true
let categories: Future<[Category]>
}
...
func editAcronymHandler(_ req: Request) throws -> Future<View> {
return try req.parameters.next(Acronym.self)
.flatMap(to: View.self) { acronym in
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)
return try req.view().render("createAcronym", context)
}
}
提交处理方法改为解码 CreateAcronymData,保存后对比「已有分类集合」与「新选分类集合」,用集合的差集算出要新增和要移除的,再分别 addCategory / detach,最后统一 flatten(on:) 后重定向。
func editAcronymPostHandler(_ req: Request) throws -> Future<Response> {
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
return acronym.save(on: req).flatMap(to: Response.self) { savedAcronym in
guard let id = savedAcronym.id else {
throw Abort(HTTPResponseStatus.internalServerError)
}
return try acronym.categories.query(on: req).all()
.flatMap(to: Response.self) { existingCategories in
let existingStringArry = existingCategories.map { $0.name }
let existingSet = Set<String>(existingStringArry)
let newSet = Set<String>(data.categories ?? [])
let categoriesToAdd = newSet.subtracting(existingSet)
let categoriesToRemove = existingSet.subtracting(newSet)
var categoryResults: [Future<Void>] = []
for newCategory in categoriesToAdd {
categoryResults.append(try Category.addCategory(newCategory, to: acronym, on: req))
}
for categoryNameToRemove in categoriesToRemove {
let categoryToRemove = existingCategories.first {
$0.name == categoryNameToRemove
}
if let category = categoryToRemove {
categoryResults.append(acronym.categories.detach(category, on: req))
}
}
return categoryResults.flatten(on: req)
.transform(to: req.redirect(to: "/acronyms/\(id)"))
}
}
}
}
Set 的差集来同步分类,可避免重复添加或漏删,是处理「多对多关系增量更新」的常用技巧。