├── .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 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | 
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 | --- raz
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 |