feat: Begins a generic htmx form context and template, integrates user form, begins views for vendor and purchase orders.

This commit is contained in:
2025-01-08 14:02:50 -05:00
parent 3557227430
commit 2b6e92a5c6
18 changed files with 493 additions and 93 deletions

View File

@@ -84,6 +84,19 @@ form input {
margin-bottom: 15px;
}
.user-form, .employee-form {
padding: 80px;
width: 100%;
}
.user-form input {
width: 100%;
}
.employee-form input {
width: 100%;
}
table {
width: 100%;
}
@@ -97,27 +110,22 @@ td, th {
padding: 5px 15px;
}
.employee-form {
margin: 20px auto;
width: 50%;
background-color: #aeb6bf;
padding: 20px;
border-radius: 25px;
}
.employee-form label {
color: #555;
font-weight: bold;
}
input[type=submit] {
padding: 5px 20px;
}
input[type=text], input[type=password] {
input[type=text], input[type=password], input[type=email] {
background-color: inherit;
color: inherit;
border: none;
border-bottom: 2px solid #555;
padding: 5px;
}
input[type=text]:focus, input[type=password]:focus, input[type=email]:focus {
outline: none;
}
.btn {
cursor: pointer;
}
@@ -153,3 +161,46 @@ input[type=text], input[type=password] {
.toggle img:hover {
background-color: #555;
}
.dropbtn {
background-color: #3498DB;
color: white;
padding: 16px;
font-size: 16px;
border: none;
cursor: pointer;
}
.dropbtn:hover, .dropbtn:focus {
background-color: #2980B9;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
overflow: auto;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
.dropdown a:hover {
background-color: #ddd;
}
.show {
display: block;
}

26
Public/js/main.js Normal file
View File

@@ -0,0 +1,26 @@
// Close dropdown if user clicks outside of it.
// Adapted from: https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_js_dropdown
//
window.onClick = function(event) {
if (!event.target.matches('.dropbtn')) {
var dropdowns = document.getElementsByClassName("dropdown-content");
var i;
for (i=0; i < dropdowns.length; i++) {
var openDropdown = dropdowns[i];
if (openDropdown.classList.contains('show')) {
openDropdown.classList.remove('show');
}
}
}
}
// Show the drop-down menu, with the given id.
function showDropdownContent(id) {
document.getElementById(id).classList.toggle("show");
}
// Update the drop-down with the item that was clicked inside it's menu.
function updateDropDownSelection(id, contentId) {
let content = document.getElementById(contentId).innerHTML;
document.getElementById(id).innerHTML = content;
}

View File

@@ -14,7 +14,6 @@
hx-swap-oob="outerHTML"
#endif
>
<label for="firstName">First Name</label>
<input type="text"
id="firstName"
name="firstName"
@@ -24,7 +23,6 @@
#if(employee.firstName): value=#(employee.firstName) #endif
>
<br>
<label for="lastName">Last Name</label>
<input type="text"
id="lastName"
name="lastName"

View File

@@ -1,6 +1,6 @@
#extend("index"):
#export("content"):
<div id="content">
#export("content"):
<div id="content">
<header>
<div class="container">
#extend("logo")
@@ -12,7 +12,7 @@
<nav>
<ul class="nav-links">
<li>
<a hx-get="/?route=users"
<a hx-get="/users"
hx-target="#home-content"
hx-swap="outerHTML"
hx-push-url="true"
@@ -22,7 +22,7 @@
</a>
</li>
<li>
<a hx-get="/?route=employees"
<a hx-get="/employees"
hx-target="#home-content"
hx-swap="outerHTML"
hx-push-url="true"
@@ -38,6 +38,6 @@
<p>We're in!</p>
</div>
</section>
</div>
#endexport
</div>
#endexport
#endextend

View File

@@ -0,0 +1,23 @@
<form id="#(formId)"
#if(formClass):
class="#(formClass)"
#endif
#if(htmxPostTargetUrl):
hx-post="#(htmxPostTargetUrl)"
#else:
hx-put="#(htmxPutTargetUrl)"
#endif
hx-target="#(htmxTarget)"
hx-push-url="#(htmxPushUrl)"
#if(htmxSwap):
hx-swap="#(htmxSwap)"
#endif
#if(htmxSwapOob):
hx-swap-oob="#(htmxSwapOob)"
#endif
#if(htmxResetAfterRequest):
hx-on::after-request=" if(event.detail.successful) this.reset()"
#endif
>
#import("formBody")
</form>

View File

@@ -4,10 +4,24 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="js/main.js"></script>
<link rel="stylesheet" href="css/main.css">
<title>#(title)</title>
</head>
<body>
<!-- <div class="dropdown"> -->
<!-- <button id="test-dropdown" -->
<!-- onClick="showDropdownContent('myDropdown')" -->
<!-- class="dropbtn" -->
<!-- > -->
<!-- Dropdown -->
<!-- </button> -->
<!-- <div id="myDropdown" class="dropdown-content"> -->
<!-- <a href="#" id="home" onClick="updateDropDownSelection('test-dropdown', 'home')">Home</a> -->
<!-- <a href="#" id="about" onClick="updateDropDownSelection('test-dropdown', 'about')">About</a> -->
<!-- <a href="#" id="contact" onClick="updateDropDownSelection('test-dropdown', 'contact')">Contact</a> -->
<!-- </div> -->
<!-- </div> -->
#import("content")
</body>
</html>

View File

@@ -0,0 +1,41 @@
<form hx-post="/fix-me"
>
<input type="number"
id="workOrder"
name="workOrder"
placeholder="12345"
>
<br>
<!-- TODO: Add vendor drop-down -->
<input type="hidden"
id="vendorBranchId"
name="vendorBranchId"
>
<!-- TODO: Add employee drop-down -->
<input type="hidden"
id="employeeId"
name="employeeId"
>
<br>
<input type="text"
id="materials"
name="materials"
placeholder="Materials"
required
>
<br>
<input type="text"
id="customer"
name="customer"
placeholder="Customer Name"
required
>
<br>
<label for="truckStock">
<input type="checkbox"
id="truckStock"
name="truckStock"
>
</form>

View File

@@ -0,0 +1,26 @@
<table id="po-table">
<tr>
<th>PO</th>
<th>Work Order</th>
<th>Vendor</th>
<th>Materials</th>
<th>Employee</th>
<th>Truck Stock</th>
<th></th>
</tr>
<tbody id="po-table-body">
#for(po in purchaseOrders):
<tr id="po_#(po.id)">
<td>#(po.id)</td>
<td>#(po.workOrder)</td>
<td>#(po.vendorBranch.vendor.name) - #(po.vendorBranch.name)</td>
<td>#(po.materials)</td>
<td>#(po.employee.firstName) #(po.employee.lastName)</td>
<td>#capitalized(po.truckStock)</td>
<td>
<!-- TODO: add buttons here -->
</td>
</tr>
#endfor
</tbody>
</table>

View File

@@ -1,10 +1,5 @@
<form class="user-form"
id="user-form"
hx-post="#(htmxTargetUrl)"
hx-target="#(htmxTarget)"
hx-push-url="#(htmxPushUrl)"
>
<label for="username">Username</label>
#extend("htmx-form", htmxForm):
#export("formBody"):
<input type="text"
id="username"
name="username"
@@ -13,7 +8,15 @@
required
>
<br>
<label for="password">Password</label>
#if(context.showEmailInput):
<input type="email"
id="email"
name="email"
placeholder="Email"
required
>
<br>
#endif
<input type="password"
id="password"
name="password"
@@ -22,8 +25,7 @@
required
>
<br>
#if(showConfirmPassword):
<label for="confirmPassword">Password</label>
#if(context.showConfirmPassword):
<input type="password"
id="confirmPassword"
name="confirmPassword"
@@ -33,5 +35,6 @@
>
<br>
#endif
<input type="submit" value="#(buttonLabel)">
</form>
<input type="submit" value="#(context.buttonLabel)">
#endexport
#endextend

View File

@@ -2,11 +2,22 @@
<tr>
<th>Username</th>
<th>Email</th>
<th></th>
</tr>
#for(user in users):
<tr>
<tr id="user_#(user.id)">
<td>#(user.username)</td>
<td>#(user.email)</td>
<td style="width: 60px;">
<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
</table>

View File

@@ -5,5 +5,6 @@
<p>Users are people that can login and generate puchase orders for employees.</p>
<br>
</div>
#extend("user-form", form)
#extend("user-table")
</div>

View File

@@ -0,0 +1,20 @@
<form hx-post="/vendors">
<input type="hidden"
id="vendorId"
name="id"
#if(vendor.id):
value="#(vendor.id)"
>
<input type="text"
id="name"
name="name"
placeholder="Vendor Name"
autofocus
required
#if(vendor.name):
value="#(vendor.name)"
#endif
>
<input type="submit" value="#(buttonLabel)">
</form>

View File

@@ -0,0 +1,67 @@
import Vapor
/// Represents a generic form context that is used to generate form templates
/// that are handled by htmx.
struct HtmxFormCTX<C: Content>: Content {
let formClass: String?
let formId: String
let htmxPostTargetUrl: String?
let htmxPutTargetUrl: String?
let htmxTarget: String
let htmxPushUrl: Bool
let htmxResetAfterRequest: Bool
let htmxSwapOob: String?
let htmxSwap: String?
let context: C
init(
formClass: String? = nil,
formId: String,
htmxTargetUrl: TargetUrl,
htmxTarget: String,
htmxPushUrl: Bool,
htmxResetAfterRequest: Bool = true,
htmxSwapOob: HtmxSwap? = nil,
htmxSwap: HtmxSwap? = nil,
context: C
) {
self.formClass = formClass
self.formId = formId
self.htmxPostTargetUrl = htmxTargetUrl.postUrl
self.htmxPutTargetUrl = htmxTargetUrl.putUrl
self.htmxTarget = htmxTarget
self.htmxPushUrl = htmxPushUrl
self.htmxResetAfterRequest = htmxResetAfterRequest
self.htmxSwapOob = htmxSwapOob?.rawValue
self.htmxSwap = htmxSwap?.rawValue
self.context = context
}
enum HtmxSwap: String {
case innerHTML
case outerHTML
case afterbegin
case beforebegin
case afterend
case beforeend
case delete
case none
}
enum TargetUrl {
case put(String)
case post(String)
var putUrl: String? {
guard case let .put(url) = self else { return nil }
return url
}
var postUrl: String? {
guard case let .post(url) = self else { return nil }
return url
}
}
}
struct EmptyContent: Content {}

View File

@@ -35,6 +35,9 @@ struct ApiController: RouteCollection {
users.group("login") {
$0.get(use: self.login(req:))
}
users.group(":userID") {
$0.delete(use: self.deleteUser(req:))
}
vendors.get(use: vendorsIndex)
vendors.post(use: createVendor)
@@ -174,6 +177,16 @@ struct ApiController: RouteCollection {
try await User.query(on: req.db).all().map { $0.toDTO() }
}
@Sendable
func deleteUser(req: Request) async throws -> HTTPStatus {
guard let user = try await User.find(req.parameters.get("userID"), on: req.db) else {
throw Abort(.notFound)
}
try await user.delete(on: req.db)
return .noContent
}
// MARK: - Vendors
@Sendable

View File

@@ -0,0 +1,10 @@
import Fluent
import Vapor
struct PurchaseOrderViewController: RouteCollection {
private let api = ApiController()
func boot(routes: any RoutesBuilder) throws {
// Do something.
}
}

View File

@@ -8,29 +8,89 @@ struct UserViewController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
let users = routes.protected.grouped("users")
users.get(use: index(req:))
users.post(use: create(req:))
users.group(":userID") {
$0.delete(use: delete(req:))
}
}
@Sendable
func index(req: Request) async throws -> View {
let users = try await api.usersIndex(req: req)
return try await req.view.render("users", ["users": users])
try await req.view.render(
"users",
UsersCTX(users: api.getSortedUsers(req: req))
)
}
@Sendable
func create(req: Request) async throws -> View {
_ = try await api.createUser(req: req)
return try await req.view.render("user-table", ["users": api.getSortedUsers(req: req)])
}
@Sendable
func delete(req: Request) async throws -> View {
_ = try await api.deleteUser(req: req)
return try await req.view.render("user-table", ["users": api.getSortedUsers(req: req)])
}
}
struct UserFormCTX: Content {
let htmxTargetUrl: String
let htmxTarget: String
let htmxPushUrl: String
let showConfirmPassword: Bool
let buttonLabel: String
let htmxForm: HtmxFormCTX<Context>
static func signIn(route: String) -> Self {
struct Context: Content {
let showConfirmPassword: Bool
let showEmailInput: Bool
let buttonLabel: String
}
static func signIn(next: String?) -> Self {
.init(
htmxTargetUrl: route,
htmxForm: .init(
formClass: "user-form",
formId: "user-form",
htmxTargetUrl: .post("/login\(next != nil ? "?next=\(next!)" : "")"),
htmxTarget: "body",
htmxPushUrl: "true",
showConfirmPassword: false,
buttonLabel: "Sign In"
htmxPushUrl: true,
htmxResetAfterRequest: true,
htmxSwapOob: nil,
htmxSwap: nil,
context: .init(showConfirmPassword: false, showEmailInput: false, buttonLabel: "Sign In")
)
)
}
static func create() -> Self {
.init(
htmxForm: .init(
formClass: "user-form",
formId: "user-form",
htmxTargetUrl: .post("/users"),
htmxTarget: "#user-table",
htmxPushUrl: false,
htmxResetAfterRequest: true,
htmxSwapOob: nil,
htmxSwap: nil,
context: .init(showConfirmPassword: true, showEmailInput: true, buttonLabel: "Create")
)
)
}
}
private struct UsersCTX: Content {
let users: [User.DTO]
let form: UserFormCTX
init(users: [User.DTO], form: UserFormCTX? = nil) {
self.users = users
self.form = form ?? .create()
}
}
private extension ApiController {
func getSortedUsers(req: Request) async throws -> [User.DTO] {
try await usersIndex(req: req)
.sorted { ($0.username ?? "") < ($1.username ?? "") }
}
}

View File

@@ -0,0 +1,20 @@
import Fluent
import Vapor
struct VendorViewController: RouteCollection {
private let api = ApiController()
func boot(routes: any RoutesBuilder) throws {
// Do something.
}
}
struct VendorFormCTX: Content {
let vendor: Vendor?
let buttonLabel: String
init(vendor: Vendor? = nil, buttonLabel: String = "Create") {
self.vendor = vendor
self.buttonLabel = buttonLabel
}
}

View File

@@ -15,11 +15,12 @@ struct ViewController: RouteCollection {
// routes.get(use: index(req:))
routes.get("login", use: getLogin(req:))
routes.post(use: postLogin(req:))
routes.post("login", use: postLogin(req:))
// MARK: Protected routes.
protected.get(use: home(req:))
protected.get("**", use: catchAll(req:))
protected.post("logout", use: logout(req:))
// protected.get("users", use: users(req:))
try routes.register(collection: employees)
@@ -30,7 +31,7 @@ struct ViewController: RouteCollection {
func getLogin(req: Request) async throws -> View {
req.logger.debug("Login Query: \(req.url.query ?? "n/a")")
let params = try? req.query.decode(LoginParameter.self)
return try await req.view.render("login", UserFormCTX.signIn(route: params?.next ?? "/"))
return try await req.view.render("login", UserFormCTX.signIn(next: params?.next))
}
@Sendable
@@ -60,17 +61,32 @@ struct ViewController: RouteCollection {
@Sendable
func home(req: Request) async throws -> View {
let ctx = try req.query.decode(HomeCTX.self)
guard let route = ctx.route else {
return try await req.view.render("home", ctx)
var route: HomeRoute?
if let loginParams = try? req.query.decode(LoginParameter.self),
let next = loginParams.next.split(separator: "/").last
{
route = HomeRoute(rawValue: String(next))
} else if let routeString = req.parameters.getCatchall().first {
route = HomeRoute(rawValue: routeString)
}
switch route {
case .users:
return try await users.index(req: req)
case .employees:
return try await employees.index(req: req)
return try await req.view.render("home", HomeCTX(route: route))
}
@Sendable
func catchAll(req: Request) async throws -> View {
var route: HomeRoute?
if let loginParams = try? req.query.decode(LoginParameter.self),
let next = loginParams.next.split(separator: "/").last
{
route = HomeRoute(rawValue: String(next))
} else if let routeString = req.parameters.getCatchall().last {
route = HomeRoute(rawValue: routeString)
}
return try await req.view.render("home", HomeCTX(route: route))
}
}