├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ └── codeStyleConfig.xml
├── compiler.xml
├── encodings.xml
├── google-java-format.xml
├── jarRepositories.xml
├── kotlinc.xml
├── misc.xml
├── uiDesigner.xml
└── vcs.xml
├── .vscode
└── settings.json
├── README.md
├── js
├── package-lock.json
├── package.json
└── undead.ts
├── justfile
├── pom.xml
├── src
├── main
│ ├── java
│ │ └── run
│ │ │ └── undead
│ │ │ ├── config
│ │ │ └── Config.java
│ │ │ ├── context
│ │ │ ├── Context.java
│ │ │ ├── HttpContext.java
│ │ │ ├── HttpHandler.java
│ │ │ ├── WsContext.java
│ │ │ ├── WsHandler.java
│ │ │ └── WsSender.java
│ │ │ ├── event
│ │ │ ├── SimpleUndeadEvent.java
│ │ │ ├── SimpleUndeadInfo.java
│ │ │ ├── UndeadEvent.java
│ │ │ └── UndeadInfo.java
│ │ │ ├── form
│ │ │ └── Form.java
│ │ │ ├── handle
│ │ │ └── http
│ │ │ │ └── RequestAdaptor.java
│ │ │ ├── javalin
│ │ │ ├── JavalinRequestAdaptor.java
│ │ │ ├── JavalinRouteMatcher.java
│ │ │ ├── JavalinWsAdaptor.java
│ │ │ ├── JavalinWsSender.java
│ │ │ ├── UndeadHandler.java
│ │ │ └── example
│ │ │ │ ├── Server.java
│ │ │ │ ├── UndeadJavalin.java
│ │ │ │ └── view
│ │ │ │ ├── UndeadCounter.java
│ │ │ │ ├── UndeadSalesDashboard.java
│ │ │ │ ├── UndeadUserForm.java
│ │ │ │ ├── UndeadVolumeControl.kt
│ │ │ │ ├── model
│ │ │ │ └── UserModel.java
│ │ │ │ ├── tags
│ │ │ │ ├── ExCard.java
│ │ │ │ └── Input.java
│ │ │ │ └── tmpl
│ │ │ │ └── Tmpl.java
│ │ │ ├── js
│ │ │ ├── AddClassOpts.java
│ │ │ ├── Cmd.java
│ │ │ ├── DispatchOpts.java
│ │ │ ├── ExecOpts.java
│ │ │ ├── FocusFirstOpts.java
│ │ │ ├── FocusOpts.java
│ │ │ ├── HideOpts.java
│ │ │ ├── JS.java
│ │ │ ├── NavigateOpts.java
│ │ │ ├── PatchOpts.java
│ │ │ ├── PopFocusOpts.java
│ │ │ ├── PushFocusOpts.java
│ │ │ ├── PushOpts.java
│ │ │ ├── RemoveAttrOpts.java
│ │ │ ├── RemoveClassOpts.java
│ │ │ ├── SetAttrOpts.java
│ │ │ ├── ShowOpts.java
│ │ │ ├── ToggleOpts.java
│ │ │ ├── Transition.java
│ │ │ └── TransitionOpts.java
│ │ │ ├── protocol
│ │ │ ├── Msg.java
│ │ │ ├── MsgParser.java
│ │ │ └── Reply.java
│ │ │ ├── pubsub
│ │ │ ├── MemPubSub.java
│ │ │ ├── Pub.java
│ │ │ ├── PubSub.java
│ │ │ └── Sub.java
│ │ │ ├── template
│ │ │ ├── Directive.java
│ │ │ ├── MainLayout.java
│ │ │ ├── PageTitle.java
│ │ │ ├── Undead.java
│ │ │ ├── UndeadTemplate.java
│ │ │ └── WrapperTemplate.java
│ │ │ ├── url
│ │ │ └── Values.java
│ │ │ └── view
│ │ │ ├── Meta.java
│ │ │ ├── RouteMatcher.java
│ │ │ └── View.java
│ └── resources
│ │ └── public
│ │ └── js
│ │ ├── undead.js
│ │ └── undead.js.map
└── test
│ └── java
│ └── run
│ └── undead
│ ├── context
│ └── ContextTest.java
│ ├── form
│ ├── FormTest.java
│ └── MyForm.java
│ ├── js
│ └── JSCommandsTest.java
│ ├── protocol
│ └── MsgTest.java
│ ├── pubsub
│ └── PubSubTest.java
│ ├── template
│ └── UndeadTemplateTest.java
│ └── url
│ ├── Foo.java
│ └── UrlTest.java
└── undead-core.iml
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled class file
2 | *.class
3 |
4 | # Log file
5 | *.log
6 |
7 | # BlueJ files
8 | *.ctxt
9 |
10 | # Mobile Tools for Java (J2ME)
11 | .mtj.tmp/
12 |
13 | # Package Files #
14 | *.jar
15 | *.war
16 | *.nar
17 | *.ear
18 | *.zip
19 | *.tar.gz
20 | *.rar
21 |
22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
23 | hs_err_pid*
24 | replay_pid*
25 |
26 | # target dir
27 | target/
28 |
29 | # javascript
30 | node_modules
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/google-java-format.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.idea/uiDesigner.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | -
6 |
7 |
8 | -
9 |
10 |
11 | -
12 |
13 |
14 | -
15 |
16 |
17 | -
18 |
19 |
20 |
21 |
22 |
23 | -
24 |
25 |
26 |
27 |
28 |
29 | -
30 |
31 |
32 |
33 |
34 |
35 | -
36 |
37 |
38 |
39 |
40 |
41 | -
42 |
43 |
44 |
45 |
46 | -
47 |
48 |
49 |
50 |
51 | -
52 |
53 |
54 |
55 |
56 | -
57 |
58 |
59 |
60 |
61 | -
62 |
63 |
64 |
65 |
66 | -
67 |
68 |
69 |
70 |
71 | -
72 |
73 |
74 | -
75 |
76 |
77 |
78 |
79 | -
80 |
81 |
82 |
83 |
84 | -
85 |
86 |
87 |
88 |
89 | -
90 |
91 |
92 |
93 |
94 | -
95 |
96 |
97 |
98 |
99 | -
100 |
101 |
102 | -
103 |
104 |
105 | -
106 |
107 |
108 | -
109 |
110 |
111 | -
112 |
113 |
114 |
115 |
116 | -
117 |
118 |
119 | -
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "java.configuration.updateBuildConfiguration": "automatic"
3 | }
4 |
--------------------------------------------------------------------------------
/js/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "undead-js",
3 | "version": "0.0.1",
4 | "description": "build / customize client javascript for Undead projects",
5 | "scripts": {
6 | "build": "esbuild undead.ts --minify --bundle --sourcemap --outfile=../src/main/resources/public/js/undead.js --drop:console --target=es2020",
7 | "build-dev": "esbuild undead.ts --bundle --sourcemap --outfile=../src/main/resources/public/js/undead.js --target=es2020"
8 | },
9 | "dependencies": {
10 | "phoenix": "^1.7.9",
11 | "phoenix_live_view": "^0.20.1",
12 | "topbar": "^2.0.1"
13 | },
14 | "devDependencies": {
15 | "@types/node": "^20.8.9",
16 | "@types/phoenix": "^1.6.3",
17 | "@types/phoenix_live_view": "^0.18.2",
18 | "esbuild": "^0.19.5",
19 | "typescript": "^5.2.2"
20 | },
21 | "author": "Donnie Flood ",
22 | "license": "MIT"
23 | }
24 |
--------------------------------------------------------------------------------
/js/undead.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from "phoenix";
2 | import { LiveSocket } from "phoenix_live_view";
3 | import topbar from "topbar";
4 |
5 | // LiveView
6 | let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content");
7 | let liveSocket = new LiveSocket("/live", Socket, {
8 | params: { _csrf_token: csrfToken },
9 | bindingPrefix: "ud-"
10 | });
11 |
12 | // Show progress bar on live navigation and form submits
13 | topbar.config({ barColors: { 0: "#FF0000" }, shadowColor: "rgba(0, 0, 0, .3)" });
14 | window.addEventListener("phx:page-loading-start", () => topbar.show(200));
15 | window.addEventListener("phx:page-loading-stop", () => topbar.hide());
16 |
17 | // connect if there are any LiveViews on the page
18 | liveSocket.connect();
19 |
20 | // add event listener for generic js-exec events from server
21 | // this works by adding a data attribute to the element with the js to execute
22 | // and then triggering a custom event with the selector to find the element
23 | // see: https://fly.io/phoenix-files/server-triggered-js/
24 | window.addEventListener("phx:js-exec", (e: Event) => {
25 | const detail = (e as CustomEvent).detail;
26 | document.querySelectorAll(detail.to).forEach(el => {
27 | liveSocket.execJS(el, el.getAttribute(detail.attr))
28 | })
29 | })
30 |
31 | // expose liveSocket on window for web console debug logs and latency simulation:
32 | liveSocket.enableDebug()
33 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
34 | // >> liveSocket.disableLatencySim()
35 | // @ts-ignore
36 | export { liveSocket };
37 |
--------------------------------------------------------------------------------
/justfile:
--------------------------------------------------------------------------------
1 | alias ex := run-example
2 | alias js := build-js
3 |
4 | # run the undead example server
5 | run-example:
6 | mvn package exec:exec
7 |
8 | # build the undead js
9 | build-js:
10 | cd js; npm i; npm run build; cd ..
11 | @echo "remember to restart the server"
12 |
13 | # build dev js
14 | build-dev-js:
15 | cd js; npm i; npm run build-dev; cd ..
16 | @echo "remember to restart the server"
17 |
18 | # release to maven central
19 | release: && open-sonatype
20 | mvn clean deploy
21 |
22 | # opens a browser to the sonatype nexus repo
23 | open-sonatype:
24 | open https://central.sonatype.com/publishing/deployments
25 |
26 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 | 4.0.0
6 |
7 | run.undead
8 | undead-core
9 | 0.0.15
10 | undead-core
11 |
12 | Undead is a library for building scary fast, highly dynamic web applications on the JVM.
13 |
14 | https://github.com/floodfx/undead
15 |
16 | UTF-8
17 | 21
18 | 1.9.20
19 |
20 |
21 |
22 | The Apache License, Version 2.0
23 | http://www.apache.org/licenses/LICENSE-2.0.txt
24 |
25 |
26 |
27 |
28 | Donnie Flood
29 | donnie@floodfx.com
30 | floodfx
31 | https://github.com/floodfx
32 |
33 |
34 |
35 | scm:git:git://github.com/floodfx/undead.git
36 | scm:git:ssh://github.com:floodfx/undead.git
37 | http://github.com/floodfx/undead/tree/main
38 |
39 |
40 |
41 | org.junit.jupiter
42 | junit-jupiter-engine
43 | 5.9.1
44 | test
45 |
46 |
47 | io.javalin
48 | javalin
49 | 5.6.3
50 |
51 |
52 | com.squareup.moshi
53 | moshi
54 | 1.15.0
55 |
56 |
57 | com.squareup.okhttp3
58 | okhttp
59 | 4.11.0
60 |
61 |
62 | org.slf4j
63 | slf4j-simple
64 | 2.0.7
65 |
66 |
67 | com.google.guava
68 | guava
69 | 32.1.3-jre
70 |
71 |
72 | org.apache.httpcomponents.client5
73 | httpclient5
74 | 5.2.1
75 |
76 |
77 | com.fasterxml.jackson.core
78 | jackson-databind
79 | 2.15.3
80 |
81 |
82 | org.hibernate.validator
83 | hibernate-validator
84 | 8.0.0.Final
85 |
86 |
87 | org.jetbrains.kotlin
88 | kotlin-stdlib-jdk8
89 | ${kotlin.version}
90 |
91 |
92 | org.jetbrains.kotlin
93 | kotlin-test
94 | ${kotlin.version}
95 | test
96 |
97 |
98 |
99 |
100 |
101 | ossrh
102 | https://s01.oss.sonatype.org/content/repositories/snapshots
103 |
104 |
105 | ossrh
106 | https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | maven-clean-plugin
115 | 3.1.0
116 |
117 |
118 |
119 | maven-resources-plugin
120 | 3.0.2
121 |
122 |
123 | maven-compiler-plugin
124 | 3.8.0
125 |
126 | ${maven.compiler.release}
127 | 21
128 | 21
129 |
130 |
131 |
132 |
133 | default-compile
134 | none
135 |
136 |
137 |
138 | default-testCompile
139 | none
140 |
141 |
142 | java-compile
143 | compile
144 |
145 | compile
146 |
147 |
148 |
149 | java-test-compile
150 | test-compile
151 |
152 | testCompile
153 |
154 |
155 |
156 |
157 |
158 | maven-surefire-plugin
159 | 2.22.1
160 |
161 |
162 | --enable-preview
163 |
164 |
165 |
166 |
167 | maven-jar-plugin
168 | 3.0.2
169 |
170 |
171 | maven-install-plugin
172 | 2.5.2
173 |
174 |
175 | maven-deploy-plugin
176 | 2.8.2
177 |
178 |
179 |
180 | maven-site-plugin
181 | 3.7.1
182 |
183 |
184 | maven-project-info-reports-plugin
185 | 3.0.0
186 |
187 |
188 |
189 |
190 |
191 | org.codehaus.mojo
192 | exec-maven-plugin
193 | 3.1.0
194 |
195 | java
196 |
197 | --enable-preview
198 | -classpath
199 |
200 | run.undead.javalin.example.Server
201 |
202 |
203 |
204 |
205 | org.apache.maven.plugins
206 | maven-javadoc-plugin
207 | 3.6.0
208 |
209 | 21
210 | false
211 | false
212 | --enable-preview
213 |
214 |
215 |
216 | attach-javadocs
217 |
218 | jar
219 |
220 |
221 |
222 |
223 |
224 | org.apache.maven.plugins
225 | maven-source-plugin
226 | 3.3.0
227 |
228 |
229 | attach-sources
230 |
231 | jar-no-fork
232 |
233 |
234 |
235 |
236 |
237 | org.apache.maven.plugins
238 | maven-gpg-plugin
239 | 3.1.0
240 |
241 |
242 | sign-artifacts
243 | verify
244 |
245 | sign
246 |
247 |
248 |
249 |
250 |
251 | --pinentry-mode
252 | loopback
253 |
254 |
255 |
256 |
257 | org.sonatype.central
258 | central-publishing-maven-plugin
259 | 0.1.2
260 | true
261 |
262 | central
263 | true
264 |
265 |
266 |
267 | org.jetbrains.kotlin
268 | kotlin-maven-plugin
269 | ${kotlin.version}
270 |
271 |
272 | compile
273 | compile
274 |
275 | compile
276 |
277 |
278 |
279 | src/main/java
280 | target/generated-sources/annotations
281 |
282 |
283 |
284 |
285 | test-compile
286 | test-compile
287 |
288 | test-compile
289 |
290 |
291 |
292 | src/test/java
293 | target/generated-test-sources/test-annotations
294 |
295 |
296 |
297 |
298 |
299 | 1.8
300 |
301 |
302 |
303 | org.apache.maven.plugins
304 | maven-compiler-plugin
305 |
306 | 21
307 | 21
308 | --enable-preview
309 |
310 |
311 |
312 | default-compile
313 | none
314 |
315 |
316 | default-testCompile
317 | none
318 |
319 |
320 | java-compile
321 | compile
322 |
323 | compile
324 |
325 |
326 |
327 | java-test-compile
328 | test-compile
329 |
330 | testCompile
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/config/Config.java:
--------------------------------------------------------------------------------
1 | package run.undead.config;
2 |
3 | import run.undead.pubsub.MemPubSub;
4 | import run.undead.pubsub.PubSub;
5 | import run.undead.template.MainLayout;
6 | import run.undead.template.PageTitle;
7 | import run.undead.template.UndeadTemplate;
8 | import run.undead.template.WrapperTemplate;
9 | import run.undead.view.RouteMatcher;
10 | import run.undead.view.View;
11 |
12 | import java.util.function.Consumer;
13 |
14 | /**
15 | * Config contains the configuration that is used across all Undead Views. At a minimum, you must
16 | * provide a {@link RouteMatcher} to match HTTP requests to Undead {@link View}s.
17 | *
18 | * See {@link MainLayout} for more information on how to customize the default layout.
19 | *
20 | */
21 | public class Config {
22 |
23 | public MainLayout mainLayout;
24 | public WrapperTemplate wrapperTemplate;
25 | public RouteMatcher routeMatcher;
26 |
27 | public Consumer debug;
28 |
29 | public PubSub pubsub;
30 |
31 | public Config() {
32 | // use the default main layout
33 | this.mainLayout = new MainLayout() {
34 | @Override
35 | public UndeadTemplate render(PageTitle pageTitle, String csrfToken, UndeadTemplate content) {
36 | return MainLayout.super.render(pageTitle, csrfToken, content);
37 | }
38 | };
39 | // default PubSub to MemPubSub
40 | this.pubsub = MemPubSub.INSTANCE;
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/context/Context.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | import run.undead.event.UndeadEvent;
4 | import run.undead.event.UndeadInfo;
5 | import run.undead.view.View;
6 | import run.undead.template.MainLayout;
7 |
8 | /**
9 | * Context abstracts the underlying transport mechanism (i.e. HTTP or WebSocket) for a
10 | * {@link View} and provides common functionality and metadata to extend the behavior
11 | * of the {@link View} instance.
12 | */
13 | public interface Context {
14 | // TODO:
15 | // support tempAssign (perhaps a @TempAssign annotation on a property?)
16 | // support pushPatch, pushRedirect,
17 | // how do we inject services into this object?
18 | // uploads: allowUpload, cancelUpload, consumeUploadedEntries, uploadedEntries
19 |
20 | /**
21 | * id is the unique id of the Undead {@link View} instance
22 | */
23 | String id();
24 |
25 | /**
26 | * connected is true if connected to a websocket, false for http request
27 | */
28 | default Boolean connected() {
29 | return false;
30 | }
31 |
32 | /**
33 | * url is the URL for this {@link View}
34 | */
35 | String url();
36 |
37 | /**
38 | * pageTitle updates the `` tag of the {@link View} page. Requires using the
39 | * {@link MainLayout#liveTitle} helper in rendering the page.
40 | */
41 | default void pageTitle(String newTitle) {
42 | // noop by default
43 | }
44 |
45 | /**
46 | * pushEvent pushes an event to the client. Requires either the client javascript
47 | * to have a {@code window.addEventListener} defined for that event or a client
48 | * {@code Hook} to be defined and to be listening for the event via {@code this.handleEvent} callback.
49 | */
50 | default void pushEvent(UndeadEvent event){
51 | // noop by default
52 | }
53 |
54 | /**
55 | * sendInfo sends an internal server message to this {@link View} instance. The
56 | * {@link View} must implement the {@link View#handleInfo(Context, UndeadInfo)} callback
57 | * to handle the info message.
58 | */
59 | default void sendInfo(UndeadInfo info) {
60 | // noop by default
61 | }
62 |
63 | void redirect(String url);
64 |
65 | /**
66 | * subscribe subscribes the {@link View} to the given topic. The {@link View} must
67 | * implement the {@link View#handleInfo(Context, UndeadInfo)} callback to handle the
68 | * info messages for the topic.
69 | * @param topic the topic to subscribe to
70 | */
71 | default void subscribe(String topic) {
72 | // noop by default
73 | }
74 |
75 | /**
76 | * unsubscribe unsubscribes the {@link View} from the given topic.
77 | * @param topic the topic to unsubscribe from
78 | */
79 | default void unsubscribe(String topic) {
80 | // noop by default
81 | }
82 |
83 | /**
84 | * publish publishes the given data to the given topic.
85 | * @param topic the topic to publish to
86 | * @param data the data to publish
87 | */
88 | default void publish(String topic, String data) {
89 | // noop by default
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/context/HttpContext.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | /**
4 | * HttpContext implements the {@link Context} interface for the HTTP request
5 | * lifecycle.
6 | */
7 | public class HttpContext implements Context {
8 | private final String id;
9 | private final String url;
10 |
11 | public String redirect;
12 |
13 | public HttpContext(String id, String url) {
14 | this.id = id;
15 | this.url = url;
16 | }
17 |
18 | @Override
19 | public String id() {
20 | return this.id;
21 | }
22 |
23 | @Override
24 | public String url() {
25 | return this.url;
26 | }
27 |
28 | @Override
29 | public void redirect(String url) {
30 | this.redirect = url;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/context/HttpHandler.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | import okhttp3.HttpUrl;
4 | import run.undead.handle.http.RequestAdaptor;
5 | import run.undead.template.*;
6 | import run.undead.view.Meta;
7 | import run.undead.view.View;
8 |
9 | import java.util.HashMap;
10 | import java.util.UUID;
11 |
12 | /**
13 | * HttpHandler handles the HTTP request lifecycle for a {@link View} and either
14 | * returns the rendered HTML or redirects the request.
15 | */
16 | public class HttpHandler {
17 |
18 | /**
19 | * Handle the HTTP request lifecycle for a {@link View} and either
20 | * return the rendered HTML or redirect the request.
21 | * @param view the {@link View} to render
22 | * @param mainLayout the {@link MainLayout} to render the {@link View} inside
23 | * @param adaptor the {@link RequestAdaptor} to pull data from the HTTP request
24 | * @param pageTitle the {@link PageTitle} to pass to the {@link MainLayout}
25 | * @param wrapperTemplate the optional {@link WrapperTemplate} to render the {@link View} inside
26 | * @return the rendered HTML or null if the request was redirected
27 | */
28 | static public String handle(
29 | View view,
30 | MainLayout mainLayout,
31 | RequestAdaptor adaptor,
32 | PageTitle pageTitle,
33 | WrapperTemplate wrapperTemplate
34 | ) {
35 |
36 | // new viewId for each request
37 | var viewId = UUID.randomUUID().toString();
38 |
39 | // extract csrf token from session data or generate it if it doesn't exist
40 | var sessionData = adaptor.sessionData();
41 | var csrfToken = (String) sessionData.get("_csrf_token");
42 | if (csrfToken == null) {
43 | csrfToken = UUID.randomUUID().toString();
44 | sessionData.put("_csrf_token", csrfToken);
45 | }
46 |
47 | var ctx = new HttpContext(viewId, adaptor.url());
48 |
49 | // execute the `LiveView`'s `mount` function, passing in the data from the HTTP request
50 | var params = new HashMap<>();
51 | params.put("_csrf_token", csrfToken);
52 | params.put("_mounts", -1);
53 | params.putAll(adaptor.pathParams());
54 | params.putAll(adaptor.queryParams());
55 |
56 | // Step 1: call mount
57 | view.mount(ctx, sessionData, params);//socket, sessionData, params
58 |
59 | // handle redirects in mount
60 | if (ctx.redirect != null) {
61 | adaptor.willRedirect(ctx.redirect);
62 | return null;
63 | }
64 |
65 | // Step 2: call handleParams
66 | var url = HttpUrl.parse(adaptor.url());
67 | view.handleParams(ctx, url.uri(), params);
68 |
69 | // handle redirects in handleParams
70 | if (ctx.redirect != null) {
71 | adaptor.willRedirect(ctx.redirect);
72 | return null;
73 | }
74 |
75 | // Step 3: call render
76 | var meta = new Meta();
77 | // TODO implement Components
78 | var tmpl = view.render(meta);
79 |
80 | // TODO implement serialization
81 | var serializedSession = "";//await serDe.serialize({ ...sessionData });
82 |
83 | // TODO implement tracking of statics
84 | var serializedStatics = "";//serDe.serialize({ ...view.statics });
85 |
86 | // check if there is a WrapperTemplate and render the View inside it
87 | var content = tmpl;
88 | if (wrapperTemplate != null) {
89 | content = wrapperTemplate.render(sessionData, content);
90 | }
91 |
92 | // render the root container of the View
93 | var rootContent = Undead.HTML. """
94 |
99 | \{ Directive.NoEscape(content) }
100 |
101 | """ ;
102 |
103 | // render the main layout with the View inside
104 | var pageTmpl = mainLayout.render(pageTitle, csrfToken, rootContent);
105 |
106 | // serialize the View to HTML
107 | return pageTmpl.toString();
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/context/WsContext.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | import run.undead.event.SimpleUndeadInfo;
4 | import run.undead.event.UndeadEvent;
5 | import run.undead.event.UndeadInfo;
6 | import run.undead.protocol.Reply;
7 | import run.undead.pubsub.PubSub;
8 | import run.undead.template.UndeadTemplate;
9 | import run.undead.view.Meta;
10 | import run.undead.view.View;
11 |
12 | import java.util.List;
13 | import java.util.Map;
14 | import java.util.concurrent.ConcurrentHashMap;
15 |
16 | /**
17 | * WsContext is an implementation of the {@link Context} for the WebSocket
18 | * lifecycle.
19 | */
20 | public class WsContext implements Context {
21 | protected String id;
22 | protected String url;
23 | protected View view;
24 | protected String joinRef;
25 | protected String msgRef;
26 | protected String csrfToken;
27 | protected String redirect;
28 | protected WsSender sender;
29 | protected Map subs; // topic to subId
30 | protected PubSub pubsub;
31 | protected UndeadTemplate lastTmpl;
32 | protected List events;
33 | protected String title;
34 |
35 | public WsContext(String id, String url, View view) {
36 | this.id = id;
37 | this.url = url;
38 | this.view = view;
39 | this.subs = new ConcurrentHashMap<>();
40 | }
41 |
42 | @Override
43 | public String id() {
44 | return this.id;
45 | }
46 |
47 | @Override
48 | public String url() {
49 | return this.url;
50 | }
51 |
52 | @Override
53 | public Boolean connected() {
54 | return true;
55 | }
56 |
57 | @Override
58 | public void pageTitle(String newTitle) {
59 | this.title = newTitle;
60 | }
61 |
62 | @Override
63 | public void pushEvent(UndeadEvent event) {
64 | this.events.add(event);
65 | }
66 |
67 | @Override
68 | public void sendInfo(UndeadInfo info) {
69 | this.view.handleInfo(this, info);
70 | var content = this.view.render(new Meta());
71 | this.sender.send(Reply.diff(this.id, diffParts(content)));
72 | }
73 |
74 | @Override
75 | public void redirect(String url) {
76 | this.redirect = url;
77 | }
78 |
79 | @Override
80 | public void subscribe(String topic) {
81 | if(this.pubsub == null) {
82 | throw new RuntimeException("pubsub not set");
83 | }
84 | // check if already subscribed
85 | if(this.subs.containsKey(topic)) {
86 | return;
87 | }
88 | var subId = this.pubsub.subscribe(topic, (t, m) -> {
89 | this.sendInfo(new SimpleUndeadInfo(t, m));
90 | });
91 | // save topic to sub mapping
92 | this.subs.put(topic, subId);
93 | }
94 |
95 | @Override
96 | public void unsubscribe(String topic) {
97 | if(this.pubsub == null) {
98 | throw new RuntimeException("pubsub not set");
99 | }
100 | // check if we have a subId for this topic
101 | if(!this.subs.containsKey(topic)) {
102 | return;
103 | }
104 | this.pubsub.unsubscribe(this.subs.get(topic));
105 | }
106 |
107 | @Override
108 | public void publish(String topic, String data) {
109 | if(this.pubsub == null) {
110 | throw new RuntimeException("pubsub not set");
111 | }
112 | this.pubsub.publish(topic, data);
113 | }
114 |
115 | public void handleClose() {
116 | if(this.pubsub != null) {
117 | for(var topic : subs.keySet()) {
118 | this.unsubscribe(topic);
119 | }
120 | }
121 | if (this.view != null) {
122 | this.view.shutdown();
123 | }
124 | }
125 |
126 | public void handleError(Object err) {
127 | // TODO send something to client to reload?
128 | this.handleClose();
129 | }
130 |
131 | /**
132 | * diffParts takes the new template and diffs it with the last template (if there was one)
133 | * and returns the diff "parts" to send to the client. Additionally, this method will
134 | * add the title and events to the parts if they are set.
135 | * @param newTmpl the new template to diff with the last template
136 | * @return the diff "parts" to send to the client
137 | */
138 | public Map diffParts(UndeadTemplate newTmpl) {
139 | var oldTmpl = this.lastTmpl;
140 | this.lastTmpl = newTmpl;
141 | var parts = newTmpl.toParts();
142 | // if we have an old template diff with new template
143 | if(oldTmpl != null) {
144 | parts = UndeadTemplate.diff(oldTmpl.toParts(), newTmpl.toParts());
145 | }
146 | // add title if set
147 | if(this.title != null) {
148 | parts.put("t", this.title);
149 | this.title = null;
150 | }
151 | // add events if set
152 | if(this.events != null) {
153 | parts.put("e", this.events);
154 | this.events = null;
155 | }
156 | return parts;
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/context/WsHandler.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | import com.google.common.base.Strings;
4 | import run.undead.config.Config;
5 | import run.undead.event.SimpleUndeadEvent;
6 | import run.undead.protocol.MsgParser;
7 | import run.undead.protocol.Reply;
8 | import run.undead.template.UndeadTemplate;
9 | import run.undead.url.Values;
10 | import run.undead.view.Meta;
11 | import okhttp3.HttpUrl;
12 | import run.undead.view.View;
13 |
14 | import java.util.Map;
15 |
16 | /**
17 | * WsHandler handles the websocket request lifecycle for a {@link View}
18 | */
19 | public class WsHandler {
20 | private final Config undeadConfig;
21 | private WsSender wsSender;
22 | private WsContext context;
23 |
24 | public WsHandler(Config undeadConfig) {
25 | this.undeadConfig = undeadConfig;
26 | }
27 |
28 | public void setWsSender(WsSender wsSender) {
29 | this.wsSender = wsSender;
30 | }
31 |
32 | public void handleMessage(String message) throws Exception {
33 | var msg = new MsgParser().parseJSON(message);
34 | switch (msg.event()) {
35 | case "heartbeat":
36 | wsSender.send(Reply.heartbeat(msg));
37 | break;
38 | case "phx_join":
39 | // check if topic starts with "lv:" or "lvu:"
40 | // "lv:" is a live view join
41 | // "lvu:" is a live view upload join
42 | var prefix = msg.topic().split(":");
43 | switch (prefix[0]) {
44 | case "lv":
45 | // for "lv" joins the payload should include a "url" or "redirect" key
46 | // from which we can to look up the View
47 | var urlStr = "";
48 | if (msg.payload().containsKey("url")) {
49 | urlStr = (String) msg.payload().get("url");
50 | } else if (msg.payload().containsKey("redirect")) {
51 | urlStr = (String) msg.payload().get("redirect");
52 | } else {
53 | throw new RuntimeException("no url or redirect key found in payload");
54 | }
55 | // extract the path from the URL and match it using the route matcher
56 | var url = HttpUrl.parse(urlStr);
57 | var path = url.encodedPath();
58 | var view = undeadConfig.routeMatcher.matches(path);
59 | if (view == null) {
60 | throw new RuntimeException("unable to find view for path:" + path + " url:" + urlStr);
61 | }
62 |
63 | // get data from params
64 | var params = (Map) msg.payload().get("params");
65 | if (params == null) {
66 | throw new RuntimeException("params not present in payload");
67 | }
68 |
69 | // TODO decode session
70 | var session = (String) msg.payload().get("session");
71 | // TODO pull path params from url
72 | var pathParams = undeadConfig.routeMatcher.pathParams(path);
73 | // merge path params into params
74 | params.putAll(pathParams);
75 |
76 | // setup WS context
77 | context = new WsContext(msg.topic(), urlStr, view);
78 | context.joinRef = msg.joinRef();
79 | context.msgRef = msg.msgRef();
80 | context.csrfToken = (String) params.get("_csrf_token");
81 | context.view = view;
82 | context.sender = wsSender;
83 | context.pubsub = undeadConfig.pubsub;
84 | // TODO get session data and params
85 |
86 | // lv: join messages get a mount => handleParams => render
87 | view.mount(context, Map.of(), params);
88 | view.handleParams(context, url.uri(), params);
89 | var content = view.render(new Meta());
90 |
91 | // instead of serializing as HTML string, we send back the parts data structure
92 | wsSender.send(Reply.rendered(msg, context.diffParts(content)));
93 | break;
94 | // TODO case "lvu" i.e. uploads
95 | default: // unknown phx_join topic
96 | throw new RuntimeException("unknown phx_join topic");
97 | }
98 | break;
99 | case "event":
100 | // determine type of event and further details
101 | var payloadEventType = (String) msg.payload().get("type");
102 | var payloadEvent = (String) msg.payload().get("event");
103 | // TODO handle components
104 | // var cid = (String)msg.payload().get("cid");
105 | UndeadTemplate tmpl;
106 | switch (payloadEventType) {
107 | case "click":
108 | case "keyup":
109 | case "keydown":
110 | case "blur":
111 | case "focus":
112 | case "hook":
113 | // get value from payload with should be a map
114 | var payloadValues = Values.from((Map) msg.payload().get("value"));
115 | // convert the value map to a url.Values
116 |
117 | // check if the click is a lv:clear-flash event
118 | // which does not invoke HandleEvent but should
119 | // set the flash value to "" and send a responseDiff
120 | if (payloadEventType.equals("lv:clear-flash")) {
121 | var flashKey = payloadValues.get("key");
122 | // TODO implement clear flash
123 | throw new RuntimeException("clear flash not implemented");
124 | } else {
125 | // handle the event
126 | this.context.view.handleEvent(context, new SimpleUndeadEvent(payloadEvent, payloadValues));
127 | }
128 | break;
129 | case "form":
130 | // for form events the payload value is a string that needs to be parsed into the data
131 | var values = Values.from((String) msg.payload().get("value"));
132 | // handle uploads before calling calling handleEvent
133 | // TODO uploads
134 | this.context.view.handleEvent(context, new SimpleUndeadEvent(payloadEvent, values));
135 | break;
136 | default:
137 | throw new RuntimeException("unknown event type:" + payloadEventType);
138 | }
139 | // check if we have a redirect
140 | if (context != null && !Strings.isNullOrEmpty(context.redirect)) {
141 | wsSender.send(Reply.redirect(msg, context.redirect));
142 | break;
143 | }
144 | // otherwise rerender
145 | var content = context.view.render(new Meta());
146 | wsSender.send(Reply.replyDiff(msg, context.diffParts(content)));
147 | break;
148 | default:
149 | throw new RuntimeException("unhandled event:" + msg.event());
150 | }
151 | }
152 |
153 | public void handleError(Object error) {
154 | if (this.context != null) {
155 | this.context.handleError(error);
156 | }
157 | }
158 |
159 | public void handleClose() {
160 | if (this.context != null) {
161 | this.context.handleClose();
162 | }
163 | }
164 |
165 |
166 |
167 | }
168 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/context/WsSender.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | /**
4 | * WsSender is an interface for sending String data to the websocket client.
5 | */
6 | public interface WsSender {
7 |
8 | /**
9 | * Send string data to the client over the websocket.
10 | * @param data the data to send
11 | */
12 | void send(String data);
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/event/SimpleUndeadEvent.java:
--------------------------------------------------------------------------------
1 | package run.undead.event;
2 |
3 | import run.undead.url.Values;
4 |
5 | /**
6 | * SimpleUndeadEvent is a simple implementation of {@link UndeadEvent} that is used internally by Undead.
7 | * @param type the type of the event
8 | * @param data the data associated with the event
9 | */
10 | public record SimpleUndeadEvent(String type, Values data) implements UndeadEvent {
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/event/SimpleUndeadInfo.java:
--------------------------------------------------------------------------------
1 | package run.undead.event;
2 |
3 | /**
4 | * SimpleUndeadInfo is a simple implementation of {@link UndeadInfo} that is used internally by Undead.
5 | * @param type the type of the event
6 | * @param data the String data associated with the event
7 | */
8 | public record SimpleUndeadInfo(String type, String data) implements UndeadInfo {
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/event/UndeadEvent.java:
--------------------------------------------------------------------------------
1 | package run.undead.event;
2 |
3 | import run.undead.js.JS;
4 | import run.undead.url.Values;
5 | import run.undead.template.UndeadTemplate;
6 | import run.undead.view.View;
7 |
8 | /**
9 | * UndeadEvent is the interface for all events that are received from the browser. Events are automatically
10 | * sent to the server when an element with a ud-
attribute's event is triggered from an element
11 | * in an {@link UndeadTemplate}.
12 | *
13 | * @see UndeadTemplate
14 | * @see View
15 | * @see JS
16 | */
17 | public interface UndeadEvent {
18 | /**
19 | * type is the type of the event. For example, if a button has a ud-click="my-event"
attribute,
20 | * then the type of the event will be my-event
.
21 | * @return the type of the event
22 | */
23 | String type();
24 |
25 | /**
26 | * data contains the {@link Values} associated with the event. For example, if a button has a
27 | * ud-value-foo="bar"
attribute, then the data of the event will contain a key/value
28 | * pairs of foo
to bar
.
29 | * @return the data associated with the event
30 | */
31 | Values data();
32 | }
33 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/event/UndeadInfo.java:
--------------------------------------------------------------------------------
1 | package run.undead.event;
2 |
3 | import run.undead.context.Context;
4 |
5 | /**
6 | * UndeadInfo is the interface for all events that are from the server (either from a {@link Context#sendInfo(UndeadInfo)}
7 | * or from a pub/sub subscription. Since this data comes directly from another server side process, Undead
8 | * does not parse it or otherwise modify. It is up to the developer to parse the data and handle it appropriately.
9 | */
10 | public interface UndeadInfo {
11 |
12 | /**
13 | * type is the type of info event
14 | * @return the type of the info event
15 | */
16 | String type();
17 |
18 | /**
19 | * data is the String data associated with the info
20 | * @return the String data
21 | */
22 | String data();
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/form/Form.java:
--------------------------------------------------------------------------------
1 | package run.undead.form;
2 |
3 | import com.fasterxml.jackson.databind.DeserializationFeature;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 | import com.google.common.base.Strings;
6 | import run.undead.url.Values;
7 | import jakarta.validation.Validation;
8 | import jakarta.validation.Validator;
9 | import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator;
10 | import run.undead.view.View;
11 |
12 | import java.util.*;
13 |
14 | /**
15 | * Form makes it dead easy to validate and bind form data to a model in Undead.
16 | * Currently, this requires the use Jakarta Bean Validation (JSR 380)
17 | * annotations to validate the model. Form handles mapping the form data to the model automatically
18 | * and providing a easy-to-use API for rendering form inputs and error messages.
19 | *
20 | * Here is an example {@link View} that uses Form:
21 | *
{@code
22 | * // first define UserModel with Jakarta Bean Validation annotations
23 | * public record UserModel(@NotBlank String name, @NotBlank @Email String email) { }
24 | * }
25 | *
26 | * Then define a view that uses this model and form:
27 | * {@code
28 | * public class UndeadUserForm implements View {
29 | *
30 | * private Form form;
31 | * private UserModel user;
32 | *
33 | * public UndeadUserForm() {
34 | * this.form = new Form<>(UserModel.class, new Values());
35 | * }
36 | *
37 | * @Override
38 | * public void handleEvent(Socket socket, UndeadEvent event) {
39 | * if (event.type().equals("validate")) {
40 | * // just update the form with the data from the event
41 | * // which will trigger validations and update the form state
42 | * this.form.update(event.data(), event.type());
43 | * return;
44 | * }
45 | * if (event.type().equals("submit")) {
46 | * this.form.update(event.data(), event.type());
47 | * // if form is valid, "save" user and reset form
48 | * if (this.form.valid()) {
49 | * this.user = this.form.model();
50 | * this.form = new Form<>(UserModel.class, new Values());
51 | * }
52 | * }
53 | * }
54 | *
55 | * @Override
56 | * public UndeadTemplate render(Meta meta) {
57 | * // NOTE the ud-change="validate"
and ud-submit="submit"
attributes
58 | * // on the form element. These are used to trigger the form validation and submission events
59 | * return HTML. """
60 | *
72 | * """ ;
73 | * }
74 | * }
75 | *
76 | *
77 | * The view above uses the following helper tags to render the form inputs and error messages:
78 | *
{@code
79 | * public class Input {
80 | * public static UndeadTemplate TextInput(Form form, String name, String... classes) {
81 | * var value = form.data().get(name);
82 | * var classesString = "";
83 | * for (var clazz : classes) {
84 | * classesString += clazz + " ";
85 | * }
86 | * return Directive.HTML. """
87 | *
88 | * """ ;
89 | * }
90 | * public static UndeadTemplate ErrorMsg(Form form, String name) {
91 | * var error = form.errorFor(name);
92 | * if (Strings.isNullOrEmpty(error)) {
93 | * return Directive.EMPTY;
94 | * }
95 | * return Directive.HTML. """
96 | * \{ error }
97 | * """ ;
98 | * }
99 | * }
100 | * }
101 | *
102 | *
103 | * @param the type of the model to which the form data is bound.
104 | *
105 | */
106 | public class Form {
107 |
108 | private final ObjectMapper mapper;
109 | private final Validator validator;
110 | private final Values values;
111 | private final Values changes;
112 | private final Class clazz;
113 | private String action;
114 | private final Set touched;
115 | private T model;
116 | private Map errors;
117 |
118 | /**
119 | * Create a new, empty form with the given model.
120 | * @param clazz the class of the model to bind the form data to
121 | */
122 | public Form(Class clazz) {
123 | this(clazz, new Values());
124 | }
125 |
126 | /**
127 | * Create a new form with the given model and initial values.
128 | * @param clazz the class of the model to bind the form data to
129 | * @param initial the initial values for the form which can be empty
130 | */
131 | public Form(Class clazz, Values initial) {
132 | this.mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
133 | this.validator = Validation.byDefaultProvider()
134 | .configure()
135 | .messageInterpolator(new ParameterMessageInterpolator())
136 | .buildValidatorFactory()
137 | .getValidator();
138 | this.touched = new HashSet<>();
139 | this.values = initial;
140 | this.changes = new Values();
141 | this.clazz = clazz;
142 | this.errors = new HashMap<>();
143 | this.model = (T) mapper.convertValue(values.asMap(), clazz);
144 | }
145 |
146 | /**
147 | * Updates the form with new values and an action. If the action is
148 | * null or empty, the form will not be validated (valid() will return true)
149 | * regardless of whether or not there are errors.
150 | * @param newValues the new values to update the form with
151 | * @param action the action to validate the form with
152 | */
153 | public void update(Values newValues, String action) {
154 | this.action = action;
155 |
156 | // merge old and new data and calculate changes
157 | newValues.asMap().forEach((k, v) -> {
158 | if (values.get(k) == null || !values.get(k).equals(v)) {
159 | switch (v) {
160 | case String s -> changes.add(k, s);
161 | case List l -> {
162 | for (Object o : l) {
163 | changes.add(k, String.valueOf(o));
164 | }
165 | }
166 | // throw error?
167 | default -> changes.add(k, String.valueOf(v));
168 | }
169 | }
170 | values.set(k, v);
171 | });
172 | // handle case where _target is set but the newData does not contain the _target field
173 | // this happens in the case of a checkbox that is unchecked
174 | var target = newValues.get("_target");
175 | if (target != null && newValues.get(target) == null) {
176 | values.delete(target);
177 | changes.add(target, "");
178 | }
179 | // validate if action is not empty
180 | if (!Strings.isNullOrEmpty(action)) {
181 | // map values to model
182 | model = (T) mapper.convertValue(values.asMap(), clazz);
183 | // validate model
184 | var violations = validator.validate(model);
185 | // convert violations to errors
186 | errors = new HashMap<>();
187 | for (var v : violations) {
188 | errors.put(v.getPropertyPath().toString(), v.getMessage());
189 | }
190 |
191 | // if we get a _target field in the form, use it to indicate which fields were touched
192 | // if not, assume all fields in input were touched and all fields
193 | // with errors were touched
194 | if (target != null) {
195 | this.touched.add(target);
196 | } else {
197 | for (var k : values.asMap().keySet()) {
198 | this.touched.add(k);
199 | }
200 | for (var e : errors.keySet()) {
201 | this.touched.add(e);
202 | }
203 | }
204 | }
205 | }
206 |
207 | /**
208 | * Returns the errors for the form.
209 | * @return the errors for the form mapped by field name
210 | */
211 | public Map errors() {
212 | return this.errors;
213 | }
214 |
215 | /**
216 | * Returns the error for the given field name.
217 | * @param key the field name to get the error for
218 | * @return the error for the given field name or null if there is no error
219 | */
220 | public String errorFor(String key) {
221 | if (errors == null || errors.get(key) == null || touched == null || !touched.contains(key) || valid()) {
222 | return null;
223 | }
224 | return this.errors.get(key);
225 | }
226 |
227 | /**
228 | * Returns the latest model for the form with the form data bound to it.
229 | * @return the latest model for the form as type T
230 | */
231 | public T model() {
232 | return this.model;
233 | }
234 |
235 | /**
236 | * Returns the changes for the form, that is, the fields that have changed
237 | * since it was initialized and their current values.
238 | * @return the changes for the form mapped by field name
239 | */
240 | public Map changes() {
241 | return this.changes.asMap();
242 | }
243 |
244 | /**
245 | * Returns the values for the form, that is, the last set of values that were
246 | * passed to the form.
247 | * @return the {@link Values} for the form
248 | */
249 | public Values data() {
250 | return this.values;
251 | }
252 |
253 | /**
254 | * Returns whether or not the form is "valid". A form is valid if there has been an update
255 | * with an non-null or empty action and there are no errors on any of the fields that have been
256 | * touched (i.e. attempted to be changed) since the last update.
257 | * @return true if the form is valid, false otherwise
258 | */
259 | public boolean valid() {
260 | // no action or empty errors means is always valid
261 | if (Strings.isNullOrEmpty(action) || errors().isEmpty() || touched.isEmpty()) {
262 | return true;
263 | }
264 | // if nothing was touched the changeset is valid
265 | // regardless of whether or not there are errors
266 | // otherwise, only check for errors on touched fields
267 | // and return false if there are any errors
268 | for (String k : touched) {
269 | if (errors().containsKey(k)) {
270 | return false;
271 | }
272 | }
273 | return true;
274 | }
275 | }
276 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/handle/http/RequestAdaptor.java:
--------------------------------------------------------------------------------
1 | package run.undead.handle.http;
2 |
3 | import java.util.Map;
4 |
5 | /**
6 | * RequestAdaptor is an interface for adapting the HTTP request from a server
7 | * framework to the Undead render lifecycle. This normalizes the HTTP request
8 | * so that the Undead render lifecycle can be used with any server framework that
9 | * can adapt its request to this interface.
10 | */
11 | public interface RequestAdaptor {
12 |
13 | /**
14 | * The session data from the HTTP request
15 | * @return a Map of session data
16 | */
17 | Map sessionData();
18 |
19 | /**
20 | * The URL of the HTTP request
21 | * @return the URL
22 | */
23 | String url();
24 |
25 | /**
26 | * The path of the HTTP request
27 | * @return the path
28 | */
29 | String path();
30 |
31 | /**
32 | * The path parameters of the HTTP request
33 | * @return a Map of path parameters
34 | */
35 | Map pathParams();
36 |
37 | /**
38 | * The query parameters of the HTTP request
39 | * @return a Map of query parameters
40 | */
41 | Map queryParams();
42 |
43 | /**
44 | * A callback to deliver redirect URLs to the server framework
45 | * @param destURL
46 | */
47 | void willRedirect(String destURL);
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/JavalinRequestAdaptor.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin;
2 |
3 | import run.undead.handle.http.RequestAdaptor;
4 | import io.javalin.http.Context;
5 |
6 | import java.util.List;
7 | import java.util.Map;
8 |
9 | /**
10 | * JavalinRequestAdaptor is an implementation of {@link RequestAdaptor} for Javalin
11 | * HTTP requests.
12 | */
13 | public class JavalinRequestAdaptor implements RequestAdaptor {
14 | private final Context ctx;
15 | private String redirectURL;
16 |
17 | public JavalinRequestAdaptor(Context ctx) {
18 | this.ctx = ctx;
19 | }
20 |
21 | @Override
22 | public Map sessionData() {
23 | return this.ctx.sessionAttributeMap();
24 | }
25 |
26 | @Override
27 | public String url() {
28 | return this.ctx.url();
29 | }
30 |
31 | @Override
32 | public String path() {
33 | return this.ctx.path();
34 | }
35 |
36 | @Override
37 | public Map pathParams() {
38 | return ctx.pathParamMap();
39 | }
40 |
41 | @Override
42 | public Map> queryParams() {
43 | return ctx.queryParamMap();
44 | }
45 |
46 | @Override
47 | public void willRedirect(String destURL) {
48 | this.redirectURL = destURL;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/JavalinRouteMatcher.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin;
2 |
3 | import run.undead.view.RouteMatcher;
4 | import run.undead.view.View;
5 | import io.javalin.config.RoutingConfig;
6 | import io.javalin.routing.PathParser;
7 |
8 | import java.util.LinkedHashMap;
9 | import java.util.Map;
10 |
11 | /**
12 | * JavalinRouteMatcher is an implementation of {@link RouteMatcher} for Javalin.
13 | */
14 | public class JavalinRouteMatcher implements RouteMatcher {
15 | private final RoutingConfig routingConfig;
16 | private final Map routeRegistry;
17 |
18 | /**
19 | * JavalinRouteMatcher creates a new JavalinRouteMatcher with the given {@link RoutingConfig}
20 | * for the Javalin server.
21 | * @param routingConfig the {@link RoutingConfig} for the Javalin server
22 | */
23 | public JavalinRouteMatcher(RoutingConfig routingConfig) {
24 | this.routingConfig = routingConfig;
25 | this.routeRegistry = new LinkedHashMap(); // order matters
26 | }
27 |
28 | @Override
29 | public Map pathParams(String path) {
30 | // first find the matching path regex in the routeRegistry
31 | String pathRegex = null;
32 | for (var entry : this.routeRegistry.entrySet()) {
33 | var parser = new PathParser(entry.getKey(), this.routingConfig);
34 | if (parser.matches(path)) {
35 | pathRegex = entry.getKey();
36 | break;
37 | }
38 | }
39 | if (pathRegex == null) {
40 | throw new RuntimeException("unable to find matching path regex for path:" + path);
41 | }
42 | // now extract the path params
43 | var parser = new PathParser(pathRegex, this.routingConfig);
44 | return parser.extractPathParams(path);
45 | }
46 |
47 | @Override
48 | public View matches(String path) {
49 | // iterate through map keys trying to find a match using PathParser
50 | for (var entry : this.routeRegistry.entrySet()) {
51 | var parser = new PathParser(entry.getKey(), this.routingConfig);
52 | if (parser.matches(path)) {
53 | try {
54 | return (View) entry.getValue().newInstance();
55 | } catch (Exception e) {
56 | throw new RuntimeException(e);
57 | }
58 | }
59 | }
60 | return null;
61 | }
62 |
63 | @Override
64 | public void addRoute(String pathRegex, View view) {
65 | this.routeRegistry.put(pathRegex, view.getClass());
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/JavalinWsAdaptor.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin;
2 |
3 | import run.undead.config.Config;
4 | import run.undead.context.WsHandler;
5 | import io.javalin.websocket.WsConfig;
6 | import run.undead.view.View;
7 |
8 | import java.time.Duration;
9 | import java.time.temporal.ChronoUnit;
10 | import java.util.Map;
11 | import java.util.concurrent.ConcurrentHashMap;
12 |
13 | /**
14 | * JavalinWsAdaptor connects Javalin websockets to the Undead {@link WsHandler} which
15 | * handles the websocket lifecycle of the Undead {@link View}.
16 | */
17 | public class JavalinWsAdaptor {
18 |
19 | private final Map handlerRegistry = new ConcurrentHashMap<>();
20 | private final Config undeadConf;
21 |
22 | public JavalinWsAdaptor(Config undeadConf, WsConfig wsConfig) {
23 | this.undeadConf = undeadConf;
24 | wsConfig.onConnect(ctx -> {
25 | // client sends heartbeat every 30 seconds which is the default idleTimeout,
26 | // therefore we need to extend default idle timeout to longer
27 | ctx.session.setIdleTimeout(Duration.of(45, ChronoUnit.SECONDS));
28 | });
29 | wsConfig.onError(ctx -> {
30 | var handler = getHandler(ctx.getSessionId());
31 | if(handler == null) {
32 | return;
33 | }
34 | handler.setWsSender(new JavalinWsSender(ctx));
35 | // give socket a chance to handle error
36 | handler.handleError(ctx.error());
37 | clearHandler(ctx.getSessionId());
38 | });
39 | wsConfig.onClose(ctx -> {
40 | var handler = getHandler(ctx.getSessionId());
41 | if(handler == null) {
42 | return;
43 | }
44 | // give socket a chance to handle close / cleanup
45 | handler.handleClose();
46 | clearHandler(ctx.getSessionId());
47 | });
48 | wsConfig.onMessage(ctx -> {
49 | if(undeadConf.debug != null) {
50 | undeadConf.debug.accept("Raw ws message: " + ctx.message());
51 | }
52 | var handler = getOrCreateHandler(ctx.getSessionId());
53 | handler.setWsSender(new JavalinWsSender(ctx));
54 | handler.handleMessage(ctx.message());
55 | });
56 | }
57 |
58 | private WsHandler getHandler(String sessionId) {
59 | return handlerRegistry.get(sessionId);
60 | }
61 |
62 | private WsHandler getOrCreateHandler(String sessionId) {
63 | return handlerRegistry.computeIfAbsent(sessionId, k -> new WsHandler(undeadConf));
64 | }
65 |
66 | private void clearHandler(String sessionId) {
67 | handlerRegistry.remove(sessionId);
68 | }
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/JavalinWsSender.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin;
2 |
3 | import run.undead.context.WsSender;
4 | import io.javalin.websocket.WsContext;
5 |
6 | /**
7 | * JavalinWsSender is an implementation of {@link WsSender} for Javalin websockets.
8 | */
9 | public class JavalinWsSender implements WsSender {
10 | private final WsContext ws;
11 |
12 | public JavalinWsSender(WsContext ws) {
13 | this.ws = ws;
14 | }
15 |
16 | @Override
17 | public void send(String data) {
18 | ws.send(data);
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/UndeadHandler.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin;
2 |
3 | import run.undead.config.Config;
4 | import run.undead.context.HttpHandler;
5 | import run.undead.template.PageTitle;
6 | import run.undead.view.View;
7 | import io.javalin.http.Context;
8 | import io.javalin.http.Handler;
9 | import org.jetbrains.annotations.NotNull;
10 |
11 | /**
12 | * A Javalin {@link Handler} that handles the HTTP request lifecycle of Undead {@link View}s
13 | */
14 | public class UndeadHandler implements Handler {
15 | private final Config config;
16 | private final View view;
17 | private PageTitle pageTitle;
18 |
19 | /**
20 | * Constructs a new {@link UndeadHandler} with the given {@link Config} and {@link View}.
21 | * @param config the {@link Config} to use
22 | * @param view the {@link View} to render
23 | */
24 | public UndeadHandler(Config config, View view) {
25 | this.config = config;
26 | this.view = view;
27 | this.pageTitle = new PageTitle();
28 | }
29 |
30 | public View view() {
31 | return this.view;
32 | }
33 |
34 | public UndeadHandler withPageTitleConfig(PageTitle config) {
35 | this.pageTitle = config;
36 | return this;
37 | }
38 |
39 | @Override
40 | public void handle(@NotNull Context ctx) throws Exception {
41 | var res = HttpHandler.handle(
42 | this.view.getClass().newInstance(),
43 | this.config.mainLayout,
44 | new JavalinRequestAdaptor(ctx),
45 | this.pageTitle,
46 | this.config.wrapperTemplate
47 | );
48 | ctx.contentType("text/html");
49 | ctx.result(res);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/Server.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example;
2 |
3 | import io.javalin.Javalin;
4 | import io.javalin.http.staticfiles.Location;
5 | import run.undead.config.Config;
6 | import run.undead.javalin.example.view.UndeadCounter;
7 | import run.undead.javalin.example.view.UndeadSalesDashboard;
8 | import run.undead.javalin.example.view.UndeadUserForm;
9 | import run.undead.javalin.example.view.UndeadVolumeControl;
10 | import run.undead.javalin.example.view.tags.ExCard;
11 |
12 | import java.util.List;
13 |
14 | import static run.undead.template.Directive.For;
15 | import static run.undead.template.Undead.HTML;
16 |
17 | /**
18 | * Server is a simple Javalin server that uses Undead to render rich, dynamic views.
19 | */
20 | public class Server {
21 | public static void main(String[] args) {
22 |
23 | var undeadConf = new Config();
24 | var app = new UndeadJavalin(Javalin.create(config ->
25 | // register static file locations for the Undead client javascript
26 | config.staticFiles.add(staticFileConfig -> {
27 | staticFileConfig.directory = "/public/js";
28 | staticFileConfig.location = Location.CLASSPATH;
29 | staticFileConfig.hostedPath = "/js";
30 | })
31 | ), undeadConf)
32 | // use the UndeadJavalin instance to register Undead Views to routes
33 | .undead("/count", new UndeadCounter())
34 | .undead("/count/{start}", new UndeadCounter())
35 | .undead("/dashboard", new UndeadSalesDashboard())
36 | .undead("/user/new", new UndeadUserForm())
37 | .undead("/volume", new UndeadVolumeControl())
38 | .javalin() // get the underlying Javalin instance from UndeadJavalin
39 | .get("/", ctx -> {
40 |
41 | var examples = List.of(
42 | new ExCard("Counter", "A simple counter that can be incremented and decremented.", "/count"),
43 | new ExCard("Sales Dashboard", "A sales dashboard that shows sales data in a chart.", "/dashboard"),
44 | new ExCard("User Form", "A user form that shows how easy it is to use forms.", "/user/new"),
45 | new ExCard("Volume Control", "A Kotlin-based volume control that shows how to use a keyboard input.", "/volume")
46 | );
47 | var html = HTML."""
48 |
49 |
50 |
51 |
52 |
53 |
54 | Undead Examples
55 |
56 |
57 |
58 |
59 |
60 |
63 |
64 | \{ For(examples, ex -> ex.render()) }
65 |
66 |
67 |
68 | """;
69 | ctx.result(html.toString());
70 | ctx.contentType("text/html");
71 | })
72 | .start(1313);
73 | }
74 | }
75 |
76 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/UndeadJavalin.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example;
2 |
3 | import run.undead.config.Config;
4 | import run.undead.javalin.JavalinRouteMatcher;
5 | import run.undead.javalin.JavalinWsAdaptor;
6 | import run.undead.javalin.UndeadHandler;
7 | import run.undead.view.View;
8 | import io.javalin.Javalin;
9 | import io.javalin.config.RoutingConfig;
10 | import io.javalin.http.HandlerType;
11 |
12 | /**
13 | * UndeadJavalin is a wrapper around Javalin that provides a simple API for registering
14 | * {@link View} to server routes. This automatically registers the
15 | * {@link UndeadHandler} with the Javalin server and configures
16 | * WebSocket support for Undead {@link View}s as well.
17 | */
18 | public class UndeadJavalin {
19 | private final Config config;
20 | private final Javalin app;
21 | private RoutingConfig routingConfig;
22 |
23 | public UndeadJavalin(Javalin app, Config config) {
24 | this.app = app;
25 | this.config = config;
26 | if(this.config.routeMatcher == null) {
27 | // register route matcher with the config
28 | this.config.routeMatcher = new JavalinRouteMatcher(this.app.cfg.routing);
29 | }
30 | // register websocket handler with javalin
31 | app.ws("/live/websocket", ws -> new JavalinWsAdaptor(config, ws));
32 | }
33 |
34 | /**
35 | * Registers an Undead {@link View} with the Javalin server for the
36 | * given path. All {@link UndeadHandler}s are registered as GET handlers.
37 | * @see View
38 | * @param path the path that routes to the {@link View}
39 | * @param view the {@link View} to register
40 | * @return the UndeadJavalin instance for chaining
41 | */
42 | public UndeadJavalin undead(String path, View view) {
43 | this.config.routeMatcher.addRoute(path, view);
44 | app.addHandler(HandlerType.GET, path, new UndeadHandler(this.config, view));
45 | return this;
46 | }
47 |
48 | /**
49 | * Get the underlying Javalin instance.
50 | * @return the underlying Javalin instance
51 | */
52 | public Javalin javalin() {
53 | return this.app;
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/UndeadCounter.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view;
2 |
3 | import run.undead.event.UndeadEvent;
4 | import run.undead.js.HideOpts;
5 | import run.undead.js.JS;
6 | import run.undead.js.ShowOpts;
7 | import run.undead.context.Context;
8 | import run.undead.template.UndeadTemplate;
9 | import run.undead.view.Meta;
10 | import run.undead.view.View;
11 |
12 | import java.util.Map;
13 |
14 | import static run.undead.template.Undead.*;
15 | import static run.undead.template.Directive.*;
16 |
17 | public class UndeadCounter implements View {
18 | private Integer count;
19 |
20 | public UndeadCounter() {
21 | this.count = 0;
22 | }
23 |
24 | @Override
25 | public void mount(Context context, Map sessionData, Map params) {
26 | if (params.get("start") != null) {
27 | this.count = Integer.parseInt((String) params.get("start"));
28 | } else {
29 | this.count = 0;
30 | }
31 | }
32 |
33 | @Override
34 | public void handleEvent(Context context, UndeadEvent event) {
35 | if (event.type().equals("inc")) {
36 | this.count++;
37 | } else if (event.type().equals("dec") && this.count > 0) {
38 | this.count--;
39 | }
40 | }
41 |
42 | @Override
43 | public UndeadTemplate render(Meta meta) {
44 | return HTML. """
45 |
46 |
Zombie Count
47 |
48 | -
49 | 9, HTML."text-error"))}">
50 |
51 |
52 | +
53 |
54 |
55 | Show Status
56 | Hide Status
57 |
58 |
59 |
🧟 status
60 | \{Switch(count,
61 | Case.of(c -> c == 1, c -> {return HTML. """
62 |
Zombies are coming
63 | """;}),
64 | Case.of(c -> 1 < c && c <= 4, c -> {return HTML. """
65 |
Zombies are here
66 | """;}),
67 | Case.of(c -> 4 < c && c <= 9, c -> {return HTML. """
68 |
Uh oh, they are breaking in!
69 | """;}),
70 | Case.of(c -> c > 9, c -> {return HTML. """
71 |
Zombies are eating your 🧠
72 | """;}),
73 | Case.defaultOf(c -> {return HTML. """
74 |
\{c} zombies. That's good.
75 | """;})
76 | ) }
77 |
78 |
79 | """ ;
80 | }
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/UndeadSalesDashboard.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view;
2 |
3 | import run.undead.context.Context;
4 | import run.undead.event.SimpleUndeadInfo;
5 | import run.undead.event.UndeadEvent;
6 | import run.undead.event.UndeadInfo;
7 | import run.undead.template.Undead;
8 | import run.undead.template.UndeadTemplate;
9 | import run.undead.view.Meta;
10 | import run.undead.view.View;
11 |
12 | import java.math.BigDecimal;
13 | import java.math.RoundingMode;
14 | import java.util.Map;
15 | import java.util.Timer;
16 | import java.util.TimerTask;
17 |
18 | /**
19 | * UndeadSalesDashboard is a simple example of a liveview that displays some stats that
20 | * refresh every second.
21 | */
22 | public class UndeadSalesDashboard implements View {
23 | private int newOrders;
24 | private BigDecimal salesAmount;
25 | private int rating;
26 | private Timer timer;
27 |
28 | public UndeadSalesDashboard() {
29 | this.doRefresh();
30 | }
31 |
32 | @Override
33 | public void mount(Context context, Map sessionData, Map params) {
34 | // start timer task if we're connected (i.e. not http request)
35 | if (context.connected()) {
36 | this.timer = new Timer();
37 | this.timer.schedule(new TimerTask() {
38 | public void run() {
39 | context.sendInfo(new SimpleUndeadInfo("refresh", null));
40 | }
41 | }, 0, 1000);
42 | }
43 | }
44 |
45 | @Override
46 | public void handleInfo(Context context, UndeadInfo info) {
47 | if (info.type().equals("refresh")) {
48 | this.doRefresh();
49 | }
50 | }
51 |
52 | @Override
53 | public void handleEvent(Context context, UndeadEvent event) {
54 | if (event.type().equals("refresh")) {
55 | this.doRefresh();
56 | }
57 | }
58 |
59 | private void doRefresh() {
60 | this.newOrders = randomNewOrders();
61 | this.salesAmount = randomPrice();
62 | this.rating = randomRating();
63 | }
64 |
65 | @Override
66 | public UndeadTemplate render(Meta meta) {
67 | return Undead.HTML. """
68 |
69 |
70 |
71 |
72 |
🥡 New Orders
73 |
\{ newOrders }
74 |
75 |
76 |
77 |
💰 Sales Amount
78 |
$\{ (salesAmount) }
79 |
80 |
81 |
82 |
🌟 Rating
83 |
\{ ratingToStars(rating) }
84 |
85 |
86 |
87 |
88 | ↻ Refresh
89 |
90 |
91 | """ ;
92 | }
93 |
94 | private String ratingToStars(int rating) {
95 | String stars = "";
96 | var i = 0;
97 | for (; i < rating; i++) {
98 | stars += "⭐";
99 | }
100 | for (; i < 5; i++) {
101 | stars += "☆";
102 | }
103 | return stars;
104 | }
105 |
106 | private int randomNewOrders() {
107 | return (int) (Math.random() * 100);
108 | }
109 |
110 | private BigDecimal randomPrice() {
111 | return BigDecimal.valueOf(Math.random() * 100).setScale(2, RoundingMode.HALF_UP);
112 | }
113 |
114 | private int randomRating() {
115 | return (int) (Math.random() * 5);
116 | }
117 |
118 | @Override
119 | public void shutdown() {
120 | this.timer.cancel();
121 | this.timer = null;
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/UndeadUserForm.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view;
2 |
3 | import run.undead.event.UndeadEvent;
4 | import run.undead.form.Form;
5 | import run.undead.javalin.example.view.model.UserModel;
6 | import run.undead.context.Context;
7 | import run.undead.template.UndeadTemplate;
8 | import run.undead.view.Meta;
9 | import run.undead.view.View;
10 |
11 | import static run.undead.javalin.example.view.tags.Input.ErrorMsg;
12 | import static run.undead.javalin.example.view.tags.Input.TextInput;
13 | import static run.undead.template.Directive.*;
14 | import static run.undead.template.Undead.*;
15 |
16 | /**
17 | * UndeadUserForm is a simple form that uses Undead's {@link Form} to validate and save a
18 | * {@link UserModel} and display the user once saved.
19 | */
20 | public class UndeadUserForm implements View {
21 |
22 | private Form form;
23 | private UserModel user;
24 |
25 | public UndeadUserForm() {
26 | // initialize the form with an empty UserModel
27 | this.form = new Form<>(UserModel.class);
28 | }
29 |
30 | @Override
31 | public void handleEvent(Context context, UndeadEvent event) {
32 | if (event.type().equals("validate")) {
33 | // for "validate" (i.e. form changes) we update the form with the data
34 | // from the event which will trigger validations and update the form state
35 | // and trigger a re-render
36 | this.form.update(event.data(), event.type());
37 | return;
38 | }
39 | if (event.type().equals("submit")) {
40 | // for "submit" we update the form with the data from the event as well
41 | // and then check if the form is valid
42 | // if form is valid, "save" user and "reset" the form
43 | this.form.update(event.data(), event.type());
44 | if (this.form.valid()) {
45 | this.user = this.form.model();
46 | this.form = new Form<>(UserModel.class);
47 | }
48 | }
49 | }
50 |
51 | @Override
52 | public UndeadTemplate render(Meta meta) {
53 | return HTML. """
54 |
66 | \{ maybeShowUser(user) }""" ;
67 | }
68 |
69 | private UndeadTemplate maybeShowUser(UserModel user) {
70 | if (user == null) {
71 | return EMPTY;
72 | }
73 | return HTML. """
74 |
75 |
User
76 |
Name: \{ user.name() }
77 |
Email: \{ user.email() }
78 |
79 | """ ;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/UndeadVolumeControl.kt:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view
2 |
3 | import run.undead.context.Context
4 | import run.undead.event.UndeadEvent
5 | import run.undead.javalin.example.view.tmpl.Tmpl
6 | import run.undead.template.UndeadTemplate
7 | import run.undead.view.Meta
8 | import run.undead.view.View
9 | import kotlin.math.max
10 | import kotlin.math.min
11 |
12 | /**
13 | * UndeadVolumeControl is an Undead View that renders a volume control that updates
14 | * the volume when the user presses the arrow keys.
15 | */
16 | class UndeadVolumeControl : View {
17 | var volume: Int = 30
18 |
19 | override fun handleEvent(context: Context?, event: UndeadEvent?) {
20 | when (event?.type()) {
21 | "key_update" -> {
22 | val key = event.data()["key"]
23 | when (key) {
24 | "ArrowUp" -> volume = 100
25 | "ArrowDown" -> volume = 0
26 | "ArrowLeft" -> volume = max(0, volume - 10)
27 | "ArrowRight" -> volume = min(100, volume + 10)
28 | }
29 | }
30 | }
31 | }
32 |
33 | // Can't use Java StringTemplates in Kotlin so load this from a Java class
34 | override fun render(meta: Meta?): UndeadTemplate {
35 | return Tmpl.volumeTemplate(volume)
36 | }
37 | }
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/model/UserModel.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view.model;
2 |
3 | import jakarta.validation.constraints.Email;
4 | import jakarta.validation.constraints.NotBlank;
5 | import run.undead.form.Form;
6 |
7 | /**
8 | * UserModel is a simple model for a user with a name and email with Jakarta Bean Validation annotations
9 | * for use with Undead's {@link Form}.
10 | * @param name the name of the user
11 | * @param email the email of the user
12 | */
13 | public record UserModel(@NotBlank String name, @NotBlank @Email String email) { }
14 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/tags/ExCard.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view.tags;
2 |
3 | import run.undead.template.Undead;
4 | import run.undead.template.UndeadTemplate;
5 |
6 | public class ExCard {
7 | public final String title;
8 | public final String body;
9 | public final String href;
10 |
11 | public ExCard(String title, String body, String href) {
12 | this.title = title;
13 | this.body = body;
14 | this.href = href;
15 | }
16 |
17 | public UndeadTemplate render() {
18 | return Undead.HTML."""
19 |
20 |
21 |
\{title}
22 |
\{body}
23 |
26 |
27 |
28 | """;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/tags/Input.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view.tags;
2 |
3 | import com.google.common.base.Strings;
4 | import run.undead.form.Form;
5 | import run.undead.template.Undead;
6 | import run.undead.template.UndeadTemplate;
7 |
8 | /**
9 | * Input contains some helper tags for rendering form text inputs and their error messages.
10 | */
11 | public class Input {
12 |
13 | /**
14 | * TextInput renders a text input with the given name and value with the optional css classes.
15 | * @param form the form to pull the value from
16 | * @param name the name of the input field and the key to pull the value from the form
17 | * @param classes the optional css classes to apply to the input
18 | * @return the {@link UndeadTemplate} for the text input
19 | */
20 | public static UndeadTemplate TextInput(Form form, String name, String... classes) {
21 | var value = form.data().get(name);
22 | var classesString = "";
23 | for (var clazz : classes) {
24 | classesString += clazz + " ";
25 | }
26 | return Undead.HTML. """
27 |
28 | """ ;
29 | }
30 |
31 | /**
32 | * ErrorMsg renders the error message for the given form and input name
33 | * @param form the form to pull the error message from
34 | * @param name the name of the input field and the key to pull the error message from the form
35 | * @return the {@link UndeadTemplate} for the error message
36 | */
37 | public static UndeadTemplate ErrorMsg(Form form, String name) {
38 | var error = form.errorFor(name);
39 | if (Strings.isNullOrEmpty(error)) {
40 | return Undead.EMPTY;
41 | }
42 | return Undead.HTML. """
43 | \{ error }
44 | """ ;
45 | }
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/javalin/example/view/tmpl/Tmpl.java:
--------------------------------------------------------------------------------
1 | package run.undead.javalin.example.view.tmpl;
2 |
3 | import run.undead.template.Undead;
4 | import run.undead.template.UndeadTemplate;
5 |
6 | public class Tmpl {
7 | public static final UndeadTemplate volumeTemplate(Integer volume) {
8 | return Undead.HTML."""
9 |
10 |
🎧 Volume Control
11 |
12 |
13 |
14 |
Use the keyboard arrow keys to control the volume.
15 |
16 |
17 |
20 |
21 |
22 |
23 |
Keyboard Controls
24 |
25 | ↑
26 | Max volume
27 |
28 |
29 | ↓
30 | Silence
31 |
32 |
33 | →
34 | Step up
35 |
36 |
37 | ←
38 | Step down
39 |
40 |
41 |
42 | """;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/AddClassOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.time.Duration;
6 | import java.util.ArrayList;
7 | import java.util.LinkedHashMap;
8 | import java.util.List;
9 |
10 | /**
11 | * AddClassOpts are the options for the add_class JS command.
12 | * @see JS#addClass(AddClassOpts)
13 | * @see JS#addClass(String)
14 | */
15 | public class AddClassOpts implements Cmd {
16 | protected String classNames;
17 |
18 | protected String to;
19 | protected Long time;
20 | protected Transition transition;
21 |
22 | /**
23 | * AddClassOpts accepts css class names and adds them to element being targeted
24 | * @param classNames the css class names (space separated)
25 | */
26 | public AddClassOpts(String classNames) {
27 | this(classNames,null, Duration.ofMillis(200), new Transition());
28 | }
29 |
30 | /**
31 | * AddClassOpts accepts css class names and a DOM selector to apply them to
32 | * @param classNames the css class names (space separated)
33 | * @param to the DOM selector for the targeted element
34 | */
35 | public AddClassOpts(String classNames, String to) {
36 | this(classNames, to, Duration.ofMillis(200), new Transition());
37 | }
38 |
39 | /**
40 | * AddClassOpts accepts css class names, a DOM selector to apply them to, and
41 | * a duration for the transition.
42 | * @param classNames the css class names (space separated)
43 | * @param to the DOM selector for the targeted element
44 | * @param time the duration of the transition
45 | */
46 | public AddClassOpts(String classNames, String to, Duration time) {
47 | this(classNames, to, time, new Transition());
48 | }
49 |
50 | /**
51 | * AddClassOpts accepts css class names, a DOM selector to apply them to, a
52 | * duration for the transition, and a {@link Transition} containing the
53 | * css transition classes to apply.
54 | * @see Transition
55 | * @param classNames the css class names (space separated)
56 | * @param to the DOM selector for the targeted element
57 | * @param time the duration of the transition
58 | * @param transition the css transition classes to apply
59 | */
60 | public AddClassOpts(String classNames, String to, Duration time, Transition transition) {
61 | this.classNames = classNames;
62 | this.to = to;
63 | this.time = time.toMillis();
64 | this.transition = transition;
65 | }
66 |
67 | @Override
68 | public String toJSON() {
69 | return JS.moshi.adapter(AddClassOpts.class).serializeNulls().toJson(this);
70 | }
71 |
72 | }
73 |
74 | /**
75 | * AddClassCmdAdaptor is responsible for serializing AddClassOpts to JSON.
76 | */
77 | class AddClassCmdAdapter {
78 | @ToJson
79 | public List toJSON(AddClassOpts cmd) {
80 | var map = new LinkedHashMap<>(); // preserve order
81 | map.put("names", cmd.classNames.split("\s+"));
82 | map.put("to", cmd.to);
83 | map.put("time", cmd.time);
84 | map.put("transition", cmd.transition);
85 | var list = new ArrayList<>();
86 | list.add("add_class");
87 | list.add(map);
88 | return list;
89 | }
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/Cmd.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | /**
4 | * Marker interface for JS commands that can be sent to the client.
5 | */
6 | public interface Cmd {
7 |
8 | String toJSON();
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/DispatchOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 | import java.util.Map;
9 |
10 | /**
11 | * DispatchOpts are the options for the dispatch JS command.
12 | * @see JS#dispatch(DispatchOpts)
13 | * @see JS#dispatch(String)
14 | */
15 | public class DispatchOpts implements Cmd {
16 | protected final String to;
17 | protected final String event;
18 | protected final Map detail;
19 | protected final Boolean bubbles;
20 |
21 | /**
22 | * DispatchOpts accepts the name of the event to dispatch to the DOM
23 | * @param event the name of the event to dispatch
24 | */
25 | public DispatchOpts(String event) {
26 | this(event, null, null, null);
27 | }
28 |
29 | /**
30 | * DispatchOpts accepts the name of the event to dispatch to the DOM
31 | * and the DOM selector to dispatch it to
32 | * @param event the name of the event to dispatch
33 | * @param to the DOM selector to dispatch the event to
34 | */
35 | public DispatchOpts(String event, String to) {
36 | this(event, to, null, null);
37 | }
38 |
39 | /**
40 | * DispatchOpts accepts the name of the event to dispatch to the DOM, the DOM selector
41 | * to dispatch it to, and a map that sets the detail of the event. (Client
42 | * side events have an optional payload named `detail` that can be set which is
43 | * what is set by the provided Map)
44 | * @param event the name of the event to dispatch
45 | * @param to the DOM selector to dispatch the event to
46 | * @param detail the detail of the event
47 | */
48 | public DispatchOpts(String event, String to, Map detail) {
49 | this(event, to, detail, null);
50 | }
51 |
52 | /**
53 | * DispatchOpts accepts the name of the event to dispatch to the DOM, the DOM selector
54 | * the DOM selector to dispatch it to, and a map that sets the detail of the event,
55 | * and a flag to control whether the event bubbles up the DOM tree or not.
56 | * @param event the name of the event to dispatch
57 | * @param to the DOM selector to dispatch the event to
58 | * @param detail the detail of the event
59 | * @param bubbles whether the event bubbles up the DOM tree or not
60 | */
61 | public DispatchOpts(String event, String to, Map detail, Boolean bubbles) {
62 | this.to = to;
63 | this.event = event;
64 | this.detail = detail;
65 | this.bubbles = bubbles == null || bubbles;
66 | }
67 |
68 | @Override
69 | public String toJSON() {
70 | return JS.moshi.adapter(DispatchOpts.class).serializeNulls().toJson(this);
71 | }
72 | }
73 |
74 | /**
75 | * DispatchCmdAdapter is the Moshi adapter for DispatchOpts
76 | */
77 | class DispatchCmdAdapter {
78 | @ToJson
79 | public List toJSON(DispatchOpts cmd) {
80 | var map = new LinkedHashMap<>(); // preserve order
81 | map.put("to", cmd.to);
82 | map.put("event", cmd.event);
83 | // only add detail if it's not null
84 | if( cmd.detail != null ) {
85 | map.put("detail", cmd.detail);
86 | }
87 | // only add bubbles if it's false
88 | if( !cmd.bubbles ) {
89 | map.put("bubbles", false);
90 | }
91 | var list = new ArrayList<>();
92 | list.add("dispatch");
93 | list.add(map);
94 | return list;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/ExecOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * ExecOpts are the options for the exec JS command.
11 | */
12 | public class ExecOpts implements Cmd {
13 |
14 | protected String to;
15 | protected String attr;
16 |
17 | /**
18 | * ExecOpts takes the target attribute name that contains the JS Commands to execute.
19 | * @param attr the target attribute name that contains the JS Commands to execute
20 | */
21 | public ExecOpts(String attr) {
22 | this(attr, null);
23 | }
24 |
25 | /**
26 | * ExecOpts takes the target attribute name that contains the JS Commands to execute
27 | * and a DOM selector that contains the attribute.
28 | * @param attr the target attribute name that contains the JS Commands to execute
29 | * @param to the DOM selector that contains the attribute
30 | */
31 | public ExecOpts(String attr, String to) {
32 | this.to = to;
33 | this.attr = attr;
34 | }
35 |
36 | @Override
37 | public String toJSON() {
38 | return JS.moshi.adapter(ExecOpts.class).serializeNulls().toJson(this);
39 | }
40 |
41 | }
42 |
43 | /**
44 | * ExecCmdAdaptor is a Moshi adaptor for the ExecOpts class.
45 | */
46 | class ExecCmdAdaptor {
47 | @ToJson
48 | public List toJSON(ExecOpts cmd) {
49 | var map = new LinkedHashMap<>(); // preserve order
50 | map.put("to", cmd.to);
51 | map.put("attr", cmd.attr);
52 | var list = new ArrayList<>();
53 | list.add("exec");
54 | list.add(map);
55 | return list;
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/FocusFirstOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * FocusFirstOpts are the options for the focus_first command.
11 | * @see JS#focusFirst(FocusFirstOpts)
12 | * @see JS#focusFirst()
13 | */
14 | public class FocusFirstOpts implements Cmd {
15 |
16 | protected String to;
17 |
18 | /**
19 | * Applies focus_first to current element
20 | */
21 | public FocusFirstOpts() {
22 | this(null);
23 | }
24 |
25 | /**
26 | * Applies focus_first to the element specified by the DOM selector
27 | * @param to the DOM selector for the targeted element
28 | */
29 | public FocusFirstOpts(String to) {
30 | this.to = to;
31 | }
32 |
33 | @Override
34 | public String toJSON() {
35 | return JS.moshi.adapter(FocusFirstOpts.class).serializeNulls().toJson(this);
36 | }
37 |
38 | }
39 |
40 | /**
41 | * FocusFirstCmdAdaptor is a Moshi adaptor for the FocusFirstOpts class.
42 | */
43 | class FocusFirstCmdAdaptor {
44 | @ToJson
45 | public List toJSON(FocusFirstOpts cmd) {
46 | var map = new LinkedHashMap<>(); // preserve order
47 | map.put("to", cmd.to);
48 | var list = new ArrayList<>();
49 | list.add("focus_first");
50 | list.add(map);
51 | return list;
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/FocusOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * FocusOpts are the options for the focus command.
11 | * @see JS#focus(String)
12 | * @see JS#focus()
13 | */
14 | public class FocusOpts implements Cmd {
15 |
16 | protected String to;
17 |
18 | /**
19 | * focus on current element
20 | */
21 | public FocusOpts() {
22 | this(null);
23 | }
24 |
25 | /**
26 | * focus on the element specified by the DOM selector
27 | * @param to the DOM selector for the targeted element
28 | */
29 | public FocusOpts(String to) {
30 | this.to = to;
31 | }
32 |
33 | @Override
34 | public String toJSON() {
35 | return JS.moshi.adapter(FocusOpts.class).serializeNulls().toJson(this);
36 | }
37 |
38 | }
39 |
40 | /**
41 | * FocusCmdAdaptor is a Moshi adaptor for the FocusOpts class.
42 | */
43 | class FocusCmdAdaptor {
44 | @ToJson
45 | public List toJSON(FocusOpts cmd) {
46 | var map = new LinkedHashMap<>(); // preserve order
47 | map.put("to", cmd.to);
48 | var list = new ArrayList<>();
49 | list.add("focus");
50 | list.add(map);
51 | return list;
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/HideOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.time.Duration;
6 | import java.util.ArrayList;
7 | import java.util.LinkedHashMap;
8 | import java.util.List;
9 |
10 | /**
11 | * HideOpts are the options for the hide JS command. This command
12 | * hides the targeted element.
13 | */
14 | public class HideOpts implements Cmd {
15 | protected String to;
16 | protected Long time;
17 | protected Transition transition;
18 |
19 | /**
20 | * hides the current element
21 | */
22 | public HideOpts() {
23 | this(null, Duration.ofMillis(200), new Transition());
24 | }
25 |
26 | /**
27 | * hides the element specified by the DOM selector
28 | * @param to the DOM selector for the targeted element
29 | */
30 | public HideOpts(String to) {
31 | this(to, Duration.ofMillis(200), new Transition());
32 | }
33 |
34 | /**
35 | * hides the element specified by the DOM selector and
36 | * a duration for the transition.
37 | * @param to the DOM selector for the targeted element
38 | * @param time the duration of the transition
39 | */
40 | public HideOpts(String to, Duration time) {
41 | this(to, time, new Transition());
42 | }
43 |
44 | /**
45 | * hides the element specified by the DOM selector, a
46 | * duration for the transition, and a {@link Transition}
47 | * containing the css transition classes to apply.
48 | * @see Transition
49 | * @param to the DOM selector for the targeted element
50 | * @param time the duration of the transition
51 | * @param transition the css transition classes to apply
52 | */
53 | public HideOpts(String to, Duration time, Transition transition) {
54 | this.to = to;
55 | this.time = time.toMillis();
56 | this.transition = transition;
57 | }
58 |
59 | @Override
60 | public String toJSON() {
61 | return JS.moshi.adapter(HideOpts.class).serializeNulls().toJson(this);
62 | }
63 |
64 | }
65 |
66 | /**
67 | * HideCmdAdapter is a Moshi adaptor for the HideOpts class.
68 | */
69 | class HideCmdAdapter {
70 | @ToJson
71 | public List toJSON(HideOpts hideOpts) {
72 | var map = new LinkedHashMap<>(); // preserve order
73 | map.put("to", hideOpts.to);
74 | map.put("time", hideOpts.time);
75 | map.put("transition", hideOpts.transition);
76 | var list = new ArrayList<>();
77 | list.add("hide");
78 | list.add(map);
79 | return list;
80 | }
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/JS.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.JsonAdapter;
4 | import com.squareup.moshi.Moshi;
5 | import run.undead.template.UndeadTemplate;
6 | import run.undead.view.View;
7 |
8 | import java.util.ArrayList;
9 | import java.util.List;
10 |
11 | /**
12 | * JS is a helper class for building JS commands that are rendered in {@link UndeadTemplate}s.
13 | * JS provides a number of methods that make it easier to manipulate the DOM, dispatch client-side events,
14 | * and push events (and data) to the server.
15 | *
16 | * Here is an example of using JS to toggle the visibility of an element:
17 | * {@code
18 | * // in your {@link View#render} method ...
19 | * public UndeadTemplate render() {
20 | * return Undead.HTML."""
21 | * Hello World!
22 | * Toggle
23 | * """ ;
24 | * }
25 | * }
26 | *
27 | * JS commands are "chainable" so you can build up a list of commands to execute in order.
28 | * For example, the following hides an element and pushes an event to the server:
29 | * {@code
30 | * // in your {@link View#render} method ...
31 | * return Undead.HTML."""
32 | * Hide
33 | * """ ;
34 | * }
35 | *
36 | * List of all the JS commands:
37 | *
38 | * {@link JS#addClass}
39 | * {@link JS#dispatch}
40 | * {@link JS#exec}
41 | * {@link JS#focus}
42 | * {@link JS#focusFirst}
43 | * {@link JS#hide}
44 | * {@link JS#navigate}
45 | * {@link JS#patch}
46 | * {@link JS#popFocus}
47 | * {@link JS#push}
48 | * {@link JS#pushFocus}
49 | * {@link JS#removeAttr}
50 | * {@link JS#removeClass}
51 | * {@link JS#setAttr}
52 | * {@link JS#show}
53 | * {@link JS#toggle}
54 | * {@link JS#transition}
55 | *
56 | *
57 | */
58 | public class JS {
59 |
60 | protected final static String DEFAULT_DISPLAY = "block";
61 |
62 | protected final static Moshi moshi = new Moshi.Builder()
63 | .add(new TransitionAdapter())
64 | .add(new ShowCmdAdapter())
65 | .add(new HideCmdAdapter())
66 | .add(new AddClassCmdAdapter())
67 | .add(new RemoveClassCmdAdapter())
68 | .add(new ToggleCmdAdapter())
69 | .add(new SetAttrCmdAdapter())
70 | .add(new RemoveAttrCmdAdapter())
71 | .add(new TransitionCmdAdapter())
72 | .add(new PushCmdAdapter())
73 | .add(new DispatchCmdAdapter())
74 | .add(new ExecCmdAdaptor())
75 | .add(new FocusCmdAdaptor())
76 | .add(new FocusFirstCmdAdaptor())
77 | .add(new NavigateCmdAdaptor())
78 | .add(new PatchCmdAdaptor())
79 | .add(new PopFocusCmdAdaptor())
80 | .add(new PushFocusCmdAdaptor())
81 | .build();
82 | protected final static JsonAdapter listAdaptor = moshi.adapter(List.class).serializeNulls();
83 |
84 | private final List cmds;
85 |
86 | public JS () {
87 | this.cmds = new ArrayList<>();
88 | }
89 |
90 |
91 | /**
92 | * `toJSON` returns the JSON String representation of the JS commands.
93 | * @return
94 | */
95 | public String toJSON() {
96 | return listAdaptor.toJson(cmds);
97 | }
98 |
99 |
100 | /**
101 | * addClass adds css classes to DOM elements
102 | * @param addClassOpts options for the add_class command
103 | * @see AddClassOpts
104 | * @return the JS instance for chaining
105 | */
106 | public JS addClass(AddClassOpts addClassOpts) {
107 | this.cmds.add(addClassOpts);
108 | return this;
109 | }
110 |
111 | /**
112 | * addClass adds css classes to DOM elements
113 | * @param classNames the css class names to add (space separated)
114 | * @return the JS instance for chaining
115 | */
116 | public JS addClass(String classNames) {
117 | this.cmds.add(new AddClassOpts(classNames));
118 | return this;
119 | }
120 |
121 | /**
122 | * dispatch dispatches an event to the DOM
123 | * @param dispatchOpts options for the dispatch command
124 | * @see DispatchOpts
125 | * @return the JS instance for chaining
126 | */
127 | public JS dispatch(DispatchOpts dispatchOpts) {
128 | this.cmds.add(dispatchOpts);
129 | return this;
130 | }
131 |
132 | /**
133 | * dispatch dispatches an event to the DOM
134 | * @param event the name of the event to dispatch
135 | * @return the JS instance for chaining
136 | */
137 | public JS dispatch(String event) {
138 | this.cmds.add(new DispatchOpts(event));
139 | return this;
140 | }
141 |
142 | /**
143 | * exec executes JS commands located in element attributes
144 | * @param execOpts options for the exec command
145 | * @see ExecOpts
146 | * @return the JS instance for chaining
147 | */
148 | public JS exec(ExecOpts execOpts) {
149 | this.cmds.add(execOpts);
150 | return this;
151 | }
152 |
153 | /**
154 | * exec executes JS commands located in element attributes
155 | * @param attr the name of the attribute that contains the JS commands
156 | * @return the JS instance for chaining
157 | */
158 | public JS exec(String attr) {
159 | this.cmds.add(new ExecOpts(attr));
160 | return this;
161 | }
162 |
163 | /**
164 | * focusFirst sends focus to the first focusable child in selector
165 | * @param focusFirstOpts options for the focus_first command
166 | * @see FocusFirstOpts
167 | * @return the JS instance for chaining
168 | */
169 | public JS focusFirst(FocusFirstOpts focusFirstOpts) {
170 | this.cmds.add(focusFirstOpts);
171 | return this;
172 | }
173 |
174 | /**
175 | * focusFirst sends focus to the first focusable child in selector
176 | * @return the JS instance for chaining
177 | */
178 | public JS focusFirst() {
179 | this.cmds.add(new FocusFirstOpts());
180 | return this;
181 | }
182 |
183 | /**
184 | * focus sends focus to a selector
185 | * @return the JS instance for chaining
186 | */
187 | public JS focus() {
188 | this.cmds.add(new FocusOpts());
189 | return this;
190 | }
191 |
192 | /**
193 | * focus sends focus to a selector
194 | * @param to the DOM selector to send focus to
195 | * @return the JS instance for chaining
196 | */
197 | public JS focus(String to) {
198 | this.cmds.add(new FocusOpts(to));
199 | return this;
200 | }
201 |
202 | /**
203 | * hide makes DOM elements invisible
204 | * @param hideOpts options for the hide command
205 | * @see HideOpts
206 | * @return the JS instance for chaining
207 | */
208 | public JS hide(HideOpts hideOpts) {
209 | this.cmds.add(hideOpts);
210 | return this;
211 | }
212 |
213 | /**
214 | * hide makes DOM elements invisible
215 | * @return the JS instance for chaining
216 | */
217 | public JS hide() {
218 | this.cmds.add(new HideOpts());
219 | return this;
220 | }
221 |
222 | /**
223 | * navigate sends a navigation event to the server and updates the
224 | * browser's pushState history.
225 | * @param navigateOpts options for the navigate command
226 | * @see NavigateOpts
227 | * @return the JS instance for chaining
228 | */
229 | public JS navigate(NavigateOpts navigateOpts) {
230 | this.cmds.add(navigateOpts);
231 | return this;
232 | }
233 |
234 | /**
235 | * navigate sends a navigation event to the server and updates the
236 | * browser's pushState history.
237 | * @param href the href to navigate to
238 | * @return the JS instance for chaining
239 | */
240 | public JS navigate(String href) {
241 | this.cmds.add(new NavigateOpts(href));
242 | return this;
243 | }
244 |
245 | /**
246 | * patch sends a patch event to the server
247 | * @param patchOpts options for the patch command
248 | * @see PatchOpts
249 | * @return the JS instance for chaining
250 | */
251 | public JS patch(PatchOpts patchOpts) {
252 | this.cmds.add(patchOpts);
253 | return this;
254 | }
255 |
256 | /**
257 | * patch sends a patch event to the server
258 | * @param href the href to patch
259 | * @return the JS instance for chaining
260 | */
261 | public JS patch(String href) {
262 | this.cmds.add(new PatchOpts(href));
263 | return this;
264 | }
265 |
266 | /**
267 | * popFocus focuses the last pushed element
268 | * @return the JS instance for chaining
269 | */
270 | public JS popFocus() {
271 | this.cmds.add(new PopFocusOpts());
272 | return this;
273 | }
274 |
275 |
276 | /**
277 | * pushFocus pushes focus from the source element to be later popped
278 | * @param pushFocusOpts options for the push_focus command
279 | * @see PushFocusOpts
280 | * @return the JS instance for chaining
281 | */
282 | public JS pushFocus(PushFocusOpts pushFocusOpts) {
283 | this.cmds.add(pushFocusOpts);
284 | return this;
285 | }
286 |
287 | /**
288 | * pushFocus pushes focus from the source element to be later popped
289 | * @param to the DOM selector to push focus to
290 | * @return the JS instance for chaining
291 | */
292 | public JS pushFocus(String to) {
293 | this.cmds.add(new PushFocusOpts(to));
294 | return this;
295 | }
296 |
297 | /**
298 | * push sends an event to the server
299 | * @param pushOpts options for the push command
300 | * @see PushOpts
301 | * @return the JS instance for chaining
302 | */
303 | public JS push(PushOpts pushOpts) {
304 | this.cmds.add(pushOpts);
305 | return this;
306 | }
307 |
308 | /**
309 | * push sends an event to the server
310 | * @param event the name of the event to push
311 | * @return the JS instance for chaining
312 | */
313 | public JS push(String event) {
314 | this.cmds.add(new PushOpts(event));
315 | return this;
316 | }
317 |
318 | /**
319 | * removeAttr removes an attribute from a DOM element
320 | * @param removeAttrOpts options for the remove_attr command
321 | * @see RemoveAttrOpts
322 | * @return the JS instance for chaining
323 | */
324 | public JS removeAttr(RemoveAttrOpts removeAttrOpts) {
325 | this.cmds.add(removeAttrOpts);
326 | return this;
327 | }
328 |
329 | /**
330 | * removeAttr removes an attribute from a DOM element
331 | * @param name the name of the attribute to remove
332 | * @return the JS instance for chaining
333 | */
334 | public JS removeAttr(String name) {
335 | this.cmds.add(new RemoveAttrOpts(name));
336 | return this;
337 | }
338 |
339 | /**
340 | * removeClass removes css classes from DOM elements
341 | * @param removeClassOpts options for the remove_class command
342 | * @see RemoveClassOpts
343 | * @return the JS instance for chaining
344 | */
345 | public JS removeClass(RemoveClassOpts removeClassOpts) {
346 | this.cmds.add(removeClassOpts);
347 | return this;
348 | }
349 |
350 | /**
351 | * removeClass removes css classes from DOM elements
352 | * @param classNames the css class names to remove (space separated)
353 | * @return the JS instance for chaining
354 | */
355 | public JS removeClass(String classNames) {
356 | this.cmds.add(new RemoveClassOpts(classNames));
357 | return this;
358 | }
359 |
360 | /**
361 | * setAttr sets an attribute on a DOM element
362 | * @param setAttrOpts options for the set_attr command
363 | * @see SetAttrOpts
364 | * @return the JS instance for chaining
365 | */
366 | public JS setAttr(SetAttrOpts setAttrOpts) {
367 | this.cmds.add(setAttrOpts);
368 | return this;
369 | }
370 |
371 | /**
372 | * setAttr sets an attribute on a DOM element
373 | * @param name the name of the attribute to set
374 | * @param value the value of the attribute to set
375 | * @return the JS instance for chaining
376 | */
377 | public JS setAttr(String name, String value) {
378 | this.cmds.add(new SetAttrOpts(name, value));
379 | return this;
380 | }
381 |
382 | /**
383 | * show makes DOM elements visible
384 | * @param showOpts options for the show command
385 | * @see ShowOpts
386 | * @return the JS instance for chaining
387 | */
388 | public JS show(ShowOpts showOpts) {
389 | this.cmds.add(showOpts);
390 | return this;
391 | }
392 |
393 | /**
394 | * show makes DOM elements visible
395 | * @return the JS instance for chaining
396 | */
397 | public JS show() {
398 | this.cmds.add(new ShowOpts());
399 | return this;
400 | }
401 |
402 | /**
403 | * toggle toggles the visibility of DOM elements
404 | * @param toggleOpts options for the toggle command
405 | * @see ToggleOpts
406 | * @return the JS instance for chaining
407 | */
408 | public JS toggle(ToggleOpts toggleOpts) {
409 | this.cmds.add(toggleOpts);
410 | return this;
411 | }
412 |
413 | /**
414 | * toggle toggles the visibility of DOM elements
415 | * @return the JS instance for chaining
416 | */
417 | public JS toggle() {
418 | this.cmds.add(new ToggleOpts());
419 | return this;
420 | }
421 |
422 | /**
423 | * transition applies a css transition to a DOM element
424 | * @param transitionOpts options for the transition command
425 | * @see TransitionOpts
426 | * @return the JS instance for chaining
427 | */
428 | public JS transition(TransitionOpts transitionOpts) {
429 | this.cmds.add(transitionOpts);
430 | return this;
431 | }
432 |
433 | }
434 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/NavigateOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * NavigateOpts are the options for the navigate JS command. This command
11 | * sends a navigation event to the server and updates the browser's pushState history
12 | */
13 | public class NavigateOpts implements Cmd {
14 |
15 | protected Boolean replace;
16 | protected String href;
17 |
18 | /**
19 | * sends a navigation event with given href to the server
20 | * @param href the URL to navigate to
21 | */
22 | public NavigateOpts(String href) {
23 | this(href, null);
24 | }
25 |
26 | /**
27 | * sends a navigation event with given href to the server
28 | * @param href the URL to navigate to
29 | * @param replace whether to replace the current history entry or not
30 | */
31 | public NavigateOpts(String href, Boolean replace) {
32 | this.href = href;
33 | this.replace = replace;
34 | }
35 |
36 | @Override
37 | public String toJSON() {
38 | return JS.moshi.adapter(NavigateOpts.class).toJson(this);
39 | }
40 |
41 | }
42 |
43 | /**
44 | * NavigateCmdAdaptor is a Moshi adaptor for the NavigateOpts class.
45 | */
46 | class NavigateCmdAdaptor {
47 | @ToJson
48 | public List toJSON(NavigateOpts cmd) {
49 | var map = new LinkedHashMap<>(); // preserve order
50 | map.put("href", cmd.href);
51 | if(cmd.replace != null) {
52 | map.put("replace", cmd.replace);
53 | }
54 | var list = new ArrayList<>();
55 | list.add("navigate");
56 | list.add(map);
57 | return list;
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/PatchOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * PatchOpts are the options for the patch JS command. This command
11 | * sends a patch event to the server and updates the browser's pushState history
12 | */
13 | public class PatchOpts implements Cmd {
14 |
15 | protected Boolean replace;
16 | protected String href;
17 |
18 | /**
19 | * sends a patch event with given href to the server
20 | * @param href the href to send to the server
21 | */
22 | public PatchOpts(String href) {
23 | this(href, null);
24 | }
25 |
26 | /**
27 | * sends a patch event with given href to the server
28 | * @param href the href to send to the server
29 | * @param replace whether to replace the current history entry or not
30 | */
31 | public PatchOpts(String href, Boolean replace) {
32 | this.href = href;
33 | this.replace = replace;
34 | }
35 |
36 | @Override
37 | public String toJSON() {
38 | return JS.moshi.adapter(PatchOpts.class).toJson(this);
39 | }
40 |
41 | }
42 |
43 | /**
44 | * PatchCmdAdaptor is a Moshi adaptor for the PatchOpts class.
45 | */
46 | class PatchCmdAdaptor {
47 | @ToJson
48 | public List toJSON(PatchOpts cmd) {
49 | var map = new LinkedHashMap<>(); // preserve order
50 | map.put("href", cmd.href);
51 | if (cmd.replace != null){
52 | map.put("replace", cmd.replace);
53 | }
54 | var list = new ArrayList<>();
55 | list.add("patch");
56 | list.add(map);
57 | return list;
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/PopFocusOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * PopFocusOpts are the options for the pop_focus command. This command
11 | * pops the focus stack and focuses on the previous element. There are no
12 | * options for this command but this class handles the serialization.
13 | */
14 | public class PopFocusOpts implements Cmd {
15 |
16 | @Override
17 | public String toJSON() {
18 | return JS.moshi.adapter(PopFocusOpts.class).toJson(this);
19 | }
20 |
21 | }
22 |
23 | /**
24 | * PopFocusCmdAdaptor is a Moshi adaptor for the PopFocusOpts class.
25 | */
26 | class PopFocusCmdAdaptor {
27 | @ToJson
28 | public List toJSON(PopFocusOpts cmd) {
29 | var list = new ArrayList<>();
30 | list.add("pop_focus");
31 | list.add(new LinkedHashMap<>());
32 | return list;
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/PushFocusOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * PushFocusOpts are the options for the push_focus command. This command
11 | * pushes focus from the source element to be later popped with pop_focus.
12 | */
13 | public class PushFocusOpts implements Cmd {
14 |
15 | protected String to;
16 |
17 | /**
18 | * Pushes focus from the current element to be later popped with pop_focus.
19 | */
20 | public PushFocusOpts() {
21 | this(null);
22 | }
23 |
24 | /**
25 | * Pushes focus from the current element to the element specified by the DOM selector
26 | * to be later popped with pop_focus.
27 | * @param to the DOM selector for the targeted element
28 | */
29 | public PushFocusOpts(String to) {
30 | this.to = to;
31 | }
32 |
33 | @Override
34 | public String toJSON() {
35 | return JS.moshi.adapter(PushFocusOpts.class).serializeNulls().toJson(this);
36 | }
37 |
38 | }
39 |
40 | /**
41 | * PushFocusCmdAdaptor is a Moshi adaptor for the PushFocusOpts class.
42 | */
43 | class PushFocusCmdAdaptor {
44 | @ToJson
45 | public List toJSON(PushFocusOpts cmd) {
46 | var map = new LinkedHashMap<>(); // preserve order
47 | map.put("to", cmd.to);
48 | var list = new ArrayList<>();
49 | list.add("push_focus");
50 | list.add(map);
51 | return list;
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/PushOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 | import java.util.Map;
9 |
10 | /**
11 | * PushOpts are the options for the push command. This command
12 | * pushes an event to the server.
13 | */
14 | public class PushOpts implements Cmd {
15 |
16 | protected String event;
17 | protected String target;
18 | protected String loading;
19 | protected Boolean pageLoading;
20 | protected Map values;
21 |
22 | /**
23 | * Pushes an event to the server.
24 | * @param event the event to push
25 | */
26 | public PushOpts(String event) {
27 | this(event, null, null, null, null);
28 | }
29 |
30 | /**
31 | * Pushes an event to the server with a target.
32 | * @param event the event to push
33 | * @param target the DOM selector for the targeted element
34 | */
35 | public PushOpts(String event, String target) {
36 | this(event, target, null, null, null);
37 | }
38 |
39 | /**
40 | * Pushes an event to the server with a target and loading indicator.
41 | * @param event the event to push
42 | * @param target the DOM selector for the targeted element
43 | * @param loading the DOM selector to apply the loading indicator to
44 | */
45 | public PushOpts(String event, String target, String loading) {
46 | this(event, target, loading, null, null);
47 | }
48 |
49 | /**
50 | * Pushes an event to the server with a target, loading indicator, and page loading indicator.
51 | * @param event the event to push
52 | * @param target the DOM selector for the targeted element
53 | * @param loading the DOM selector to apply the loading indicator to
54 | * @param pageLoading whether or not to trigger page loading events
55 | */
56 | public PushOpts(String event, String target,String loading, Boolean pageLoading) {
57 | this(event, target, loading, pageLoading, null);
58 | }
59 |
60 | /**
61 | * Pushes an event to the server with a target, loading indicator, page loading indicator, and values.
62 | * @param event the event to push
63 | * @param target the DOM selector for the targeted element
64 | * @param loading the DOM selector to apply the loading indicator to
65 | * @param pageLoading whether or not to trigger page loading events
66 | * @param values the values to include with the event sent to the server
67 | */
68 | public PushOpts(String event, String target, String loading, Boolean pageLoading, Map values) {
69 | this.event = event;
70 | this.target = target;
71 | this.loading = loading;
72 | this.pageLoading = pageLoading;
73 | this.values = values;
74 | }
75 |
76 | @Override
77 | public String toJSON() {
78 | return JS.moshi.adapter(PushOpts.class).toJson(this);
79 | }
80 |
81 | }
82 |
83 | /**
84 | * PushCmdAdapter is a Moshi adaptor for the PushOpts class.
85 | */
86 | class PushCmdAdapter {
87 | @ToJson
88 | public List toJSON(PushOpts cmd) {
89 | var map = new LinkedHashMap<>(); // preserve order
90 | map.put("event", cmd.event);
91 | // only add additional props if not null
92 | if(cmd.target != null) {
93 | map.put("target", cmd.target);
94 | }
95 | if(cmd.loading != null) {
96 | map.put("loading", cmd.loading);
97 | }
98 | if(cmd.pageLoading != null) {
99 | map.put("page_loading", cmd.pageLoading);
100 | }
101 | if(cmd.values != null) {
102 | map.put("value", cmd.values);
103 | }
104 |
105 | var list = new ArrayList<>();
106 | list.add("push");
107 | list.add(map);
108 | return list;
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/RemoveAttrOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * RemoveAttrOpts are the options for the remove_attr command. This command
11 | * removes an attribute from the targeted element.
12 | */
13 | public class RemoveAttrOpts implements Cmd {
14 |
15 | protected String to;
16 | protected String attr;
17 |
18 | /**
19 | * removes the attribute from the current element
20 | * @param attr the attribute to remove
21 | */
22 | public RemoveAttrOpts(String attr) {
23 | this(attr, null);
24 | }
25 |
26 | /**
27 | * removes the attribute from the element specified by the DOM selector
28 | * @param attr the attribute to remove
29 | * @param to the DOM selector for the targeted element
30 | */
31 | public RemoveAttrOpts(String attr, String to) {
32 | this.to = to;
33 | this.attr = attr;
34 | }
35 |
36 | @Override
37 | public String toJSON() {
38 | return JS.moshi.adapter(RemoveAttrOpts.class).serializeNulls().toJson(this);
39 | }
40 |
41 | }
42 |
43 | /**
44 | * RemoveAttrCmdAdapter is a Moshi adaptor for the RemoveAttrOpts class.
45 | */
46 | class RemoveAttrCmdAdapter {
47 | @ToJson
48 | public List toJSON(RemoveAttrOpts cmd) {
49 | var map = new LinkedHashMap<>(); // preserve order
50 | map.put("to", cmd.to);
51 | map.put("attr", cmd.attr);
52 | var list = new ArrayList<>();
53 | list.add("remove_attr");
54 | list.add(map);
55 | return list;
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/RemoveClassOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.time.Duration;
6 | import java.util.ArrayList;
7 | import java.util.LinkedHashMap;
8 | import java.util.List;
9 |
10 | /**
11 | * RemoveClassOpts are the options for the remove_class JS command. This command
12 | * removes the specified classes from the targeted element.
13 | */
14 | public class RemoveClassOpts implements Cmd {
15 | protected String classNames;
16 |
17 | protected String to;
18 | protected Long time;
19 | protected Transition transition;
20 |
21 | /**
22 | * removes the specified classes from the current element
23 | * @param classNames the classes to remove
24 | */
25 | public RemoveClassOpts(String classNames) {
26 | this(classNames,null, Duration.ofMillis(200), new Transition());
27 | }
28 |
29 | /**
30 | * removes the specified classes from the current element
31 | * @param classNames the classes to remove
32 | * @param to the DOM selector for the targeted element
33 | */
34 | public RemoveClassOpts(String classNames, String to) {
35 | this(classNames, to, Duration.ofMillis(200), new Transition());
36 | }
37 |
38 | /**
39 | * removes the specified classes from the current element
40 | * @param classNames the classes to remove
41 | * @param to the DOM selector for the targeted element
42 | * @param time the duration of the transition
43 | */
44 | public RemoveClassOpts(String classNames, String to, Duration time) {
45 | this(classNames, to, time, new Transition());
46 | }
47 |
48 | /**
49 | * removes the specified classes from the current element
50 | * @param classNames the classes to remove
51 | * @param to the DOM selector for the targeted element
52 | * @param time the duration of the transition
53 | * @param transition the css transition classes to apply
54 | * @see Transition
55 | */
56 | public RemoveClassOpts(String classNames, String to, Duration time, Transition transition) {
57 | this.classNames = classNames;
58 | this.to = to;
59 | this.time = time.toMillis();
60 | this.transition = transition;
61 | }
62 |
63 | @Override
64 | public String toJSON() {
65 | return JS.moshi.adapter(RemoveClassOpts.class).serializeNulls().toJson(this);
66 | }
67 |
68 | }
69 |
70 | /**
71 | * RemoveClassCmdAdapter is a Moshi adaptor for the RemoveClassOpts class.
72 | */
73 | class RemoveClassCmdAdapter {
74 | @ToJson
75 | public List toJSON(RemoveClassOpts cmd) {
76 | var map = new LinkedHashMap<>(); // preserve order
77 | map.put("names", cmd.classNames.split("\s+"));
78 | map.put("to", cmd.to);
79 | map.put("time", cmd.time);
80 | map.put("transition", cmd.transition);
81 | var list = new ArrayList<>();
82 | list.add("remove_class");
83 | list.add(map);
84 | return list;
85 | }
86 |
87 | }
88 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/SetAttrOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.ArrayList;
6 | import java.util.LinkedHashMap;
7 | import java.util.List;
8 |
9 | /**
10 | * SetAttrOpts are the options for the set_attr command. This command
11 | * sets an attribute on the targeted element.
12 | */
13 | public class SetAttrOpts implements Cmd {
14 |
15 | protected String to;
16 | protected String attr;
17 | protected String value;
18 |
19 | /**
20 | * sets the attribute on the current element
21 | * @param attr the attribute to set
22 | * @param value the value to set the attribute to
23 | */
24 | public SetAttrOpts(String attr, String value) {
25 | this(attr, value, null);
26 | }
27 |
28 | /**
29 | * sets the attribute on the element specified by the DOM selector
30 | * @param attr the attribute to set
31 | * @param value the value to set the attribute to
32 | * @param to the DOM selector for the targeted element
33 | */
34 | public SetAttrOpts(String attr, String value, String to) {
35 | this.to = to;
36 | this.attr = attr;
37 | this.value = value;
38 | }
39 |
40 | @Override
41 | public String toJSON() {
42 | return JS.moshi.adapter(SetAttrOpts.class).serializeNulls().toJson(this);
43 | }
44 |
45 | }
46 |
47 | /**
48 | * SetAttrCmdAdapter is a Moshi adaptor for the SetAttrOpts class.
49 | */
50 | class SetAttrCmdAdapter {
51 | @ToJson
52 | public List toJSON(SetAttrOpts cmd) {
53 | var attr = new ArrayList<>();
54 | attr.add(cmd.attr);
55 | attr.add(cmd.value);
56 | var map = new LinkedHashMap<>(); // preserve order
57 | map.put("to", cmd.to);
58 | map.put("attr", attr);
59 | var list = new ArrayList<>();
60 | list.add("set_attr");
61 | list.add(map);
62 | return list;
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/ShowOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.time.Duration;
6 | import java.util.ArrayList;
7 | import java.util.LinkedHashMap;
8 | import java.util.List;
9 |
10 | /**
11 | * ShowOpts are the options for the show JS command. This command
12 | * shows the targeted element.
13 | */
14 | public class ShowOpts implements Cmd {
15 | protected String to;
16 | protected Long time;
17 | protected Transition transition;
18 | protected String display;
19 |
20 | /**
21 | * shows the current element
22 | */
23 | public ShowOpts() {
24 | this(null, Duration.ofMillis(200), new Transition(), JS.DEFAULT_DISPLAY);
25 | }
26 |
27 | /**
28 | * shows the element specified by the DOM selector
29 | * @param to the DOM selector for the targeted element
30 | */
31 | public ShowOpts(String to) {
32 | this(to, Duration.ofMillis(200), new Transition(), JS.DEFAULT_DISPLAY);
33 | }
34 |
35 | /**
36 | * shows the element specified by the DOM selector and
37 | * a duration for the transition.
38 | * @param to the DOM selector for the targeted element
39 | * @param time the duration of the transition
40 | */
41 | public ShowOpts(String to, Duration time) {
42 | this(to, time, new Transition(), JS.DEFAULT_DISPLAY);
43 | }
44 |
45 | /**
46 | * shows the element specified by the DOM selector, a
47 | * duration for the transition, and a {@link Transition}
48 | * containing the css transition classes to apply.
49 | * @see Transition
50 | * @param to the DOM selector for the targeted element
51 | * @param time the duration of the transition
52 | * @param transition the css transition classes to apply
53 | */
54 | public ShowOpts(String to, Duration time, Transition transition) {
55 | this(to, time, transition, JS.DEFAULT_DISPLAY);
56 | }
57 |
58 | /**
59 | * shows the element specified by the DOM selector, a
60 | * duration for the transition, and a {@link Transition}
61 | * containing the css transition classes to apply.
62 | * @see Transition
63 | * @param to the DOM selector for the targeted element
64 | * @param time the duration of the transition
65 | * @param transition the css transition classes to apply
66 | * @param display the css display property to apply
67 | */
68 | public ShowOpts(String to, Duration time, Transition transition, String display) {
69 | this.to = to;
70 | this.time = time.toMillis();
71 | this.transition = transition;
72 | this.display = display;
73 | }
74 |
75 | @Override
76 | public String toJSON() {
77 | return JS.moshi.adapter(ShowOpts.class).serializeNulls().toJson(this);
78 | }
79 |
80 | }
81 |
82 | /**
83 | * ShowCmdAdapter is a Moshi adaptor for the ShowOpts class.
84 | */
85 | class ShowCmdAdapter {
86 | @ToJson
87 | public List toJSON(ShowOpts showOpts) {
88 | var map = new LinkedHashMap<>(); // preserve order
89 | map.put("to", showOpts.to);
90 | map.put("time", showOpts.time);
91 | map.put("transition", showOpts.transition);
92 | map.put("display", showOpts.display);
93 | var list = new ArrayList<>();
94 | list.add("show");
95 | list.add(map);
96 | return list;
97 | }
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/ToggleOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.time.Duration;
6 | import java.util.ArrayList;
7 | import java.util.LinkedHashMap;
8 | import java.util.List;
9 |
10 | /**
11 | * ToggleOpts are the options for the toggle JS command. This command
12 | * toggles the visibility of the targeted element.
13 | */
14 | public class ToggleOpts implements Cmd {
15 |
16 | protected String to;
17 | protected Long time;
18 | protected Transition ins;
19 | protected Transition outs;
20 | protected String display;
21 |
22 | /**
23 | * toggles the visibility of the current element
24 | */
25 | public ToggleOpts() {
26 | this(null, Duration.ofMillis(200), new Transition(), new Transition(), JS.DEFAULT_DISPLAY);
27 | }
28 |
29 | /**
30 | * toggles the visibility of the element specified by the DOM selector
31 | * @param to the DOM selector for the targeted element
32 | */
33 | public ToggleOpts(String to) {
34 | this(to, Duration.ofMillis(200), new Transition(), new Transition(), JS.DEFAULT_DISPLAY);
35 | }
36 |
37 | /**
38 | * toggles the visibility of the element specified by the DOM selector and
39 | * a duration for the transition.
40 | * @param to the DOM selector for the targeted element
41 | * @param time the duration of the transition
42 | */
43 | public ToggleOpts(String to, Duration time) {
44 | this(to, time, new Transition(), new Transition(), JS.DEFAULT_DISPLAY);
45 | }
46 |
47 | /**
48 | * toggles the visibility of the element specified by the DOM selector, a
49 | * duration for the transition, and a {@link Transition}
50 | * containing the css transition classes to apply when showing the element.
51 | * @see Transition
52 | * @param to the DOM selector for the targeted element
53 | * @param time the duration of the transition
54 | * @param in the css transition classes to apply when showing the element
55 | */
56 | public ToggleOpts(String to, Duration time, Transition in) {
57 | this(to, time, in, new Transition(), JS.DEFAULT_DISPLAY);
58 | }
59 |
60 | /**
61 | * toggles the visibility of the element specified by the DOM selector, a
62 | * duration for the transition, a {@link Transition}
63 | * containing the css transition classes to apply when showing the element
64 | * and a {@link Transition} containing the css transition classes to apply
65 | * when hiding the element.
66 | * @see Transition
67 | * @param to the DOM selector for the targeted element
68 | * @param time the duration of the transition
69 | * @param in the css transition classes to apply when showing the element
70 | * @param out the css transition classes to apply when hiding the element
71 | */
72 | public ToggleOpts(String to, Duration time, Transition in, Transition out) {
73 | this(to, time, in, out, JS.DEFAULT_DISPLAY);
74 | }
75 |
76 | /**
77 | * toggles the visibility of the element specified by the DOM selector, a
78 | * duration for the transition, a {@link Transition}
79 | * containing the css transition classes to apply when showing the element,
80 | * a {@link Transition} containing the css transition classes to apply
81 | * when hiding the element and a css display property to apply when showing
82 | * the element.
83 | * @see Transition
84 | * @param to the DOM selector for the targeted element
85 | * @param time the duration of the transition
86 | * @param in the css transition classes to apply when showing the element
87 | * @param out the css transition classes to apply when hiding the element
88 | * @param display the css display property to apply
89 | */
90 | public ToggleOpts(String to, Duration time, Transition in, Transition out, String display) {
91 | this.to = to;
92 | this.time = time.toMillis();
93 | this.ins = in;
94 | this.outs = out;
95 | this.display = display;
96 | }
97 |
98 | @Override
99 | public String toJSON() {
100 | return JS.moshi.adapter(ToggleOpts.class).serializeNulls().toJson(this);
101 | }
102 |
103 | }
104 |
105 | /**
106 | * ToggleCmdAdapter is a Moshi adaptor for the ToggleOpts class.
107 | */
108 | class ToggleCmdAdapter {
109 | @ToJson
110 | public List toJSON(ToggleOpts cmd) {
111 | var map = new LinkedHashMap<>(); // preserve order
112 | map.put("to", cmd.to);
113 | map.put("time", cmd.time);
114 | map.put("ins", cmd.ins);
115 | map.put("outs", cmd.outs);
116 | map.put("display", cmd.display);
117 | var list = new ArrayList<>();
118 | list.add("toggle");
119 | list.add(map);
120 | return list;
121 | }
122 |
123 | }
124 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/Transition.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.util.List;
6 |
7 | /**
8 | * Transition represents the css transition classes to apply to an element.
9 | * Transitions are useful for temporarily adding an animation class to element(s),
10 | * such as for highlighting content changes.
11 | */
12 | class Transition {
13 | protected List data;
14 |
15 | Transition() {
16 | data = List.of(
17 | new String[]{},
18 | new String[]{},
19 | new String[]{}
20 | );
21 | }
22 |
23 | /**
24 | * the transition css classes to apply before removing classes
25 | * @param cssClasses the transition css classes to apply for the transition
26 | */
27 | public Transition(String cssClasses) {
28 | data = List.of(
29 | cssClasses.split("\s+"),
30 | new String[]{},
31 | new String[]{}
32 | );
33 | }
34 |
35 | /**
36 | * a transition with the transition classes, the class to apply to start the transition,
37 | * and the ending transition class: "ease-out duration-300", "opacity-0", "opacity-100"
38 | * @param transitionClass the transition css classes to apply for the transition
39 | * @param startClass the class to apply to start the transition
40 | * @param endClass the class to apply to end the transition
41 | */
42 | public Transition(String transitionClass, String startClass, String endClass) {
43 | data = List.of(
44 | transitionClass.split("\s+"),
45 | startClass.split("\s+"),
46 | endClass.split("\s+")
47 | );
48 | }
49 |
50 | public String toJSON() {
51 | return JS.moshi.adapter(Transition.class).toJson(this);
52 | }
53 |
54 | }
55 |
56 | /**
57 | * TransitionAdapter is a Moshi adaptor for the Transition class.
58 | */
59 | class TransitionAdapter {
60 | @ToJson
61 | public List toJSON(Transition t) {
62 | return t.data;
63 | }
64 |
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/js/TransitionOpts.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import com.squareup.moshi.ToJson;
4 |
5 | import java.time.Duration;
6 | import java.util.ArrayList;
7 | import java.util.LinkedHashMap;
8 | import java.util.List;
9 |
10 | /**
11 | * TransitionOpts are the options for the transition JS command. This command
12 | * transitions the targeted element.
13 | */
14 | public class TransitionOpts implements Cmd {
15 | protected String to;
16 | protected Long time;
17 | protected Transition transition;
18 |
19 | /**
20 | * transitions the current element
21 | * @param transition the transition to apply
22 | */
23 | public TransitionOpts(Transition transition) {
24 | this(transition, null, Duration.ofMillis(200));
25 | }
26 |
27 | /**
28 | * transitions the element specified by the DOM selector
29 | * @param transition the transition to apply
30 | * @param to the DOM selector for the targeted element
31 | */
32 | public TransitionOpts(Transition transition, String to) {
33 | this(transition, to, Duration.ofMillis(200));
34 | }
35 |
36 | /**
37 | * transitions the element specified by the DOM selector and
38 | * a duration for the transition.
39 | * @param transition the transition to apply
40 | * @param to the DOM selector for the targeted element
41 | * @param time the duration of the transition
42 | */
43 | public TransitionOpts(Transition transition, String to, Duration time) {
44 | this.to = to;
45 | this.time = time.toMillis();
46 | this.transition = transition;
47 | }
48 |
49 | @Override
50 | public String toJSON() {
51 | return JS.moshi.adapter(TransitionOpts.class).serializeNulls().toJson(this);
52 | }
53 |
54 | }
55 |
56 | /**
57 | * TransitionCmdAdapter is a Moshi adaptor for the TransitionOpts class.
58 | */
59 | class TransitionCmdAdapter {
60 | @ToJson
61 | public List toJSON(TransitionOpts transitionOpts) {
62 | var map = new LinkedHashMap<>(); // preserve order
63 | map.put("to", transitionOpts.to);
64 | map.put("time", transitionOpts.time);
65 | map.put("transition", transitionOpts.transition);
66 | var list = new ArrayList<>();
67 | list.add("transition");
68 | list.add(map);
69 | return list;
70 | }
71 |
72 | }
73 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/protocol/Msg.java:
--------------------------------------------------------------------------------
1 | package run.undead.protocol;
2 |
3 | import java.util.Map;
4 |
5 | public record Msg(
6 | String joinRef, String msgRef, String topic, String event, Map payload) {
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/protocol/MsgParser.java:
--------------------------------------------------------------------------------
1 | package run.undead.protocol;
2 |
3 | import com.squareup.moshi.JsonAdapter;
4 | import com.squareup.moshi.JsonDataException;
5 | import com.squareup.moshi.Moshi;
6 |
7 | import java.io.IOException;
8 | import java.util.List;
9 | import java.util.Map;
10 |
11 | public class MsgParser {
12 | private final Moshi moshi;
13 | private final JsonAdapter jsonAdapter;
14 |
15 | public MsgParser() {
16 | this.moshi = new Moshi.Builder().build();
17 | this.jsonAdapter = moshi.adapter(List.class);
18 | }
19 |
20 | public Msg parseJSON(String json) throws IOException, JsonDataException {
21 | var raw = jsonAdapter.fromJson(json);
22 | if (raw.size() != 5) {
23 | throw new JsonDataException("Invalid format: should be json array with 5 elements");
24 | }
25 | var joinRef = raw.get(0);
26 | var msgRef = raw.get(1);
27 | var topic = raw.get(2);
28 | var event = raw.get(3);
29 | var payload = raw.get(4);
30 |
31 | if (joinRef != null) {
32 | switch (joinRef) {
33 | case String s -> {
34 | }
35 | default -> throw new JsonDataException("joinRef must be a string");
36 | }
37 | }
38 | switch (msgRef) {
39 | case String s -> {
40 | }
41 | default -> throw new JsonDataException("msgRef must be a string");
42 | }
43 | switch (topic) {
44 | case String s -> {
45 | }
46 | default -> throw new JsonDataException("topic must be a string");
47 | }
48 | switch (event) {
49 | case String s -> {
50 | }
51 | default -> throw new JsonDataException("event must be a string");
52 | }
53 | switch (payload) {
54 | case Map m -> {
55 | }
56 | default -> throw new JsonDataException("payload must be a map");
57 | }
58 |
59 | return new Msg(
60 | (String) joinRef, (String) msgRef, (String) topic, (String) event, (Map) payload);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/protocol/Reply.java:
--------------------------------------------------------------------------------
1 | package run.undead.protocol;
2 |
3 | import com.squareup.moshi.JsonAdapter;
4 | import com.squareup.moshi.Moshi;
5 |
6 | import java.util.ArrayList;
7 | import java.util.List;
8 | import java.util.Map;
9 |
10 | public class Reply {
11 | private static final Moshi moshi = new Moshi.Builder().build();
12 | private static final JsonAdapter listAdaptor = moshi.adapter(List.class);
13 |
14 | public static String rendered(Msg orig, Map parts) {
15 | var data = List.of(
16 | orig.joinRef(),
17 | orig.msgRef(),
18 | orig.topic(),
19 | "phx_reply",
20 | Map.of(
21 | "status", "ok",
22 | "response", Map.of("rendered", parts)
23 | )
24 | );
25 | return listAdaptor.toJson(data);
26 | }
27 |
28 | public static String heartbeat(Msg orig) {
29 | var data = new ArrayList();
30 | data.add(null);
31 | data.add(orig.msgRef());
32 | data.add("phoenix");
33 | data.add("phx_reply");
34 | data.add(Map.of("status", "ok"));
35 | return listAdaptor.toJson(data);
36 | }
37 |
38 | public static String redirect(Msg orig, String url) {
39 | var data = new ArrayList();
40 | data.add(orig.joinRef());
41 | data.add(orig.msgRef());
42 | data.add(orig.topic());
43 | data.add("phx_reply");
44 | data.add(Map.of(
45 | "status", "ok",
46 | "response", Map.of(
47 | "to", url
48 | )
49 | ));
50 | return listAdaptor.toJson(data);
51 | }
52 |
53 | public static String replyDiff(Msg orig, Map parts) {
54 | var data = new ArrayList();
55 | data.add(orig.joinRef());
56 | data.add(orig.msgRef());
57 | data.add(orig.topic());
58 | data.add("phx_reply");
59 | data.add(Map.of(
60 | "status", "ok",
61 | "response", Map.of(
62 | "diff", parts
63 | )
64 | ));
65 | return listAdaptor.toJson(data);
66 | }
67 |
68 | public static String diff(String topic, Map diff) {
69 | var data = new ArrayList();
70 | data.add(null);
71 | data.add(null); // empty msgRef
72 | data.add(topic);
73 | data.add("diff");
74 | data.add(diff);
75 | return listAdaptor.toJson(data);
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/pubsub/MemPubSub.java:
--------------------------------------------------------------------------------
1 | package run.undead.pubsub;
2 |
3 | import java.util.ArrayList;
4 | import java.util.HashMap;
5 | import java.util.Map;
6 | import java.util.UUID;
7 | import java.util.concurrent.ExecutorService;
8 | import java.util.concurrent.Executors;
9 | import java.util.concurrent.Future;
10 | import java.util.function.BiConsumer;
11 |
12 | /**
13 | * An in-memory pubsub implementation for development and testing. This will not work
14 | * in a distributed environment.
15 | *
16 | * @see {@link PubSub}
17 | */
18 | public enum MemPubSub implements PubSub {
19 | INSTANCE;
20 |
21 | private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
22 | private final Map>> subs = new HashMap<>();
23 |
24 | @Override
25 | public void publish(String topic, String data) {
26 | Map> topicSubscriptions = subs.get(topic);
27 | if (topicSubscriptions != null) {
28 | var futureList = new ArrayList();
29 | for(BiConsumer callback : topicSubscriptions.values()) {
30 | futureList.add(executorService.submit(() -> callback.accept(topic, data)));
31 | }
32 | for(Future future : futureList) {
33 | try {
34 | future.get();
35 | } catch (Exception e) {
36 | e.printStackTrace();
37 | }
38 | }
39 | }
40 | }
41 |
42 | @Override
43 | public String subscribe(String topic, BiConsumer callback) {
44 | String subscriptionId = UUID.randomUUID().toString();
45 | Map> topicSubscriptions = subs.computeIfAbsent(topic, k -> new HashMap<>());
46 | topicSubscriptions.put(subscriptionId, callback);
47 | return subscriptionId;
48 | }
49 |
50 | @Override
51 | public void unsubscribe(String subscriptionId) {
52 | for (Map> topicSubscriptions : subs.values()) {
53 | topicSubscriptions.remove(subscriptionId);
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/pubsub/Pub.java:
--------------------------------------------------------------------------------
1 | package run.undead.pubsub;
2 |
3 | /**
4 | * Pub is an interface for publishing messages to a topic. It is extremely simple
5 | * on purpose and should be easy to implement for almost any pubsub system. The
6 | * String data could be JSON, base64 data, or many other formats. The topic
7 | * could also be as simple or complex as needed.
8 | */
9 | public interface Pub {
10 | /**
11 | * Publish a message to a topic.
12 | * @param topic the topic to publish to
13 | * @param data the data to publish
14 | */
15 | void publish(String topic, String data);
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/pubsub/PubSub.java:
--------------------------------------------------------------------------------
1 | package run.undead.pubsub;
2 |
3 | /**
4 | * PubSub is an interface for publishing and subscribing to messages on a topic
5 | * and is a combination of {@link Pub} and {@link Sub} interfaces.
6 | */
7 | public interface PubSub extends Pub, Sub{ }
8 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/pubsub/Sub.java:
--------------------------------------------------------------------------------
1 | package run.undead.pubsub;
2 |
3 | import java.util.function.BiConsumer;
4 |
5 | /**
6 | * Sub is an interface for subscribing to a topic, receiving messages, and
7 | * unsubscribing. It is extremely simple on purpose and should be easy to
8 | * implement for almost any pubsub system. Typically, you would subscribe
9 | * to a topic and provide a callback function that will be called when a
10 | * message is received. The callback function will be passed the topic and
11 | * data.
12 | *
13 | * @see {@link Pub}
14 | */
15 | public interface Sub {
16 | /**
17 | * Subscribe to a topic and provide a callback function that will be called
18 | * when a message is received. The callback function will be passed the
19 | * topic and data.
20 | * @param topic the topic to subscribe to
21 | * @param callback the callback function to call when a message is received
22 | * @return a subscription id that can be used to {@link #unsubscribe(String)}
23 | */
24 | String subscribe(String topic, BiConsumer callback);
25 |
26 | /**
27 | * Unsubscribe from a topic.
28 | * @param subscriptionId the subscription id returned from {@link #subscribe(String, BiConsumer)}
29 | */
30 | void unsubscribe(String subscriptionId);
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/template/Directive.java:
--------------------------------------------------------------------------------
1 | package run.undead.template;
2 |
3 | import java.util.ArrayList;
4 | import java.util.Collection;
5 | import java.util.List;
6 | import java.util.function.Function;
7 | import java.util.function.Predicate;
8 | import java.util.stream.Collectors;
9 | import java.util.stream.IntStream;
10 |
11 | import static run.undead.template.Undead.EMPTY;
12 |
13 | /**
14 | * Directive contains functions that can be used in templates to control the flow of the template or otherwise
15 | * provide common functionality in templates.
16 | */
17 | public class Directive {
18 |
19 |
20 |
21 | /**
22 | * NoEscape ensures no additional HTML escaping occurs for this template which is
23 | * useful when you want to embed HTML inside a template without it being escaped.
24 | * THIS IS UNSAFE AND SHOULD BE USED WITH CAUTION.
25 | * @param obj object (or template) to not escape
26 | */
27 | public static UndeadTemplate NoEscape(Object obj) {
28 | switch (obj) {
29 | case UndeadTemplate lt -> {
30 | return lt;
31 | }
32 | default -> {
33 | var fragment = StringTemplate.of(List.of(String.valueOf(obj)), List.of());
34 | return new UndeadTemplate(fragment);
35 | }
36 | }
37 | }
38 |
39 | /**
40 | * If models an if statement in a template. If the condition is true the trueCase is returned
41 | * otherwise an empty template is returned.
42 | * @param object object to test
43 | * @param cond case to test
44 | * @return trueCase if condition is true otherwise an empty template
45 | * @param type of object to test
46 | */
47 | public static UndeadTemplate If(T object, Case cond) {
48 | if (cond.test(object)) {
49 | return cond.apply(object);
50 | }
51 | return EMPTY;
52 | }
53 |
54 | /**
55 | * If models an if statement in a template. If the condition is true the trueCase is returned
56 | * otherwise an empty template is returned.
57 | * @param cond condition to test
58 | * @param trueCase template to return if condition is true
59 | * @return trueCase if condition is true otherwise an empty template
60 | */
61 | public static UndeadTemplate If(Boolean cond, UndeadTemplate trueCase) {
62 | if (cond) {
63 | return trueCase;
64 | }
65 | return EMPTY;
66 | }
67 |
68 |
69 | /**
70 | * If models a ternary operator in a template. If the condition is true the trueCase is returned
71 | * otherwise the falseCase is returned.
72 | * @param cond condition to test
73 | * @param trueCase template to return if condition is true
74 | * @param falseCase template to return if condition is false
75 | * @return trueCase if condition is true otherwise falseCase
76 | */
77 | public static UndeadTemplate If(Boolean cond, UndeadTemplate trueCase, UndeadTemplate falseCase) {
78 | if (cond) {
79 | return trueCase;
80 | }
81 | return falseCase;
82 | }
83 |
84 | /**
85 | * If models a ternary operator in a template. If the condition is true the trueCase is returned
86 | * otherwise the falseCase is returned.
87 | * @param object object to test
88 | * @param p predicate to test
89 | * @param trueFunc function to apply if predicate is true
90 | * @param falseFunc function to apply if predicate is false
91 | * @return trueCase if condition is true otherwise falseCase
92 | * @param type of object to test
93 | */
94 | public static UndeadTemplate If(T object, Predicate p, Function trueFunc, Function falseFunc) {
95 | if (p.test(object)) {
96 | return trueFunc.apply(object);
97 | }
98 | return falseFunc.apply(object);
99 | }
100 |
101 | /**
102 | * Switch is a switch statement for templates. It takes a list of cases and returns the first
103 | * case that matches. If no case matches it returns an empty template.
104 | * @param object object to switch on
105 | * @param cases list of cases to match
106 | * @return the first case that matches or an empty template
107 | * @param type of object to switch on
108 | */
109 | public static UndeadTemplate Switch(T object, Case... cases) {
110 | for (var c : cases) {
111 | if (c.predicate().test(object)) {
112 | return c.function().apply(object);
113 | }
114 | }
115 | return EMPTY;
116 | }
117 |
118 | /**
119 | * For applies a function to each element of a collection and returns a list of the results
120 | * @param collection collection of data to map over
121 | * @param func function to apply to each element of the collection
122 | * @return a list of the results of applying the function to each element of the collection
123 | * @param
124 | */
125 | public static List For(Collection collection, Function func) {
126 | if(collection == null) {
127 | return List.of();
128 | }
129 | return collection.stream().map(func).collect(Collectors.toList());
130 | }
131 |
132 | /**
133 | * Join joins a list of templates with a separator template.
134 | * @param tmpls list of templates to join
135 | * @param sep separator template
136 | * @return a new template that is the concatenation of all templates with the separator template between each
137 | */
138 | public static UndeadTemplate Join(List tmpls, UndeadTemplate sep) {
139 | return UndeadTemplate.concat(
140 | IntStream.range(0, tmpls.size())
141 | .mapToObj(
142 | i -> {
143 | var tmpl = tmpls.get(i);
144 | if (i == tmpls.size() - 1) {
145 | return tmpl;
146 | }
147 | return UndeadTemplate.concat(tmpl, sep);
148 | })
149 | .collect(Collectors.toList()));
150 | }
151 |
152 | /**
153 | * Range returns a list of integers from start to end by step
154 | * @param start start of range
155 | * @param end end of range
156 | * @param step step of range
157 | * @return list of integers from start to end
158 | */
159 | public static List Range(int start, int end, int step) {
160 | var list = new ArrayList();
161 | for(var i = start; step > 0 ? i < end : end < i; i += step) {
162 | list.add(i);
163 | }
164 | return list;
165 | }
166 |
167 | /**
168 | * Range returns a list of integers from zero to end by step of 1
169 | * @param end end of range
170 | * @return list of integers from zero to end by step of 1
171 | */
172 | public static List Range(int end) {
173 | return Range(0, end, 1);
174 | }
175 |
176 | /**
177 | * Case is a predicate and function pair used to model a case statement in a template.
178 | * The predicate is used to determine if the case matches and the function is used to
179 | * generate the template if the case matches. In both the predicate and function the
180 | * input is the object being switched on.
181 | * @param
182 | */
183 | public interface Case {
184 | Predicate predicate();
185 | Function function();
186 |
187 | default boolean test(T t) {
188 | return predicate().test(t);
189 | }
190 |
191 | default UndeadTemplate apply(T t) {
192 | return function().apply(t);
193 | }
194 |
195 | /**
196 | * of creates a new case with the given predicate and function
197 | * @param predicate predicate that determines if the case matches
198 | * @param function function that generates the template if the case matches
199 | * @return a new case made with the given predicate and function
200 | * @param type of object passed to predicate and function
201 | */
202 | static Case of(Predicate predicate, Function function) {
203 | return new Case() {
204 | @Override
205 | public Predicate predicate() {
206 | return predicate;
207 | }
208 |
209 | @Override
210 | public Function function() {
211 | return function;
212 | }
213 | };
214 | }
215 |
216 | static Case of(Boolean cond, UndeadTemplate tmpl) {
217 | return Case.of(t -> cond, t -> tmpl);
218 | }
219 |
220 | /**
221 | * defaultOf creates a new case with the given function and a predicate that always returns true
222 | * @param func function that generates the template if this case is matched
223 | * @return a new case made with the given function and a predicate that always returns true
224 | * @param type of object passed to predicate and function
225 | */
226 | static Case defaultOf(Function func) {
227 | return Case.of(t -> true, func);
228 | }
229 |
230 | static Case defaultOf(UndeadTemplate tmpl) {
231 | return Case.of(t -> true, t -> tmpl);
232 | }
233 |
234 | }
235 |
236 | }
237 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/template/MainLayout.java:
--------------------------------------------------------------------------------
1 | package run.undead.template;
2 |
3 | import run.undead.view.View;
4 |
5 | import static run.undead.template.Undead.HTML;
6 | import static run.undead.template.Directive.NoEscape;
7 |
8 | /**
9 | * MainLayout wraps all Undead {@link View} with a common layout and
10 | * typically contains HTML head and body tags including any css, javascript, and other
11 | * static assets. The default implementation loads the Undead javascript, TailwindCSS,
12 | * and DaisyUI.
13 | * See {@link #render} for more information on how to customize the default layout.
14 | */
15 | public interface MainLayout {
16 |
17 | /**
18 | * render wraps the given content with a common layout and typically contains HTML head and body tags including any
19 | * css, javascript, and other static assets.
20 | * @param pageTitle the title of the page (see {@link PageTitle})
21 | * @param csrfToken
22 | * @param content
23 | * @return
24 | */
25 | default UndeadTemplate render(
26 | PageTitle pageTitle,
27 | String csrfToken,
28 | UndeadTemplate content
29 | ) {
30 | return HTML. """
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | \{ this.liveTitle(pageTitle) }
39 |
40 |
41 |
42 |
43 |
44 |
45 |
48 |
49 | \{ NoEscape(content) }
50 |
51 |
52 | """ ;
53 | }
54 |
55 | default UndeadTemplate liveTitle(PageTitle pageTitle) {
56 | return HTML. """
57 |
61 | \{ pageTitle.title() }
62 |
63 | """ ;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/template/PageTitle.java:
--------------------------------------------------------------------------------
1 | package run.undead.template;
2 |
3 | import org.jetbrains.annotations.NotNull;
4 |
5 | public class PageTitle {
6 | private String title;
7 | private String prefix;
8 | private String suffix;
9 |
10 | public PageTitle() {
11 | this.title = "";
12 | this.prefix = "";
13 | this.suffix = "";
14 | }
15 |
16 | public String title() {
17 | return this.title;
18 | }
19 |
20 | public String prefix() {
21 | return this.prefix;
22 | }
23 |
24 | public String suffix() {
25 | return this.suffix;
26 | }
27 |
28 | public PageTitle withTitle(@NotNull String title) {
29 | this.title = title;
30 | return this;
31 | }
32 |
33 | public PageTitle withPrefix(@NotNull String prefix) {
34 | this.prefix = prefix;
35 | return this;
36 | }
37 |
38 | public PageTitle withSuffix(@NotNull String suffix) {
39 | this.suffix = suffix;
40 | return this;
41 | }
42 |
43 |
44 | }
45 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/template/Undead.java:
--------------------------------------------------------------------------------
1 | package run.undead.template;
2 |
3 | /**
4 | * Undead is a string template processor that escapes all HTML entities in the template.
5 | */
6 | public class Undead {
7 | /**
8 | * HTML is a string template processor that escapes all HTML entities in the template.
9 | */
10 | public static final StringTemplate.Processor HTML =
11 | template -> new UndeadTemplate(template);
12 |
13 | /**
14 | * EMPTY is an empty template.
15 | */
16 | public static final UndeadTemplate EMPTY = HTML."";
17 | }
18 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/template/WrapperTemplate.java:
--------------------------------------------------------------------------------
1 | package run.undead.template;
2 |
3 | import java.util.Map;
4 |
5 | public interface WrapperTemplate {
6 | UndeadTemplate render(Map sessionData, UndeadTemplate content);
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/url/Values.java:
--------------------------------------------------------------------------------
1 | package run.undead.url;
2 |
3 | import com.google.common.collect.ArrayListMultimap;
4 | import com.google.common.collect.Multimap;
5 | import org.apache.hc.core5.http.NameValuePair;
6 | import org.apache.hc.core5.net.URLEncodedUtils;
7 | import run.undead.event.UndeadEvent;
8 | import run.undead.js.JS;
9 |
10 | import java.net.URI;
11 | import java.nio.charset.StandardCharsets;
12 | import java.util.HashMap;
13 | import java.util.List;
14 | import java.util.Map;
15 |
16 | /**
17 | * Values is a convenience wrapper around Multimap. It represents
18 | * the data associated with an {@link UndeadEvent} including events sent via
19 | * {@link JS#push} along with query string parameters and form data.
20 | */
21 | public class Values {
22 |
23 | private final Multimap values;
24 |
25 | public Values() {
26 | this.values = ArrayListMultimap.create();
27 | }
28 |
29 | /**
30 | * from creates a Values object from a Map
31 | * @param map the map to create the Values object from
32 | * @return a Values object
33 | */
34 | public static Values from(Map map) {
35 | var v = new Values();
36 | for (var key : map.keySet()) {
37 | v.add(key, map.get(key));
38 | }
39 | return v;
40 | }
41 |
42 | /**
43 | * from creates a Values object from a url encoded string
44 | * @param urlEncoded the encoded string to create the Values object from
45 | * @return a Values object
46 | */
47 | public static Values from(String urlEncoded) {
48 | var values = new Values();
49 | // URLEncodedUtils doesn't like query strings without a leading "?"
50 | if (!urlEncoded.startsWith("?")) {
51 | urlEncoded = "?" + urlEncoded;
52 | }
53 | try {
54 | // URLEncodedUtils handles spaces and "+" correctly whereas URLDecoder does not
55 | List params = URLEncodedUtils.parse(new URI(urlEncoded), StandardCharsets.UTF_8);
56 | // iterate over params creating a Map handling multiple values for the same key
57 | var map = new HashMap();
58 | for (NameValuePair param : params) {
59 | values.add(param.getName(), param.getValue());
60 | }
61 | } catch (Exception e) {
62 | throw new RuntimeException(e);
63 | }
64 | return values;
65 | }
66 |
67 | /**
68 | * add adds a key/value pair to the Values object
69 | * @param key the key to add
70 | * @param value the value to add
71 | */
72 | public void add(String key, String value) {
73 | this.values.put(key, value);
74 | }
75 |
76 | /**
77 | * remove removes a key/value pair from the Values object
78 | * @param key the key to remove
79 | */
80 | public void delete(String key) {
81 | this.values.removeAll(key);
82 | }
83 |
84 | /**
85 | * has returns true if the Values object contains the key
86 | * @param key the key to check
87 | */
88 | public void has(String key) {
89 | this.values.containsKey(key);
90 | }
91 |
92 | /**
93 | * set sets the value for the given key. If the value is a String, it is set as is. If the value is a List,
94 | * each value in the list is set. Otherwise, the value is converted to a String and set.
95 | * @param key the key to set
96 | * @param value the value to set
97 | */
98 | public void set(String key, Object value) {
99 | this.values.removeAll(key);
100 | switch (value) {
101 | case String s -> this.values.put(key, s);
102 | case List l -> {
103 | for (Object o : l) {
104 | this.values.put(key, String.valueOf(o));
105 | }
106 | }
107 | // throw error?
108 | default -> this.values.put(key, String.valueOf(value));
109 | }
110 | }
111 |
112 | /**
113 | * get returns the first value for the given key or null if the key does not exist
114 | * @param key the key to get
115 | * @return the first value for the given key or null if the key does not exist
116 | */
117 | public String get(String key) {
118 | var v = this.values.get(key);
119 | if (v.size() > 0) {
120 | return v.iterator().next();
121 | }
122 | return null;
123 | }
124 |
125 | /**
126 | * getAll returns all values for the given key or an empty list if the key does not exist
127 | * @param key the key to get
128 | * @return all values for the given key or an empty list if the key does not exist
129 | */
130 | public List getAll(String key) {
131 | var v = this.values.get(key);
132 | return List.copyOf(v);
133 | }
134 |
135 | // TODO
136 | // public String urlEncode() {
137 | // }
138 |
139 | /**
140 | * asMap returns the Values object as a Map where Object is either a String or List
141 | * @return the Values object as a Map
142 | */
143 | public Map asMap() {
144 | // convert to Map where object is either a String or List
145 | // depending on if there are multiple values for the same key or not
146 | var map = new HashMap();
147 | for (var key : this.values.keySet()) {
148 | if (this.values.get(key).size() == 1) {
149 | map.put(key, this.values.get(key).iterator().next());
150 | continue;
151 | }
152 | map.put(key, List.copyOf(this.values.get(key)));
153 | }
154 | return map;
155 | }
156 |
157 | /**
158 | * toString returns the keys and values in the Values
159 | * Note: this is not suitable for use in a query string
160 | * @return the Values object as a url encoded string
161 | */
162 | public String toString() {
163 | return this.values.toString();
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/view/Meta.java:
--------------------------------------------------------------------------------
1 | package run.undead.view;
2 |
3 | public class Meta {
4 | }
5 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/view/RouteMatcher.java:
--------------------------------------------------------------------------------
1 | package run.undead.view;
2 |
3 | import java.util.Map;
4 |
5 | /**
6 | * RouteMatcher is an interface for matching an HTTP request path to a view and extracting path parameters
7 | * from a path. When implementing a RouteMatcher for a particular server implementation you should use the
8 | * same regex matching logic that the server uses for its routes and path parameters.
9 | */
10 | public interface RouteMatcher {
11 |
12 | /**
13 | * addRoute adds a route mapping the pathRegex to the {@link View}.
14 | *
15 | * @param pathRegex the pathRegex to match against
16 | * @param view the {@link View} to return if the path matches
17 | */
18 | void addRoute(String pathRegex, View view);
19 |
20 | /**
21 | * matches returns a view if the path matches a route, otherwise null. Implementations should
22 | * use the same regex matching logic that the server uses for its routes.
23 | *
24 | * @param path the path to match against the pathRegex previously added with addRoute
25 | * @return the {@link View} if the path matches a route, otherwise null
26 | */
27 | View matches(String path);
28 |
29 | /**
30 | * pathParams returns a map of path parameters extracted from the path using the previously
31 | * added pathRegex. Implementations should use the same regex matching logic that the server
32 | * uses to extract path parameters.
33 | * @param path the path to extract path parameters from
34 | * @return a map of path parameters
35 | */
36 | Map pathParams(String path);
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/src/main/java/run/undead/view/View.java:
--------------------------------------------------------------------------------
1 | package run.undead.view;
2 |
3 | import run.undead.event.UndeadEvent;
4 | import run.undead.event.UndeadInfo;
5 | import run.undead.context.Context;
6 | import run.undead.template.UndeadTemplate;
7 |
8 | import java.net.URI;
9 | import java.util.Map;
10 |
11 | /**
12 | *
13 | * The View interface defines the lifecycle callbacks for an Undead View. Views have a lifecycle that
14 | * consists a short, fast HTTP request/response and a long-lived WebSocket connection.
15 | * Implementation Note: Views should have a no-arg constructor as they are instantiated by reflection.
16 | *
17 | *
18 | * Interface methods
19 | *
20 | *
21 | * {@link View#mount} - (optional) mount is where a View can be initialized based on session data and/or path or query parameters
22 | * {@link View#handleParams} - (optional) handleParams is called after mount and whenever there is a URI change
23 | * {@link View#handleEvent} - (optional) handleEvent is called when an event (click, keypress, etc) is received from the browser
24 | * {@link View#handleInfo} - (optional) handleInfo is called when an event (a.k.a info) is received from the server
25 | * {@link View#render} - (required) render returns an {@link UndeadTemplate} based on the state of the View
26 | * {@link View#shutdown} - (optional) shutdown is called when the View is shutting down and is a good place to clean up
27 | *
28 | * As you can see the only required method is {@link View#render} which is called to render the View based on its state.
29 | * The other methods are optional so you can implement only the callbacks that you need for your View.
30 | *
31 | *
32 | * View Lifecycle
33 | * Phase 1: HTTP Request/Response
34 | *
35 | * Views are HTTP GET routes on a web server (e.g. GET /my-view?foo=bar
) and when a user navigates to a URL
36 | * that is handled by an Undead View, the server runs the View's lifecycle callbacks and renders the View
37 | * as HTML.
38 | *
39 | *
40 | * During this HTTP phase, Views can't handle events or internal messages so only the following lifecycle
41 | * methods are called (in order):
42 | *
{@link View#mount} => {@link View#handleParams} => {@link View#render}
43 | *
44 | *
45 | * First, {@link View#mount} is called and is passed a {@link Context}, sessionData, and parameters
46 | * (both path and query) from the HTTP request. This is where you should initialize any state for the View,
47 | * perhaps, based on sessionData, params or both. It is reasonable for authz and authn logic to take
48 | * place in mount as well. See {@link Context} for more information on the Socket.
49 | *
50 | *
51 | * Next, {@link View#handleParams} is called and is passed the {@link Context}, the {@link URI} of the
52 | * View (based on the path), and the parameters. This is called after {@link View#mount} and whenever there is a live patch
53 | * event. You can use this callback to update the state of the View based on the parameters and is useful
54 | * if there are live patch events that update the URI of the View. See {@link Context} for more information
55 | *
56 | * Finally, {@link View#render} is called and is passed a {@link Meta} object. Render should use the state
57 | * of the View return an HTML {@link UndeadTemplate} for the View. See {@link Meta} for more information.
58 | *
59 | *
60 | * Phase 2: WebSocket Connection
61 | *
62 | * After the HTTP request/response phase, the client loads the HTML that we sent back including the Undead
63 | * javascript code which connects back to the server via a WebSocket. Once the WebSocket is connected,
64 | * the same initial lifecycle callbacks are called again (in order): mount => handleParams => render but
65 | * instead of rendering HTML, we send back a data structure that allows efficient patching of the DOM.
66 | * (Note: this is transparent to the developer as the same {@link View#render} method is called).
67 | *
68 | *
69 | * Now that the View has established a long-running WebSocket connection, it can handle events from the browser
70 | * or server events. To receive browser events, the {@link UndeadTemplate} returned from {@link View#render} uses
71 | * the ud-
attributes to register event handlers. For example, ud-click="my-event"
added
72 | * to a button will send a my-event
event to the server when the button is clicked. Undead will route
73 | * the event to the View's {@link View#handleEvent} callback. See {@link UndeadTemplate} for more information on
74 | * the ud-
attributes.
75 | *
76 | *
77 | * Server events are any events that are not triggered by the browser but rather messages that come from a pub/sub
78 | * topic or the same View instance. For example, a View could have a timer that fires periodically sending an
79 | * internal message to the client via the {@link Context#sendInfo} method. Regardless of the source of the event,
80 | * the View will receive the event via the {@link View#handleInfo} callback.
81 | *
82 | *
83 | * To review, events from the browser are received via {@link View#handleEvent} and server messages are received
84 | * via {@link View#handleInfo}. In both cases, the View may update its state which will cause {@link View#render}
85 | * to be called again and for the diffs to be sent back to the client and applied to the DOM.
86 | *
87 | *
88 | * Finally, when the WebSocket connection is closed, the {@link View#shutdown} callback is called so the View
89 | * can clean up any resources, timers, connections, etc.
90 | *
91 | */
92 | public interface View {
93 |
94 | /**
95 | * mount is called once for both the HTTP request/response and the WebSocket connection phase and is
96 | * typically used to initialize the state of the View based on the sessionData and params.
97 | * @see Context
98 | * @param context the {@link Context} for the View
99 | * @param sessionData a Map of session data from the HTTP request
100 | * @param params a Map of parameters (both path and query) from the HTTP request
101 | */
102 | default void mount(Context context, Map sessionData, Map params) {
103 | // by default mount does nothing which is ok
104 | }
105 |
106 | /**
107 | * handleParams is called once after mount and whenever there is a URI change. You can use this callback
108 | * to update the state of the View based on the parameters and is useful if there are events that update
109 | * the URI of the View (e.g. add / update / remove query parameters).
110 | * @param context the {@link Context} for the View
111 | * @param uri the {@link URI} of the View
112 | * @param params a Map of parameters (both path and query)
113 | */
114 | default void handleParams(Context context, URI uri, Map params) {
115 | // by default handleParams does nothing which is ok
116 | }
117 |
118 | /**
119 | * handleEvent is called when an event (click, keypress, etc) is received from the browser from an element
120 | * that has a ud-
attribute. For example, ud-click="my-event"
added to a button
121 | * will send a my-event
event to handleEvent when the button is clicked. When handleEvent is
122 | * called, the View may update its state which will cause {@link View#render} to be called again and for
123 | * the diffs to be sent back to the client and applied to the DOM. Note: the default
124 | * implementation throws a {@link RuntimeException} so you must implement this method in your View if it will
125 | * receive {@link UndeadEvent}.
126 | * @see UndeadTemplate
127 | * @param context the {@link Context} for the View
128 | * @param event the {@link UndeadEvent} with the event type and data
129 | */
130 | default void handleEvent(Context context, UndeadEvent event) {
131 | // if we get an event, tell the developer they need to implement this
132 | throw new RuntimeException("Implement handleEvent in your view");
133 | }
134 |
135 | /**
136 | * handleInfo is called when an event (a.k.a info) is received from the server. For example, a View could
137 | * have a timer that fires periodically sending an internal message to the client via the {@link Context#sendInfo}
138 | * method. When handleInfo is called, the View may update its state which will cause {@link View#render} to
139 | * be called again and for the diffs to be sent back to the client and applied to the DOM. Note:
140 | * the default implementation throws a {@link RuntimeException} so you must implement this method in your View if
141 | * it will receive {@link UndeadInfo}.
142 | * @see Context
143 | * @param context the {@link Context} for the View
144 | * @param info the {@link UndeadInfo} with the info type and data
145 | */
146 | default void handleInfo(Context context, UndeadInfo info) {
147 | // if we get an info, tell the developer they need to implement this
148 | throw new RuntimeException("Implement handleInfo in your view");
149 | }
150 |
151 | /**
152 | * render returns an {@link UndeadTemplate} based on the state of the View. This method is called
153 | * during both the HTTP request/response phase, the WebSocket connection phase, and after any event
154 | * is received by the View. The {@link Meta} object is passed to the View and provides additional
155 | * metadata and helper methods for rendering the View.
156 | * @see UndeadTemplate
157 | * @see Meta
158 | * @param meta the {@link Meta} object for the View
159 | * @return an {@link UndeadTemplate} based on the state of the View
160 | */
161 | UndeadTemplate render(Meta meta);
162 |
163 | /**
164 | * shutdown is called when the View is shutting down and is a good place to clean up any resources,
165 | * timers, connections, etc.
166 | */
167 | default void shutdown() {
168 | // empty implementation is ok
169 | }
170 | }
171 |
172 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/context/ContextTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.context;
2 |
3 | import org.junit.jupiter.api.Test;
4 | import run.undead.template.Undead;
5 |
6 | import java.util.Map;
7 |
8 | import static org.junit.jupiter.api.Assertions.assertEquals;
9 |
10 | public class ContextTest {
11 |
12 | @Test
13 | public void testBasicDiff() {
14 | var ctx = new WsContext(null, null, null);
15 | var name = "foo";
16 | var tmpl = Undead.HTML."""
17 |
18 |
hello \{name}
19 |
20 | """;
21 | var p = ctx.diffParts(tmpl);
22 | // no diff
23 | assertEquals(tmpl.toParts(), p);
24 |
25 | name = "bar";
26 | var tmpl2 = Undead.HTML."""
27 |
28 |
hello \{name}
29 |
30 | """;
31 | var p2 = ctx.diffParts(tmpl2);
32 | assertEquals(p2, Map.of("0", "bar"));
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/form/FormTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.form;
2 |
3 | import jakarta.validation.Validation;
4 | import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator;
5 | import org.junit.jupiter.api.Test;
6 |
7 | import java.io.IOException;
8 |
9 | import static org.junit.jupiter.api.Assertions.assertEquals;
10 |
11 |
12 | public class FormTest {
13 | @Test
14 | public void ParseValidation() throws IOException {
15 | var form = new MyForm();
16 | var validator = Validation.byDefaultProvider()
17 | .configure()
18 | .messageInterpolator(new ParameterMessageInterpolator())
19 | .buildValidatorFactory()
20 | .getValidator();
21 | var violations = validator.validate(form);
22 | assertEquals(2, violations.size());
23 | System.out.println(violations);
24 | }
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/form/MyForm.java:
--------------------------------------------------------------------------------
1 | package run.undead.form;
2 |
3 | import jakarta.validation.constraints.Email;
4 | import jakarta.validation.constraints.NotBlank;
5 |
6 | public class MyForm {
7 | @NotBlank()
8 | private String name;
9 |
10 |
11 | @Email()
12 | @NotBlank()
13 | private String email;
14 |
15 | public String getName() {
16 | return name;
17 | }
18 |
19 | public void setName(String name) {
20 | this.name = name;
21 | }
22 |
23 | public String getEmail() {
24 | return email;
25 | }
26 |
27 | public void setEmail(String email) {
28 | this.email = email;
29 | }
30 |
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/js/JSCommandsTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.js;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import java.io.IOException;
6 | import java.util.Map;
7 |
8 | import static org.junit.jupiter.api.Assertions.assertEquals;
9 |
10 | public class JSCommandsTest {
11 |
12 | @Test
13 | public void testTransition() throws IOException {
14 | var transition = new Transition();
15 | assertEquals("[[],[],[]]",
16 | transition.toJSON());
17 |
18 | assertEquals("[[\"foo\",\"bar\"],[],[]]",
19 | new Transition("foo bar").toJSON());
20 |
21 | assertEquals("[[\"foo\"],[\"bar\"],[\"baz\"]]",
22 | new Transition("foo", "bar", "baz").toJSON());
23 |
24 | assertEquals("[[\"foo\"],[\"bar\"],[\"baz\",\"biz\"]]",
25 | new Transition("foo", "bar", "baz biz").toJSON());
26 | }
27 |
28 | @Test
29 | public void testShow() throws IOException {
30 | var show = new ShowOpts();
31 | assertEquals("[\"show\",{\"to\":null,\"time\":200,\"transition\":[[],[],[]],\"display\":\"block\"}]",
32 | show.toJSON());
33 |
34 | show = new ShowOpts("foo");
35 | assertEquals("[\"show\",{\"to\":\"foo\",\"time\":200,\"transition\":[[],[],[]],\"display\":\"block\"}]",
36 | show.toJSON());
37 | show = new ShowOpts("foo", java.time.Duration.ofMillis(100));
38 | assertEquals("[\"show\",{\"to\":\"foo\",\"time\":100,\"transition\":[[],[],[]],\"display\":\"block\"}]",
39 | show.toJSON());
40 |
41 | show = new ShowOpts("foo", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"));
42 | assertEquals("[\"show\",{\"to\":\"foo\",\"time\":100,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"display\":\"block\"}]",
43 | show.toJSON());
44 |
45 | show = new ShowOpts("foo", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"), "inline");
46 | assertEquals("[\"show\",{\"to\":\"foo\",\"time\":100,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"display\":\"inline\"}]",
47 | show.toJSON());
48 | }
49 |
50 | @Test
51 | public void testHide() throws IOException {
52 | var hide = new HideOpts();
53 | assertEquals("[\"hide\",{\"to\":null,\"time\":200,\"transition\":[[],[],[]]}]",
54 | hide.toJSON());
55 |
56 | hide = new HideOpts("foo");
57 | assertEquals("[\"hide\",{\"to\":\"foo\",\"time\":200,\"transition\":[[],[],[]]}]",
58 | hide.toJSON());
59 | hide = new HideOpts("foo", java.time.Duration.ofMillis(100));
60 | assertEquals("[\"hide\",{\"to\":\"foo\",\"time\":100,\"transition\":[[],[],[]]}]",
61 | hide.toJSON());
62 |
63 | hide = new HideOpts("foo", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"));
64 | assertEquals("[\"hide\",{\"to\":\"foo\",\"time\":100,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]",
65 | hide.toJSON());
66 |
67 | }
68 |
69 | @Test
70 | public void testAddClass() {
71 | var addClass = new AddClassOpts("foo");
72 | assertEquals("[\"add_class\",{\"names\":[\"foo\"],\"to\":null,\"time\":200,\"transition\":[[],[],[]]}]",
73 | addClass.toJSON());
74 |
75 | addClass = new AddClassOpts("foo", "bar");
76 | assertEquals("[\"add_class\",{\"names\":[\"foo\"],\"to\":\"bar\",\"time\":200,\"transition\":[[],[],[]]}]",
77 | addClass.toJSON());
78 |
79 | addClass = new AddClassOpts("foo", "bar", java.time.Duration.ofMillis(100));
80 | assertEquals("[\"add_class\",{\"names\":[\"foo\"],\"to\":\"bar\",\"time\":100,\"transition\":[[],[],[]]}]",
81 | addClass.toJSON());
82 |
83 | addClass = new AddClassOpts("foo", "bar", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"));
84 | assertEquals("[\"add_class\",{\"names\":[\"foo\"],\"to\":\"bar\",\"time\":100,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]",
85 | addClass.toJSON());
86 | }
87 |
88 | @Test
89 | public void testRemoveClass() {
90 | var removeClass = new RemoveClassOpts("foo");
91 | assertEquals("[\"remove_class\",{\"names\":[\"foo\"],\"to\":null,\"time\":200,\"transition\":[[],[],[]]}]",
92 | removeClass.toJSON());
93 |
94 | removeClass = new RemoveClassOpts("foo", "bar");
95 | assertEquals("[\"remove_class\",{\"names\":[\"foo\"],\"to\":\"bar\",\"time\":200,\"transition\":[[],[],[]]}]",
96 | removeClass.toJSON());
97 |
98 | removeClass = new RemoveClassOpts("foo", "bar", java.time.Duration.ofMillis(100));
99 | assertEquals("[\"remove_class\",{\"names\":[\"foo\"],\"to\":\"bar\",\"time\":100,\"transition\":[[],[],[]]}]",
100 | removeClass.toJSON());
101 |
102 | removeClass = new RemoveClassOpts("foo", "bar", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"));
103 | assertEquals("[\"remove_class\",{\"names\":[\"foo\"],\"to\":\"bar\",\"time\":100,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]",
104 | removeClass.toJSON());
105 | }
106 |
107 | @Test void testToggle() {
108 | var toggle = new ToggleOpts();
109 | assertEquals("[\"toggle\",{\"to\":null,\"time\":200,\"ins\":[[],[],[]],\"outs\":[[],[],[]],\"display\":\"block\"}]",
110 | toggle.toJSON());
111 |
112 | toggle = new ToggleOpts("#foo");
113 | assertEquals("[\"toggle\",{\"to\":\"#foo\",\"time\":200,\"ins\":[[],[],[]],\"outs\":[[],[],[]],\"display\":\"block\"}]",
114 | toggle.toJSON());
115 |
116 | toggle = new ToggleOpts("#foo", java.time.Duration.ofMillis(100));
117 | assertEquals("[\"toggle\",{\"to\":\"#foo\",\"time\":100,\"ins\":[[],[],[]],\"outs\":[[],[],[]],\"display\":\"block\"}]",
118 | toggle.toJSON());
119 |
120 | toggle = new ToggleOpts("#foo", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"));
121 | assertEquals("[\"toggle\",{\"to\":\"#foo\",\"time\":100,\"ins\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"outs\":[[],[],[]],\"display\":\"block\"}]",
122 | toggle.toJSON());
123 |
124 | toggle = new ToggleOpts("#foo", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"), new Transition("foo", "bar", "baz"));
125 | assertEquals("[\"toggle\",{\"to\":\"#foo\",\"time\":100,\"ins\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"outs\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"display\":\"block\"}]",
126 | toggle.toJSON());
127 |
128 | toggle = new ToggleOpts("#foo", java.time.Duration.ofMillis(100), new Transition("foo", "bar", "baz"), new Transition("foo", "bar", "baz"), "inline");
129 | assertEquals("[\"toggle\",{\"to\":\"#foo\",\"time\":100,\"ins\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"outs\":[[\"foo\"],[\"bar\"],[\"baz\"]],\"display\":\"inline\"}]",
130 | toggle.toJSON());
131 |
132 | }
133 |
134 | @Test
135 | public void testSetAttr() {
136 | var setAttr = new SetAttrOpts("foo", "bar");
137 | assertEquals("[\"set_attr\",{\"to\":null,\"attr\":[\"foo\",\"bar\"]}]",
138 | setAttr.toJSON());
139 |
140 | setAttr = new SetAttrOpts("foo", "bar", "baz");
141 | assertEquals("[\"set_attr\",{\"to\":\"baz\",\"attr\":[\"foo\",\"bar\"]}]",
142 | setAttr.toJSON());
143 | }
144 |
145 | @Test
146 | public void testRemoveAttr() {
147 | var removeAttr = new RemoveAttrOpts("foo");
148 | assertEquals("[\"remove_attr\",{\"to\":null,\"attr\":\"foo\"}]",
149 | removeAttr.toJSON());
150 |
151 | removeAttr = new RemoveAttrOpts("foo", "bar");
152 | assertEquals("[\"remove_attr\",{\"to\":\"bar\",\"attr\":\"foo\"}]",
153 | removeAttr.toJSON());
154 | }
155 |
156 | @Test
157 | public void testTransitionCmd() {
158 | var tsn = new Transition("foo", "bar", "baz");
159 | var transition = new TransitionOpts(tsn);
160 | assertEquals("[\"transition\",{\"to\":null,\"time\":200,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]",
161 | transition.toJSON());
162 |
163 | transition = new TransitionOpts(tsn, "foo");
164 | assertEquals("[\"transition\",{\"to\":\"foo\",\"time\":200,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]",
165 | transition.toJSON());
166 |
167 | transition = new TransitionOpts(tsn, "foo", java.time.Duration.ofMillis(100));
168 | assertEquals("[\"transition\",{\"to\":\"foo\",\"time\":100,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]",
169 | transition.toJSON());
170 | }
171 |
172 | @Test
173 | public void testPush() {
174 | var push = new PushOpts("event");
175 | assertEquals("[\"push\",{\"event\":\"event\"}]",
176 | push.toJSON());
177 |
178 | push = new PushOpts("event", "#foo");
179 | assertEquals("[\"push\",{\"event\":\"event\",\"target\":\"#foo\"}]",
180 | push.toJSON());
181 |
182 | push = new PushOpts("event", "#foo", "#bar");
183 | assertEquals("[\"push\",{\"event\":\"event\",\"target\":\"#foo\",\"loading\":\"#bar\"}]",
184 | push.toJSON());
185 |
186 | push = new PushOpts("event", "#foo", "#bar", true);
187 | assertEquals("[\"push\",{\"event\":\"event\",\"target\":\"#foo\",\"loading\":\"#bar\",\"page_loading\":true}]",
188 | push.toJSON());
189 |
190 | push = new PushOpts("event", "#foo", "#bar", true, Map.of("foo", "bar"));
191 | assertEquals("[\"push\",{\"event\":\"event\",\"target\":\"#foo\",\"loading\":\"#bar\",\"page_loading\":true,\"value\":{\"foo\":\"bar\"}}]",
192 | push.toJSON());
193 |
194 | }
195 |
196 | @Test
197 | public void testDispatch() {
198 | var dispatch = new DispatchOpts("event");
199 | assertEquals("[\"dispatch\",{\"to\":null,\"event\":\"event\"}]",
200 | dispatch.toJSON());
201 |
202 | dispatch = new DispatchOpts("event", "#foo");
203 | assertEquals("[\"dispatch\",{\"to\":\"#foo\",\"event\":\"event\"}]",
204 | dispatch.toJSON());
205 |
206 | dispatch = new DispatchOpts("event", "#foo", Map.of("foo", "bar"));
207 | assertEquals("[\"dispatch\",{\"to\":\"#foo\",\"event\":\"event\",\"detail\":{\"foo\":\"bar\"}}]",
208 | dispatch.toJSON());
209 |
210 | dispatch = new DispatchOpts("event", "#foo", Map.of("foo", "bar"), false);
211 | assertEquals("[\"dispatch\",{\"to\":\"#foo\",\"event\":\"event\",\"detail\":{\"foo\":\"bar\"},\"bubbles\":false}]",
212 | dispatch.toJSON());
213 | }
214 |
215 | @Test
216 | public void testExec() {
217 | var exec = new ExecOpts("foo");
218 | assertEquals("[\"exec\",{\"to\":null,\"attr\":\"foo\"}]",
219 | exec.toJSON());
220 |
221 | exec = new ExecOpts("foo", "#bar");
222 | assertEquals("[\"exec\",{\"to\":\"#bar\",\"attr\":\"foo\"}]",
223 | exec.toJSON());
224 | }
225 |
226 | @Test
227 | public void testFocus() {
228 | var focus = new FocusOpts();
229 | assertEquals("[\"focus\",{\"to\":null}]",
230 | focus.toJSON());
231 |
232 | focus = new FocusOpts("#foo");
233 | assertEquals("[\"focus\",{\"to\":\"#foo\"}]",
234 | focus.toJSON());
235 | }
236 |
237 | @Test
238 | public void testFocusFirst() {
239 | var focusFirst = new FocusFirstOpts();
240 | assertEquals("[\"focus_first\",{\"to\":null}]",
241 | focusFirst.toJSON());
242 |
243 | focusFirst = new FocusFirstOpts("#foo");
244 | assertEquals("[\"focus_first\",{\"to\":\"#foo\"}]",
245 | focusFirst.toJSON());
246 | }
247 |
248 | @Test
249 | public void testNavigate() {
250 | var nav = new NavigateOpts("/my/path");
251 | assertEquals("[\"navigate\",{\"href\":\"/my/path\"}]",
252 | nav.toJSON());
253 |
254 | nav = new NavigateOpts("/my/path", true);
255 | assertEquals("[\"navigate\",{\"href\":\"/my/path\",\"replace\":true}]",
256 | nav.toJSON());
257 | }
258 |
259 | @Test
260 | public void testPatch() {
261 | var patch = new PatchOpts("/my/path");
262 | assertEquals("[\"patch\",{\"href\":\"/my/path\"}]",
263 | patch.toJSON());
264 |
265 | patch = new PatchOpts("/my/path", true);
266 | assertEquals("[\"patch\",{\"href\":\"/my/path\",\"replace\":true}]",
267 | patch.toJSON());
268 | }
269 |
270 | @Test
271 | public void testPopFocus() {
272 | var popFocus = new PopFocusOpts();
273 | assertEquals("[\"pop_focus\",{}]",
274 | popFocus.toJSON());
275 | }
276 |
277 | @Test
278 | public void testPushFocus() {
279 | var pushFocus = new PushFocusOpts();
280 | assertEquals("[\"push_focus\",{\"to\":null}]",
281 | pushFocus.toJSON());
282 |
283 | pushFocus = new PushFocusOpts("#foo");
284 | assertEquals("[\"push_focus\",{\"to\":\"#foo\"}]",
285 | pushFocus.toJSON());
286 | }
287 |
288 |
289 | @Test
290 | public void testJS() {
291 | var js = new JS();
292 | assertEquals("[]", js.toJSON());
293 |
294 | js.show(new ShowOpts());
295 | assertEquals("[[\"show\",{\"to\":null,\"time\":200,\"transition\":[[],[],[]],\"display\":\"block\"}]]", js.toJSON());
296 |
297 | js = new JS();
298 | js.hide(new HideOpts("foo"));
299 | assertEquals("[[\"hide\",{\"to\":\"foo\",\"time\":200,\"transition\":[[],[],[]]}]]", js.toJSON());
300 |
301 | js = new JS();
302 | js.hide(new HideOpts());
303 | assertEquals("[[\"hide\",{\"to\":null,\"time\":200,\"transition\":[[],[],[]]}]]", js.toJSON());
304 |
305 | js = new JS();
306 | js.addClass(new AddClassOpts("foo"));
307 | assertEquals("[[\"add_class\",{\"names\":[\"foo\"],\"to\":null,\"time\":200,\"transition\":[[],[],[]]}]]", js.toJSON());
308 |
309 | js = new JS();
310 | js.removeClass(new RemoveClassOpts("foo"));
311 | assertEquals("[[\"remove_class\",{\"names\":[\"foo\"],\"to\":null,\"time\":200,\"transition\":[[],[],[]]}]]", js.toJSON());
312 |
313 | js = new JS();
314 | js.toggle(new ToggleOpts());
315 | assertEquals("[[\"toggle\",{\"to\":null,\"time\":200,\"ins\":[[],[],[]],\"outs\":[[],[],[]],\"display\":\"block\"}]]", js.toJSON());
316 |
317 | js = new JS();
318 | js.setAttr(new SetAttrOpts("foo", "bar"));
319 | assertEquals("[[\"set_attr\",{\"to\":null,\"attr\":[\"foo\",\"bar\"]}]]", js.toJSON());
320 |
321 | js = new JS();
322 | js.removeAttr(new RemoveAttrOpts("foo"));
323 | assertEquals("[[\"remove_attr\",{\"to\":null,\"attr\":\"foo\"}]]", js.toJSON());
324 |
325 | js = new JS();
326 | js.transition(new TransitionOpts(new Transition("foo", "bar", "baz")));
327 | assertEquals("[[\"transition\",{\"to\":null,\"time\":200,\"transition\":[[\"foo\"],[\"bar\"],[\"baz\"]]}]]", js.toJSON());
328 |
329 | js = new JS();
330 | js.push(new PushOpts("event"));
331 | assertEquals("[[\"push\",{\"event\":\"event\"}]]", js.toJSON());
332 |
333 | js = new JS();
334 | js.dispatch(new DispatchOpts("event"));
335 | assertEquals("[[\"dispatch\",{\"to\":null,\"event\":\"event\"}]]", js.toJSON());
336 |
337 | js = new JS();
338 | js.exec(new ExecOpts("foo"));
339 | assertEquals("[[\"exec\",{\"to\":null,\"attr\":\"foo\"}]]", js.toJSON());
340 |
341 | js = new JS();
342 | js.focus();
343 | assertEquals("[[\"focus\",{\"to\":null}]]", js.toJSON());
344 |
345 | js = new JS();
346 | js.focusFirst(new FocusFirstOpts());
347 | assertEquals("[[\"focus_first\",{\"to\":null}]]", js.toJSON());
348 |
349 | js = new JS();
350 | js.navigate(new NavigateOpts("/my/path"));
351 | assertEquals("[[\"navigate\",{\"href\":\"/my/path\"}]]", js.toJSON());
352 |
353 | js = new JS();
354 | js.patch(new PatchOpts("/my/path"));
355 | assertEquals("[[\"patch\",{\"href\":\"/my/path\"}]]", js.toJSON());
356 |
357 | js = new JS();
358 | js.popFocus();
359 | assertEquals("[[\"pop_focus\",{}]]", js.toJSON());
360 |
361 | js = new JS();
362 | js.pushFocus(new PushFocusOpts());
363 | assertEquals("[[\"push_focus\",{\"to\":null}]]", js.toJSON());
364 | }
365 | }
366 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/protocol/MsgTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.protocol;
2 |
3 | import com.squareup.moshi.JsonDataException;
4 | import org.junit.jupiter.api.Test;
5 |
6 | import java.io.IOException;
7 |
8 | import static org.junit.jupiter.api.Assertions.assertEquals;
9 | import static org.junit.jupiter.api.Assertions.assertThrows;
10 |
11 | public class MsgTest {
12 | String msgStr = """
13 | ["join", "msg", "topic", "event", {"foo":"bar"}]
14 | """;
15 |
16 | String msgStr2 = """
17 | [1, "msg", "topic", "event", {"foo":"bar"}]
18 | """;
19 |
20 | @Test
21 | public void ParseRaw() throws IOException {
22 | var msgParser = new MsgParser();
23 | var msg = msgParser.parseJSON(msgStr);
24 | System.out.println("msg:" + msg);
25 | assertEquals(msg.joinRef(), "join");
26 | }
27 |
28 | @Test
29 | public void ParseBadRaw() throws IOException {
30 | var msgParser = new MsgParser();
31 | assertThrows(JsonDataException.class, () -> {
32 | msgParser.parseJSON(msgStr2);
33 | });
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/pubsub/PubSubTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.pubsub;
2 |
3 | import org.junit.jupiter.api.Test;
4 |
5 | import java.util.concurrent.atomic.AtomicInteger;
6 |
7 | import static org.junit.jupiter.api.Assertions.assertEquals;
8 |
9 | public class PubSubTest {
10 |
11 | @Test
12 | public void testPublish() {
13 | var counter = new AtomicInteger(0);
14 | String subscriptionId = MemPubSub.INSTANCE.subscribe("my-topic", (topic, data) -> {
15 | assertEquals("my-topic", topic);
16 | assertEquals("my-data", data);
17 | counter.incrementAndGet();
18 | });
19 |
20 | // Publish a message to the topic
21 | MemPubSub.INSTANCE.publish("my-topic", "my-data");
22 |
23 | // Verify that the subscription callback is called
24 | assertEquals(1, counter.get());
25 | }
26 |
27 | @Test
28 | public void testUnsubscribe() {
29 | // Subscribe to a topic
30 | var counter = new AtomicInteger(0);
31 | String subscriptionId = MemPubSub.INSTANCE.subscribe("my-topic", (topic, data) -> {
32 | counter.incrementAndGet();
33 | });
34 |
35 | // Test that the subscription callback is called
36 | MemPubSub.INSTANCE.publish("my-topic", "my-data");
37 | assertEquals(1, counter.get());
38 |
39 | // now unsubscribe
40 | MemPubSub.INSTANCE.unsubscribe(subscriptionId);
41 |
42 | // Publish a message to the topic
43 | MemPubSub.INSTANCE.publish("my-topic", "my-data");
44 |
45 | // subscription callback should not be called
46 | assertEquals(1, counter.get());
47 | }
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/template/UndeadTemplateTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.template;
2 |
3 | import com.google.common.collect.Maps;
4 | import org.junit.jupiter.api.Test;
5 | import run.undead.template.Directive.Case;
6 |
7 | import java.util.List;
8 | import java.util.Map;
9 | import java.util.function.Function;
10 |
11 | import static org.junit.jupiter.api.Assertions.assertEquals;
12 | import static run.undead.template.Directive.*;
13 | import static run.undead.template.Undead.HTML;
14 | import static run.undead.template.UndeadTemplate.concat;
15 |
16 | public class UndeadTemplateTest {
17 |
18 | @Test
19 | public void testUndeadTemplate() {
20 | UndeadTemplate template = HTML."test";
21 | assertEquals("test", template.toString());
22 | }
23 |
24 | @Test
25 | public void testUndeadTemplateWithTags() {
26 | UndeadTemplate template = HTML."test ";
27 | assertEquals("test ", template.toString());
28 | }
29 |
30 | @Test
31 | public void testConcat() {
32 | UndeadTemplate template = concat(HTML."test", HTML."test");
33 | assertEquals("testtest", template.toString());
34 |
35 | // use values
36 | var foo = "foo";
37 | var bar = "bar";
38 | template = concat(HTML."\{foo} ", HTML."test", HTML."\{bar} ");
39 | assertEquals("foo testbar ", template.toString());
40 | }
41 |
42 | @Test
43 | public void testMap() {
44 | var items = List.of("foo", "bar", 1, false);
45 | var template = HTML."""
46 | \{ For(items, item -> HTML."\{item}
")}
47 | """.trim();
48 | assertEquals("foo
bar
1
false
", template.toString());
49 | }
50 |
51 | @Test
52 | public void testJoin() {
53 | var items = List.of("foo", "bar", 1, false);
54 | var template = HTML."""
55 | \{ Join(
56 | For(items, item -> HTML."\{item}
"),
57 | HTML."|"
58 | )}
59 | """.trim();
60 | assertEquals("foo
|bar
|1
|false
", template.toString());
61 | }
62 |
63 | @Test
64 | public void testSwitch() {
65 | var template = HTML."""
66 | \{ Switch(1,
67 | Case.of(i -> i == 1, i -> HTML."one"),
68 | Case.of(i -> i == 2, i -> HTML."two"),
69 | Case.of(i -> i == 3, i -> HTML."three"),
70 | Case.defaultOf(i -> HTML."other(\{i})")
71 | )}
72 | """.trim();
73 | assertEquals("one", template.toString());
74 |
75 | var color = "red";
76 | template = HTML."""
77 | \{ Switch(
78 | Case.of("blue".equals(color), HTML."blue"),
79 | Case.of("red".equals(color), HTML."red"),
80 | Case.defaultOf(HTML."green")
81 | )}
82 | """.trim();
83 | assertEquals("red", template.toString());
84 | }
85 |
86 | @Test
87 | public void testRange() {
88 | var template = HTML."""
89 | \{ For(
90 | Range(10),
91 | i -> HTML."\{i}
"
92 | )}
93 | """.trim();
94 | assertEquals("0
1
2
3
4
5
6
7
8
9
", template.toString());
95 |
96 | template = HTML."""
97 | \{ For(
98 | Range(2, 10, 2),
99 | i -> HTML."\{i}
"
100 | )}
101 | """.trim();
102 | assertEquals("2
4
6
8
", template.toString());
103 |
104 | // negative step
105 | template = HTML."""
106 | \{ For(
107 | Range(10, 2, -2),
108 | i -> HTML."\{i}
"
109 | )}
110 | """.trim();
111 | assertEquals("10
8
6
4
", template.toString());
112 | }
113 |
114 | @Test
115 | public void testBasicDiff() {
116 | Function tmplFn = (Map vars) -> {
117 | return HTML."""
118 |
119 |
hello \{vars.get("name")}
120 | \{ HTML."\{vars.get("email")} "}
121 |
122 | """;
123 | };
124 | var vars = Maps.newHashMap();
125 | vars.put("name", "foo");
126 | vars.put("email", "e1");
127 | var t = tmplFn.apply(vars);
128 | vars.put("email", "e2");
129 | var t2 = tmplFn.apply(vars);
130 | var d = UndeadTemplate.diff(t.toParts(), t2.toParts());
131 | assertEquals(d, Map.of("1", Map.of("0","e2")));
132 | }
133 |
134 | @Test
135 | public void testSwitchDiff() {
136 | Function tmplFn = (Map vars) -> {
137 | return HTML."""
138 |
139 |
hello \{vars.get("name")}
140 | \{ Switch( (Integer)vars.get("count"),
141 | Case.of(c -> c == 0, c -> HTML." zero:\{c} "),
142 | Case.of(c -> c == 1, c -> HTML."one:\{c} "),
143 | Case.of(c -> c >= 2, c -> HTML." more:\{c}")
144 | )
145 | }
146 |
147 | """;
148 | };
149 | var vars = Maps.newHashMap();
150 | vars.put("name", "foo");
151 | vars.put("count", 0);
152 | var t = tmplFn.apply(vars);
153 | vars.put("count", 1);
154 | var t2 = tmplFn.apply(vars);
155 | var d = UndeadTemplate.diff(t.toParts(), t2.toParts());
156 | assertEquals(d, Map.of("1", Map.of("0","1", "s", List.of("one:", " "))));
157 | vars.put("count", 2);
158 | var t3 = tmplFn.apply(vars);
159 | d = UndeadTemplate.diff(t2.toParts(), t3.toParts());
160 | assertEquals(d, Map.of("1", Map.of("0","2", "s", List.of(" more:", ""))));
161 | }
162 |
163 | @Test
164 | void testEmptyArray() {
165 | var list = List.of();
166 | var template = HTML."""
167 | \{ For(null,
168 | i -> {
169 | return HTML."\{i}
";
170 | }
171 | )}
172 | """.trim();
173 | assertEquals("", template.toString());
174 | assertEquals(Map.of("0", "", "s", List.of("", "")), template.toParts());
175 | }
176 |
177 | @Test
178 | void testArray() {
179 | var template = HTML."""
180 | \{ For(List.of(100,101),
181 | i -> {
182 | return HTML."\{i}
";
183 | }
184 | )}
185 | """.trim();
186 | assertEquals("100
101
", template.toString());
187 | //0={d=[{0=100}, {0=101}], s=[,
]}, s=[, ]
188 | assertEquals(Map.of("0", Map.of("d", List.of(Map.of("0", "100"), Map.of("0", "101")), "s", List.of("", "
")), "s", List.of("", "")), template.toParts());
189 | }
190 |
191 | }
192 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/url/Foo.java:
--------------------------------------------------------------------------------
1 | package run.undead.url;
2 |
3 | import java.util.List;
4 |
5 | class Foo {
6 | public List name;
7 |
8 | public int age;
9 | }
10 |
--------------------------------------------------------------------------------
/src/test/java/run/undead/url/UrlTest.java:
--------------------------------------------------------------------------------
1 | package run.undead.url;
2 |
3 | import com.fasterxml.jackson.databind.DeserializationFeature;
4 | import com.fasterxml.jackson.databind.ObjectMapper;
5 | import org.apache.hc.core5.http.NameValuePair;
6 | import org.apache.hc.core5.net.URLEncodedUtils;
7 | import org.junit.jupiter.api.Test;
8 |
9 | import java.net.URI;
10 | import java.net.URLDecoder;
11 | import java.net.URLEncoder;
12 | import java.nio.charset.StandardCharsets;
13 | import java.util.HashMap;
14 | import java.util.List;
15 |
16 | import static org.junit.jupiter.api.Assertions.assertEquals;
17 |
18 | public class UrlTest {
19 |
20 | @Test
21 | public void TestValuesWithURLEncoded() {
22 | var myName = URLEncoder.encode("my name", StandardCharsets.UTF_8);
23 | var myVal = URLDecoder.decode(myName, StandardCharsets.UTF_8);
24 | System.out.println("myName:" + myName + " myVal:" + myVal);
25 | var values = Values.from("name=my+name&name=foo&age=24&_target=name");
26 | assertEquals(List.of("my name", "foo"), values.getAll("name"));
27 | assertEquals("24", values.get("age"));
28 | assertEquals("name", values.get("_target"));
29 |
30 | ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
31 | var foo = mapper.convertValue(values.asMap(), Foo.class);
32 | assertEquals(foo.name, List.of("my name", "foo"));
33 | assertEquals(foo.age, 24);
34 | }
35 |
36 | @Test
37 | public void ParseURLEncoded() throws java.net.URISyntaxException {
38 | String bodyStr = "?name=my+name&name=foo&age=24&_target=name";
39 | List params = URLEncodedUtils.parse(new URI(bodyStr), StandardCharsets.UTF_8);
40 | // iterate over params creating a Map handling multiple values for the same key
41 | var map = new HashMap();
42 | for (NameValuePair param : params) {
43 | if (map.containsKey(param.getName())) {
44 | var v = map.get(param.getName());
45 | if (v instanceof List) {
46 | ((List) v).add(param.getValue());
47 | } else {
48 | var l = List.of(v, param.getValue());
49 | map.put(param.getName(), l);
50 | }
51 | } else {
52 | map.put(param.getName(), param.getValue());
53 | }
54 | }
55 | ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
56 | var foo = mapper.convertValue(map, Foo.class);
57 | assertEquals(foo.name, List.of("my name", "foo"));
58 | assertEquals(foo.age, 24);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/undead-core.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------