├── .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 | 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 | 6 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | <div 95 | data-phx-main="true" 96 | data-phx-session="\{ serializedSession }" 97 | data-phx-static="\{ serializedStatics }" 98 | id="ud-\{ viewId }"> 99 | \{ Directive.NoEscape(content) } 100 | </div> 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<String, String> subs; // topic to subId 30 | protected PubSub pubsub; 31 | protected UndeadTemplate lastTmpl; 32 | protected List<UndeadEvent> 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<String, Object> 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<String, String>) 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 <code>ud-</code> 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 <code>ud-click="my-event"</code> attribute, 20 | * then the type of the event will be <code>my-event</code>. 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 | * <code>ud-value-foo="bar"</code> attribute, then the data of the event will contain a key/value 28 | * pairs of <code>foo</code> to <code>bar</code>. 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 <a href="https://beanvalidation.org/">Jakarta Bean Validation (JSR 380)</a> 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 | * <p> 20 | * Here is an example {@link View} that uses Form: 21 | * <pre>{@code 22 | * // first define UserModel with Jakarta Bean Validation annotations 23 | * public record UserModel(@NotBlank String name, @NotBlank @Email String email) { } 24 | * }</pre> 25 | * 26 | * Then define a view that uses this model and form: 27 | * {@code 28 | * public class UndeadUserForm implements View { 29 | * 30 | * private Form<UserModel> 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 <code>ud-change="validate"</code> and <code>ud-submit="submit"</code> attributes 58 | * // on the form element. These are used to trigger the form validation and submission events 59 | * return HTML. """ 60 | * <form class="form" ud-change="validate" ud-submit="submit"> 61 | * <div class="flex flex-col space-y-4 mx-4 w-[250px]"> 62 | * <h2 class="text-2xl">User Form</h2> 63 | * <label for="name">Name</label> 64 | * \{ TextInput(form, "name", "input input-bordered") } 65 | * \{ ErrorMsg(form, "name") } 66 | * <label for="email">Email</label> 67 | * \{ TextInput(form, "email", "input input-bordered") } 68 | * \{ ErrorMsg(form, "email") } 69 | * <button \{ If(!form.valid(), HTML." disabled", EMPTY) } class="btn btn-primary" type="submit">Submit</button> 70 | * </div> 71 | * </form> 72 | * """ ; 73 | * } 74 | * } 75 | * </p> 76 | * <p> 77 | * The view above uses the following helper tags to render the form inputs and error messages: 78 | * <pre>{@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 | * <input type="text" id="\{ name }" name="\{ name }" value="\{ value }" class="\{ classesString }" /> 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 | * <div class="text-sm text-error">\{ error }</div> 97 | * """ ; 98 | * } 99 | * } 100 | * }</pre> 101 | * </p> 102 | * 103 | * @param <T> the type of the model to which the form data is bound. 104 | * 105 | */ 106 | public class Form<T> { 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<String> touched; 115 | private T model; 116 | private Map<String, String> 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<String, String> 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<String, Object> 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<String, Object> 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<String, String> pathParams() { 38 | return ctx.pathParamMap(); 39 | } 40 | 41 | @Override 42 | public Map<String, List<String>> 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<String, Class> 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<String, WsHandler> 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 | <!DOCTYPE html> 49 | <html lang="en"> 50 | <head> 51 | <meta charset="utf-8" /> 52 | <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 53 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 54 | <title>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 | 56 | 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 | 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 |
55 |
56 |

User Form

57 | 58 | \{ TextInput(form, "name", "input input-bordered") } 59 | \{ ErrorMsg(form, "name") } 60 | 61 | \{ TextInput(form, "email", "input input-bordered") } 62 | \{ ErrorMsg(form, "email") } 63 | 64 |
65 |
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 |
24 | Go 25 |
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 |
18 |
\{volume}%
19 |
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 |  *        
 22 |  *        
 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 |  *    
 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("footestbar", 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 | 14 | 15 | --------------------------------------------------------------------------------