From f3ffdbf41b70d2c979fb5db651d641b3b44f278e Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Mon, 27 Jan 2025 09:01:23 -0500 Subject: [PATCH] feat: Adds reset password views and view routes. --- .../App/Middleware/ViewRoute+middleware.swift | 1 + Sources/SharedModels/Routes/ViewRoute.swift | 29 +++++++++++++++++++ Sources/ViewControllerLive/Routes+view.swift | 19 ++++++++++++ .../Views/Users/UserDetail.swift | 5 +++- .../Views/Users/UserForm.swift | 27 ++++++++++++----- Tests/ApiRouteTests/UserApiRouteTests.swift | 21 ++++++++++++++ .../ViewControllerTests.swift | 25 ++++++++++++++++ .../resetPasswordViews.1.html | 16 ++++++++++ .../resetPasswordViews.2.html | 19 ++++++++++++ .../ViewControllerTests/userViews.4.html | 2 +- 10 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.1.html create mode 100644 Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.2.html diff --git a/Sources/App/Middleware/ViewRoute+middleware.swift b/Sources/App/Middleware/ViewRoute+middleware.swift index 7d74e50..6af795d 100644 --- a/Sources/App/Middleware/ViewRoute+middleware.swift +++ b/Sources/App/Middleware/ViewRoute+middleware.swift @@ -17,6 +17,7 @@ extension SiteRoute.View { var middleware: [any Middleware]? { switch self { case .employee, + .resetPassword, .purchaseOrder, .user, .vendor, diff --git a/Sources/SharedModels/Routes/ViewRoute.swift b/Sources/SharedModels/Routes/ViewRoute.swift index 3c8a5df..7cd2c1a 100644 --- a/Sources/SharedModels/Routes/ViewRoute.swift +++ b/Sources/SharedModels/Routes/ViewRoute.swift @@ -3,11 +3,13 @@ import Foundation @preconcurrency import URLRouting public extension SiteRoute { + // swiftlint:disable type_body_length enum View: Sendable, Equatable { case employee(SiteRoute.View.EmployeeRoute) case login(SiteRoute.View.LoginRoute) case purchaseOrder(SiteRoute.View.PurchaseOrderRoute) + case resetPassword(SiteRoute.View.ResetPasswordRoute) case user(SiteRoute.View.UserRoute) case vendor(SiteRoute.View.VendorRoute) case vendorBranch(SiteRoute.View.VendorBranchRoute) @@ -16,6 +18,7 @@ public extension SiteRoute { Route(.case(Self.employee)) { SiteRoute.View.EmployeeRoute.router } Route(.case(Self.login)) { SiteRoute.View.LoginRoute.router } Route(.case(Self.purchaseOrder)) { SiteRoute.View.PurchaseOrderRoute.router } + Route(.case(Self.resetPassword)) { SiteRoute.View.ResetPasswordRoute.router } Route(.case(Self.user)) { SiteRoute.View.UserRoute.router } Route(.case(Self.vendor)) { SiteRoute.View.VendorRoute.router } Route(.case(Self.vendorBranch)) { SiteRoute.View.VendorBranchRoute.router } @@ -238,6 +241,31 @@ public extension SiteRoute { } } + public enum ResetPasswordRoute: Sendable, Equatable { + case index(id: User.ID) + case submit(id: User.ID, request: User.ResetPassword) + + static let rootPath = "reset-password" + + public static let router = OneOf { + Route(.case(Self.index(id:))) { + Path { rootPath; User.ID.parser() } + Method.get + } + Route(.case(Self.submit(id:request:))) { + Path { rootPath; User.ID.parser() } + Method.patch + Body { + FormData { + Field("password", .string) + Field("confirmPassword", .string) + } + .map(.memberwise(User.ResetPassword.init)) + } + } + } + } + public enum SelectContext: String, Codable, Equatable, Sendable, CaseIterable { case purchaseOrderForm case purchaseOrderSearch @@ -375,4 +403,5 @@ public extension SiteRoute { } } } + // swiftlint:enable type_body_length } diff --git a/Sources/ViewControllerLive/Routes+view.swift b/Sources/ViewControllerLive/Routes+view.swift index 1b6174a..71408a0 100644 --- a/Sources/ViewControllerLive/Routes+view.swift +++ b/Sources/ViewControllerLive/Routes+view.swift @@ -40,6 +40,9 @@ public extension SiteRoute.View { case let .purchaseOrder(route): return try await route.view(isHtmxRequest: isHtmxRequest) + case let .resetPassword(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + case let .user(route): return try await route.view(isHtmxRequest: isHtmxRequest) @@ -180,6 +183,22 @@ extension SiteRoute.View.PurchaseOrderRoute.Search { } } +extension SiteRoute.View.ResetPasswordRoute { + + @Sendable + func view(isHtmxRequest: Bool) async throws -> AnySendableHTML { + @Dependency(\.database) var database + switch self { + case let .index(id: id): + return UserForm(context: .resetPassword(id: id)) + case let .submit(id: id, request: request): + try await database.users.resetPassword(id, request) + let user = try await database.users.get(id) + return UserDetail(user: user) + } + } +} + extension SiteRoute.View.UserRoute { private func mainPage(_ html: C) async throws -> AnySendableHTML where C: Sendable { diff --git a/Sources/ViewControllerLive/Views/Users/UserDetail.swift b/Sources/ViewControllerLive/Views/Users/UserDetail.swift index a1ac3dd..e8fca39 100644 --- a/Sources/ViewControllerLive/Views/Users/UserDetail.swift +++ b/Sources/ViewControllerLive/Views/Users/UserDetail.swift @@ -49,7 +49,10 @@ struct UserDetail: HTML, Sendable { ) // TODO: trigger the reset password route. button( - .class("btn-primary") + .class("btn-primary"), + .hx.target(.id(.float)), + .hx.get(route: .resetPassword(.index(id: user.id))), + .hx.trigger(.event(.click)) ) { "Reset Password" } } } diff --git a/Sources/ViewControllerLive/Views/Users/UserForm.swift b/Sources/ViewControllerLive/Views/Users/UserForm.swift index c36cae7..d1a4e16 100644 --- a/Sources/ViewControllerLive/Views/Users/UserForm.swift +++ b/Sources/ViewControllerLive/Views/Users/UserForm.swift @@ -7,7 +7,7 @@ struct UserForm: HTML, Sendable { let context: Context var content: some HTML { - if context == .create { + if context.isFloat { Float(shouldDisplay: true) { makeForm() } @@ -26,7 +26,7 @@ struct UserForm: HTML, Sendable { .hx.swap(context == .create ? .afterBegin.transition(true).swap("0.5s") : .outerHTML), .hx.on( .afterRequest, - .ifSuccessful(.resetForm, .toggleContent(.float)) + context.toggleContent ? .ifSuccessful(.resetForm, .toggleContent(.float)) : .ifSuccessful(.resetForm) ) ) { if case let .login(next) = context, let next { @@ -70,6 +70,21 @@ struct UserForm: HTML, Sendable { return true } + var isFloat: Bool { + switch self { + case .create, + .resetPassword: + return true + case .login: + return false + } + } + + var toggleContent: Bool { + guard case .resetPassword = self else { return true } + return false + } + var showUsername: Bool { switch self { case .create: return true @@ -120,8 +135,7 @@ struct UserForm: HTML, Sendable { case .login: return .body case .resetPassword: - // FIX: doesn't return anything - return .this + return .id(.float) } } @@ -131,9 +145,8 @@ struct UserForm: HTML, Sendable { return .user(.index) case .login: return .login(.index()) - case .resetPassword: - // FIX: Route. - return .user(.index) + case let .resetPassword(id: id): + return .resetPassword(.index(id: id)) } } } diff --git a/Tests/ApiRouteTests/UserApiRouteTests.swift b/Tests/ApiRouteTests/UserApiRouteTests.swift index 0241715..8d23f3d 100644 --- a/Tests/ApiRouteTests/UserApiRouteTests.swift +++ b/Tests/ApiRouteTests/UserApiRouteTests.swift @@ -65,6 +65,27 @@ struct UserApiRouteTests { #expect(route == .user(.index)) } + @Test + func resetPassword() throws { + let id = UUID(0) + let json = """ + { + \"password\": \"super-secret\", + \"confirmPassword\": \"super-secret\" + } + """ + var request = URLRequestData( + method: "PATCH", + path: "/api/v1/users/\(id)/reset-password", + body: .init(json.utf8) + ) + let route = try router.parse(&request) + #expect(route == .user(.resetPassword( + id: id, + request: .init(password: "super-secret", confirmPassword: "super-secret") + ))) + } + @Test func update() throws { let id = UUID(0) diff --git a/Tests/ViewControllerTests/ViewControllerTests.swift b/Tests/ViewControllerTests/ViewControllerTests.swift index f17d959..d26b3c9 100644 --- a/Tests/ViewControllerTests/ViewControllerTests.swift +++ b/Tests/ViewControllerTests/ViewControllerTests.swift @@ -101,6 +101,25 @@ struct ViewControllerTests { } } + @Test + func resetPasswordViews() async throws { + try await withSnapshotTesting(record: record) { + try await withDependencies { + $0.dateFormatter = .mock + $0.database.users = .mock + $0.viewController = .liveValue + } operation: { + @Dependency(\.viewController) var viewController + + var htmlString = try await viewController.render(.resetPassword(.index(id: UUID(0)))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.resetPassword(.submit(id: UUID(0), request: .mock))) + assertSnapshot(of: htmlString, as: .html) + } + } + } + @Test func userViews() async throws { try await withSnapshotTesting(record: record) { @@ -408,3 +427,9 @@ extension PurchaseOrder.Create { ) } } + +extension User.ResetPassword { + static var mock: Self { + .init(password: "super-secret", confirmPassword: "super-secret") + } +} diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.1.html new file mode 100644 index 0000000..8bda856 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.1.html @@ -0,0 +1,16 @@ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.2.html new file mode 100644 index 0000000..3e42f83 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/resetPasswordViews.2.html @@ -0,0 +1,19 @@ +
+
+ +
+
+
+ + + Email: + +
+
Created:01/31/2025Updated:01/31/2025
+
+ + + +
+
+
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html index 9c822d6..3e42f83 100644 --- a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html @@ -13,7 +13,7 @@
- +
\ No newline at end of file