From 293503d7a29f850a1b0511f38e739b7b549c7332 Mon Sep 17 00:00:00 2001 From: Riyyi Date: Sat, 23 Nov 2024 00:52:26 +0100 Subject: [PATCH] Everywhere: Add title sorting, add error toasts for HTMX --- README.org | 2 +- .../Controllers/API/TodoAPIController.swift | 2 + Sources/App/Controllers/TodoController.swift | 61 ++++++++++++++----- .../Middleware/CustomErrorMiddleware.swift | 28 +++++++-- Sources/App/UserState/UserState.swift | 1 + .../Components/TodosTableComponent.swift | 47 +++++++++++--- .../{Shared => Layouts}/MainLayout.swift | 0 Sources/App/routes.swift | 4 +- 8 files changed, 112 insertions(+), 33 deletions(-) rename Sources/App/Views/{Shared => Layouts}/MainLayout.swift (100%) diff --git a/README.org b/README.org index 0191e58..b4e47fb 100644 --- a/README.org +++ b/README.org @@ -7,4 +7,4 @@ Website, written in Swift 6. - [[https://github.com/vapor/vapor][Vapor]] HTTP web framework - [[https://github.com/vapor/fluent.git][Fluent]] ORM - [[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 diff --git a/Sources/App/Controllers/API/TodoAPIController.swift b/Sources/App/Controllers/API/TodoAPIController.swift index f4ece67..4ffe7e1 100644 --- a/Sources/App/Controllers/API/TodoAPIController.swift +++ b/Sources/App/Controllers/API/TodoAPIController.swift @@ -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") diff --git a/Sources/App/Controllers/TodoController.swift b/Sources/App/Controllers/TodoController.swift index dd468e6..4823740 100644 --- a/Sources/App/Controllers/TodoController.swift +++ b/Sources/App/Controllers/TodoController.swift @@ -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? { diff --git a/Sources/App/Middleware/CustomErrorMiddleware.swift b/Sources/App/Middleware/CustomErrorMiddleware.swift index bbcb9bd..1518a71 100644 --- a/Sources/App/Middleware/CustomErrorMiddleware.swift +++ b/Sources/App/Middleware/CustomErrorMiddleware.swift @@ -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) diff --git a/Sources/App/UserState/UserState.swift b/Sources/App/UserState/UserState.swift index b8860ac..e8ede45 100644 --- a/Sources/App/UserState/UserState.swift +++ b/Sources/App/UserState/UserState.swift @@ -4,6 +4,7 @@ import Vapor public final class UserState: @unchecked Sendable { var toast: ToastState = ToastState() + var todos: TodosState = TodosState() } // ----------------------------------------- diff --git a/Sources/App/Views/Components/TodosTableComponent.swift b/Sources/App/Views/Components/TodosTableComponent.swift index 1d0ef6b..2c290c6 100644 --- a/Sources/App/Views/Components/TodosTableComponent.swift +++ b/Sources/App/Views/Components/TodosTableComponent.swift @@ -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" } + + } +} diff --git a/Sources/App/Views/Shared/MainLayout.swift b/Sources/App/Views/Layouts/MainLayout.swift similarity index 100% rename from Sources/App/Views/Shared/MainLayout.swift rename to Sources/App/Views/Layouts/MainLayout.swift diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 657cdb5..a40fac1 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -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) } }