diff --git a/Public/css/main.css b/Public/css/main.css index a93cf40..df2969c 100644 --- a/Public/css/main.css +++ b/Public/css/main.css @@ -23,6 +23,30 @@ header { border-bottom: 1px solid grey; } +.col-1 {width: 8.33%;} +.col-2 {width: 16.66%;} +.col-3 {width: 25%;} +.col-4 {width: 33.33%;} +.col-5 {width: 41.66%;} +.col-6 {width: 50%;} +.col-7 {width: 58.33%;} +.col-8 {width: 66.66%;} +.col-9 {width: 75%;} +.col-10 {width: 83.33%;} +.col-11 {width: 91.66%;} +.col-12 {width: 100%;} + +[class*="col-"] { + float: left; + padding: 15px; +} + +.row::after { + content: ""; + clear: both; + display: table; +} + #logo { float: left; font-size: 1.5em; @@ -101,6 +125,7 @@ input[type=text], input[type=password], input[type=email], input[type=number] { border: none; border-bottom: 2px solid #555; padding: 5px; + font-size: 1.2em; } select { @@ -401,35 +426,25 @@ button.edit { opacity: 0.8; } -.row { - display: flex; - margin: 10px 0; - width: 100%; -} - -.row .container { - display: inline; -} - .input { display: inline; width: 100%; } .row .input { - display: inline; border: none; } .row label { - display: inline-block; - width: 400px; - margin-right: 20px; + display: inline; } -.htmx-swapping { - opacity: 0; - transition: opacity 1s ease-in-out; +.row .label { + font-size: 1.25em; +} + +.row .date { + font-size: 1.25em; } #employee-detail form input[type=text] { @@ -453,6 +468,11 @@ button.edit { display: inline-block; } +.htmx-swapping { + opacity: 0; + transition: opacity 1s ease-in-out; +} + .htmx-indicator { display: none; } @@ -465,14 +485,28 @@ button.edit { display: inline; } -.btn-detail { +.btn-detail, .btn-add, .btn-close { border: none; color: grey; - text-decoration: .none; + text-decoration: none; + background-color: inherit; +} + +.btn-detail { margin-left: 10px; } +button:hover { + background-color: #444; + opacity: 0.8; +} + .btn-detail:hover { background-color: #444; opacity: 0.8; } + +.btn-close { + float: right; + font-size: 1.5em; +} diff --git a/Sources/App/Controllers/View/UsersViewController.swift b/Sources/App/Controllers/View/UsersViewController.swift index 4cb2017..a2c8b7e 100644 --- a/Sources/App/Controllers/View/UsersViewController.swift +++ b/Sources/App/Controllers/View/UsersViewController.swift @@ -13,27 +13,68 @@ struct UserViewController: RouteCollection { // let users = routes.protected.grouped("users") let users = routes.grouped("users") users.get(use: index) + users.post(use: create) + users.get("create", use: form) users.group(":id") { + $0.post(use: update) $0.get(use: get) + $0.delete(use: delete) } } @Sendable func index(req: Request) async throws -> HTMLResponse { - HTMLResponse { - MainPage(route: .users) { - div(.class("container")) { - UserDetail(user: nil) - UserTable() - } - } + try await req.render { + try await mainPage(UserDetail(user: nil)) } } @Sendable func get(req: Request) async throws -> HTMLResponse { let user = try await users.get(req.ensureIDPathComponent()) - return HTMLResponse { UserDetail(user: user) } + let detail = UserDetail(user: user) + + guard req.isHtmxRequest else { + return try await req.render { try await mainPage(detail) } + } + return await req.render { UserDetail(user: user) } + } + + @Sendable + func create(req: Request) async throws -> HTMLResponse { + _ = try await users.create(req.content.decode(User.Create.self)) + let users = try await users.fetchAll() + // return req.redirect(to: "/users") + return await req.render { UserTable(users: users) } + } + + @Sendable + func delete(req: Request) async throws -> HTTPStatus { + try await users.delete(req.ensureIDPathComponent()) + return .ok + } + + @Sendable + func form(req: Request) async throws -> HTMLResponse { + await req.render { UserForm(context: .create) } + } + + @Sendable + func update(req: Request) async throws -> HTMLResponse { + let updates = try req.content.decode(User.Update.self) + req.logger.info("\(updates)") + let user = try await users.update(req.ensureIDPathComponent(), updates) + return await req.render { UserTable.Row(user: user) } + } + + private func mainPage(_ html: C) async throws -> some SendableHTMLDocument where C: Sendable { + let users = try await users.fetchAll() + return MainPage(displayNav: true, route: .users) { + div(.class("container")) { + html + UserTable(users: users) + } + } } } diff --git a/Sources/App/Extensions/Request+extensions.swift b/Sources/App/Extensions/Request+extensions.swift index f08fda9..c196a7a 100644 --- a/Sources/App/Extensions/Request+extensions.swift +++ b/Sources/App/Extensions/Request+extensions.swift @@ -1,4 +1,6 @@ +import Elementary import Vapor +import VaporElementary extension Request { func ensureValidContent(_ decoding: T.Type) throws -> T where T: Content, T: Validatable { @@ -19,4 +21,11 @@ extension Request { var isHtmxRequest: Bool { headers.contains(name: "hx-request") } + + func render( + @HTMLBuilder html: () async throws -> C + ) async rethrows -> HTMLResponse where C: Sendable { + let html = try await html() + return HTMLResponse { html } + } } diff --git a/Sources/App/Views/Buttons.swift b/Sources/App/Views/Buttons.swift index 6a2603e..9c18649 100644 --- a/Sources/App/Views/Buttons.swift +++ b/Sources/App/Views/Buttons.swift @@ -10,11 +10,29 @@ struct ToggleFormButton: HTML { enum Button { + static func add() -> some HTML { + button(.class("btn-add")) { "+" } + } + static func danger(@HTMLBuilder body: () -> C) -> some HTML { button(.class("danger")) { body() } } - static func close(id: String) -> some HTML { - button(.class("btn-add"), .on(.click, "toggleContent('\(id)')")) { "x" } + static func close(id: String, resetURL: String? = nil) -> some HTML { + button(.class("btn-close"), .on(.click, makeOnClick(id, resetURL))) { + "x" + } + } + + static func update() -> some HTML { + button(.class("btn-update")) { "Update" } + } + + private static func makeOnClick(_ id: String, _ resetURL: String?) -> String { + var output = "toggleContent('\(id)');" + if let resetURL { + return "\(output) window.location.href='\(resetURL)';" + } + return output } } diff --git a/Sources/App/Views/Float.swift b/Sources/App/Views/Float.swift index 883a3db..2d4963b 100644 --- a/Sources/App/Views/Float.swift +++ b/Sources/App/Views/Float.swift @@ -3,21 +3,43 @@ import Elementary struct Float: HTML { let id: String + let shouldDisplay: Bool let body: C? + let resetURL: String? init(id: String = "float") { self.id = id + self.shouldDisplay = false + self.resetURL = nil self.body = nil } - init(id: String = "float", @HTMLBuilder body: () -> C) { + init( + id: String = "float", + shouldDisplay: Bool, + resetURL: String? = nil, + @HTMLBuilder body: () -> C + ) { self.id = id + self.shouldDisplay = shouldDisplay + self.resetURL = resetURL self.body = body() } - var content: some HTML { - div(.id(id), .class("float")) { - if let body { + private var classString: String { + shouldDisplay ? "float" : "" + } + + private var display: String { + shouldDisplay ? "block" : "hidden" + } + + var content: some HTML { + div(.id(id), .class(classString), .style("display: \(display);")) { + if let body, shouldDisplay { + div(.class("btn-row")) { + Button.close(id: id, resetURL: resetURL) + } body } } diff --git a/Sources/App/Views/Main.swift b/Sources/App/Views/Main.swift index e324bd6..7f30af3 100644 --- a/Sources/App/Views/Main.swift +++ b/Sources/App/Views/Main.swift @@ -1,7 +1,7 @@ import Elementary import ElementaryHTMX -struct MainPage: HTMLDocument { +struct MainPage: SendableHTMLDocument where Inner: Sendable { var title: String { "Purchase Orders" } @@ -38,7 +38,29 @@ struct MainPage: HTMLDocument { } } -extension MainPage: Sendable where Inner: Sendable {} +struct RouteHeaderView: HTML { + + let title: String + let description: String + + init(title: String, description: String) { + self.title = title + self.description = description + } + + init(route: ViewRoute) { + self.init(title: route.title, description: route.description) + } + + var content: some HTML { + div(.class("container"), .style("padding: 20px 20px;")) { + h1 { title } + br() + p { description } + br() + } + } +} struct Logo: HTML, Sendable { @@ -48,3 +70,5 @@ struct Logo: HTML, Sendable { } } } + +protocol SendableHTMLDocument: HTMLDocument, Sendable {} diff --git a/Sources/App/Views/Navbar.swift b/Sources/App/Views/Navbar.swift index f6e9bd7..d3b0281 100644 --- a/Sources/App/Views/Navbar.swift +++ b/Sources/App/Views/Navbar.swift @@ -1,7 +1,7 @@ import Elementary import ElementaryHTMX -struct Navbar: HTML { +struct Navbar: HTML, Sendable { var content: some HTML { div(.class("sidepanel"), .id("sidepanel")) { a(.href("javascript:void(0)"), .class("closebtn"), .on(.click, "closeSidepanel()")) { diff --git a/Sources/App/Views/RouteHeaderView.swift b/Sources/App/Views/RouteHeaderView.swift deleted file mode 100644 index 2fa27a7..0000000 --- a/Sources/App/Views/RouteHeaderView.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Elementary -import ElementaryHTMX - -struct RouteHeaderView: HTML { - - let title: String - let description: String - - init(title: String, description: String) { - self.title = title - self.description = description - } - - init(route: ViewRoute) { - self.init(title: route.title, description: route.description) - } - - var content: some HTML { - div(.class("container"), .style("padding: 20px 20px;")) { - h1 { title } - br() - p { description } - br() - } - } -} diff --git a/Sources/App/Views/Users/UserDetail.swift b/Sources/App/Views/Users/UserDetail.swift index 511d8cc..de6fe26 100644 --- a/Sources/App/Views/Users/UserDetail.swift +++ b/Sources/App/Views/Users/UserDetail.swift @@ -8,42 +8,41 @@ struct UserDetail: HTML, Sendable { let user: User? - var classString: String { - user != nil ? "float" : "" - } - - var display: String { - user != nil ? "block" : "hidden" - } - var content: some HTML { - div( - .id("float"), - .class(classString), - .style("display: \(display);") - ) { + Float(shouldDisplay: user != nil, resetURL: "/users") { if let user { - Button.close(id: "float") - form { + form( + .hx.post("/users/\(user.id)"), + .hx.swap(.outerHTML), + .hx.target("#user_\(user.id)"), + .custom(name: "hx-on::after-request", value: "toggleContent('float'); window.location.href='/users';") + ) { div(.class("row")) { makeLabel(for: "username", value: "Username:") - input(.type(.text), .name("username"), .value(user.username)) + input(.class("col-5"), .type(.text), .id("username"), .name("username"), .value(user.username), .required) makeLabel(for: "email", value: "Email:") - input(.type(.email), .name("email"), .value(user.username)) + input(.class("col-5"), .type(.email), .id("email"), .name("email"), .value(user.email), .required) } div(.class("row")) { - div(.style("display: inline-block;")) { - h3(.class("label")) { "Created:" } - h3 { dateFormatter.formattedDate(user.createdAt) } - } - div(.style("display: inline-block;")) { - h3(.class("label")) { "Updated:" } - h3 { dateFormatter.formattedDate(user.updatedAt) } - } + span(.class("label col-1")) { "Created:" } + span(.class("date col-4")) { dateFormatter.formattedDate(user.createdAt) } + span(.class("label col-1")) { "Updated:" } + span(.class("date col-4")) { dateFormatter.formattedDate(user.updatedAt) } + } + div(.class("btn-row user-buttons")) { + button( + .type(.submit), + .style("background-color: blue; color: white;") + ) { "Update" } + Button.danger { "Delete" } + .attributes( + .hx.delete("/users/\(user.id)"), + .hx.trigger(.event(.click)), + .hx.swap(.outerHTML), + .hx.target("#user_\(user.id)"), + .custom(name: "hx-on::after-request", value: "toggleContent('float'); window.location.href='/users';") + ) } - } - div(.class("btn-row user-buttons")) { - Button.danger { "Delete" } } } } @@ -53,7 +52,7 @@ struct UserDetail: HTML, Sendable { for name: String, value: String ) -> some HTML { - label(.for(name)) { h3 { value } } + label(.for(name), .class("col-1")) { span(.class("label")) { value } } } func row(_ label: String, _ value: String) -> some HTML { diff --git a/Sources/App/Views/Users/UserForm.swift b/Sources/App/Views/Users/UserForm.swift index ac31506..0d73bda 100644 --- a/Sources/App/Views/Users/UserForm.swift +++ b/Sources/App/Views/Users/UserForm.swift @@ -5,12 +5,27 @@ struct UserForm: HTML, Sendable { let context: Context var content: some HTML { + if context == .create { + Float(shouldDisplay: true) { + makeForm() + } + } else { + makeForm() + } + } + + private func makeForm() -> some HTML { form( .id("user-form"), .class("user-form"), .hx.post(context.targetURL), .hx.pushURL(context.pushURL), - .custom(name: "hx-on::after-request", value: "if(event.detail.successful) this.reset(); toggleContent('form');") + .hx.target(context.target), + .hx.swap(.outerHTML), + .custom( + name: "hx-on::after-request", + value: "if(event.detail.successful) this.reset(); toggleContent('float');" + ) ) { input(.type(.text), .id("username"), .name("username"), .placeholder("Username"), .autofocus, .required) br() @@ -21,14 +36,20 @@ struct UserForm: HTML, Sendable { input(.type(.password), .id("password"), .name("password"), .placeholder("Password"), .required) br() if context.showConfirmPassword { - input(.type(.password), .id("confirmPassword"), .name("confirmPassword"), .required) + input( + .type(.password), + .id("confirmPassword"), + .name("confirmPassword"), + .placeholder("Confirm Password"), + .required + ) br() } input(.type(.submit), .value(context.buttonLabel)) } } - enum Context { + enum Context: Equatable { case create case login(next: String?) @@ -62,6 +83,15 @@ struct UserForm: HTML, Sendable { } } + var target: String { + switch self { + case .create: + return "next table" + case .login: + return "body" + } + } + var targetURL: String { switch self { case .create: diff --git a/Sources/App/Views/Users/UserIndex.swift b/Sources/App/Views/Users/UserIndex.swift deleted file mode 100644 index 86e4dc1..0000000 --- a/Sources/App/Views/Users/UserIndex.swift +++ /dev/null @@ -1,19 +0,0 @@ -import DatabaseClient -import Elementary -import SharedModels - -struct UserIndex: HTML { - let user: User? - - init(user: User? = nil) { - self.user = user - } - - var content: some HTML { - div { - // UserDetail(user: user) - div(.id("float"), .class("float")) {} - UserTable() - } - } -} diff --git a/Sources/App/Views/Users/UserTable.swift b/Sources/App/Views/Users/UserTable.swift index 7d2e076..5f8f85f 100644 --- a/Sources/App/Views/Users/UserTable.swift +++ b/Sources/App/Views/Users/UserTable.swift @@ -6,7 +6,7 @@ import SharedModels struct UserTable: HTML { - @Dependency(\.database.users.fetchAll) var fetchAll + let users: [User] var content: some HTML { table(.id("user-table")) { @@ -14,11 +14,17 @@ struct UserTable: HTML { tr { th { "Username" } th { "Email" } - th(.style("width: 50px;")) { ToggleFormButton() } + th(.style("width: 50px;")) { + Button.add() + .attributes( + .hx.get("/users/create"), + .hx.target("#float"), + .hx.swap(.outerHTML) + ) + } } } - tbody { - let users = try await fetchAll() + tbody(.id("user-table-body")) { for user in users { Row(user: user) } @@ -29,8 +35,12 @@ struct UserTable: HTML { struct Row: HTML { let user: User + init(user: User) { + self.user = user + } + var content: some HTML { - tr { + tr(.id("user_\(user.id)")) { td { user.username } td { user.email } td { @@ -38,6 +48,7 @@ struct UserTable: HTML { .hx.get("/users/\(user.id.uuidString)"), .hx.target("#float"), .hx.swap(.outerHTML), + .hx.pushURL(true), .class("btn-detail") ) { "〉" diff --git a/Sources/DatabaseClient/Users.swift b/Sources/DatabaseClient/Users.swift index fa9e3b1..242feff 100644 --- a/Sources/DatabaseClient/Users.swift +++ b/Sources/DatabaseClient/Users.swift @@ -16,12 +16,14 @@ public extension DatabaseClient { public var login: @Sendable (User.Login) async throws -> User.Token public var logout: @Sendable (User.Token.ID) async throws -> Void public var token: @Sendable (User.ID) async throws -> User.Token + public var update: @Sendable (User.ID, User.Update) async throws -> User } } extension User: Content {} extension User.Create: Content {} extension User.Token: Content {} +extension User.Update: Content {} extension DatabaseClient.Users: TestDependencyKey { public static let testValue: DatabaseClient.Users = Self() diff --git a/Sources/DatabaseClientLive/Users.swift b/Sources/DatabaseClientLive/Users.swift index 40a923d..73f1265 100644 --- a/Sources/DatabaseClientLive/Users.swift +++ b/Sources/DatabaseClientLive/Users.swift @@ -66,6 +66,23 @@ public extension DatabaseClient.Users { } return try token.toDTO() + } update: { id, updates in + guard let user = try await UserModel.find(id, on: database) else { + throw Abort(.notFound) + } + var hasChanges = false + if let username = updates.username { + hasChanges = true + user.username = username + } + if let email = updates.email { + hasChanges = true + user.email = email + } + + guard hasChanges else { return try user.toDTO() } + try await user.save(on: database) + return try user.toDTO() } } } diff --git a/Sources/SharedModels/User.swift b/Sources/SharedModels/User.swift index f1a2c31..8255a7c 100644 --- a/Sources/SharedModels/User.swift +++ b/Sources/SharedModels/User.swift @@ -77,6 +77,11 @@ public extension User { } } + struct Update: Codable, Equatable, Sendable { + public let username: String? + public let email: String? + } + } // public extension User {