diff --git a/Public/css/output.css b/Public/css/output.css index 6602dd3..e3a0c32 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -6739,6 +6739,9 @@ .justify-center { justify-content: center; } + .justify-end { + justify-content: flex-end; + } .gap-4 { gap: calc(var(--spacing) * 4); } @@ -6770,6 +6773,13 @@ margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); } } + .space-x-6 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 6) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse))); + } + } .overflow-x-auto { overflow-x: auto; } diff --git a/Sources/DatabaseClient/Projects.swift b/Sources/DatabaseClient/Projects.swift index b2ee243..43aa9ce 100644 --- a/Sources/DatabaseClient/Projects.swift +++ b/Sources/DatabaseClient/Projects.swift @@ -11,6 +11,7 @@ extension DatabaseClient { public var delete: @Sendable (Project.ID) async throws -> Void public var get: @Sendable (Project.ID) async throws -> Project? public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page + public var update: @Sendable (Project.Update) async throws -> Project } } @@ -40,6 +41,16 @@ extension DatabaseClient.Projects: TestDependencyKey { .filter(\.$user.$id == userID) .paginate(request) .map { try $0.toDTO() } + }, + update: { updates in + guard let model = try await ProjectModel.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() } ) } @@ -78,6 +89,37 @@ extension Project.Create { } } +extension Project.Update { + + func validate() throws(ValidationError) { + if let name { + guard !name.isEmpty else { + throw ValidationError("Project name should not be empty.") + } + } + if let streetAddress { + guard !streetAddress.isEmpty else { + throw ValidationError("Project street address should not be empty.") + } + } + if let city { + guard !city.isEmpty else { + throw ValidationError("Project city should not be empty.") + } + } + if let state { + guard !state.isEmpty else { + throw ValidationError("Project state should not be empty.") + } + } + if let zipCode { + guard !zipCode.isEmpty else { + throw ValidationError("Project zipCode should not be empty.") + } + } + } +} + extension Project { struct Migrate: AsyncMigration { let name = "CreateProject" @@ -155,7 +197,6 @@ final class ProjectModel: Model, @unchecked Sendable { self.name = name self.streetAddress = streetAddress self.city = city - self.city = city self.state = state self.zipCode = zipCode $user.id = userID @@ -175,4 +216,29 @@ final class ProjectModel: Model, @unchecked Sendable { updatedAt: updatedAt! ) } + + func applyUpdates(_ updates: Project.Update) -> Bool { + var hasUpdates = false + if let name = updates.name, name != self.name { + hasUpdates = true + self.name = name + } + if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress { + hasUpdates = true + self.streetAddress = streetAddress + } + if let city = updates.city, city != self.city { + hasUpdates = true + self.city = city + } + if let state = updates.state, state != self.state { + hasUpdates = true + self.state = state + } + if let zipCode = updates.zipCode, zipCode != self.zipCode { + hasUpdates = true + self.zipCode = zipCode + } + return hasUpdates + } } diff --git a/Sources/ManualDCore/Project.swift b/Sources/ManualDCore/Project.swift index 225ce3d..d3b11c4 100644 --- a/Sources/ManualDCore/Project.swift +++ b/Sources/ManualDCore/Project.swift @@ -57,6 +57,32 @@ extension Project { self.zipCode = zipCode } } + + public struct Update: Codable, Equatable, Sendable { + + public let id: Project.ID + public let name: String? + public let streetAddress: String? + public let city: String? + public let state: String? + public let zipCode: String? + + public init( + id: Project.ID, + name: String? = nil, + streetAddress: String? = nil, + city: String? = nil, + state: String? = nil, + zipCode: String? = nil + ) { + self.id = id + self.name = name + self.streetAddress = streetAddress + self.city = city + self.state = state + self.zipCode = zipCode + } + } } #if DEBUG diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index 67f7b75..c9736fd 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -35,9 +35,10 @@ extension SiteRoute.View { case create(Project.Create) case delete(id: Project.ID) case detail(Project.ID, DetailRoute) - case form(dismiss: Bool = false) + case form(id: Project.ID? = nil, dismiss: Bool = false) case index case page(PageRequest) + case update(Project.Update) public static func page(page: Int, per limit: Int) -> Self { .page(.init(page: page, per: limit)) @@ -81,6 +82,9 @@ extension SiteRoute.View { } Method.get Query { + Optionally { + Field("id", default: nil) { Project.ID.parser() } + } Field("dismiss", default: false) { Bool.parser() } } } @@ -100,6 +104,31 @@ extension SiteRoute.View { } .map(.memberwise(PageRequest.init)) } + Route(.case(Self.update)) { + Path { rootPath } + Method.patch + Body { + FormData { + Field("id") { Project.ID.parser() } + Optionally { + Field("name", .string) + } + Optionally { + Field("streetAddress", .string) + } + Optionally { + Field("city", .string) + } + Optionally { + Field("state", .string) + } + Optionally { + Field("zipCode", .string) + } + } + .map(.memberwise(Project.Update.init)) + } + } } } } diff --git a/Sources/Styleguide/Buttons.swift b/Sources/Styleguide/Buttons.swift index 57e0163..457187b 100644 --- a/Sources/Styleguide/Buttons.swift +++ b/Sources/Styleguide/Buttons.swift @@ -65,14 +65,7 @@ public struct EditButton: HTML, Sendable { } public var body: some HTML { - button( - .class( - """ - btn btn-primary - """ - ), - .type(type) - ) { + button(.class("btn btn-success dark:text-white"), .type(type)) { div(.class("flex")) { if let title { span(.class("pe-2")) { title } diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 4b2d427..bf13087 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -85,8 +85,16 @@ extension SiteRoute.View.ProjectRoute { let projects = try await database.projects.fetch(user.id, page) return ProjectsTable(userID: user.id, projects: projects) - case .form(let dismiss): - return ProjectForm(dismiss: dismiss) + case .form(let id, let dismiss): + request.logger.debug("Project form: \(id != nil ? "Fetching project for: \(id!)" : "N/A")") + var project: Project? = nil + if let id, dismiss == false { + project = try await database.projects.get(id) + } + request.logger.debug( + project == nil ? "No project found" : "Showing form for existing project" + ) + return ProjectForm(dismiss: dismiss, project: project) case .create(let form): let project = try await database.projects.create(user.id, form) @@ -97,11 +105,16 @@ extension SiteRoute.View.ProjectRoute { try await database.projects.delete(id) return EmptyHTML() + case .update(let form): + let project = try await database.projects.update(form) + return ProjectView(projectID: project.id, activeTab: .project) + case .detail(let projectID, let route): switch route { case .index(let tab): - return ProjectView(projectID: projectID, activeTab: tab) - // return try await defaultDetailView(projectID: projectID, activeTab: tab) + return request.view { + ProjectView(projectID: projectID, activeTab: tab) + } case .equipment(let route): return try await route.renderView(on: request, projectID: projectID) case .frictionRate(let route): diff --git a/Sources/ViewController/Views/Project/ProjectDetail.swift b/Sources/ViewController/Views/Project/ProjectDetail.swift index b116a2e..44458a9 100644 --- a/Sources/ViewController/Views/Project/ProjectDetail.swift +++ b/Sources/ViewController/Views/Project/ProjectDetail.swift @@ -18,7 +18,7 @@ struct ProjectDetail: HTML, Sendable { h1(.class("text-2xl font-bold")) { "Project" } EditButton() .attributes( - .hx.get(route: .project(.form(dismiss: false))), + .hx.get(route: .project(.form(id: project.id, dismiss: false))), .hx.target("#projectForm"), .hx.swap(.outerHTML) ) @@ -54,6 +54,6 @@ struct ProjectDetail: HTML, Sendable { } } - div(.id("projectForm")) {} + ProjectForm(dismiss: true) } } diff --git a/Sources/ViewController/Views/Project/ProjectForm.swift b/Sources/ViewController/Views/Project/ProjectForm.swift index a5c656e..a328619 100644 --- a/Sources/ViewController/Views/Project/ProjectForm.swift +++ b/Sources/ViewController/Views/Project/ProjectForm.swift @@ -21,9 +21,16 @@ struct ProjectForm: HTML, Sendable { h1(.class("text-3xl font-bold pb-6 ps-2")) { "Project" } form( .class("space-y-4 p-4"), - .method(.post), - .action(route: .project(.index)) + project == nil + ? .hx.post(route: .project(.index)) + : .hx.patch(route: .project(.index)), + .hx.target("body"), + .hx.swap(.outerHTML) ) { + if let project { + input(.class("hidden"), .name("id"), .value("\(project.id)")) + } + div { label(.for("name")) { "Name" } Input(id: "name", placeholder: "Name") @@ -32,35 +39,32 @@ struct ProjectForm: HTML, Sendable { div { label(.for("streetAddress")) { "Address" } Input(id: "streetAddress", placeholder: "Street Address") - .attributes(.type(.text), .required) + .attributes(.type(.text), .required, .value(project?.streetAddress)) } div { label(.for("city")) { "City" } Input(id: "city", placeholder: "City") - .attributes(.type(.text), .required) + .attributes(.type(.text), .required, .value(project?.city)) } div { label(.for("state")) { "State" } Input(id: "state", placeholder: "State") - .attributes(.type(.text), .required) + .attributes(.type(.text), .required, .value(project?.state)) } div { label(.for("zipCode")) { "Zip" } Input(id: "zipCode", placeholder: "Zip code") - .attributes(.type(.text), .required) + .attributes(.type(.text), .required, .value(project?.zipCode)) } - Row { - div {} - div(.class("space-x-4")) { - CancelButton() - .attributes( - .hx.get(route: .project(.form(dismiss: true))), - .hx.target("#projectForm"), - .hx.swap(.outerHTML) - ) - SubmitButton() - } + div(.class("flex justify-end space-x-6")) { + CancelButton() + .attributes( + .hx.get(route: .project(.form(dismiss: true))), + .hx.target("#projectForm"), + .hx.swap(.outerHTML) + ) + SubmitButton() } } } diff --git a/Sources/ViewController/Views/Rooms/RoomsView.swift b/Sources/ViewController/Views/Rooms/RoomsView.swift index 0f7abc4..9284679 100644 --- a/Sources/ViewController/Views/Rooms/RoomsView.swift +++ b/Sources/ViewController/Views/Rooms/RoomsView.swift @@ -61,6 +61,7 @@ struct RoomsView: HTML, Sendable { .class("badge badge-outline badge-success badge-xl")) } td {} + td {} } } } @@ -87,10 +88,11 @@ struct RoomsView: HTML, Sendable { Number(room.registerCount) } td { - Row { + div(.class("flex justify-end space-x-6")) { TrashButton() .attributes( - .hx.delete(route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))), + .hx.delete( + route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))), .hx.target("closest tr"), .hx.confirm("Are you sure?") )