Everywhere: Add title sorting, add error toasts for HTMX
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------
|
// -----------------------------------------
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user