feat: Adds update path to equivalent length form / database / view routes.

This commit is contained in:
2026-01-07 17:31:54 -05:00
parent f8bed40670
commit 79b7892d9a
11 changed files with 460 additions and 118 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,14 +23,11 @@ public struct MainPage<Inner: HTML>: 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"
integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"
crossorigin="anonymous">
</script>
"""
)
script(
.src("https://unpkg.com/htmx-remove@latest"),
.crossorigin(.anonymous),
.integrity("sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT")
) {}
}
public var body: some HTML {