diff --git a/Package.swift b/Package.swift index f11294c..380ca9e 100644 --- a/Package.swift +++ b/Package.swift @@ -67,6 +67,7 @@ let package = Package( name: "ManualDCore", dependencies: [ .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "Fluent", package: "fluent"), .product(name: "URLRouting", package: "swift-url-routing"), .product(name: "CasePaths", package: "swift-case-paths"), ] diff --git a/Sources/DatabaseClient/Equipment.swift b/Sources/DatabaseClient/Equipment.swift index e57434d..d765563 100644 --- a/Sources/DatabaseClient/Equipment.swift +++ b/Sources/DatabaseClient/Equipment.swift @@ -11,6 +11,7 @@ extension DatabaseClient { public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo? public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo? + public var update: @Sendable (EquipmentInfo.Update) async throws -> EquipmentInfo } } @@ -38,12 +39,21 @@ extension DatabaseClient.Equipment { .filter("projectID", .equal, projectId) .first() else { - throw NotFoundError() + return nil } return try model.toDTO() }, get: { id in try await EquipmentModel.find(id, on: database).map { try $0.toDTO() } + }, + update: { request in + guard let model = try await EquipmentModel.find(request.id, on: database) else { + throw NotFoundError() + } + guard request.hasUpdates else { return try model.toDTO() } + try model.applyUpdates(request) + try await model.save(on: database) + return try model.toDTO() } ) } @@ -77,6 +87,30 @@ extension EquipmentInfo.Create { } } +extension EquipmentInfo.Update { + var hasUpdates: Bool { + staticPressure != nil || heatingCFM != nil || coolingCFM != nil + } + + func validate() throws(ValidationError) { + if let staticPressure { + guard staticPressure >= 0 else { + throw ValidationError("Equipment info static pressure should be greater than 0.") + } + } + if let heatingCFM { + guard heatingCFM >= 0 else { + throw ValidationError("Equipment info heating CFM should be greater than 0.") + } + } + if let coolingCFM { + guard coolingCFM >= 0 else { + throw ValidationError("Equipment info heating CFM should be greater than 0.") + } + } + } +} + extension EquipmentInfo { struct Migrate: AsyncMigration { @@ -159,4 +193,17 @@ final class EquipmentModel: Model, @unchecked Sendable { updatedAt: updatedAt! ) } + + func applyUpdates(_ updates: EquipmentInfo.Update) throws { + try updates.validate() + if let staticPressure = updates.staticPressure { + self.staticPressure = staticPressure + } + if let heatingCFM = updates.heatingCFM { + self.heatingCFM = heatingCFM + } + if let coolingCFM = updates.coolingCFM { + self.coolingCFM = coolingCFM + } + } } diff --git a/Sources/ManualDCore/ComponentPressureLosses.swift b/Sources/ManualDCore/ComponentPressureLosses.swift index 6aac771..7d8181e 100644 --- a/Sources/ManualDCore/ComponentPressureLosses.swift +++ b/Sources/ManualDCore/ComponentPressureLosses.swift @@ -42,6 +42,15 @@ extension ComponentPressureLoss { self.name = name self.value = value } + + // Return's commonly used default component pressure losses. + public static func `default`(projectID: Project.ID) -> [Self] { + [ + .init(projectID: projectID, name: "supply-outlet", value: 0.03), + .init(projectID: projectID, name: "return-grille", value: 0.03), + .init(projectID: projectID, name: "balancing-damper", value: 0.03), + ] + } } } diff --git a/Sources/ManualDCore/EquipmentInfo.swift b/Sources/ManualDCore/EquipmentInfo.swift index d15a50d..347af39 100644 --- a/Sources/ManualDCore/EquipmentInfo.swift +++ b/Sources/ManualDCore/EquipmentInfo.swift @@ -50,6 +50,25 @@ extension EquipmentInfo { self.coolingCFM = coolingCFM } } + + public struct Update: Codable, Equatable, Sendable { + public let id: EquipmentInfo.ID + public let staticPressure: Double? + public let heatingCFM: Int? + public let coolingCFM: Int? + + public init( + id: EquipmentInfo.ID, + staticPressure: Double? = nil, + heatingCFM: Int? = nil, + coolingCFM: Int? = nil + ) { + self.id = id + self.staticPressure = staticPressure + self.heatingCFM = heatingCFM + self.coolingCFM = coolingCFM + } + } } #if DEBUG diff --git a/Sources/ManualDCore/Routes/SiteRoute.swift b/Sources/ManualDCore/Routes/SiteRoute.swift index 99b6969..f3a8b91 100644 --- a/Sources/ManualDCore/Routes/SiteRoute.swift +++ b/Sources/ManualDCore/Routes/SiteRoute.swift @@ -1,4 +1,5 @@ import CasePathsCore +import FluentKit import Foundation @preconcurrency import URLRouting diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index d14d6d3..aaec272 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -1,4 +1,5 @@ import CasePathsCore +import FluentKit import Foundation @preconcurrency import URLRouting @@ -10,9 +11,7 @@ extension SiteRoute { case login(LoginRoute) case signup(SignupRoute) case project(ProjectRoute) - // case frictionRate(FrictionRateRoute) case effectiveLength(EffectiveLengthRoute) - // case user(UserRoute) public static let router = OneOf { Route(.case(Self.login)) { @@ -24,15 +23,9 @@ extension SiteRoute { Route(.case(Self.project)) { SiteRoute.View.ProjectRoute.router } - // Route(.case(Self.frictionRate)) { - // SiteRoute.View.FrictionRateRoute.router - // } Route(.case(Self.effectiveLength)) { SiteRoute.View.EffectiveLengthRoute.router } - // Route(.case(Self.user)) { - // SiteRoute.View.UserRoute.router - // } } } } @@ -43,7 +36,11 @@ extension SiteRoute.View { case detail(Project.ID, DetailRoute) case form(dismiss: Bool = false) case index - case page(page: Int = 1, limit: Int = 25) + case page(PageRequest) + + public static func page(page: Int, per limit: Int) -> Self { + .page(.init(page: page, per: limit)) + } static let rootPath = "projects" @@ -83,7 +80,7 @@ extension SiteRoute.View { Path { rootPath } Method.get } - Route(.case(Self.page(page:limit:))) { + Route(.case(Self.page)) { Path { rootPath "page" @@ -91,8 +88,9 @@ extension SiteRoute.View { Method.get Query { Field("page", default: 1) { Int.parser() } - Field("limit", default: 25) { Int.parser() } + Field("per", default: 25) { Int.parser() } } + .map(.memberwise(PageRequest.init)) } } } @@ -102,6 +100,7 @@ extension SiteRoute.View.ProjectRoute { public enum DetailRoute: Equatable, Sendable { case index + case equipment(EquipmentInfoRoute) case frictionRate(FrictionRateRoute) case rooms(RoomRoute) @@ -109,6 +108,9 @@ extension SiteRoute.View.ProjectRoute { Route(.case(Self.index)) { Method.get } + Route(.case(Self.equipment)) { + EquipmentInfoRoute.router + } Route(.case(Self.frictionRate)) { FrictionRateRoute.router } @@ -160,6 +162,7 @@ extension SiteRoute.View.ProjectRoute { public enum FrictionRateRoute: Equatable, Sendable { case index + // TODO: Remove form or move equipment / component losses routes here. case form(FormType, dismiss: Bool = false) static let rootPath = "friction-rate" @@ -187,6 +190,64 @@ extension SiteRoute.View.ProjectRoute { case componentPressureLoss } } + + public enum EquipmentInfoRoute: Equatable, Sendable { + case index + case form(dismiss: Bool) + case submit(EquipmentInfo.Create) + case update(EquipmentInfo.Update) + + static let rootPath = "equipment" + + public static let router = OneOf { + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.form)) { + Path { + rootPath + "create" + } + Method.get + Query { + Field("dismiss", default: true) { Bool.parser() } + } + } + Route(.case(Self.submit)) { + Path { rootPath } + Method.post + Body { + FormData { + Field("projectID") { Project.ID.parser() } + Field("staticPressure") { Double.parser() } + Field("heatingCFM") { Int.parser() } + Field("coolingCFM") { Int.parser() } + } + .map(.memberwise(EquipmentInfo.Create.init)) + } + } + Route(.case(Self.update)) { + Path { rootPath } + Method.patch + Body { + FormData { + Field("id") { EquipmentInfo.ID.parser() } + Optionally { + Field("staticPressure", default: nil) { Double.parser() } + } + Optionally { + Field("heatingCFM", default: nil) { Int.parser() } + } + Optionally { + Field("coolingCFM", default: nil) { Int.parser() } + } + } + .map(.memberwise(EquipmentInfo.Update.init)) + } + } + } + } } extension SiteRoute.View { @@ -313,3 +374,9 @@ extension SiteRoute.View { } } } + +extension PageRequest: @retroactive Equatable { + public static func == (lhs: FluentKit.PageRequest, rhs: FluentKit.PageRequest) -> Bool { + lhs.page == rhs.page && lhs.per == rhs.per + } +} diff --git a/Sources/Styleguide/Buttons.swift b/Sources/Styleguide/Buttons.swift index 1d48af7..27cb771 100644 --- a/Sources/Styleguide/Buttons.swift +++ b/Sources/Styleguide/Buttons.swift @@ -53,11 +53,11 @@ public struct CancelButton: HTML, Sendable { } public struct EditButton: HTML, Sendable { - let title: String + let title: String? let type: HTMLAttribute.ButtonType public init( - title: String = "Edit", + title: String? = nil, type: HTMLAttribute.ButtonType = .button ) { self.title = title @@ -68,13 +68,15 @@ public struct EditButton: HTML, Sendable { button( .class( """ - text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg + btn btn-primary """ ), .type(type) ) { div(.class("flex")) { - span(.class("pe-2")) { title } + if let title { + span(.class("pe-2")) { title } + } SVG(.squarePen) } } @@ -86,6 +88,9 @@ public struct PlusButton: HTML, Sendable { public init() {} public var body: some HTML { - button(.type(.button)) { SVG(.circlePlus) } + button( + .type(.button), + .class("btn btn-primary") + ) { SVG(.circlePlus) } } } diff --git a/Sources/ViewController/Extensions/DatabaseExtensions.swift b/Sources/ViewController/Extensions/DatabaseExtensions.swift index 0f58e3a..f73bbb6 100644 --- a/Sources/ViewController/Extensions/DatabaseExtensions.swift +++ b/Sources/ViewController/Extensions/DatabaseExtensions.swift @@ -3,6 +3,7 @@ import Fluent import ManualDCore import Vapor +// FIX: Remove these, not used currently. extension DatabaseClient.Projects { func fetchPage( @@ -21,6 +22,16 @@ extension DatabaseClient.Projects { } } +extension DatabaseClient.ComponentLoss { + + func createDefaults(projectID: Project.ID) async throws { + let defaults = ComponentPressureLoss.Create.default(projectID: projectID) + for loss in defaults { + _ = try await create(loss) + } + } +} + extension PageRequest { static func next(_ currentPage: Page) -> Self { .init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per) diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 5dc7293..2a67c8a 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -81,9 +81,8 @@ extension SiteRoute.View.ProjectRoute { return request.view { ProjectsTable(userID: user.id, projects: projects) } - case .page(let page, let limit): - let projects = try await database.projects.fetchPage( - userID: user.id, page: page, limit: limit) + case .page(let page): + let projects = try await database.projects.fetch(user.id, page) return ProjectsTable(userID: user.id, projects: projects) case .form(let dismiss): @@ -91,6 +90,7 @@ extension SiteRoute.View.ProjectRoute { case .create(let form): let project = try await database.projects.create(user.id, form) + try await database.componentLoss.createDefaults(projectID: project.id) return request.view { ProjectView(projectID: project.id, activeTab: .projects) { ProjectDetail(project: project) @@ -106,6 +106,8 @@ extension SiteRoute.View.ProjectRoute { ProjectDetail(project: project) } } + case .equipment(let route): + return try await route.renderView(on: request, projectID: projectID) case .frictionRate(let route): return try await route.renderView(on: request, projectID: projectID) @@ -121,6 +123,30 @@ extension SiteRoute.View.ProjectRoute { } } +extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute { + func renderView( + on request: ViewController.Request, + projectID: Project.ID + ) async throws -> AnySendableHTML { + @Dependency(\.database) var database + + switch self { + case .index: + let equipment = try await database.equipment.fetch(projectID) + return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID) + case .form(let dismiss): + let equipment = try await database.equipment.fetch(projectID) + return EquipmentInfoForm(dismiss: dismiss, projectID: projectID, equipmentInfo: equipment) + case .submit(let form): + let equipment = try await database.equipment.create(form) + return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID) + case .update(let updates): + let equipment = try await database.equipment.update(updates) + return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID) + } + } +} + extension SiteRoute.View.ProjectRoute.RoomRoute { func renderView( on request: ViewController.Request, @@ -162,18 +188,24 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute { switch self { case .index: + let equipment = try await database.equipment.fetch(projectID) let componentLosses = try await database.componentLoss.fetch(projectID) return request.view { ProjectView(projectID: projectID, activeTab: .frictionRate) { - FrictionRateView(componentLosses: componentLosses, projectID: projectID) + FrictionRateView( + equipmentInfo: equipment, + componentLosses: componentLosses, + projectID: projectID + ) } } case .form(let type, let dismiss): // FIX: Forms need to reference existing items. switch type { case .equipmentInfo: - return EquipmentForm(dismiss: dismiss, projectID: projectID) + return div { "REMOVE ME!" } + // return EquipmentForm(dismiss: dismiss, projectID: projectID) case .componentPressureLoss: return ComponentLossForm(dismiss: dismiss, projectID: projectID) } diff --git a/Sources/ViewController/Views/FrictionRate/ComponentLossForm.swift b/Sources/ViewController/Views/ComponentLoss/ComponentLossForm.swift similarity index 100% rename from Sources/ViewController/Views/FrictionRate/ComponentLossForm.swift rename to Sources/ViewController/Views/ComponentLoss/ComponentLossForm.swift diff --git a/Sources/ViewController/Views/FrictionRate/ComponentLossesView.swift b/Sources/ViewController/Views/ComponentLoss/ComponentLossesView.swift similarity index 100% rename from Sources/ViewController/Views/FrictionRate/ComponentLossesView.swift rename to Sources/ViewController/Views/ComponentLoss/ComponentLossesView.swift diff --git a/Sources/ViewController/Views/EquipmentInfo/EquipmentInfoForm.swift b/Sources/ViewController/Views/EquipmentInfo/EquipmentInfoForm.swift new file mode 100644 index 0000000..f2a924b --- /dev/null +++ b/Sources/ViewController/Views/EquipmentInfo/EquipmentInfoForm.swift @@ -0,0 +1,86 @@ +import Elementary +import ManualDCore +import Styleguide + +// TODO: Have form hold onto equipment info model to edit. +struct EquipmentInfoForm: HTML, Sendable { + + let dismiss: Bool + let projectID: Project.ID + let equipmentInfo: EquipmentInfo? + + var staticPressure: String { + guard let staticPressure = equipmentInfo?.staticPressure else { + return "0.5" + } + return "\(staticPressure)" + } + + var heatingCFM: String { + guard let heatingCFM = equipmentInfo?.heatingCFM else { + return "" + } + return "\(heatingCFM)" + } + + var coolingCFM: String { + guard let heatingCFM = equipmentInfo?.heatingCFM else { + return "" + } + return "\(heatingCFM)" + } + + var body: some HTML { + ModalForm(id: "equipmentForm", dismiss: dismiss) { + h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" } + form( + .class("space-y-4 p-4"), + equipmentInfo != nil + ? .hx.patch(route: .project(.detail(projectID, .equipment(.index)))) + : .hx.post(route: .project(.detail(projectID, .equipment(.index)))), + .hx.target("#equipmentInfo"), + .hx.swap(.outerHTML) + ) { + input(.class("hidden"), .name("projectID"), .value("\(projectID)")) + + if let equipmentInfo { + input(.class("hidden"), .name("id"), .value("\(equipmentInfo.id)")) + } + + div { + label(.for("staticPressure")) { "Static Pressure" } + Input(id: "staticPressure", placeholder: "Static pressure") + .attributes( + .type(.number), .value(staticPressure), .min("0"), .max("1.0"), .step("0.1") + ) + } + div { + label(.for("heatingCFM")) { "Heating CFM" } + Input(id: "heatingCFM", placeholder: "CFM") + .attributes(.type(.number), .min("0"), .value(heatingCFM)) + } + div { + label(.for("coolingCFM")) { "Cooling CFM" } + Input(id: "coolingCFM", placeholder: "CFM") + .attributes(.type(.number), .min("0"), .value(coolingCFM)) + } + Row { + div {} + div(.class("space-x-4")) { + CancelButton() + .attributes( + .hx.get( + route: .project( + .detail(projectID, .equipment(.form(dismiss: true))) + ) + ), + .hx.target("#equipmentForm"), + .hx.swap(.outerHTML) + ) + SubmitButton(title: "Save") + } + } + } + } + } +} diff --git a/Sources/ViewController/Views/EquipmentInfo/EquipmentInfoView.swift b/Sources/ViewController/Views/EquipmentInfo/EquipmentInfoView.swift new file mode 100644 index 0000000..252d810 --- /dev/null +++ b/Sources/ViewController/Views/EquipmentInfo/EquipmentInfoView.swift @@ -0,0 +1,54 @@ +import Elementary +import ManualDCore +import Styleguide + +struct EquipmentInfoView: HTML, Sendable { + let equipmentInfo: EquipmentInfo? + var projectID: Project.ID + + var body: some HTML { + div( + .class("space-y-4 border border-gray-200 rounded-lg shadow-lg p-4"), + .id("equipmentInfo") + ) { + + Row { + h1(.class("text-2xl font-bold")) { "Equipment Info" } + + if equipmentInfo != nil { + EditButton() + .attributes( + .hx.get(route: .project(.detail(projectID, .equipment(.form(dismiss: false))))), + .hx.target("#equipmentForm"), + .hx.swap(.outerHTML) + ) + } + } + + if let equipmentInfo { + + Row { + Label { "Static Pressure" } + Number(equipmentInfo.staticPressure) + } + .attributes(.class("border-b border-gray-200")) + + Row { + Label { "Heating CFM" } + Number(equipmentInfo.heatingCFM) + } + .attributes(.class("border-b border-gray-200")) + + Row { + Label { "Cooling CFM" } + Number(equipmentInfo.coolingCFM) + } + .attributes(.class("border-b border-gray-200")) + + EquipmentInfoForm(dismiss: true, projectID: projectID, equipmentInfo: nil) + } else { + EquipmentInfoForm(dismiss: false, projectID: projectID, equipmentInfo: nil) + } + } + } +} diff --git a/Sources/ViewController/Views/FrictionRate/EquipmentForm.swift b/Sources/ViewController/Views/FrictionRate/EquipmentForm.swift deleted file mode 100644 index 6c19f9c..0000000 --- a/Sources/ViewController/Views/FrictionRate/EquipmentForm.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Elementary -import ManualDCore -import Styleguide - -// TODO: Have form hold onto equipment info model to edit. -struct EquipmentForm: HTML, Sendable { - - let dismiss: Bool - let projectID: Project.ID - - var body: some HTML { - ModalForm(id: "equipmentForm", dismiss: dismiss) { - h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" } - form(.class("space-y-4 p-4")) { - div { - label(.for("staticPressure")) { "Static Pressure" } - Input(id: "staticPressure", placeholder: "Static pressure") - .attributes( - .type(.number), .value("0.5"), .min("0"), .max("1.0"), .step("0.1") - ) - } - div { - label(.for("heatingCFM")) { "Heating CFM" } - Input(id: "heatingCFM", placeholder: "CFM") - .attributes(.type(.number), .min("0")) - } - div { - label(.for("coolingCFM")) { "Cooling CFM" } - Input(id: "coolingCFM", placeholder: "CFM") - .attributes(.type(.number), .min("0")) - } - Row { - div {} - div(.class("space-x-4")) { - CancelButton() - .attributes( - .hx.get( - route: .project( - .detail(projectID, .frictionRate(.form(.equipmentInfo, dismiss: true))) - ) - ), - .hx.target("#equipmentForm"), - .hx.swap(.outerHTML) - ) - SubmitButton(title: "Save") - } - } - } - } - } -} diff --git a/Sources/ViewController/Views/FrictionRate/EquipmentInfoView.swift b/Sources/ViewController/Views/FrictionRate/EquipmentInfoView.swift deleted file mode 100644 index dde0f84..0000000 --- a/Sources/ViewController/Views/FrictionRate/EquipmentInfoView.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Elementary -import ManualDCore -import Styleguide - -struct EquipmentInfoView: HTML, Sendable { - let equipmentInfo: EquipmentInfo - var projectID: Project.ID { equipmentInfo.projectID } - - var body: some HTML { - div(.class("space-y-4 border border-gray-200 rounded-lg shadow-lg p-4")) { - Row { - h1(.class("text-2xl font-bold")) { "Equipment Info" } - } - - Row { - Label { "Static Pressure" } - Number(equipmentInfo.staticPressure) - } - .attributes(.class("border-b border-gray-200")) - - Row { - Label { "Heating CFM" } - Number(equipmentInfo.heatingCFM) - } - .attributes(.class("border-b border-gray-200")) - - Row { - Label { "Cooling CFM" } - Number(equipmentInfo.coolingCFM) - } - .attributes(.class("border-b border-gray-200")) - - Row { - div {} - EditButton() - .attributes( - .hx.get(route: .project(.detail(projectID, .frictionRate(.form(.equipmentInfo))))), - .hx.target("#equipmentForm"), - .hx.swap(.outerHTML) - ) - } - } - - div(.id("equipmentForm")) {} - } -} diff --git a/Sources/ViewController/Views/FrictionRate/FrictionRateView.swift b/Sources/ViewController/Views/FrictionRate/FrictionRateView.swift index f31da5e..0bdf612 100644 --- a/Sources/ViewController/Views/FrictionRate/FrictionRateView.swift +++ b/Sources/ViewController/Views/FrictionRate/FrictionRateView.swift @@ -4,13 +4,14 @@ import Styleguide struct FrictionRateView: HTML, Sendable { + let equipmentInfo: EquipmentInfo? let componentLosses: [ComponentPressureLoss] let projectID: Project.ID var body: some HTML { div(.class("p-4 space-y-6")) { h1(.class("text-4xl font-bold pb-6")) { "Friction Rate" } - EquipmentInfoView(equipmentInfo: EquipmentInfo.mock) + EquipmentInfoView(equipmentInfo: equipmentInfo, projectID: projectID) ComponentPressureLossesView( componentPressureLosses: componentLosses, projectID: projectID ) diff --git a/Sources/ViewController/Views/Project/ProjectsTable.swift b/Sources/ViewController/Views/Project/ProjectsTable.swift index cb233b1..150eb6c 100644 --- a/Sources/ViewController/Views/Project/ProjectsTable.swift +++ b/Sources/ViewController/Views/Project/ProjectsTable.swift @@ -78,7 +78,7 @@ extension ProjectsTable { // if there are more pages left. if projects.metadata.pageCount > projects.metadata.page { tr( - .hx.get(route: .project(.page(page: projects.metadata.page + 1, limit: 25))), + .hx.get(route: .project(.page(.next(projects)))), .hx.trigger(.event(.revealed)), .hx.swap(.outerHTML), .hx.target("this"),