feat: working on detail views.

This commit is contained in:
2025-01-12 17:42:06 -05:00
parent 0e31d2c30c
commit 1ce369e156
27 changed files with 527 additions and 137 deletions

View File

@@ -147,13 +147,17 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus {
background-color: #555; background-color: #555;
} }
.toggle img { .toggle, .toggle img {
background-color: inherit; background-color: inherit;
width: 60px; width: 60px;
height: 30px; height: 30px;
}
a.toggle, a img.toggle {
cursor: pointer; cursor: pointer;
} }
.toggle img:hover { .toggle img:hover {
background-color: #555; background-color: #555;
} }
@@ -221,13 +225,15 @@ tr.htmx-swapping td {
overflow: auto; overflow: auto;
z-index: 1; z-index: 1;
position: fixed; position: fixed;
top: 100px; top: 60px;
left: 0; left: 0;
background-color: #14141f; background-color: #14141f;
width: 100%; width: 100%;
} }
.closebtn { .closebtn {
border: none;
background-color: inherit;
color: grey; color: grey;
margin-left: 50px; margin-left: 50px;
text-decoration: none; text-decoration: none;
@@ -309,10 +315,7 @@ tr.htmx-swapping td {
background-color: inherit; background-color: inherit;
font-size: 1.3em; font-size: 1.3em;
padding-bottom: 10px; padding-bottom: 10px;
} cursor: pointer;
.btn-row button:hover {
color: blue;
} }
.btn-detail { .btn-detail {
@@ -336,3 +339,72 @@ tr.htmx-swapping td {
.label { .label {
color: #00ffcc; color: #00ffcc;
} }
.float {
z-index: 1;
position: absolute;
top: 60px;
left: 0;
width: 100%;
background-color: #14141f;
padding: 20px;
}
.float .closebtn {
position: relative;
float: right;
top: 0;
right: 5px;
font-size: 36px;
margin-left: 50px;
color: grey;
}
.float .closebtn:hover {
color: white;
}
.float.htmx-swapping {
opacity: 0;
transition: opacity 0.5s ease-out;
}
.float table {
position: relative;
top: 15px;
}
.btn-row {
margin-top: 40px;
margin-right: 5px;
}
.btn-row button {
float: right;
padding: 10px 20px;
border-radius: 10px;
margin-left: 20px;
}
button.danger {
background-color: #ff4d4d;
color: white;
}
button.edit {
float: right;
background-color: #9999ff;
color: white;
}
.danger:hover, .edit:hover {
opacity: 0.8;
}
form table input[type=text] {
border: none;
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
font-size: 1.5em;
}

View File

@@ -0,0 +1,63 @@
<div id="employee-detail" class="float" #if(!employee): style="display:none;" #endif>
#if(employee):
<button class="closebtn"
hx-get="/employees"
hx-target="body"
hx-push-url="true"
hx-swap="outerHTML"
hx-disable-elt="this"
>
&times;
</button>
<form hx-put="/employees/#(employee.id)"
hx-target="#employee_#(employee.id)"
hx-swap="outerHTML"
hx-disable-elt="this"
hx-on::after-request="window.location.href='/employees'; toggleContent('employee-detail');"
>
<table>
<tbody>
<tr>
<td style="width: 20%;"><h3 class="label">First Name:</h3></td>
<td><input type="text" name="firstName" value="#(employee.firstName)"></td>
</tr>
<tr>
<td><h3 class="label">Last Name:</h3></td>
<td><input type="text" name="lastName" value="#(employee.lastName)"></td>
</tr>
<tr>
<td><h3 class="label">Active:</h3></td>
<td>
#if(employee.active):
<a class="toggle"
hx-patch="/employees/#(id)/toggle-active"
hx-target="#employee_#(id)"
hx-swap="outerHTML"
>
<img class="toggle" src="/images/toggle-on.svg" alt="Active">
</a>
#else:
<a class="toggle"
hx-patch="/employees/#(id)/toggle-active"
hx-target="#employee_#(id)"
hx-swap="outerHTML"
>
<img class="toggle" src="/images/toggle-off.svg" alt="Active">
</a>
#endif
</td>
</tr>
</tbody>
</table>
<div class="btn-row">
<button class="edit"
type="submit"
hx-swap="outerHTML"
>
Update
</button>
<button class="danger">Delete</button>
</div>
</form>
#endif
</div>

View File

@@ -7,6 +7,7 @@
<h3>Employees are who purchase orders can be generated for.</h3> <h3>Employees are who purchase orders can be generated for.</h3>
<br> <br>
</div> </div>
#extend("employees/detail")
#extend("form-container"): #export("formContent"): #extend("form-container"): #export("formContent"):
#extend("employees/form", form) #extend("employees/form", form)
#endexport #endextend #endexport #endextend

View File

@@ -19,22 +19,33 @@
</a> </a>
#endif #endif
</td> </td>
<td style="width: 100px;"> <td style="width: 50px;">
<a class="btn btn-delete" <button hx-get="/employees/#(id)"
href="javascript:void(0)" hx-target="#employee-detail"
hx-delete="/employees/#(id)" hx-swap="outerHTML swap:0.5s"
hx-target="#employee-table" hx-push-url="true"
hx-swap="outerHTML" class="btn btn-detail"
hx-confirm="Are you sure you want to delete this employee?"
> >
#extend("img/trash-can") &rsaquo;
</a> </button>
<a class="btn btn-edit" hx-get="/employees/#(id)"
hx-target="#employee-form"
hx-on::after-request=" if(event.detail.successful) toggleContent('form')"
>
#extend("img/pencil")
</a>
</td> </td>
<!-- <td style="width: 100px;"> -->
<!-- <a class="btn btn-delete" -->
<!-- href="javascript:void(0)" -->
<!-- hx-delete="/employees/#(id)" -->
<!-- hx-target="#employee-table" -->
<!-- hx-swap="outerHTML" -->
<!-- hx-confirm="Are you sure you want to delete this employee?" -->
<!-- > -->
<!-- #extend("img/trash-can") -->
<!-- </a> -->
<!-- <a class="btn btn-edit" hx-get="/employees/#(id)" -->
<!-- hx-target="#employee-form" -->
<!-- hx-on::after-request=" if(event.detail.successful) toggleContent('form')" -->
<!-- > -->
<!-- #extend("img/pencil") -->
<!-- </a> -->
<!-- </td> -->
</tr> </tr>

View File

@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="js/main.js"></script> <script src="/js/main.js"></script>
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="/css/main.css">
<title>#(title)</title> <title>#(title)</title>
</head> </head>
<body> <body>

View File

@@ -1,46 +1,56 @@
<div id="po-detail" class="container"> <div id="po-detail" class="container float">
#if(purchaseOrderDetail):
<a href="javascript:void(0)"
class="closebtn"
hx-get="/purchase-orders/details/close"
hx-target="#po-detail"
hx-swap="outerHTML"
>
&times;
</a>
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td class="label"><h3>Purchase Order:</h3></td> <td class="label"><h3>Purchase Order:</h3></td>
<td><h3>#(purchaseOrder.id)</h3></td> <td><h3>#(purchaseOrderDetail.id)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Work Order:</h3></td> <td class="label"><h3>Work Order:</h3></td>
<td><h3>#(purchaseOrder.workOrder)</h3></td> <td><h3>#(purchaseOrderDetail.workOrder)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Customer:</h3></td> <td class="label"><h3>Customer:</h3></td>
<td><h3>#(purchaseOrder.customer)</h3></td> <td><h3>#(purchaseOrderDetail.customer)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Vendor:</h3></td> <td class="label"><h3>Vendor:</h3></td>
<td><h3>#capitalized(purchaseOrder.vendorBranch.vendor.name) - #capitalized(purchaseOrder.vendorBranch.name)</h3></td> <td><h3>#capitalized(purchaseOrderDetail.vendorBranch.vendor.name) - #capitalized(purchaseOrderDetail.vendorBranch.name)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Materials:</h3></td> <td class="label"><h3>Materials:</h3></td>
<td><h3>#(purchaseOrder.materials)<h3></td> <td><h3>#(purchaseOrderDetail.materials)<h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Created For:</h3></td> <td class="label"><h3>Created For:</h3></td>
<td><h3>#capitalized(purchaseOrder.createdFor.firstName) #capitalized(purchaseOrder.createdFor.lastName)</h3></td> <td><h3>#capitalized(purchaseOrderDetail.createdFor.firstName) #capitalized(purchaseOrderDetail.createdFor.lastName)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Truck Stock:</h3></td> <td class="label"><h3>Truck Stock:</h3></td>
<td><h3>#capitalized(purchaseOrder.truckStock)</h3></td> <td><h3>#capitalized(purchaseOrderDetail.truckStock)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Created By:</h3></td> <td class="label"><h3>Created By:</h3></td>
<td><h3>#(purchaseOrder.createdBy.username)</h3></td> <td><h3>#(purchaseOrderDetail.createdBy.username)</h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Date:</h3></td> <td class="label"><h3>Date:</h3></td>
<td><h3>#date(purchaseOrder.createdAt)<h3></td> <td><h3>#date(purchaseOrderDetail.createdAt, "MM-dd-yyyy")<h3></td>
</tr> </tr>
<tr> <tr>
<td class="label"><h3>Updated:</h3></td> <td class="label"><h3>Updated:</h3></td>
<td><h3>#date(purchaseOrder.updatedAt)<h3></td> <td><h3>#date(purchaseOrderDetail.updatedAt, "MM-dd-yyyy")<h3></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
#endif
</div> </div>

View File

@@ -8,6 +8,16 @@
#extend("form-container"): #export("formContent"): #extend("form-container"): #export("formContent"):
#extend("purchaseOrders/form", form) #extend("purchaseOrders/form", form)
#endexport #endextend #endexport #endextend
<div class="float"
id="po-detail"
#if(purchaseOrderDetail):
hx-get="/purchase-orders/#(purchaseOrderDetail.id)"
hx-target="this"
hx-trigger="load"
hx-swap="outerHTML"
#endif
>
</div>
<div class="btn-row"> <div class="btn-row">
#if(hasPrevious): #if(hasPrevious):
<button hx-get="/purchase-orders?page=#(page - 1)&limit=#(limit)" <button hx-get="/purchase-orders?page=#(page - 1)&limit=#(limit)"

View File

@@ -8,11 +8,11 @@
<td>#(createdBy.username)</td> <td>#(createdBy.username)</td>
<td>#capitalized(truckStock)</td> <td>#capitalized(truckStock)</td>
<td style="text-align: center;"> <td style="text-align: center;">
<!-- TODO: add buttons here -->
<button class="btn btn-detail" <button class="btn btn-detail"
hx-get="/purchase-orders/#(id)" hx-get="/purchase-orders/#(id)"
hx-target="#home-content" hx-target="#po-detail"
hx-push-url="true" hx-push-url="true"
hx-swap="outerHTML"
> >
&rsaquo; &rsaquo;
</button> </button>

View File

@@ -0,0 +1,36 @@
<div id="user-detail" class="float" #if(!user): style="display:none;" #endif>
#if(user):
<button class="closebtn"
hx-get="/users"
hx-target="body"
hx-push-url="true"
hx-swap="outerHTML"
hx-disable-elt="this"
>
&times;
</button>
<table>
<tbody>
<tr>
<td class="label"><h3>Username:</h3></td>
<td><h3>#(user.username)</h3></td>
</tr>
<tr>
<td class="label"><h3>Email:</h3></td>
<td><h3>#(user.email)</h3></td>
</tr>
<tr>
<td class="label"><h3>Created:</h3></td>
<td><h3>#date(user.createdAt, "MM-dd-yyyy")</h3></td>
</tr>
<tr>
<td class="label"><h3>Updated:</h3></td>
<td><h3>#date(user.updatedAt, "MM-dd-yyyy")</h3></td>
</tr>
</tbody>
</table>
<div class="btn-row user-buttons">
<button class="danger">Delete</button>
</div>
#endif
</div>

View File

@@ -7,9 +7,11 @@
<p>Users are people that can login and generate puchase orders for employees.</p> <p>Users are people that can login and generate puchase orders for employees.</p>
<br> <br>
</div> </div>
#extend("users/detail")
#extend("form-container"): #export("formContent"): #extend("form-container"): #export("formContent"):
#extend("users/form", form) #extend("users/form", form)
#endexport #endextend #endexport #endextend
#extend("users/table") #extend("users/table")
</div> </div>
#endexport #endexport

View File

@@ -0,0 +1,14 @@
<tr id="user_#(id)">
<td>#(username)</td>
<td>#(email)</td>
<td style="width: 50px;">
<button hx-get="/users/#(id)"
hx-target="#user-detail"
hx-swap="outerHTML swap:0.5s"
hx-push-url="true"
class="btn btn-detail"
>
&rsaquo;
</button>
</td>
</tr>

View File

@@ -8,20 +8,7 @@
</thead> </thead>
<tbody> <tbody>
#for(user in users): #for(user in users):
<tr id="user_#(user.id)"> #extend("users/table-row", user)
<td>#(user.username)</td>
<td>#(user.email)</td>
<td style="width: 50px;">
<a class="btn btn-delete"
hx-delete="/users/#(user.id)"
hx-target="#user-table"
hx-swap="outerHTML"
hx-confirm="Are you sure you want to delete this user?"
>
<img src="images/trash-can.svg" alt="Delete">
</a>
</td>
</tr>
#endfor #endfor
</tbody> </tbody>
</table> </table>

View File

@@ -20,7 +20,7 @@ struct EmployeeApiController: RouteCollection {
@Sendable @Sendable
func index(req: Request) async throws -> [Employee.DTO] { func index(req: Request) async throws -> [Employee.DTO] {
let params = try req.query.decode(EmployeesIndexQuery.self) let params = try req.query.decode(EmployeesIndexQuery.self)
return try await employees.fetchAll(params.active ?? false) return try await employees.fetchAll(params.active == true ? .active : .default)
} }
@Sendable @Sendable

View File

@@ -31,7 +31,7 @@ struct VendorBranchApiController: RouteCollection {
guard let id = req.parameters.get("vendorID", as: Vendor.IDValue.self) else { guard let id = req.parameters.get("vendorID", as: Vendor.IDValue.self) else {
throw Abort(.badRequest, reason: "Vendor id not provided.") throw Abort(.badRequest, reason: "Vendor id not provided.")
} }
return try await vendorBranches.fetchForVendor(id) return try await vendorBranches.fetchAll(.for(vendorID: id))
} }
@Sendable @Sendable

View File

@@ -13,7 +13,8 @@ struct EmployeeViewController: RouteCollection {
protected.get("form", use: employeeForm(req:)) protected.get("form", use: employeeForm(req:))
protected.post(use: create(req:)) protected.post(use: create(req:))
protected.group(":employeeID") { protected.group(":employeeID") {
$0.get(use: edit(req:)) $0.get(use: get(req:))
$0.get("edit", use: edit(req:))
$0.delete(use: delete(req:)) $0.delete(use: delete(req:))
$0.put(use: update(req:)) $0.put(use: update(req:))
$0.patch("toggle-active", use: toggleActive(req:)) $0.patch("toggle-active", use: toggleActive(req:))
@@ -22,15 +23,36 @@ struct EmployeeViewController: RouteCollection {
@Sendable @Sendable
func index(req: Request) async throws -> View { func index(req: Request) async throws -> View {
return try await req.view.render("employees/index", EmployeesCTX(db: employees)) return try await renderIndex(req)
}
@Sendable
private func renderIndex(
_ req: Request,
_ employee: Employee.DTO? = nil,
_ form: EmployeeFormCTX? = nil
) async throws -> View {
return try await req.view.render(
"employees/index",
EmployeesCTX(employee: employee, employees: employees.fetchAll(), form: form ?? .init())
)
} }
@Sendable @Sendable
func create(req: Request) async throws -> View { func create(req: Request) async throws -> View {
try Employee.Create.validate(content: req) try Employee.Create.validate(content: req)
let model = try req.content.decode(Employee.Create.self) let employee = try await employees.create(req.content.decode(Employee.Create.self))
_ = try await employees.create(model) return try await req.view.render("employees/table-row", employee)
return try await req.view.render("employees/index", EmployeesCTX(oob: true, db: employees)) }
@Sendable
func get(req: Request) async throws -> View {
let employee = try await employees.get(req.ensureIDPathComponent(key: "employeeID"))
// Check if we've rendered the page yet.
guard req.isHtmxRequest else {
return try await renderIndex(req, employee)
}
return try await req.view.render("employees/detail", ["employee": employee])
} }
@Sendable @Sendable
@@ -42,6 +64,7 @@ struct EmployeeViewController: RouteCollection {
return try await req.view.render("employees/table-row", employee) return try await req.view.render("employees/table-row", employee)
} }
// TODO: I think we can just return a response and remove the table-row, here.
@Sendable @Sendable
func delete(req: Request) async throws -> View { func delete(req: Request) async throws -> View {
let id = try req.requireEmployeeID() let id = try req.requireEmployeeID()
@@ -55,7 +78,7 @@ struct EmployeeViewController: RouteCollection {
guard let employee = try await employees.get(req.parameters.get("employeeID")) else { guard let employee = try await employees.get(req.parameters.get("employeeID")) else {
throw Abort(.notFound) throw Abort(.notFound)
} }
return try await req.view.render("employees/form", EmployeeFormCTX(employee: employee)) return try await req.view.render("employees/detail", EmployeeDetailCTX(editing: true, employee: employee))
} }
@Sendable @Sendable
@@ -63,8 +86,10 @@ struct EmployeeViewController: RouteCollection {
let id = try req.requireEmployeeID() let id = try req.requireEmployeeID()
try Employee.Update.validate(content: req) try Employee.Update.validate(content: req)
let updates = try req.content.decode(Employee.Update.self) let updates = try req.content.decode(Employee.Update.self)
_ = try await employees.update(id, updates) req.logger.info("Employee updates: \(updates)")
return try await req.view.render("employees/index", EmployeesCTX(oob: true, db: employees)) let employee = try await employees.update(id, updates)
req.logger.info("Done updating employee: \(employee)")
return try await req.view.render("employees/table-row", employee)
} }
@Sendable @Sendable
@@ -83,19 +108,29 @@ private extension Request {
} }
} }
private struct EmployeeDetailCTX: Content {
let editing: Bool
let employee: Employee.DTO?
init(editing: Bool = false, employee: Employee.DTO? = nil) {
self.editing = editing
self.employee = employee
}
}
private struct EmployeesCTX: Content { private struct EmployeesCTX: Content {
let oob: Bool let employee: Employee.DTO?
let employees: [Employee.DTO] let employees: [Employee.DTO]
let form: EmployeeFormCTX let form: EmployeeFormCTX
init( init(
oob: Bool = false, employee: Employee.DTO? = nil,
employee: Employee? = nil, employees: [Employee.DTO],
db: EmployeeDB form: EmployeeFormCTX? = nil
) async throws { ) {
self.oob = oob self.employee = employee
self.employees = try await db.fetchAll() self.employees = employees
self.form = .init(employee: employee.map { $0.toDTO() }) self.form = form ?? .init()
} }
} }

View File

@@ -3,16 +3,17 @@ import Fluent
import Vapor import Vapor
struct PurchaseOrderViewController: RouteCollection { struct PurchaseOrderViewController: RouteCollection {
@Dependency(\.employees) var employees
@Dependency(\.purchaseOrders) var purchaseOrders @Dependency(\.purchaseOrders) var purchaseOrders
@Dependency(\.vendorBranches) var vendorBranches
private let employeesApi = EmployeeApiController()
private let branches = VendorBranchDB()
private let api = ApiController()
func boot(routes: any RoutesBuilder) throws { func boot(routes: any RoutesBuilder) throws {
let pos = routes.protected.grouped("purchase-orders") let pos = routes.protected.grouped("purchase-orders")
pos.get(use: index(req:)) pos.get(use: index(req:))
pos.group("details", "close") {
$0.get(use: detailClose(req:))
}
pos.post(use: create(req:)) pos.post(use: create(req:))
pos.group(":id") { pos.group(":id") {
$0.get(use: detail(req:)) $0.get(use: detail(req:))
@@ -25,8 +26,8 @@ struct PurchaseOrderViewController: RouteCollection {
let purchaseOrdersPage = try await purchaseOrders.fetchPage( let purchaseOrdersPage = try await purchaseOrders.fetchPage(
.init(page: params?.page ?? 1, per: params?.limit ?? 50) .init(page: params?.page ?? 1, per: params?.limit ?? 50)
) )
let branches = try await self.branches.getBranches(req: req) let branches = try await vendorBranches.getBranches(req: req)
let employees = try await employeesApi.index(req: req) let employees = try await employees.fetchAll()
req.logger.debug("Branches: \(branches)") req.logger.debug("Branches: \(branches)")
return try await req.view.render( return try await req.view.render(
"purchaseOrders/index", "purchaseOrders/index",
@@ -43,7 +44,12 @@ struct PurchaseOrderViewController: RouteCollection {
throw Abort(.badRequest, reason: "Id not supplied.") throw Abort(.badRequest, reason: "Id not supplied.")
} }
let purchaseOrder = try await purchaseOrders.get(id) let purchaseOrder = try await purchaseOrders.get(id)
return try await req.view.render("purchaseOrders/detail", ["purchaseOrder": purchaseOrder]) return try await req.view.render("purchaseOrders/detail", ["purchaseOrderDetail": purchaseOrder])
}
@Sendable
func detailClose(req: Request) async throws -> View {
return try await req.view.render("purchaseOrders/detail")
} }
@Sendable @Sendable
@@ -62,6 +68,7 @@ private struct PurchaseOrderIndex: Content {
} }
private struct PurchaseOrderCTX: Content { private struct PurchaseOrderCTX: Content {
let purchaseOrderDetail: PurchaseOrder.DTO?
let purchaseOrders: [PurchaseOrder.DTO] let purchaseOrders: [PurchaseOrder.DTO]
let page: Int let page: Int
let limit: Int let limit: Int
@@ -69,7 +76,12 @@ private struct PurchaseOrderCTX: Content {
let hasPrevious: Bool let hasPrevious: Bool
let form: PurchaseOrderFormCTX? let form: PurchaseOrderFormCTX?
init(page: Page<PurchaseOrder.DTO>, form: PurchaseOrderFormCTX?) { init(
detail: PurchaseOrder.DTO? = nil,
page: Page<PurchaseOrder.DTO>,
form: PurchaseOrderFormCTX?
) {
self.purchaseOrderDetail = detail
self.purchaseOrders = page.items self.purchaseOrders = page.items
self.page = page.metadata.page self.page = page.metadata.page
self.limit = page.metadata.per self.limit = page.metadata.per

View File

@@ -1,31 +1,50 @@
import Dependencies
import Fluent import Fluent
import Vapor import Vapor
struct UserViewController: RouteCollection { struct UserViewController: RouteCollection {
@Dependency(\.users) var users
private let api = UserApiController() private let api = UserApiController()
func boot(routes: any RoutesBuilder) throws { func boot(routes: any RoutesBuilder) throws {
let users = routes.protected.grouped("users") let users = routes.protected.grouped("users")
users.get(use: index(req:)) users.get(use: index(req:))
users.post(use: create(req:)) users.post(use: create(req:))
users.group(":userID") { users.group(":id") {
$0.get(use: details(req:))
$0.delete(use: delete(req:)) $0.delete(use: delete(req:))
} }
} }
@Sendable @Sendable
func index(req: Request) async throws -> View { func index(req: Request) async throws -> View {
try await req.view.render( try await renderIndex(req)
"users/index", }
UsersCTX(users: api.getSortedUsers(req: req))
) @Sendable
private func renderIndex(_ req: Request, _ user: User.DTO? = nil) async throws -> View {
let users = try await api.getSortedUsers(req: req)
return try await req.view.render("users/index", UsersCTX(user: user, users: users))
} }
@Sendable @Sendable
func create(req: Request) async throws -> View { func create(req: Request) async throws -> View {
_ = try await api.create(req: req) let user = try await api.create(req: req)
return try await req.view.render("users/table", ["users": api.getSortedUsers(req: req)]) return try await req.view.render("users/table-row", user)
}
@Sendable
func details(req: Request) async throws -> View {
let user = try await users.get(req.ensureIDPathComponent())
// Check if the page has been rendered before.
guard req.isHtmxRequest else {
// Not an htmx-request, so render the whole page with the details.
return try await renderIndex(req, user)
}
// An htmx-request header was present, so just return the details,
return try await req.view.render("users/detail", ["user": user])
} }
@Sendable @Sendable
@@ -50,11 +69,11 @@ struct UserFormCTX: Content {
formClass: "user-form", formClass: "user-form",
formId: "user-form", formId: "user-form",
htmxTargetUrl: .post("/login\((next != nil && next != "/") ? "?next=\(next!)" : "")"), htmxTargetUrl: .post("/login\((next != nil && next != "/") ? "?next=\(next!)" : "")"),
htmxTarget: "body", htmxTarget: "user-table",
htmxPushUrl: true, htmxPushUrl: true,
htmxResetAfterRequest: true, htmxResetAfterRequest: true,
htmxSwapOob: nil, htmxSwapOob: nil,
htmxSwap: nil, htmxSwap: .afterbegin,
context: .init(showConfirmPassword: false, showEmailInput: false, buttonLabel: "Sign In") context: .init(showConfirmPassword: false, showEmailInput: false, buttonLabel: "Sign In")
) )
) )
@@ -78,10 +97,16 @@ struct UserFormCTX: Content {
} }
private struct UsersCTX: Content { private struct UsersCTX: Content {
let user: User.DTO?
let users: [User.DTO] let users: [User.DTO]
let form: UserFormCTX let form: UserFormCTX
init(users: [User.DTO], form: UserFormCTX? = nil) { init(
user: User.DTO? = nil,
users: [User.DTO],
form: UserFormCTX? = nil
) {
self.user = user
self.users = users self.users = users
self.form = form ?? .create() self.form = form ?? .create()
} }

View File

@@ -15,14 +15,19 @@ extension DependencyValues {
@DependencyClient @DependencyClient
struct EmployeeDB: Sendable { struct EmployeeDB: Sendable {
var create: @Sendable (Employee.Create) async throws -> Employee.DTO var create: @Sendable (Employee.Create) async throws -> Employee.DTO
var fetchAll: @Sendable (Bool) async throws -> [Employee.DTO] var fetchAll: @Sendable (FetchRequest) async throws -> [Employee.DTO]
var get: @Sendable (Employee.IDValue) async throws -> Employee.DTO? var get: @Sendable (Employee.IDValue) async throws -> Employee.DTO?
var update: @Sendable (Employee.IDValue, Employee.Update) async throws -> Employee.DTO var update: @Sendable (Employee.IDValue, Employee.Update) async throws -> Employee.DTO
var delete: @Sendable (Employee.IDValue) async throws -> Void var delete: @Sendable (Employee.IDValue) async throws -> Void
var toggleActive: @Sendable (Employee.IDValue) async throws -> Employee.DTO var toggleActive: @Sendable (Employee.IDValue) async throws -> Employee.DTO
enum FetchRequest {
case active
case `default`
}
func fetchAll() async throws -> [Employee.DTO] { func fetchAll() async throws -> [Employee.DTO] {
try await fetchAll(false) try await fetchAll(.default)
} }
func get(_ id: String?) async throws -> Employee.DTO? { func get(_ id: String?) async throws -> Employee.DTO? {
@@ -43,12 +48,12 @@ extension EmployeeDB: TestDependencyKey {
try await model.save(on: database) try await model.save(on: database)
return model.toDTO() return model.toDTO()
}, },
fetchAll: { active in fetchAll: { request in
var query = Employee.query(on: database) var query = Employee.query(on: database)
.sort(\.$lastName) .sort(\.$lastName)
if active { if request == .active {
query = query.filter(\.$active == active) query = query.filter(\.$active == true)
} }
return try await query.all().map { $0.toDTO() } return try await query.all().map { $0.toDTO() }

View File

@@ -15,6 +15,7 @@ struct UserDB: Sendable {
var create: @Sendable (User.Create) async throws -> User.DTO var create: @Sendable (User.Create) async throws -> User.DTO
var delete: @Sendable (User.IDValue) async throws -> Void var delete: @Sendable (User.IDValue) async throws -> Void
var fetchAll: @Sendable () async throws -> [User.DTO] var fetchAll: @Sendable () async throws -> [User.DTO]
var get: @Sendable (User.IDValue) async throws -> User.DTO?
var login: @Sendable (User) async throws -> UserToken var login: @Sendable (User) async throws -> UserToken
} }
@@ -46,6 +47,9 @@ extension UserDB: TestDependencyKey {
fetchAll: { fetchAll: {
try await User.query(on: db).all().map { $0.toDTO() } try await User.query(on: db).all().map { $0.toDTO() }
}, },
get: { id in
try await User.find(id, on: db).map { $0.toDTO() }
},
login: { user in login: { user in
let token = try user.generateToken() let token = try user.generateToken()
try await token.save(on: db) try await token.save(on: db)

View File

@@ -14,13 +14,18 @@ public extension DependencyValues {
public struct VendorBranchDB: Sendable { public struct VendorBranchDB: Sendable {
var create: @Sendable (VendorBranch.Create, Vendor.IDValue) async throws -> VendorBranch.DTO var create: @Sendable (VendorBranch.Create, Vendor.IDValue) async throws -> VendorBranch.DTO
var delete: @Sendable (VendorBranch.IDValue) async throws -> Void var delete: @Sendable (VendorBranch.IDValue) async throws -> Void
var fetchAll: @Sendable (Bool) async throws -> [VendorBranch.DTO] var fetchAll: @Sendable (FetchRequest) async throws -> [VendorBranch.DTO]
var fetchForVendor: @Sendable (Vendor.IDValue) async throws -> [VendorBranch.DTO]
var get: @Sendable (VendorBranch.IDValue) async throws -> VendorBranch.DTO? var get: @Sendable (VendorBranch.IDValue) async throws -> VendorBranch.DTO?
var update: @Sendable (VendorBranch.IDValue, VendorBranch.Update) async throws -> VendorBranch.DTO var update: @Sendable (VendorBranch.IDValue, VendorBranch.Update) async throws -> VendorBranch.DTO
enum FetchRequest: Equatable {
case `default`
case `for`(vendorID: Vendor.IDValue)
case withVendor
}
func fetchAll() async throws -> [VendorBranch.DTO] { func fetchAll() async throws -> [VendorBranch.DTO] {
try await fetchAll(false) try await fetchAll(.default)
} }
} }
@@ -43,28 +48,30 @@ extension VendorBranchDB: TestDependencyKey {
} }
try await branch.delete(on: db) try await branch.delete(on: db)
}, },
fetchAll: { withVendor in fetchAll: { request in
var query = VendorBranch.query(on: db) var query = VendorBranch.query(on: db)
if withVendor == true { switch request {
case .withVendor:
query = query.with(\.$vendor) query = query.with(\.$vendor)
case let .for(vendorID: vendorID):
let branches = try await Vendor.query(on: db)
.filter(\.$id == vendorID)
.with(\.$branches)
.first()?
.branches
.map { $0.toDTO() }
guard let branches else { throw Abort(.badGateway, reason: "Vendor id not found.") }
return branches
case .default:
break
} }
return try await query.all().map { $0.toDTO() } return try await query.all().map { $0.toDTO() }
},
fetchForVendor: { vendorID in
guard let vendor = try await Vendor.query(on: db)
.filter(\.$id == vendorID)
.with(\.$branches)
.first()
else {
throw Abort(.notFound)
}
return vendor.branches.map { $0.toDTO() }
}, },
get: { id in get: { id in
try await VendorBranch.find(id, on: db).map { $0.toDTO() } try await VendorBranch.find(id, on: db).map { $0.toDTO() }
}, },
update: { id, updates in update: { id, updates in

View File

@@ -1,8 +0,0 @@
import Vapor
extension Request {
func ensureValidContent<T>(_ decoding: T.Type) throws -> T where T: Content, T: Validatable {
try T.validate(content: self)
return try content.decode(T.self)
}
}

View File

@@ -0,0 +1,22 @@
import Vapor
extension Request {
func ensureValidContent<T>(_ decoding: T.Type) throws -> T where T: Content, T: Validatable {
try T.validate(content: self)
return try content.decode(T.self)
}
func ensureIDPathComponent<T: LosslessStringConvertible>(
as decoding: T.Type = UUID.self,
key: String = "id"
) throws -> T {
guard let id = parameters.get(key, as: T.self) else {
throw Abort(.badRequest, reason: "Id not supplied.")
}
return id
}
var isHtmxRequest: Bool {
headers.contains(name: "hx-request")
}
}

View File

@@ -5,12 +5,16 @@ extension RoutesBuilder {
// Used to ensure views are protected, redirects users to the login page if they're // Used to ensure views are protected, redirects users to the login page if they're
// not authenticated. // not authenticated.
var protected: any RoutesBuilder { var protected: any RoutesBuilder {
grouped( #if DEBUG
return self
#else
return grouped(
User.credentialsAuthenticator(), User.credentialsAuthenticator(),
User.redirectMiddleware { req in User.redirectMiddleware { req in
"login?next=\(req.url)" "login?next=\(req.url)"
} }
) )
#endif
} }
func apiUnprotected(route: PathComponent) -> any RoutesBuilder { func apiUnprotected(route: PathComponent) -> any RoutesBuilder {
@@ -20,11 +24,15 @@ extension RoutesBuilder {
// Allows basic or token authentication for api routes and prefixes the // Allows basic or token authentication for api routes and prefixes the
// given route with "/api/v1". // given route with "/api/v1".
func apiProtected(route: PathComponent) -> any RoutesBuilder { func apiProtected(route: PathComponent) -> any RoutesBuilder {
#if DEBUG
return apiUnprotected(route: route)
#else
let prefixed = grouped("api", "v1", route) let prefixed = grouped("api", "v1", route)
return prefixed.grouped( return prefixed.grouped(
User.authenticator(), User.authenticator(),
UserToken.authenticator(), UserToken.authenticator(),
User.guardMiddleware() User.guardMiddleware()
) )
#endif
} }
} }

View File

@@ -27,22 +27,39 @@ final class Employee: Model, @unchecked Sendable {
@Field(key: "is_active") @Field(key: "is_active")
var active: Bool var active: Bool
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
init() {} init() {}
init( init(
id: UUID? = nil, id: UUID? = nil,
firstName: String, firstName: String,
lastName: String, lastName: String,
active: Bool active: Bool,
createdAt: Date? = nil,
updatedAt: Date? = nil
) { ) {
self.id = id self.id = id
self.firstName = firstName self.firstName = firstName
self.lastName = lastName self.lastName = lastName
self.active = active self.active = active
self.createdAt = createdAt
self.updatedAt = updatedAt
} }
func toDTO() -> DTO { func toDTO() -> DTO {
.init(id: id, firstName: $firstName.value, lastName: $lastName.value, active: $active.value) .init(
id: id,
firstName: $firstName.value,
lastName: $lastName.value,
active: $active.value,
createdAt: createdAt,
updatedAt: updatedAt
)
} }
func applyUpdates(_ updates: Update) { func applyUpdates(_ updates: Update) {
@@ -68,7 +85,11 @@ extension Employee {
let active: Bool? let active: Bool?
func toModel() -> Employee { func toModel() -> Employee {
.init(firstName: firstName, lastName: lastName, active: active ?? true) .init(
firstName: firstName,
lastName: lastName,
active: active ?? true
)
} }
} }
@@ -78,6 +99,8 @@ extension Employee {
var firstName: String? var firstName: String?
var lastName: String? var lastName: String?
var active: Bool? var active: Bool?
var createdAt: Date?
var updatedAt: Date?
func toModel() -> Employee { func toModel() -> Employee {
let model = Employee() let model = Employee()
@@ -106,6 +129,8 @@ extension Employee {
.field("first_name", .string, .required) .field("first_name", .string, .required)
.field("last_name", .string, .required) .field("last_name", .string, .required)
.field("is_active", .bool, .required, .sql(.default(true))) .field("is_active", .bool, .required, .sql(.default(true)))
.field("created_at", .datetime)
.field("updated_at", .datetime)
.unique(on: "first_name", "last_name") .unique(on: "first_name", "last_name")
.create() .create()
} }

View File

@@ -23,9 +23,20 @@ final class User: Model, @unchecked Sendable {
@Field(key: "password_hash") @Field(key: "password_hash")
var passwordHash: String var passwordHash: String
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
init() {} init() {}
init(id: UUID? = nil, username: String, email: String, passwordHash: String) { init(
id: UUID? = nil,
username: String,
email: String,
passwordHash: String
) {
self.id = id self.id = id
self.username = username self.username = username
self.email = email self.email = email
@@ -33,7 +44,13 @@ final class User: Model, @unchecked Sendable {
} }
func toDTO() -> DTO { func toDTO() -> DTO {
.init(id: id, username: $username.value, email: $email.value) .init(
id: id,
username: $username.value,
email: $email.value,
createdAt: createdAt,
updatedAt: updatedAt
)
} }
func generateToken() throws -> UserToken { func generateToken() throws -> UserToken {
@@ -58,6 +75,8 @@ extension User {
let id: UUID? let id: UUID?
let username: String? let username: String?
let email: String? let email: String?
let createdAt: Date?
let updatedAt: Date?
} }
struct Migrate: AsyncMigration { struct Migrate: AsyncMigration {
@@ -69,6 +88,8 @@ extension User {
.field("username", .string, .required) .field("username", .string, .required)
.field("email", .string, .required) .field("email", .string, .required)
.field("password_hash", .string, .required) .field("password_hash", .string, .required)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.unique(on: "email", "username") .unique(on: "email", "username")
.create() .create()
} }

View File

@@ -13,6 +13,12 @@ final class Vendor: Model, @unchecked Sendable {
@Field(key: "name") @Field(key: "name")
var name: String var name: String
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
@Children(for: \.$vendor) @Children(for: \.$vendor)
var branches: [VendorBranch] var branches: [VendorBranch]
@@ -29,7 +35,9 @@ final class Vendor: Model, @unchecked Sendable {
name: $name.value, name: $name.value,
branches: ($branches.value != nil && $branches.value!.count > 0) branches: ($branches.value != nil && $branches.value!.count > 0)
? $branches.value!.map { $0.toDTO() } ? $branches.value!.map { $0.toDTO() }
: (includeBranches == true) ? [] : nil : (includeBranches == true) ? [] : nil,
createdAt: createdAt,
updatedAt: updatedAt
) )
} }
@@ -54,6 +62,8 @@ extension Vendor {
var id: UUID? var id: UUID?
var name: String? var name: String?
var branches: [VendorBranch.DTO]? var branches: [VendorBranch.DTO]?
let createdAt: Date?
let updatedAt: Date?
func toModel() -> Vendor { func toModel() -> Vendor {
let model = Vendor() let model = Vendor()
@@ -72,6 +82,8 @@ extension Vendor {
try await database.schema(Vendor.schema) try await database.schema(Vendor.schema)
.id() .id()
.field("name", .string, .required) .field("name", .string, .required)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.unique(on: "name") .unique(on: "name")
.create() .create()
} }

View File

@@ -12,6 +12,12 @@ final class VendorBranch: Model, @unchecked Sendable {
@Field(key: "name") @Field(key: "name")
var name: String var name: String
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
@Parent(key: "vendor_id") @Parent(key: "vendor_id")
var vendor: Vendor var vendor: Vendor
@@ -24,7 +30,13 @@ final class VendorBranch: Model, @unchecked Sendable {
} }
func toDTO() -> DTO { func toDTO() -> DTO {
.init(id: id, name: $name.value, vendorId: $vendor.id) .init(
id: id,
name: $name.value,
vendorId: $vendor.id,
createdAt: createdAt,
updatedAt: updatedAt
)
} }
func applyUpdates(_ updates: Update) { func applyUpdates(_ updates: Update) {
@@ -50,6 +62,8 @@ extension VendorBranch {
var id: UUID? var id: UUID?
var name: String? var name: String?
var vendorId: Vendor.IDValue? var vendorId: Vendor.IDValue?
let createdAt: Date?
let updatedAt: Date?
func toModel() -> VendorBranch { func toModel() -> VendorBranch {
let model = VendorBranch() let model = VendorBranch()
@@ -74,6 +88,8 @@ extension VendorBranch {
.id() .id()
.field("name", .string, .required) .field("name", .string, .required)
.field("vendor_id", .uuid, .required) .field("vendor_id", .uuid, .required)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.foreignKey("vendor_id", references: Vendor.schema, "id", onDelete: .cascade) .foreignKey("vendor_id", references: Vendor.schema, "id", onDelete: .cascade)
.create() .create()
} }