From d8328314edfaec1cef88127119781629bf44c517 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 17 Jan 2025 23:50:04 -0500 Subject: [PATCH] feat: Working on route and id helpers for views. --- Public/images/search.svg | 1 + .../PurchaseOrderSearchViewController.swift | 84 ++++++++ .../View/PurchaseOrderViewController.swift | 72 ++----- .../View/UtilsViewController.swift | 26 ++- .../App/Views/Employees/EmployeeForm.swift | 9 +- .../App/Views/Employees/EmployeeTable.swift | 14 +- .../PurchaseOrders/PurchaseOrderForm.swift | 55 +---- .../PurchaseOrders/PurchaseOrderSearch.swift | 72 ++++--- .../PurchaseOrders/PurchaseOrderTable.swift | 88 ++++++-- Sources/App/Views/Users/UserDetail.swift | 8 +- Sources/App/Views/Users/UserForm.swift | 3 +- Sources/App/Views/Users/UserTable.swift | 14 +- .../App/Views/Utils/AttributeExtensions.swift | 11 + Sources/App/Views/Utils/EmployeeSelect.swift | 47 ---- Sources/App/Views/Utils/Img.swift | 4 + Sources/App/Views/Utils/Select.swift | 88 ++++++++ Sources/App/Views/ViewRoute.swift | 200 ++++++++++++++++++ Sources/App/routes.swift | 13 +- Sources/DatabaseClient/PurchaseOrders.swift | 2 +- .../DatabaseClientLive/PurchaseOrders.swift | 25 +-- Sources/SharedModels/PurchaseOrder.swift | 4 +- 21 files changed, 585 insertions(+), 255 deletions(-) create mode 100644 Public/images/search.svg create mode 100644 Sources/App/Controllers/View/PurchaseOrderSearchViewController.swift create mode 100644 Sources/App/Views/Utils/AttributeExtensions.swift delete mode 100644 Sources/App/Views/Utils/EmployeeSelect.swift create mode 100644 Sources/App/Views/Utils/Select.swift diff --git a/Public/images/search.svg b/Public/images/search.svg new file mode 100644 index 0000000..1170aac --- /dev/null +++ b/Public/images/search.svg @@ -0,0 +1 @@ + diff --git a/Sources/App/Controllers/View/PurchaseOrderSearchViewController.swift b/Sources/App/Controllers/View/PurchaseOrderSearchViewController.swift new file mode 100644 index 0000000..e1a0475 --- /dev/null +++ b/Sources/App/Controllers/View/PurchaseOrderSearchViewController.swift @@ -0,0 +1,84 @@ +import Dependencies +import Elementary +import Fluent +import SharedModels +import Vapor +import VaporElementary + +struct PurchaseOrderSearchViewController: RouteCollection { + @Dependency(\.database.employees) var employees + @Dependency(\.database.vendorBranches) var vendorBranches + @Dependency(\.database.purchaseOrders) var purchaseOrders + + func boot(routes: any RoutesBuilder) throws { + let route = routes.protected.grouped("purchase-orders", "search") + route.get(use: index) + // route.get("form", use: form) + route.post(use: post) + } + + @Sendable + func index(req: Request) async throws -> HTMLResponse { + let query = try? req.query.decode(FormQuery.self) + let html = PurchaseOrderSearch(context: query?.context) + guard req.isHtmxRequest else { + return await req.render { mainPage(search: html) } + } + return await req.render { html } + } + + @Sendable + func post(req: Request) async throws -> HTMLResponse { + let context = try req.content.decode(PurchaseOrderSearchContent.self) + let results = try await purchaseOrders.search(context.toDatabaseQuery(), .init(page: 1, per: 25)) + return await req.render { PurchaseOrderTable(page: results, context: .search, searchContext: nil) } + } + + // + // @Sendable + // func form(req: Request) async throws -> HTMLResponse { + // let query = try req.query.decode(FormQuery.self) + // let html = PurchaseOrderSearch(context: query.context) + // guard req.isHtmxRequest else { + // return await req.render { mainPage(search: html) } + // } + // return await req.render { PurchaseOrderSearch(context: query.context) } + // } + + func mainPage(search: PurchaseOrderSearch = .init()) -> some SendableHTMLDocument { + MainPage(displayNav: true, route: .purchaseOrders) { + div(.class("container"), .id("purchase-order-content")) { + search + PurchaseOrderTable(page: .init(items: [], metadata: .init(page: 0, per: 50, total: 0))) + } + } + } + +} + +extension PurchaseOrderSearchContent { + + func toDatabaseQuery() throws -> PurchaseOrder.SearchContext { + switch context { + case .employee: + guard let createdForID else { + throw Abort(.badRequest, reason: "Employee id not provided") + } + return .employee(createdForID) + case .customer: + guard let search, !search.isEmpty else { + throw Abort(.badRequest, reason: "Customer search string is empty.") + } + return .customer(search) + case .vendor: + guard let vendorBranchID else { + throw Abort(.badRequest, reason: "Vendor branch id not provided.") + } + return .vendor(vendorBranchID) + } + } +} + +private struct FormQuery: Content { + let context: PurchaseOrderSearchContext +} diff --git a/Sources/App/Controllers/View/PurchaseOrderViewController.swift b/Sources/App/Controllers/View/PurchaseOrderViewController.swift index 3ca02a4..de93e48 100644 --- a/Sources/App/Controllers/View/PurchaseOrderViewController.swift +++ b/Sources/App/Controllers/View/PurchaseOrderViewController.swift @@ -1,5 +1,6 @@ import Dependencies import Elementary +import Fluent import SharedModels import Vapor import VaporElementary @@ -14,12 +15,10 @@ 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.post("search", use: postSearch) + // route.get("search", use: getSearch) route.group("create") { $0.get(use: form) - $0.get("vendor-branch-select", use: vendorBranchSelect(req:)) - $0.get("employee-select", use: employeeSelect(req:)) } route.group(":id") { $0.get(use: get) @@ -49,18 +48,6 @@ struct PurchaseOrderViewController: RouteCollection { return await req.render { PurchaseOrderForm(shouldShow: true) } } - @Sendable - func vendorBranchSelect(req: Request) async throws -> HTMLResponse { - let branches = try await vendorBranches.fetchAllWithDetail() - return await req.render { PurchaseOrderForm.VendorSelect(vendorBranches: branches) } - } - - @Sendable - func employeeSelect(req: Request) async throws -> HTMLResponse { - let employees = try await self.employees.fetchAll() - return await req.render { PurchaseOrderForm.EmployeeSelect(employees: employees) } - } - @Sendable func get(req: Request) async throws -> HTMLResponse { let purchaseOrder = try await purchaseOrders.get(req.ensureIDPathComponent(as: Int.self)) @@ -83,18 +70,23 @@ struct PurchaseOrderViewController: RouteCollection { @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) } + let context = try req.content.decode(PurchaseOrderSearchContent.self) + let results = try await purchaseOrders.search(context.toDatabaseQuery(), .init(page: 1, per: 25)) + return await req.render { PurchaseOrderTable(page: results, context: .search, searchContext: nil) } } + // Show the form to generate a search query. @Sendable func getSearch(req: Request) async throws -> HTMLResponse { - let context = try req.query.decode(SearchQuery.self).toSearchContext() - return await req.render { PurchaseOrderSearch(context: context) } + // TODO: Need to handle updating the form. + return await req.render { + MainPage(displayNav: true, route: .purchaseOrders) { + div(.class("container"), .id("purchase-order-content")) { + PurchaseOrderSearch() + PurchaseOrderTable(page: .init(items: [], metadata: .init(page: 0, per: 50, total: 0))) + } + } + } } private func mainPage( @@ -103,9 +95,8 @@ struct PurchaseOrderViewController: RouteCollection { ) async throws -> some SendableHTMLDocument where C: Sendable { let page = try await purchaseOrders.fetchPage(.init(page: page.page, per: page.limit)) return MainPage(displayNav: true, route: .purchaseOrders) { - div(.class("container")) { + div(.class("container"), .id("purchase-order-content")) { html - PurchaseOrderSearch() PurchaseOrderTable(page: page) } } @@ -142,32 +133,3 @@ private struct CreateContext: Content { ) } } - -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 - } -} diff --git a/Sources/App/Controllers/View/UtilsViewController.swift b/Sources/App/Controllers/View/UtilsViewController.swift index 573ac1a..1e07824 100644 --- a/Sources/App/Controllers/View/UtilsViewController.swift +++ b/Sources/App/Controllers/View/UtilsViewController.swift @@ -11,28 +11,44 @@ struct UtilsViewController: RouteCollection { let route = routes.protected route.group("select") { $0.get("employee", use: employeeSelect(req:)) + $0.get("vendor-branches", use: vendorBranchSelect(req:)) } } @Sendable func employeeSelect(req: Request) async throws -> HTMLResponse { - let context = try req.query.decode(EmployeeSelectContext.self) + let context = try req.query.decode(SelectQueryContext.self) let employees = try await database.employees.fetchAll() return await req.render { context.toHTML(employees: employees) } } + + @Sendable + func vendorBranchSelect(req: Request) async throws -> HTMLResponse { + let context = try req.query.decode(SelectQueryContext.self) + let branches = try await database.vendorBranches.fetchAllWithDetail() + return await req.render { context.toHTML(branches: branches) } + } } -private struct EmployeeSelectContext: Content { +private struct SelectQueryContext: Content { - let context: EmployeeSelect.Context + let context: SelectContext func toHTML(employees: [Employee]) -> EmployeeSelect { switch context { - case .form: + case .purchaseOrderForm: return .purchaseOrderForm(employees: employees) - case .search: + case .purchaseOrderSearch: return .purchaseOrderSearch(employees: employees) } } + func toHTML(branches: [VendorBranch.Detail]) -> VendorBranchSelect { + switch context { + case .purchaseOrderForm: + return .purchaseOrderForm(branches: branches) + case .purchaseOrderSearch: + return .purchaseOrderSearch(branches: branches) + } + } } diff --git a/Sources/App/Views/Employees/EmployeeForm.swift b/Sources/App/Views/Employees/EmployeeForm.swift index a727710..6023a69 100644 --- a/Sources/App/Views/Employees/EmployeeForm.swift +++ b/Sources/App/Views/Employees/EmployeeForm.swift @@ -20,7 +20,7 @@ struct EmployeeForm: HTML { Float(shouldDisplay: shouldShow, resetURL: "/employees") { form( employee == nil ? .hx.post(targetURL) : .hx.put(targetURL), - employee == nil ? .hx.target("#employee-table") : .hx.target("#employee_\(employee!.id)"), + .hx.target(target), employee == nil ? .hx.swap(.beforeEnd.transition(true).swap("0.5s")) : .hx.swap(.outerHTML.transition(true).swap("0.5s")), @@ -62,6 +62,13 @@ struct EmployeeForm: HTML { } } + private var target: HXTarget { + guard let employee else { + return .employee(.table) + } + return .employee(.row(id: employee.id)) + } + private var buttonLabel: String { guard employee != nil else { return "Create" } return "Update" diff --git a/Sources/App/Views/Employees/EmployeeTable.swift b/Sources/App/Views/Employees/EmployeeTable.swift index 9db1e46..0390b26 100644 --- a/Sources/App/Views/Employees/EmployeeTable.swift +++ b/Sources/App/Views/Employees/EmployeeTable.swift @@ -14,14 +14,14 @@ struct EmployeeTable: HTML { Button.add() .attributes( .style("padding: 0px 10px;"), - .hx.get("/employees/create"), - .hx.target("#float"), + .hx.get(route: .employees(.create)), + .hx.target(.float), .hx.swap(.outerHTML.transition(true).swap("0.5s")) ) } } } - tbody(.id("employee-table")) { + tbody(.id(.employee(.table))) { for employee in employees { Row(employee: employee) } @@ -33,14 +33,14 @@ struct EmployeeTable: HTML { let employee: Employee var content: some HTML { - tr(.id("employee_\(employee.id)")) { - td { "\(employee.firstName.capitalized) \(employee.lastName.capitalized)" } + tr(.id(.employee(.row(id: employee.id)))) { + td { employee.fullName } td { Button.detail() .attributes( .style("padding-left: 15px;"), - .hx.get("/employees/\(employee.id)"), - .hx.target("#float"), + .hx.get(route: .employees(.id(employee.id))), + .hx.target(.float), .hx.pushURL(true), .hx.swap(.outerHTML.transition(true).swap("0.5s")) ) diff --git a/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift b/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift index 31f99d7..61d7d20 100644 --- a/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift +++ b/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift @@ -24,13 +24,10 @@ struct PurchaseOrderForm: HTML { } } form( - .hx.post("/purchase-orders"), - .hx.target("#purchase-order-table"), + .hx.post(route: .purchaseOrders()), + .hx.target(.purchaseOrders(.table)), .hx.swap(.afterBegin), - .custom( - name: "hx-on::after-request", - value: "if(event.detail.successful) toggleContent('float')" - ) + .customToggleFloatAfterRequest ) { div(.class("row")) { label( @@ -65,16 +62,7 @@ struct PurchaseOrderForm: HTML { .for("vendorBranchID"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") ) { "Vendor:" } if purchaseOrder == nil { - div( - .class("col-4"), - .hx.get("/purchase-orders/create/vendor-branch-select"), - .hx.target("this"), - .hx.swap(.outerHTML.transition(true).swap("0.5s")), - .hx.trigger(.event(.revealed)), - .hx.indicator(".hx-indicator") - ) { - Img.spinner().attributes(.class("hx-indicator"), .style("float: left;")) - } + VendorBranchSelect.purchaseOrderForm() } else { input( .type(.text), .class("col-4"), @@ -89,16 +77,7 @@ struct PurchaseOrderForm: HTML { .for("createdForID"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") ) { "Employee:" } if purchaseOrder == nil { - div( - .class("col-3"), - .hx.get("/purchase-orders/create/employee-select"), - .hx.target("this"), - .hx.swap(.outerHTML.transition(true).swap("0.5s")), - .hx.trigger(.event(.revealed)), - .hx.indicator(".hx-indicator") - ) { - Img.spinner().attributes(.class("hx-indicator"), .style("float: left;")) - } + EmployeeSelect.purchaseOrderForm() } else { input( .type(.text), .class("col-3"), @@ -134,28 +113,4 @@ struct PurchaseOrderForm: HTML { guard purchaseOrder != nil else { return "Create" } return "Update" } - - struct VendorSelect: HTML { - let vendorBranches: [VendorBranch.Detail] - - var content: some HTML { - select(.name("vendorBranchID"), .class("col-4")) { - for branch in vendorBranches { - option(.value(branch.id.uuidString)) { "\(branch.vendor.name) - \(branch.name)" } - } - } - } - } - - struct EmployeeSelect: HTML { - let employees: [Employee] - - var content: some HTML { - select(.name("createdForID"), .class("col-3")) { - for employee in employees { - option(.value(employee.id.uuidString)) { employee.fullName } - } - } - } - } } diff --git a/Sources/App/Views/PurchaseOrders/PurchaseOrderSearch.swift b/Sources/App/Views/PurchaseOrders/PurchaseOrderSearch.swift index 971e9ee..aaffbad 100644 --- a/Sources/App/Views/PurchaseOrders/PurchaseOrderSearch.swift +++ b/Sources/App/Views/PurchaseOrders/PurchaseOrderSearch.swift @@ -5,46 +5,70 @@ import Vapor struct PurchaseOrderSearch: HTML { - let context: PurchaseOrderSearchContext? + let context: PurchaseOrderSearchContext init(context: PurchaseOrderSearchContext? = nil) { - self.context = context + self.context = context ?? .employee } var content: some HTML { form( - .id("search"), - .hx.post("/purchase-orders/search"), - .hx.target("#purchase-order-table"), - .hx.swap(.outerHTML.transition(true).swap("1s")) + .id(.search), + .hx.post(route: .purchaseOrders(.search())), + .hx.target(.purchaseOrders()), + .hx.swap(.outerHTML) ) { - 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) + div(.class("btn-row")) { + button( + .class("btn-secondary"), .style("position: absolute; top: 80px; right: 20px;"), + .hx.get(route: .purchaseOrders()), .hx.pushURL(true), .hx.target("body") + ) + { "x" } + } + div(.class("row")) { + select( + .name("context"), .class("col-3"), + .hx.get(route: .purchaseOrders(.search())), + .hx.target(.search), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.pushURL(true) + ) { + for context in PurchaseOrderSearchContext.allCases { + option(.value(context.rawValue)) { context.rawValue.capitalized } + .attributes(.selected, when: self.context == context) + } + } - option(.value("customer")) { "Customer" } - .attributes(.selected, when: context == .customer) + if context == .employee { + EmployeeSelect.purchaseOrderSearch() + } else if context == .customer { + input( + .type(.text), .class("col-6"), .style("margin-left: 60px; margin-top: 18px;"), + .name("search"), .placeholder("Search"), .required + ) + } else if context == .vendor { + VendorBranchSelect.purchaseOrderSearch() + } } - if context == .employee || context == nil { - EmployeeSelect.purchaseOrderSearch() - } else if context == .customer { - input(.type(.text), .name("search"), .placeholder("Search"), .required) + div(.class("btn-row")) { + button(.type(.submit), .class("btn-primary")) + { "Search" } } - - button(.type(.submit), .class("btn-primary")) { "Search" } - // Img.spinner().attributes(.class("hx-indicator")) } } } -enum PurchaseOrderSearchContext: String, Codable, Content { +enum PurchaseOrderSearchContext: String, Codable, Content, CaseIterable { case employee case customer + case vendor +} + +struct PurchaseOrderSearchContent: Content { + let context: PurchaseOrderSearchContext + let createdForID: Employee.ID? + let search: String? + let vendorBranchID: VendorBranch.ID? } diff --git a/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift b/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift index 3b69628..c8b59ba 100644 --- a/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift +++ b/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift @@ -7,30 +7,66 @@ import Vapor struct PurchaseOrderTable: HTML { let page: Page + let context: Context + let searchContext: PurchaseOrderSearchContext? + + init( + page: Page, + context: Context = .default, + searchContext: PurchaseOrderSearchContext? = nil + ) { + self.page = page + self.context = context + self.searchContext = searchContext + } var content: some HTML { - table { - thead { - tr { - th { "PO" } - th { "Work Order" } - th { "Customer" } - th { "Vendor" } - th { "Materials" } - th { "Created For" } - th { - Button.add() - .attributes( - .hx.get("/purchase-orders/create"), - .hx.target("#float"), - .hx.swap(.outerHTML), - .hx.pushURL(true) - ) - } + table(.id(.purchaseOrders())) { + if page.items.count > 0 { + thead { + buttonRow + tableHeader + } + tbody(.id(.purchaseOrders(.table))) { + Rows(page: page) } } - tbody(.id("purchase-order-table")) { - Rows(page: page) + } + } + + private var tableHeader: some HTML { + tr { + th { "PO" } + th { "Work Order" } + th { "Customer" } + th { "Vendor" } + th { "Materials" } + th { "Created For" } + th { + if context != .search { + Button.add() + .attributes( + .hx.get(route: .purchaseOrders(.create)), .hx.target(.float), + .hx.swap(.outerHTML), .hx.pushURL(true) + ) + } + } + } + } + + private var buttonRow: some HTML { + tr { + div(.class("btn-row")) { + if context != .search { + button( + .class("btn-primary"), .style("position: absolute; top: 80px; right: 20px;"), + .hx.get(route: .purchaseOrders(.search(.context(.employee)))), + .hx.target(.body), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.pushURL(true) + ) + { Img.search() } + } } } } @@ -45,10 +81,11 @@ struct PurchaseOrderTable: HTML { } if page.metadata.pageCount > page.metadata.page { tr( - .hx.get("/purchase-orders/next?page=\(page.metadata.page + 1)&limit=\(page.metadata.per)"), + // .hx.get("/purchase-orders/next?page=\(page.metadata.page + 1)&limit=\(page.metadata.per)"), + .hx.get(route: .purchaseOrders(.nextPage(page.metadata))), .hx.trigger(.event(.revealed)), .hx.swap(.outerHTML.transition(true).swap("1s")), - .hx.target("this"), + .hx.target(.this), .hx.indicator("next .htmx-indicator") ) { img(.src("/images/spinner.svg"), .class("htmx-indicator"), .width(60), .height(60)) @@ -63,7 +100,7 @@ struct PurchaseOrderTable: HTML { var content: some HTML { tr( - .id("purchase_order_\(purchaseOrder.id)") + .id(.purchaseOrders(.row(id: purchaseOrder.id))) ) { td { "\(purchaseOrder.id)" } td { purchaseOrder.workOrder != nil ? String(purchaseOrder.workOrder!) : "" } @@ -83,6 +120,11 @@ struct PurchaseOrderTable: HTML { } } } + + enum Context: String { + case `default` + case search + } } private extension VendorBranch.Detail { diff --git a/Sources/App/Views/Users/UserDetail.swift b/Sources/App/Views/Users/UserDetail.swift index 60e1f29..3a5cfab 100644 --- a/Sources/App/Views/Users/UserDetail.swift +++ b/Sources/App/Views/Users/UserDetail.swift @@ -12,9 +12,9 @@ struct UserDetail: HTML, Sendable { Float(shouldDisplay: user != nil, resetURL: "/users") { if let user { form( - .hx.post("/users/\(user.id)"), + .hx.post(route: .users(.id(user.id))), .hx.swap(.outerHTML), - .hx.target("#user_\(user.id)"), + .hx.target(.user(.row(id: user.id))), .custom(name: "hx-on::after-request", value: "toggleContent('float'); window.location.href='/users';") ) { div(.class("row")) { @@ -36,10 +36,10 @@ struct UserDetail: HTML, Sendable { ) { "Update" } Button.danger { "Delete" } .attributes( - .hx.delete("/users/\(user.id)"), + .hx.delete(route: .users(.id(user.id))), .hx.trigger(.event(.click)), .hx.swap(.outerHTML), - .hx.target("#user_\(user.id)"), + .hx.target(.user(.row(id: user.id))), .hx.confirm("Are you sure you want to delete this user?"), .custom(name: "hx-on::after-request", value: "toggleContent('float'); window.location.href='/users';") ) diff --git a/Sources/App/Views/Users/UserForm.swift b/Sources/App/Views/Users/UserForm.swift index a637f8e..baacf44 100644 --- a/Sources/App/Views/Users/UserForm.swift +++ b/Sources/App/Views/Users/UserForm.swift @@ -17,7 +17,7 @@ struct UserForm: HTML, Sendable { private func makeForm() -> some HTML { form( - .id("user-form"), + .id(.user(.form)), .class("user-form"), .hx.post(context.targetURL), .hx.pushURL(context.pushURL), @@ -99,6 +99,7 @@ struct UserForm: HTML, Sendable { } } + // TODO: Return a route container. var targetURL: String { switch self { case .create: diff --git a/Sources/App/Views/Users/UserTable.swift b/Sources/App/Views/Users/UserTable.swift index 46bb6d7..f41d3d3 100644 --- a/Sources/App/Views/Users/UserTable.swift +++ b/Sources/App/Views/Users/UserTable.swift @@ -9,7 +9,7 @@ struct UserTable: HTML { let users: [User] var content: some HTML { - table(.id("user-table")) { + table(.id(.user(.table()))) { thead { tr { th { "Username" } @@ -17,14 +17,14 @@ struct UserTable: HTML { th(.style("width: 50px;")) { Button.add() .attributes( - .hx.get("/users/create"), - .hx.target("#float"), + .hx.get(route: .users(.create)), + .hx.target(.float), .hx.swap(.outerHTML) ) } } } - tbody(.id("user-table-body")) { + tbody(.id(.user(.table(.body)))) { for user in users { Row(user: user) } @@ -40,13 +40,13 @@ struct UserTable: HTML { } var content: some HTML { - tr(.id("user_\(user.id)")) { + tr(.id(.user(.row(id: user.id)))) { td { user.username } td { user.email } td { Button.detail().attributes( - .hx.get("/users/\(user.id.uuidString)"), - .hx.target("#float"), + .hx.get(route: .users(.id(user.id))), + .hx.target(.float), .hx.swap(.outerHTML), .hx.pushURL(true) ) diff --git a/Sources/App/Views/Utils/AttributeExtensions.swift b/Sources/App/Views/Utils/AttributeExtensions.swift new file mode 100644 index 0000000..83991e3 --- /dev/null +++ b/Sources/App/Views/Utils/AttributeExtensions.swift @@ -0,0 +1,11 @@ +import Elementary + +extension HTMLAttribute where Tag: HTMLTrait.Attributes.Global { + + static var customToggleFloatAfterRequest: Self { + .custom( + name: "hx-on::after-request", + value: "if(event.detail.successful) toggleContent('float')" + ) + } +} diff --git a/Sources/App/Views/Utils/EmployeeSelect.swift b/Sources/App/Views/Utils/EmployeeSelect.swift deleted file mode 100644 index 1906397..0000000 --- a/Sources/App/Views/Utils/EmployeeSelect.swift +++ /dev/null @@ -1,47 +0,0 @@ -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 - } -} diff --git a/Sources/App/Views/Utils/Img.swift b/Sources/App/Views/Utils/Img.swift index 64331a9..4044bd6 100644 --- a/Sources/App/Views/Utils/Img.swift +++ b/Sources/App/Views/Utils/Img.swift @@ -4,4 +4,8 @@ enum Img { static func spinner(width: Int = 30, height: Int = 30) -> some HTML { img(.src("/images/spinner.svg"), .width(width), .height(height)) } + + static func search(width: Int = 30, height: Int = 30) -> some HTML { + img(.src("/images/search.svg"), .width(width), .height(height)) + } } diff --git a/Sources/App/Views/Utils/Select.swift b/Sources/App/Views/Utils/Select.swift new file mode 100644 index 0000000..b7b1f2c --- /dev/null +++ b/Sources/App/Views/Utils/Select.swift @@ -0,0 +1,88 @@ +import Elementary +import ElementaryHTMX +import SharedModels +import Vapor + +struct EmployeeSelect: HTML { + + let employees: [Employee]? + let context: SelectContext + + var content: some HTML { + if let employees { + select(.name("createdForID"), .class(context.classString)) { + for employee in employees { + option(.value(employee.id.uuidString)) { employee.fullName } + } + } + .attributes(.style("margin-left: 15px;"), when: context == .purchaseOrderSearch) + } 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(employees: employees, context: .purchaseOrderForm) + } + + static func purchaseOrderSearch(employees: [Employee]? = nil) -> Self { + .init(employees: employees, context: .purchaseOrderSearch) + } + +} + +struct VendorBranchSelect: HTML { + let branches: [VendorBranch.Detail]? + let context: SelectContext + + var content: some HTML { + if let branches { + select(.name("vendorBranchID"), .class(context.classString)) { + for branch in branches { + option(.value(branch.id.uuidString)) { "\(branch.vendor.name) - \(branch.name)" } + } + } + .attributes(.style("margin-left: 15px;"), when: context == .purchaseOrderSearch) + } else { + div( + .hx.get("/select/vendor-branches?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(branches: [VendorBranch.Detail]? = nil) -> Self { + .init(branches: branches, context: .purchaseOrderForm) + } + + static func purchaseOrderSearch(branches: [VendorBranch.Detail]? = nil) -> Self { + .init(branches: branches, context: .purchaseOrderSearch) + } +} + +enum SelectContext: String, Codable, Content { + case purchaseOrderForm + case purchaseOrderSearch + + var classString: String { + switch self { + case .purchaseOrderForm: return "col-3" + case .purchaseOrderSearch: return "col-6" + } + } +} diff --git a/Sources/App/Views/ViewRoute.swift b/Sources/App/Views/ViewRoute.swift index ec16346..0a45c79 100644 --- a/Sources/App/Views/ViewRoute.swift +++ b/Sources/App/Views/ViewRoute.swift @@ -1,3 +1,203 @@ +import Elementary +import ElementaryHTMX +import Fluent +import SharedModels + +enum RouteContainer { + case employees(EmployeeRoute? = nil) + case purchaseOrders(PurchaseOrderRoute? = nil) + case users(UserRoute? = nil) + + var url: String { + switch self { + case let .employees(employees): + let path = "/employees" + guard let employees else { return path } + return "\(path)/\(employees.path)" + + case let .purchaseOrders(route): + let path = "/purchase-orders" + guard let route else { return path } + return "\(path)/\(route.path)" + + case let .users(route): + let path = "/users" + guard let route else { return path } + return "\(path)/\(route.path)" + } + } + + enum EmployeeRoute { + case create + case id(Employee.ID) + + var path: String { + switch self { + case .create: return "create" + case let .id(id): return id.uuidString + } + } + } + + enum PurchaseOrderRoute { + case create + case nextPage(PageMetadata) + case search(SearchQuery? = nil) + + var path: String { + switch self { + case .create: + return "create" + + case let .nextPage(currentPage): + return "next?page=\(currentPage.page + 1)&limit\(currentPage.per)" + + case let .search(query): + guard let query else { return "search" } + return "search?\(query.query)" + } + } + + enum SearchQuery { + case context(PurchaseOrderSearchContext) + + var query: String { + switch self { + case let .context(context): + return "context=\(context.rawValue)" + } + } + } + } + + enum UserRoute { + case create + case id(User.ID) + + var path: String { + switch self { + case .create: return "create" + case let .id(id): return id.uuidString + } + } + } + +} + +extension HTMLAttribute.hx { + static func get(route: RouteContainer) -> HTMLAttribute { + get(route.url) + } + + static func post(route: RouteContainer) -> HTMLAttribute { + post(route.url) + } + + static func put(route: RouteContainer) -> HTMLAttribute { + put(route.url) + } + + static func delete(route: RouteContainer) -> HTMLAttribute { + delete(route.url) + } + +} + +enum RouteKey: String { + case purchaseOrders = "purchase-orders" +} + +enum HXTarget { + case body + case employee(EmployeeKey) + case float + case purchaseOrders(PurchaseOrdersKey? = nil) + case search + case this + case user(UserKey) + + var selector: String { + switch self { + case .body: return "body" + case .this: return "this" + default: + return "#\(id)" + } + } + + var id: String { + switch self { + case let .employee(key): return key.key + case .float: return "float" + case let .purchaseOrders(key): + guard let key else { return "purchase-orders" } + return key.key + case .search: return "search" + case let .user(key): return key.key + case .this, .body: + fatalError("'\(selector)' can not be used as an id.") + } + } + + enum PurchaseOrdersKey { + case table + case row(id: PurchaseOrder.ID) + + var key: String { + switch self { + case .table: return "purchase-orders-table" + case let .row(id): return "purchase_order_\(id)" + } + } + } + + enum EmployeeKey { + case table + case row(id: Employee.ID) + + var key: String { + switch self { + case .table: return "employee-table" + case let .row(id): return "employee_\(id.uuidString)" + } + } + } + + enum UserKey { + case form + case row(id: User.ID) + case table(Table? = nil) + + var key: String { + switch self { + case .form: return "user-form" + case let .row(id): return "user_\(id)" + case let .table(table): + let key = "user-table" + guard let table else { return key } + return "\(key)-\(table.rawValue)" + } + } + + enum Table: String { + case body + } + } +} + +extension HTMLAttribute.hx { + + static func target(_ target: HXTarget) -> HTMLAttribute { + Self.target(target.selector) + } +} + +extension HTMLAttribute where Tag: HTMLTrait.Attributes.Global { + static func id(_ target: HXTarget) -> Self { + id(target.id) + } +} + enum ViewRoute: String { case employees diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 3fa097c..3eb2029 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -13,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: PurchaseOrderSearchViewController()) try app.register(collection: UtilsViewController()) app.get { _ in @@ -56,18 +57,6 @@ func routes(_ app: Application) throws { } } } - - let protected = app.grouped(UserPasswordAuthenticator(), UserSessionAuthenticator()) - - protected.get("me") { req in - let user = try req.auth.require(User.self) - - return HTMLResponse { - MainPage(displayNav: false, route: .purchaseOrders) { - h1 { "You are logged in as: \(user.username)" } - } - } - } } private struct LoginContext: Content { diff --git a/Sources/DatabaseClient/PurchaseOrders.swift b/Sources/DatabaseClient/PurchaseOrders.swift index 7bce8b1..c18cbb6 100644 --- a/Sources/DatabaseClient/PurchaseOrders.swift +++ b/Sources/DatabaseClient/PurchaseOrders.swift @@ -13,7 +13,7 @@ public extension DatabaseClient { public var get: @Sendable (PurchaseOrder.ID) async throws -> PurchaseOrder? // var update: @Sendable (PurchaseOrder.ID, PurchaseOrder.Update) async throws -> PurchaseOrder public var delete: @Sendable (PurchaseOrder.ID) async throws -> Void - public var search: @Sendable (PurchaseOrder.SearchContext) async throws -> Page + public var search: @Sendable (PurchaseOrder.SearchContext, PageRequest) async throws -> Page } } diff --git a/Sources/DatabaseClientLive/PurchaseOrders.swift b/Sources/DatabaseClientLive/PurchaseOrders.swift index 13da795..32495f1 100644 --- a/Sources/DatabaseClientLive/PurchaseOrders.swift +++ b/Sources/DatabaseClientLive/PurchaseOrders.swift @@ -29,31 +29,24 @@ public extension DatabaseClient.PurchaseOrders { throw NotFoundError() } try await model.delete(on: database) - } search: { search in + } search: { search, page in let query = PurchaseOrderModel.allQuery(on: database) switch search { - case let .employee(employee): - guard let employee = try await EmployeeModel.query(on: database).group(.or, { group in - group.filter(\.$firstName ~~ employee).filter(\.$lastName ~~ employee) - }).first() - else { return Page.empty } - - return try await query.filter(\.$createdFor.$id == employee.id!) - .paginate(.init(page: 1, per: 25)) + case let .employee(id): + return try await query.filter(\.$createdFor.$id == id) + .paginate(page) .map { try $0.toDTO() } case let .customer(search): return try await query.filter(\.$customer ~~ search) - .paginate(.init(page: 1, per: 25)) + .paginate(page) .map { try $0.toDTO() } - case let .vendor(search): - guard let vendor = try await VendorModel.query(on: database).filter(\.$name ~~ search).first() else { - return .empty - } - // TODO: how to search for this?? - return .init(items: [], metadata: .init(page: 1, per: 1, total: 0)) + case let .vendor(id): + return try await query.filter(\.$vendorBranch.$id == id) + .paginate(page) + .map { try $0.toDTO() } } } } diff --git a/Sources/SharedModels/PurchaseOrder.swift b/Sources/SharedModels/PurchaseOrder.swift index 7782974..bac00aa 100644 --- a/Sources/SharedModels/PurchaseOrder.swift +++ b/Sources/SharedModels/PurchaseOrder.swift @@ -117,8 +117,8 @@ public extension PurchaseOrder { enum SearchContext: Sendable { case customer(String) - case vendor(String) - case employee(String) + case vendor(VendorBranch.ID) + case employee(Employee.ID) } }