From 97b231767e0ace860a542ee073dee6c084e8a258 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Tue, 21 Jan 2025 09:51:11 -0500 Subject: [PATCH] feat: Moves vendor branch views to their own files, starts to implement snapshot testing for html --- Package.resolved | 11 +++- Package.swift | 20 +++++- Sources/App/Controllers/ViewController.swift | 17 ++++- Sources/App/Views/HTMXExtensions.swift | 4 +- Sources/App/Views/Utils/Buttons.swift | 9 +++ Sources/App/Views/Utils/Img.swift | 5 ++ .../VendorBranches/VendorBranchForm.swift | 34 ++++++++++ .../VendorBranches/VendorBranchList.swift | 46 ++++++++++++++ Sources/App/Views/Vendors/VendorDetail.swift | 62 +------------------ Sources/App/configure.swift | 8 ++- .../HtmlSnapshotTesting.swift | 12 ++++ Sources/SharedModels/Routes/ViewRoute.swift | 17 +++-- .../HtmlSnapshotTestingTests.swift | 12 ++++ .../testSimple.1.html | 33 ++++++++++ .../VendorBranchViewRouteTests.swift | 12 ++++ 15 files changed, 231 insertions(+), 71 deletions(-) create mode 100644 Sources/App/Views/VendorBranches/VendorBranchForm.swift create mode 100644 Sources/App/Views/VendorBranches/VendorBranchList.swift create mode 100644 Sources/HtmlSnapshotTesting/HtmlSnapshotTesting.swift create mode 100644 Tests/HtmlSnapshotTestingTests/HtmlSnapshotTestingTests.swift create mode 100644 Tests/HtmlSnapshotTestingTests/__Snapshots__/HtmlSnapshotTestingTests/testSimple.1.html diff --git a/Package.resolved b/Package.resolved index 5c07f3d..5623485 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dab5887e7f33b2dba9c9b86598c47d541464dda5c29e084c8d38dec82923c953", + "originHash" : "20332ed810f0f8bda6b1a104968eae13e98b479c32a983c890e6526a4940c7ad", "pins" : [ { "identity" : "async-http-client", @@ -316,6 +316,15 @@ "version" : "1.1.0" } }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", + "state" : { + "revision" : "2e6a85b73fc14e27d7542165ae73b1a10516ca9a", + "version" : "1.17.7" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index b8677f0..42e7337 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,8 @@ let package = Package( .executable(name: "App", targets: ["App"]), .library(name: "SharedModels", targets: ["SharedModels"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]), - .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]) + .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]), + .library(name: "HtmlSnapshotTesting", targets: ["HtmlSnapshotTesting"]) ], dependencies: [ // 💧 A server-side Swift web framework. @@ -26,7 +27,8 @@ let package = Package( .package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.4.0"), .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"), - .package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3") + .package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.17.7") ], targets: [ .executableTarget( @@ -89,6 +91,20 @@ let package = Package( .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver") ] ), + .target( + name: "HtmlSnapshotTesting", + dependencies: [ + .product(name: "Elementary", package: "elementary"), + .product(name: "SnapshotTesting", package: "swift-snapshot-testing") + ] + ), + .testTarget( + name: "HtmlSnapshotTestingTests", + dependencies: [ + .target(name: "App"), + .target(name: "HtmlSnapshotTesting") + ] + ), .target( name: "SharedModels", dependencies: [ diff --git a/Sources/App/Controllers/ViewController.swift b/Sources/App/Controllers/ViewController.swift index a40209b..54cbc75 100644 --- a/Sources/App/Controllers/ViewController.swift +++ b/Sources/App/Controllers/ViewController.swift @@ -16,6 +16,7 @@ extension SharedModels.ViewRoute { var middleware: [any Middleware]? { switch self { + case .index: return viewProtectedMiddleware case let .employee(route): return route.middleware case .login: return nil case let .purchaseOrder(route): return route.middleware @@ -28,6 +29,9 @@ extension SharedModels.ViewRoute { func handle(request: Request) async throws -> any AsyncResponseEncodable { @Dependency(\.database.users) var users switch self { + case .index: + return request.redirect(to: Self.router.path(for: .purchaseOrder(.index))) + case let .employee(route): return try await route.handle(request: request) @@ -348,6 +352,17 @@ extension SharedModels.ViewRoute.VendorBranchRoute { @Dependency(\.database) var database switch self { + case let .index(for: vendorID): + guard let vendorID else { + throw Abort(.badRequest, reason: "Vendor id not supplied") + } + return try await request.render { + try await VendorBranchList( + vendorID: vendorID, + branches: database.vendorBranches.fetchAll(.for(vendorID: vendorID)) + ) + } + case let .select(context: context): return try await request.render { try await context.toHTML(branches: database.vendorBranches.fetchAllWithDetail()) @@ -355,7 +370,7 @@ extension SharedModels.ViewRoute.VendorBranchRoute { case let .create(branch): return try await request.render { - try await VendorDetail.BranchRow(branch: database.vendorBranches.create(branch)) + try await VendorBranchList.Row(branch: database.vendorBranches.create(branch)) } case let .delete(id: id): diff --git a/Sources/App/Views/HTMXExtensions.swift b/Sources/App/Views/HTMXExtensions.swift index 712fa12..efb1041 100644 --- a/Sources/App/Views/HTMXExtensions.swift +++ b/Sources/App/Views/HTMXExtensions.swift @@ -117,11 +117,13 @@ enum IDKey: CustomStringConvertible { } enum Branch: CustomStringConvertible { + case list case form case row(id: VendorBranch.ID) var description: String { switch self { + case .list: return "list" case .form: return "form" case let .row(id): return id.uuidString } @@ -148,7 +150,7 @@ enum IDKey: CustomStringConvertible { var description: String { switch self { - case let .content: return "content" + case .content: return "content" case let .row(id): return "\(id)" case .search: return "search" case .table: return "table" diff --git a/Sources/App/Views/Utils/Buttons.swift b/Sources/App/Views/Utils/Buttons.swift index 8207b50..98333f8 100644 --- a/Sources/App/Views/Utils/Buttons.swift +++ b/Sources/App/Views/Utils/Buttons.swift @@ -1,4 +1,6 @@ import Elementary +import SharedModels +import URLRouting struct ToggleFormButton: HTML { var content: some HTML { @@ -24,6 +26,13 @@ enum Button { } } + static func close(id: IDKey, resetURL route: ViewRoute? = nil) -> some HTML { + close( + id: id.description, + resetURL: route != nil ? ViewRoute.router.path(for: route!) : nil + ) + } + static func update() -> some HTML { button(.class("btn-update")) { "Update" } } diff --git a/Sources/App/Views/Utils/Img.swift b/Sources/App/Views/Utils/Img.swift index 00f63c1..b048088 100644 --- a/Sources/App/Views/Utils/Img.swift +++ b/Sources/App/Views/Utils/Img.swift @@ -10,4 +10,9 @@ enum Img { static func search(width: Int = 30, height: Int = 30) -> some HTML { img(.src("/images/search.svg"), .width(width), .height(height)) } + + @Sendable + static func trashCan(width: Int = 30, height: Int = 30) -> some HTML { + img(.src("/images/trash-can.svg"), .width(width), .height(height)) + } } diff --git a/Sources/App/Views/VendorBranches/VendorBranchForm.swift b/Sources/App/Views/VendorBranches/VendorBranchForm.swift new file mode 100644 index 0000000..9d8b3fd --- /dev/null +++ b/Sources/App/Views/VendorBranches/VendorBranchForm.swift @@ -0,0 +1,34 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorBranchForm: HTML { + let vendorID: Vendor.ID + + var content: some HTML { + form( + .id(.branch(.form)), + .hx.post(route: .vendorBranch(.index())), + .hx.target(.id(.branch(.list))), + .hx.swap(.beforeEnd), + .hx.on(.afterRequest, .ifSuccessful(.resetForm)) + ) { + input(.type(.hidden), .name("vendorID"), .value(vendorID.uuidString)) + input( + .type(.text), .class("col-9"), .name("name"), .placeholder("Add branch..."), .required, + // .hx.post(route: .vendorBranch(.index())), + .hx.trigger(.event(.keyup).changed().delay("800ms")) // , + // .hx.target(.id(.branch(.list))), + // .hx.swap(.beforeEnd), + // .custom(name: "hx-on::after-request", value: "if(event.detail.successful) this.reset();") + ) + button( + .type(.submit), + .class("btn-secondary"), + .style("float: right; padding: 10px 50px;"), + .hx.target(.id(.branch(.list))), + .hx.swap(.beforeEnd) + ) { "+" } + } + } +} diff --git a/Sources/App/Views/VendorBranches/VendorBranchList.swift b/Sources/App/Views/VendorBranches/VendorBranchList.swift new file mode 100644 index 0000000..bfc8c8e --- /dev/null +++ b/Sources/App/Views/VendorBranches/VendorBranchList.swift @@ -0,0 +1,46 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorBranchList: HTML { + let vendorID: Vendor.ID + let branches: [VendorBranch]? + + var content: some HTML { + if let branches { + ul(.id(.branch(.list))) { + for branch in branches { + Row(branch: branch) + } + } + } else { + div( + .hx.get(route: .vendorBranch(.index(for: vendorID))), + .hx.target(.this), + .hx.indicator(".hx-indicator"), + .hx.trigger(.event(.revealed)) + ) { + Img.spinner().attributes(.class("hx-indicator")) + } + } + } + + struct Row: HTML { + let branch: VendorBranch + + var content: some HTML { + li(.id(.branch(.row(id: branch.id))), .class("branch-row")) { + span(.class("label")) { branch.name.capitalized } + button( + .class("btn"), + .hx.delete(route: .vendorBranch(.delete(id: branch.id))), + .hx.target(.id(.branch(.row(id: branch.id)))), + .hx.swap(.outerHTML.transition(true).swap("0.5s")) + ) { + Img.trashCan().attributes(.style("margin-top: 5px;")) + } + } + } + } + +} diff --git a/Sources/App/Views/Vendors/VendorDetail.swift b/Sources/App/Views/Vendors/VendorDetail.swift index b9dc4dc..e459950 100644 --- a/Sources/App/Views/Vendors/VendorDetail.swift +++ b/Sources/App/Views/Vendors/VendorDetail.swift @@ -2,7 +2,6 @@ import Elementary import ElementaryHTMX import SharedModels -// TODO: Lazy Load branches when view appears. struct VendorDetail: HTML { let vendor: Vendor @@ -11,8 +10,8 @@ struct VendorDetail: HTML { Float(shouldDisplay: true) { VendorForm(.formOnly(vendor)) h2(.style("margin-left: 20px; font-size: 1.5em;"), .class("label")) { "Branches" } - branchForm - branches + VendorBranchForm(vendorID: vendor.id) + VendorBranchList(vendorID: vendor.id, branches: nil) } closeButton: { Button.close(id: "float") .attributes( @@ -23,61 +22,4 @@ struct VendorDetail: HTML { ) } } - - // TODO: What route for here?? - var branchForm: some HTML { - form( - .id(.branch(.form)), - .hx.post("/vendors/branches"), - .hx.target("#branches"), - .hx.swap(.beforeEnd), - .hx.on(.afterRequest, .ifSuccessful(.resetForm)) - // .custom(name: "hx-on::after-request", value: "if(event.detail.successful) this.reset();") - ) { - input(.type(.hidden), .name("vendorID"), .value(vendor.id.uuidString)) - input( - .type(.text), .class("col-9"), .name("name"), .placeholder("Add branch..."), .required, - // FIX: route - // .hx.post(route: .vendorBranch(.index(for: vendor.id))), - .hx.trigger(.event(.keyup).changed().delay("800ms")), - .hx.target("#branches"), - .hx.swap(.beforeEnd) // , - // .custom(name: "hx-on::after-request", value: "if(event.detail.successful) this.reset();") - ) - button( - .type(.submit), - .class("btn-secondary"), - .style("float: right; padding: 10px 50px;"), - .hx.target("#branch-table"), - .hx.swap(.beforeEnd) - ) { "+" } - } - } - - var branches: some HTML { - ul(.id("branches")) { - for branch in vendor.branches ?? [] { - BranchRow(branch: branch) - } - } - } - - struct BranchRow: HTML { - let branch: VendorBranch - - var content: some HTML { - li(.id(.branch(.row(id: branch.id))), .class("branch-row")) { - span(.class("label")) { branch.name.capitalized } - button( - .class("btn"), - .hx.delete(route: .vendorBranch(.delete(id: branch.id))), - .hx.target(.id(.branch(.row(id: branch.id)))), - .hx.swap(.outerHTML.transition(true).swap("0.5s")) - ) { - img(.src("/images/trash-can.svg"), .width(30), .height(30), .style("margin-top: 5px;")) - } - } - } - } - } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index f3c743f..c416d06 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -40,7 +40,13 @@ public func configure(_ app: Application) async throws { app.mount( SiteRoute.router, - middleware: { $0.middleware() }, + middleware: { + if app.environment == .testing { + return nil + } else { + return $0.middleware() + } + }, use: siteHandler ) diff --git a/Sources/HtmlSnapshotTesting/HtmlSnapshotTesting.swift b/Sources/HtmlSnapshotTesting/HtmlSnapshotTesting.swift new file mode 100644 index 0000000..48d034f --- /dev/null +++ b/Sources/HtmlSnapshotTesting/HtmlSnapshotTesting.swift @@ -0,0 +1,12 @@ +import Elementary +@preconcurrency import SnapshotTesting + +public extension Snapshotting where Value == (any HTML), Format == String { + static var html: Snapshotting { + var snapshotting = SimplySnapshotting.lines + .pullback { (html: any HTML) in html.renderFormatted() } + + snapshotting.pathExtension = "html" + return snapshotting + } +} diff --git a/Sources/SharedModels/Routes/ViewRoute.swift b/Sources/SharedModels/Routes/ViewRoute.swift index b0f86d5..610db3e 100644 --- a/Sources/SharedModels/Routes/ViewRoute.swift +++ b/Sources/SharedModels/Routes/ViewRoute.swift @@ -2,11 +2,9 @@ import CasePathsCore import Foundation @preconcurrency import URLRouting -// swiftlint:disable file_length -// TODO: Need vendor branch index route, to load branches when vendor form is displayed. -// Also need a home / index route that will redirect to login or purchase orders. public enum ViewRoute: Sendable, Equatable { + case index case employee(EmployeeRoute) case login(LoginRoute) case purchaseOrder(PurchaseOrderRoute) @@ -15,6 +13,9 @@ public enum ViewRoute: Sendable, Equatable { case vendorBranch(VendorBranchRoute) public static let router = OneOf { + Route(.case(Self.index)) { + Method.get + } Route(.case(Self.employee)) { EmployeeRoute.router } Route(.case(Self.login)) { LoginRoute.router } Route(.case(Self.purchaseOrder)) { PurchaseOrderRoute.router } @@ -388,6 +389,7 @@ public extension ViewRoute { enum VendorBranchRoute: Sendable, Equatable { case create(VendorBranch.Create) case delete(id: VendorBranch.ID) + case index(for: Vendor.ID? = nil) case select(context: ViewRoute.SelectContext) public static let router = OneOf { @@ -406,6 +408,13 @@ public extension ViewRoute { Path { "vendors"; "branches"; VendorBranch.ID.parser() } Method.delete } + Route(.case(Self.index(for:))) { + Path { "vendors"; "branches" } + Method.get + Query { + Optionally { Field("vendorID") { Vendor.ID.parser() } } + } + } Route(.case(Self.select(context:))) { Path { "vendors"; "branches"; "select" } Method.get @@ -416,5 +425,3 @@ public extension ViewRoute { } } } - -// swiftlint:enable file_length diff --git a/Tests/HtmlSnapshotTestingTests/HtmlSnapshotTestingTests.swift b/Tests/HtmlSnapshotTestingTests/HtmlSnapshotTestingTests.swift new file mode 100644 index 0000000..028a2a8 --- /dev/null +++ b/Tests/HtmlSnapshotTestingTests/HtmlSnapshotTestingTests.swift @@ -0,0 +1,12 @@ +@testable import App +import Elementary +import HtmlSnapshotTesting +import SnapshotTesting +import XCTest + +final class SnapshotTestingTests: XCTestCase { + func testSimple() { + let doc = MainPage.loggedIn(next: nil) + assertSnapshot(of: doc, as: .html) + } +} diff --git a/Tests/HtmlSnapshotTestingTests/__Snapshots__/HtmlSnapshotTestingTests/testSimple.1.html b/Tests/HtmlSnapshotTestingTests/__Snapshots__/HtmlSnapshotTestingTests/testSimple.1.html new file mode 100644 index 0000000..7a99021 --- /dev/null +++ b/Tests/HtmlSnapshotTestingTests/__Snapshots__/HtmlSnapshotTestingTests/testSimple.1.html @@ -0,0 +1,33 @@ + + + + Purchase Orders + + + + + + + +
+ + + +
+
+

Purchase Orders

+
+

+
+
+
+ +
+ + \ No newline at end of file diff --git a/Tests/ViewRouteTests/VendorBranchViewRouteTests.swift b/Tests/ViewRouteTests/VendorBranchViewRouteTests.swift index a0db28b..e64d37e 100644 --- a/Tests/ViewRouteTests/VendorBranchViewRouteTests.swift +++ b/Tests/ViewRouteTests/VendorBranchViewRouteTests.swift @@ -31,6 +31,18 @@ struct VendorBranchViewRouteTests { #expect(route == .vendorBranch(.delete(id: id))) } + @Test + func index() throws { + let id = UUID(0) + var request = URLRequestData( + method: "GET", + path: "/vendors/branches", + query: ["vendorID": ["\(id)"]] + ) + let route = try router.parse(&request) + #expect(route == .vendorBranch(.index(for: id))) + } + @Test func select() throws { var request = URLRequestData(