Everywhere: Add title sorting, add error toasts for HTMX
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import Fluent
|
||||
import Vapor
|
||||
|
||||
// The code in this file is unused
|
||||
|
||||
struct TodoAPIController: RouteCollection {
|
||||
func boot(routes: RoutesBuilder) throws {
|
||||
let todos = routes.grouped("todos")
|
||||
|
||||
@@ -9,26 +9,31 @@ import VaporElementary
|
||||
struct TodoController: RouteCollection {
|
||||
func boot(routes: RoutesBuilder) throws {
|
||||
routes.group("todos") { todos in
|
||||
todos.get(use: index)
|
||||
|
||||
todos.get(use: index)
|
||||
todos.post(use: create)
|
||||
|
||||
todos.group(":id") { todo in
|
||||
todo.delete(use: delete)
|
||||
}
|
||||
|
||||
todos.group("sort") { todo in
|
||||
todo.get(use: sort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Sendable
|
||||
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 {
|
||||
MainLayout(title: "Todos") {
|
||||
TodosTableComponent(name: "todos", todos: todos)
|
||||
TodosFormComponent(name: "todos-form", target: "todos")
|
||||
|
||||
button(.class("btn btn-primary"), .type(.button), .hx.get("/test/toast")) { "Toast" }
|
||||
TodosPage(table: state.todos.table)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,44 +42,68 @@ struct TodoController: RouteCollection {
|
||||
func create(req: Request) async throws -> HTMLResponse {
|
||||
do {
|
||||
try TodoDTO.validate(content: req)
|
||||
}
|
||||
catch let error as ValidationsError {
|
||||
} catch let error as ValidationsError {
|
||||
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()
|
||||
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 the empty form
|
||||
TodosFormComponent(name: "todos-form", target: "todos")
|
||||
|
||||
// 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
|
||||
func delete(req: Request) async throws -> HTMLResponse {
|
||||
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)
|
||||
}
|
||||
|
||||
try await todo.delete(on: req.db)
|
||||
|
||||
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
|
||||
private func todos(db: any Database) async throws -> [TodoDTO] {
|
||||
try await Todo.query(on: db).all().map { $0.toDTO() }
|
||||
private func todos(db: any Database, title: DatabaseQuery.Sort.Direction = .ascending) async throws -> [TodoDTO] {
|
||||
try await Todo.query(on: db).sort("title", title).all().map { $0.toDTO() }
|
||||
}
|
||||
|
||||
private func hexToUUID(hex: String) -> UUID? {
|
||||
|
||||
@@ -23,13 +23,13 @@ public final class CustomErrorMiddleware: Middleware {
|
||||
}
|
||||
|
||||
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 status: HTTPResponseStatus
|
||||
var headers: HTTPHeaders
|
||||
@@ -57,11 +57,27 @@ public final class CustomErrorMiddleware: Middleware {
|
||||
|
||||
headers.contentType = .html
|
||||
|
||||
// Render error to a page
|
||||
let statusCode = String(status.code)
|
||||
let body = Response.Body(string: MainLayout(title: "Error \(statusCode))") {
|
||||
ErrorPage(status: statusCode, reason: reason)
|
||||
}.render())
|
||||
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)
|
||||
}.render())
|
||||
}
|
||||
|
||||
|
||||
// Create a Response with appropriate status
|
||||
return Response(status: status, headers: headers, body: body)
|
||||
|
||||
@@ -4,6 +4,7 @@ import Vapor
|
||||
|
||||
public final class UserState: @unchecked Sendable {
|
||||
var toast: ToastState = ToastState()
|
||||
var todos: TodosState = TodosState()
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
|
||||
@@ -4,22 +4,25 @@ import Fluent
|
||||
|
||||
struct TodosTableComponent: HTML {
|
||||
|
||||
var name: String
|
||||
var todos: [TodoDTO]
|
||||
var refresh: Bool = false
|
||||
var state: TodosTableState = TodosTableState()
|
||||
|
||||
// -------------------------------------
|
||||
|
||||
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")) {
|
||||
thead {
|
||||
tr {
|
||||
th { "#" }
|
||||
th { "ID" }
|
||||
th {
|
||||
"Title "
|
||||
i(.class("bi bi-arrow-down-circle")) {}
|
||||
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 "
|
||||
i(.class("bi bi-arrow-\(order == "descending" ? "down" : "up")-circle")) {}
|
||||
}
|
||||
}
|
||||
th { "Modifier" }
|
||||
}
|
||||
@@ -28,7 +31,7 @@ struct TodosTableComponent: HTML {
|
||||
.hx.confirm("Are you sure?"), .hx.target("closest tr"),
|
||||
.hx.swap(.outerHTML, "swap:0.5s")
|
||||
) {
|
||||
for (index, todo) in todos.enumerated() {
|
||||
for (index, todo) in state.todos.enumerated() {
|
||||
tr {
|
||||
td { "\(index)" }
|
||||
td { todo.id?.uuidString ?? "" }
|
||||
@@ -39,7 +42,7 @@ struct TodosTableComponent: HTML {
|
||||
.class("bi bi-trash3 text-danger"),
|
||||
.data("bs-toggle", value: "tooltip"),
|
||||
.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" }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,11 @@ func routes(_ app: Application) throws {
|
||||
@Sendable
|
||||
func toast(req: Request) throws -> HTMLResponse {
|
||||
let state = try getState(request: req)
|
||||
let toast = state.toast
|
||||
state.toast = ToastState() // Clear toast
|
||||
|
||||
return HTMLResponse {
|
||||
ToastComponent(state: state.toast)
|
||||
ToastComponent(state: toast)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user