feat: Working on search for purchase orders.

This commit is contained in:
2025-01-17 17:04:41 -05:00
parent be0b5a6033
commit 531a385dba
46 changed files with 283 additions and 817 deletions

View File

@@ -1,97 +0,0 @@
import Vapor
/// Represents a generic form context that is used to generate form templates
/// that are handled by htmx.
struct HtmxFormCTX<Context: 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: Context
init(
formClass: String? = nil,
formId: String,
htmxTargetUrl: TargetUrl,
htmxTarget: String,
htmxPushUrl: Bool,
htmxResetAfterRequest: Bool = true,
htmxSwapOob: HtmxSwap? = nil,
htmxSwap: HtmxSwap? = nil,
context: Context
) {
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 {}
struct ButtonLabelContext: Content {
let buttonLabel: String
}
extension HtmxFormCTX where Context == ButtonLabelContext {
init(
formClass: String? = nil,
formId: String,
htmxTargetUrl: TargetUrl,
htmxTarget: String,
htmxPushUrl: Bool,
htmxResetAfterRequest: Bool = true,
htmxSwapOob: HtmxSwap? = nil,
htmxSwap: HtmxSwap? = nil,
buttonLabel: String
) {
self.init(
formClass: formClass,
formId: formId,
htmxTargetUrl: htmxTargetUrl,
htmxTarget: htmxTarget,
htmxPushUrl: htmxPushUrl,
htmxResetAfterRequest: htmxResetAfterRequest,
htmxSwapOob: htmxSwapOob,
htmxSwap: htmxSwapOob,
context: .init(buttonLabel: buttonLabel)
)
}
}

View File

@@ -14,6 +14,8 @@ struct PurchaseOrderViewController: RouteCollection {
route.get(use: index)
route.get("next", use: nextPage)
route.post(use: create(req:))
route.post("search", use: postSearch)
route.get("search", use: getSearch)
route.group("create") {
$0.get(use: form)
$0.get("vendor-branch-select", use: vendorBranchSelect(req:))
@@ -39,11 +41,11 @@ struct PurchaseOrderViewController: RouteCollection {
@Sendable
func form(req: Request) async throws -> HTMLResponse {
// guard req.isHtmxRequest else {
// return try await req.render {
// try await mainPage(PurchaseOrderForm(shouldShow: true), page: .default)
// }
// }
guard req.isHtmxRequest else {
return try await req.render {
try await mainPage(PurchaseOrderForm(shouldShow: true), page: .default)
}
}
return await req.render { PurchaseOrderForm(shouldShow: true) }
}
@@ -73,12 +75,28 @@ struct PurchaseOrderViewController: RouteCollection {
@Sendable
func create(req: Request) async throws -> HTMLResponse {
let create = try req.content.decode(PurchaseOrder.CreateIntermediate.self)
let create = try req.content.decode(CreateContext.self).toIntermediate()
let user = try req.auth.require(User.self)
let purchaseOrder = try await purchaseOrders.create(create.toCreate(createdByID: user.id))
return await req.render { PurchaseOrderTable.Row(purchaseOrder: purchaseOrder) }
}
@Sendable
func postSearch(req: Request) async throws -> HTMLResponse {
let query = try req.content.decode([String: String].self)
req.logger.info("query: \(query)")
let context = try req.content.decode(SearchContext.self).toSearch()
let purchaseOrders = try await purchaseOrders.search(context)
req.logger.info("\(purchaseOrders)")
return await req.render { PurchaseOrderTable.Rows(page: purchaseOrders) }
}
@Sendable
func getSearch(req: Request) async throws -> HTMLResponse {
let context = try req.query.decode(SearchQuery.self).toSearchContext()
return await req.render { PurchaseOrderSearch(context: context) }
}
private func mainPage<C: HTML>(
_ html: C,
page: IndexQuery
@@ -87,6 +105,7 @@ struct PurchaseOrderViewController: RouteCollection {
return MainPage(displayNav: true, route: .purchaseOrders) {
div(.class("container")) {
html
PurchaseOrderSearch()
PurchaseOrderTable(page: page)
}
}
@@ -101,3 +120,54 @@ struct IndexQuery: Content {
.init(page: 1, limit: 25)
}
}
private struct CreateContext: Content {
let id: Int?
let workOrder: String
let materials: String
let customer: String
let truckStock: Bool?
let createdForID: Employee.ID
let vendorBranchID: VendorBranch.ID
func toIntermediate() -> PurchaseOrder.CreateIntermediate {
.init(
id: id,
workOrder: workOrder.isEmpty ? nil : Int(workOrder),
materials: materials,
customer: customer,
truckStock: truckStock,
createdForID: createdForID,
vendorBranchID: vendorBranchID
)
}
}
private struct SearchContext: Content {
let context: String
let search: String
func toSearch() throws -> PurchaseOrder.SearchContext {
switch context {
case "employee":
return .employee(search)
case "customer":
return .customer(search)
case "vendor":
return .vendor(search)
default:
throw Abort(.badRequest, reason: "Invalid search context.")
}
}
}
struct SearchQuery: Content {
let context: String
func toSearchContext() throws -> PurchaseOrderSearchContext {
guard let context = PurchaseOrderSearchContext(rawValue: context) else {
throw Abort(.badRequest, reason: "Invalid context.")
}
return context
}
}

View File

@@ -0,0 +1,38 @@
import DatabaseClient
import Dependencies
import SharedModels
import Vapor
import VaporElementary
struct UtilsViewController: RouteCollection {
@Dependency(\.database) var database
func boot(routes: any RoutesBuilder) throws {
let route = routes.protected
route.group("select") {
$0.get("employee", use: employeeSelect(req:))
}
}
@Sendable
func employeeSelect(req: Request) async throws -> HTMLResponse {
let context = try req.query.decode(EmployeeSelectContext.self)
let employees = try await database.employees.fetchAll()
return await req.render { context.toHTML(employees: employees) }
}
}
private struct EmployeeSelectContext: Content {
let context: EmployeeSelect.Context
func toHTML(employees: [Employee]) -> EmployeeSelect {
switch context {
case .form:
return .purchaseOrderForm(employees: employees)
case .search:
return .purchaseOrderSearch(employees: employees)
}
}
}

View File

@@ -9,14 +9,14 @@ extension RoutesBuilder {
// Used to ensure views are protected, redirects users to the login page if they're
// not authenticated.
var protected: any RoutesBuilder {
// return self
return grouped(
UserPasswordAuthenticator(),
UserSessionAuthenticator(),
User.redirectMiddleware { req in
"login?next=\(req.url)"
}
)
return self
// return grouped(
// UserPasswordAuthenticator(),
// UserSessionAuthenticator(),
// User.redirectMiddleware { req in
// "/login?next=\(req.url)"
// }
// )
}
func apiUnprotected(route: PathComponent) -> any RoutesBuilder {

View File

@@ -25,11 +25,11 @@ struct PurchaseOrderForm: HTML {
}
form(
.hx.post("/purchase-orders"),
.hx.target("purchase-order-table"),
.hx.swap(.afterBegin.transition(true).swap("1s")),
.hx.target("#purchase-order-table"),
.hx.swap(.afterBegin),
.custom(
name: "hx-on::after-request",
value: "if (event.detail.successful) toggleContent('float'); window.location.href='/purchase-orders';"
value: "if(event.detail.successful) toggleContent('float')"
)
) {
div(.class("row")) {
@@ -139,7 +139,7 @@ struct PurchaseOrderForm: HTML {
let vendorBranches: [VendorBranch.Detail]
var content: some HTML<HTMLTag.select> {
select(.name("vendorBranchID"), .class("col-3")) {
select(.name("vendorBranchID"), .class("col-4")) {
for branch in vendorBranches {
option(.value(branch.id.uuidString)) { "\(branch.vendor.name) - \(branch.name)" }
}

View File

@@ -0,0 +1,50 @@
import Elementary
import ElementaryHTMX
import SharedModels
import Vapor
struct PurchaseOrderSearch: HTML {
let context: PurchaseOrderSearchContext?
init(context: PurchaseOrderSearchContext? = nil) {
self.context = context
}
var content: some HTML {
form(
.id("search"),
.hx.post("/purchase-orders/search"),
.hx.target("#purchase-order-table"),
.hx.swap(.outerHTML.transition(true).swap("1s"))
) {
select(
.name("context"), .class("col-3"),
.hx.get("/purchase-orders/search"),
.hx.target("#search"),
.hx.swap(.outerHTML)
) {
option(.value("employee")) { "Employee" }
.attributes(.selected, when: context == .employee || context == nil)
option(.value("customer")) { "Customer" }
.attributes(.selected, when: context == .customer)
}
if context == .employee || context == nil {
EmployeeSelect.purchaseOrderSearch()
} else if context == .customer {
input(.type(.text), .name("search"), .placeholder("Search"), .required)
}
button(.type(.submit), .class("btn-primary")) { "Search" }
// Img.spinner().attributes(.class("hx-indicator"))
}
}
}
enum PurchaseOrderSearchContext: String, Codable, Content {
case employee
case customer
}

View File

@@ -23,7 +23,7 @@ struct PurchaseOrderTable: HTML {
.attributes(
.hx.get("/purchase-orders/create"),
.hx.target("#float"),
.hx.swap(.outerHTML.transition(true).swap("1s")),
.hx.swap(.outerHTML),
.hx.pushURL(true)
)
}
@@ -49,7 +49,7 @@ struct PurchaseOrderTable: HTML {
.hx.trigger(.event(.revealed)),
.hx.swap(.outerHTML.transition(true).swap("1s")),
.hx.target("this"),
.hx.indicator(".htmx-indicator")
.hx.indicator("next .htmx-indicator")
) {
img(.src("/images/spinner.svg"), .class("htmx-indicator"), .width(60), .height(60))
}

View File

@@ -0,0 +1,47 @@
import Elementary
import ElementaryHTMX
import SharedModels
import Vapor
struct EmployeeSelect: HTML {
let classString: String
let name: String
let employees: [Employee]?
let context: Context
var content: some HTML {
if let employees {
select(.name(name), .class(classString)) {
for employee in employees {
option(.value(employee.id.uuidString)) { employee.fullName }
}
}
.attributes(.style("margin-left: 15px;"), when: context == .search)
} else {
div(
.hx.get("/select/employee?context=\(context.rawValue)"),
.hx.target("this"),
.hx.swap(.outerHTML.transition(true).swap("0.5s")),
.hx.indicator("next .hx-indicator"),
.hx.trigger(.event(.revealed)),
.style("display: inline;")
) {
Img.spinner().attributes(.class("hx-indicator"))
}
}
}
static func purchaseOrderForm(employees: [Employee]? = nil) -> Self {
.init(classString: "col-3", name: "createdForID", employees: employees, context: .form)
}
static func purchaseOrderSearch(employees: [Employee]? = nil) -> Self {
.init(classString: "col-3", name: "employeeID", employees: employees, context: .search)
}
enum Context: String, Codable, Content {
case form
case search
}
}

View File

@@ -8,7 +8,7 @@ struct Navbar: HTML, Sendable {
"x"
}
a(.hx.get("/purchase-orders?page=1&limit=50"), .hx.target("body"), .hx.pushURL(true)) {
"Purchae Orders"
"Purchase Orders"
}
a(.hx.get("/users"), .hx.target("body"), .hx.pushURL(true)) {
"Users"

View File

@@ -39,8 +39,6 @@ public func configure(_ app: Application) async throws {
let databaseClient = DatabaseClient.live(database: app.db)
try await app.migrations.add(databaseClient.migrations())
app.views.use(.leaf)
try withDependencies {
$0.database = databaseClient
} operation: {

View File

@@ -1,6 +1,7 @@
import DatabaseClientLive
import Dependencies
import Elementary
import ElementaryHTMX
import Fluent
import SharedModels
import Vapor
@@ -12,6 +13,7 @@ func routes(_ app: Application) throws {
try app.register(collection: VendorViewController())
try app.register(collection: EmployeeViewController())
try app.register(collection: PurchaseOrderViewController())
try app.register(collection: UtilsViewController())
app.get { _ in
HTMLResponse {
@@ -38,7 +40,21 @@ func routes(_ app: Application) throws {
let token = try await users.login(loginForm)
let user = try await users.get(token.userID)!
req.session.authenticate(user)
return try await PurchaseOrderViewController().index(req: req)
let context = try req.query.decode(LoginContext.self)
return await req.render {
MainPage(displayNav: true, route: .purchaseOrders) {
div(
.hx.get(context.next ?? "/purchase-orders"),
.hx.pushURL(true),
.hx.target("body"),
.hx.trigger(.event(.revealed)),
.hx.indicator(".hx-indicator")
) {
Img.spinner().attributes(.class("hx-indicator"))
}
}
}
}
let protected = app.grouped(UserPasswordAuthenticator(), UserSessionAuthenticator())