feat: Moves vendor branch views to their own files, starts to implement snapshot testing for html

This commit is contained in:
2025-01-21 09:51:11 -05:00
parent 40726c8bd7
commit 97b231767e
15 changed files with 231 additions and 71 deletions

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "dab5887e7f33b2dba9c9b86598c47d541464dda5c29e084c8d38dec82923c953", "originHash" : "20332ed810f0f8bda6b1a104968eae13e98b479c32a983c890e6526a4940c7ad",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@@ -316,6 +316,15 @@
"version" : "1.1.0" "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", "identity" : "swift-syntax",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -10,7 +10,8 @@ let package = Package(
.executable(name: "App", targets: ["App"]), .executable(name: "App", targets: ["App"]),
.library(name: "SharedModels", targets: ["SharedModels"]), .library(name: "SharedModels", targets: ["SharedModels"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]) .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]),
.library(name: "HtmlSnapshotTesting", targets: ["HtmlSnapshotTesting"])
], ],
dependencies: [ dependencies: [
// 💧 A server-side Swift web framework. // 💧 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/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/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/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: [ targets: [
.executableTarget( .executableTarget(
@@ -89,6 +91,20 @@ let package = Package(
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver") .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( .target(
name: "SharedModels", name: "SharedModels",
dependencies: [ dependencies: [

View File

@@ -16,6 +16,7 @@ extension SharedModels.ViewRoute {
var middleware: [any Middleware]? { var middleware: [any Middleware]? {
switch self { switch self {
case .index: return viewProtectedMiddleware
case let .employee(route): return route.middleware case let .employee(route): return route.middleware
case .login: return nil case .login: return nil
case let .purchaseOrder(route): return route.middleware case let .purchaseOrder(route): return route.middleware
@@ -28,6 +29,9 @@ extension SharedModels.ViewRoute {
func handle(request: Request) async throws -> any AsyncResponseEncodable { func handle(request: Request) async throws -> any AsyncResponseEncodable {
@Dependency(\.database.users) var users @Dependency(\.database.users) var users
switch self { switch self {
case .index:
return request.redirect(to: Self.router.path(for: .purchaseOrder(.index)))
case let .employee(route): case let .employee(route):
return try await route.handle(request: request) return try await route.handle(request: request)
@@ -348,6 +352,17 @@ extension SharedModels.ViewRoute.VendorBranchRoute {
@Dependency(\.database) var database @Dependency(\.database) var database
switch self { 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): case let .select(context: context):
return try await request.render { return try await request.render {
try await context.toHTML(branches: database.vendorBranches.fetchAllWithDetail()) try await context.toHTML(branches: database.vendorBranches.fetchAllWithDetail())
@@ -355,7 +370,7 @@ extension SharedModels.ViewRoute.VendorBranchRoute {
case let .create(branch): case let .create(branch):
return try await request.render { 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): case let .delete(id: id):

View File

@@ -117,11 +117,13 @@ enum IDKey: CustomStringConvertible {
} }
enum Branch: CustomStringConvertible { enum Branch: CustomStringConvertible {
case list
case form case form
case row(id: VendorBranch.ID) case row(id: VendorBranch.ID)
var description: String { var description: String {
switch self { switch self {
case .list: return "list"
case .form: return "form" case .form: return "form"
case let .row(id): return id.uuidString case let .row(id): return id.uuidString
} }
@@ -148,7 +150,7 @@ enum IDKey: CustomStringConvertible {
var description: String { var description: String {
switch self { switch self {
case let .content: return "content" case .content: return "content"
case let .row(id): return "\(id)" case let .row(id): return "\(id)"
case .search: return "search" case .search: return "search"
case .table: return "table" case .table: return "table"

View File

@@ -1,4 +1,6 @@
import Elementary import Elementary
import SharedModels
import URLRouting
struct ToggleFormButton: HTML { struct ToggleFormButton: HTML {
var content: some HTML<HTMLTag.a> { var content: some HTML<HTMLTag.a> {
@@ -24,6 +26,13 @@ enum Button {
} }
} }
static func close(id: IDKey, resetURL route: ViewRoute? = nil) -> some HTML<HTMLTag.button> {
close(
id: id.description,
resetURL: route != nil ? ViewRoute.router.path(for: route!) : nil
)
}
static func update() -> some HTML<HTMLTag.button> { static func update() -> some HTML<HTMLTag.button> {
button(.class("btn-update")) { "Update" } button(.class("btn-update")) { "Update" }
} }

View File

@@ -10,4 +10,9 @@ enum Img {
static func search(width: Int = 30, height: Int = 30) -> some HTML<HTMLTag.img> { static func search(width: Int = 30, height: Int = 30) -> some HTML<HTMLTag.img> {
img(.src("/images/search.svg"), .width(width), .height(height)) img(.src("/images/search.svg"), .width(width), .height(height))
} }
@Sendable
static func trashCan(width: Int = 30, height: Int = 30) -> some HTML<HTMLTag.img> {
img(.src("/images/trash-can.svg"), .width(width), .height(height))
}
} }

View File

@@ -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)
) { "+" }
}
}
}

View File

@@ -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<HTMLTag.li> {
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;"))
}
}
}
}
}

View File

@@ -2,7 +2,6 @@ import Elementary
import ElementaryHTMX import ElementaryHTMX
import SharedModels import SharedModels
// TODO: Lazy Load branches when view appears.
struct VendorDetail: HTML { struct VendorDetail: HTML {
let vendor: Vendor let vendor: Vendor
@@ -11,8 +10,8 @@ struct VendorDetail: HTML {
Float(shouldDisplay: true) { Float(shouldDisplay: true) {
VendorForm(.formOnly(vendor)) VendorForm(.formOnly(vendor))
h2(.style("margin-left: 20px; font-size: 1.5em;"), .class("label")) { "Branches" } h2(.style("margin-left: 20px; font-size: 1.5em;"), .class("label")) { "Branches" }
branchForm VendorBranchForm(vendorID: vendor.id)
branches VendorBranchList(vendorID: vendor.id, branches: nil)
} closeButton: { } closeButton: {
Button.close(id: "float") Button.close(id: "float")
.attributes( .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<HTMLTag.li> {
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;"))
}
}
}
}
} }

View File

@@ -40,7 +40,13 @@ public func configure(_ app: Application) async throws {
app.mount( app.mount(
SiteRoute.router, SiteRoute.router,
middleware: { $0.middleware() }, middleware: {
if app.environment == .testing {
return nil
} else {
return $0.middleware()
}
},
use: siteHandler use: siteHandler
) )

View File

@@ -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
}
}

View File

@@ -2,11 +2,9 @@ import CasePathsCore
import Foundation import Foundation
@preconcurrency import URLRouting @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 { public enum ViewRoute: Sendable, Equatable {
case index
case employee(EmployeeRoute) case employee(EmployeeRoute)
case login(LoginRoute) case login(LoginRoute)
case purchaseOrder(PurchaseOrderRoute) case purchaseOrder(PurchaseOrderRoute)
@@ -15,6 +13,9 @@ public enum ViewRoute: Sendable, Equatable {
case vendorBranch(VendorBranchRoute) case vendorBranch(VendorBranchRoute)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.index)) {
Method.get
}
Route(.case(Self.employee)) { EmployeeRoute.router } Route(.case(Self.employee)) { EmployeeRoute.router }
Route(.case(Self.login)) { LoginRoute.router } Route(.case(Self.login)) { LoginRoute.router }
Route(.case(Self.purchaseOrder)) { PurchaseOrderRoute.router } Route(.case(Self.purchaseOrder)) { PurchaseOrderRoute.router }
@@ -388,6 +389,7 @@ public extension ViewRoute {
enum VendorBranchRoute: Sendable, Equatable { enum VendorBranchRoute: Sendable, Equatable {
case create(VendorBranch.Create) case create(VendorBranch.Create)
case delete(id: VendorBranch.ID) case delete(id: VendorBranch.ID)
case index(for: Vendor.ID? = nil)
case select(context: ViewRoute.SelectContext) case select(context: ViewRoute.SelectContext)
public static let router = OneOf { public static let router = OneOf {
@@ -406,6 +408,13 @@ public extension ViewRoute {
Path { "vendors"; "branches"; VendorBranch.ID.parser() } Path { "vendors"; "branches"; VendorBranch.ID.parser() }
Method.delete Method.delete
} }
Route(.case(Self.index(for:))) {
Path { "vendors"; "branches" }
Method.get
Query {
Optionally { Field("vendorID") { Vendor.ID.parser() } }
}
}
Route(.case(Self.select(context:))) { Route(.case(Self.select(context:))) {
Path { "vendors"; "branches"; "select" } Path { "vendors"; "branches"; "select" }
Method.get Method.get
@@ -416,5 +425,3 @@ public extension ViewRoute {
} }
} }
} }
// swiftlint:enable file_length

View File

@@ -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)
}
}

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Purchase Orders</title>
<meta charset="UTF-8">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="/js/main.js"></script>
<link rel="stylesheet" href="/css/main.css">
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
</head>
<body>
<header class="header">
<div id="logo">HHE - Purchase Orders</div>
<div class="sidepanel" id="sidepanel">
<a href="javascript:void(0)" class="closebtn" onclick="closeSidepanel()">x</a><a hx-get="/purchase-orders?page=1&amp;limit=50" hx-target="body" hx-push-url="true">Purchase Orders</a><a hx-get="/users" hx-target="body" hx-push-url="true">Users</a><a hx-get="/employees" hx-target="body" hx-push-url="true">Employees</a><a hx-get="/vendors" hx-target="body" hx-push-url="true">Vendors</a>
<div style="border-bottom: 1px solid grey; margin-bottom: 5px;"></div>
Logout<a hx-post="/logout" hx-target="#content" hx-swap="outerHTML" hx-trigger="click"></a>
</div>
<button class="openbtn" onclick="openSidepanel()">
<img src="/images/menu.svg" style="width: 30px;, height: 30px;">
</button>
</header>
<div class="container" style="padding: 20px 20px;">
<h1>Purchase Orders</h1>
<br>
<p class="secondary"><i></i></p>
<br>
</div>
<div hx-get="/purchase-orders" hx-push-url="true" hx-target="body" hx-trigger="revealed" hx-indicator=".hx-indicator">
<img src="/images/spinner.svg" width="30" height="30" class="hx-indicator">
</div>
</body>
</html>

View File

@@ -31,6 +31,18 @@ struct VendorBranchViewRouteTests {
#expect(route == .vendorBranch(.delete(id: id))) #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 @Test
func select() throws { func select() throws {
var request = URLRequestData( var request = URLRequestData(