diff --git a/Package.swift b/Package.swift index d6a63cc..47c2296 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,9 @@ let package = Package( .library(name: "SharedModels", targets: ["SharedModels"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "DatabaseClientLive", targets: ["DatabaseClientLive"]), - .library(name: "HtmlSnapshotTesting", targets: ["HtmlSnapshotTesting"]) + .library(name: "HtmlSnapshotTesting", targets: ["HtmlSnapshotTesting"]), + .library(name: "ViewController", targets: ["ViewController"]), + .library(name: "ViewControllerLive", targets: ["ViewControllerLive"]) ], dependencies: [ // 💧 A server-side Swift web framework. @@ -127,6 +129,37 @@ let package = Package( .product(name: "CasePaths", package: "swift-case-paths") ], swiftSettings: swiftSettings + ), + .target( + name: "ViewController", + dependencies: [ + .target(name: "SharedModels"), + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + .product(name: "Elementary", package: "elementary") + ], + swiftSettings: swiftSettings + ), + .target( + name: "ViewControllerLive", + dependencies: [ + .target(name: "DatabaseClient"), + .target(name: "ViewController"), + .product(name: "ElementaryHTMX", package: "elementary-htmx"), + .product(name: "Vapor", package: "vapor") + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ViewControllerTests", + dependencies: [ + .target(name: "ViewControllerLive"), + .target(name: "HtmlSnapshotTesting") + ], + resources: [ + .copy("__Snapshots__") + ], + swiftSettings: swiftSettings ) ], swiftLanguageModes: [.v5] diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift new file mode 100644 index 0000000..fd888ff --- /dev/null +++ b/Sources/App/routes.swift @@ -0,0 +1,4 @@ +import Dependencies +import Elementary +import SharedModels +@_exported import ViewController diff --git a/Sources/SharedModels/Routes/ViewRoute.swift b/Sources/SharedModels/Routes/ViewRoute.swift index 610db3e..cd415cc 100644 --- a/Sources/SharedModels/Routes/ViewRoute.swift +++ b/Sources/SharedModels/Routes/ViewRoute.swift @@ -2,6 +2,7 @@ import CasePathsCore import Foundation @preconcurrency import URLRouting +// TODO: Remove `delete` routes from views and use api routes. public enum ViewRoute: Sendable, Equatable { case index diff --git a/Sources/ViewController/ViewController.swift b/Sources/ViewController/ViewController.swift new file mode 100644 index 0000000..df3d244 --- /dev/null +++ b/Sources/ViewController/ViewController.swift @@ -0,0 +1,31 @@ +import Dependencies +import DependenciesMacros +import Elementary +import SharedModels + +public extension DependencyValues { + var viewController: ViewController { + get { self[ViewController.self] } + set { self[ViewController.self] = newValue } + } +} + +@DependencyClient +public struct ViewController: Sendable { + public typealias AuthenticateHandler = @Sendable (User) -> Void + + public var view: @Sendable (ViewRoute, Bool, @escaping AuthenticateHandler) async throws -> (any HTML)? + + @Sendable + public func view( + for route: ViewRoute, + isHtmxRequest: Bool, + authenticate: @escaping AuthenticateHandler + ) async throws -> (any HTML)? { + try await view(route, isHtmxRequest, authenticate) + } +} + +extension ViewController: TestDependencyKey { + public static let testValue: ViewController = Self() +} diff --git a/Sources/ViewControllerLive/Dependencies/DateFormatter.swift b/Sources/ViewControllerLive/Dependencies/DateFormatter.swift new file mode 100644 index 0000000..bb96c8e --- /dev/null +++ b/Sources/ViewControllerLive/Dependencies/DateFormatter.swift @@ -0,0 +1,28 @@ +import Dependencies +import Foundation + +public extension DependencyValues { + var dateFormatter: DateFormatter { + get { self[DateFormatter.self] } + set { self[DateFormatter.self] = newValue } + } +} + +#if hasFeature(RetroactiveAttribute) + extension DateFormatter: @retroactive DependencyKey { + + public static var liveValue: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + } + } +#else + extension DateFormatter: DependencyKey { + public static var liveValue: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + } + } +#endif diff --git a/Sources/ViewControllerLive/Live.swift b/Sources/ViewControllerLive/Live.swift new file mode 100644 index 0000000..b8448b7 --- /dev/null +++ b/Sources/ViewControllerLive/Live.swift @@ -0,0 +1,12 @@ +import Dependencies +import Elementary +import SharedModels +@_exported import ViewController + +extension ViewController: DependencyKey { + public static var liveValue: ViewController { + .init(view: { route, isHtmxRequest, authenticate in + try await route.view(isHtmxRequest: isHtmxRequest, authenticate: authenticate) + }) + } +} diff --git a/Sources/ViewControllerLive/Routes+view.swift b/Sources/ViewControllerLive/Routes+view.swift new file mode 100644 index 0000000..10146f6 --- /dev/null +++ b/Sources/ViewControllerLive/Routes+view.swift @@ -0,0 +1,366 @@ +import DatabaseClient +import Dependencies +import Elementary +import SharedModels +import Vapor + +extension SharedModels.ViewRoute { + + func view(isHtmxRequest: Bool, authenticate: @escaping @Sendable (User) -> Void) async throws -> (any HTML)? { + @Dependency(\.database.users) var users + switch self { + case .index: + // return request.redirect(to: Self.router.path(for: .purchaseOrder(.index))) + return nil + + case let .employee(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + + case let .login(route): + switch route { + case let .index(next: next): + return MainPage(displayNav: false, route: .login) { + UserForm(context: .login(next: next)) + } + case let .post(login): + let token = try await users.login(.init(username: login.username, password: login.password)) + let user = try await users.get(token.userID)! + authenticate(user) + // request.logger.info("Logged in next: \(login.next ?? "N/A")") + return MainPage.loggedIn(next: login.next) + } + + case let .purchaseOrder(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + + case let .user(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + + case let .vendor(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + + case let .vendorBranch(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + } + } +} + +extension SharedModels.ViewRoute.EmployeeRoute { + + private func mainPage( + _ html: C + ) async throws -> some SendableHTMLDocument where C: Sendable { + @Dependency(\.database) var database + let employees = try await database.employees.fetchAll() + return MainPage(displayNav: true, route: .employees) { + div(.class("container")) { + html + EmployeeTable(employees: employees) + } + } + } + + func view(isHtmxRequest: Bool) async throws -> (any HTML)? { + @Dependency(\.database.employees) var employees + + switch self { + case .form: + return try await render(mainPage, isHtmxRequest, EmployeeForm(shouldShow: true)) + + case let .select(context: context): + return try await context.toHTML(employees: employees.fetchAll()) + + case .index: + return try await mainPage(EmployeeForm()) + + case let .get(id: id): + guard let employee = try await employees.get(id) else { + throw Abort(.badRequest, reason: "Employee id not found.") + } + return try await render(mainPage, isHtmxRequest, EmployeeForm(employee: employee)) + // return try await request.render(mainPage: mainPage) { + // EmployeeForm(employee: employee) + // } + + case let .create(employee): + return try await EmployeeTable.Row(employee: employees.create(employee)) + + case let .delete(id: id): + try await employees.delete(id) + return nil + + case let .update(id: id, updates: updates): + return try await EmployeeTable.Row(employee: employees.update(id, updates)) + } + } + +} + +extension SharedModels.ViewRoute.PurchaseOrderRoute { + private func mainPage( + _ html: C + ) async throws -> some SendableHTMLDocument where C: Sendable { + @Dependency(\.database.purchaseOrders) var purchaseOrders + let page = try await purchaseOrders.fetchPage(.init(page: 1, per: 25)) + return MainPage(displayNav: true, route: .purchaseOrders) { + div(.class("container"), .id("purchase-order-content")) { + html + PurchaseOrderTable(page: page) + } + } + } + + func view(isHtmxRequest: Bool) async throws -> (any HTML)? { + @Dependency(\.database.purchaseOrders) var purchaseOrders + + switch self { + case .form: + return try await render(mainPage, isHtmxRequest) { + PurchaseOrderForm(shouldShow: true) + } + + case let .search(route): + return try await route.view(isHtmxRequest: isHtmxRequest) + + case let .create(purchaseOrder): + return try await PurchaseOrderTable.Row(purchaseOrder: purchaseOrders.create(purchaseOrder)) + + case let .delete(id: id): + try await purchaseOrders.delete(id) + return nil + + case .index: + return try await mainPage(PurchaseOrderForm()) + + case let .get(id: id): + guard let purchaseOrder = try await purchaseOrders.get(id) else { + throw Abort(.badRequest, reason: "Purchase order not found.") + } + return try await render(mainPage, isHtmxRequest) { + PurchaseOrderForm(purchaseOrder: purchaseOrder, shouldShow: true) + } + + case let .page(page: page, limit: limit): + return try await PurchaseOrderTable.Rows( + page: purchaseOrders.fetchPage(.init(page: page, per: limit)) + ) + } + } + +} + +extension SharedModels.ViewRoute.PurchaseOrderRoute.Search { + + func mainPage(search: PurchaseOrderSearch = .init()) -> some SendableHTMLDocument { + MainPage(displayNav: true, route: .purchaseOrders) { + div(.class("container"), .id("purchase-order-content")) { + search + PurchaseOrderTable( + page: .init(items: [], metadata: .init(page: 0, per: 50, total: 0)), + context: .search + ) + } + } + } + + func view(isHtmxRequest: Bool) async throws -> any HTML { + @Dependency(\.database) var database + switch self { + case let .index(context: context, table: table): + let html = PurchaseOrderSearch(context: context) + if table == true || !isHtmxRequest { + return mainPage(search: html) + } + return html + + case let .request(context): + let results = try await database.purchaseOrders.search(context.toDatabaseQuery(), .init(page: 1, per: 25)) + return PurchaseOrderTable(page: results, context: .search) + } + } +} + +extension SharedModels.ViewRoute.UserRoute { + + private func mainPage(_ html: C) async throws -> some SendableHTMLDocument where C: Sendable { + @Dependency(\.database) var database + let users = try await database.users.fetchAll() + return MainPage(displayNav: true, route: .users) { + div(.class("container")) { + html + UserTable(users: users) + } + } + } + + func view(isHtmxRequest: Bool) async throws -> (any HTML)? { + @Dependency(\.database.users) var users + + switch self { + case .form: + return try await render(mainPage, isHtmxRequest, UserForm(context: .create)) + + case let .create(user): + return try await UserTable.Row(user: users.create(user)) + + case let .delete(id: id): + try await users.delete(id) + return nil + + case .index: + return try await mainPage(UserDetail(user: nil)) + + case let .get(id: id): + guard let user = try await users.get(id) else { + throw Abort(.badRequest, reason: "User not found.") + } + return UserDetail(user: user) + + case let .update(id: id, updates: updates): + return try await UserTable.Row(user: users.update(id, updates)) + } + } + +} + +extension SharedModels.ViewRoute.VendorRoute { + private func mainPage(_ html: C) async throws -> some SendableHTMLDocument where C: Sendable { + @Dependency(\.database) var database + let vendors = try await database.vendors.fetchAll(.withBranches) + return MainPage(displayNav: true, route: .vendors) { + div(.class("container"), .id("content")) { + html + VendorTable(vendors: vendors) + } + } + } + + func view(isHtmxRequest: Bool) async throws -> (any HTML)? { + @Dependency(\.database) var database + + switch self { + case .form: + // html = VendorForm(.float(shouldShow: true)) + return try await render(mainPage, isHtmxRequest, VendorForm(.float(shouldShow: true))) + + case .index: + // return VendorForm() + return try await mainPage(VendorForm()) + + case let .create(vendor): + let vendor = try await database.vendors.create(vendor) + let table = try await VendorTable(vendors: database.vendors.fetchAll(.withBranches)) + return try await render(mainPage, isHtmxRequest) { + div(.class("container"), .id("content")) { + VendorDetail(vendor: vendor) + table + } + } + + case let .delete(id: id): + try await database.vendors.delete(id) + return nil + + case let .get(id: id): + guard let vendor = try await database.vendors.get(id, .withBranches) else { + throw Abort(.badRequest, reason: "Vendor not found.") + } + return try await render(mainPage, isHtmxRequest, VendorDetail(vendor: vendor)) + + case let .update(id: id, updates: updates): + return try await VendorDetail( + vendor: database.vendors.update(id, with: updates, returnWithBranches: true) + ) + } + } +} + +extension SharedModels.ViewRoute.VendorBranchRoute { + + func view(isHtmxRequest: Bool) async throws -> (any HTML)? { + @Dependency(\.database) var database + + switch self { + case let .index(for: vendorID): + guard let vendorID else { + throw Abort(.badRequest, reason: "Vendor id not supplied") + } + return try await VendorBranchList( + vendorID: vendorID, + branches: database.vendorBranches.fetchAll(.for(vendorID: vendorID)) + ) + + case let .select(context: context): + return try await context.toHTML(branches: database.vendorBranches.fetchAllWithDetail()) + + case let .create(branch): + return try await VendorBranchList.Row(branch: database.vendorBranches.create(branch)) + + case let .delete(id: id): + try await database.vendorBranches.delete(id) + return nil + } + } +} + +extension SharedModels.ViewRoute.PurchaseOrderRoute.Search.Request { + + func toDatabaseQuery() throws -> PurchaseOrder.SearchContext { + switch context { + case .employee: + guard let createdForID else { + throw Abort(.badRequest, reason: "Employee id not provided") + } + return .employee(createdForID) + case .customer: + guard let customerSearch, !customerSearch.isEmpty else { + throw Abort(.badRequest, reason: "Customer search string is empty.") + } + return .customer(customerSearch) + case .vendor: + guard let vendorBranchID else { + throw Abort(.badRequest, reason: "Vendor branch id not provided.") + } + return .vendor(vendorBranchID) + } + } +} + +extension SharedModels.ViewRoute.SelectContext { + func toHTML(employees: [Employee]) -> EmployeeSelect { + switch self { + case .purchaseOrderForm: + return .purchaseOrderForm(employees: employees) + case .purchaseOrderSearch: + return .purchaseOrderSearch(employees: employees) + } + } + + func toHTML(branches: [VendorBranch.Detail]) -> VendorBranchSelect { + switch self { + case .purchaseOrderForm: + return .purchaseOrderForm(branches: branches) + case .purchaseOrderSearch: + return .purchaseOrderSearch(branches: branches) + } + } +} + +private func render( + _ mainPage: (C) async throws -> any SendableHTMLDocument, + _ isHtmxRequest: Bool, + @HTMLBuilder html: () -> C +) async rethrows -> any HTML { + guard isHtmxRequest else { + return try await mainPage(html()) + } + return html() +} + +private func render( + _ mainPage: (C) async throws -> any SendableHTMLDocument, + _ isHtmxRequest: Bool, + _ html: @autoclosure @escaping () -> C +) async rethrows -> any HTML { + try await render(mainPage, isHtmxRequest) { html() } +} diff --git a/Sources/ViewControllerLive/Views/Employees/EmployeeForm.swift b/Sources/ViewControllerLive/Views/Employees/EmployeeForm.swift new file mode 100644 index 0000000..167948f --- /dev/null +++ b/Sources/ViewControllerLive/Views/Employees/EmployeeForm.swift @@ -0,0 +1,81 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct EmployeeForm: HTML { + let employee: Employee? + let shouldShow: Bool + + init(employee: Employee? = nil, shouldShow: Bool = false) { + self.employee = employee + self.shouldShow = shouldShow + } + + init(employee: Employee) { + self.employee = employee + self.shouldShow = true + } + + var content: some HTML { + Float(shouldDisplay: shouldShow, resetURL: .employee(.index)) { + form( + employee == nil ? .hx.post(route: targetURL) : .hx.put(route: targetURL), + .hx.target(target), + employee == nil + ? .hx.swap(.beforeEnd.transition(true).swap("0.5s")) + : .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.on( + .afterRequest, + .ifSuccessful(.toggleContent(.float), .setWindowLocation(to: .employee(.index))) + ) + ) { + div(.class("row")) { + input( + .type(.text), .class("col-5"), + .name("firstName"), .value(employee?.firstName ?? ""), + .placeholder("First Name"), .required + ) + div(.class("col-2")) {} + input( + .type(.text), .class("col-5"), + .name("lastName"), .value(employee?.lastName ?? ""), + .placeholder("Last Name"), .required + ) + } + div(.class("btn-row")) { + button(.type(.submit), .class("btn-primary")) { + buttonLabel + } + if let employee { + Button.danger { + "Delete" + } + .attributes( + .hx.confirm("Are you sure you want to delete this employee?"), + .hx.delete(route: .employee(.delete(id: employee.id))), + .hx.target(.id(.employee(.row(id: employee.id)))), + .hx.swap(.outerHTML.transition(true).swap("1s")) + ) + } + } + } + } + } + + private var target: HXTarget { + guard let employee else { + return .id(.employee(.table)) + } + return .id(.employee(.row(id: employee.id))) + } + + private var buttonLabel: String { + guard employee != nil else { return "Create" } + return "Update" + } + + private var targetURL: SharedModels.ViewRoute { + guard let employee else { return .employee(.index) } + return .employee(.get(id: employee.id)) + } +} diff --git a/Sources/ViewControllerLive/Views/Employees/EmployeeTable.swift b/Sources/ViewControllerLive/Views/Employees/EmployeeTable.swift new file mode 100644 index 0000000..4e6ae33 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Employees/EmployeeTable.swift @@ -0,0 +1,51 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct EmployeeTable: HTML { + let employees: [Employee] + + var content: some HTML { + table { + thead { + tr { + th { "Name" } + th(.style("width: 100px;")) { + Button.add() + .attributes( + .style("padding: 0px 10px;"), + .hx.get(route: .employee(.form)), + .hx.target(.id(.float)), + .hx.swap(.outerHTML.transition(true).swap("0.5s")) + ) + } + } + } + tbody(.id(.employee(.table))) { + for employee in employees { + Row(employee: employee) + } + } + } + } + + struct Row: HTML { + let employee: Employee + + var content: some HTML { + tr(.id(.employee(.row(id: employee.id)))) { + td { employee.fullName } + td { + Button.detail() + .attributes( + .style("padding-left: 15px;"), + .hx.get(route: .employee(.get(id: employee.id))), + .hx.target(.id(.float)), + .hx.pushURL(true), + .hx.swap(.outerHTML.transition(true).swap("0.5s")) + ) + } + } + } + } +} diff --git a/Sources/ViewControllerLive/Views/HTMXExtensions.swift b/Sources/ViewControllerLive/Views/HTMXExtensions.swift new file mode 100644 index 0000000..7d045d6 --- /dev/null +++ b/Sources/ViewControllerLive/Views/HTMXExtensions.swift @@ -0,0 +1,203 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +extension HTMLAttribute.hx { + static func get(route: SharedModels.ViewRoute) -> HTMLAttribute { + get(SharedModels.ViewRoute.router.path(for: route)) + } + + static func post(route: SharedModels.ViewRoute) -> HTMLAttribute { + post(SharedModels.ViewRoute.router.path(for: route)) + } + + static func put(route: SharedModels.ViewRoute) -> HTMLAttribute { + put(SharedModels.ViewRoute.router.path(for: route)) + } + + static func delete(route: SharedModels.ViewRoute) -> HTMLAttribute { + delete(SharedModels.ViewRoute.router.path(for: route)) + } +} + +extension HTMLAttribute.hx { + static func target(_ target: HXTarget) -> HTMLAttribute { + Self.target(target.selector) + } +} + +extension HTMLAttribute.hx where Tag: HTMLTrait.Attributes.Global { + + static func on(_ event: HXOnEvent, value: String) -> HTMLAttribute { + HTMLAttribute.custom(name: "hx-on::\(event.rawValue)", value: value) + } + + static func on(_ event: HXOnEvent, _ value: HXOnValue) -> HTMLAttribute { + on(event, value: value.value) + } + + static func on(_ event: HXOnEvent, _ values: HXOnValue...) -> HTMLAttribute { + on(event, value: values.value) + } +} + +enum HXOnEvent: String { + case afterRequest = "after-request" +} + +indirect enum HXOnValue { + case ifSuccessful([Self]) + case resetForm + case setWindowLocation(String) + case toggleContent(id: String) + + static func toggleContent(_ toggle: Toggle) -> Self { + toggleContent(id: toggle.rawValue) + } + + static func setWindowLocation(to route: ViewRoute) -> Self { + setWindowLocation(ViewRoute.router.path(for: route)) + } + + static func ifSuccessful(_ values: Self...) -> Self { + .ifSuccessful(values) + } + + fileprivate var value: String { + switch self { + case .resetForm: + return "this.reset();" + case let .toggleContent(toggle): + return "toggleContent('\(toggle)');" + case let .setWindowLocation(string): + return "window.location.href='\(string)';" + case let .ifSuccessful(values): + return "if(event.detail.successful) \(values.value)" + } + } + + enum Toggle: String { + case float + } +} + +extension Array where Element == HXOnValue { + + var value: String { + return map(\.value).joined(separator: " ") + } +} + +extension HTMLAttribute where Tag: HTMLTrait.Attributes.Global { + static func id(_ key: IDKey) -> Self { + id(key.description) + } +} + +enum IDKey: CustomStringConvertible { + case branch(Branch) + case employee(Employee) + case float + case purchaseOrder(PurchaseOrder? = nil) + case user(User) + case vendor(Vendor) + + var description: String { + switch self { + case let .branch(key): return "branch-\(key)" + case let .employee(key): return "employee-\(key)" + case .float: return "float" + case let .purchaseOrder(key): + guard let key else { return "purchase-order" } + return "purchase-order-\(key)" + case let .user(key): return "user-\(key)" + case let .vendor(key): return "vendor-\(key)" + } + } + + enum Branch: CustomStringConvertible { + case list + case form + case row(id: VendorBranch.ID) + + var description: String { + switch self { + case .list: return "list" + case .form: return "form" + case let .row(id): return id.uuidString + } + } + } + + enum Employee: CustomStringConvertible { + case table + case row(id: SharedModels.Employee.ID) + + var description: String { + switch self { + case .table: return "table" + case let .row(id): return "\(id)" + } + } + } + + enum PurchaseOrder: CustomStringConvertible { + case content + case row(id: SharedModels.PurchaseOrder.ID) + case search + case table + + var description: String { + switch self { + case .content: return "content" + case let .row(id): return "\(id)" + case .search: return "search" + case .table: return "table" + } + } + } + + enum User: CustomStringConvertible { + case form + case row(id: SharedModels.User.ID) + case table + + var description: String { + switch self { + case .form: return "form" + case .table: return "table" + case let .row(id): return "\(id)" + } + } + } + + enum Vendor: CustomStringConvertible { + case form + case row(id: SharedModels.Vendor.ID) + + var description: String { + switch self { + case .form: return "form" + case let .row(id): return "\(id)" + } + } + + } +} + +enum HXTarget: CustomStringConvertible { + case body + case id(IDKey) + case this + + var selector: String { + switch self { + case .body: return "body" + case let .id(key): return "#\(key)" + case .this: return "this" + } + } + + var description: String { selector } + +} diff --git a/Sources/ViewControllerLive/Views/MainPage.swift b/Sources/ViewControllerLive/Views/MainPage.swift new file mode 100644 index 0000000..93fe7bd --- /dev/null +++ b/Sources/ViewControllerLive/Views/MainPage.swift @@ -0,0 +1,140 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct MainPage: SendableHTMLDocument where Inner: Sendable { + + var title: String { "Purchase Orders" } + var lang: String { "en" } + + let inner: Inner + let displayNav: Bool + let routeHeader: RouteHeaderView + + init( + displayNav: Bool = false, + route: RouteHeaderView.ViewRoute, + _ inner: () -> Inner + ) { + self.displayNav = displayNav + self.routeHeader = .init(route: route) + self.inner = inner() + } + + var head: some HTML { + meta(.charset(.utf8)) + script(.src("https://unpkg.com/htmx.org@2.0.4")) {} + script(.src("/js/main.js")) {} + link(.rel(.stylesheet), .href("/css/main.css")) + link(.rel(.icon), .href("/images/favicon.ico"), .custom(name: "type", value: "image/x-icon")) + } + + var body: some HTML { + header(.class("header")) { + Logo() + if displayNav { + Navbar() + } + } + routeHeader + inner + } +} + +extension MainPage where Inner == LoggedIn { + static func loggedIn(next: String?) -> Self { + MainPage(displayNav: true, route: .purchaseOrders) { + LoggedIn(next: next) + } + } +} + +struct LoggedIn: HTML { + let next: String? + var content: some HTML { + div( + .hx.get(nextRoute ?? ViewRoute.router.path(for: .purchaseOrder(.index))), + .hx.pushURL(true), + .hx.target(.body), + .hx.trigger(.event(.revealed)), + .hx.indicator(".hx-indicator") + ) { + Img.spinner().attributes(.class("hx-indicator")) + } + } + + // HACK: to get search route to work after login. + var nextRoute: String? { + if let next, next.contains("search") { + return ViewRoute.router.path(for: .purchaseOrder(.search(.index(context: .employee, table: true)))) + } + return next + } +} + +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(.class("secondary")) { i { description } } + br() + } + } + + enum ViewRoute: String { + + case employees + case login + case purchaseOrders + case users + case vendors + + var title: String { + switch self { + case .purchaseOrders: + return "Purchase Orders" + default: + return rawValue.capitalized + } + } + + var description: String { + switch self { + case .employees: + return "Employees are who purchase orders can be issued to." + case .purchaseOrders, .login: + return "" + case .users: + return "Users are who can login and issue purchase orders for employees." + case .vendors: + return "Vendors are where purchase orders can be issued to." + } + } + + } +} + +struct Logo: HTML, Sendable { + + var content: some HTML { + div(.id("logo")) { + "HHE - Purchase Orders" + } + } +} + +protocol SendableHTMLDocument: HTMLDocument, Sendable {} diff --git a/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderForm.swift b/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderForm.swift new file mode 100644 index 0000000..83b8ec5 --- /dev/null +++ b/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderForm.swift @@ -0,0 +1,130 @@ +import Dependencies +import Elementary +import ElementaryHTMX +import SharedModels + +struct PurchaseOrderForm: HTML { + + @Dependency(\.dateFormatter) var dateFormatter + + let purchaseOrder: PurchaseOrder? + let shouldShow: Bool + + init(purchaseOrder: PurchaseOrder? = nil, shouldShow: Bool = false) { + self.purchaseOrder = purchaseOrder + self.shouldShow = shouldShow + } + + var content: some HTML { + Float(shouldDisplay: shouldShow, resetURL: .purchaseOrder(.index)) { + if shouldShow { + if purchaseOrder != nil { + p { + span(.class("label"), .style("margin-right: 15px;")) { "Note:" } + span { i(.style("font-size: 1em;")) { + "Vendor and Employee can not be changed once a purchase order has been created." + } } + } + } + form( + .hx.post(route: .purchaseOrder(.index)), + .hx.target(.id(.purchaseOrder(.table))), + .hx.swap(.afterBegin), + .hx.on(.afterRequest, .ifSuccessful(.toggleContent(.float))) + ) { + div(.class("row")) { + label( + .for("customer"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") + ) { "Customer:" } + input( + .type(.text), .class("col-3"), + .name("customer"), .placeholder("Customer"), + .value(purchaseOrder?.customer ?? ""), + .required, .autofocus + ) + label( + .for("workOrder"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") + ) { "Work Order:" } + input( + .type(.text), .class("col-4"), + .name("workOrder"), .placeholder("Work Order: (12345)"), + .value("\(purchaseOrder?.workOrder != nil ? String(purchaseOrder!.workOrder!) : "")") + ) + } + div(.class("row")) { + label( + .for("materials"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") + ) { "Materials:" } + input( + .type(.text), .class("col-3"), + .name("materials"), .placeholder("Materials"), + .value(purchaseOrder?.materials ?? ""), + .required + ) + label( + .for("vendorBranchID"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") + ) { "Vendor:" } + if purchaseOrder == nil { + VendorBranchSelect.purchaseOrderForm() + } else { + input( + .type(.text), .class("col-4"), + .name("vendorBranchID"), + .value("\(purchaseOrder!.vendorBranch.vendor.name) - \(purchaseOrder!.vendorBranch.name)"), + .disabled + ) + } + } + div(.class("row")) { + label( + .for("createdForID"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") + ) { "Employee:" } + if purchaseOrder == nil { + EmployeeSelect.purchaseOrderForm() + } else { + input( + .type(.text), .class("col-3"), + .value(purchaseOrder!.createdFor.fullName), + .disabled + ) + } + label( + .for("truckStock"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;") + ) { "Truck Stock:" } + if purchaseOrder?.truckStock == true { + input( + .type(.checkbox), .class("col-2"), .name("truckStock"), .style("margin-top: 20px;"), .checked + ) + } else { + input( + .type(.checkbox), .class("col-2"), .name("truckStock"), .style("margin-top: 20px;") + ) + } + } + if let purchaseOrder, let createdAt = purchaseOrder.createdAt { + div(.class("row")) { + label(.class("label col-2")) { "Created:" } + h3(.class("col-2")) { dateFormatter.string(from: createdAt) } + if let updatedAt = purchaseOrder.updatedAt { + div(.class("col-1")) {} + label(.class("label col-2")) { "Updated:" } + h3(.class("col-2")) { dateFormatter.string(from: updatedAt) } + } + } + } + div(.class("btn-row")) { + button(.class("btn-primary"), .type(.submit)) { buttonLabel } + if purchaseOrder != nil { + Button.danger { "Delete" } + } + } + } + } + } + } + + private var buttonLabel: String { + guard purchaseOrder != nil else { return "Create" } + return "Update" + } +} diff --git a/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderSearch.swift b/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderSearch.swift new file mode 100644 index 0000000..78c6f4d --- /dev/null +++ b/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderSearch.swift @@ -0,0 +1,63 @@ +import Elementary +import ElementaryHTMX +import SharedModels +import Vapor + +struct PurchaseOrderSearch: HTML { + + typealias Context = SharedModels.ViewRoute.PurchaseOrderRoute.Search.Context + + let context: Context + + init(context: Context? = nil) { + self.context = context ?? .employee + } + + var content: some HTML { + form( + .id(.purchaseOrder(.search)), + .hx.post(route: .purchaseOrder(.search(.index()))), + .hx.target(.id(.purchaseOrder())), + .hx.swap(.outerHTML) + ) { + div(.class("btn-row")) { + button( + .class("btn-secondary"), .style("position: absolute; top: 80px; right: 20px;"), + .hx.get(route: .purchaseOrder(.index)), .hx.pushURL(true), .hx.target("body") + ) + { "x" } + } + div(.class("row")) { + select( + .name("context"), .class("col-3"), + .hx.get(route: .purchaseOrder(.search(.index()))), + .hx.target(.id(.purchaseOrder(.search))), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.pushURL(true) + ) { + for context in Context.allCases { + option(.value(context.rawValue)) { context.rawValue.capitalized } + .attributes(.selected, when: self.context == context) + } + } + + if context == .employee { + EmployeeSelect.purchaseOrderSearch() + } else if context == .customer { + input( + .type(.text), .class("col-6"), .style("margin-left: 60px; margin-top: 18px;"), + .name("customerSearch"), .placeholder("Search"), .required + ) + } else if context == .vendor { + VendorBranchSelect.purchaseOrderSearch() + } + } + + div(.class("btn-row")) { + button(.type(.submit), .class("btn-primary")) + { "Search" } + } + } + } + +} diff --git a/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderTable.swift b/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderTable.swift new file mode 100644 index 0000000..f143b21 --- /dev/null +++ b/Sources/ViewControllerLive/Views/PurchaseOrders/PurchaseOrderTable.swift @@ -0,0 +1,137 @@ +import Elementary +import ElementaryHTMX +import Fluent +import SharedModels +import Vapor + +struct PurchaseOrderTable: HTML { + typealias SearchContext = SharedModels.ViewRoute.PurchaseOrderRoute.Search.Context + + let page: Page + let context: Context + let searchContext: SearchContext? + + init( + page: Page, + context: Context = .default, + searchContext: SearchContext? = nil + ) { + self.page = page + self.context = context + self.searchContext = searchContext + } + + var content: some HTML { + table(.id(.purchaseOrder())) { + thead { + buttonRow + tableHeader + } + tbody(.id(.purchaseOrder(.table))) { + Rows(page: page) + } + } + } + + private var tableHeader: some HTML { + tr { + th { "PO" } + th { "Work Order" } + th { "Customer" } + th { "Vendor" } + th { "Materials" } + th { "Created For" } + th { + if context != .search { + Button.add() + .attributes( + .hx.get(route: .purchaseOrder(.form)), .hx.target(.id(.float)), + .hx.swap(.outerHTML), .hx.pushURL(true) + ) + } + } + } + } + + private var buttonRow: some HTML { + tr { + div(.class("btn-row")) { + if context != .search { + button( + .id("btn-search"), + .class("btn-primary"), .style("position: absolute; top: 80px; right: 20px;"), + .hx.get(route: .purchaseOrder(.search(.index(context: .employee, table: true)))), + .hx.target(.body), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.pushURL(true) + ) + { Img.search() } + } + } + } + } + + // Produces only the rows for the given page + struct Rows: HTML { + let page: Page + + var content: some HTML { + for purchaseOrder in page.items { + Row(purchaseOrder: purchaseOrder) + } + // We set page to 0 when we're on search, but have not completed the search + // form yet, so don't add the infinite scroll row / trigger otherwise it will + // load the first page, which is not what we want, but we need the empty table + // to be available once the search form is completed. + if page.metadata.page > 0, page.metadata.pageCount > page.metadata.page { + tr( + .hx.get(route: .purchaseOrder(.page(page: page.metadata.page + 1, limit: page.metadata.per))), + .hx.trigger(.event(.revealed)), + .hx.swap(.outerHTML.transition(true).swap("1s")), + .hx.target(.this), + .hx.indicator("next .htmx-indicator") + ) { + img(.src("/images/spinner.svg"), .class("htmx-indicator"), .width(60), .height(60)) + } + } + } + } + + // A single row. + struct Row: HTML { + let purchaseOrder: PurchaseOrder + + var content: some HTML { + tr( + .id(.purchaseOrder(.row(id: purchaseOrder.id))) + ) { + td { "\(purchaseOrder.id)" } + td { purchaseOrder.workOrder != nil ? String(purchaseOrder.workOrder!) : "" } + td { purchaseOrder.customer } + td { purchaseOrder.vendorBranch.displayName } + td { purchaseOrder.materials } + td { purchaseOrder.createdFor.fullName } + td { + Button.detail() + .attributes( + .hx.get(route: .purchaseOrder(.get(id: purchaseOrder.id))), + .hx.target(.id(.float)), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.pushURL(true) + ) + } + } + } + } + + enum Context: String { + case `default` + case search + } +} + +private extension VendorBranch.Detail { + var displayName: String { + "\(vendor.name.capitalized) - \(name.capitalized)" + } +} diff --git a/Sources/ViewControllerLive/Views/Users/UserDetail.swift b/Sources/ViewControllerLive/Views/Users/UserDetail.swift new file mode 100644 index 0000000..f9d429b --- /dev/null +++ b/Sources/ViewControllerLive/Views/Users/UserDetail.swift @@ -0,0 +1,76 @@ +import Dependencies +import Elementary +import Foundation +import SharedModels + +struct UserDetail: HTML, Sendable { + @Dependency(\.dateFormatter) var dateFormatter + + let user: User? + + var content: some HTML { + Float(shouldDisplay: user != nil, resetURL: .user(.index)) { + if let user { + form( + .hx.post(route: .user(.get(id: user.id))), + .hx.swap(.outerHTML), + .hx.target(.id(.user(.row(id: user.id)))), + .hx.on(.afterRequest, .toggleContent(.float)) + ) { + div(.class("row")) { + makeLabel(for: "username", value: "Username:") + input(.class("col-4"), .type(.text), .id("username"), .name("username"), .value(user.username), .required) + makeLabel(for: "email", value: "Email:") + input(.class("col-4"), .type(.email), .id("email"), .name("email"), .value(user.email), .required) + } + div(.class("row")) { + span(.class("label col-2")) { "Created:" } + span(.class("date col-4")) { dateFormatter.formattedDate(user.createdAt) } + span(.class("label col-2")) { "Updated:" } + span(.class("date col-4")) { dateFormatter.formattedDate(user.updatedAt) } + } + div(.class("btn-row user-buttons")) { + button( + .type(.submit), + .class("btn-secondary") + ) { "Update" } + Button.danger { "Delete" } + .attributes( + .hx.delete(route: .user(.get(id: user.id))), + .hx.trigger(.event(.click)), + .hx.swap(.outerHTML), + .hx.target(.id(.user(.row(id: user.id)))), + .hx.confirm("Are you sure you want to delete this user?"), + .hx.on( + .afterRequest, + .toggleContent(.float), .setWindowLocation(to: .user(.index)) + ) + ) + } + } + } + } + } + + func makeLabel( + for name: String, + value: String + ) -> some HTML { + label(.for(name), .class("col-2")) { span(.class("label")) { value } } + } + + func row(_ label: String, _ value: String) -> some HTML { + tr { + td(.class("label")) { h3 { label } } + td { h3 { value } } + } + } +} + +extension DateFormatter { + + func formattedDate(_ date: Date?) -> String { + guard let date else { return "" } + return string(from: date) + } +} diff --git a/Sources/ViewControllerLive/Views/Users/UserForm.swift b/Sources/ViewControllerLive/Views/Users/UserForm.swift new file mode 100644 index 0000000..4ce7c95 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Users/UserForm.swift @@ -0,0 +1,119 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +// Form used to login or create a new user. +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), + .hx.target(context.target), + .hx.swap(context == .create ? .afterBegin.transition(true).swap("0.5s") : .outerHTML), + .hx.on( + .afterRequest, + .ifSuccessful(.resetForm, .toggleContent(.float)) + ) + ) { + if case let .login(next) = context, let next { + input(.type(.hidden), .name("next"), .value(next)) + } + div(.class("row")) { + input(.type(.text), .id("username"), .name("username"), .placeholder("Username"), .autofocus, .required) + } + if context.showEmailInput { + div(.class("row")) { + input(.type(.email), .id("email"), .name("email"), .placeholder("Email"), .required) + } + } + div(.class("row")) { + input(.type(.password), .id("password"), .name("password"), .placeholder("Password"), .required) + } + if context.showConfirmPassword { + div(.class("row")) { + input( + .type(.password), .id("confirmPassword"), .name("confirmPassword"), + .placeholder("Confirm Password"), + .required + ) + } + } + div(.class("row")) { + button(.type(.submit), .class("btn-primary")) { context.buttonLabel } + } + } + } + + enum Context: Equatable { + case create + case login(next: String?) + + var showConfirmPassword: Bool { + switch self { + case .create: return true + case .login: return false + } + } + + var showEmailInput: Bool { + switch self { + case .create: return true + case .login: return false + } + } + + var pushURL: Bool { + switch self { + case .create: return false + case .login: return true + } + } + + var buttonLabel: String { + switch self { + case .create: + return "Create" + case .login: + return "Login" + } + } + + var target: HXTarget { + switch self { + case .create: + return .id(.user(.table)) + case .login: + return .body + } + } + + // TODO: Return a ViewRoute. + var targetURL: String { + switch self { + case .create: + return "/users" + case .login: + return "/login" + // let path = "/login" + // if let next { + // return "\(path)?next=\(next)" + // } + // return path + } + } + } +} diff --git a/Sources/ViewControllerLive/Views/Users/UserTable.swift b/Sources/ViewControllerLive/Views/Users/UserTable.swift new file mode 100644 index 0000000..54bb936 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Users/UserTable.swift @@ -0,0 +1,57 @@ +import DatabaseClient +import Dependencies +import Elementary +import ElementaryHTMX +import SharedModels + +struct UserTable: HTML { + + let users: [User] + + var content: some HTML { + table { + thead { + tr { + th { "Username" } + th { "Email" } + th(.style("width: 50px;")) { + Button.add() + .attributes( + .hx.get(route: .user(.form)), + .hx.target(.id(.float)), + .hx.swap(.outerHTML) + ) + } + } + } + tbody(.id(.user(.table))) { + for user in users { + Row(user: user) + } + } + } + } + + struct Row: HTML { + let user: User + + init(user: User) { + self.user = user + } + + var content: some HTML { + tr(.id(.user(.row(id: user.id)))) { + td { user.username } + td { user.email } + td { + Button.detail().attributes( + .hx.get(route: .user(.get(id: user.id))), + .hx.target(.id(.float)), + .hx.swap(.outerHTML), + .hx.pushURL(true) + ) + } + } + } + } +} diff --git a/Sources/ViewControllerLive/Views/Utils/Buttons.swift b/Sources/ViewControllerLive/Views/Utils/Buttons.swift new file mode 100644 index 0000000..98333f8 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Utils/Buttons.swift @@ -0,0 +1,53 @@ +import Elementary +import SharedModels +import URLRouting + +struct ToggleFormButton: HTML { + var content: some HTML { + a(.href("javascript:void(0)"), .on(.click, "toggleContent('form')"), .class("btn-add")) { + "+" + } + } +} + +enum Button { + + static func add() -> some HTML { + button(.class("btn btn-add")) { "+" } + } + + static func danger(@HTMLBuilder body: () -> C) -> some HTML { + button(.class("danger")) { body() } + } + + static func close(id: String, resetURL: String? = nil) -> some HTML { + button(.class("btn-close"), .on(.click, makeOnClick(id, resetURL))) { + "x" + } + } + + static func close(id: IDKey, resetURL route: ViewRoute? = nil) -> some HTML { + close( + id: id.description, + resetURL: route != nil ? ViewRoute.router.path(for: route!) : nil + ) + } + + static func update() -> some HTML { + button(.class("btn-update")) { "Update" } + } + + static func detail() -> some HTML { + button(.class("btn-detail")) { + "〉" + } + } + + private static func makeOnClick(_ id: String, _ resetURL: String?) -> String { + let output = "toggleContent('\(id)');" + if let resetURL { + return "\(output) window.location.href='\(resetURL)';" + } + return output + } +} diff --git a/Sources/ViewControllerLive/Views/Utils/Float.swift b/Sources/ViewControllerLive/Views/Utils/Float.swift new file mode 100644 index 0000000..cf459d7 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Utils/Float.swift @@ -0,0 +1,81 @@ +import Elementary +import SharedModels + +struct Float: HTML { + + let id: String + let shouldDisplay: Bool + let body: C? + let closeButton: B? + + init(id: String = "float") { + self.id = id + self.shouldDisplay = false + self.body = nil + self.closeButton = nil + } + + init( + id: String = "float", + shouldDisplay: Bool, + @HTMLBuilder body: () -> C, + @HTMLBuilder closeButton: () -> B + ) { + self.id = id + self.shouldDisplay = shouldDisplay + self.body = body() + self.closeButton = closeButton() + } + + 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 { + if let closeButton { + div(.class("btn-row")) { + closeButton + } + } + body + } + } + } +} + +struct DefaultCloseButton: HTML { + let id: String + let resetURL: String? + + var content: some HTML { + Button.close(id: id, resetURL: resetURL) + } +} + +extension Float where B == DefaultCloseButton { + init( + id: String = "float", + shouldDisplay: Bool, + resetURL route: ViewRoute? = nil, + @HTMLBuilder body: () -> C + ) { + self.init( + id: id, + shouldDisplay: shouldDisplay, + body: body, + closeButton: { DefaultCloseButton( + id: id, + resetURL: route != nil ? ViewRoute.router.path(for: route!) : nil + ) } + ) + } + +} + +extension Float: Sendable where C: Sendable, B: Sendable {} diff --git a/Sources/ViewControllerLive/Views/Utils/Img.swift b/Sources/ViewControllerLive/Views/Utils/Img.swift new file mode 100644 index 0000000..b048088 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Utils/Img.swift @@ -0,0 +1,18 @@ +import Elementary + +enum Img { + @Sendable + static func spinner(width: Int = 30, height: Int = 30) -> some HTML { + img(.src("/images/spinner.svg"), .width(width), .height(height)) + } + + @Sendable + static func search(width: Int = 30, height: Int = 30) -> some HTML { + img(.src("/images/search.svg"), .width(width), .height(height)) + } + + @Sendable + static func trashCan(width: Int = 30, height: Int = 30) -> some HTML { + img(.src("/images/trash-can.svg"), .width(width), .height(height)) + } +} diff --git a/Sources/ViewControllerLive/Views/Utils/Navbar.swift b/Sources/ViewControllerLive/Views/Utils/Navbar.swift new file mode 100644 index 0000000..8fce606 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Utils/Navbar.swift @@ -0,0 +1,31 @@ +import Elementary +import ElementaryHTMX + +struct Navbar: HTML, Sendable { + var content: some HTML { + div(.class("sidepanel"), .id("sidepanel")) { + a(.href("javascript:void(0)"), .class("closebtn"), .on(.click, "closeSidepanel()")) { + "x" + } + a(.hx.get("/purchase-orders?page=1&limit=50"), .hx.target("body"), .hx.pushURL(true)) { + "Purchase Orders" + } + a(.hx.get("/users"), .hx.target("body"), .hx.pushURL(true)) { + "Users" + } + a(.hx.get("/employees"), .hx.target("body"), .hx.pushURL(true)) { + "Employees" + } + a(.hx.get("/vendors"), .hx.target("body"), .hx.pushURL(true)) { + "Vendors" + } + div(.style("border-bottom: 1px solid grey; margin-bottom: 5px;")) {} + a(.hx.post("/logout"), .hx.target("#content"), .hx.swap(.outerHTML), .hx.trigger(.event(.click))) { + "Logout" + } + } + button(.class("openbtn"), .on(.click, "openSidepanel()")) { + img(.src("/images/menu.svg"), .style("width: 30px;, height: 30px;")) + } + } +} diff --git a/Sources/ViewControllerLive/Views/Utils/Select.swift b/Sources/ViewControllerLive/Views/Utils/Select.swift new file mode 100644 index 0000000..0e9bb90 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Utils/Select.swift @@ -0,0 +1,89 @@ +import Elementary +import ElementaryHTMX +import SharedModels +import Vapor + +struct EmployeeSelect: HTML { + + let employees: [Employee]? + let context: ViewRoute.SelectContext + + var content: some HTML { + if let employees { + select(.name("createdForID"), .class(context.classString)) { + for employee in employees { + option(.value(employee.id.uuidString)) { employee.fullName } + } + } + .attributes(.style("margin-left: 15px;"), when: context == .purchaseOrderSearch) + } else { + div( + .hx.get(route: .employee(.select(context: context))), + .hx.target("this"), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.indicator("next .hx-indicator"), + .hx.trigger(.event(.revealed)), + .style("display: inline;") + ) { + Img.spinner().attributes(.class("hx-indicator")) + } + } + } + + static func purchaseOrderForm(employees: [Employee]? = nil) -> Self { + .init(employees: employees, context: .purchaseOrderForm) + } + + static func purchaseOrderSearch(employees: [Employee]? = nil) -> Self { + .init(employees: employees, context: .purchaseOrderSearch) + } + +} + +struct VendorBranchSelect: HTML { + let branches: [VendorBranch.Detail]? + let context: ViewRoute.SelectContext + + var content: some HTML { + if let branches { + select(.name("vendorBranchID"), .class("col-4")) { + for branch in branches { + option(.value(branch.id.uuidString)) { "\(branch.vendor.name) - \(branch.name)" } + } + } + .attributes(.style("margin-left: 15px;"), when: context == .purchaseOrderSearch) + } else { + div( + .hx.get(route: .vendorBranch(.select(context: context))), + .hx.target(.this), + .hx.swap(.outerHTML.transition(true).swap("0.5s")), + .hx.indicator("next .hx-indicator"), + .hx.trigger(.event(.revealed)), + .style("display: inline;") + ) { + Img.spinner().attributes(.class("hx-indicator")) + } + } + } + + static func purchaseOrderForm(branches: [VendorBranch.Detail]? = nil) -> Self { + .init(branches: branches, context: .purchaseOrderForm) + } + + static func purchaseOrderSearch(branches: [VendorBranch.Detail]? = nil) -> Self { + .init(branches: branches, context: .purchaseOrderSearch) + } +} + +// enum SelectContext: String, Codable, Content { +// case purchaseOrderForm +// case purchaseOrderSearch + +extension ViewRoute.SelectContext { + var classString: String { + switch self { + case .purchaseOrderForm: return "col-3" + case .purchaseOrderSearch: return "col-6" + } + } +} diff --git a/Sources/ViewControllerLive/Views/VendorBranches/VendorBranchForm.swift b/Sources/ViewControllerLive/Views/VendorBranches/VendorBranchForm.swift new file mode 100644 index 0000000..9d8b3fd --- /dev/null +++ b/Sources/ViewControllerLive/Views/VendorBranches/VendorBranchForm.swift @@ -0,0 +1,34 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorBranchForm: HTML { + let vendorID: Vendor.ID + + var content: some HTML { + form( + .id(.branch(.form)), + .hx.post(route: .vendorBranch(.index())), + .hx.target(.id(.branch(.list))), + .hx.swap(.beforeEnd), + .hx.on(.afterRequest, .ifSuccessful(.resetForm)) + ) { + input(.type(.hidden), .name("vendorID"), .value(vendorID.uuidString)) + input( + .type(.text), .class("col-9"), .name("name"), .placeholder("Add branch..."), .required, + // .hx.post(route: .vendorBranch(.index())), + .hx.trigger(.event(.keyup).changed().delay("800ms")) // , + // .hx.target(.id(.branch(.list))), + // .hx.swap(.beforeEnd), + // .custom(name: "hx-on::after-request", value: "if(event.detail.successful) this.reset();") + ) + button( + .type(.submit), + .class("btn-secondary"), + .style("float: right; padding: 10px 50px;"), + .hx.target(.id(.branch(.list))), + .hx.swap(.beforeEnd) + ) { "+" } + } + } +} diff --git a/Sources/ViewControllerLive/Views/VendorBranches/VendorBranchList.swift b/Sources/ViewControllerLive/Views/VendorBranches/VendorBranchList.swift new file mode 100644 index 0000000..bfc8c8e --- /dev/null +++ b/Sources/ViewControllerLive/Views/VendorBranches/VendorBranchList.swift @@ -0,0 +1,46 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorBranchList: HTML { + let vendorID: Vendor.ID + let branches: [VendorBranch]? + + var content: some HTML { + if let branches { + ul(.id(.branch(.list))) { + for branch in branches { + Row(branch: branch) + } + } + } else { + div( + .hx.get(route: .vendorBranch(.index(for: vendorID))), + .hx.target(.this), + .hx.indicator(".hx-indicator"), + .hx.trigger(.event(.revealed)) + ) { + Img.spinner().attributes(.class("hx-indicator")) + } + } + } + + struct Row: HTML { + let branch: VendorBranch + + var content: some HTML { + li(.id(.branch(.row(id: branch.id))), .class("branch-row")) { + span(.class("label")) { branch.name.capitalized } + button( + .class("btn"), + .hx.delete(route: .vendorBranch(.delete(id: branch.id))), + .hx.target(.id(.branch(.row(id: branch.id)))), + .hx.swap(.outerHTML.transition(true).swap("0.5s")) + ) { + Img.trashCan().attributes(.style("margin-top: 5px;")) + } + } + } + } + +} diff --git a/Sources/ViewControllerLive/Views/Vendors/VendorDetail.swift b/Sources/ViewControllerLive/Views/Vendors/VendorDetail.swift new file mode 100644 index 0000000..e459950 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Vendors/VendorDetail.swift @@ -0,0 +1,25 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorDetail: HTML { + + let vendor: Vendor + + var content: some HTML { + Float(shouldDisplay: true) { + VendorForm(.formOnly(vendor)) + h2(.style("margin-left: 20px; font-size: 1.5em;"), .class("label")) { "Branches" } + VendorBranchForm(vendorID: vendor.id) + VendorBranchList(vendorID: vendor.id, branches: nil) + } closeButton: { + Button.close(id: "float") + .attributes( + .hx.get(route: .vendor(.index)), + .hx.pushURL(true), + .hx.target(.body), + .hx.swap(.outerHTML) + ) + } + } +} diff --git a/Sources/ViewControllerLive/Views/Vendors/VendorForm.swift b/Sources/ViewControllerLive/Views/Vendors/VendorForm.swift new file mode 100644 index 0000000..f140200 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Vendors/VendorForm.swift @@ -0,0 +1,92 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorForm: HTML { + + let context: Context + var vendor: Vendor? { context.vendor } + + init( + _ context: Context + ) { + self.context = context + } + + init() { self.init(.float(nil)) } + + enum Context { + case float(Vendor? = nil, shouldShow: Bool = false) + case formOnly(Vendor) + + var vendor: Vendor? { + switch self { + case let .float(vendor, _): return vendor + case let .formOnly(vendor): return vendor + } + } + } + + var content: some HTML { + switch context { + case let .float(vendor, shouldDisplay): + Float(shouldDisplay: shouldDisplay) { + makeForm(vendor: vendor) + } + case let .formOnly(vendor): + makeForm(vendor: vendor) + } + } + + func makeForm(vendor: Vendor?) -> some HTML { + form( + .id(.vendor(.form)), + vendor != nil ? .hx.put(route: targetURL) : .hx.post(route: targetURL), + .hx.target("#content"), + .hx.swap(.outerHTML) + ) { + div(.class("row")) { + input( + .type(.text), + .class("col-9"), + .id("vendor-name"), + .name("name"), + .value(vendor?.name ?? ""), + .placeholder("Vendor Name"), + vendor != nil ? .hx.put(route: targetURL) : .hx.post(route: targetURL), + .hx.trigger(.event(.keyup).changed().delay("500ms")), + .required + ) + if let vendor { + button( + .class("danger"), + .style("font-size: 1.25em; padding: 10px 20px; border-radius: 10px;"), + .hx.delete(route: .vendor(.delete(id: vendor.id))), + .hx.confirm("Are you sure you want to delete this vendor?"), + .hx.target(.id(.vendor(.row(id: vendor.id)))), + .hx.swap(.outerHTML.transition(true).swap("1s")), + .custom( + name: "hx-on::after-request", + value: "if(event.detail.successful) toggleContent('float'); window.location.href='/vendors';" + ) + ) { "Delete" } + } + button( + .type(.submit), + .class("btn-primary"), + .style("float: right") + ) { buttonLabel } + } + } + } + + private var buttonLabel: String { + guard vendor != nil else { return "Create" } + return "Update" + } + + var targetURL: SharedModels.ViewRoute { + guard let vendor else { return .vendor(.index) } + return .vendor(.get(id: vendor.id)) + } +} diff --git a/Sources/ViewControllerLive/Views/Vendors/VendorTable.swift b/Sources/ViewControllerLive/Views/Vendors/VendorTable.swift new file mode 100644 index 0000000..3858a01 --- /dev/null +++ b/Sources/ViewControllerLive/Views/Vendors/VendorTable.swift @@ -0,0 +1,53 @@ +import Elementary +import ElementaryHTMX +import SharedModels + +struct VendorTable: HTML { + let vendors: [Vendor] + + var content: some HTML { + table { + thead { + tr { + th { "Name" } + th { "Branches" } + th(.style("width: 100px;")) { + Button.add() + .attributes( + .style("padding: 0px 10px;"), + .hx.get(route: .vendor(.form)), + .hx.target(.id(.float)), + .hx.swap(.outerHTML) + ) + } + } + } + tbody(.id("vendor-table")) { + for vendor in vendors { + Row(vendor: vendor) + } + } + } + } + + struct Row: HTML { + let vendor: Vendor + + var content: some HTML { + tr(.id(.vendor(.row(id: vendor.id)))) { + td { vendor.name.capitalized } + td { "(\(vendor.branches?.count ?? 0)) Branches" } + td { + Button.detail() + .attributes( + .style("padding-left: 15px;"), + .hx.get(route: .vendor(.get(id: vendor.id))), + .hx.target(.id(.float)), + .hx.pushURL(true), + .hx.swap(.outerHTML) + ) + } + } + } + } +} diff --git a/Tests/ViewControllerTests/ViewControllerTests.swift b/Tests/ViewControllerTests/ViewControllerTests.swift new file mode 100644 index 0000000..f6b85a6 --- /dev/null +++ b/Tests/ViewControllerTests/ViewControllerTests.swift @@ -0,0 +1,343 @@ +import DatabaseClient +import Dependencies +import Foundation +import HtmlSnapshotTesting +import SharedModels +import SnapshotTesting +import Testing +import ViewControllerLive + +// NOTE: Passing routes as arguments doesn't work bc they are sometimes not in the same order. +@Suite("ViewControllerTests") +struct ViewControllerTests { + + let record: SnapshotTestingConfiguration.Record = .missing + + @Test + func sanity() { + #expect(Bool(true)) + } + + @Test + func employeeViews() async throws { + try await withSnapshotTesting(record: record) { + try await withDependencies { + $0.viewController = .liveValue + $0.database.employees = .mock + } operation: { + @Dependency(\.viewController) var viewController + @Dependency(\.database) var database + + var htmlString = try await viewController.render(.employee(.index)) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.employee(.form)) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.employee(.get(id: UUID(0)))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.employee(.select(context: .purchaseOrderForm))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.employee(.select(context: .purchaseOrderSearch))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.employee(.update(id: UUID(0), updates: .mock))) + assertSnapshot(of: htmlString, as: .html) + } + } + } + + @Test + func loginViews() async throws { + try await withSnapshotTesting(record: record) { + try await withDependencies { + $0.viewController = .liveValue + } operation: { + @Dependency(\.viewController) var viewController + + let htmlString = try await viewController.render(.login(.index(next: "/purchase-orders"))) + assertSnapshot(of: htmlString, as: .html) + } + } + } + + // @Test + // func purchaseOrderViews() async throws { + // try await withSnapshotTesting(record: record) { + // try await withDependencies { + // $0.database.purchaseOrders = .mock + // $0.viewController = .liveValue + // } operation: { + // @Dependency(\.viewController) var viewController + // + // let htmlString = try await viewController.render(.login(.index(next: "/purchase-orders"))) + // assertSnapshot(of: htmlString, as: .html) + // } + // } + // } + + @Test + func userViews() async throws { + try await withDependencies { + $0.dateFormatter = .liveValue + $0.database.users = .mock + $0.viewController = .liveValue + } operation: { + @Dependency(\.database) var database + @Dependency(\.viewController) var viewController + + var htmlString = try await viewController.render(.user(.index)) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.user(.form)) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.user(.create(.mock))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.user(.get(id: UUID(0)))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.user(.update(id: UUID(0), updates: .mock))) + assertSnapshot(of: htmlString, as: .html) + } + } + + @Test + func vendorViews() async throws { + try await withDependencies { + $0.dateFormatter = .liveValue + $0.database.vendors = .mock + $0.viewController = .liveValue + } operation: { + @Dependency(\.database) var database + @Dependency(\.viewController) var viewController + + var htmlString = try await viewController.render(.vendor(.index)) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendor(.form)) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendor(.create(.mock))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendor(.get(id: UUID(0)))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendor(.update(id: UUID(0), updates: .mock))) + assertSnapshot(of: htmlString, as: .html) + } + } + + @Test + func vendorBranchViews() async throws { + try await withDependencies { + $0.dateFormatter = .liveValue + $0.database.vendors = .mock + $0.database.vendorBranches = .mock + $0.viewController = .liveValue + } operation: { + @Dependency(\.database) var database + @Dependency(\.viewController) var viewController + + var htmlString = try await viewController.render(.vendorBranch(.index(for: UUID(0)))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendorBranch(.select(context: .purchaseOrderSearch))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendorBranch(.select(context: .purchaseOrderForm))) + assertSnapshot(of: htmlString, as: .html) + + htmlString = try await viewController.render(.vendorBranch(.create(.mock))) + assertSnapshot(of: htmlString, as: .html) + } + } +} + +extension ViewController { + + func render(_ route: ViewRoute) async throws -> String { + guard let html = try await view(for: route, isHtmxRequest: true, authenticate: { _ in }) else { + throw TestError() + } + return html.renderFormatted() + } + +} + +struct TestError: Error {} + +extension DatabaseClient.Employees { + static var mock: Self { + .init( + create: { _ in .mock }, + delete: { _ in }, + fetchAll: { _ in [Employee.mock] }, + get: { _ in Employee.mock }, + update: { _, _ in Employee.mock } + ) + } +} + +extension DatabaseClient.Users { + static var mock: Self { + .init( + count: { 1 }, + create: { _ in User.mock }, + delete: { _ in }, + fetchAll: { [User.mock] }, + get: { _ in User.mock }, + login: { _ in User.Token.mock }, + logout: { _ in }, + token: { _ in User.Token.mock }, + update: { _, _ in User.mock } + ) + } +} + +extension DatabaseClient.Vendors { + static var mock: Self { + .init( + create: { _ in Vendor.mock }, + delete: { _ in }, + fetchAll: { _ in [Vendor.mock] }, + get: { _, _ in Vendor.mock }, + update: { _, _, _ in Vendor.mock } + ) + } +} + +extension DatabaseClient.VendorBranches { + static var mock: Self { + .init( + create: { _ in VendorBranch.mock }, + delete: { _ in }, + fetchAll: { _ in [VendorBranch.mock] }, + fetchAllWithDetail: { [VendorBranch.Detail.mock] }, + get: { _ in VendorBranch.mock }, + update: { _, _ in VendorBranch.mock } + ) + } +} + +extension Date { + static var mock: Self { + Date(timeIntervalSince1970: 1_234_567_890) + } +} + +extension Employee { + static var mock: Self { + Employee( + id: UUID(0), + createdAt: Date(timeIntervalSince1970: 1_234_567_890), + firstName: "Testy", + lastName: "McTestface", + updatedAt: Date(timeIntervalSince1970: 1_234_567_890) + ) + } +} + +extension Employee.Create { + static var mock: Self { + .init(firstName: "Testy", lastName: "McTestface") + } + + func employeeMock() -> Employee { + @Dependency(\.date.now) var now + return .init( + id: UUID(0), + createdAt: Date(timeIntervalSince1970: 1_234_567_890), + firstName: firstName, + lastName: lastName, + updatedAt: Date(timeIntervalSince1970: 1_234_567_890) + ) + } +} + +extension Employee.Update { + static var mock: Self { + .init(firstName: "Testy", lastName: "McTestface", active: false) + } +} + +extension User { + static var mock: Self { + .init(id: UUID(0), email: "test@example.com", username: "test") + } +} + +extension User.Create { + static var mock: Self { + .init(username: "test", email: "test@example.com", password: "super-secret", confirmPassword: "super-secret") + } +} + +extension User.Token { + static var mock: Self { + .init(id: UUID(1), userID: UUID(0), value: "test-token") + } +} + +extension User.Update { + static var mock: Self { + User.Update(username: "test", email: "test@test.com") + } +} + +extension Vendor { + static var mock: Self { + .init(id: UUID(0), name: "Test", branches: nil, createdAt: .mock, updatedAt: .mock) + } +} + +extension Vendor.Create { + static var mock: Self { + .init(name: "Test") + } +} + +extension Vendor.Update { + static var mock: Self { + .init(name: "Test") + } +} + +extension VendorBranch { + static var mock: Self { + .init(id: UUID(1), name: "Mock", vendorID: UUID(0), createdAt: .mock, updatedAt: .mock) + } +} + +extension VendorBranch.Create { + static var mock: Self { + .init(name: "Mock", vendorID: UUID(0)) + } +} + +extension VendorBranch.Detail { + static var mock: Self { + .init(id: UUID(1), name: "Mock", vendor: .mock, createdAt: .mock, updatedAt: .mock) + } +} + +extension PurchaseOrder { + static var mock: Self { + .init( + id: 1, + workOrder: 12245, + materials: "foo", + customer: "Testy McTestface", + truckStock: true, + createdBy: .mock, + createdFor: .mock, + vendorBranch: .mock, + createdAt: .mock, + updatedAt: .mock + ) + } +} diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.1.html new file mode 100644 index 0000000..2feb593 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.1.html @@ -0,0 +1,51 @@ + + + + Purchase Orders + + + + + + + +
+ + + +
+
+

Employees

+
+

Employees are who purchase orders can be issued to.

+
+
+
+
+ + + + + + + + + + + + + +
Name + +
Testy Mctestface + +
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.2.html new file mode 100644 index 0000000..541cae9 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.2.html @@ -0,0 +1,15 @@ +
+
+ +
+
+
+ +
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.3.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.3.html new file mode 100644 index 0000000..46bbcff --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.3.html @@ -0,0 +1,16 @@ +
+
+ +
+
+
+ +
+ +
+
+ + +
+
+
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.4.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.4.html new file mode 100644 index 0000000..841b221 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.4.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.5.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.5.html new file mode 100644 index 0000000..84fc7e7 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.5.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.6.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.6.html new file mode 100644 index 0000000..dab18db --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/employeeViews.6.html @@ -0,0 +1,6 @@ + + Testy Mctestface + + + + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/loginViews.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/loginViews.1.html new file mode 100644 index 0000000..68b3764 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/loginViews.1.html @@ -0,0 +1,34 @@ + + + + Purchase Orders + + + + + + + +
+ +
+
+

Login

+
+

+
+
+
+ +
+ +
+
+ +
+
+ +
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.1.html new file mode 100644 index 0000000..82fb8b5 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.1.html @@ -0,0 +1,53 @@ + + + + Purchase Orders + + + + + + + +
+ + + +
+
+

Users

+
+

Users are who can login and issue purchase orders for employees.

+
+
+
+
+ + + + + + + + + + + + + + + +
UsernameEmail + +
testtest@example.com + +
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.2.html new file mode 100644 index 0000000..59ee509 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.2.html @@ -0,0 +1,22 @@ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.3.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.3.html new file mode 100644 index 0000000..d02d441 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.3.html @@ -0,0 +1,7 @@ + + test + test@example.com + + + + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html new file mode 100644 index 0000000..bd0fc68 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.4.html @@ -0,0 +1,18 @@ +
+
+ +
+
+
+ + + Email: + +
+
Created:Updated:
+
+ + +
+
+
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.5.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.5.html new file mode 100644 index 0000000..d02d441 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userViews.5.html @@ -0,0 +1,7 @@ + + test + test@example.com + + + + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.1.html new file mode 100644 index 0000000..94c82f7 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.1.html @@ -0,0 +1,8 @@ +
    +
  • + Mock + +
  • +
\ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.2.html new file mode 100644 index 0000000..744eda4 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.2.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.3.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.3.html new file mode 100644 index 0000000..c6916fe --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.3.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.4.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.4.html new file mode 100644 index 0000000..c7ed01a --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorBranchViews.4.html @@ -0,0 +1,6 @@ +
  • + Mock + +
  • \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.1.html new file mode 100644 index 0000000..dfb0600 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.1.html @@ -0,0 +1,53 @@ + + + + Purchase Orders + + + + + + + +
    + + + +
    +
    +

    Vendors

    +
    +

    Vendors are where purchase orders can be issued to.

    +
    +
    +
    +
    + + + + + + + + + + + + + + + +
    NameBranches + +
    Test(0) Branches + +
    +
    + + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.2.html new file mode 100644 index 0000000..66f77c7 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.2.html @@ -0,0 +1,11 @@ +
    +
    + +
    +
    +
    + + +
    +
    +
    \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.3.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.3.html new file mode 100644 index 0000000..7feeda8 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.3.html @@ -0,0 +1,43 @@ +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    Branches

    +
    + + + +
    +
    + +
    +
    + + + + + + + + + + + + + + + +
    NameBranches + +
    Test(0) Branches + +
    +
    \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.4.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.4.html new file mode 100644 index 0000000..5312bd8 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.4.html @@ -0,0 +1,21 @@ +
    +
    + +
    +
    +
    + + + +
    +
    +

    Branches

    +
    + + + +
    +
    + +
    +
    \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.5.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.5.html new file mode 100644 index 0000000..5312bd8 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/vendorViews.5.html @@ -0,0 +1,21 @@ +
    +
    + +
    +
    +
    + + + +
    +
    +

    Branches

    +
    + + + +
    +
    + +
    +
    \ No newline at end of file