From 79b7892d9a0c1f9c7f1adaacce3d4ccdc08d2a39 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Wed, 7 Jan 2026 17:31:54 -0500 Subject: [PATCH] feat: Adds update path to equivalent length form / database / view routes. --- Public/css/output.css | 108 ++++++++++--- Sources/App/configure.swift | 14 -- Sources/DatabaseClient/EffectiveLength.swift | 49 ++++++ Sources/ManualDCore/EffectiveLength.swift | 49 ++++++ Sources/ManualDCore/Routes/ViewRoute.swift | 47 ++++++ .../EquivalentLengthForm+extensions.swift | 20 ++- Sources/ViewController/Live.swift | 23 ++- .../EffectiveLength/EffectiveLengthForm.swift | 104 +++++++++--- .../EffectiveLengthsView.swift | 148 ++++++++++++------ Sources/ViewController/Views/MainPage.swift | 13 +- Tests/ApiRouteTests/ProjectRouteTests.swift | 3 +- 11 files changed, 460 insertions(+), 118 deletions(-) diff --git a/Public/css/output.css b/Public/css/output.css index 291d89f..8555a75 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -19,13 +19,12 @@ --color-black: #000; --color-white: #fff; --spacing: 0.25rem; - --breakpoint-lg: 64rem; - --container-lg: 32rem; - --container-xl: 36rem; - --container-4xl: 56rem; - --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-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; @@ -4235,6 +4234,12 @@ .right-2 { right: calc(var(--spacing) * 2); } + .right-4 { + right: calc(var(--spacing) * 4); + } + .right-6 { + right: calc(var(--spacing) * 6); + } .dock-sm { @layer daisyui.l1.l2 { height: calc(0.25rem * 14); @@ -4292,6 +4297,12 @@ } } } + .bottom-4 { + bottom: calc(var(--spacing) * 4); + } + .bottom-6 { + bottom: calc(var(--spacing) * 6); + } .join { display: inline-flex; align-items: stretch; @@ -4803,6 +4814,9 @@ } } } + .col-span-2 { + grid-column: span 2 / span 2; + } .timeline-end { @layer daisyui.l1.l2.l3 { grid-column-start: 1; @@ -5272,8 +5286,8 @@ } } } - .mx-auto { - margin-inline: auto; + .mx-2 { + margin-inline: calc(var(--spacing) * 2); } .file-input-ghost { @layer daisyui.l1.l2 { @@ -5615,6 +5629,9 @@ .mt-6 { margin-top: calc(var(--spacing) * 6); } + .mt-auto { + margin-top: auto; + } .breadcrumbs { @layer daisyui.l1.l2.l3 { max-width: 100%; @@ -6405,6 +6422,9 @@ .h-\[1em\] { height: 1em; } + .h-full { + height: 100%; + } .h-screen { height: 100vh; } @@ -6539,21 +6559,9 @@ .w-full { width: 100%; } - .w-screen { - width: 100vw; - } .max-w-\[280px\] { max-width: 280px; } - .max-w-lg { - max-width: var(--container-lg); - } - .max-w-screen-lg { - max-width: var(--breakpoint-lg); - } - .max-w-xl { - max-width: var(--container-xl); - } .flex-none { flex: none; } @@ -6738,6 +6746,12 @@ } } } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .grid-cols-5 { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } .flex-col { flex-direction: column; } @@ -6759,6 +6773,9 @@ .justify-end { justify-content: flex-end; } + .gap-2 { + gap: calc(var(--spacing) * 2); + } .gap-4 { gap: calc(var(--spacing) * 4); } @@ -7078,10 +7095,6 @@ border-style: var(--tw-border-style); border-width: 1px; } - .border-t { - border-top-style: var(--tw-border-style); - border-top-width: 1px; - } .border-r-2 { border-right-style: var(--tw-border-style); border-right-width: 2px; @@ -7202,6 +7215,12 @@ .border-gray-200 { border-color: var(--color-gray-200); } + .border-primary { + border-color: var(--color-primary); + } + .border-secondary { + border-color: var(--color-secondary); + } .menu-active { :where(:not(ul, details, .menu-title, .btn))& { @layer daisyui.l1.l2 { @@ -7353,6 +7372,9 @@ } } } + .bg-base-100 { + background-color: var(--color-base-100); + } .bg-red-500 { background-color: var(--color-red-500); } @@ -7644,6 +7666,9 @@ .p-4 { padding: calc(var(--spacing) * 4); } + .p-6 { + padding: calc(var(--spacing) * 6); + } .menu-title { @layer daisyui.l1.l2.l3 { padding-inline: calc(0.25rem * 3); @@ -7770,6 +7795,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } .py-1\.5 { padding-block: calc(var(--spacing) * 1.5); } @@ -7796,6 +7824,9 @@ .pe-2 { padding-inline-end: calc(var(--spacing) * 2); } + .pt-6 { + padding-top: calc(var(--spacing) * 6); + } .pb-4 { padding-bottom: calc(var(--spacing) * 4); } @@ -7854,6 +7885,14 @@ font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } .text-xl { font-size: var(--text-xl); line-height: var(--tw-leading, var(--text-xl--line-height)); @@ -8421,6 +8460,12 @@ .text-info { color: var(--color-info); } + .text-primary { + color: var(--color-primary); + } + .text-secondary { + color: var(--color-secondary); + } .text-slate-900 { color: var(--color-slate-900); } @@ -8436,6 +8481,9 @@ .uppercase { text-transform: uppercase; } + .italic { + font-style: italic; + } .tabular-nums { --tw-numeric-spacing: tabular-nums; font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); @@ -8495,6 +8543,10 @@ --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .outline { outline-style: var(--tw-outline-style); outline-width: 1px; @@ -9343,6 +9395,16 @@ color: var(--color-gray-800); } } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } .dark\:text-white { @media (prefers-color-scheme: dark) { color: var(--color-white); diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 00923e7..ebb6a8c 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -119,20 +119,6 @@ private func siteHandler( @Dependency(\.apiController) var apiController @Dependency(\.viewController) var viewController - request.logger.debug("Site Handler: Route: \(route)") - request.logger.debug("Content: \(request.content)") - // - // // HACK: Can't get arrays to decode currently. - if let content = try? request.content.decode( - SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo.self - ) { - request.logger.debug("Site Handler: Got step two: \(content)") - // return try await viewController.respond( - // route: .project(.detail(content.projectID, .equivalentLength(.submit(.two(content))))), - // request: request - // ) - } - switch route { case .api(let route): return try await apiController.respond(route, request: request) diff --git a/Sources/DatabaseClient/EffectiveLength.swift b/Sources/DatabaseClient/EffectiveLength.swift index a653eb2..7868c6a 100644 --- a/Sources/DatabaseClient/EffectiveLength.swift +++ b/Sources/DatabaseClient/EffectiveLength.swift @@ -10,7 +10,9 @@ extension DatabaseClient { 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 fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength? + public var update: @Sendable (EffectiveLength.Update) async throws -> EffectiveLength } } @@ -37,8 +39,34 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey { .all() .map { try $0.toDTO() } }, + fetchMax: { projectID in + let effectiveLengths = try await EffectiveLengthModel.query(on: database) + .with(\.$project) + .filter(\.$project.$id, .equal, projectID) + .all() + .map { try $0.toDTO() } + + return .init( + supply: effectiveLengths.filter({ $0.type == .supply }) + .sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength }) + .first, + return: effectiveLengths.filter({ $0.type == .return }) + .sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength }) + .first + ) + + }, get: { id in try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() } + }, + update: { updates in + guard let model = try await EffectiveLengthModel.find(updates.id, on: database) else { + throw NotFoundError() + } + if try model.applyUpdates(updates) { + try await model.save(on: database) + } + return try model.toDTO() } ) } @@ -155,4 +183,25 @@ final class EffectiveLengthModel: Model, @unchecked Sendable { updatedAt: updatedAt! ) } + + func applyUpdates(_ updates: EffectiveLength.Update) throws -> Bool { + var hasUpdates = false + if let name = updates.name, name != self.name { + hasUpdates = true + self.name = name + } + if let type = updates.type, type.rawValue != self.type { + hasUpdates = true + self.type = type.rawValue + } + if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths { + hasUpdates = true + self.straightLengths = straightLengths + } + if let groups = updates.groups { + hasUpdates = true + self.groups = try JSONEncoder().encode(groups) + } + return hasUpdates + } } diff --git a/Sources/ManualDCore/EffectiveLength.swift b/Sources/ManualDCore/EffectiveLength.swift index 0df9ba5..6a99fc7 100644 --- a/Sources/ManualDCore/EffectiveLength.swift +++ b/Sources/ManualDCore/EffectiveLength.swift @@ -61,6 +61,29 @@ extension EffectiveLength { } } + public struct Update: Codable, Equatable, Sendable { + + public let id: EffectiveLength.ID + public let name: String? + public let type: EffectiveLengthType? + public let straightLengths: [Int]? + public let groups: [Group]? + + public init( + id: EffectiveLength.ID, + name: String? = nil, + type: EffectiveLength.EffectiveLengthType? = nil, + straightLengths: [Int]? = nil, + groups: [EffectiveLength.Group]? = nil + ) { + self.id = id + self.name = name + self.type = type + self.straightLengths = straightLengths + self.groups = groups + } + } + public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable { case `return` case supply @@ -85,6 +108,32 @@ extension EffectiveLength { self.quantity = quantity } } + + public struct MaxContainer: Codable, Equatable, Sendable { + public let supply: EffectiveLength? + public let `return`: EffectiveLength? + + public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) { + self.supply = supply + self.return = `return` + } + } +} + +extension EffectiveLength { + public var totalEquivalentLength: Double { + straightLengths.reduce(into: 0.0) { $0 += Double($1) } + + groups.totalEquivalentLength + } +} + +extension Array where Element == EffectiveLength.Group { + + public var totalEquivalentLength: Double { + reduce(into: 0.0) { + $0 += ($1.value * Double($1.quantity)) + } + } } #if DEBUG diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index e6c5a2c..9d32908 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -375,14 +375,23 @@ extension SiteRoute.View.ProjectRoute { } public enum EquivalentLengthRoute: Equatable, Sendable { + case delete(id: EffectiveLength.ID) case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil) case form(dismiss: Bool = false) case index case submit(FormStep) + case update(StepThree) static let rootPath = "effective-lengths" public static let router = OneOf { + Route(.case(Self.delete(id:))) { + Path { + rootPath + EffectiveLength.ID.parser() + } + Method.delete + } Route(.case(Self.index)) { Path { rootPath } Method.get @@ -417,6 +426,44 @@ extension SiteRoute.View.ProjectRoute { Method.post FormStep.router } + Route(.case(Self.update)) { + Path { rootPath } + Method.patch + Body { + FormData { + Optionally { + Field("id", default: nil) { EffectiveLength.ID.parser() } + } + Field("name", .string) + Field("type") { EffectiveLength.EffectiveLengthType.parser() } + Many { + Field("straightLengths") { + Int.parser() + } + } + Many { + Field("group[group]") { + Int.parser() + } + } + Many { + Field("group[letter]", .string) + } + Many { + Field("group[length]") { + Int.parser() + } + + } + Many { + Field("group[quantity]") { + Int.parser() + } + } + } + .map(.memberwise(StepThree.init)) + } + } } public enum FormStep: Equatable, Sendable { diff --git a/Sources/ViewController/Extensions/EquivalentLengthForm+extensions.swift b/Sources/ViewController/Extensions/EquivalentLengthForm+extensions.swift index b7aabf8..efccc90 100644 --- a/Sources/ViewController/Extensions/EquivalentLengthForm+extensions.swift +++ b/Sources/ViewController/Extensions/EquivalentLengthForm+extensions.swift @@ -22,7 +22,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree { value: Double(groupLengths[n]), quantity: groupQuantities[n] ) - ) + ) } return groups @@ -44,3 +44,21 @@ extension EffectiveLength.Create { ) } } + +extension EffectiveLength.Update { + init( + form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree, + projectID: Project.ID + ) throws { + guard let id = form.id else { + throw ValidationError("Id not found.") + } + self.init( + id: id, + name: form.name, + type: form.type, + straightLengths: form.straightLengths, + groups: form.groups + ) + } +} diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 406cb39..0d7a583 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -246,6 +246,11 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute { @Dependency(\.database) var database switch self { + + case .delete(let id): + try await database.effectiveLength.delete(id) + return EmptyHTML() + case .index: return request.view { ProjectView(projectID: projectID, activeTab: .equivalentLength) @@ -262,16 +267,30 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute { return GroupField(style: style ?? .supply) } + case .update(let form): + _ = try await database.effectiveLength.update(.init(form: form, projectID: projectID)) + return ProjectView(projectID: projectID, activeTab: .equivalentLength) + case .submit(let step): switch step { case .one(let stepOne): + var effectiveLength: EffectiveLength? = nil + if let id = stepOne.id { + effectiveLength = try await database.effectiveLength.get(id) + } return EffectiveLengthForm.StepTwo( - projectID: projectID, stepOne: stepOne, effectiveLength: nil + projectID: projectID, + stepOne: stepOne, + effectiveLength: effectiveLength ) case .two(let stepTwo): request.logger.debug("ViewController: Got step two...") + var effectiveLength: EffectiveLength? = nil + if let id = stepTwo.id { + effectiveLength = try await database.effectiveLength.get(id) + } return EffectiveLengthForm.StepThree( - projectID: projectID, effectiveLength: nil, stepTwo: stepTwo + projectID: projectID, effectiveLength: effectiveLength, stepTwo: stepTwo ) case .three(let stepThree): request.logger.debug("ViewController: Got step three: \(stepThree)") diff --git a/Sources/ViewController/Views/EffectiveLength/EffectiveLengthForm.swift b/Sources/ViewController/Views/EffectiveLength/EffectiveLengthForm.swift index b737eaa..edd2767 100644 --- a/Sources/ViewController/Views/EffectiveLength/EffectiveLengthForm.swift +++ b/Sources/ViewController/Views/EffectiveLength/EffectiveLengthForm.swift @@ -11,12 +11,22 @@ import Styleguide // TODO: Add back buttons / capability?? +// TODO: Add patch / update capability + struct EffectiveLengthForm: HTML, Sendable { - static let id = "equivalentLengthForm" + + static func id(_ equivalentLength: EffectiveLength?) -> String { + let base = "equivalentLengthForm" + guard let equivalentLength else { return base } + return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))" + } let projectID: Project.ID let dismiss: Bool let type: EffectiveLength.EffectiveLengthType + let effectiveLength: EffectiveLength? + + var id: String { Self.id(effectiveLength) } init( projectID: Project.ID, @@ -26,13 +36,27 @@ struct EffectiveLengthForm: HTML, Sendable { self.projectID = projectID self.dismiss = dismiss self.type = type + self.effectiveLength = nil + } + + init( + effectiveLength: EffectiveLength + ) { + self.dismiss = true + self.type = effectiveLength.type + self.projectID = effectiveLength.projectID + self.effectiveLength = effectiveLength + } var body: some HTML { - ModalForm(id: Self.id, dismiss: dismiss) { + ModalForm( + id: id, + dismiss: dismiss + ) { h1(.class("text-2xl font-bold")) { "Effective Length" } - div(.id("formStep")) { - StepOne(projectID: projectID, effectiveLength: nil) + div(.id("formStep_\(id)")) { + StepOne(projectID: projectID, effectiveLength: effectiveLength) } } } @@ -52,7 +76,7 @@ struct EffectiveLengthForm: HTML, Sendable { form( .class("space-y-4"), .hx.post(route), - .hx.target("#formStep"), + .hx.target("#formStep_\(EffectiveLengthForm.id(effectiveLength))"), .hx.swap(.innerHTML) ) { if let id = effectiveLength?.id { @@ -87,7 +111,7 @@ struct EffectiveLengthForm: HTML, Sendable { form( .class("space-y-4"), .hx.post(route), - .hx.target("#formStep"), + .hx.target("#formStep_\(EffectiveLengthForm.id(effectiveLength))"), .hx.swap(.innerHTML) ) { if let id = effectiveLength?.id { @@ -109,8 +133,14 @@ struct EffectiveLengthForm: HTML, Sendable { SVG(.circlePlus) } } - div(.id("straightLengths")) { - StraightLengthField() + div(.id("straightLengths"), .class("space-y-4")) { + if let effectiveLength { + for length in effectiveLength.straightLengths { + StraightLengthField(value: length) + } + } else { + StraightLengthField() + } } Row { @@ -127,16 +157,23 @@ struct EffectiveLengthForm: HTML, Sendable { let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo var route: String { - let baseRoute = SiteRoute.View.router.path( - for: .project(.detail(projectID, .equivalentLength(.index))) - ) - return "\(baseRoute)/stepThree" + if effectiveLength != nil { + return SiteRoute.View.router.path( + for: .project(.detail(projectID, .equivalentLength(.index)))) + } else { + let baseRoute = SiteRoute.View.router.path( + for: .project(.detail(projectID, .equivalentLength(.index))) + ) + return "\(baseRoute)/stepThree" + } } var body: some HTML { form( .class("space-y-4"), - .hx.post(route), + effectiveLength == nil + ? .hx.post(route) + : .hx.patch(route), .hx.target("body"), .hx.swap(.outerHTML) ) { @@ -163,8 +200,23 @@ struct EffectiveLengthForm: HTML, Sendable { SVG(.circlePlus) } } + + div(.class("grid grid-cols-5 gap-2")) { + Label("Group") + Label("Letter") + Label("Length") + Label("Quantity") + .attributes(.class("col-span-2")) + } + div(.id("groups"), .class("space-y-4")) { - GroupField(style: stepTwo.type) + if let effectiveLength { + for group in effectiveLength.groups { + GroupField(style: effectiveLength.type, group: group) + } + } else { + GroupField(style: stepTwo.type) + } } Row { div {} @@ -188,30 +240,38 @@ struct StraightLengthField: HTML, Sendable { name: "straightLengths", placeholder: "Length" ) - .attributes(.type(.number), .min("0"), .autofocus, .required) + .attributes(.type(.number), .min("0"), .autofocus, .required, .value(value)) TrashButton() .attributes(.data("remove", value: "true")) } - .attributes(.hx.ext("remove")) + .attributes(.hx.ext("remove"), .class("space-x-4")) } } struct GroupField: HTML, Sendable { let style: EffectiveLength.EffectiveLengthType + let group: EffectiveLength.Group? + + init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) { + self.style = style + self.group = group + } var body: some HTML { - Row { + div(.class("grid grid-cols-5 gap-2")) { GroupSelect(style: style) Input(name: "group[letter]", placeholder: "Letter") - .attributes(.type(.text), .autofocus, .required) + .attributes(.type(.text), .autofocus, .required, .value(group?.letter)) Input(name: "group[length]", placeholder: "Length") - .attributes(.type(.number), .min("0"), .required) + .attributes(.type(.number), .min("0"), .required, .value(group?.value)) Input(name: "group[quantity]", placeholder: "Quantity") - .attributes(.type(.number), .min("1"), .value("1"), .required) - TrashButton() - .attributes(.data("remove", value: "true")) + .attributes(.type(.number), .min("1"), .value("1"), .required, .value(group?.quantity ?? 1)) + div(.class("flex justify-end")) { + TrashButton() + .attributes(.data("remove", value: "true"), .class("mx-2")) + } } .attributes(.class("space-x-2"), .hx.ext("remove")) } diff --git a/Sources/ViewController/Views/EffectiveLength/EffectiveLengthsView.swift b/Sources/ViewController/Views/EffectiveLength/EffectiveLengthsView.swift index 76f4d41..f2e9d1b 100644 --- a/Sources/ViewController/Views/EffectiveLength/EffectiveLengthsView.swift +++ b/Sources/ViewController/Views/EffectiveLength/EffectiveLengthsView.swift @@ -3,32 +3,56 @@ import ElementaryHTMX import ManualDCore import Styleguide +// TODO: Group into grids of supply / return. + struct EffectiveLengthsView: HTML, Sendable { let projectID: Project.ID let effectiveLengths: [EffectiveLength] + var supplies: [EffectiveLength] { + effectiveLengths.filter({ $0.type == .supply }) + .sorted { $0.totalEquivalentLength > $1.totalEquivalentLength } + } + + var returns: [EffectiveLength] { + effectiveLengths.filter({ $0.type == .return }) + .sorted { $0.totalEquivalentLength > $1.totalEquivalentLength } + } + var body: some HTML { div( - .class("m-4") + .class("m-4 space-y-4") ) { Row { h1(.class("text-2xl font-bold")) { "Equivalent Lengths" } PlusButton() - .attributes(.showModal(id: EffectiveLengthForm.id)) + .attributes(.showModal(id: EffectiveLengthForm.id(nil))) } .attributes(.class("pb-6")) - div( - .id("effectiveLengths"), - .class("space-y-6") - ) { - for row in effectiveLengths { - EffectiveLengthView(effectiveLength: row) + EffectiveLengthForm(projectID: projectID, dismiss: true) + + div { + h2(.class("text-xl font-bold pb-4")) { "Supplies" } + div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")) { + for (n, row) in supplies.enumerated() { + EffectiveLengthView(effectiveLength: row) + .attributes(.class(n == 0 ? "border-primary" : "border-gray-200")) + } + } + } + + div { + h2(.class("text-xl font-bold pb-4")) { "Returns" } + div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 space-x-4 space-y-4")) { + for (n, row) in returns.enumerated() { + EffectiveLengthView(effectiveLength: row) + .attributes(.class(n == 0 ? "border-secondary" : "border-gray-200")) + } } } - EffectiveLengthForm(projectID: projectID, dismiss: true) } } @@ -42,52 +66,84 @@ struct EffectiveLengthsView: HTML, Sendable { } var groupsTotal: Double { - effectiveLength.groups.reduce(into: 0) { - $0 += ($1.value * Double($1.quantity)) - } + effectiveLength.groups.totalEquivalentLength + } + + var id: String { + return "effectiveLenghtCard_\(effectiveLength.id.uuidString.replacing("-", with: ""))" } var body: some HTML { div( - .class( - """ - border border-gray-200 rounded-lg shadow-lg p-4 - """ - ) + .class("card h-full bg-base-100 shadow-sm border rounded-lg"), + .id(id) ) { - Row { - span(.class("text-xl font-bold")) { effectiveLength.name } - } - Row { + div(.class("card-body")) { + Row { + h2 { effectiveLength.name } + div( + .class("space-x-4") + ) { + span(.class("text-sm italic")) { + "Total" + } + .attributes(.class("text-primary"), when: effectiveLength.type == .supply) + .attributes(.class("text-secondary"), when: effectiveLength.type == .return) + + Number(self.effectiveLength.totalEquivalentLength, digits: 0) + .attributes(.class("badge badge-outline text-lg")) + .attributes( + .class("badge-primary"), when: effectiveLength.type == .supply + ) + .attributes( + .class("badge-secondary"), when: effectiveLength.type == .return + ) + } + } + .attributes(.class("card-title pb-6")) + Label("Straight Lengths") - } - for length in effectiveLength.straightLengths { - Row { - div {} - Number(length) - } - } - Row { - Label("Groups") - Label("Equivalent Length") - Label("Quantity") - } - .attributes(.class("border-b border-gray-200")) - - for group in effectiveLength.groups { - Row { - span { "\(group.group)-\(group.letter)" } - Number(group.value) - Number(group.quantity) + for length in effectiveLength.straightLengths { + div(.class("flex justify-end")) { + Number(length) + } } + + Row { + Label("Groups") + Label("Equivalent Length") + Label("Quantity") + } + .attributes(.class("border-b border-gray-200")) + + for group in effectiveLength.groups { + Row { + span { "\(group.group)-\(group.letter)" } + Number(group.value) + Number(group.quantity) + } + } + + div(.class("card-actions justify-end pt-6 space-y-4 mt-auto")) { + // TODO: Delete. + TrashButton() + .attributes( + .hx.delete( + route: .project( + .detail( + effectiveLength.projectID, .equivalentLength(.delete(id: effectiveLength.id))) + )), + .hx.confirm("Are you sure?"), + .hx.target("#\(id)"), + .hx.swap(.outerHTML) + ) + EditButton() + .attributes(.showModal(id: EffectiveLengthForm.id(effectiveLength))) + } + + EffectiveLengthForm(effectiveLength: effectiveLength) } - Row { - Label("Total") - Number(Double(straightLengthsTotal) + groupsTotal, digits: 0) - .attributes(.class("text-xl font-bold")) - } - .attributes(.class("border-b border-t border-gray-200")) } } } diff --git a/Sources/ViewController/Views/MainPage.swift b/Sources/ViewController/Views/MainPage.swift index a6d3d9c..c29badd 100644 --- a/Sources/ViewController/Views/MainPage.swift +++ b/Sources/ViewController/Views/MainPage.swift @@ -23,14 +23,11 @@ public struct MainPage: SendableHTMLDocument where Inner: Sendable script(.src("/js/main.js")) {} link(.rel(.stylesheet), .href("/css/output.css")) link(.rel(.icon), .href("/images/favicon.ico"), .custom(name: "type", value: "image/x-icon")) - HTMLRaw( - """ - - """ - ) + script( + .src("https://unpkg.com/htmx-remove@latest"), + .crossorigin(.anonymous), + .integrity("sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT") + ) {} } public var body: some HTML { diff --git a/Tests/ApiRouteTests/ProjectRouteTests.swift b/Tests/ApiRouteTests/ProjectRouteTests.swift index 3102064..b55f9ad 100644 --- a/Tests/ApiRouteTests/ProjectRouteTests.swift +++ b/Tests/ApiRouteTests/ProjectRouteTests.swift @@ -80,7 +80,6 @@ struct ProjectRouteTests { Optionally { Field("id", default: nil) { EffectiveLength.ID.parser() } } - Field("projectID") { Project.ID.parser() } Field("name", .string) Field("type") { EffectiveLength.EffectiveLengthType.parser() } Many { @@ -94,7 +93,7 @@ struct ProjectRouteTests { var request = URLRequestData( body: .init( - "projectID=15062A72-7AB5-4F15-9B1F-74A4BFA53CBB&name=Test&type=supply&straightLengths=20&straightLengths=10" + "name=Test&type=supply&straightLengths=20&straightLengths=10" .utf8 ) )