From 4c8a23be94ddf0810132005a71b09a30bc2d2be7 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 5 Jan 2026 15:59:23 -0500 Subject: [PATCH] feat: Adds update and delete routes to room. --- Public/css/output.css | 23 ++---- .../ComponentPressureLoss.swift | 6 +- Sources/DatabaseClient/EffectiveLength.swift | 20 +++-- Sources/DatabaseClient/Equipment.swift | 4 +- Sources/DatabaseClient/Interface.swift | 2 +- Sources/DatabaseClient/Rooms.swift | 67 ++++++++++++++++- Sources/ManualDCore/Room.swift | 23 ++++++ Sources/ManualDCore/Routes/ViewRoute.swift | 61 ++++++++++++++- Sources/Styleguide/Buttons.swift | 13 ++++ Sources/Styleguide/ElementaryExtensions.swift | 15 ++++ Sources/Styleguide/HTMXExtensions.swift | 8 +- Sources/Styleguide/SVG.swift | 6 ++ Sources/ViewController/Live.swift | 65 ++++++++-------- .../Views/Project/ProjectView.swift | 74 +++++++++++++------ .../Views/Project/ProjectsTable.swift | 17 ++++- .../ViewController/Views/Rooms/RoomForm.swift | 26 +++++-- .../Views/Rooms/RoomsView.swift | 61 +++++++++++---- 17 files changed, 366 insertions(+), 125 deletions(-) diff --git a/Public/css/output.css b/Public/css/output.css index 50d0916..6602dd3 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -9,14 +9,11 @@ monospace; --color-red-500: oklch(63.7% 0.237 25.331); --color-red-600: oklch(57.7% 0.245 27.325); - --color-green-400: oklch(79.2% 0.209 151.711); - --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-indigo-600: oklch(51.1% 0.262 276.966); --color-slate-300: oklch(86.9% 0.022 252.894); --color-slate-900: oklch(20.8% 0.042 265.755); - --color-gray-100: oklch(96.7% 0.003 264.542); --color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-400: oklch(70.7% 0.022 261.325); @@ -24,16 +21,9 @@ --color-black: #000; --color-white: #fff; --spacing: 0.25rem; - --container-xs: 20rem; --container-xl: 36rem; - --container-2xl: 42rem; - --container-7xl: 80rem; - --text-xs: 0.75rem; - --text-xs--line-height: calc(1 / 0.75); --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); - --text-base: 1rem; - --text-base--line-height: calc(1.5 / 1); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; @@ -42,17 +32,9 @@ --text-3xl--line-height: calc(2.25 / 1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); - --text-5xl: 3rem; - --text-5xl--line-height: 1; - --font-weight-medium: 500; - --font-weight-semibold: 600; --font-weight-bold: 700; - --leading-tight: 1.25; - --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; - --radius-xl: 0.75rem; - --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-duration: 150ms; @@ -9359,6 +9341,11 @@ color: var(--color-gray-800); } } + .dark\:text-white { + @media (prefers-color-scheme: dark) { + color: var(--color-white); + } + } } @layer base { :where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] { diff --git a/Sources/DatabaseClient/ComponentPressureLoss.swift b/Sources/DatabaseClient/ComponentPressureLoss.swift index 118e45b..8898556 100644 --- a/Sources/DatabaseClient/ComponentPressureLoss.swift +++ b/Sources/DatabaseClient/ComponentPressureLoss.swift @@ -79,8 +79,10 @@ extension ComponentPressureLoss { .field("value", .double, .required) .field("createdAt", .datetime) .field("updatedAt", .datetime) - .field("projectID", .uuid, .required, .references(ProjectModel.schema, "id")) - .unique(on: "projectID", "name") + .field( + "projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade) + ) + // .unique(on: "projectID", "name") .create() } diff --git a/Sources/DatabaseClient/EffectiveLength.swift b/Sources/DatabaseClient/EffectiveLength.swift index 1f11cd5..a653eb2 100644 --- a/Sources/DatabaseClient/EffectiveLength.swift +++ b/Sources/DatabaseClient/EffectiveLength.swift @@ -9,7 +9,7 @@ extension DatabaseClient { public struct EffectiveLengthClient: Sendable { public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength public var delete: @Sendable (EffectiveLength.ID) async throws -> Void - public var fetch: @Sendable (Project.ID) async throws -> EffectiveLength? + public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength] public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength? } } @@ -31,15 +31,11 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey { try await model.delete(on: database) }, fetch: { projectID in - guard - let model = try await EffectiveLengthModel.query(on: database) - .filter("projectID", .equal, projectID) - .first() - else { - throw NotFoundError() - } - - return try model.toDTO() + try await EffectiveLengthModel.query(on: database) + .with(\.$project) + .filter(\.$project.$id, .equal, projectID) + .all() + .map { try $0.toDTO() } }, get: { id in try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() } @@ -82,7 +78,9 @@ extension EffectiveLength { .field("groups", .data) .field("createdAt", .datetime) .field("updatedAt", .datetime) - .field("projectID", .uuid, .required, .references(ProjectModel.schema, "id")) + .field( + "projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade) + ) .unique(on: "projectID", "name", "type") .create() } diff --git a/Sources/DatabaseClient/Equipment.swift b/Sources/DatabaseClient/Equipment.swift index d765563..0fb03ae 100644 --- a/Sources/DatabaseClient/Equipment.swift +++ b/Sources/DatabaseClient/Equipment.swift @@ -125,7 +125,9 @@ extension EquipmentInfo { .field("coolingCFM", .int16, .required) .field("createdAt", .datetime) .field("updatedAt", .datetime) - .field("projectID", .uuid, .required, .references(ProjectModel.schema, "id")) + .field( + "projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade) + ) .unique(on: "projectID") .create() } diff --git a/Sources/DatabaseClient/Interface.swift b/Sources/DatabaseClient/Interface.swift index 8ea8a43..02246af 100644 --- a/Sources/DatabaseClient/Interface.swift +++ b/Sources/DatabaseClient/Interface.swift @@ -60,9 +60,9 @@ extension DatabaseClient.Migrations: DependencyKey { public static let liveValue = Self( run: { [ + Project.Migrate(), User.Migrate(), User.Token.Migrate(), - Project.Migrate(), ComponentPressureLoss.Migrate(), EquipmentInfo.Migrate(), Room.Migrate(), diff --git a/Sources/DatabaseClient/Rooms.swift b/Sources/DatabaseClient/Rooms.swift index 227ab19..f53192d 100644 --- a/Sources/DatabaseClient/Rooms.swift +++ b/Sources/DatabaseClient/Rooms.swift @@ -11,14 +11,13 @@ extension DatabaseClient { public var delete: @Sendable (Room.ID) async throws -> Void public var get: @Sendable (Room.ID) async throws -> Room? public var fetch: @Sendable (Project.ID) async throws -> [Room] + public var update: @Sendable (Room.Update) async throws -> Room } } extension DatabaseClient.Rooms: TestDependencyKey { public static let testValue = Self() -} -extension DatabaseClient.Rooms { public static func live(database: any Database) -> Self { .init( create: { request in @@ -41,6 +40,17 @@ extension DatabaseClient.Rooms { .filter(\.$project.$id, .equal, projectID) .all() .map { try $0.toDTO() } + }, + update: { updates in + guard let model = try await RoomModel.find(updates.id, on: database) else { + throw NotFoundError() + } + + try updates.validate() + if model.applyUpdates(updates) { + try await model.save(on: database) + } + return try model.toDTO() } ) } @@ -75,6 +85,32 @@ extension Room.Create { } } +extension Room.Update { + + func validate() throws(ValidationError) { + if let name { + guard !name.isEmpty else { + throw ValidationError("Room name should not be empty.") + } + } + if let heatingLoad { + guard heatingLoad >= 0 else { + throw ValidationError("Room heating load should not be less than 0.") + } + } + if let coolingLoad { + guard coolingLoad >= 0 else { + throw ValidationError("Room cooling total should not be less than 0.") + } + } + if let registerCount { + guard registerCount >= 1 else { + throw ValidationError("Room cooling sensible should not be less than 1.") + } + } + } +} + extension Room { struct Migrate: AsyncMigration { let name = "CreateRoom" @@ -88,7 +124,9 @@ extension Room { .field("registerCount", .int8, .required) .field("createdAt", .datetime) .field("updatedAt", .datetime) - .field("projectID", .uuid, .required, .references(ProjectModel.schema, "id")) + .field( + "projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade) + ) .unique(on: "projectID", "name") .create() } @@ -161,4 +199,27 @@ final class RoomModel: Model, @unchecked Sendable { updatedAt: updatedAt! ) } + + func applyUpdates(_ updates: Room.Update) -> Bool { + var hasUpdates = false + + if let name = updates.name, name != self.name { + hasUpdates = true + self.name = name + } + if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad { + hasUpdates = true + self.heatingLoad = heatingLoad + } + if let coolingLoad = updates.coolingLoad, coolingLoad != self.coolingLoad { + hasUpdates = true + self.coolingLoad = coolingLoad + } + if let registerCount = updates.registerCount, registerCount != self.registerCount { + hasUpdates = true + self.registerCount = registerCount + } + return hasUpdates + } + } diff --git a/Sources/ManualDCore/Room.swift b/Sources/ManualDCore/Room.swift index c6add53..f24cc11 100644 --- a/Sources/ManualDCore/Room.swift +++ b/Sources/ManualDCore/Room.swift @@ -69,6 +69,29 @@ extension Room { } } + public struct Update: Codable, Equatable, Sendable { + public let id: Room.ID + public let name: String? + public let heatingLoad: Double? + public let coolingLoad: Double? + public let registerCount: Int? + + public init( + id: Room.ID, + name: String? = nil, + heatingLoad: Double? = nil, + coolingLoad: Double? = nil, + registerCount: Int? = nil + ) { + self.id = id + self.name = name + self.heatingLoad = heatingLoad + self.coolingLoad = coolingLoad + self.registerCount = registerCount + } + } + + // TODO: Remove and just use create. public struct Form: Codable, Equatable, Sendable { public let name: String public let heatingLoad: Double diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index aaec272..67f7b75 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -33,6 +33,7 @@ extension SiteRoute { extension SiteRoute.View { public enum ProjectRoute: Equatable, Sendable { case create(Project.Create) + case delete(id: Project.ID) case detail(Project.ID, DetailRoute) case form(dismiss: Bool = false) case index @@ -59,6 +60,13 @@ extension SiteRoute.View { .map(.memberwise(Project.Create.init)) } } + Route(.case(Self.delete)) { + Path { + rootPath + Project.ID.parser() + } + Method.delete + } Route(.case(Self.detail)) { Path { rootPath @@ -99,7 +107,7 @@ extension SiteRoute.View { extension SiteRoute.View.ProjectRoute { public enum DetailRoute: Equatable, Sendable { - case index + case index(tab: Tab = .default) case equipment(EquipmentInfoRoute) case frictionRate(FrictionRateRoute) case rooms(RoomRoute) @@ -107,6 +115,11 @@ extension SiteRoute.View.ProjectRoute { static let router = OneOf { Route(.case(Self.index)) { Method.get + Query { + Field("tab", default: Tab.default) { + Tab.parser() + } + } } Route(.case(Self.equipment)) { EquipmentInfoRoute.router @@ -118,16 +131,35 @@ extension SiteRoute.View.ProjectRoute { RoomRoute.router } } + + public enum Tab: String, CaseIterable, Equatable, Sendable { + case project + case rooms + case effectiveLength + case frictionRate + case ductSizing + + public static var `default`: Self { .rooms } + } } public enum RoomRoute: Equatable, Sendable { - case form(dismiss: Bool = false) + case delete(id: Room.ID) + case form(id: Room.ID? = nil, dismiss: Bool = false) case index case submit(Room.Form) + case update(Room.Update) static let rootPath = "rooms" public static let router = OneOf { + Route(.case(Self.delete)) { + Path { + rootPath + Room.ID.parser() + } + Method.delete + } Route(.case(Self.form)) { Path { rootPath @@ -135,6 +167,9 @@ extension SiteRoute.View.ProjectRoute { } Method.get Query { + Optionally { + Field("id", default: nil) { Room.ID.parser() } + } Field("dismiss", default: false) { Bool.parser() } } } @@ -157,6 +192,28 @@ extension SiteRoute.View.ProjectRoute { .map(.memberwise(Room.Form.init)) } } + Route(.case(Self.update)) { + Path { rootPath } + Method.patch + Body { + FormData { + Field("id") { Room.ID.parser() } + Optionally { + Field("name", .string) + } + Optionally { + Field("heatingLoad") { Double.parser() } + } + Optionally { + Field("coolingLoad") { Double.parser() } + } + Optionally { + Field("registerCount") { Digits() } + } + } + .map(.memberwise(Room.Update.init)) + } + } } } diff --git a/Sources/Styleguide/Buttons.swift b/Sources/Styleguide/Buttons.swift index 27cb771..57e0163 100644 --- a/Sources/Styleguide/Buttons.swift +++ b/Sources/Styleguide/Buttons.swift @@ -94,3 +94,16 @@ public struct PlusButton: HTML, Sendable { ) { SVG(.circlePlus) } } } + +public struct TrashButton: HTML, Sendable { + public init() {} + + public var body: some HTML { + button( + .type(.button), + .class("btn btn-error dark:text-white") + ) { + SVG(.trash) + } + } +} diff --git a/Sources/Styleguide/ElementaryExtensions.swift b/Sources/Styleguide/ElementaryExtensions.swift index 65ddc38..d827146 100644 --- a/Sources/Styleguide/ElementaryExtensions.swift +++ b/Sources/Styleguide/ElementaryExtensions.swift @@ -14,3 +14,18 @@ extension HTMLAttribute where Tag == HTMLTag.form { action(SiteRoute.View.router.path(for: route)) } } + +extension HTMLAttribute where Tag == HTMLTag.input { + + public static func value(_ string: String?) -> Self { + value(string ?? "") + } + + public static func value(_ int: Int?) -> Self { + value(int == nil ? "" : "\(int!)") + } + + public static func value(_ double: Double?) -> Self { + value(double == nil ? "" : "\(double!)") + } +} diff --git a/Sources/Styleguide/HTMXExtensions.swift b/Sources/Styleguide/HTMXExtensions.swift index d6d4736..a2496e0 100644 --- a/Sources/Styleguide/HTMXExtensions.swift +++ b/Sources/Styleguide/HTMXExtensions.swift @@ -23,10 +23,10 @@ extension HTMLAttribute.hx { put(SiteRoute.View.router.path(for: route)) } - // @Sendable - // static func delete(route: SiteRoute.Api) -> HTMLAttribute { - // delete(SiteRoute.Api.router.path(for: route)) - // } + @Sendable + public static func delete(route: SiteRoute.View) -> HTMLAttribute { + delete(SiteRoute.View.router.path(for: route)) + } } extension HTMLAttribute.hx { diff --git a/Sources/Styleguide/SVG.swift b/Sources/Styleguide/SVG.swift index fae90da..ffe3e0e 100644 --- a/Sources/Styleguide/SVG.swift +++ b/Sources/Styleguide/SVG.swift @@ -20,6 +20,7 @@ extension SVG { case email case key case squarePen + case trash case user var svg: String { @@ -67,6 +68,11 @@ extension SVG { case .squarePen: return """ + + """ + case .trash: + return """ + """ case .user: return """ diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 2a67c8a..4b2d427 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -91,36 +91,28 @@ 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) - } - } + return ProjectView(projectID: project.id, activeTab: .rooms) + + case .delete(let id): + try await database.projects.delete(id) + return EmptyHTML() case .detail(let projectID, let route): switch route { - case .index: - let project = try await database.projects.get(projectID)! - return request.view { - ProjectView(projectID: projectID, activeTab: .projects) { - ProjectDetail(project: project) - } - } + case .index(let tab): + return ProjectView(projectID: projectID, activeTab: tab) + // return try await defaultDetailView(projectID: projectID, activeTab: tab) 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) - case .rooms(let route): return try await route.renderView(on: request, projectID: projectID) } - - // case .rooms(let projectID, let route): - // return try await route.renderView(on: request, projectID: projectID) - } } + } extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute { @@ -156,26 +148,32 @@ extension SiteRoute.View.ProjectRoute.RoomRoute { switch self { - case .form(let dismiss): - return RoomForm(dismiss: dismiss, projectID: projectID) + case .delete(let id): + try await database.rooms.delete(id) + return EmptyHTML() + + case .form(let id, let dismiss): + var room: Room? = nil + if let id, dismiss == false { + room = try await database.rooms.get(id) + } + return RoomForm(dismiss: dismiss, projectID: projectID, room: room) case .index: - let rooms = try await database.rooms.fetch(projectID) return request.view { - ProjectView(projectID: projectID, activeTab: .rooms) { - RoomsView(projectID: projectID, rooms: rooms) - } + ProjectView(projectID: projectID, activeTab: .rooms) } case .submit(let form): request.logger.debug("New room form submitted.") let _ = try await database.rooms.create(.init(form: form, projectID: projectID)) - let rooms = try await database.rooms.fetch(projectID) return request.view { - ProjectView(projectID: projectID, activeTab: .rooms) { - RoomsView(projectID: projectID, rooms: rooms) - } + ProjectView(projectID: projectID, activeTab: .rooms) } + + case .update(let form): + _ = try await database.rooms.update(form) + return ProjectView(projectID: projectID, activeTab: .rooms) } } } @@ -192,14 +190,9 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute { let componentLosses = try await database.componentLoss.fetch(projectID) return request.view { - ProjectView(projectID: projectID, activeTab: .frictionRate) { - FrictionRateView( - equipmentInfo: equipment, - componentLosses: componentLosses, - projectID: projectID - ) - } + ProjectView(projectID: projectID, activeTab: .frictionRate) } + case .form(let type, let dismiss): // FIX: Forms need to reference existing items. switch type { @@ -249,7 +242,7 @@ extension SiteRoute.View.EffectiveLengthRoute { private func _render( isHtmxRequest: Bool, - active activeTab: Sidebar.ActiveTab = .projects, + active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, showSidebar: Bool = true, @HTMLBuilder inner: () async throws -> C ) async throws -> AnySendableHTML where C: Sendable { @@ -262,7 +255,7 @@ private func _render( private func _render( isHtmxRequest: Bool, - active activeTab: Sidebar.ActiveTab = .projects, + active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, showSidebar: Bool = true, @HTMLBuilder inner: () -> C ) -> AnySendableHTML where C: Sendable { diff --git a/Sources/ViewController/Views/Project/ProjectView.swift b/Sources/ViewController/Views/Project/ProjectView.swift index 07b7c35..0613c07 100644 --- a/Sources/ViewController/Views/Project/ProjectView.swift +++ b/Sources/ViewController/Views/Project/ProjectView.swift @@ -1,23 +1,24 @@ +import DatabaseClient +import Dependencies import Elementary import ElementaryHTMX import ManualDCore import Styleguide -// TODO: Need a back button to navigate to all projects table. +// TODO: Make view async and load based on the active tab. + +struct ProjectView: HTML, Sendable { + @Dependency(\.database) var database -struct ProjectView: HTML, Sendable where Inner: Sendable { let projectID: Project.ID - let activeTab: Sidebar.ActiveTab - let inner: Inner + let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab init( projectID: Project.ID, - activeTab: Sidebar.ActiveTab, - @HTMLBuilder inner: () -> Inner + activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab ) { self.projectID = projectID self.activeTab = activeTab - self.inner = inner() } var body: some HTML { @@ -25,7 +26,30 @@ struct ProjectView: HTML, Sendable where Inner: Sendable { div(.class("flex flex-row")) { Sidebar(active: activeTab, projectID: projectID) main(.class("flex flex-col h-screen w-full px-6 py-10")) { - inner + switch self.activeTab { + case .project: + if let project = try await database.projects.get(projectID) { + ProjectDetail(project: project) + } else { + div { + "FIX ME!" + } + } + case .rooms: + try await RoomsView(projectID: projectID, rooms: database.rooms.fetch(projectID)) + + case .effectiveLength: + try await EffectiveLengthsView( + effectiveLengths: database.effectiveLength.fetch(projectID) + ) + case .frictionRate: + try await FrictionRateView( + equipmentInfo: database.equipment.fetch(projectID), + componentLosses: database.componentLoss.fetch(projectID), projectID: projectID) + case .ductSizing: + div { "FIX ME!" } + + } } } } @@ -35,7 +59,7 @@ struct ProjectView: HTML, Sendable where Inner: Sendable { // TODO: Update to use DaisyUI drawer. struct Sidebar: HTML { - let active: ActiveTab + let active: SiteRoute.View.ProjectRoute.DetailRoute.Tab let projectID: Project.ID var body: some HTML { @@ -49,15 +73,31 @@ struct Sidebar: HTML { ) ) { - // TODO: Move somewhere outside of the sidebar. + div(.class("flex")) { + // TODO: Move somewhere outside of the sidebar. + button( + .class("w-full btn btn-secondary"), + .hx.get(route: .project(.index)), + .hx.target("body"), + .hx.pushURL(true), + .hx.swap(.outerHTML), + ) { + "< All Projects" + } + } + Row { Label("Theme") input(.type(.checkbox), .class("toggle theme-controller"), .value("light")) } .attributes(.class("p-4")) - row(title: "Project", icon: .mapPin, route: .project(.detail(projectID, .index))) - .attributes(.data("active", value: active == .projects ? "true" : "false")) + row( + title: "Project", + icon: .mapPin, + route: .project(.detail(projectID, .index(tab: .project))) + ) + .attributes(.data("active", value: active == .project ? "true" : "false")) row(title: "Rooms", icon: .doorClosed, route: .project(.detail(projectID, .rooms(.index)))) .attributes(.data("active", value: active == .rooms ? "true" : "false")) @@ -109,13 +149,3 @@ struct Sidebar: HTML { row(title: title, icon: icon, href: SiteRoute.View.router.path(for: route)) } } - -extension Sidebar { - enum ActiveTab: Equatable, Sendable { - case projects - case rooms - case effectiveLength - case frictionRate - case ductSizing - } -} diff --git a/Sources/ViewController/Views/Project/ProjectsTable.swift b/Sources/ViewController/Views/Project/ProjectsTable.swift index 150eb6c..a0bd36a 100644 --- a/Sources/ViewController/Views/Project/ProjectsTable.swift +++ b/Sources/ViewController/Views/Project/ProjectsTable.swift @@ -67,10 +67,19 @@ extension ProjectsTable { td { "\(project.name)" } td { "\(project.streetAddress)" } td { - a( - .class("btn btn-success"), - .href(route: .project(.detail(project.id, .index))) - ) { ">" } + Row { + div {} + TrashButton() + .attributes( + .hx.delete(route: .project(.delete(id: project.id))), + .hx.confirm("Are you sure?"), + .hx.target("closest tr") + ) + a( + .class("btn btn-success dark:text-white"), + .href(route: .project(.detail(project.id, .index()))) + ) { ">" } + } } } } diff --git a/Sources/ViewController/Views/Rooms/RoomForm.swift b/Sources/ViewController/Views/Rooms/RoomForm.swift index d52d836..14e1d1a 100644 --- a/Sources/ViewController/Views/Rooms/RoomForm.swift +++ b/Sources/ViewController/Views/Rooms/RoomForm.swift @@ -10,34 +10,48 @@ struct RoomForm: HTML, Sendable { let dismiss: Bool let projectID: Project.ID + let room: Room? var body: some HTML { ModalForm(id: "roomForm", dismiss: dismiss) { h1(.class("text-3xl font-bold pb-6")) { "Room" } // TODO: Use htmx here. form( - .method(.post), - .action(route: .project(.detail(projectID, .rooms(.index)))) + room == nil + ? .hx.post(route: .project(.detail(projectID, .rooms(.index)))) + : .hx.patch(route: .project(.detail(projectID, .rooms(.index)))), + .hx.target("body"), + .hx.swap(.outerHTML) ) { + + input(.class("hidden"), .name("projectID"), .value("\(projectID)")) + + if let id = room?.id { + input(.class("hidden"), .name("id"), .value("\(id)")) + } + div { label(.for("name")) { "Name:" } Input(id: "name", placeholder: "Room Name") - .attributes(.type(.text), .required, .autofocus) + .attributes(.type(.text), .required, .autofocus, .value(room?.name)) } div { label(.for("heatingLoad")) { "Heating Load:" } Input(id: "heatingLoad", placeholder: "Heating Load") - .attributes(.type(.number), .required, .min("0")) + .attributes(.type(.number), .required, .min("0"), .value(room?.heatingLoad)) } div { label(.for("coolingLoad")) { "Cooling Load:" } Input(id: "coolingLoad", placeholder: "Cooling Load") - .attributes(.type(.number), .required, .min("0")) + .attributes(.type(.number), .required, .min("0"), .value(room?.coolingLoad)) } div { label(.for("registerCount")) { "Registers:" } Input(id: "registerCount", placeholder: "Register Count") - .attributes(.type(.number), .required, .value("1"), .min("1")) + .attributes( + .type(.number), .required, .min("0"), + .value("\(room != nil ? room!.registerCount : 1)"), + ) } Row { // Force button to the right, probably a better way. diff --git a/Sources/ViewController/Views/Rooms/RoomsView.swift b/Sources/ViewController/Views/Rooms/RoomsView.swift index 26f2279..0f7abc4 100644 --- a/Sources/ViewController/Views/Rooms/RoomsView.swift +++ b/Sources/ViewController/Views/Rooms/RoomsView.swift @@ -39,23 +39,13 @@ struct RoomsView: HTML, Sendable { th { Label("Heating Load") } th { Label("Cooling Total") } th { Label("Register Count") } + th {} } } tbody { - for room in rooms { - tr { - td { room.name } - td { - Number(room.heatingLoad) - .attributes(.class("text-error")) - } - td { - Number(room.coolingLoad) - .attributes(.class("text-success")) - } - td { - Number(room.registerCount) - } + div(.id("rooms")) { + for room in rooms { + RoomRow(room: room) } } // TOTALS @@ -75,7 +65,48 @@ struct RoomsView: HTML, Sendable { } } } - RoomForm(dismiss: true, projectID: projectID) + RoomForm(dismiss: true, projectID: projectID, room: nil) + } + } + + public struct RoomRow: HTML, Sendable { + let room: Room + + public var body: some HTML { + tr(.id("\(room.id)")) { + td { room.name } + td { + Number(room.heatingLoad) + .attributes(.class("text-error")) + } + td { + Number(room.coolingLoad) + .attributes(.class("text-success")) + } + td { + Number(room.registerCount) + } + td { + Row { + TrashButton() + .attributes( + .hx.delete(route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))), + .hx.target("closest tr"), + .hx.confirm("Are you sure?") + ) + EditButton() + .attributes( + .hx.get( + route: .project( + .detail(room.projectID, .rooms(.form(id: room.id, dismiss: false))) + ) + ), + .hx.target("#roomForm"), + .hx.swap(.outerHTML) + ) + } + } + } } }