Browse Source

Everywhere: Add title sorting, add error toasts for HTMX

master
Riyyi 4 weeks ago
parent
commit
293503d7a2
  1. 2
      README.org
  2. 2
      Sources/App/Controllers/API/TodoAPIController.swift
  3. 57
      Sources/App/Controllers/TodoController.swift
  4. 24
      Sources/App/Middleware/CustomErrorMiddleware.swift
  5. 1
      Sources/App/UserState/UserState.swift
  6. 45
      Sources/App/Views/Components/TodosTableComponent.swift
  7. 0
      Sources/App/Views/Layouts/MainLayout.swift
  8. 4
      Sources/App/routes.swift

2
README.org

@ -7,4 +7,4 @@ Website, written in Swift 6.
- [[https://github.com/vapor/vapor][Vapor]] HTTP web framework - [[https://github.com/vapor/vapor][Vapor]] HTTP web framework
- [[https://github.com/vapor/fluent.git][Fluent]] ORM - [[https://github.com/vapor/fluent.git][Fluent]] ORM
- [[https://github.com/sliemeobn/elementary][Elementary]] HTML templating - [[https://github.com/sliemeobn/elementary][Elementary]] HTML templating
- [[https://github.com/bigskysoftware/htmx][htmx]] font-end JS - [[https://github.com/bigskysoftware/htmx][htmx]] front-end JS

2
Sources/App/Controllers/API/TodoAPIController.swift

@ -1,6 +1,8 @@
import Fluent import Fluent
import Vapor import Vapor
// The code in this file is unused
struct TodoAPIController: RouteCollection { struct TodoAPIController: RouteCollection {
func boot(routes: RoutesBuilder) throws { func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos") let todos = routes.grouped("todos")

57
Sources/App/Controllers/TodoController.swift

@ -10,25 +10,30 @@ struct TodoController: RouteCollection {
func boot(routes: RoutesBuilder) throws { func boot(routes: RoutesBuilder) throws {
routes.group("todos") { todos in routes.group("todos") { todos in
todos.get(use: index) todos.get(use: index)
todos.post(use: create) todos.post(use: create)
todos.group(":id") { todo in todos.group(":id") { todo in
todo.delete(use: delete) todo.delete(use: delete)
} }
todos.group("sort") { todo in
todo.get(use: sort)
}
} }
} }
@Sendable @Sendable
func index(req: Request) async throws -> HTMLResponse { func index(req: Request) async throws -> HTMLResponse {
let todos = try await todos(db: req.db) let state = try getState(request: req)
state.todos.table.name = "todos"
state.todos.table.sort["title"] = state.todos.table.sort["title"] ?? .ascending
let todos = try await todos(db: req.db, title: state.todos.table.sort["title"]!)
state.todos.table.todos = todos
state.todos.table.refresh = false
return HTMLResponse { return HTMLResponse {
MainLayout(title: "Todos") { MainLayout(title: "Todos") {
TodosTableComponent(name: "todos", todos: todos) TodosPage(table: state.todos.table)
TodosFormComponent(name: "todos-form", target: "todos")
button(.class("btn btn-primary"), .type(.button), .hx.get("/test/toast")) { "Toast" }
} }
} }
} }
@ -37,31 +42,35 @@ struct TodoController: RouteCollection {
func create(req: Request) async throws -> HTMLResponse { func create(req: Request) async throws -> HTMLResponse {
do { do {
try TodoDTO.validate(content: req) try TodoDTO.validate(content: req)
} } catch let error as ValidationsError {
catch let error as ValidationsError {
return HTMLResponse { return HTMLResponse {
TodosFormComponent(name: "todos-form", target: "todos", errors: ["title": error.description]) TodosFormComponent(
name: "todos-form", target: "todos", errors: ["title": error.description])
} }
} }
let todo = try req.content.decode(TodoDTO.self).toModel() let todo = try req.content.decode(TodoDTO.self).toModel()
try await todo.save(on: req.db) try await todo.save(on: req.db)
let todos = try await todos(db: req.db) let state = try getState(request: req)
let todos = try await todos(db: req.db, title: state.todos.table.sort["title"] ?? .ascending)
state.todos.table.todos = todos
state.todos.table.refresh = true
return HTMLResponse { return HTMLResponse {
// Return the empty form // Return the empty form
TodosFormComponent(name: "todos-form", target: "todos") TodosFormComponent(name: "todos-form", target: "todos")
// Also update the todos table // Also update the todos table
TodosTableComponent(name: "todos", todos: todos, refresh: true) // TODO: Put component names inside variables TodosTableComponent(state: state.todos.table) // TODO: Put component names inside variables
} }
} }
@Sendable @Sendable
func delete(req: Request) async throws -> HTMLResponse { func delete(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else { let todo = try await Todo.find(uuid, on: req.db)
else {
throw Abort(.notFound) throw Abort(.notFound)
} }
@ -70,11 +79,31 @@ struct TodoController: RouteCollection {
return HTMLResponse {} // TODO: Return 204 No Content return HTMLResponse {} // TODO: Return 204 No Content
} }
struct Sort: Content {
let title: String
}
@Sendable
func sort(req: Request) async throws -> HTMLResponse {
let state = try getState(request: req)
let sort = try req.query.decode(Sort.self)
state.todos.table.sort["title"] = sort.title == "descending" ? .descending : .ascending
let todos = try await todos(db: req.db, title: state.todos.table.sort["title"]!)
state.todos.table.todos = todos
state.todos.table.refresh = true
return HTMLResponse {
TodosTableComponent(state: state.todos.table)
}
}
// ------------------------------------- // -------------------------------------
@Sendable @Sendable
private func todos(db: any Database) async throws -> [TodoDTO] { private func todos(db: any Database, title: DatabaseQuery.Sort.Direction = .ascending) async throws -> [TodoDTO] {
try await Todo.query(on: db).all().map { $0.toDTO() } try await Todo.query(on: db).sort("title", title).all().map { $0.toDTO() }
} }
private func hexToUUID(hex: String) -> UUID? { private func hexToUUID(hex: String) -> UUID? {

24
Sources/App/Middleware/CustomErrorMiddleware.swift

@ -23,13 +23,13 @@ public final class CustomErrorMiddleware: Middleware {
} }
return next.respond(to: request).flatMapErrorThrowing { error in return next.respond(to: request).flatMapErrorThrowing { error in
self.makeResponse(with: request, reason: error) try self.makeResponse(with: request, reason: error)
} }
} }
// ------------------------------------- // -------------------------------------
private func makeResponse(with req: Request, reason error: Error) -> Response { private func makeResponse(with req: Request, reason error: Error) throws -> Response {
let reason: String let reason: String
let status: HTTPResponseStatus let status: HTTPResponseStatus
var headers: HTTPHeaders var headers: HTTPHeaders
@ -57,11 +57,27 @@ public final class CustomErrorMiddleware: Middleware {
headers.contentType = .html headers.contentType = .html
// Render error to a page
let statusCode = String(status.code) let statusCode = String(status.code)
let body = Response.Body(string: MainLayout(title: "Error \(statusCode))") { var body = Response.Body()
// Display error in toast message for HTMX requests
if let isHTMXHeader = req.headers.first(name: "HX-Request"), isHTMXHeader == "true" {
let state = try getState(request: req)
// Only set a new toast if the endpoint hasnt already
if state.toast.message.isEmpty {
state.toast = ToastState(message: reason,
title: "Error \(statusCode)",
level: ToastState.Level.error)
}
headers.add(name: "HX-Trigger", value: "toast")
}
// Render error to a page
else {
body = Response.Body(string: MainLayout(title: "Error \(statusCode))") {
ErrorPage(status: statusCode, reason: reason) ErrorPage(status: statusCode, reason: reason)
}.render()) }.render())
}
// Create a Response with appropriate status // Create a Response with appropriate status
return Response(status: status, headers: headers, body: body) return Response(status: status, headers: headers, body: body)

1
Sources/App/UserState/UserState.swift

@ -4,6 +4,7 @@ import Vapor
public final class UserState: @unchecked Sendable { public final class UserState: @unchecked Sendable {
var toast: ToastState = ToastState() var toast: ToastState = ToastState()
var todos: TodosState = TodosState()
} }
// ----------------------------------------- // -----------------------------------------

45
Sources/App/Views/Components/TodosTableComponent.swift

@ -4,22 +4,25 @@ import Fluent
struct TodosTableComponent: HTML { struct TodosTableComponent: HTML {
var name: String var state: TodosTableState = TodosTableState()
var todos: [TodoDTO]
var refresh: Bool = false
// ------------------------------------- // -------------------------------------
var content: some HTML { var content: some HTML {
div(.id("cdiv_" + name), refresh ? .hx.swapOOB(.outerHTML) : .empty()) { div(.id("cdiv_" + state.name), state.refresh ? .hx.swapOOB(.outerHTML) : .empty()) {
table(.class("table")) { table(.class("table")) {
thead { thead {
tr { tr {
th { "#" } th { "#" }
th { "ID" } th { "ID" }
th { th {
let order = state.sort["title"]?.description ?? "ascending"
span(.style("cursor: pointer;"),
.hx.get("/\(state.name)/sort?title=\(order == "descending" ? "ascending" : "descending")"),
.hx.target("closest div")) {
"Title " "Title "
i(.class("bi bi-arrow-down-circle")) {} i(.class("bi bi-arrow-\(order == "descending" ? "down" : "up")-circle")) {}
}
} }
th { "Modifier" } th { "Modifier" }
} }
@ -28,7 +31,7 @@ struct TodosTableComponent: HTML {
.hx.confirm("Are you sure?"), .hx.target("closest tr"), .hx.confirm("Are you sure?"), .hx.target("closest tr"),
.hx.swap(.outerHTML, "swap:0.5s") .hx.swap(.outerHTML, "swap:0.5s")
) { ) {
for (index, todo) in todos.enumerated() { for (index, todo) in state.todos.enumerated() {
tr { tr {
td { "\(index)" } td { "\(index)" }
td { todo.id?.uuidString ?? "" } td { todo.id?.uuidString ?? "" }
@ -39,7 +42,7 @@ struct TodosTableComponent: HTML {
.class("bi bi-trash3 text-danger"), .class("bi bi-trash3 text-danger"),
.data("bs-toggle", value: "tooltip"), .data("bs-toggle", value: "tooltip"),
.data("bs-title", value: "Delete"), .data("bs-title", value: "Delete"),
.hx.delete("/\(name)/\(id.uuidString)") .hx.delete("/\(state.name)/\(id.uuidString)")
) {} ) {}
} }
} }
@ -48,8 +51,34 @@ struct TodosTableComponent: HTML {
} }
} }
ScriptAfterLoad(initial: !refresh) { "web.tooltips();" }; ScriptAfterLoad(initial: !state.refresh) { "web.tooltips();" };
} }
} }
} }
struct Modal: HTML {
var content: some HTML {
div(.class("modal fade"), .id("alertModal"), .tabindex(-1)) {
div(.class("modal-dialog modal-dialog-centered")) {
div(.class("modal-content")) {
div(.class("modal-header border-bottom-0")) {
button(
.class("btn-close"), .type(.button), .data("bs-dismiss", value: "modal")
) {}
}
div(.class("modal-body")) {
div(.class("alert alert-danger")) {
"Toby123"
}
}
}
}
}
button(
.class("btn btn-primary"), .type(.button), .data("bs-toggle", value: "modal"),
.data("bs-target", value: "#alertModal")
) { "Show" }
}
}

0
Sources/App/Views/Shared/MainLayout.swift → Sources/App/Views/Layouts/MainLayout.swift

4
Sources/App/routes.swift

@ -31,9 +31,11 @@ func routes(_ app: Application) throws {
@Sendable @Sendable
func toast(req: Request) throws -> HTMLResponse { func toast(req: Request) throws -> HTMLResponse {
let state = try getState(request: req) let state = try getState(request: req)
let toast = state.toast
state.toast = ToastState() // Clear toast
return HTMLResponse { return HTMLResponse {
ToastComponent(state: state.toast) ToastComponent(state: toast)
} }
} }

Loading…
Cancel
Save