├── .gitignore ├── .swift-version ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── DemoApp │ ├── Error.swift │ ├── Project │ │ ├── AddProject.swift │ │ ├── ListProjects.swift │ │ ├── Project.swift │ │ └── ProjectViewModel.swift │ ├── Task │ │ ├── AddTask.swift │ │ ├── ListTasks.swift │ │ ├── Task.swift │ │ └── TaskViewModel.swift │ ├── Team │ │ ├── AddTeam.swift │ │ ├── ListTeams.swift │ │ ├── TeamViewModel.swift │ │ └── Teams.swift │ ├── main.swift │ ├── menu.swift │ └── tools.swift └── Frontless │ ├── Widgets │ ├── Button.swift │ ├── Card.swift │ ├── Column.swift │ ├── ComboBox.swift │ ├── Container.swift │ ├── Div.swift │ ├── Dom.swift │ ├── Form.swift │ ├── IFDiv.swift │ ├── Input.swift │ ├── MainMenu.swift │ ├── MenuWidget.swift │ ├── Navbar.swift │ ├── Small.swift │ ├── Span.swift │ ├── Table.swift │ ├── TextEditor.swift │ └── validations.swift │ ├── globals.swift │ └── navigation.swift ├── Tests └── FrontlessTests │ ├── ButtonTests.swift │ ├── ColumnTests.swift │ ├── ComboBoxTests.swift │ ├── ContainerTests.swift │ ├── DivTests.swift │ ├── FormTests.swift │ ├── IFDivTests.swift │ ├── MainMenuTests.swift │ ├── ShowIfTests.swift │ ├── SmallTests.swift │ ├── SpanTests.swift │ ├── TableTests.swift │ ├── TestRenderer.swift │ ├── TestView.swift │ ├── XCTestManifests.swift │ ├── assertions.swift │ └── navigationTests.swift └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | Bundle/ 93 | 94 | .swiftpm/ 95 | .DS_Store 96 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | wasm-5.3.1-RELEASE 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | swift-format format -i -r Sources 3 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "JavaScriptKit", 6 | "repositoryURL": "https://github.com/swiftwasm/JavaScriptKit.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "b19e7c8b10a2750ed47753e31ed13613171f3294", 10 | "version": "0.10.1" 11 | } 12 | }, 13 | { 14 | "package": "OpenCombine", 15 | "repositoryURL": "https://github.com/OpenCombine/OpenCombine.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "28993ae57de5a4ea7e164787636cafad442d568c", 19 | "version": "0.12.0" 20 | } 21 | }, 22 | { 23 | "package": "OpenCombineJS", 24 | "repositoryURL": "https://github.com/swiftwasm/OpenCombineJS.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "eaf324ce78710f53b52fb82e9a8de4693633e33a", 28 | "version": "0.1.1" 29 | } 30 | }, 31 | { 32 | "package": "swift-argument-parser", 33 | "repositoryURL": "https://github.com/apple/swift-argument-parser", 34 | "state": { 35 | "branch": null, 36 | "revision": "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 37 | "version": "0.5.0" 38 | } 39 | }, 40 | { 41 | "package": "Benchmark", 42 | "repositoryURL": "https://github.com/google/swift-benchmark", 43 | "state": { 44 | "branch": null, 45 | "revision": "8e0ef8bb7482ab97dcd2cd1d6855bd38921c345d", 46 | "version": "0.1.0" 47 | } 48 | }, 49 | { 50 | "package": "swift-log", 51 | "repositoryURL": "https://github.com/apple/swift-log.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "12d3a8651d32295794a850307f77407f95b8c881", 55 | "version": "1.4.1" 56 | } 57 | }, 58 | { 59 | "package": "Tokamak", 60 | "repositoryURL": "https://github.com/TokamakUI/Tokamak", 61 | "state": { 62 | "branch": null, 63 | "revision": "c2ed28ca40445b1b63bfdfe45de2507e5eb24a21", 64 | "version": "0.8.0" 65 | } 66 | } 67 | ] 68 | }, 69 | "version": 1 70 | } 71 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Frontless", 8 | platforms: [.macOS(.v11)], 9 | products: [ 10 | .library( 11 | name: "Frontless", 12 | targets: ["Frontless"]), 13 | .executable( 14 | name: "DemoApp", 15 | targets: ["DemoApp"] 16 | ) 17 | ], 18 | dependencies: [ 19 | .package(name: "Tokamak", url: "https://github.com/TokamakUI/Tokamak", from: "0.8.0"), 20 | .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), 21 | .package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.12.0") 22 | ], 23 | targets: [ 24 | .target( 25 | name: "Frontless", 26 | dependencies: [ 27 | .product(name: "TokamakDOM", package: "Tokamak"), 28 | .product(name: "Logging", package: "swift-log"), 29 | .product(name: "OpenCombine", package: "OpenCombine"), 30 | ]), 31 | .target( 32 | name: "DemoApp", 33 | dependencies: [ 34 | .target(name: "Frontless"), 35 | ]), 36 | .testTarget( 37 | name: "FrontlessTests", 38 | dependencies: [ 39 | .target(name: "Frontless") 40 | ]), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frontless 2 | 3 | 4 | _Don't use Javascript, React, Npm, Gulp, Grunt, Webpack, when you can simply use Swift! :-)_ 5 | 6 | Frontless is experimental set of components for building web applications in Swift/SwiftUI - running natively in the browser via Web Assembly. 7 | It's built on top of https://github.com/TokamakUI/Tokamak, but differs in goals: 8 | 9 | * It's more web friendly - it uses Bootstrap CSS, has native href links, and forward/back navigation 10 | * It's suitable for building bigger desktop web-apps, such as CRUD web apps 11 | * It has SwiftUI look and feel, but its components are specifically designed for the web, such as Table, Menu, Filters 12 | 13 | 14 | 15 | ---------- 16 | Live demo: [Todo App built as SPA, hosted as static files on Github](https://www.marcinkliks.pl/okrasa/?#ListOKR) 17 | 18 |
19 | Zrzut ekranu 2021-11-14 o 01 16 38 20 | 21 | Zrzut ekranu 2021-11-14 o 01 17 11 22 |
23 | 24 | 25 | 26 | ![image](https://user-images.githubusercontent.com/552398/111958567-abd9ec00-8aed-11eb-8394-7f41b9eaf192.png) 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Sources/DemoApp/Error.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Frontless 3 | import TokamakDOM 4 | 5 | class ErrorsViewModel: ObservableObject { 6 | @Published var message: String = "error" 7 | @Published var show: Bool = false 8 | } 9 | 10 | struct Error: View { 11 | @Binding var error: String 12 | @Binding var show: Bool 13 | 14 | var body: some View { 15 | if show { 16 | HTML("div", ["class": "modal doc overlay"]) { 17 | HTML("div", ["class": "card doc"]) { 18 | HTML("label", ["class": "modal-close", "for": "modal-control"]) 19 | HTML("h3", ["class": "section"], content: "Modal") 20 | HTML("p", ["class": "section"], content: "This is a modal dialog") 21 | 22 | Text(error) 23 | Button("OK") { 24 | error = "" 25 | show = false 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/DemoApp/Project/AddProject.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import TokamakDOM 6 | 7 | struct AddProject: View { 8 | @State var name: String = "" 9 | @State var cancellable: AnyCancellable? = nil 10 | 11 | @State var editId: String? = nil 12 | 13 | @ObservedObject public var projectModel = ProjectViewModel() 14 | @ObservedObject public var validationState = ValidationState() 15 | 16 | init(id: String? = nil) { 17 | if let id = id { 18 | projectModel.getBy(id: id) 19 | } 20 | } 21 | 22 | var body: some View { 23 | return Card { 24 | Form(title: "Add project") { 25 | FormField( 26 | label: "Name", 27 | helpText: """ 28 | What's the project name? 29 | """, validation: Validations.required, state: validationState, text: $name 30 | ) { 31 | TextEditor(value: $name) 32 | } 33 | Button("Cancel", type: .secondary) { 34 | navigate(to: "ListTeams") 35 | }.clipped() 36 | 37 | Button("OK", type: .primary) { 38 | validationState.showHints = true 39 | if validationState.ok { 40 | projectModel.addProject( 41 | project: Project( 42 | id: self.editId ?? window.uuid!().string!, 43 | name: name 44 | ) 45 | ) 46 | navigate(to: "ListProjects") 47 | } 48 | } 49 | } 50 | }._onMount { 51 | self.cancellable = projectModel.$project.sink { [self] project in 52 | guard let project = project else { 53 | return 54 | } 55 | self.name = project.name 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/DemoApp/Project/ListProjects.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Frontless 3 | import JavaScriptKit 4 | import TokamakDOM 5 | import TokamakStaticHTML 6 | 7 | struct ListProjects: View { 8 | @ObservedObject private var projectModel = ProjectViewModel() 9 | 10 | @State var text: String = "" 11 | @Binding var menuItems: [MenuItem] 12 | @State var choosenTeam: String = "" 13 | @State var choosenStatus: String = "" 14 | 15 | var teamName: String = "" 16 | 17 | func filter() -> [[String]] { 18 | let items = projectModel.projects.filter { 19 | (text.isEmpty ? true : $0.name.lowercased().contains(text)) 20 | } 21 | return items.map { self.tableRowFor(project: $0) } 22 | } 23 | 24 | func tableRowFor(project: Project) -> [String] { 25 | return [ 26 | project.name, 27 | "Edit ", 28 | ] 29 | } 30 | 31 | public var body: some View { 32 | let filtered = Binding<[[String]]>( 33 | get: { 34 | self.filter() 35 | }, 36 | set: { print($0) } 37 | ) 38 | 39 | Card { 40 | Div(class: "input-group") { 41 | HTML("span", ["class": "input-group-text"], content: "Search") 42 | Input("", text: $text).style([.width: "100"]) 43 | } 44 | 45 | Table( 46 | items: filtered, 47 | columns: [ 48 | "Name", "Actions", 49 | ] 50 | ) 51 | Small("Count: \(String(projectModel.projects.count))").clipped() 52 | 53 | .onAppear { 54 | menuItems = [MenuItem(label: "Add Project", url: "AddProject")] 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/DemoApp/Project/Project.swift: -------------------------------------------------------------------------------- 1 | import Frontless 2 | import JavaScriptKit 3 | 4 | struct Project: Codable, ConvertibleToJSValue, Hashable { 5 | public var id: String 6 | public var name: String = "" 7 | 8 | func hash(into hasher: inout Hasher) { 9 | hasher.combine(name) 10 | } 11 | 12 | func jsValue() -> JSValue { 13 | return [ 14 | "name": name 15 | ].jsValue() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DemoApp/Project/ProjectViewModel.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import TokamakCore 6 | import TokamakDOM 7 | 8 | class ProjectViewModel: ObservableObject { 9 | @Published var projects = [Project]() 10 | @Published var project: Project? = nil 11 | 12 | let localStorage = JSObject.global.localStorage.object! 13 | 14 | var listClosure: JSClosure? 15 | var getClosure: JSClosure? 16 | var addClosure: JSClosure? 17 | 18 | public func getBy(id: String) { 19 | // _ = window.xfetch!("http://localhost:8080/tasks/\(id)", getClosure) 20 | project = 21 | projects.filter { (project) -> Bool in 22 | project.id == id 23 | }.first 24 | } 25 | 26 | public func addProject(project: Project) { 27 | do { 28 | var writableProjects = projects.filter { $0.id != project.id } 29 | writableProjects.append(project) 30 | let bytesToSave = try JSONEncoder().encode(writableProjects) 31 | _ = localStorage.setItem!("projects", String(bytes: bytesToSave, encoding: .utf8)!) 32 | } catch { 33 | logger.error("Error saving to local storage: \(error)") 34 | } 35 | // _ = window.xpost!("http://localhost:8080/okrs", task.jsValue(), addClosure) 36 | } 37 | 38 | func loadFromLocalStorage() { 39 | let decoder = JSONDecoder() 40 | logger.debug("Loading data from local storage") 41 | guard let value = localStorage.getItem!("projects").string else { 42 | logger.error("Cannot read projects!") 43 | return 44 | } 45 | let json = value.data(using: .utf8)! 46 | logger.debug("Loaded") 47 | 48 | do { 49 | let decoded: [Project] = try decoder.decode([Project].self, from: json) 50 | projects = decoded 51 | } catch { 52 | logger.error("Error: \(error)") 53 | } 54 | } 55 | 56 | init() { 57 | logger.debug("Initialize DataViewModel") 58 | listClosure = JSClosure { [weak self] arguments -> Void in 59 | let projects: [Project] = try! JSValueDecoder().decode(from: arguments[0]) 60 | self?.projects = projects 61 | } 62 | loadFromLocalStorage() 63 | } 64 | 65 | deinit { 66 | logger.debug("Deinit DataViewModel") 67 | listClosure?.release() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/DemoApp/Task/AddTask.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import TokamakCore 6 | import TokamakDOM 7 | import TokamakStaticHTML 8 | 9 | struct AddTask: View { 10 | @State var title: String = "" 11 | @State var whyNow: String = "" 12 | @State var priority: String = "0" 13 | @State var description: String = "" 14 | @State var how: String = "" 15 | @State var assignee: String = "" 16 | @State var team: String = "" 17 | 18 | @State var project: String = "" 19 | @State var taskType: String = "1" 20 | @State var selection: String = "" 21 | @State var cancellable: AnyCancellable? = nil 22 | 23 | @State var editId: String? = nil 24 | 25 | @ObservedObject public var taskModel = TaskModel() 26 | @ObservedObject public var teamModel = TeamModel() 27 | @ObservedObject public var projectModel = ProjectViewModel() 28 | @ObservedObject public var validationState = ValidationState() 29 | 30 | init(id: String? = nil) { 31 | if let id = id { 32 | taskModel.getBy(id: id) 33 | } 34 | } 35 | 36 | var body: some View { 37 | return Card { 38 | Form(title: "Add task") { 39 | Input("Username", text: $assignee) 40 | .style([.width: "100"]) 41 | Container { 42 | 43 | FormField( 44 | label: "Title", 45 | helpText: """ 46 | What's to do? 47 | """, validation: Validations.required, state: validationState, text: $title 48 | ) { 49 | TextEditor(value: $title) 50 | } 51 | 52 | FormField( 53 | label: "❓Description", 54 | helpText: """ 55 | """, validation: Validations.none, state: validationState, text: $description 56 | ) { TextEditor(value: $description) } 57 | 58 | FormField( 59 | label: "Team", validation: Validations.required, state: validationState, text: $team 60 | ) { 61 | ComboBox( 62 | selection: $team, 63 | items: teamModel.teams.map { 64 | PickerItem(content: $0.value.name, value: $0.value.id) 65 | }) 66 | } 67 | FormField( 68 | label: "Piority", validation: Validations.required, state: validationState, 69 | text: $priority 70 | ) { 71 | ComboBox( 72 | selection: $priority, 73 | items: TaskPriority.allCases.map { prio in 74 | PickerItem(content: String(describing: prio), value: String(prio.rawValue)) 75 | }) 76 | } 77 | FormField( 78 | label: "Project", validation: Validations.required, state: validationState, 79 | text: $project 80 | ) { 81 | ComboBox( 82 | selection: $project, 83 | items: projectModel.projects.map { 84 | PickerItem(content: $0.name, value: $0.id) 85 | }) 86 | } 87 | ShowIf({ priority == "2" }) { 88 | FormField( 89 | label: "⌛Why so critical?", 90 | helpText: """ 91 | Provide explanation why it has to be done before everything else. 92 | """, validation: Validations.none, state: validationState, text: $whyNow 93 | ) { TextEditor(value: $whyNow) } 94 | } 95 | 96 | FormField( 97 | label: "Type", validation: Validations.required, state: validationState, text: $taskType 98 | ) { 99 | ComboBox( 100 | selection: $taskType, 101 | items: TaskType.allCases.map { taskType in 102 | PickerItem(content: String(describing: taskType), value: String(taskType.rawValue)) 103 | }) 104 | } 105 | FormField( 106 | label: "Assignee", validation: Validations.required, state: validationState, 107 | text: $assignee 108 | ) { 109 | Input("Username", text: $assignee) 110 | } 111 | } 112 | Row { 113 | Col(width: 3) {} 114 | Col { 115 | Button("Cancel", type: .secondary) { 116 | navigate(to: "ListTasks") 117 | } 118 | } 119 | Col { 120 | Button("OK") { 121 | validationState.showHints = true 122 | if validationState.ok { 123 | taskModel.addTask( 124 | task: Task( 125 | title: title, 126 | why: description, 127 | id: self.editId ?? window.uuid!().string!, 128 | need: whyNow, 129 | project: project, team: team, type: TaskType(rawValue: Int(taskType)!)!, 130 | assignee: assignee, priority: TaskPriority(rawValue: Int(priority)!)!, 131 | description: description 132 | ) 133 | ) 134 | navigate(to: "ListTasks") 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | }._onMount { 142 | self.cancellable = taskModel.$task.sink { [self] task in 143 | guard let task = task else { 144 | return 145 | } 146 | self.title = task.title 147 | self.description = task.description 148 | self.project = task.project 149 | self.taskType = String(task.type.rawValue) 150 | self.priority = String(task.priority.rawValue) 151 | self.assignee = task.assignee 152 | self.whyNow = task.why 153 | self.editId = task.id 154 | self.team = task.team 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/DemoApp/Task/ListTasks.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Frontless 3 | import JavaScriptKit 4 | import TokamakDOM 5 | import TokamakStaticHTML 6 | 7 | struct ListTasks: View { 8 | @ObservedObject private var taskViewModel = TaskModel() 9 | @ObservedObject private var teamViewModel = TeamModel() 10 | 11 | @State var text: String = "" 12 | @Binding var menuItems: [MenuItem] 13 | @State var choosenTeam: String = "" 14 | @State var choosenStatus: String = "" 15 | 16 | var teamName: String = "" 17 | 18 | func filter() -> [[String]] { 19 | let items = taskViewModel.tasks.filter { 20 | (text.isEmpty ? true : $0.title.lowercased().contains(text)) 21 | && (choosenTeam.isEmpty ? true : $0.project.lowercased() == choosenTeam) 22 | && (choosenStatus.isEmpty ? true : $0.status.rawValue == Int(choosenStatus)) 23 | } 24 | return items.map { self.tableRowFor(task: $0) } 25 | } 26 | 27 | func tableRowFor(task: Task) -> [String] { 28 | let teamName = teamViewModel.teams[task.team]?.name ?? "-" 29 | return [ 30 | task.title, 31 | "\(task.status)", 32 | task.assignee, 33 | teamName, 34 | "Edit ", 35 | ] 36 | } 37 | 38 | public var body: some View { 39 | let filtered = Binding<[[String]]>( 40 | get: { 41 | self.filter() 42 | }, 43 | set: { print($0) } 44 | ) 45 | Card { 46 | Div(class: "input-group") { 47 | HTML("span", ["class": "input-group-text"], content: "Search") 48 | Input("", text: $text).style([.width: "100"]) 49 | ComboBox( 50 | selection: $choosenTeam, 51 | items: teamViewModel.teams.map { 52 | PickerItem(value: $0.value.id, content: $0.value.name) 53 | }, placeholder: "Team" 54 | ) 55 | ComboBox( 56 | selection: $choosenStatus, items: taskViewModel.statuses, placeholder: "Task status" 57 | ) 58 | } 59 | 60 | Table( 61 | items: filtered, 62 | columns: [ 63 | "Title", "Status", "Assignee", "Team", "Actions", 64 | ] 65 | ) 66 | Small("Count: \(String(taskViewModel.tasks.count))").clipped() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/DemoApp/Task/Task.swift: -------------------------------------------------------------------------------- 1 | import Frontless 2 | import JavaScriptKit 3 | 4 | struct Task: Codable, ConvertibleToJSValue, Hashable { 5 | public var title: String = "" 6 | var why: String = "" 7 | var id: String 8 | var need: String = "" 9 | var project: String 10 | var team: String 11 | var type: TaskType = .bug 12 | var assignee: String 13 | var status: TaskStatus = .open 14 | var reviews: [TaskAction] = [] 15 | var priority: TaskPriority = .high 16 | var description: String 17 | 18 | func hash(into hasher: inout Hasher) { 19 | hasher.combine(title) 20 | } 21 | 22 | func jsValue() -> JSValue { 23 | return [ 24 | "title": title, 25 | "why": why, 26 | "need": need, 27 | "project": project, 28 | "description": description, 29 | 30 | ].jsValue() 31 | } 32 | } 33 | 34 | enum TaskStatus: Int, Codable, CaseIterable { 35 | case open = 0 36 | case in_progress = 1 37 | case closed = 2 38 | case reopened = 3 39 | } 40 | 41 | enum TaskType: Int, Codable, CaseIterable { 42 | case bug = 0 43 | case feature 44 | case epic 45 | } 46 | 47 | enum TaskPriority: Int, Codable, CaseIterable { 48 | case low = 0 49 | case high 50 | case critical 51 | } 52 | 53 | struct TaskAction: Codable, ConvertibleToJSValue, Hashable { 54 | public var assignee: String = "" 55 | var comment: String = "" 56 | var status: TaskStatus 57 | 58 | func hash(into hasher: inout Hasher) { 59 | hasher.combine(assignee) 60 | hasher.combine(comment) 61 | } 62 | 63 | func jsValue() -> JSValue { 64 | return [].jsValue() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/DemoApp/Task/TaskViewModel.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import TokamakCore 6 | import TokamakDOM 7 | 8 | class TaskModel: ObservableObject { 9 | @Published var tasks = [Task]() 10 | @Published var task: Task? = nil 11 | 12 | let localStorage = JSObject.global.localStorage.object! 13 | 14 | public var statuses: [PickerItem] = TaskStatus.allCases.map { 15 | PickerItem(String($0.rawValue), "\($0)") 16 | } 17 | 18 | public var assignees: [PickerItem] = [ 19 | PickerItem("0", "me"), 20 | PickerItem("1", "you"), 21 | ] 22 | 23 | var listClosure: JSClosure? 24 | var getClosure: JSClosure? 25 | var addClosure: JSClosure? 26 | 27 | public func getBy(id: String) { 28 | // _ = window.xfetch!("http://localhost:8080/tasks/\(id)", getClosure) 29 | task = 30 | tasks.filter { (task) -> Bool in 31 | task.id == id 32 | }.first 33 | } 34 | 35 | public func addTask(task: Task) { 36 | do { 37 | var writableTasks = tasks.filter { $0.id != task.id } 38 | writableTasks.append(task) 39 | let bytesToSave = try JSONEncoder().encode(writableTasks) 40 | _ = localStorage.setItem!("tasks", String(bytes: bytesToSave, encoding: .utf8)!) 41 | } catch { 42 | logger.error("Error saving to local storage: \(error)") 43 | } 44 | // _ = window.xpost!("http://localhost:8080/okrs", task.jsValue(), addClosure) 45 | } 46 | 47 | func loadFromLocalStorage() { 48 | let decoder = JSONDecoder() 49 | logger.debug("Loading data from local storage") 50 | guard let value = localStorage.getItem!("tasks").string else { 51 | logger.error("Cannot read tasks!") 52 | return 53 | } 54 | let json = value.data(using: .utf8)! 55 | logger.debug("Loaded") 56 | 57 | do { 58 | let decoded: [Task] = try decoder.decode([Task].self, from: json) 59 | tasks = decoded 60 | } catch { 61 | logger.error("Error: \(error)") 62 | } 63 | } 64 | 65 | init() { 66 | logger.debug("Initialize DataViewModel") 67 | listClosure = JSClosure { [weak self] arguments -> Void in 68 | let tasks: [Task] = try! JSValueDecoder().decode(from: arguments[0]) 69 | self?.tasks = tasks 70 | } 71 | loadFromLocalStorage() 72 | } 73 | 74 | deinit { 75 | logger.debug("Deinit DataViewModel") 76 | listClosure?.release() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/DemoApp/Team/AddTeam.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import TokamakCore 6 | import TokamakDOM 7 | import TokamakStaticHTML 8 | 9 | struct AddTeam: View { 10 | @State var name: String = "" 11 | @State var cancellable: AnyCancellable? = nil 12 | 13 | @State var editId: String? 14 | 15 | @ObservedObject public var dataViewModel = TeamModel() 16 | @ObservedObject public var validationState = ValidationState() 17 | 18 | init(id: String? = nil) { 19 | if let id = id { 20 | logger.debug("ID: \(id)") 21 | editId = id 22 | dataViewModel.getBy(id: id) 23 | } 24 | } 25 | 26 | var body: some View { 27 | return Card { 28 | Form(title: "Add team") { 29 | Container { 30 | FormField( 31 | label: "Name", 32 | helpText: """ 33 | Team's name 34 | """, validation: Validations.required, state: validationState, text: $name 35 | ) { 36 | TextEditor(value: $name) 37 | } 38 | } 39 | Row { 40 | Col(width: 3) {} 41 | Col { 42 | Button("Cancel", type: .secondary) { 43 | navigate(to: "ListTeams") 44 | } 45 | } 46 | Col { 47 | Button("OK") { 48 | validationState.showHints = true 49 | if validationState.ok { 50 | dataViewModel.addTeam( 51 | team: Team( 52 | name: name, 53 | id: self.editId ?? window.uuid!().string! 54 | )) 55 | navigate(to: "ListTeams") 56 | } 57 | } 58 | } 59 | } 60 | } 61 | }._onMount { 62 | self.cancellable = dataViewModel.$team.sink { [self] team in 63 | guard let team = team else { 64 | return 65 | } 66 | self.name = team.name 67 | self.editId = team.id 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/DemoApp/Team/ListTeams.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Frontless 3 | import JavaScriptKit 4 | import TokamakDOM 5 | import TokamakStaticHTML 6 | 7 | struct ListTeams: View { 8 | @ObservedObject private var teamViewModel = TeamModel() 9 | 10 | @State var text: String = "" 11 | @Binding var menuItems: [MenuItem] 12 | 13 | func filter() -> [[String]] { 14 | let items = teamViewModel.teams.filter { 15 | (text.isEmpty ? true : $0.value.name.lowercased().contains(text)) 16 | } 17 | print(items) 18 | return items.map { self.tableRowFor(team: $0.value) } 19 | } 20 | 21 | func tableRowFor(team: Team) -> [String] { 22 | [ 23 | team.name, 24 | "Edit ", 25 | ] 26 | } 27 | 28 | public var body: some View { 29 | let filtered = Binding<[[String]]>( 30 | get: { 31 | self.filter() 32 | }, 33 | set: { print($0) } 34 | ) 35 | 36 | Card { 37 | Div(class: "input-group") { 38 | HTML("span", ["class": "input-group-text"], content: "Search") 39 | Input("", text: $text).style([.width: "100"]) 40 | } 41 | 42 | Table( 43 | items: filtered, 44 | columns: [ 45 | "Name", "Actions", 46 | ] 47 | ) 48 | Small("Count: \(String(teamViewModel.teams.count))").clipped() 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/DemoApp/Team/TeamViewModel.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import TokamakCore 6 | import TokamakDOM 7 | 8 | class TeamModel: ObservableObject { 9 | @Published var teams = [String: Team]() 10 | @Published var team: Team? = nil 11 | 12 | let localStorage = JSObject.global.localStorage.object! 13 | 14 | var listClosure: JSClosure? 15 | var getClosure: JSClosure? 16 | var addClosure: JSClosure? 17 | 18 | public func getBy(id: String) { 19 | // _ = window.xfetch!("http://localhost:8080/teams/\(id)", getClosure) 20 | team = teams[id] 21 | } 22 | 23 | public func addTeam(team: Team) { 24 | do { 25 | var writableTeams = teams 26 | print(writableTeams) 27 | writableTeams[team.id] = team 28 | let bytesToSave = try JSONEncoder().encode(writableTeams) 29 | _ = localStorage.setItem!("teams", String(bytes: bytesToSave, encoding: .utf8)!) 30 | teams = writableTeams 31 | } catch { 32 | logger.error("Error saving to local storage: \(error)") 33 | } 34 | // _ = window.xpost!("http://localhost:8080/okrs", task.jsValue(), addClosure) 35 | } 36 | 37 | func loadFromLocalStorage() { 38 | let decoder = JSONDecoder() 39 | logger.debug("Loading data from local storage") 40 | guard let value = localStorage.getItem!("teams").string else { 41 | logger.error("Cannot read tasks!") 42 | return 43 | } 44 | let json = value.data(using: .utf8)! 45 | logger.debug("Loaded") 46 | 47 | do { 48 | let decoded: [String: Team] = try decoder.decode([String: Team].self, from: json) 49 | teams = decoded 50 | } catch { 51 | logger.error("Error: \(error)") 52 | } 53 | } 54 | 55 | init() { 56 | logger.debug("Initialize DataViewModel") 57 | listClosure = JSClosure { [weak self] arguments -> Void in 58 | let teams: [String: Team] = try! JSValueDecoder().decode(from: arguments[0]) 59 | self?.teams = teams 60 | } 61 | loadFromLocalStorage() 62 | } 63 | 64 | deinit { 65 | logger.debug("Deinit DataViewModel") 66 | listClosure?.release() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/DemoApp/Team/Teams.swift: -------------------------------------------------------------------------------- 1 | import Frontless 2 | import JavaScriptKit 3 | 4 | struct Team: Codable, ConvertibleToJSValue, Hashable { 5 | var name: String 6 | var id: String 7 | 8 | func hash(into hasher: inout Hasher) { 9 | hasher.combine(name) 10 | } 11 | 12 | func jsValue() -> JSValue { 13 | return [ 14 | "name": name 15 | ].jsValue() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/DemoApp/main.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import Logging 6 | import TokamakDOM 7 | 8 | LoggingSystem.bootstrap { _ in StreamLogHandler.standardOutput(label: "demo-app") } 9 | 10 | var logger = Logger(label: "demo-app") 11 | logger.logLevel = .debug 12 | 13 | struct DemoApp: App { 14 | var body: some Scene { 15 | WindowGroup(" ⛢ Demo") { 16 | ContentView().environmentObject(ErrorsViewModel()) 17 | } 18 | } 19 | } 20 | 21 | struct ContentView: View { 22 | @StateObject var hashState = HashState() 23 | @State var button: JSObject? 24 | 25 | @EnvironmentObject var errorViewModel: ErrorsViewModel 26 | @State var menuItems: [MenuItem] = [] 27 | 28 | @State var currentPage: AnyView? 29 | @State var cancellable: AnyCancellable? = nil 30 | 31 | var body: some View { 32 | Div( 33 | id: "root", 34 | style: 35 | "overflow-x: hidden; overflow-y: scroll; display: block; justify-content: left; width: 100%; height: 100%" 36 | ) { 37 | // Upper menu 38 | Row { 39 | NavBar(title: "uJira") { 40 | ForEach(self.getMenu(), id: \.href) { (item: MainMenu) in 41 | item 42 | } 43 | } 44 | 45 | if errorViewModel.show { 46 | Error(error: $errorViewModel.message, show: $errorViewModel.show) 47 | } 48 | } 49 | Row { 50 | // if !menuItems.isEmpty { 51 | // Col(width: 2) { 52 | // Menu(items: $menuItems) 53 | // } 54 | // } 55 | // Content page 56 | Col(width: 11) { 57 | currentPage 58 | } 59 | } 60 | }._onMount { 61 | self.cancellable = hashState.$currentPage.sink { [self] value in 62 | currentPage = self.dispatchMenu(page: value) 63 | } 64 | } 65 | } 66 | } 67 | 68 | _ = document.head.object!.insertAdjacentHTML!( 69 | "beforeend", 70 | #""" 71 | 125 | """#) 126 | 127 | DemoApp.main() 128 | -------------------------------------------------------------------------------- /Sources/DemoApp/menu.swift: -------------------------------------------------------------------------------- 1 | import Frontless 2 | import TokamakCore 3 | 4 | extension ContentView { 5 | func getMenuItem(label: String, id: String, logo: Bool = false) -> MainMenu { 6 | let prefix = "?" 7 | let selected = (hashState.currentPage == "#\(id)") && !logo 8 | 9 | return MainMenu(label, href: "\(prefix)#\(id)", selected: selected, logo: logo) 10 | } 11 | 12 | public func getMenu() -> [MainMenu] { 13 | return [ 14 | MainMenu( 15 | "Tasks", href: "#", selected: false, logo: false, dropdown: true, 16 | submenus: [ 17 | SubMenu(label: "List tasks", href: "#ListTasks"), 18 | SubMenu(label: "Add task", href: "#AddTask"), 19 | ]), 20 | MainMenu( 21 | "Teams", href: "#", selected: false, logo: false, dropdown: true, 22 | submenus: [ 23 | SubMenu(label: "List teams", href: "#ListTeams"), 24 | SubMenu(label: "Add team", href: "#AddTeam"), 25 | ]), 26 | MainMenu( 27 | "Projects", href: "#", selected: false, logo: false, dropdown: true, 28 | submenus: [ 29 | SubMenu(label: "List projects", href: "#ListProjects"), 30 | SubMenu(label: "Add project", href: "#AddProject"), 31 | ]), 32 | ] 33 | } 34 | 35 | func dispatchMenu(page: String) -> AnyView { 36 | switch page { 37 | case "#ListTasks": return AnyView(ListTasks(menuItems: $menuItems)) 38 | case "#AddTask": return AnyView(AddTask(id: hashState.currentArguments.first)) 39 | 40 | case "#ListTeams": return AnyView(ListTeams(menuItems: $menuItems)) 41 | case "#AddTeam": return AnyView(AddTeam(id: hashState.currentArguments.first)) 42 | 43 | case "#ListProjects": return AnyView(ListProjects(menuItems: $menuItems)) 44 | case "#AddProject": return AnyView(AddProject(id: hashState.currentArguments.first)) 45 | 46 | default: return AnyView(ListTasks(menuItems: $menuItems)) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/DemoApp/tools.swift: -------------------------------------------------------------------------------- 1 | import CombineShim 2 | import Foundation 3 | import Frontless 4 | import JavaScriptKit 5 | import Logging 6 | import TokamakDOM 7 | 8 | public func measure(_ f: () -> Void) -> Double { 9 | let t0 = window.performance.now().number! 10 | f() 11 | let t1 = window.performance.now().number! 12 | return t1 - t0 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Button.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakCore 3 | import TokamakDOM 4 | 5 | public enum ButtonType: String { 6 | case primary = "primary" 7 | case secondary = "secondary" 8 | } 9 | 10 | public class Button: Dom, View { 11 | let label: String 12 | 13 | @State var isPressed: Bool = false 14 | var action: () -> Void 15 | var type: ButtonType 16 | 17 | public init( 18 | _ label: String, 19 | type: ButtonType = .primary, 20 | action: @escaping () -> Void 21 | ) { 22 | self.action = action 23 | self.label = label 24 | self.type = type 25 | } 26 | 27 | public var body: some View { 28 | let listeners: [String: Listener] = [ 29 | "pointerdown": { _ in self.isPressed = true }, 30 | "pointerup": { [self] _ in 31 | self.isPressed = false 32 | action() 33 | }, 34 | ] 35 | 36 | return AnyView( 37 | DynamicHTML( 38 | "a", 39 | [ 40 | "class": "btn btn-\(type.rawValue) \(self.getClassesText())", 41 | "style": self.getStyleText(), 42 | ], 43 | listeners: listeners, content: label 44 | ) 45 | ) 46 | } 47 | } 48 | 49 | extension Button { 50 | public func style(_ style: Style) -> some View { 51 | return MyModifiedContent(content: self, style: style) 52 | } 53 | 54 | public func klass(_ klasses: String...) -> some View { 55 | return MyModifiedContent(content: self, klasses: klasses) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Card.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakDOM 3 | import TokamakStaticHTML 4 | 5 | public struct Card: View { 6 | let content: Content 7 | 8 | public init(@ViewBuilder content: () -> Content) { 9 | self.content = content() 10 | } 11 | 12 | public var body: some View { 13 | Div(class: "card") { 14 | Div(class: "card-body") { 15 | Container { 16 | content 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Column.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakDOM 3 | import TokamakStaticHTML 4 | 5 | public struct Col: View { 6 | let width: Int 7 | let content: Content 8 | 9 | public init( 10 | width: Int = 1, 11 | @ViewBuilder content: () -> Content 12 | ) { 13 | self.content = content() 14 | self.width = width 15 | } 16 | 17 | public var body: some View { 18 | HTML("div", ["class": "col-sm-\(width) col-md-\(width)"]) { 19 | content 20 | } 21 | } 22 | } 23 | 24 | public struct Row: View { 25 | let content: Content 26 | 27 | public init(@ViewBuilder content: () -> Content) { 28 | self.content = content() 29 | } 30 | 31 | public var body: some View { 32 | HTML("div", ["class": "row"]) { 33 | content 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/ComboBox.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakDOM 3 | 4 | import class OpenCombine.PassthroughSubject 5 | import struct OpenCombine.Published 6 | 7 | public typealias PickerItem = (value: String, content: String) 8 | 9 | public struct ComboBox: View { 10 | var selection: Binding 11 | var items: [PickerItem] 12 | var placeholder: String 13 | 14 | public init(selection: Binding, items: [PickerItem], placeholder: String = "---") { 15 | self.selection = selection 16 | self.items = items 17 | self.items.insert(PickerItem("", placeholder), at: 0) 18 | self.placeholder = placeholder 19 | } 20 | 21 | public var body: some View { 22 | DynamicHTML( 23 | "select", ["class": "form-select"], 24 | listeners: [ 25 | "change": { 26 | let valueString = $0.target.object!.value.string 27 | selection.wrappedValue = valueString! 28 | } 29 | ] 30 | ) { 31 | ForEach(0..: View { 6 | let content: Content 7 | 8 | public init(@ViewBuilder content: () -> Content) { 9 | self.content = content() 10 | } 11 | 12 | public var body: some View { 13 | HTML("div", ["class": "container"]) { 14 | content 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Div.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakCore 3 | import TokamakDOM 4 | import TokamakStaticHTML 5 | 6 | public struct ShowIf: View { 7 | let expression: () -> Bool 8 | let content: Content 9 | 10 | public init( 11 | _ expression: @escaping () -> Bool, 12 | @ViewBuilder content: () -> Content 13 | ) { 14 | self.content = content() 15 | self.expression = expression 16 | } 17 | 18 | public var body: some View { 19 | HTML("div", ["style": "display: \(self.expression() ? "block" : "none")"]) { 20 | content 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Dom.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakCore 3 | import TokamakDOM 4 | 5 | public typealias Style = [StyleCommand: String] 6 | public typealias HTMLClasses = [String] 7 | 8 | public struct MyModifiedContent where Content: Dom { 9 | @Environment(\.self) public var environment 10 | public private(set) var content: Content 11 | 12 | public init(content: Content, style: Style) { 13 | self.content = content 14 | self.content.setStyle(style: style) 15 | } 16 | 17 | public init(content: Content, klasses: HTMLClasses) { 18 | self.content = content 19 | self.content.setClass(klasses: klasses) 20 | } 21 | } 22 | 23 | extension MyModifiedContent: View, ParentView where Content: View { 24 | public var body: some View { 25 | return self.content.body 26 | } 27 | 28 | public var children: [AnyView] { 29 | [AnyView(content)] 30 | } 31 | } 32 | 33 | public enum StyleCommand: String { 34 | case backgroundColor = "background-color" 35 | case color 36 | case padding 37 | case margin 38 | case border 39 | case width 40 | } 41 | 42 | public class Dom { 43 | func getStyle() -> Style { 44 | style 45 | } 46 | 47 | func setStyle(style: Style) { 48 | self.style = style 49 | } 50 | 51 | func setClass(klasses: HTMLClasses) { 52 | self.klasses = klasses 53 | } 54 | 55 | func getKlasses() -> HTMLClasses { 56 | klasses 57 | } 58 | 59 | var style: Style = [:] 60 | var klasses: HTMLClasses = [] 61 | 62 | public init() {} 63 | 64 | public func getStyleText() -> String { 65 | getStyle().map { key, value in 66 | "\(key.rawValue):\(value)" 67 | }.joined(separator: ";") 68 | } 69 | 70 | public func getClassesText() -> String { 71 | getKlasses().joined(separator: " ") 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Form.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakCore 3 | import TokamakDOM 4 | 5 | public protocol FormFieldProtocol {} 6 | extension FormField: FormFieldProtocol {} 7 | 8 | public struct Form: View { 9 | let content: Content 10 | let title: String 11 | 12 | public init( 13 | title: String, 14 | @ViewBuilder content: () -> Content 15 | ) { 16 | self.title = title 17 | self.content = content() 18 | } 19 | 20 | public var body: some View { 21 | HTML("div", ["class": "section"]) { 22 | HTML("form", [:]) { 23 | HTML("fieldset", ["class": "doc md-12"]) { 24 | HTML("legend", ["class": "doc"], content: title) 25 | content 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | extension Form: ParentView { 33 | public var children: [AnyView] { 34 | (content as? ParentView)?.children ?? [AnyView(content)] 35 | } 36 | } 37 | 38 | public struct FormField: View { 39 | var label: String 40 | @Binding var text: String 41 | var validation: ValidationFunc = Validations.none 42 | @ObservedObject var state: ValidationState 43 | var helpText: String? 44 | let content: Content 45 | 46 | public init( 47 | label: String, 48 | helpText: String? = nil, 49 | validation: @escaping ValidationFunc = Validations.none, 50 | state: ValidationState, 51 | text: Binding, 52 | @ViewBuilder content: () -> Content 53 | ) { 54 | self.content = content() 55 | self.label = label 56 | _state = ObservedObject(wrappedValue: state) 57 | _text = text 58 | self.validation = validation 59 | self.helpText = helpText 60 | } 61 | 62 | public var body: some View { 63 | HTML("div", ["class": "row"]) { 64 | HTML( 65 | "div", 66 | [ 67 | "style": "text-align: right; white-space: normal; word-wrap: none; padding-bottom: 30px", 68 | "class": "col-sm-12 col-md-3", 69 | ] 70 | ) { 71 | HTML("div", [:], content: label) 72 | HTML("small", [:], content: helpText ?? "") 73 | } 74 | 75 | HTML("div", ["class": "col-sm-12 col-md", "style": "margin-bottom: 30px"]) { 76 | Container { 77 | content 78 | 79 | let result = state.validate(id: label, input: text, validationFun: validation) 80 | if let validationError = result { 81 | if state.showHints { 82 | Text(validationError).foregroundColor(Color.red) 83 | } 84 | } else { 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/IFDiv.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakCore 3 | import TokamakStaticHTML 4 | 5 | public struct Div: View { 6 | let id: String 7 | let `class`: String 8 | let style: String 9 | let content: Content 10 | 11 | public init( 12 | id: String = "", class: String = "", 13 | style: String = "", 14 | @ViewBuilder content: () -> Content 15 | ) { 16 | self.content = content() 17 | self.id = id 18 | self.style = style 19 | self.class = `class` 20 | } 21 | 22 | public var body: some View { 23 | HTML("div", ["id": id, "class": `class`, "style": style]) { 24 | content 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Input.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakCore 3 | import TokamakDOM 4 | import TokamakStaticHTML 5 | 6 | public class Input: Dom, View { 7 | let label: String 8 | let placeholder: String 9 | @Binding var text: String 10 | 11 | public init( 12 | _ label: String, 13 | text: Binding, 14 | placeholder: String = "" 15 | ) { 16 | self.label = label 17 | self._text = text 18 | self.placeholder = placeholder 19 | } 20 | 21 | public var body: some View { 22 | let listeners: [String: Listener] = [ 23 | "input": { event in 24 | if let newValue = event.target.object?.value.string { 25 | self.text = newValue 26 | } 27 | } 28 | ] 29 | 30 | return AnyView( 31 | DynamicHTML( 32 | "input", 33 | [ 34 | HTMLAttribute("value", isUpdatedAsProperty: true): self.text, 35 | HTMLAttribute("type", isUpdatedAsProperty: true): "text", 36 | HTMLAttribute("placeholder", isUpdatedAsProperty: true): placeholder, 37 | HTMLAttribute("class", isUpdatedAsProperty: true): "form-control", 38 | HTMLAttribute("style", isUpdatedAsProperty: true): self.getStyleText(), 39 | ], 40 | listeners: listeners, content: label 41 | ) 42 | ) 43 | } 44 | } 45 | 46 | extension Input { 47 | public func style(_ style: Style) -> some View { 48 | return MyModifiedContent(content: self, style: style) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/MainMenu.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakCore 3 | import TokamakDOM 4 | 5 | public struct SubMenu: View, Hashable { 6 | 7 | public init(label: String, href: String) { 8 | self.label = label 9 | self.href = href 10 | } 11 | 12 | var label: String 13 | var href: String 14 | 15 | public var body: some View { 16 | return AnyView( 17 | DynamicHTML( 18 | "a", ["class": "dropdown-item", "href": href], content: label) 19 | ) 20 | } 21 | } 22 | 23 | public struct MainMenu: View { 24 | let label: String 25 | public let href: String 26 | let selected: Bool 27 | let logo: Bool 28 | let dropdown: Bool 29 | let submenus: [SubMenu] 30 | @State var showed: Bool 31 | 32 | public init( 33 | _ label: String, 34 | href: String, 35 | selected: Bool = false, 36 | logo: Bool = false, 37 | dropdown: Bool = false, 38 | submenus: [SubMenu] = [] 39 | ) { 40 | self.href = href 41 | self.label = label 42 | self.selected = selected 43 | self.logo = logo 44 | self.dropdown = dropdown 45 | self.submenus = submenus 46 | _showed = .init(wrappedValue: false) 47 | } 48 | 49 | public var body: some View { 50 | if dropdown { 51 | return AnyView( 52 | DynamicHTML( 53 | "li", ["class": "nav-item dropdown"], 54 | listeners: [ 55 | "mouseover": { _ in self.showed = true }, 56 | "mouseout": { _ in self.showed = false }, 57 | "click": { _ in self.showed = false }, 58 | 59 | ] 60 | ) { 61 | HTML( 62 | "a", 63 | [ 64 | "class": "nav-link dropdown-toggle \(self.showed ? "show": "")", 65 | "id": "navbarDropdown", 66 | "role": "button", 67 | "data-toggle": "dropdown", 68 | "aria-haspopup": "true", 69 | "aria-expanded": "false", 70 | ], content: label) 71 | 72 | DynamicHTML( 73 | "ul", 74 | [ 75 | "class": "dropdown-menu \(self.showed ? "show": "")", 76 | "aria-labelledby": "navbarDropdown", 77 | ] 78 | ) { 79 | ForEach(self.submenus, id: \.self) { item in 80 | item 81 | } 82 | } 83 | }) 84 | 85 | } else { 86 | return AnyView( 87 | DynamicHTML( 88 | "li", ["class": "nav-item"] 89 | ) { 90 | HTML( 91 | "a", 92 | [ 93 | "href": href, 94 | "class": "nav-link \(self.selected ? "active" : "")", 95 | ], 96 | content: label) 97 | }) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/MenuWidget.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakCore 3 | import TokamakDOM 4 | 5 | public struct MenuItem: Hashable { 6 | var label: String 7 | var url: String 8 | 9 | public init(label: String, url: String) { 10 | self.label = label 11 | self.url = url 12 | } 13 | } 14 | 15 | public struct Menu: View { 16 | @Binding var items: [MenuItem] 17 | 18 | public init(items: Binding<[MenuItem]>) { 19 | _items = items 20 | } 21 | 22 | public var body: some View { 23 | HTML("nav", ["class": "col-md-4 col-lg-3", "style": "min-width: 220px"]) { 24 | ForEach( 25 | items, id: \.self, 26 | content: { e in 27 | HTML("a", ["href": "?#\(e.url)"], content: e.label) 28 | }) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Navbar.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JavaScriptKit 3 | import TokamakCore 4 | import TokamakDOM 5 | import TokamakStaticHTML 6 | 7 | public struct NavBar: View { 8 | let content: Content 9 | let title: String 10 | let color: String 11 | 12 | public init( 13 | title: String, 14 | color: String = "#2f83c1", 15 | @ViewBuilder content: () -> Content 16 | ) { 17 | self.content = content() 18 | self.color = color 19 | self.title = title 20 | } 21 | 22 | public var body: some View { 23 | HTML( 24 | "navbar", 25 | ["class": "navbar navbar-expand-lg navbar-dark", "style": "background-color: \(color)"] 26 | ) { 27 | HTML("div", ["class": "container-fluid"]) { 28 | HTML("a", ["class": "navbar-brand", "href": "#"], content: title) 29 | HTML( 30 | "button", 31 | [ 32 | "class": "navbar-toggler", 33 | "type": "button", 34 | "data-bs-toggle": "collapse", 35 | "data-bs-target": "#navbarText", 36 | "aria-controls": "navbarText", 37 | "aria-expanded": "false", 38 | "aria-label": "Toggle navigation", 39 | ] 40 | ) { 41 | HTML("span", ["class": "navbar-toggler-icon"], content: "") 42 | } 43 | HTML("div", ["class": "collapse navbar-collapse", "id": "navbarText"]) { 44 | HTML("ul", ["class": "navbar-nav me-auto mb-2 mb-lg-0"]) { 45 | content 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Small.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakDOM 3 | import TokamakStaticHTML 4 | 5 | public struct Small: View { 6 | let id: String 7 | let `class`: String 8 | var style: String 9 | let content: String 10 | 11 | public init( 12 | _ content: String, id: String = "", class: String = "", 13 | style: String = "" 14 | ) { 15 | self.content = content 16 | self.id = id 17 | self.style = style 18 | self.class = `class` 19 | } 20 | 21 | public var body: some View { 22 | HTML("small", ["id": id, "class": `class`, "style": style], content: content) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Span.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakDOM 3 | import TokamakStaticHTML 4 | 5 | public struct Span: View { 6 | let id: String 7 | let `class`: String 8 | var style: String 9 | let content: String 10 | 11 | public init( 12 | _ content: String, id: String = "", class: String = "", 13 | style: String = "" 14 | ) { 15 | self.content = content 16 | self.id = id 17 | self.style = style 18 | self.class = `class` 19 | } 20 | 21 | public var body: some View { 22 | HTML("span", ["id": id, "class": `class`, "style": style], content: content) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/Table.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import TokamakDOM 3 | import TokamakStaticHTML 4 | 5 | public struct Table: View { 6 | @Binding var items: [[String]] 7 | @State var columns: [String] 8 | 9 | public init(items: Binding<[[String]]>, columns: [String]) { 10 | _items = items 11 | _columns = .init(wrappedValue: columns) 12 | } 13 | 14 | public var body: some View { 15 | HTML("table", ["class": "table table-striped table-bordered"]) { 16 | HTML("thead") { 17 | HTML("tr") { 18 | ForEach(columns, id: \.self) { column in 19 | HTML("th", [:], content: column) 20 | } 21 | } 22 | } 23 | HTML("tbody") { 24 | ForEach(items, id: \.self) { item in 25 | HTML("tr") { 26 | ForEach(item, id: \.self) { cell in 27 | HTML("td", [:], content: cell) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/TextEditor.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakDOM 3 | 4 | import class OpenCombine.PassthroughSubject 5 | import struct OpenCombine.Published 6 | 7 | public struct TextEditor: View { 8 | var value: Binding 9 | 10 | public init(value: Binding) { 11 | self.value = value 12 | } 13 | 14 | public var body: some View { 15 | return AnyView( 16 | DynamicHTML( 17 | "textarea", 18 | [ 19 | "style": "width: 100%" 20 | ], 21 | listeners: [ 22 | "input": { event in 23 | if let newValue = event.target.object?.value.string { 24 | self.value.wrappedValue = newValue 25 | } 26 | } 27 | ], content: self.value.wrappedValue)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Frontless/Widgets/validations.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakDOM 3 | 4 | public typealias ValidationFunc = (String) -> String? 5 | 6 | public class Validations { 7 | public static func required(i: String) -> String? { 8 | if i.isEmpty { 9 | return "Is required" 10 | } 11 | return nil 12 | } 13 | 14 | public static func none(i _: String) -> String? { 15 | return nil 16 | } 17 | } 18 | 19 | public class ValidationState: ObservableObject { 20 | @Published public var showHints: Bool = false 21 | var validations: [String: Bool] = [:] 22 | 23 | public var ok: Bool { 24 | validations.allSatisfy { (_, value) -> Bool in 25 | value == true 26 | } 27 | } 28 | 29 | public init() {} 30 | 31 | public func validate(id: String, input: String, validationFun: (String) -> String?) -> String? { 32 | if let result = validationFun(input) { 33 | validations[id] = false 34 | return result 35 | } else { 36 | validations[id] = true 37 | } 38 | return nil 39 | } 40 | 41 | deinit {} 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Frontless/globals.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import Logging 3 | 4 | public let location = JSObject.global.location.object! 5 | public let window = JSObject.global.window.object! 6 | public let document = JSObject.global.document.object! 7 | public let math = JSObject.global.Math.object! 8 | 9 | let logger = Logger(label: "frontless") 10 | -------------------------------------------------------------------------------- /Sources/Frontless/navigation.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptKit 2 | import TokamakCore 3 | import TokamakDOM 4 | 5 | public func navigate(to: String) { 6 | location.hash = JSValue(stringLiteral: to) 7 | } 8 | 9 | public func parseHash(i: String) -> (page: String, args: [String]) { 10 | var page: String = "" 11 | var arguments: [String] = [] 12 | 13 | logger.debug(#function) 14 | let a = Array(i) 15 | if let position: Int = a.firstIndex(of: "?") { 16 | page = String(a[.. JSValue in 38 | logger.debug("onHashChange") 39 | self?.currentHash = location.hash.string! 40 | let (page, args) = parseHash(i: self!.currentHash) 41 | self?.currentArguments = args 42 | self?.currentPage = page 43 | return .undefined 44 | } 45 | currentHash = location.hash.string! 46 | let (page, args) = parseHash(i: currentHash) 47 | currentPage = page 48 | currentArguments = args 49 | 50 | window.onhashchange = .function(onHashChange) 51 | self.onHashChange = onHashChange 52 | } 53 | 54 | deinit { 55 | window.onhashchange = .undefined 56 | onHashChange.release() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/ButtonTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Frontless 3 | import TokamakDOM 4 | import TokamakStaticHTML 5 | 6 | 7 | final class ButtonTests: XCTestCase { 8 | func testButton() { 9 | struct testButtonView: View { 10 | @State var clicked: Bool = false 11 | var body: some View { 12 | Frontless.Button("test") { 13 | clicked = true 14 | } 15 | } 16 | } 17 | let dom = StaticHTMLRenderer(testButtonView()) 18 | AssertEnds(body: dom.html, with: "test") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/ColumnTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Frontless 3 | import TokamakDOM 4 | import TokamakStaticHTML 5 | 6 | 7 | final class ColumnTests: XCTestCase { 8 | func testColumn() { 9 | struct testColumnView: View { 10 | var body: some View { 11 | Col { 12 | 13 | } 14 | } 15 | } 16 | let dom = StaticHTMLRenderer(testColumnView()) 17 | AssertEnds(body: dom.html, with: "
") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/ComboBoxTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Frontless 3 | import TokamakDOM 4 | import TokamakStaticHTML 5 | 6 | 7 | final class ComboBoxTests: XCTestCase { 8 | func testComboBox() { 9 | struct testComboBoxView: View { 10 | @State var selection: String = "raz" 11 | var body: some View { 12 | ComboBox(selection: $selection, items: [(value: "raz", content: "raz")]) 13 | } 14 | } 15 | let dom = StaticHTMLRenderer(testComboBoxView()) 16 | AssertEnds(body: dom.html, with: """ 17 | 18 | """) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/ContainerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/DivTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/FormTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/IFDivTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/MainMenuTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/ShowIfTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Frontless 3 | import TokamakDOM 4 | import TokamakStaticHTML 5 | 6 | 7 | 8 | final class ShowIfTests: XCTestCase { 9 | 10 | func testHideDiv() { 11 | struct myView : View { 12 | 13 | @State var show: Bool = false 14 | var body: some View { 15 | ShowIf({ show }) { 16 | Small("Hello world!") 17 | } 18 | } 19 | } 20 | 21 | let view = myView() 22 | let hideDom = StaticHTMLRenderer(view) 23 | AssertEnds(body: hideDom.html, with: """ 24 |
Hello world!
25 | """) 26 | let viewShow = myView(show: true) 27 | let showDom = StaticHTMLRenderer(viewShow) 28 | AssertEnds(body: showDom.html, with: """ 29 |
Hello world!
30 | """) 31 | 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/SmallTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/SpanTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/TableTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/TestRenderer.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Tokamak contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // Created by Max Desiatov on 21/12/2018. 16 | // 17 | 18 | #if os(WASI) 19 | import JavaScriptKit 20 | #else 21 | import Dispatch 22 | #endif 23 | import TokamakCore 24 | 25 | public func testScheduler(closure: @escaping () -> ()) { 26 | #if os(WASI) 27 | let fn = JSClosure { _ -> JSValue in 28 | closure() 29 | return .undefined 30 | } 31 | _ = JSObject.global.setTimeout!(fn, 0) 32 | #else 33 | DispatchQueue.main.async(execute: closure) 34 | #endif 35 | } 36 | 37 | public final class TestRenderer: Renderer { 38 | public private(set) var reconciler: StackReconciler? 39 | 40 | public var rootTarget: TestView { 41 | reconciler!.rootTarget 42 | } 43 | 44 | public init(_ view: V) { 45 | reconciler = StackReconciler( 46 | view: view, 47 | target: TestView(EmptyView()), 48 | environment: .init(), 49 | renderer: self, 50 | scheduler: testScheduler 51 | ) 52 | } 53 | 54 | public func mountTarget( 55 | before _: TestView?, 56 | to parent: TestView, 57 | with mountedHost: TestRenderer.MountedHost 58 | ) -> TestView? { 59 | let result = TestView(mountedHost.view) 60 | parent.add(subview: result) 61 | 62 | return result 63 | } 64 | 65 | public func update( 66 | target: TestView, 67 | with mountedHost: TestRenderer.MountedHost 68 | ) {} 69 | 70 | public func unmount( 71 | target: TestView, 72 | from parent: TestView, 73 | with mountedHost: TestRenderer.MountedHost, 74 | completion: () -> () 75 | ) { 76 | target.removeFromSuperview() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/TestView.swift: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Tokamak contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // Created by Max Desiatov on 18/12/2018. 16 | // 17 | 18 | import TokamakCore 19 | 20 | /// A class that `TestRenderer` uses as a target. 21 | /// When rendering to a `TestView` instance it is possible 22 | /// to examine its `subviews` and `props` for testing. 23 | public final class TestView: Target { 24 | /// Subviews of this test view. 25 | public private(set) var subviews: [TestView] 26 | 27 | /// Parent `TestView` instance that owns this instance as a child 28 | private weak var parent: TestView? 29 | 30 | public var view: AnyView 31 | 32 | /** Initialize a new test view. */ 33 | init(_ view: V, _ subviews: [TestView] = []) { 34 | self.subviews = subviews 35 | self.view = AnyView(view) 36 | } 37 | 38 | /** Add a subview to this test view. 39 | - parameter subview: the subview to be added to this view. 40 | */ 41 | func add(subview: TestView) { 42 | subviews.append(subview) 43 | subview.parent = self 44 | } 45 | 46 | /** Remove a subview from this test view. 47 | - parameter subview: the subview to be removed from this view. 48 | */ 49 | func remove(subview: TestView) { 50 | subviews.removeAll { $0 === subview } 51 | } 52 | 53 | /// Remove this test view from a superview if there is any 54 | func removeFromSuperview() { 55 | parent?.remove(subview: self) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | #if !canImport(ObjectiveC) 5 | public func allTests() -> [XCTestCaseEntry] { 6 | return [ 7 | testCase(frontlessTests.allTests), 8 | ] 9 | } 10 | #endif 11 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/assertions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | func AssertEnds( 5 | body: String, 6 | with: String, 7 | file: StaticString = #file, line: UInt = #line 8 | ) { 9 | 10 | let bodyEndings = "" 11 | // 12 | // let bodyEndings = """ 13 | // 19 | // """ 20 | let body = body.replacingOccurrences(of: "\n", 21 | with: "") 22 | if let range = body.range(of: bodyEndings) { 23 | let endMark = "" 24 | let substring = body[range.upperBound...] 25 | if substring == with + endMark { 26 | XCTAssert(true, file: file, line: line) 27 | } else { 28 | XCTFail("\(substring) is not equal to expected: \(with + endMark)", file: file, line: line) 29 | print(substring.difference(from: with+endMark)) 30 | } 31 | } else { 32 | XCTFail("Body endings not found") 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Tests/FrontlessTests/navigationTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | @testable import Frontless 4 | 5 | final class NavigationTests: XCTestCase { 6 | func testParseHash() throws { 7 | let parsed = parseHash(i: "#AddTask?383c08b4-44a3-464e-a701-26165dc2a1c1") 8 | XCTAssertEqual(parsed.page, "#AddTask") 9 | XCTAssertEqual(parsed.args, ["383c08b4-44a3-464e-a701-26165dc2a1c1"]) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 71 | 72 |