├── .gitignore ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── ViewKit │ ├── Exports.swift │ ├── Extensions │ ├── EventLoopFuture+Throwing.swift │ └── Request+Nonce.swift │ ├── Form │ ├── FileValue.swift │ ├── FormFieldOption │ │ ├── FormFieldOption+Static.swift │ │ ├── FormFieldOption.swift │ │ └── FormFieldOptionRepresentable.swift │ ├── FormFields │ │ ├── ArraySelectionFormField.swift │ │ ├── FileFormField.swift │ │ ├── FormField+Validators.swift │ │ ├── FormField.swift │ │ ├── FormFieldRepresentable.swift │ │ └── SelectionFormField.swift │ ├── FormInput.swift │ └── Forms │ │ ├── Form.swift │ │ └── ModelForm.swift │ ├── Leaf │ └── LeafContextRepresentable.swift │ └── ViewController │ ├── AdminViewController.swift │ ├── Create │ ├── CreateViewController+Public.swift │ └── CreateViewController.swift │ ├── Delete │ ├── DeleteViewController+Public.swift │ └── DeleteViewController.swift │ ├── Get │ ├── GetViewController+Public.swift │ └── GetViewController.swift │ ├── IdentifiableViewController.swift │ ├── List │ ├── ListPage.swift │ ├── ListPageInfo.swift │ ├── ListSort.swift │ ├── ListViewController+Public.swift │ └── ListViewController.swift │ ├── Update │ ├── UpdateViewController+Public.swift │ └── UpdateViewController.swift │ └── ViewController.swift └── Tests └── ViewKitTests ├── ExampleController.swift ├── ExampleForm.swift ├── ExampleModel.swift └── ViewKitTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | .swiftpm 4 | Packages 5 | *.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2018-2020 Binary Birds 5 | 6 | Authors: 7 | 8 | Tibor Bodecs 9 | 10 | Everyone is permitted to copy and distribute verbatim or modified 11 | copies of this license document, and changing it is allowed as long 12 | as the name is changed. 13 | 14 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 15 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 16 | 17 | 0. You just DO WHAT THE FUCK YOU WANT TO. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | swift test --enable-test-discovery --parallel 3 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "async-http-client", 6 | "repositoryURL": "https://github.com/swift-server/async-http-client.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "0dda95cffcdf3d96ca551e5701efd1661cf31374", 10 | "version": "1.2.5" 11 | } 12 | }, 13 | { 14 | "package": "async-kit", 15 | "repositoryURL": "https://github.com/vapor/async-kit.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "5760c79afb8ebc24fd3251fdd9724af225fdf1f9", 19 | "version": "1.3.0" 20 | } 21 | }, 22 | { 23 | "package": "console-kit", 24 | "repositoryURL": "https://github.com/vapor/console-kit.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "08f36a30e0893e6a52fefbf1c2db4a6bc1288ba2", 28 | "version": "4.2.5" 29 | } 30 | }, 31 | { 32 | "package": "fluent", 33 | "repositoryURL": "https://github.com/vapor/fluent", 34 | "state": { 35 | "branch": null, 36 | "revision": "4004e926cdef1fbb937501c8a60554349cced675", 37 | "version": "4.2.0" 38 | } 39 | }, 40 | { 41 | "package": "fluent-kit", 42 | "repositoryURL": "https://github.com/vapor/fluent-kit", 43 | "state": { 44 | "branch": null, 45 | "revision": "9d47c328bf83999968c12a3bc94ead1d706ad4a9", 46 | "version": "1.11.0" 47 | } 48 | }, 49 | { 50 | "package": "multipart-kit", 51 | "repositoryURL": "https://github.com/vapor/multipart-kit.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "73706f1883f2ba950d41f18aec7e3a53766d4a6d", 55 | "version": "4.0.0" 56 | } 57 | }, 58 | { 59 | "package": "routing-kit", 60 | "repositoryURL": "https://github.com/vapor/routing-kit.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "4cf052b78aebaf1b23f2264ce04d57b4b6eb5254", 64 | "version": "4.2.0" 65 | } 66 | }, 67 | { 68 | "package": "sql-kit", 69 | "repositoryURL": "https://github.com/vapor/sql-kit.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "2e1ffbfa5ec6c87f6c932ecebbc11c7a5bc0115d", 73 | "version": "3.8.1" 74 | } 75 | }, 76 | { 77 | "package": "swift-backtrace", 78 | "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "93b3d9a76454e05379a32a2f3b2a1f5a7794b414", 82 | "version": "1.2.1" 83 | } 84 | }, 85 | { 86 | "package": "swift-crypto", 87 | "repositoryURL": "https://github.com/apple/swift-crypto.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "0141f53dd525706c803b0c20aa8ad36f9ecd45e5", 91 | "version": "1.1.5" 92 | } 93 | }, 94 | { 95 | "package": "swift-log", 96 | "repositoryURL": "https://github.com/apple/swift-log.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", 100 | "version": "1.4.2" 101 | } 102 | }, 103 | { 104 | "package": "swift-metrics", 105 | "repositoryURL": "https://github.com/apple/swift-metrics.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "e382458581b05839a571c578e90060fff499f101", 109 | "version": "2.1.1" 110 | } 111 | }, 112 | { 113 | "package": "swift-nio", 114 | "repositoryURL": "https://github.com/apple/swift-nio.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "6d3ca7e54e06a69d0f2612c2ce8bb8b7319085a4", 118 | "version": "2.26.0" 119 | } 120 | }, 121 | { 122 | "package": "swift-nio-extras", 123 | "repositoryURL": "https://github.com/apple/swift-nio-extras.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "de1c80ad1fdff1ba772bcef6b392c3ef735f39a6", 127 | "version": "1.8.0" 128 | } 129 | }, 130 | { 131 | "package": "swift-nio-http2", 132 | "repositoryURL": "https://github.com/apple/swift-nio-http2.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "f4736a3b78a2bbe3feb7fc0f33f6683a8c27974c", 136 | "version": "1.16.3" 137 | } 138 | }, 139 | { 140 | "package": "swift-nio-ssl", 141 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "bbb38fbcbbe9dc4665b2c638dfa5681b01079bfb", 145 | "version": "2.10.4" 146 | } 147 | }, 148 | { 149 | "package": "swift-nio-transport-services", 150 | "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "1d28d48e071727f4558a8a4bb1894472abc47a58", 154 | "version": "1.9.2" 155 | } 156 | }, 157 | { 158 | "package": "tau", 159 | "repositoryURL": "https://github.com/binarybirds/tau", 160 | "state": { 161 | "branch": null, 162 | "revision": "935292e605b815833629ba31a82480a7c8b23217", 163 | "version": "1.0.0" 164 | } 165 | }, 166 | { 167 | "package": "tau-kit", 168 | "repositoryURL": "https://github.com/binarybirds/tau-kit", 169 | "state": { 170 | "branch": null, 171 | "revision": "907a31cb6e2937db0a7aba6f5d90a634d30f26cd", 172 | "version": "1.0.0" 173 | } 174 | }, 175 | { 176 | "package": "vapor", 177 | "repositoryURL": "https://github.com/vapor/vapor", 178 | "state": { 179 | "branch": null, 180 | "revision": "4ba5c2d2112005e4a07cedb43ec52b47175d67ce", 181 | "version": "4.41.3" 182 | } 183 | }, 184 | { 185 | "package": "websocket-kit", 186 | "repositoryURL": "https://github.com/vapor/websocket-kit.git", 187 | "state": { 188 | "branch": null, 189 | "revision": "2b06a70dfcfa76a2e5079f60e3ae911511f09db0", 190 | "version": "2.1.2" 191 | } 192 | } 193 | ] 194 | }, 195 | "version": 1 196 | } 197 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "view-kit", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .library(name: "ViewKit", targets: ["ViewKit"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/vapor/vapor", from: "4.41.0"), 14 | .package(url: "https://github.com/vapor/fluent", from: "4.0.0"), 15 | .package(url: "https://github.com/vapor/fluent-kit", from: "1.0.0"), 16 | .package(url: "https://github.com/binarybirds/tau", from: "1.0.0"), 17 | ], 18 | targets: [ 19 | .target(name: "ViewKit", dependencies: [ 20 | .product(name: "Vapor", package: "vapor"), 21 | .product(name: "Fluent", package: "fluent"), 22 | .product(name: "Tau", package: "tau"), 23 | ]), 24 | .testTarget(name: "ViewKitTests", dependencies: [ 25 | .target(name: "ViewKit"), 26 | .product(name: "XCTFluent", package: "fluent-kit"), 27 | .product(name: "XCTVapor", package: "vapor"), 28 | ]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ViewKit 2 | 3 | A generic, reusable view layer for building (not just) admin interfaces using Vapor 4. 4 | 5 | 6 | ## Install 7 | 8 | Add the repository as a Swift package dependency: 9 | 10 | ```swift 11 | .package(url: "https://github.com/binarybirds/view-kit.git", from: "1.3.0-rc"), 12 | ``` 13 | 14 | Add ViewKit to the target dependencies: 15 | 16 | ```swift 17 | .product(name: "ViewKit", package: "view-kit"), 18 | ``` 19 | 20 | Update the packages and you are ready to use ViewKit. 21 | -------------------------------------------------------------------------------- /Sources/ViewKit/Exports.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Exports.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 05. 02.. 6 | // 7 | 8 | @_exported import Vapor 9 | @_exported import Fluent 10 | @_exported import Tau 11 | -------------------------------------------------------------------------------- /Sources/ViewKit/Extensions/EventLoopFuture+Throwing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventLoopFuture+Throwing.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | extension EventLoopFuture { 9 | 10 | /// throwing version of the .flatMap function 11 | func throwingFlatMap(file: StaticString = #file, 12 | line: UInt = #line, 13 | _ callback: @escaping (Value) throws -> EventLoopFuture) -> EventLoopFuture { 14 | flatMap(file: file, line: line) { [self] value in 15 | do { 16 | return try callback(value) 17 | } 18 | catch { 19 | return eventLoop.makeFailedFuture(error, file: file, line: line) 20 | } 21 | } 22 | } 23 | 24 | /// throwing version of the .map function 25 | func throwingMap(file: StaticString = #file, 26 | line: UInt = #line, 27 | _ callback: @escaping (Value) throws -> NewValue) -> EventLoopFuture { 28 | flatMapThrowing(file: file, line: line, callback) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ViewKit/Extensions/Request+Nonce.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Request+Nonce.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 11. 17.. 6 | // 7 | 8 | public extension Request { 9 | 10 | /// returns a nonce session key for a given type and identifier 11 | private func getNonceSessionKey(for type: String, id: String) -> String { 12 | "\(type)-\(id)-nonce" 13 | } 14 | 15 | /// generates a nonce and saves it in the session storage for a given key and identifier 16 | func generateNonce(for type: String, id: String) -> String { 17 | let token = [UInt8].random(count: 32).base64 18 | session.data[getNonceSessionKey(for: type, id: id)] = token 19 | return token 20 | } 21 | 22 | /// validates a nonce, then removes a given nonce from the session storage 23 | func useNonce(for type: String, id: String, token: String) throws { 24 | let nonceSessionKey = getNonceSessionKey(for: type, id: id) 25 | guard let existingToken = session.data[nonceSessionKey] else { 26 | throw Abort(.forbidden) 27 | } 28 | session.data[nonceSessionKey] = nil 29 | guard existingToken == token else { 30 | throw Abort(.forbidden) 31 | } 32 | } 33 | 34 | func validateFormToken(for key: String) throws { 35 | let context = try content.decode(FormInput.self) 36 | try useNonce(for: key, id: context.formId, token: context.formToken) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FileValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileValue.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 01.. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension File { 11 | 12 | var byteBuffer: ByteBuffer { data } 13 | 14 | var dataValue: Data? { byteBuffer.getData(at: 0, length: byteBuffer.readableBytes) } 15 | } 16 | 17 | /// represents a file data value 18 | public final class FileValue: TemplateDataRepresentable { 19 | 20 | public struct TemporaryFile: TemplateDataRepresentable { 21 | public let key: String 22 | public let name: String 23 | 24 | public init(key: String, name: String) { 25 | self.key = key 26 | self.name = name 27 | } 28 | 29 | public var templateData: TemplateData { 30 | .dictionary([ 31 | "key": key, 32 | "name": name, 33 | ]) 34 | } 35 | } 36 | 37 | public var file: File? 38 | public var originalKey: String? 39 | public var delete: Bool 40 | public var temporaryFile: TemporaryFile? 41 | 42 | public init(file: File? = nil, originalKey: String? = nil, delete: Bool = false, temporaryFile: TemporaryFile? = nil) { 43 | self.file = file 44 | self.originalKey = originalKey 45 | self.delete = delete 46 | self.temporaryFile = temporaryFile 47 | } 48 | 49 | public var templateData: TemplateData { 50 | .dictionary([ 51 | "key": .string(temporaryFile?.key ?? originalKey), 52 | "originalKey": .string(originalKey), 53 | "temporaryFile": temporaryFile.templateData, 54 | "delete": .bool(delete), 55 | ]) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFieldOption/FormFieldOption+Static.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldOption+Static.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | /// various form field options for common use-cases 9 | public extension FormFieldOption { 10 | 11 | /// constructs a new set of yes and no options 12 | static func yesNo() -> [FormFieldOption] { 13 | ["yes", "no"].map { .init(key: $0, label: $0.capitalized) } 14 | } 15 | 16 | /// constructs a new set of boolean options 17 | static func trueFalse() -> [FormFieldOption] { 18 | [true, false].map { .init(key: String($0), label: String($0).capitalized) } 19 | } 20 | 21 | /// constructs a new set of options based on the given integer numbers 22 | static func numbers(_ numbers: [Int]) -> [FormFieldOption] { 23 | numbers.map { .init(key: String($0), label: String($0)) } 24 | } 25 | 26 | /// available locales 27 | static var locales: [FormFieldOption] { 28 | Locale.availableIdentifiers 29 | .map { .init(key: $0, label: Locale.autoupdatingCurrent.localizedString(forIdentifier: $0) ?? $0) } 30 | .sorted(by: { $0.label < $1.label }) 31 | } 32 | 33 | /// NOTE: experimental gmt timezones 34 | static var gmtTimezones: [FormFieldOption] { 35 | TimeZone.knownTimeZoneIdentifiers 36 | .compactMap { TimeZone.init(identifier: $0) } 37 | .sorted(by: { $0.secondsFromGMT() < $1.secondsFromGMT() }) 38 | .map { tz in 39 | let id = tz.identifier 40 | // let abbrev = tz.abbreviation() ?? id 41 | //let city = tz.identifier.split(separator: "/").dropFirst().joined(separator: ", ").replacingOccurrences(of: "_", with: " ") 42 | //let city = tz.identifier.replacingOccurrences(of: "_", with: " ").replacingOccurrences(of: "/", with: ", ") 43 | let city = tz.identifier.replacingOccurrences(of: "_", with: " ").split(separator: "/").reversed().joined(separator: ", ") 44 | let name = tz.localizedName(for: .standard, locale: Locale.autoupdatingCurrent) ?? id 45 | let shortName = name.split(separator: " ").compactMap { $0.first }.map { String($0) }.joined() 46 | let seconds = tz.secondsFromGMT() 47 | let hours = seconds / 3600 48 | let minutes = abs(seconds / 60) % 60 49 | let gmt = String(format: "%+.2d:%.2d", hours, minutes) 50 | return .init(key: id, label: "GMT\(gmt) - \(city) (\(shortName))") 51 | } 52 | } 53 | 54 | /// NOTE: experimental unique timezones (with most popular locations) 55 | static var uniqueTimeZones: [FormFieldOption] { 56 | return [ 57 | "Pacific/Pago_Pago": "GMT-11:00 - Midway Island, Samoa", 58 | "Pacific/Honolulu": "GMT-10:00 - Hawaii", 59 | "America/Anchorage": "GMT-9:00 - Alaska", 60 | "America/Tijuana": "GMT-8:00 - Chihuahua, La Paz, Mazatlan", 61 | "America/Los_Angeles": "GMT-8:00 - Pacific Time (US & Canada), Tijuana", 62 | "America/Phoenix": "GMT-7:00 - Arizona", 63 | "America/Denver": "GMT-7:00 - Mountain Time (US & Canada)", 64 | "America/Costa_Rica": "GMT-6:00 - Central America", 65 | "America/Chicago": "GMT-6:00 - Central Time (US & Canada)", 66 | "America/Mexico_City": "GMT-6:00 - Guadalajara, Mexico City, Monterrey", 67 | "America/Regina": "GMT-6:00 - Saskatchewan", 68 | "America/Bogota": "GMT-5:00 - Bogota, Lima, Quito", 69 | "America/New_York": "GMT-5:00 - Eastern Time (US & Canada)", 70 | "America/Fort_Wayne": "GMT-5:00 - Indiana (East)", 71 | "America/Caracas": "GMT-4:00 - Caracas, La Paz", 72 | "America/Halifax": "GMT-4:00 - Atlantic Time (Canada), Brasilia, Greenland", 73 | "America/Santiago": "GMT-4:00 - Santiago", 74 | "America/St_Johns": "GMT-3:30 - Newfoundland", 75 | "America/Argentina/Buenos_Aires": "GMT-3:00 - Buenos Aires, Georgetown", 76 | "America/Noronha": "GMT-2:00 - Fernando de Noronha", 77 | "Atlantic/Azores": "GMT-1:00 - Azores", 78 | "Atlantic/Cape_Verde": "GMT-1:00 - Cape Verde Is.", 79 | "Etc/UTC": "GMT - UTC", 80 | "Africa/Casablanca": "GMT+0:00 - Casablanca, Monrovia", 81 | "Europe/Dublin": "GMT+0:00 - Dublin, Edinburgh, London", 82 | "Europe/Amsterdam": "GMT+1:00 - Amsterdam, Berlin, Rome, Stockholm, Vienna", 83 | "Europe/Prague": "GMT+1:00 - Belgrade, Bratislava, Budapest, Prague", 84 | "Europe/Paris": "GMT+1:00 - Brussels, Copenhagen, Madrid, Paris", 85 | "Europe/Warsaw": "GMT+1:00 - Sarajevo, Skopje, Warsaw, Zagreb", 86 | "Africa/Lagos": "GMT+1:00 - West Central Africa", 87 | "Europe/Istanbul": "GMT+2:00 - Athens, Beirut, Bucharest, Istanbul", 88 | "Africa/Cairo": "GMT+2:00 - Cairo, Egypt", 89 | "Africa/Maputo": "GMT+2:00 - Harare", 90 | "Europe/Kiev": "GMT+2:00 - Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius", 91 | "Asia/Jerusalem": "GMT+2:00 - Jerusalem", 92 | "Africa/Johannesburg": "GMT+2:00 - Pretoria", 93 | "Asia/Baghdad": "GMT+3:00 - Baghdad", 94 | "Asia/Riyadh": "GMT+3:00 - Kuwait, Nairobi, Riyadh", 95 | "Europe/Moscow": "GMT+3:00 - Moscow, St. Petersburg, Volgograd", 96 | "Asia/Tehran": "GMT+3:30 - Tehran", 97 | "Asia/Dubai": "GMT+4:00 - Abu Dhabi, Muscat", 98 | "Asia/Baku": "GMT+4:00 - Baku, Tbilisi, Yerevan", 99 | "Asia/Kabul": "GMT+4:30 - Kabul", 100 | "Asia/Karachi": "GMT+5:00 - Islamabad, Karachi, Tashkent", 101 | "Asia/Yekaterinburg": "GMT+5:00 - Yekaterinburg", 102 | "Asia/Kolkata": "GMT+5:30 - Chennai, Calcutta, Mumbai, New Delhi", 103 | "Asia/Kathmandu": "GMT+5:45 - Katmandu", 104 | "Asia/Almaty": "GMT+6:00 - Almaty, Novosibirsk", 105 | "Asia/Dhaka": "GMT+6:00 - Astana, Dhaka, Sri Jayawardenepura", 106 | "Asia/Rangoon": "GMT+6:30 - Rangoon", 107 | "Asia/Bangkok": "GMT+7:00 - Bangkok, Hanoi, Jakarta", 108 | "Asia/Krasnoyarsk": "GMT+7:00 - Krasnoyarsk", 109 | "Asia/Hong_Kong": "GMT+8:00 - Beijing, Chongqing, Hong Kong, Urumqi", 110 | "Asia/Irkutsk": "GMT+8:00 - Irkutsk, Ulaan Bataar", 111 | "Asia/Singapore": "GMT+8:00 - Kuala Lumpur, Perth, Singapore, Taipei", 112 | "Asia/Tokyo": "GMT+9:00 - Osaka, Sapporo, Tokyo", 113 | "Asia/Seoul": "GMT+9:00 - Seoul", 114 | "Asia/Yakutsk": "GMT+9:00 - Yakutsk", 115 | "Australia/Adelaide": "GMT+9:30 - Adelaide", 116 | "Australia/Darwin": "GMT+9:30 - Darwin", 117 | "Australia/Brisbane": "GMT+10:00 - Brisbane, Guam, Port Moresby", 118 | "Australia/Sydney": "GMT+10:00 - Canberra, Hobart, Melbourne, Sydney, Vladivostok", 119 | "Asia/Magadan": "GMT+11:00 - Magadan, Soloman Is., New Caledonia", 120 | "Pacific/Auckland": "GMT+12:00 - Auckland, Wellington", 121 | "Pacific/Fiji": "GMT+12:00 - Fiji, Kamchatka, Marshall Is.", 122 | "Pacific/Kwajalein": "GMT+12:00 - International Date Line West", 123 | ] 124 | .sorted(by: { TimeZone(identifier: $0.key)!.secondsFromGMT() < TimeZone(identifier: $1.key)!.secondsFromGMT() }) 125 | .map { .init(key: $0, label: $1) } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFieldOption/FormFieldOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldStringOption.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | 9 | /// selectable option 10 | public struct FormFieldOption { 11 | 12 | public let key: String 13 | public let label: String 14 | 15 | public init(key: String, label: String) { 16 | self.key = key 17 | self.label = label 18 | } 19 | 20 | public var templateData: TemplateData { 21 | .dictionary([ 22 | "key": key, 23 | "label": label, 24 | ]) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFieldOption/FormFieldOptionRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldStringOptionRepresentable.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | /// form field option representable 9 | public protocol FormFieldOptionRepresentable { 10 | 11 | /// transforms the current object to a FormFieldStringOption 12 | var formFieldOption: FormFieldOption { get } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFields/ArraySelectionFormField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArraySelectionFormField.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 05.. 6 | // 7 | 8 | public final class ArraySelectionFormField: FormFieldRepresentable { 9 | 10 | public var key: String 11 | public var name: String? 12 | public var error: String? 13 | public var values: [Value] 14 | public var options: [FormFieldOption] 15 | public var validators: [(ArraySelectionFormField) -> Bool] 16 | 17 | public init(key: String, 18 | values: [Value] = [], 19 | options: [FormFieldOption] = [], 20 | name: String? = nil, 21 | validators: [(ArraySelectionFormField) -> Bool] = [], 22 | error: String? = nil) 23 | { 24 | self.key = key 25 | self.values = values 26 | self.options = options 27 | self.name = name 28 | self.validators = validators 29 | self.error = error 30 | } 31 | 32 | public var templateData: TemplateData { 33 | .dictionary([ 34 | "key": key, 35 | "name": name, 36 | "values": values, 37 | "options": options.map(\.templateData), 38 | "error": error, 39 | ]) 40 | } 41 | 42 | public func validate() -> Bool { 43 | /// clean prevpublic override error messages 44 | error = nil 45 | /// run validators again... 46 | var isValid = true 47 | for validator in validators { 48 | /// stop if a field was already invalid 49 | isValid = isValid && validator(self) 50 | } 51 | return isValid 52 | } 53 | 54 | public func process(req: Request) { 55 | values = (try? req.content.get([Value].self, at: key)) ?? [] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFields/FileFormField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileFormField.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 09.. 6 | // 7 | 8 | public final class FileFormField: FormFieldRepresentable { 9 | 10 | public var key: String 11 | public var value: FileValue 12 | public var name: String? 13 | public var validators: [(FileFormField) -> Bool] 14 | public var error: String? 15 | 16 | public init(key: String, 17 | value: FileValue = .init(), 18 | name: String? = nil, 19 | validators: [(FileFormField) -> Bool] = [], 20 | error: String? = nil) 21 | { 22 | self.key = key 23 | self.value = value 24 | self.name = name 25 | self.validators = validators 26 | self.error = error 27 | } 28 | 29 | /// template data representation of the form field 30 | public var templateData: TemplateData { 31 | .dictionary([ 32 | "key": key, 33 | "name": name, 34 | "value": value, 35 | "error": error, 36 | ]) 37 | } 38 | 39 | /// validates a form field 40 | public func validate() -> Bool { 41 | /// clean previous error messages 42 | error = nil 43 | /// run validators again... 44 | var isValid = true 45 | for validator in validators { 46 | /// stop if a field was already invalid 47 | isValid = isValid && validator(self) 48 | } 49 | return isValid 50 | } 51 | 52 | public func process(req: Request) { 53 | 54 | let originalKey = key+"OriginalKey" 55 | let tempKey = key+"TemporaryKey" 56 | let tempNameKey = key+"TemporaryName" 57 | let deleteKey = key+"Delete" 58 | 59 | value.file = try? req.content.get(File.self, at: key) 60 | value.originalKey = try? req.content.get(String.self, at: originalKey) 61 | if 62 | let key = try? req.content.get(String.self, at: tempKey), 63 | let name = try? req.content.get(String.self, at: tempNameKey) 64 | { 65 | value.temporaryFile = .init(key: key, name: name) 66 | } 67 | value.delete = (try? req.content.get(Bool.self, at: deleteKey)) ?? false 68 | } 69 | } 70 | 71 | public extension FileFormField { 72 | 73 | func required(message: String? = nil) -> Self { 74 | validators.append({ [unowned self] field -> Bool in 75 | if 76 | (field.value.temporaryFile == nil && field.value.delete) || 77 | (field.value.temporaryFile == nil && field.value.originalKey == nil) 78 | { 79 | let message = message ?? "\(name ?? key.capitalized) is required" 80 | field.error = message 81 | return false 82 | } 83 | return true 84 | }) 85 | return self 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFields/FormField+Validators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormField+Validators.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public extension FormField where Value == String { 9 | 10 | func required(message: String? = nil) -> Self { 11 | validators.append({ [unowned self] field -> Bool in 12 | if field.value == nil || field.value!.isEmpty { 13 | let message = message ?? "\(name ?? key.capitalized) is required" 14 | field.error = message 15 | return false 16 | } 17 | return true 18 | }) 19 | return self 20 | } 21 | 22 | func length(max: Int, message: String? = nil) -> Self { 23 | validators.append({ [unowned self] field -> Bool in 24 | if field.value == nil || field.value!.count > max { 25 | let message = message ?? "\(name ?? key.capitalized) is too long (max: \(max) characters)" 26 | field.error = message 27 | return false 28 | } 29 | return true 30 | }) 31 | return self 32 | } 33 | 34 | func length(min: Int, message: String? = nil) -> Self { 35 | validators.append({ [unowned self] field -> Bool in 36 | if field.value == nil || field.value!.count < min { 37 | let message = message ?? "\(name ?? key.capitalized) is too short (min: \(min) characters)" 38 | field.error = message 39 | return false 40 | } 41 | return true 42 | }) 43 | return self 44 | } 45 | 46 | func alphanumerics(message: String? = nil) -> Self { 47 | validators.append({ [unowned self] field -> Bool in 48 | if field.value == nil || Validator.characterSet(.alphanumerics).validate(field.value!).isFailure { 49 | let message = message ?? "\(name ?? key.capitalized) should be only alphanumeric characters" 50 | field.error = message 51 | return false 52 | } 53 | return true 54 | }) 55 | return self 56 | } 57 | 58 | func email(message: String? = nil) -> Self { 59 | validators.append({ [unowned self] field -> Bool in 60 | if field.value == nil || Validator.email.validate(field.value!).isFailure { 61 | let message = message ?? "\(name ?? key.capitalized) should be a valid email address" 62 | field.error = message 63 | return false 64 | } 65 | return true 66 | }) 67 | return self 68 | } 69 | } 70 | 71 | public extension FormField where Value == Int { 72 | 73 | func required(message: String? = nil) -> Self { 74 | validators.append({ [unowned self] field -> Bool in 75 | if field.value == nil { 76 | let message = message ?? "\(name ?? key.capitalized) is required" 77 | field.error = message 78 | return false 79 | } 80 | return true 81 | }) 82 | return self 83 | } 84 | 85 | func min(_ min: Int, message: String? = nil) -> Self { 86 | validators.append({ [unowned self] field -> Bool in 87 | if field.value == nil || field.value! < min { 88 | let message = message ?? "\(name ?? key.capitalized) should be greater than \(min)" 89 | field.error = message 90 | return false 91 | } 92 | return true 93 | }) 94 | return self 95 | } 96 | 97 | func max(_ max: Int, message: String? = nil) -> Self { 98 | validators.append({ [unowned self] field -> Bool in 99 | if field.value == nil || field.value! > max { 100 | let message = message ?? "\(name ?? key.capitalized) should be less than \(max)" 101 | field.error = message 102 | return false 103 | } 104 | return true 105 | }) 106 | return self 107 | } 108 | 109 | func contains(_ values: [Int], message: String? = nil) -> Self { 110 | validators.append({ [unowned self] field -> Bool in 111 | if field.value == nil || !values.contains(field.value!) { 112 | let message = message ?? "\(name ?? key.capitalized) is an invalid value" 113 | field.error = message 114 | return false 115 | } 116 | return true 117 | }) 118 | return self 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFields/FormField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormField.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 23.. 6 | // 7 | 8 | public final class FormField: FormFieldRepresentable { 9 | 10 | public var key: String 11 | public var value: Value? 12 | public var name: String? 13 | public var validators: [(FormField) -> Bool] 14 | public var error: String? 15 | 16 | public init(key: String, 17 | value: Value? = nil, 18 | name: String? = nil, 19 | validators: [(FormField) -> Bool] = [], 20 | error: String? = nil) 21 | { 22 | self.key = key 23 | self.value = value 24 | self.name = name 25 | self.validators = validators 26 | self.error = error 27 | } 28 | 29 | /// template data representation of the form field 30 | public var templateData: TemplateData { 31 | .dictionary([ 32 | "key": key, 33 | "name": name, 34 | "value": value, 35 | "error": error, 36 | ]) 37 | } 38 | 39 | /// validates a form field 40 | public func validate() -> Bool { 41 | /// clean previous error messages 42 | error = nil 43 | /// run validators again... 44 | var isValid = true 45 | for validator in validators { 46 | /// stop if a field was already invalid 47 | isValid = isValid && validator(self) 48 | } 49 | return isValid 50 | } 51 | 52 | public func process(req: Request) { 53 | value = try? req.content.get(Value.self, at: key) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFields/FormFieldRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormFieldRepresentable.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 05.. 6 | // 7 | 8 | public protocol FormFieldRepresentable: AnyObject, TemplateDataRepresentable { 9 | 10 | var key: String { get set } 11 | 12 | /// name of the form field 13 | var name: String? { get set } 14 | 15 | /// error message 16 | var error: String? { get set } 17 | 18 | /// validates the form field 19 | func validate() -> Bool 20 | 21 | /// process input using the request 22 | func process(req: Request) 23 | } 24 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormFields/SelectionFormField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectionFormField.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 05.. 6 | // 7 | 8 | public final class SelectionFormField: FormFieldRepresentable { 9 | 10 | public var key: String 11 | public var name: String? 12 | public var error: String? 13 | public var value: Value? 14 | public var options: [FormFieldOption] 15 | public var validators: [(SelectionFormField) -> Bool] 16 | 17 | public init(key: String, 18 | value: Value? = nil, 19 | options: [FormFieldOption] = [], 20 | name: String? = nil, 21 | validators: [(SelectionFormField) -> Bool] = [], 22 | error: String? = nil) 23 | { 24 | self.key = key 25 | self.value = value 26 | self.options = options 27 | self.name = name 28 | self.validators = validators 29 | self.error = error 30 | } 31 | 32 | /// template data representation of the form field 33 | public var templateData: TemplateData { 34 | .dictionary([ 35 | "key": key, 36 | "name": name, 37 | "value": value, 38 | "options": options.map(\.templateData), 39 | "error": error, 40 | ]) 41 | } 42 | 43 | /// validates a form field 44 | public func validate() -> Bool { 45 | /// clean prevpublic override error messages 46 | error = nil 47 | /// run validators again... 48 | var isValid = true 49 | for validator in validators { 50 | /// stop if a field was already invalid 51 | isValid = isValid && validator(self) 52 | } 53 | return isValid 54 | } 55 | 56 | public func process(req: Request) { 57 | value = try? req.content.get(Value.self, at: key) 58 | } 59 | } 60 | 61 | public extension SelectionFormField where Value == String { 62 | 63 | func required(message: String? = nil) -> Self { 64 | validators.append({ [unowned self] field -> Bool in 65 | if field.value != nil { 66 | let message = message ?? "\(name ?? key.capitalized) is required" 67 | field.error = message 68 | return false 69 | } 70 | return true 71 | }) 72 | return self 73 | } 74 | 75 | func contains(_ values: [String], message: String? = nil) -> Self { 76 | validators.append({ [unowned self] field -> Bool in 77 | if field.value == nil || !values.contains(field.value!) { 78 | let message = message ?? "\(name ?? key.capitalized) is an invalid value" 79 | field.error = message 80 | return false 81 | } 82 | return true 83 | }) 84 | return self 85 | } 86 | } 87 | 88 | public extension SelectionFormField where Value == Int { 89 | 90 | func required(message: String? = nil) -> Self { 91 | validators.append({ [unowned self] field -> Bool in 92 | if field.value != nil { 93 | let message = message ?? "\(name ?? key.capitalized) is required" 94 | field.error = message 95 | return false 96 | } 97 | return true 98 | }) 99 | return self 100 | } 101 | 102 | func contains(_ values: [Int], message: String? = nil) -> Self { 103 | validators.append({ [unowned self] field -> Bool in 104 | if field.value == nil || !values.contains(field.value!) { 105 | let message = message ?? "\(name ?? key.capitalized) is an invalid value" 106 | field.error = message 107 | return false 108 | } 109 | return true 110 | }) 111 | return self 112 | } 113 | } 114 | 115 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/FormInput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormInput.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 11. 17.. 6 | // 7 | 8 | /// used to validate form token (nonce) values 9 | struct FormInput: Decodable { 10 | 11 | /// identifier of the form 12 | let formId: String 13 | /// associated token for the form 14 | let formToken: String 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/Forms/Form.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Form.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 22.. 6 | // 7 | 8 | public protocol Form: AnyObject, TemplateDataRepresentable { 9 | 10 | /// form fields 11 | var fields: [FormFieldRepresentable] { get } 12 | /// template data representation of the form fields 13 | var fieldsTemplateData: TemplateData { get } 14 | 15 | /// generic notification 16 | var notification: String? { get set } 17 | 18 | init() 19 | 20 | /// initialize form values asynchronously 21 | func initialize(req: Request) -> EventLoopFuture 22 | 23 | /// process input value from an incoming request 24 | func processFields(req: Request) 25 | 26 | /// process request from an incoming request 27 | func process(req: Request) -> EventLoopFuture 28 | 29 | /// process input value from an incoming request 30 | func processAfterFields(req: Request) -> EventLoopFuture 31 | 32 | /// validate form fields 33 | func validateFields() -> Bool 34 | /// validate after field validation happened 35 | func validateAfterFields(req: Request) -> EventLoopFuture 36 | /// validate the entire form 37 | func validate(req: Request) -> EventLoopFuture 38 | 39 | func save(req: Request) -> EventLoopFuture 40 | } 41 | 42 | public extension Form { 43 | 44 | var fields: [FormFieldRepresentable] { [] } 45 | 46 | var fieldsTemplateData: TemplateData { 47 | .dictionary(fields.reduce(into: [String: TemplateData]()) { $0[$1.key] = $1.templateData }) 48 | } 49 | 50 | var templateData: TemplateData { 51 | .dictionary([ 52 | "fields": fieldsTemplateData, 53 | "notification": .string(notification) 54 | ]) 55 | } 56 | 57 | func initialize(req: Request) -> EventLoopFuture { 58 | req.eventLoop.future() 59 | } 60 | 61 | func processFields(req: Request) { 62 | for field in fields { 63 | field.process(req: req) 64 | } 65 | } 66 | 67 | func processAfterFields(req: Request) -> EventLoopFuture { 68 | req.eventLoop.future() 69 | } 70 | 71 | func process(req: Request) -> EventLoopFuture { 72 | processFields(req: req) 73 | return processAfterFields(req: req) 74 | } 75 | 76 | func validateFields() -> Bool { 77 | var isValid = true 78 | for field in fields { 79 | let isFieldValid = field.validate() 80 | isValid = isValid && isFieldValid 81 | } 82 | return isValid 83 | } 84 | 85 | func validateAfterFields(req: Request) -> EventLoopFuture { 86 | req.eventLoop.future(true) 87 | } 88 | 89 | func validate(req: Request) -> EventLoopFuture { 90 | guard validateFields() else { 91 | return req.eventLoop.future(false) 92 | } 93 | return validateAfterFields(req: req) 94 | } 95 | 96 | func save(req: Request) -> EventLoopFuture { 97 | req.eventLoop.future() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/ViewKit/Form/Forms/ModelForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModelForm.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 11. 19.. 6 | // 7 | 8 | public protocol ModelForm: Form { 9 | 10 | associatedtype Model: Fluent.Model 11 | 12 | var modelId: Model.IDValue? { get set } 13 | 14 | func read(from: Model) 15 | func write(to: Model) 16 | 17 | func willSave(req: Request, model: Model) -> EventLoopFuture 18 | func didSave(req: Request, model: Model) -> EventLoopFuture 19 | } 20 | 21 | /// can be used to build forms with associated models 22 | public extension ModelForm { 23 | 24 | var templateData: TemplateData { 25 | .dictionary([ 26 | "modelId": modelId?.encodeToTemplateData() ?? .string(nil), 27 | "fields": fieldsTemplateData, 28 | "notification": .string(notification) 29 | ]) 30 | } 31 | 32 | func willSave(req: Request, model: Model) -> EventLoopFuture { 33 | req.eventLoop.future() 34 | } 35 | 36 | func didSave(req: Request, model: Model) -> EventLoopFuture { 37 | req.eventLoop.future() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/ViewKit/Leaf/LeafContextRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TemplateContextRepresentable.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 10. 18.. 6 | // 7 | 8 | public protocol TemplateContextRepresentable { 9 | 10 | var templateContext: Renderer.Context { get } 11 | } 12 | 13 | 14 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/AdminViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AdminViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | public protocol AdminViewController: 9 | ListViewController, 10 | GetViewController, 11 | CreateViewController, 12 | UpdateViewController, 13 | DeleteViewController 14 | { 15 | func setupRoutes(on: RoutesBuilder, as: PathComponent, createPath: PathComponent, updatePath: PathComponent, deletePath: PathComponent) 16 | } 17 | 18 | /* 19 | Routes & controller methods: 20 | ---------------------------------------- 21 | GET /[model]/ listView 22 | GET /[model]/:id getView 23 | GET /[model]/create createView 24 | + POST create 25 | GET /[model]/:id/update updateView 26 | + POST update 27 | GET /[model]/:id/delete deleteView 28 | + POST delete 29 | */ 30 | public extension AdminViewController { 31 | 32 | func setupRoutes(on builder: RoutesBuilder, 33 | as pathComponent: PathComponent, 34 | createPath: PathComponent = "create", 35 | updatePath: PathComponent = "update", 36 | deletePath: PathComponent = "delete") 37 | { 38 | let base = builder.grouped(pathComponent) 39 | setupListRoute(on: base) 40 | setupGetRoute(on: base) 41 | setupCreateRoutes(on: base, as: createPath) 42 | setupUpdateRoutes(on: base, as: updatePath) 43 | setupDeleteRoutes(on: base, as: deletePath) 44 | } 45 | } 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Create/CreateViewController+Public.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateViewController+Public.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | public extension CreateViewController { 9 | 10 | func beforeInvalidCreateFormRender(req: Request, form: CreateForm) -> EventLoopFuture { 11 | req.eventLoop.future(form) 12 | } 13 | 14 | func beforeCreateFormRender(req: Request, form: CreateForm) -> EventLoopFuture { 15 | req.eventLoop.future() 16 | } 17 | 18 | func renderCreateForm(req: Request, form: CreateForm) -> EventLoopFuture { 19 | let formId = UUID().uuidString 20 | let nonce = req.generateNonce(for: "create-form", id: formId) 21 | 22 | return beforeCreateFormRender(req: req, form: form).flatMap { 23 | var templateData = form.templateData.dictionary! 24 | templateData["formId"] = .string(formId) 25 | templateData["formToken"] = .string(nonce) 26 | return render(req: req, template: createView, context: .init(templateData)) 27 | } 28 | } 29 | 30 | func accessCreate(req: Request) -> EventLoopFuture { 31 | req.eventLoop.future(true) 32 | } 33 | 34 | func createView(req: Request) throws -> EventLoopFuture { 35 | accessCreate(req: req).flatMap { hasAccess in 36 | guard hasAccess else { 37 | return req.eventLoop.future(error: Abort(.forbidden)) 38 | } 39 | let form = CreateForm() 40 | return form.initialize(req: req).flatMap { 41 | renderCreateForm(req: req, form: form) 42 | } 43 | } 44 | } 45 | 46 | func beforeCreate(req: Request, model: Model, form: CreateForm) -> EventLoopFuture { 47 | req.eventLoop.future(model) 48 | } 49 | 50 | /* 51 | FLOW: 52 | ---- 53 | check access 54 | validate incoming from with token 55 | create form 56 | initialize form 57 | process input form 58 | validate form 59 | if invalid: 60 | -> before invalid render we can still alter the form! 61 | -> render 62 | else: 63 | create / find the model 64 | write the form content to the model 65 | before create we can still alter the model 66 | create 67 | call didSave model 68 | after create we can alter the model 69 | read the form with using new model 70 | call save form 71 | createResponse (render the form) 72 | */ 73 | func create(req: Request) throws -> EventLoopFuture { 74 | accessCreate(req: req).throwingFlatMap { hasAccess in 75 | guard hasAccess else { 76 | return req.eventLoop.future(error: Abort(.forbidden)) 77 | } 78 | try req.validateFormToken(for: "create-form") 79 | 80 | let form = CreateForm() 81 | return form.initialize(req: req) 82 | .flatMap { form.process(req: req) } 83 | .flatMap { form.validate(req: req) } 84 | .flatMap { isValid in 85 | guard isValid else { 86 | return beforeInvalidCreateFormRender(req: req, form: form).flatMap { 87 | renderCreateForm(req: req, form: $0).encodeResponse(for: req) 88 | } 89 | } 90 | let model = Model() 91 | form.write(to: model as! CreateForm.Model) 92 | 93 | return form.willSave(req: req, model: model as! CreateForm.Model) 94 | .flatMap { beforeCreate(req: req, model: model, form: form) } 95 | .flatMap { model in model.create(on: req.db).map { model } } 96 | .flatMap { model in form.didSave(req: req, model: model as! CreateForm.Model ).map { model } } 97 | .flatMap { afterCreate(req: req, form: form, model: $0) } 98 | .map { model in form.read(from: model as! CreateForm.Model); return model; } 99 | .flatMap { model in form.save(req: req).map { model } } 100 | .flatMap { createResponse(req: req, form: form, model: $0) } 101 | } 102 | } 103 | } 104 | 105 | func afterCreate(req: Request, form: CreateForm, model: Model) -> EventLoopFuture { 106 | req.eventLoop.future(model) 107 | } 108 | 109 | func createResponse(req: Request, form: CreateForm, model: Model) -> EventLoopFuture { 110 | renderCreateForm(req: req, form: form).encodeResponse(for: req) 111 | } 112 | 113 | func setupCreateRoutes(on builder: RoutesBuilder, as pathComponent: PathComponent) { 114 | builder.get(pathComponent, use: createView) 115 | builder.on(.POST, pathComponent, use: create) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Create/CreateViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public protocol CreateViewController: ViewController { 9 | 10 | associatedtype CreateForm: ModelForm 11 | 12 | /// the name of the edit view template 13 | var createView: String { get } 14 | 15 | /// used after form validation when we have an invalid form 16 | func beforeInvalidCreateFormRender(req: Request, form: CreateForm) -> EventLoopFuture 17 | 18 | /// this is called before the form rendering happens (used both in createView and updateView) 19 | func beforeCreateFormRender(req: Request, form: CreateForm) -> EventLoopFuture 20 | 21 | /// renders the form using the given template 22 | func renderCreateForm(req: Request, form: CreateForm) -> EventLoopFuture 23 | 24 | /// check if there is access to create the object, if the future the server will respond with a forbidden status 25 | func accessCreate(req: Request) -> EventLoopFuture 26 | 27 | /// this is the main view for the create controller 28 | func createView(req: Request) throws -> EventLoopFuture 29 | 30 | /// this will be called before the model is saved to the database during the create event 31 | func beforeCreate(req: Request, model: Model, form: CreateForm) -> EventLoopFuture 32 | 33 | /// create handler for the form submission 34 | func create(req: Request) throws -> EventLoopFuture 35 | 36 | /// runs after the model has been created 37 | func afterCreate(req: Request, form: CreateForm, model: Model) -> EventLoopFuture 38 | 39 | /// returns a response after the create flow 40 | func createResponse(req: Request, form: CreateForm, model: Model) -> EventLoopFuture 41 | 42 | /// setup the get and post create routes using the given builder 43 | func setupCreateRoutes(on: RoutesBuilder, as: PathComponent) 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Delete/DeleteViewController+Public.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteViewController+Public.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | public extension DeleteViewController { 9 | 10 | func accessDelete(req: Request) -> EventLoopFuture { 11 | req.eventLoop.future(true) 12 | } 13 | 14 | func deleteView(req: Request) throws -> EventLoopFuture { 15 | accessDelete(req: req).throwingFlatMap { hasAccess in 16 | guard hasAccess else { 17 | return req.eventLoop.future(error: Abort(.forbidden)) 18 | } 19 | let id = try identifier(req) 20 | let formId = UUID().uuidString 21 | let nonce = req.generateNonce(for: "delete-form", id: formId) 22 | 23 | return findBy(id, on: req.db).flatMap { model in 24 | render(req: req, template: deleteView, context: [ 25 | "formId": .string(formId), 26 | "formToken": .string(nonce), 27 | "model": model.templateData, 28 | ]) 29 | } 30 | } 31 | } 32 | 33 | func beforeDelete(req: Request, model: Model) -> EventLoopFuture { 34 | req.eventLoop.future(model) 35 | } 36 | 37 | func delete(req: Request) throws -> EventLoopFuture { 38 | accessDelete(req: req).throwingFlatMap { hasAccess in 39 | guard hasAccess else { 40 | return req.eventLoop.future(error: Abort(.forbidden)) 41 | } 42 | try req.validateFormToken(for: "delete-form") 43 | 44 | let id = try identifier(req) 45 | return findBy(id, on: req.db) 46 | .flatMap { beforeDelete(req: req, model: $0) } 47 | .flatMap { model in model.delete(on: req.db).map { model } } 48 | .flatMap { afterDelete(req: req, model: $0) } } 49 | .flatMap { deleteResponse(req: req, model: $0) } 50 | } 51 | 52 | func afterDelete(req: Request, model: Model) -> EventLoopFuture { 53 | req.eventLoop.future(model) 54 | } 55 | 56 | func deleteResponse(req: Request, model: Model) -> EventLoopFuture { 57 | req.eventLoop.future(Response(status: .ok, version: req.version)) 58 | } 59 | 60 | func setupDeleteRoutes(on builder: RoutesBuilder, as pathComponent: PathComponent) { 61 | builder.get(idPathComponent, pathComponent, use: deleteView) 62 | builder.post(idPathComponent, pathComponent, use: delete) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Delete/DeleteViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public protocol DeleteViewController: IdentifiableViewController { 9 | 10 | /// the view used to render the delete form 11 | var deleteView: String { get } 12 | 13 | /// check if there is access to delete the object, if the future the server will respond with a forbidden status 14 | func accessDelete(req: Request) -> EventLoopFuture 15 | 16 | /// this will be called before the model is deleted 17 | func beforeDelete(req: Request, model: Model) -> EventLoopFuture 18 | 19 | /// deletes a model from the database 20 | func delete(req: Request) throws -> EventLoopFuture 21 | 22 | /// this method will be called after a succesful deletion 23 | func afterDelete(req: Request, model: Model) -> EventLoopFuture 24 | 25 | /// returns a response after completing the delete request 26 | func deleteResponse(req: Request, model: Model) -> EventLoopFuture 27 | 28 | /// setup the get and post routes for the delete controller 29 | func setupDeleteRoutes(on: RoutesBuilder, as: PathComponent) 30 | } 31 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Get/GetViewController+Public.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetViewController+Public.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public extension GetViewController { 9 | 10 | func accessGet(req: Request) -> EventLoopFuture { 11 | req.eventLoop.future(true) 12 | } 13 | 14 | func beforeGet(req: Request, model: Model) -> EventLoopFuture { 15 | req.eventLoop.future(model) 16 | } 17 | 18 | func get(req: Request) throws -> EventLoopFuture { 19 | accessGet(req: req).throwingFlatMap { hasAccess in 20 | guard hasAccess else { 21 | return req.eventLoop.future(error: Abort(.forbidden)) 22 | } 23 | let id = try identifier(req) 24 | return findBy(id, on: req.db) 25 | .flatMap { beforeGet(req: req, model: $0) } 26 | .flatMap { getResponse(req: req, model: $0) } 27 | } 28 | } 29 | 30 | func getResponse(req: Request, model: Model) -> EventLoopFuture { 31 | render(req: req, template: getView, context: ["model": model.templateData]).encodeResponse(for: req) 32 | } 33 | 34 | func setupGetRoute(on builder: RoutesBuilder) { 35 | builder.get(idPathComponent, use: get) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Get/GetViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public protocol GetViewController: IdentifiableViewController { 9 | 10 | /// the name of the get view template 11 | var getView: String { get } 12 | 13 | /// check if there is access to get tehe object, if the future the server will respond with a forbidden status 14 | func accessGet(req: Request) -> EventLoopFuture 15 | 16 | /// builds the query in order to get the object in the admin interface 17 | func beforeGet(req: Request, model: Model) -> EventLoopFuture 18 | 19 | /// renders the get view 20 | func get(req: Request) throws -> EventLoopFuture 21 | 22 | /// returns a response after the get request 23 | func getResponse(req: Request, model: Model) -> EventLoopFuture 24 | 25 | /// setup get related route 26 | func setupGetRoute(on: RoutesBuilder) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/IdentifiableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IdentifiableViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | public protocol IdentifiableViewController: ViewController { 9 | 10 | /// name of the identifier key 11 | var idParamKey: String { get } 12 | 13 | /// path component based on the identifier key name 14 | var idPathComponent: PathComponent { get } 15 | 16 | func identifier(_ req: Request) throws -> Model.IDValue 17 | 18 | /// find a model by identifier if not found return with a notFound error 19 | func findBy(_ id: Model.IDValue, on: Database) -> EventLoopFuture 20 | } 21 | 22 | public extension IdentifiableViewController { 23 | 24 | var idParamKey: String { "id" } 25 | var idPathComponent: PathComponent { .init(stringLiteral: ":\(idParamKey)") } 26 | 27 | func findBy(_ id: Model.IDValue, on db: Database) -> EventLoopFuture { 28 | Model.find(id, on: db).unwrap(or: Abort(.notFound)) 29 | } 30 | } 31 | 32 | public extension IdentifiableViewController where Model.IDValue == UUID { 33 | 34 | func identifier(_ req: Request) throws -> Model.IDValue { 35 | guard 36 | let id = req.parameters.get(idParamKey), 37 | let uuid = UUID(uuidString: id) 38 | else { 39 | throw Abort(.badRequest) 40 | } 41 | return uuid 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/List/ListPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListPage.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 08. 22.. 6 | // 7 | 8 | /// a custom paged list with metadata information 9 | public struct ListPage: TemplateDataRepresentable where T: TemplateDataRepresentable { 10 | 11 | /// paged generic encodable items 12 | public let items: [T] 13 | 14 | /// additional page information 15 | public let info: ListPageInfo 16 | 17 | /// generic init method 18 | public init(_ items: [T], info: ListPageInfo) { 19 | self.items = items 20 | self.info = info 21 | } 22 | 23 | /// NOTE: we can only nest metadata if we init a new object... 24 | public func map(_ transform: (T) throws -> (U)) rethrows -> ListPage where U: TemplateDataRepresentable { 25 | try .init(items.map(transform), info: info) 26 | } 27 | 28 | public var templateData: TemplateData { 29 | .dictionary([ 30 | "items": .array(items.map(\.templateData)), 31 | "info": info.templateData 32 | ]) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/List/ListPageInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListPageInfo.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 10. 18.. 6 | // 7 | 8 | /// pagination metadata info 9 | public struct ListPageInfo: TemplateDataRepresentable { 10 | 11 | /// current page 12 | public let current: Int 13 | /// pagination limit (items per page) 14 | public let limit: Int 15 | /// total number of pages 16 | public let total: Int 17 | 18 | /// public init method 19 | public init(current: Int, limit: Int, total: Int) { 20 | self.current = current 21 | self.limit = limit 22 | self.total = total 23 | } 24 | 25 | public var templateData: TemplateData { 26 | .dictionary([ 27 | "current": .int(current), 28 | "limit": .int(limit), 29 | "total": .int(total), 30 | ]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/List/ListSort.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListSort.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 10. 19.. 6 | // 7 | 8 | public enum ListSort: String, CaseIterable { 9 | case asc 10 | case desc 11 | 12 | public var direction: DatabaseQuery.Sort.Direction { 13 | switch self { 14 | case .asc: 15 | return .ascending 16 | case .desc: 17 | return .descending 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/List/ListViewController+Public.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListViewController+Public.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | public extension ListViewController { 9 | 10 | var listOrderKey: String { "order" } 11 | var listSortKey: String { "sort" } 12 | var listSearchKey: String { "search" } 13 | var listLimitKey: String { "limit" } 14 | var listPageKey: String { "page" } 15 | 16 | var listAllowedOrders: [FieldKey] { [] } 17 | var listDefaultSort: ListSort { .asc } 18 | var listDefaultLimit: Int { 10 } 19 | 20 | func accessList(req: Request) -> EventLoopFuture { 21 | req.eventLoop.future(true) 22 | } 23 | 24 | func beforeListQuery(req: Request, queryBuilder: QueryBuilder) -> QueryBuilder { 25 | queryBuilder 26 | } 27 | 28 | func listQuery(order: FieldKey, sort: ListSort, queryBuilder qb: QueryBuilder, req: Request) -> QueryBuilder { 29 | qb.sort(order, sort.direction) 30 | } 31 | 32 | func listQuery(search: String, queryBuilder qb: QueryBuilder, req: Request) { 33 | /// default implementation is empty 34 | } 35 | 36 | func beforeListPageRender(page: ListPage) -> TemplateData { 37 | page.templateData 38 | } 39 | 40 | func listView(req: Request) throws -> EventLoopFuture { 41 | accessList(req: req).throwingFlatMap { hasAccess in 42 | guard hasAccess else { 43 | return req.eventLoop.future(error: Abort(.forbidden)) 44 | } 45 | /// first we need a QueryBuilder instance, we apply the beforeList method on the default query 46 | var qb = beforeListQuery(req: req, queryBuilder: Model.query(on: req.db)) 47 | 48 | /// next we get the sort from the query, if there was no sort key we use the default sort 49 | var sort = listDefaultSort 50 | if let sortQuery: String = req.query[listSortKey], let sortValue = ListSort(rawValue: sortQuery) { 51 | sort = sortValue 52 | } 53 | /// if custom ordering is allowed 54 | if !listAllowedOrders.isEmpty { 55 | /// we check for a new order using the query, otherwise we use the first element of the allowed orders 56 | let orderValue: String = req.query[listOrderKey] ?? listAllowedOrders[0].description 57 | let order = FieldKey(stringLiteral: orderValue) 58 | /// only allow ordering if the order value is in the allowed orders array 59 | if listAllowedOrders.contains(order) { 60 | qb = listQuery(order: order, sort: sort, queryBuilder: qb, req: req) 61 | } 62 | } 63 | 64 | /// check if there is a non-empty search term and apply the search term using the custom search method 65 | if let searchTerm: String = req.query[listSearchKey], !searchTerm.isEmpty { 66 | qb = qb.group(.or) { listQuery(search: searchTerm, queryBuilder: $0, req: req) } 67 | } 68 | 69 | /// apply the limit and page properties 70 | let limit: Int = req.query[listLimitKey] ?? listDefaultLimit 71 | let page: Int = max((req.query[listPageKey] ?? 1), 1) 72 | 73 | /// calculate the start and end position 74 | let start: Int = (page - 1) * limit 75 | let end: Int = page * limit 76 | 77 | /// count the total number of elements for the page info 78 | let count = qb.count() 79 | /// set the range filter and request all the elements in the given range 80 | let items = qb.copy().range(start.. ListPage in 84 | let totalPages = Int(ceil(Float(total) / Float(limit))) 85 | return ListPage(models, info: .init(current: page, limit: limit, total: totalPages)) 86 | } 87 | /// map the page elements to template values & render the list view 88 | .map { beforeListPageRender(page: $0) } 89 | .flatMap { render(req: req, template: listView, context: ["list": $0]) } 90 | } 91 | } 92 | 93 | func setupListRoute(on builder: RoutesBuilder) { 94 | builder.get(use: listView) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/List/ListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public protocol ListViewController: ViewController { 9 | 10 | /// the name of the list view template 11 | var listView: String { get } 12 | 13 | /// url query parameter list order key 14 | var listOrderKey: String { get } 15 | 16 | /// url query parameter list sort key 17 | var listSortKey: String { get } 18 | 19 | /// url query parameter list search key 20 | var listSearchKey: String { get } 21 | 22 | /// url query parameter list limit key 23 | var listLimitKey: String { get } 24 | 25 | /// url query parameter list page key 26 | var listPageKey: String { get } 27 | 28 | /// returns allowed order by fields if empty list can't be ordered 29 | var listAllowedOrders: [FieldKey] { get } 30 | 31 | /// default sort direction 32 | var listDefaultSort: ListSort { get } 33 | 34 | /// default list limit 35 | var listDefaultLimit: Int { get } 36 | 37 | /// check if there is access to list objects, if the future the server will respond with a forbidden status 38 | func accessList(req: Request) -> EventLoopFuture 39 | 40 | /// builds the query in order to list objects in the admin interface 41 | func beforeListQuery(req: Request, queryBuilder: QueryBuilder) -> QueryBuilder 42 | 43 | /// implement this method if you want to alter the sort or order function for a given field (e.g order by a joined field) 44 | func listQuery(order: FieldKey, sort: ListSort, queryBuilder: QueryBuilder, req: Request) -> QueryBuilder 45 | 46 | /// search 47 | func listQuery(search: String, queryBuilder: QueryBuilder, req: Request) 48 | 49 | /// this method is used before a page object gets rendered, you can alter the returned TemplateData as needed 50 | func beforeListPageRender(page: ListPage) -> TemplateData 51 | 52 | /// renders the list view 53 | func listView(req: Request) throws -> EventLoopFuture 54 | 55 | /// setup list related routes 56 | func setupListRoute(on: RoutesBuilder) 57 | } 58 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Update/UpdateViewController+Public.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateViewController+Public.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 12. 04.. 6 | // 7 | 8 | public extension UpdateViewController { 9 | 10 | func beforeInvalidUpdateFormRender(req: Request, form: UpdateForm) -> EventLoopFuture { 11 | req.eventLoop.future(form) 12 | } 13 | 14 | func beforeUpdateFormRender(req: Request, form: UpdateForm) -> EventLoopFuture { 15 | req.eventLoop.future() 16 | } 17 | 18 | func renderUpdateForm(req: Request, form: UpdateForm) -> EventLoopFuture { 19 | let formId = UUID().uuidString 20 | let nonce = req.generateNonce(for: "update-form", id: formId) 21 | 22 | return beforeUpdateFormRender(req: req, form: form).flatMap { 23 | var templateData = form.templateData.dictionary! 24 | templateData["formId"] = .string(formId) 25 | templateData["formToken"] = .string(nonce) 26 | return render(req: req, template: updateView, context: .init(templateData)) 27 | } 28 | } 29 | 30 | func accessUpdate(req: Request) -> EventLoopFuture { 31 | req.eventLoop.future(true) 32 | } 33 | 34 | func updateView(req: Request) throws -> EventLoopFuture { 35 | accessUpdate(req: req).throwingFlatMap { hasAccess in 36 | guard hasAccess else { 37 | return req.eventLoop.future(error: Abort(.forbidden)) 38 | } 39 | let id = try identifier(req) 40 | let form = UpdateForm() 41 | form.modelId = (id as! UpdateForm.Model.IDValue) 42 | return findBy(id, on: req.db).flatMap { model in 43 | return form.initialize(req: req).flatMap { 44 | form.read(from: model as! UpdateForm.Model) 45 | return renderUpdateForm(req: req, form: form) 46 | } 47 | } 48 | } 49 | } 50 | 51 | func beforeUpdate(req: Request, model: Model, form: UpdateForm) -> EventLoopFuture { 52 | req.eventLoop.future(model) 53 | } 54 | 55 | /* 56 | FLOW: 57 | ---- 58 | check access 59 | validate incoming from with token 60 | create form 61 | initialize form 62 | process input form 63 | validate form 64 | if invalid: 65 | -> before invalid render we can still alter the form! 66 | -> render 67 | else: 68 | create / find the model 69 | write the form content to the model 70 | before update we can still alter the model 71 | update 72 | save form 73 | after create we can alter the model 74 | read the form with using new model 75 | createResponse (render the form) 76 | */ 77 | func update(req: Request) throws -> EventLoopFuture { 78 | accessUpdate(req: req).throwingFlatMap { hasAccess in 79 | guard hasAccess else { 80 | return req.eventLoop.future(error: Abort(.forbidden)) 81 | } 82 | try req.validateFormToken(for: "update-form") 83 | 84 | let id = try identifier(req) 85 | let form = UpdateForm() 86 | form.modelId = (id as! UpdateForm.Model.IDValue) 87 | 88 | return form.initialize(req: req) 89 | .flatMap { form.process(req: req) } 90 | .flatMap { form.validate(req: req) } 91 | .throwingFlatMap { isValid in 92 | guard isValid else { 93 | return beforeInvalidUpdateFormRender(req: req, form: form) 94 | .flatMap { renderUpdateForm(req: req, form: $0).encodeResponse(for: req) } 95 | } 96 | return findBy(id, on: req.db) 97 | .map { form.write(to: $0 as! UpdateForm.Model); return $0; } 98 | .flatMap { model in form.willSave(req: req, model: model as! UpdateForm.Model).map { model } } 99 | .flatMap { beforeUpdate(req: req, model: $0, form: form) } 100 | .flatMap { model in model.update(on: req.db).map { model } } 101 | .flatMap { model in form.didSave(req: req, model: model as! UpdateForm.Model).map { model } } 102 | .flatMap { afterUpdate(req: req, form: form, model: $0) } 103 | .map { form.read(from: $0 as! UpdateForm.Model); return $0; } 104 | .flatMap { model in form.save(req: req).map { model } } 105 | .flatMap { updateResponse(req: req, form: form, model: $0) } 106 | } 107 | } 108 | } 109 | 110 | func afterUpdate(req: Request, form: UpdateForm, model: Model) -> EventLoopFuture { 111 | req.eventLoop.future(model) 112 | } 113 | 114 | func updateResponse(req: Request, form: UpdateForm, model: Model) -> EventLoopFuture { 115 | renderUpdateForm(req: req, form: form).encodeResponse(for: req) 116 | } 117 | 118 | func setupUpdateRoutes(on builder: RoutesBuilder, as pathComponent: PathComponent) { 119 | builder.get(idPathComponent, pathComponent, use: updateView) 120 | builder.on(.POST, idPathComponent, pathComponent, use: update) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/Update/UpdateViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | public protocol UpdateViewController: IdentifiableViewController { 9 | 10 | associatedtype UpdateForm: ModelForm 11 | 12 | /// the name of the update view template 13 | var updateView: String { get } 14 | 15 | /// this is called after form validation when the form is invalid 16 | func beforeInvalidUpdateFormRender(req: Request, form: UpdateForm) -> EventLoopFuture 17 | 18 | /// this is called before the form rendering happens (used both in createView and updateView) 19 | func beforeUpdateFormRender(req: Request, form: UpdateForm) -> EventLoopFuture 20 | 21 | /// renders the form using the given template 22 | func renderUpdateForm(req: Request, form: UpdateForm) -> EventLoopFuture 23 | 24 | /// check if there is access to update the object, if the future the server will respond with a forbidden status 25 | func accessUpdate(req: Request) -> EventLoopFuture 26 | 27 | /// renders the update form filled with the entity 28 | func updateView(req: Request) throws -> EventLoopFuture 29 | 30 | /// this will be called before the model is updated 31 | func beforeUpdate(req: Request, model: Model, form: UpdateForm) -> EventLoopFuture 32 | 33 | /// update handler for the form submission 34 | func update(req: Request) throws -> EventLoopFuture 35 | 36 | /// runs after the model was updated 37 | func afterUpdate(req: Request, form: UpdateForm, model: Model) -> EventLoopFuture 38 | 39 | /// returns a response after the update flow 40 | func updateResponse(req: Request, form: UpdateForm, model: Model) -> EventLoopFuture 41 | 42 | /// setup update routes using the route builder 43 | func setupUpdateRoutes(on builder: RoutesBuilder, as: PathComponent) 44 | } 45 | -------------------------------------------------------------------------------- /Sources/ViewKit/ViewController/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // ViewKit 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 26.. 6 | // 7 | 8 | 9 | public protocol ViewController { 10 | 11 | associatedtype Model: Fluent.Model & TemplateDataRepresentable 12 | 13 | func render(req: Request, template: String, context: Renderer.Context) -> EventLoopFuture 14 | } 15 | 16 | public extension ViewController { 17 | 18 | func render(req: Request, template: String, context: Renderer.Context) -> EventLoopFuture { 19 | req.tau.render(template: template, context: context) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/ViewKitTests/ExampleController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleController.swift 3 | // ViewKitTests 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 27.. 6 | // 7 | 8 | import ViewKit 9 | 10 | final class ExampleController: AdminViewController { 11 | 12 | var getView: String = "" 13 | var listView: String = "" 14 | var createView: String = "" 15 | var updateView: String = "" 16 | var deleteView: String = "" 17 | 18 | typealias Model = ExampleModel 19 | 20 | typealias CreateForm = ExampleEditForm 21 | typealias UpdateForm = ExampleEditForm 22 | 23 | func find(_ req: Request) throws -> EventLoopFuture { 24 | req.eventLoop.future(ExampleModel(id: UUID(), foo: "foo", bar: 1)) 25 | } 26 | 27 | func render(req: Request, template: String, context: Renderer.Context) -> EventLoopFuture { 28 | req.eventLoop.future(View(data: ByteBuffer(string: template))) 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Tests/ViewKitTests/ExampleForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleEditForm.swift 3 | // ViewKitTests 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 27.. 6 | // 7 | 8 | import ViewKit 9 | 10 | final class ExampleEditForm: ModelForm { 11 | 12 | typealias Model = ExampleModel 13 | 14 | // MARK: - properties 15 | var modelId: UUID? 16 | var foo = FormField(key: "foo").required().length(max: 250) 17 | var bar = FormField(key: "bar").min(300).max(900) 18 | var notification: String? 19 | 20 | var fields: [FormFieldRepresentable] { 21 | [foo, bar] 22 | } 23 | 24 | // MARK: - methods 25 | 26 | init() {} 27 | 28 | func read(from model: ExampleModel ) { 29 | foo.value = model.foo 30 | bar.value = model.bar 31 | } 32 | 33 | func write(to model: ExampleModel) { 34 | model.foo = foo.value! 35 | model.bar = bar.value! 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Tests/ViewKitTests/ExampleModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExampleModel.swift 3 | // ViewKitTests 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 27.. 6 | // 7 | 8 | import ViewKit 9 | 10 | final class ExampleModel: Model { 11 | 12 | static let schema = "examples" 13 | 14 | struct FieldKeys { 15 | static var foo: FieldKey { "foo" } 16 | static var bar: FieldKey { "bar" } 17 | } 18 | 19 | @ID() var id: UUID? 20 | @Field(key: FieldKeys.foo) var foo: String 21 | @Field(key: FieldKeys.bar) var bar: Int 22 | 23 | init() { } 24 | 25 | init(id: UUID? = nil, foo: String, bar: Int) { 26 | self.id = id 27 | self.foo = foo 28 | self.bar = bar 29 | } 30 | } 31 | 32 | extension ExampleModel: TemplateDataRepresentable { 33 | 34 | var templateData: TemplateData { 35 | .dictionary([ 36 | "id": id, 37 | "foo": foo, 38 | "bar": bar, 39 | ]) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Tests/ViewKitTests/ViewKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewKitTests.swift 3 | // ViewKitTests 4 | // 5 | // Created by Tibor Bodecs on 2020. 04. 27.. 6 | // 7 | 8 | import XCTest 9 | import ViewKit 10 | 11 | final class ViewKitTests: XCTestCase { 12 | 13 | func testModelFormTemplateDataIdentifier() throws { 14 | let uuid = UUID() 15 | let form = ExampleEditForm() 16 | form.modelId = uuid 17 | let data = form.templateData 18 | XCTAssertEqual(data.dictionary?["modelId"], uuid.templateData) 19 | } 20 | 21 | func testFormValidation() throws { 22 | let form = ExampleEditForm() 23 | XCTAssertFalse(form.validateFields()) 24 | XCTAssertEqual(form.foo.error, "Foo is required") 25 | XCTAssertEqual(form.bar.error, "Bar should be greater than 300") 26 | form.foo.value = "sample" 27 | form.bar.value = 1500 28 | XCTAssertFalse(form.validateFields()) 29 | XCTAssertEqual(form.foo.error, nil) 30 | XCTAssertEqual(form.bar.error, "Bar should be less than 900") 31 | } 32 | } 33 | --------------------------------------------------------------------------------