├── .clj-kondo └── config.edn ├── .cljfmt.edn ├── .dockerignore ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .gitmodules ├── .idea └── ClojureProjectResolveSettings.xml ├── .m2 └── settings.xml ├── Dockerfile ├── README.md ├── deps.edn ├── fly.toml ├── package.json ├── resources └── public │ ├── favicon.ico │ ├── index.html │ ├── todo-list.css │ └── user │ ├── demo_stage_ui4.css │ ├── examples.css │ ├── github-markdown.css │ └── gridsheet-optional.css ├── shadow-cljs.edn ├── src-build ├── build.clj └── build.md ├── src ├── contrib │ └── datafy_fs.clj ├── dev.cljc ├── electric_server_java11_jetty10.clj ├── electric_server_java8_jetty9.clj ├── logback.xml ├── prod.clj ├── test.clj ├── test │ ├── mbrainz.clj │ ├── person_model.clj │ ├── seattle.clj │ ├── sku_model.clj │ └── social_news.clj ├── toxiproxy.sh ├── user.clj ├── user.cljs ├── user.md ├── user │ ├── demo_10k_dom.cljc │ ├── demo_chat.cljc │ ├── demo_chat.md │ ├── demo_chat_extended.cljc │ ├── demo_chat_extended.md │ ├── demo_color.cljc │ ├── demo_explorer.cljc │ ├── demo_explorer.md │ ├── demo_index.cljc │ ├── demo_reagent_interop.cljc │ ├── demo_svg.cljc │ ├── demo_svg.md │ ├── demo_system_properties.cljc │ ├── demo_system_properties.md │ ├── demo_tic_tac_toe.cljc │ ├── demo_todomvc.cljc │ ├── demo_todomvc.md │ ├── demo_todomvc_composed.cljc │ ├── demo_todomvc_composed.md │ ├── demo_todos_simple.cljc │ ├── demo_todos_simple.md │ ├── demo_toggle.cljc │ ├── demo_toggle.md │ ├── demo_two_clocks.cljc │ ├── demo_two_clocks.md │ ├── demo_virtual_scroll.cljc │ ├── demo_webview.cljc │ ├── demo_webview.md │ ├── example_datascript_db.clj │ ├── tutorial_7guis_1_counter.cljc │ ├── tutorial_7guis_2_temperature.cljc │ ├── tutorial_7guis_4_timer.cljc │ ├── tutorial_7guis_5_crud.cljc │ ├── tutorial_backpressure.cljc │ ├── tutorial_backpressure.md │ ├── tutorial_lifecycle.cljc │ └── tutorial_lifecycle.md ├── user_main.cljc └── wip │ ├── demo_branched_route.cljc │ ├── demo_custom_types.cljc │ ├── demo_explorer2.cljc │ ├── demo_stage_ui4.cljc │ ├── js_interop.cljc │ ├── orders_datascript.cljc │ ├── orders_datomic.clj │ ├── tag_picker.cljc │ ├── teeshirt_orders.cljc │ ├── teeshirt_orders.md │ └── tracing.cljc └── yarn.lock /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {hyperfiddle.electric/def clojure.core/def 2 | hyperfiddle.electric/defn clojure.core/defn 3 | hyperfiddle.electric/for clojure.core/for 4 | hyperfiddle.electric/fn clojure.core/fn} 5 | :linters {:redundant-expression {:level :off}}} 6 | -------------------------------------------------------------------------------- /.cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:indents ^:replace {#"^." [[:inner 0]]} 2 | :test-code [(sui/ui-grid {:columns 2} 3 | (sui/ui-grid-row {} 4 | (sui/ui-grid-column {:width 12} 5 | ...))) 6 | (let [foo bar] 7 | (str "foo" 8 | "bar"))]} -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | **/node_modules 3 | **/resources/public/js 4 | **/.cpcache 5 | **/.shadow-cljs 6 | **/.nrepl-port 7 | **/.clj-kondo/.cache 8 | **/yarn-error.log 9 | **/report.html 10 | **/.idea/* 11 | !**/.idea/ClojureProjectResolveSettings.xml 12 | **/*.iml 13 | **/.lsp 14 | **/.clj-kondo 15 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: hyperfiddle/electric-examples-app 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | pull_request: 7 | jobs: 8 | deploy: 9 | name: Deploy to Fly.io 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: NO_COLOR=1 flyctl deploy --build-arg VERSION=$(git describe --tags --long --always --dirty) --build-arg DATOMIC_DEV_LOCAL_USER=$DATOMIC_DEV_LOCAL_USER --build-arg DATOMIC_DEV_LOCAL_PASSWORD=$DATOMIC_DEV_LOCAL_PASSWORD --build-arg hfql_ssh_prv_key="$HFQL_SSH_PRV_KEY" --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | DATOMIC_DEV_LOCAL_USER: ${{ secrets.DATOMIC_DEV_LOCAL_USER }} 20 | DATOMIC_DEV_LOCAL_PASSWORD: ${{ secrets.DATOMIC_DEV_LOCAL_PASSWORD }} 21 | HFQL_SSH_PRV_KEY: ${{ secrets.HFQL_SSH_PRV_KEY }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | /!.idea/ClojureProjectResolveSettings.xml 3 | /.calva 4 | /.clj-kondo 5 | /.clj-kondo/.cache 6 | /.cpcache 7 | /.idea/* 8 | /.lsp/.cache 9 | /.nrepl-port 10 | /.shadow-cljs 11 | /.vscode 12 | /node_modules 13 | /report.html 14 | /resources/public/js 15 | /target 16 | /yarn-error.log -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "electric"] 2 | path = vendors/electric 3 | url = git@github.com:hyperfiddle/electric.git 4 | -------------------------------------------------------------------------------- /.idea/ClojureProjectResolveSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | IDE 5 | 6 | -------------------------------------------------------------------------------- /.m2/settings.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | cognitect-dev-tools 9 | ${env.DATOMIC_DEV_LOCAL_USER} 10 | ${env.DATOMIC_DEV_LOCAL_PASSWORD} 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.7-stretch AS node-deps 2 | WORKDIR /app 3 | COPY package.json package.json 4 | RUN npm install 5 | 6 | FROM clojure:openjdk-11-tools-deps AS build 7 | WORKDIR /app 8 | COPY --from=node-deps /app/node_modules /app/node_modules 9 | 10 | ARG DATOMIC_DEV_LOCAL_USER 11 | ARG DATOMIC_DEV_LOCAL_PASSWORD 12 | ENV DATOMIC_DEV_LOCAL_USER=$DATOMIC_DEV_LOCAL_USER 13 | ENV DATOMIC_DEV_LOCAL_PASSWORD=$DATOMIC_DEV_LOCAL_PASSWORD 14 | 15 | # Authorize SSH Host 16 | RUN mkdir -p /root/.ssh && \ 17 | chmod 0700 /root/.ssh && \ 18 | ssh-keyscan github.com >> /root/.ssh/known_hosts 19 | 20 | # Create a wrapper for clojure binary 21 | ARG hfql_ssh_prv_key 22 | # Private keys MUST be written to a file this way ("$var") and end with a newline 23 | # If passed as a command arg directly, Github shell will strip newlines and corrupt it. 24 | RUN echo "$hfql_ssh_prv_key" > /root/.ssh/id_rsa 25 | 26 | RUN mkdir -p /usr/local/sbin/ && \ 27 | echo -e '#!/bin/sh \n eval $(ssh-agent -s) && exec /usr/local/bin/clojure "$@"' >> /usr/local/sbin/clojure && \ 28 | chmod +x /usr/local/sbin/clojure 29 | 30 | COPY .m2 /root/.m2 31 | COPY shadow-cljs.edn shadow-cljs.edn 32 | COPY deps.edn deps.edn 33 | COPY src src 34 | COPY src-build src-build 35 | COPY resources resources 36 | ARG REBUILD=unknown 37 | ARG VERSION 38 | RUN clojure -T:build build-client :verbose true :version '"'$VERSION'"' 39 | 40 | ENV VERSION=$VERSION 41 | CMD clj -J-DELECTRIC_USER_VERSION=$VERSION -M -m prod 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Examples app — Electric Clojure (v2) 2 | 3 | Live app: https://electric2.hyperfiddle.net/ 4 | 5 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {com.datomic/dev-local {:mvn/version "1.0.243"} 3 | com.google.guava/guava {:mvn/version "31.1-jre"} ; fix conflict - datomic cloud & shadow 4 | com.hyperfiddle/electric {:mvn/version "v2-alpha-536-g0c582f78"} 5 | com.hyperfiddle/rcf {:mvn/version "20220926-202227"} 6 | datascript/datascript {:mvn/version "1.5.2"} 7 | com.datomic/peer {:mvn/version "1.0.6735"} 8 | info.sunng/ring-jetty9-adapter 9 | {:mvn/version "0.14.3" ; (Jetty 9) is Java 8 compatible; 10 | ;:mvn/version "0.17.7" ; (Jetty 10) is NOT Java 8 compatible 11 | :exclusions [org.slf4j/slf4j-api info.sunng/ring-jetty9-adapter-http3]} ; no need 12 | org.clojure/clojure {:mvn/version "1.12.0-alpha4"} 13 | org.clojure/clojurescript {:mvn/version "1.11.60"} 14 | org.clojure/tools.logging {:mvn/version "1.2.4"} 15 | ch.qos.logback/logback-classic {:mvn/version "1.2.11"} 16 | ring-basic-authentication/ring-basic-authentication {:mvn/version "1.1.1"} 17 | reagent/reagent {:mvn/version "1.1.1"} 18 | markdown-clj/markdown-clj {:mvn/version "1.11.4"} 19 | nextjournal/clojure-mode {:git/url "https://github.com/nextjournal/clojure-mode" 20 | :sha "ac038ebf6e5da09dd2b8a31609e9ff4a65e36852"}} 21 | :aliases {:dev 22 | {:extra-deps 23 | {binaryage/devtools {:mvn/version "1.0.6"} 24 | com.clojure-goes-fast/clj-async-profiler {:mvn/version "1.1.1"} 25 | thheller/shadow-cljs {:mvn/version "2.22.10"}} 26 | :override-deps 27 | {com.hyperfiddle/electric {:local/root "vendors/electric"}} 28 | :jvm-opts 29 | ["-Xss2m" ; https://github.com/hyperfiddle/photon/issues/11 30 | "-XX:-OmitStackTraceInFastThrow" ;; RCF 31 | "-Djdk.attach.allowAttachSelf" 32 | ] 33 | :exec-fn user/main 34 | :exec-args {}} 35 | :build 36 | {:extra-paths ["src-build"] 37 | :ns-default build 38 | :extra-deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"} 39 | io.github.seancorfield/build-clj {:git/tag "v0.8.0" :git/sha "9bd8b8a"}} 40 | :jvm-opts ["-Xss2m"]} 41 | :shadow-cljs {:extra-deps {thheller/shadow-cljs {:mvn/version "2.22.10"}} 42 | :main-opts ["-m" "shadow.cljs.devtools.cli"] 43 | :jvm-opts ["-Xss2m"]} 44 | :hfql {:extra-deps {com.hyperfiddle/hfql {:git/url "git@github.com:hyperfiddle/hfql.git" 45 | :git/sha "39458cc87e3adeb7ed78293198c35d0fdca5d5a4"}}}} 46 | :mvn/repos {"cognitect-dev-tools" {:url "https://dev-tools.cognitect.com/maven/releases/"}}} 47 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for electric-examples-app on 2023-03-31T18:34:16+02:00 2 | 3 | app = "electric-examples-app" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | primary_region = "ewr" 7 | processes = [] 8 | 9 | [env] 10 | 11 | [experimental] 12 | allowed_public_ports = [] 13 | auto_rollback = true 14 | 15 | [[services]] 16 | http_checks = [] 17 | internal_port = 8080 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | [services.concurrency] 22 | hard_limit = 200 23 | soft_limit = 150 24 | type = "connections" 25 | 26 | [[services.ports]] 27 | force_https = true 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "1s" 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@codemirror/commands": "^6.2.2", 4 | "@codemirror/language": "^6.6.0", 5 | "@codemirror/view": "^6.9.3", 6 | "@lezer/markdown": "^1.0.2", 7 | "@nextjournal/lezer-clojure": "^1.0.0", 8 | "lezer-clojure": "0.1.10", 9 | "recharts": "^2.4.1", 10 | "react": "18.2.0", 11 | "react-dom": "18.2.0", 12 | "prop-types": "15.8.1" 13 | }, 14 | "devDependencies": { 15 | "karma": "6.4.0", 16 | "karma-chrome-launcher": "3.1.1", 17 | "karma-cljs-test": "0.1.0", 18 | "puppeteer": "15.2.0", 19 | "shadow-cljs": "^2.20.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/electric-v2-tutorial/fb5df89804ecc2d02916573897bb7ecca23940fe/resources/public/favicon.ico -------------------------------------------------------------------------------- /resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hyperfiddle 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /resources/public/todo-list.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&display=swap'); 2 | 3 | body{ 4 | font-family: 'IBM Plex Sans', sans-serif; 5 | font-weight: 400; 6 | } 7 | 8 | input[type="text"]{ 9 | padding: 0.5rem; 10 | margin-bottom: 1rem; 11 | min-width: 30rem; 12 | border-radius: 0.25rem; 13 | border: 1px gray solid; 14 | } 15 | 16 | input[type="checkbox"]{ 17 | width: 1rem; 18 | height: 1rem; 19 | } 20 | 21 | input[type="checkbox"]:checked + label{ 22 | text-decoration: line-through; 23 | color: gray; 24 | } 25 | 26 | label{ 27 | padding-left: 0.5rem; 28 | } 29 | 30 | label, input[type="checkbox"]{ 31 | cursor:pointer; 32 | transition: 0.4s ease all; 33 | } 34 | 35 | label::first-letter{ 36 | text-transform: capitalize; 37 | } 38 | 39 | .todo-list{ 40 | display:grid; 41 | grid-template-rows: auto fit-content auto; 42 | max-width: 30rem; 43 | } 44 | 45 | .todo-items{ 46 | display:grid; 47 | grid-template-columns: auto 1fr; 48 | grid-gap: 0.5rem 0; 49 | padding: 0 0.25rem; 50 | } 51 | 52 | .counter{ 53 | justify-self: center; 54 | } 55 | 56 | .count{ 57 | font-size: 1.25rem; 58 | } -------------------------------------------------------------------------------- /resources/public/user/demo_stage_ui4.css: -------------------------------------------------------------------------------- 1 | .wip-demo-stage-ui4-staged { 2 | display: block; 3 | width: 100%; 4 | height: 10em; 5 | } -------------------------------------------------------------------------------- /resources/public/user/examples.css: -------------------------------------------------------------------------------- 1 | body.hyperfiddle{ 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; 3 | -webkit-text-size-adjust: none; 4 | text-size-adjust: none; 5 | 6 | display: grid; 7 | grid-template-areas: "title title" 8 | "nav nav" 9 | "lead lead" 10 | "code result" 11 | "readme readme" 12 | "footer-nav footer-nav"; 13 | grid-template-rows: auto 1.5rem auto auto min-content; 14 | grid-template-columns: min-content auto; 15 | gap: 1rem; 16 | margin: 0; 17 | padding: 1rem; 18 | overflow-y: auto; 19 | overflow-x: hidden; 20 | 21 | box-sizing: content-box; 22 | position: relative; 23 | background-color:white; 24 | } 25 | 26 | .hyperfiddle.user-examples-demo { 27 | grid-template-areas: "title" 28 | "nav" 29 | "lead" 30 | "result" 31 | "readme" 32 | "footer-nav"; 33 | grid-template-columns: auto; 34 | 35 | } 36 | 37 | .hyperfiddle.user-examples fieldset{ 38 | background-color: white; 39 | overflow: auto; 40 | min-inline-size: auto; 41 | } 42 | .hyperfiddle.user-examples fieldset legend{ 43 | margin: 0 1rem; 44 | } 45 | 46 | 47 | .user-examples > h1{ 48 | grid-area: title; 49 | white-space: nowrap; 50 | } 51 | 52 | 53 | .user-examples-nav{ 54 | grid-area: nav; 55 | } 56 | 57 | .user-examples-footer-nav{ 58 | grid-area: footer-nav; 59 | padding-bottom: 10vh; 60 | } 61 | 62 | .user-examples-nav, 63 | .user-examples-footer-nav{ 64 | display: grid; 65 | grid-auto-flow: column; 66 | width: auto; 67 | place-items: center; 68 | align-self:center; 69 | justify-self: start; 70 | } 71 | 72 | .user-examples-nav *, 73 | .user-examples-footer-nav *{ 74 | white-space: nowrap; 75 | overflow: hidden; 76 | text-overflow: ellipsis; 77 | max-width: 100%; 78 | } 79 | 80 | .user-examples-select{ 81 | margin: 0 1rem; 82 | display:flex; 83 | align-items: center; 84 | position: relative; 85 | } 86 | 87 | .user-examples-select > select{ 88 | appearance: none; 89 | flex:1; 90 | border: 1px gray solid; 91 | border-radius: 3px; 92 | padding: 0.25rem 3rem 0.25rem 0.5rem; 93 | } 94 | 95 | .user-examples-select > svg{ 96 | width: 1rem; 97 | position: absolute; 98 | right: 0.5rem; 99 | pointer-events:none; 100 | } 101 | 102 | .user-examples-lead { 103 | grid-area: lead; 104 | } 105 | 106 | .user-examples-target{ 107 | grid-area:result; 108 | padding: 1rem; 109 | max-height: 41rem; 110 | } 111 | 112 | .user-examples-code{ 113 | grid-area:code; 114 | padding: 0; 115 | height: fit-content; 116 | max-width: 100%; 117 | max-height: 40rem; 118 | padding-right: 1rem; 119 | } 120 | 121 | .user-examples-readme{ 122 | grid-area:readme; 123 | padding-bottom: 10vh; 124 | line-height: 24px; 125 | max-width: 80ch; 126 | } 127 | 128 | 129 | @media (max-width: 980px) and (hover: none) and (pointer: coarse) { 130 | .user-examples-select > select{ 131 | padding: 0.5rem 3rem 0.5rem 1rem; 132 | } 133 | .user-examples-select optgroup, .user-examples-select option{ 134 | font-size: 1rem; 135 | } 136 | .user-examples-code{ 137 | overflow: scroll; 138 | margin:0; 139 | } 140 | .user-examples-target.SystemProperties input{ 141 | font-size: 1em; 142 | } 143 | 144 | .user-examples-target.SystemProperties td{ 145 | white-space: nowrap; 146 | } 147 | 148 | } 149 | 150 | @media (max-width: 980px){ 151 | body.hyperfiddle{ 152 | display: flex; 153 | flex-direction: column; 154 | grid-template-areas: "title" "nav" "lead" "result" "code" "readme" "footer-nav"; 155 | grid-template-rows: auto auto auto min-content auto minmax(20rem, 1fr) 156 | grid-template-columns: 100%; 157 | } 158 | 159 | .user-examples-code{ 160 | max-height: initial; 161 | } 162 | 163 | .user-examples-target{ 164 | padding: 1rem; 165 | overflow:auto; 166 | max-height: 50vh; 167 | } 168 | 169 | .user-examples-nav, 170 | .user-examples-footer-nav{ 171 | grid-template-areas: "select select" "prev next"; 172 | gap: 1rem; 173 | } 174 | .user-examples-nav-start{ 175 | grid-template-areas: "select next"; 176 | } 177 | 178 | .user-examples-nav-end{ 179 | grid-template-areas: "prev select"; 180 | } 181 | 182 | .user-examples-nav .user-examples-nav-prev { 183 | grid-area: prev; 184 | } 185 | .user-examples-nav .user-examples-nav-next { 186 | grid-area: next; 187 | } 188 | 189 | .user-examples-select{ 190 | grid-area: select; 191 | } 192 | 193 | .user-examples-select > select{ 194 | font-size: 1em; 195 | padding: 0.25em 3em 0.25rem 0.5rem; 196 | } 197 | .user-examples-select > svg{ 198 | width: 1em; 199 | right: 1em; 200 | } 201 | } 202 | 203 | @media (orientation: landscape) and (hover: none) and (pointer: coarse) { 204 | body.hyperfiddle{ 205 | font-size: 16px; 206 | grid-template-areas: "title" "nav" "lead" "result" "code" "readme" "footer-nav"; 207 | grid-template-rows: auto auto auto min-content min-content auto; 208 | grid-template-columns: 100%; 209 | line-height: initial; 210 | } 211 | 212 | body.hyperfiddle h1 { 213 | margin:0; 214 | } 215 | 216 | .user-examples-select{ 217 | font-size: 1rem; 218 | } 219 | 220 | .user-examples-code{ 221 | font-size: 1rem; 222 | } 223 | .user-examples-target{ 224 | max-height: 13rem; 225 | } 226 | 227 | } 228 | 229 | .user-examples-target.SystemProperties input{ 230 | margin: 0.25em 0 1rem 0; 231 | padding: 0.25em; 232 | } 233 | 234 | 235 | .user-examples-target.SystemProperties table{ 236 | max-width: 100%; 237 | width: 100%; 238 | } 239 | 240 | .user-examples-target.SystemProperties td{ 241 | white-space: nowrap; 242 | text-overflow: ellipsis; 243 | overflow: hidden; 244 | } 245 | 246 | @media (min-width: 981px) and (max-width: 1200px) {} 247 | @media (min-width: 1201px) {} 248 | 249 | 250 | .user-examples-target.Webview-HFQL{ 251 | padding:0; 252 | } 253 | 254 | .user-examples-target.Webview-HFQL .wip\.teeshirt-orders\/orders.needle, 255 | .user-examples-target.Webview-HFQL .wip\.orders-datascript\/orders.needle{ 256 | width: 9rem; 257 | padding: 0 0.5em; 258 | } 259 | 260 | .user-examples-target.Webview-HFQL label:has(+ .wip\.orders-datascript\/orders.needle) { 261 | position:relative; 262 | } 263 | .user-examples-target.Webview-HFQL label:has(+ .wip\.orders-datascript\/orders.needle):after { 264 | content: "🔎"; 265 | position: absolute; 266 | right: calc(-100% + 0.25em); 267 | pointer-events: none; 268 | filter: grayscale(100%) opacity(75%); 269 | } 270 | 271 | .user-examples-target.Webview-HFQL .hyperfiddle-gridsheet-wrapper{ 272 | min-width: 100%; 273 | width: fit-content; 274 | height: 100%; 275 | margin: 0; 276 | box-sizing: border-box; 277 | border: 0; 278 | } 279 | 280 | .user-examples-target.Webview-HFQL .hyperfiddle-gridsheet{ 281 | grid-template-columns: 9rem min-content repeat(3, max-content); 282 | white-space: nowrap; 283 | word-break : keep-all; 284 | line-height: initial; 285 | min-width: 100%; 286 | } 287 | 288 | 289 | .user-examples-target.DirectoryExplorer{ 290 | font-size: initial; 291 | line-height: initial; 292 | max-height: 90vh; 293 | } 294 | 295 | .user-examples-target.Chat input, 296 | .user-examples-target.ChatExtended input, 297 | .user-examples-target.Webview input, 298 | .user-examples-target.TodoList input 299 | { 300 | font-size: 1em; 301 | padding: 0.25em 0.5em; 302 | } 303 | 304 | .user-examples-target.Webview input{ 305 | margin-bottom: 1em; 306 | } 307 | 308 | .user-examples-target.Lifecycle{ 309 | height: 9rem; 310 | } 311 | 312 | 313 | .user-examples-target.TodoList .todo-items{ 314 | list-style-type: none; 315 | margin: 0; 316 | padding: 0; 317 | } 318 | 319 | .user-examples-target.TodoList .todo-items > li { 320 | margin: 0.25em 0; 321 | } 322 | .user-examples-target.TodoList .todo-items > li > input[type=checkbox]{ 323 | margin-right: 0.5em; 324 | } 325 | 326 | 327 | @media (max-width: 980px){ 328 | .user-examples-target.TodoList .todo-items { 329 | padding-top: 0.5em; 330 | } 331 | .user-examples-target.TodoList .todo-items > li { 332 | display: flex; 333 | align-items: center; 334 | margin: 0.5em 0; 335 | } 336 | .user-examples-target.TodoList .todo-items > li > input[type=checkbox]{ 337 | width: 1.5em; 338 | height: 1.5em; 339 | } 340 | } 341 | 342 | .user-examples-target.ReagentInterop{ 343 | max-height: initial; 344 | height: min-content; 345 | padding: 0; 346 | margin: 0; 347 | } 348 | 349 | .user-examples-target.CustomTypes{ 350 | white-space: nowrap; 351 | } 352 | 353 | 354 | .user-examples-target.TodoMVC{ 355 | padding: 0.5em; 356 | } 357 | .user-examples-target.TodoMVC .todomvc, 358 | .user-examples-target.TodoMVC-composed .todomvc{ 359 | max-width: 40em; 360 | } 361 | 362 | .user-examples-target.TodoMVC-composed .todomvc > div{ 363 | min-width: 40em; 364 | } 365 | 366 | @media (max-width: 980px){ 367 | .user-examples-target.TodoMVC .todomvc, 368 | .user-examples-target.TodoMVC-composed .todomvc{ 369 | margin: auto; 370 | } 371 | 372 | .user-examples-target.TodoMVC-composed .todomvc > div{ 373 | min-width: minmax(40em,100vw); 374 | } 375 | 376 | } 377 | 378 | .user-examples-target.TodoMVC-composed { 379 | min-height: 70vh; 380 | height: 100vh; 381 | } 382 | 383 | .user-examples-target.CrudForm .wip-demo-stage-ui4-staged{ 384 | width: 100%; 385 | } 386 | 387 | 388 | .user-examples-target.QRCode { 389 | max-height: 250px; 390 | height: 250px; 391 | } 392 | 393 | .user-examples-target.QRCode input{ 394 | margin-bottom: 1rem; 395 | } 396 | -------------------------------------------------------------------------------- /resources/public/user/gridsheet-optional.css: -------------------------------------------------------------------------------- 1 | /* Cosmetic styles for the gridsheet demos; doesn't impact the scroll */ 2 | 3 | .hyperfiddle .user-gridsheet-demo > [role=grid] > [role=columnheader] { 4 | white-space: nowrap; 5 | overflow-x: clip; 6 | text-overflow: ellipsis; 7 | } 8 | 9 | .hyperfiddle .user-gridsheet-demo > [role=grid] > [role=group] > [role=gridcell] { 10 | white-space: nowrap; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | } 14 | 15 | .hyperfiddle .user-gridsheet-demo > [role=grid] > [role=group]:nth-child(even) > div { 16 | background-color: #f2f2f2; 17 | } 18 | 19 | .hyperfiddle .user-gridsheet-demo > [role=grid] > [role=group]:hover > div { 20 | background-color: #ddd; 21 | } 22 | -------------------------------------------------------------------------------- /shadow-cljs.edn: -------------------------------------------------------------------------------- 1 | {:nrepl {:port 9001} 2 | :npm-deps {:install false} 3 | :builds 4 | {:dev 5 | {:target :browser 6 | :devtools {:watch-dir "resources/public" ; live reload CSS 7 | :hud #{:errors :progress} 8 | :ignore-warnings true} ; warnings don't prevent hot-reload 9 | :output-dir "resources/public/js" 10 | :asset-path "/js" 11 | :modules {:main {:entries [user] 12 | :init-fn user/start!}} 13 | :build-hooks [(hyperfiddle.electric.shadow-cljs.hooks/reload-clj) 14 | (shadow.cljs.build-report/hook {:output-to "target/build_report.html"}) 15 | (user/rcf-shadow-hook)]} 16 | :prod 17 | {:target :browser 18 | :output-dir "resources/public/js" 19 | :asset-path "/js" 20 | :module-hash-names true 21 | :modules {:main {:entries [user] :init-fn user/start!}}}}} 22 | -------------------------------------------------------------------------------- /src-build/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | "build electric.jar library artifact and demos" 3 | (:require [clojure.tools.build.api :as b] 4 | [org.corfield.build :as bb] 5 | [clojure.java.shell :as sh])) 6 | 7 | (def lib 'com.hyperfiddle/electric) 8 | (def version (b/git-process {:git-args "describe --tags --long --always --dirty"})) 9 | (def basis (b/create-basis {:project "deps.edn"})) 10 | 11 | (defn clean [opts] 12 | (bb/clean opts)) 13 | 14 | (def class-dir "target/classes") 15 | (defn default-jar-name [{:keys [version] :or {version version}}] 16 | (format "target/%s-%s-standalone.jar" (name lib) version)) 17 | 18 | (defn clean-cljs [_] 19 | (b/delete {:path "resources/public/js"})) 20 | 21 | (defn build-client [{:keys [optimize debug verbose version] 22 | :or {optimize true, debug false, verbose false, version version}}] 23 | (println "Building client. Version:" version) 24 | ; this command must run the same on: local machine, docker build, github actions. 25 | ; shell out to let Shadow manage the classpath, it's not obvious how to set that up. 26 | ; This is the only stable way we know to do it. 27 | (let [command (->> ["clj" "-J-Xss4M" "-M:shadow-cljs" "release" "prod" 28 | (when debug "--debug") 29 | (when verbose "--verbose") 30 | "--config-merge" 31 | (pr-str {:compiler-options {:optimizations (if optimize :advanced :simple)} 32 | :closure-defines {'hyperfiddle.electric-client/ELECTRIC_USER_VERSION version}})] 33 | (remove nil?))] 34 | (apply println "Running:" command) 35 | (let [{:keys [exit out err]} (apply sh/sh command)] 36 | (when err (println err)) 37 | (when out (println out)) 38 | (when-not (zero? exit) 39 | (println "Exit code" exit) 40 | (System/exit exit))))) 41 | 42 | (defn uberjar [{:keys [jar-name version optimize debug verbose] 43 | :or {version version, optimize true, debug false, verbose false}}] 44 | (println "Cleaning up before build") 45 | (clean nil) 46 | 47 | (println "Cleaning cljs compiler output") 48 | (clean-cljs nil) 49 | 50 | (build-client {:optimize optimize, :debug debug, :verbose verbose, :version version}) 51 | 52 | (println "Bundling sources") 53 | (b/copy-dir {:src-dirs ["src" "resources"] 54 | :target-dir class-dir}) 55 | 56 | (println "Compiling server. Version:" version) 57 | (b/compile-clj {:basis basis 58 | :src-dirs ["src"] 59 | :ns-compile '[prod] 60 | :class-dir class-dir}) 61 | 62 | (println "Building uberjar") 63 | (b/uber {:class-dir class-dir 64 | :uber-file (str (or jar-name (default-jar-name {:version version}))) 65 | :basis basis 66 | :main 'prod})) 67 | 68 | (defn noop [_]) ; run to preload mvn deps -------------------------------------------------------------------------------- /src-build/build.md: -------------------------------------------------------------------------------- 1 | # Continuous deployment of demos 2 | 3 | ```shell 4 | clojure -T:build build-client # optimized release build 5 | HYPERFIDDLE_ELECTRIC_APP_VERSION=`git describe --tags --long --always --dirty` 6 | docker build --progress=plain --build-arg VERSION=$HYPERFIDDLE_ELECTRIC_APP_VERSION -t electric-examples-app:latest . 7 | docker run --rm -p 7070:8080 electric-examples-app:latest 8 | # flyctl launch ... ? create fly app, generate fly.toml, see dashboard 9 | # https://fly.io/apps/electric-starter-app 10 | 11 | NO_COLOR=1 flyctl deploy --build-arg VERSION=$HYPERFIDDLE_ELECTRIC_APP_VERSION 12 | # https://electric-starter-app.fly.dev/ 13 | ``` 14 | 15 | - `NO_COLOR=1` disables docker-cli fancy shell GUI, so that we see the full log (not paginated) in case of exception 16 | - `--build-only` tests the build on fly.io without deploying 17 | -------------------------------------------------------------------------------- /src/contrib/datafy_fs.clj: -------------------------------------------------------------------------------- 1 | (ns contrib.datafy-fs 2 | "nav implementation for java file system traversals" 3 | (:require [clojure.core.protocols :as ccp :refer [nav]] 4 | [clojure.datafy :refer [datafy]] 5 | [clojure.spec.alpha :as s] 6 | [hyperfiddle.rcf :refer [tests]]) 7 | (:import [java.nio.file Path Paths Files] 8 | java.io.File 9 | java.nio.file.LinkOption 10 | [java.nio.file.attribute BasicFileAttributes FileTime])) 11 | 12 | ; spec the data, not the object 13 | (s/def ::name string?) 14 | (s/def ::absolute-path string?) 15 | (s/def ::modified inst?) 16 | (s/def ::created inst?) 17 | (s/def ::accessed inst?) 18 | (s/def ::size string?) 19 | ;; (s/def ::kind (s/nilable qualified-keyword?)) 20 | (s/def ::kind qualified-keyword?) ;; HACK FIXME implement nilable in hyperfiddle.spec 21 | (s/def ::file (s/keys :opt [::name ::absolute-path ::modified ::created ::accessed ::size ::kind])) 22 | (s/def ::children (s/coll-of ::file)) 23 | 24 | (defn get-extension [?path] 25 | (when ?path 26 | (when-not (= \. (first ?path)) ; hidden 27 | (some-> (last (re-find #"(\.[a-zA-Z0-9]+)$" ?path)) 28 | (subs 1))))) 29 | 30 | (tests 31 | "get-extension" 32 | (get-extension nil) := nil 33 | (get-extension "") := nil 34 | (get-extension ".") := nil 35 | (get-extension "..") := nil 36 | (get-extension "image") := nil 37 | (get-extension "image.") := nil 38 | (get-extension "image..") := nil 39 | (get-extension "image.png") := "png" 40 | (get-extension "image.blah.png") := "png" 41 | (get-extension "image.blah..png") := "png" 42 | (get-extension ".gitignore") := nil) 43 | 44 | (comment 45 | "java.io.File interop" 46 | (def h (clojure.java.io/file "node_modules/")) 47 | 48 | (sort (.listFiles h)) 49 | 50 | (.getName h) := "src" 51 | (.getPath h) := "src" 52 | (.isDirectory h) := true 53 | (.isFile h) := false 54 | ;(.getParent h) := nil -- ?? 55 | ;(.getParentFile h) := nil -- ?? 56 | (-> (datafy java.io.File) :members keys) 57 | (->> (seq (.listFiles h)) (take 1) first datafy) 58 | (for [x (take 5 (.listFiles h))] (.getName x))) 59 | 60 | (defn file-path "get java.nio.file.Path of j.n.f.File" 61 | [^File f] (-> f .getAbsolutePath (java.nio.file.Paths/get (make-array String 0)))) 62 | 63 | (tests 64 | (def p (file-path (clojure.java.io/file "src"))) 65 | (instance? Path p) := true 66 | (-> (datafy Path) :members keys) 67 | (-> p .getRoot str) := "/" 68 | (-> p .getFileName str) := "src" 69 | (-> p .getParent .getFileName str) := "electric" 70 | (-> p .getParent .toFile .getName) := "electric" 71 | #_(-> p .getParent .toFile datafy)) 72 | 73 | (defn path-attrs [^Path p] 74 | (Files/readAttributes p BasicFileAttributes (make-array java.nio.file.LinkOption 0))) 75 | 76 | (tests 77 | (def attrs (path-attrs (file-path (clojure.java.io/file "src")))) 78 | (instance? BasicFileAttributes attrs) := true 79 | (.isDirectory attrs) := true 80 | (.isSymbolicLink attrs) := false 81 | (.isRegularFile attrs) := false 82 | (.isOther attrs) := false) 83 | 84 | (defn file-attrs [^File f] (path-attrs (file-path f))) 85 | 86 | (tests 87 | (file-attrs (clojure.java.io/file "src")) 88 | ) 89 | 90 | (def ... `...) ; define a value for easy test assertions 91 | 92 | (extend-protocol ccp/Datafiable 93 | java.nio.file.attribute.FileTime 94 | (datafy [o] (-> o .toInstant java.util.Date/from))) 95 | 96 | (extend-protocol ccp/Datafiable 97 | java.io.File 98 | (datafy [^File f] 99 | ; represent object's top layer as EDN-ready value records, for display 100 | ; datafy is partial display view of an object as value records 101 | ; nav is ability to resolve back to the underlying object pointers 102 | ; they compose to navigate display views of objects like a link 103 | (let [attrs (file-attrs f) 104 | n (.getName f)] 105 | (as-> {::name n 106 | ::kind (cond (.isDirectory attrs) ::dir 107 | (.isSymbolicLink attrs) ::symlink 108 | (.isOther attrs) ::other 109 | (.isRegularFile attrs) (if-let [s (get-extension n)] 110 | (keyword (namespace ::foo) s) 111 | ::unknown-kind) 112 | () ::unknown-kind) 113 | ::absolute-path (-> f .getAbsolutePath) 114 | ::created (-> attrs .creationTime .toInstant java.util.Date/from) 115 | ::accessed (-> attrs .lastAccessTime .toInstant java.util.Date/from) 116 | ::modified (-> attrs .lastModifiedTime .toInstant java.util.Date/from) 117 | ::size (.size attrs)} % 118 | (merge % (if (= ::dir (::kind %)) 119 | {::children (lazy-seq (sort (.listFiles f))) 120 | ::parent `...})) 121 | (with-meta % {`ccp/nav 122 | (fn [xs k v] 123 | (case k 124 | ; reverse data back to object, to be datafied again by caller 125 | ::modified (.lastModifiedTime attrs) 126 | ::created (.creationTime attrs) 127 | ::accessed (.lastAccessTime attrs) 128 | ::children (some-> v vec) 129 | ::parent (-> f file-path .getParent .toFile) 130 | v))}))))) 131 | 132 | (tests 133 | ; careful, calling seq loses metas on the underlying 134 | (def h (clojure.java.io/file "src/")) 135 | (type h) := java.io.File 136 | "(datafy file) returns an EDN-ready data view that is one layer deep" 137 | (datafy h) 138 | := #:user.datafy-fs{:name "src", 139 | :absolute-path _, 140 | :size _, 141 | :modified _, 142 | :created _, 143 | :accessed _, 144 | :kind ::dir, 145 | :children _ 146 | :parent ...}) 147 | 148 | (tests 149 | "datafy of a directory includes a Clojure coll of children, but child elements are native file 150 | objects" 151 | (as-> (datafy h) % 152 | (nav % ::children (::children %)) 153 | (datafy %) 154 | (take 2 (map type %))) 155 | := [java.io.File java.io.File] 156 | 157 | "nav to a leaf returns the native object" 158 | (as-> (datafy h) % 159 | (nav % ::modified (::modified %))) 160 | (type *1) := java.nio.file.attribute.FileTime 161 | 162 | "datafy again to get the plain value" 163 | (type (datafy *2)) := java.util.Date) 164 | 165 | (tests 166 | (as-> (datafy h) % 167 | (nav % ::children (::children %)) 168 | (datafy %) ; can skip - simple data 169 | (map datafy %) 170 | (vec (filter #(= (::name %) "hyperfiddle") %)) ; stabilize test 171 | (nav % 0 (% 0)) 172 | (datafy %) 173 | #_(s/conform ::file %)) 174 | := #:user.datafy-fs{:name "hyperfiddle", 175 | :absolute-path _, 176 | :size _, 177 | :modified _, 178 | :created _, 179 | :accessed _, 180 | :kind ::dir, 181 | :children _ 182 | :parent ...}) 183 | 184 | (tests 185 | "nav into children and back up via parent ref" 186 | (def m (datafy h)) 187 | (::name m) := "src" 188 | (as-> m % 189 | (nav % ::children (::children %)) 190 | (datafy %) ; dir 191 | (nav % 0 (get % 0)) ; first file in dir 192 | (datafy %) 193 | (nav % ::parent (::parent %)) ; dir (skip level on way up) 194 | (datafy %) 195 | (::name %)) 196 | := "src") 197 | 198 | (defn absolute-path [^String path-str & more] 199 | (str (.toAbsolutePath (java.nio.file.Path/of ^String path-str (into-array String more))))) 200 | 201 | (comment 202 | (absolute-path "./") 203 | (absolute-path "node_modules") 204 | (clojure.java.io/file (absolute-path "./")) 205 | (clojure.java.io/file (absolute-path "node_modules"))) 206 | 207 | (s/fdef list-files :args (s/cat :file any?) :ret (s/coll-of any?)) 208 | (defn list-files [^String path-str] 209 | (try (let [m (datafy (clojure.java.io/file path-str))] 210 | (nav m ::children (::children m))) 211 | (catch java.nio.file.NoSuchFileException _))) 212 | 213 | (comment 214 | (list-files (absolute-path "./")) 215 | (list-files (absolute-path "node_modules"))) -------------------------------------------------------------------------------- /src/dev.cljc: -------------------------------------------------------------------------------- 1 | (ns dev 2 | (:require 3 | [datascript.core :as d] 4 | [hyperfiddle.api :as hf] 5 | [hyperfiddle.rcf :refer [tests % tap]])) 6 | 7 | 8 | (defn fixtures [$] 9 | ; portable 10 | (-> $ 11 | (d/with [{:db/id 1 :order/type :order/gender :db/ident :order/male} 12 | {:db/id 2 :order/type :order/gender :db/ident :order/female}]) 13 | :db-after 14 | (d/with [{:db/id 3 :order/type :order/shirt-size :db/ident :order/mens-small :order/gender :order/male} 15 | {:db/id 4 :order/type :order/shirt-size :db/ident :order/mens-medium :order/gender :order/male} 16 | {:db/id 5 :order/type :order/shirt-size :db/ident :order/mens-large :order/gender :order/male} 17 | {:db/id 6 :order/type :order/shirt-size :db/ident :order/womens-small :order/gender :order/female} 18 | {:db/id 7 :order/type :order/shirt-size :db/ident :order/womens-medium :order/gender :order/female} 19 | {:db/id 8 :order/type :order/shirt-size :db/ident :order/womens-large :order/gender :order/female}]) 20 | :db-after 21 | (d/with [{:db/id 9 :order/email "alice@example.com" :order/gender :order/female :order/shirt-size :order/womens-large 22 | :order/tags [:a :b :c]} 23 | {:db/id 10 :order/email "bob@example.com" :order/gender :order/male :order/shirt-size :order/mens-large 24 | :order/tags [:b]} 25 | {:db/id 11 :order/email "charlie@example.com" :order/gender :order/male :order/shirt-size :order/mens-medium}]) 26 | :db-after 27 | #_(d/with [{:db/id 12 :order/email "alice@example.com" :order/gender :order/female :order/shirt-size :order/womens-large} 28 | {:order/email "bob@example.com" :order/gender :order/male :order/shirt-size :order/mens-large} 29 | {:order/email "charlie@example.com" :order/gender :order/male :order/shirt-size :order/mens-medium}]) 30 | 31 | #_:db-after)) 32 | 33 | ;(defn init-datomic [] 34 | ; (def schema [{:db/ident :order/email :db/valueType :db.type/string :db/cardinality :db.cardinality/one :db/unique :db.unique/identity} 35 | ; {:db/ident :order/gender :db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 36 | ; {:db/ident :order/shirt-size :db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 37 | ; {:db/ident :order/type :db/valueType :db.type/keyword :db/cardinality :db.cardinality/one} 38 | ; {:db/ident :order/tags :db/valueType :db.type/keyword :db/cardinality :db.cardinality/many}]) 39 | ; (d/create-database "datomic:mem://hello-world") 40 | ; (def ^:dynamic *$* (-> (d/connect "datomic:mem://hello-world") d/db (d/with schema) :db-after fixtures)) 41 | ; #_(alter-var-root #'*$* (constantly $))) 42 | 43 | (def schema 44 | ;; manual db/ids for tests consistency and clarity, not a requirement 45 | [{:db/id 100001 :db/ident :order/email, :db/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/unique :db.unique/identity} 46 | {:db/id 100002 :db/ident :order/gender, :db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 47 | {:db/id 100003 :db/ident :order/shirt-size, :db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 48 | {:db/id 100004 :db/ident :order/type, :db/valueType :db.type/keyword :db/cardinality :db.cardinality/one} 49 | {:db/id 100005 :db/ident :order/tags, :db/valueType :db.type/keyword :db/cardinality :db.cardinality/many}]) 50 | 51 | (def db-config {:store {:backend :mem, :id "default"}}) 52 | 53 | (defn setup-db! [] 54 | ;; FIXME Datascript doesn’t support :db/valueType, using :hf/valueType in the meantime 55 | (let [-schema {:order/email {:hf/valueType :db.type/string :db/cardinality :db.cardinality/one :db/unique :db.unique/identity} 56 | :order/gender {:db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 57 | :order/shirt-size {:db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 58 | :order/type {#_#_:db/valueType :db.type/keyword :db/cardinality :db.cardinality/one} 59 | :order/tags {#_#_:db/valueType :db.type/keyword :db/cardinality :db.cardinality/many} 60 | :db/ident {:db/unique :db.unique/identity, :hf/valueType :db.type/keyword}}] 61 | #?(:clj (alter-var-root #'schema (constantly -schema)) 62 | :cljs (set! schema -schema))) 63 | ;(log/info "Initializing Test Database") 64 | (def conn (d/create-conn schema)) 65 | (let [$ (-> conn d/db fixtures)] 66 | #?(:clj (alter-var-root #'hf/*$* (constantly $)) 67 | :cljs (set! hf/*$* $)))) 68 | 69 | #?(:clj (setup-db!)) 70 | 71 | (def db hf/*$*) ; for @(requiring-resolve 'dev/db) 72 | 73 | (def male 1 #_:order/male #_17592186045418) 74 | (def female 2 #_:order/female #_17592186045419) 75 | (def m-sm 3 #_17592186045421) 76 | (def m-md 4 #_nil) 77 | (def m-lg 5 #_nil) 78 | (def w-sm 6 #_nil) 79 | (def w-md 7 #_nil) 80 | (def w-lg 8 #_nil) 81 | (def alice 9 #_17592186045428) 82 | (def bob 10 #_nil) 83 | (def charlie 11 #_nil) 84 | 85 | (tests 86 | (def e [:order/email "alice@example.com"]) 87 | 88 | (tests 89 | "(d/pull ['*]) is best for tests" 90 | (d/pull db ['*] e) 91 | := {:db/id 9, 92 | :order/email "alice@example.com", 93 | :order/shirt-size #:db{:id 8}, 94 | :order/gender #:db{:id 2} 95 | :order/tags [:a :b :c]}) 96 | 97 | (comment #_tests 98 | "careful, entity type is not= to equivalent hashmap" 99 | (d/touch (d/entity db e)) 100 | ; expected failure 101 | := {:order/email "alice@example.com", 102 | :order/gender #:db{:id 2}, 103 | :order/shirt-size #:db{:id 8}, 104 | :order/tags #{:c :b :a}, 105 | :db/id 9}) 106 | 107 | (tests 108 | "entities are not maps" 109 | (type (d/touch (d/entity db e))) 110 | (type *1) := datascript.impl.entity.Entity) ; not a map 111 | 112 | (tests 113 | "careful, entity API tests are fragile and (into {}) is insufficient" 114 | (->> (d/touch (d/entity db e)) ; touch is the best way to inspect an entity 115 | (into {})) ; but it's hard to convert to a map... 116 | := #:order{#_#_:id 9 ; db/id is not present! 117 | :email "alice@example.com", 118 | :gender _ #_#:db{:id 2}, ; entity ref not = 119 | :shirt-size _ #_#:db{:id 8}, ; entity ref not = 120 | :tags #{:c :b :a}} 121 | 122 | "select keys doesn't fix the problem as it's not recursive" 123 | (-> (d/touch (d/entity db e)) 124 | (select-keys [:order/email :order/shirt-size :order/gender])) 125 | := #:order{:email "alice@example.com", 126 | :shirt-size _ #_#:db{:id 8}, ; still awkward, need recursive pull 127 | :gender _ #_#:db{:id 2}}) 128 | 129 | "TLDR is use (d/pull ['*]) like the first example" 130 | (tests 131 | (d/pull db ['*] :order/female) 132 | := {:db/id female :db/ident :order/female :order/type :order/gender}) 133 | 134 | (tests 135 | (d/q '[:find [?e ...] :where [_ :order/gender ?e]] db) 136 | := [2 1] #_[:order/male :order/female]) 137 | ) 138 | 139 | (comment 140 | "CI tests" 141 | #?(:clj (alter-var-root #'hyperfiddle.rcf/*generate-tests* (constantly false))) 142 | (hyperfiddle.rcf/enable!) 143 | (require 'clojure.test) 144 | (clojure.test/run-all-tests #"(hyperfiddle.api|user.orders)")) 145 | 146 | (comment 147 | "Performance profiling, use :profile deps alias" 148 | (require '[clj-async-profiler.core :as prof]) 149 | (prof/serve-files 8082) 150 | ;; Navigate to http://localhost:8082 151 | (prof/start {:framebuf 10000000}) 152 | (prof/stop)) 153 | -------------------------------------------------------------------------------- /src/electric_server_java11_jetty10.clj: -------------------------------------------------------------------------------- 1 | ;; Start from this example if you don’t need Java 8 compat. 2 | ;; See `deps.edn`. 3 | (ns electric-server-java11-jetty10 4 | (:require [clojure.java.io :as io] 5 | [hyperfiddle.electric-jetty-adapter :as adapter] 6 | [clojure.tools.logging :as log] 7 | [ring.adapter.jetty9 :as ring] 8 | [ring.middleware.basic-authentication :as auth] 9 | [ring.middleware.content-type :refer [wrap-content-type]] 10 | [ring.middleware.cookies :as cookies] 11 | [ring.middleware.params :refer [wrap-params]] 12 | [ring.middleware.resource :refer [wrap-resource]] 13 | [ring.util.response :as res] 14 | [clojure.string :as str] 15 | [clojure.edn :as edn]) 16 | (:import [java.io IOException] 17 | [java.net BindException] 18 | [org.eclipse.jetty.server.handler.gzip GzipHandler])) 19 | 20 | (defn authenticate [username password] username) ; demo (accept-all) authentication 21 | 22 | (defn wrap-demo-authentication "A Basic Auth example. Accepts any username/password and store the username in a cookie." 23 | [next-handler] 24 | (-> (fn [ring-req] 25 | (let [res (next-handler ring-req)] 26 | (if-let [username (:basic-authentication ring-req)] 27 | (res/set-cookie res "username" username {:http-only true}) 28 | res))) 29 | (cookies/wrap-cookies) 30 | (auth/wrap-basic-authentication authenticate))) 31 | 32 | (defn wrap-demo-router "A basic path-based routing middleware" 33 | [next-handler] 34 | (fn [ring-req] 35 | (case (:uri ring-req) 36 | "/auth" (let [response ((wrap-demo-authentication next-handler) ring-req)] 37 | (if (= 401 (:status response)) ; authenticated? 38 | response ; send response to trigger auth prompt 39 | (-> (res/status response 302) ; redirect 40 | (res/header "Location" (get-in ring-req [:headers "referer"]))))) ; redirect to where the auth originated 41 | ;; For any other route, delegate to next middleware 42 | (next-handler ring-req)))) 43 | 44 | (defn template "Takes a `string` and a map of key-values `kvs`. Replace all instances of `$key$` by value in `string`" 45 | [string kvs] 46 | (reduce-kv (fn [r k v] (str/replace r (str "$" k "$") v)) string kvs)) 47 | 48 | (defn get-modules [manifest-path] 49 | (when-let [manifest (io/resource manifest-path)] 50 | (let [manifest-folder (when-let [folder-name (second (rseq (str/split manifest-path #"\/")))] 51 | (str "/" folder-name "/"))] 52 | (->> (slurp manifest) 53 | (edn/read-string) 54 | (reduce (fn [r module] (assoc r (keyword "hyperfiddle.client.module" (name (:name module))) (str manifest-folder (:output-name module)))) {}))))) 55 | 56 | (defn wrap-index-page 57 | "Server the `index.html` file with injected javascript modules from `manifest.edn`. `manifest.edn` is generated by the client build and contains javascript modules information." 58 | [next-handler resources-path manifest-path] 59 | (fn [ring-req] 60 | (if-let [response (res/resource-response (str resources-path "/index.html"))] 61 | (if-let [modules (get-modules manifest-path)] 62 | (-> (res/response (template (slurp (:body response)) modules)) ; TODO cache in prod mode 63 | (res/content-type "text/html") ; ensure `index.html` is not cached 64 | (res/header "Cache-Control" "no-store") 65 | (res/header "Last-Modified" (get-in response [:headers "Last-Modified"]))) 66 | ;; No manifest found, can't inject js modules 67 | (-> (res/not-found "Missing client program manifest") 68 | (res/content-type "text/plain"))) 69 | ;; index.html file not found on classpath 70 | (next-handler ring-req)))) 71 | 72 | (def VERSION (not-empty (System/getProperty "ELECTRIC_USER_VERSION"))) ; see Dockerfile 73 | 74 | (defn wrap-reject-stale-client 75 | "Intercept websocket UPGRADE request and check if client and server versions matches. 76 | An electric client is allowed to connect if its version matches the server's version, or if the server doesn't have a version set (dev mode). 77 | Otherwise, the client connection is rejected gracefully." 78 | [next-handler] 79 | (fn [ring-req] 80 | (if (ring/ws-upgrade-request? ring-req) 81 | (let [client-version (get-in ring-req [:query-params "ELECTRIC_USER_VERSION"])] 82 | (cond 83 | (nil? VERSION) (next-handler ring-req) 84 | (= client-version VERSION) (next-handler ring-req) 85 | :else (adapter/reject-websocket-handler 1008 "stale client") ; https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1 86 | )) 87 | (next-handler ring-req)))) 88 | 89 | (defn wrap-electric-websocket [next-handler] 90 | (fn [ring-request] 91 | (if (ring/ws-upgrade-request? ring-request) 92 | (let [authenticated-request (auth/basic-authentication-request ring-request authenticate) ; optional 93 | electric-message-handler (partial adapter/electric-ws-message-handler authenticated-request)] ; takes the ring request as first arg - makes it available to electric program 94 | (ring/ws-upgrade-response (adapter/electric-ws-adapter electric-message-handler))) 95 | (next-handler ring-request)))) 96 | 97 | (defn electric-websocket-middleware [next-handler] 98 | (-> (wrap-electric-websocket next-handler) ; 4. connect electric client 99 | (cookies/wrap-cookies) ; 3. makes cookies available to Electric app 100 | (wrap-reject-stale-client) ; 2. reject stale electric client 101 | (wrap-params) ; 1. parse query params 102 | )) 103 | 104 | (defn not-found-handler [_ring-request] 105 | (-> (res/not-found "Not found") 106 | (res/content-type "text/plain"))) 107 | 108 | (defn http-middleware [resources-path manifest-path] 109 | ;; these compose as functions, so are applied bottom up 110 | (-> not-found-handler 111 | (wrap-index-page resources-path manifest-path) ; 5. otherwise fallback to default page file 112 | (wrap-resource resources-path) ; 4. serve static file from classpath 113 | (wrap-content-type) ; 3. detect content (e.g. for index.html) 114 | (wrap-demo-router) ; 2. route 115 | (electric-websocket-middleware) ; 1. intercept electric websocket 116 | )) 117 | 118 | (defn- add-gzip-handler 119 | "Makes Jetty server compress responses. Optional but recommended." 120 | [server] 121 | (.setHandler server 122 | (doto (GzipHandler.) 123 | #_(.setIncludedMimeTypes (into-array ["text/css" "text/plain" "text/javascript" "application/javascript" "application/json" "image/svg+xml"])) ; only compress these 124 | (.setMinGzipSize 1024) 125 | (.setHandler (.getHandler server))))) 126 | 127 | (defn start-server! [{:keys [port resources-path manifest-path] 128 | :or {port 8080 129 | resources-path "public" 130 | manifest-path "public/js/manifest.edn"} 131 | :as config}] 132 | (try 133 | (let [server (ring/run-jetty (http-middleware resources-path manifest-path) 134 | (merge {:port port 135 | :join? false 136 | :configurator add-gzip-handler} 137 | config)) 138 | final-port (-> server (.getConnectors) first (.getPort))] 139 | (println "\n👉 App server available at" (str "http://" (:host config) ":" final-port "\n")) 140 | server) 141 | 142 | (catch IOException err 143 | (if (instance? BindException (ex-cause err)) ; port is already taken, retry with another one 144 | (do (log/warn "Port" port "was not available, retrying with" (inc port)) 145 | (start-server! (update config :port inc))) 146 | (throw err))))) 147 | 148 | -------------------------------------------------------------------------------- /src/electric_server_java8_jetty9.clj: -------------------------------------------------------------------------------- 1 | ;; Start from this example if you need Java 8 compat. 2 | ;; See `deps.edn` 3 | (ns electric-server-java8-jetty9 4 | (:require [clojure.java.io :as io] 5 | [hyperfiddle.electric-jetty-adapter :as adapter] 6 | [clojure.tools.logging :as log] 7 | [ring.adapter.jetty9 :as ring] 8 | [ring.middleware.basic-authentication :as auth] 9 | [ring.middleware.content-type :refer [wrap-content-type]] 10 | [ring.middleware.cookies :as cookies] 11 | [ring.middleware.params :refer [wrap-params]] 12 | [ring.middleware.resource :refer [wrap-resource]] 13 | [ring.util.response :as res] 14 | [clojure.string :as str] 15 | [clojure.edn :as edn]) 16 | (:import [java.io IOException] 17 | [java.net BindException] 18 | [org.eclipse.jetty.server.handler.gzip GzipHandler])) 19 | 20 | (defn authenticate [username password] username) ; demo (accept-all) authentication 21 | 22 | (defn wrap-demo-authentication "A Basic Auth example. Accepts any username/password and store the username in a cookie." 23 | [next-handler] 24 | (-> (fn [ring-req] 25 | (let [res (next-handler ring-req)] 26 | (if-let [username (:basic-authentication ring-req)] 27 | (res/set-cookie res "username" username {:http-only true}) 28 | res))) 29 | (cookies/wrap-cookies) 30 | (auth/wrap-basic-authentication authenticate))) 31 | 32 | (defn wrap-demo-router "A basic path-based routing middleware" 33 | [next-handler] 34 | (fn [ring-req] 35 | (case (:uri ring-req) 36 | "/auth" (let [response ((wrap-demo-authentication next-handler) ring-req)] 37 | (if (= 401 (:status response)) ; authenticated? 38 | response ; send response to trigger auth prompt 39 | (-> (res/status response 302) ; redirect 40 | (res/header "Location" (get-in ring-req [:headers "referer"]))))) ; redirect to where the auth originated 41 | ;; For any other route, delegate to next middleware 42 | (next-handler ring-req)))) 43 | 44 | (defn template "Takes a `string` and a map of key-values `kvs`. Replace all instances of `$key$` by value in `string`" 45 | [string kvs] 46 | (reduce-kv (fn [r k v] (str/replace r (str "$" k "$") v)) string kvs)) 47 | 48 | (defn get-modules [manifest-path] 49 | (when-let [manifest (io/resource manifest-path)] 50 | (let [manifest-folder (when-let [folder-name (second (rseq (str/split manifest-path #"\/")))] 51 | (str "/" folder-name "/"))] 52 | (->> (slurp manifest) 53 | (edn/read-string) 54 | (reduce (fn [r module] (assoc r (keyword "hyperfiddle.client.module" (name (:name module))) (str manifest-folder (:output-name module)))) {}))))) 55 | 56 | (defn wrap-index-page 57 | "Server the `index.html` file with injected javascript modules from `manifest.edn`. `manifest.edn` is generated by the client build and contains javascript modules information." 58 | [next-handler resources-path manifest-path] 59 | (fn [ring-req] 60 | (if-let [response (res/resource-response (str resources-path "/index.html"))] 61 | (if-let [modules (get-modules manifest-path)] 62 | (-> (res/response (template (slurp (:body response)) modules)) ; TODO cache in prod mode 63 | (res/content-type "text/html") ; ensure `index.html` is not cached 64 | (res/header "Cache-Control" "no-store") 65 | (res/header "Last-Modified" (get-in response [:headers "Last-Modified"]))) 66 | ;; No manifest found, can't inject js modules 67 | (-> (res/not-found "Missing client program manifest") 68 | (res/content-type "text/plain"))) 69 | ;; index.html file not found on classpath 70 | (next-handler ring-req)))) 71 | 72 | (def VERSION (not-empty (System/getProperty "ELECTRIC_USER_VERSION"))) ; see Dockerfile 73 | 74 | (defn wrap-reject-stale-client 75 | "Intercept websocket UPGRADE request and check if client and server versions matches. 76 | An electric client is allowed to connect if its version matches the server's version, or if the server doesn't have a version set (dev mode). 77 | Otherwise, the client connection is rejected gracefully." 78 | [next-handler] 79 | (fn [ring-req] 80 | (let [client-version (get-in ring-req [:query-params "ELECTRIC_USER_VERSION"])] 81 | (cond 82 | (nil? VERSION) (next-handler ring-req) 83 | (= client-version VERSION) (next-handler ring-req) 84 | :else (adapter/reject-websocket-handler 1008 "stale client") ; https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1 85 | )))) 86 | 87 | (def websocket-middleware 88 | (fn [next-handler] 89 | (-> (cookies/wrap-cookies next-handler) ; makes cookies available to Electric app 90 | (wrap-reject-stale-client) 91 | (wrap-params)))) 92 | 93 | (defn not-found-handler [_ring-request] 94 | (-> (res/not-found "Not found") 95 | (res/content-type "text/plain"))) 96 | 97 | (defn http-middleware [resources-path manifest-path] 98 | ;; these compose as functions, so are applied bottom up 99 | (-> not-found-handler 100 | (wrap-index-page resources-path manifest-path) ; 4. otherwise fallback to default page file 101 | (wrap-resource resources-path) ; 3. serve static file from classpath 102 | (wrap-content-type) ; 2. detect content (e.g. for index.html) 103 | (wrap-demo-router) ; 1. route 104 | )) 105 | 106 | (defn- add-gzip-handler 107 | "Makes Jetty server compress responses. Optional but recommended." 108 | [server] 109 | (.setHandler server 110 | (doto (GzipHandler.) 111 | #_(.setIncludedMimeTypes (into-array ["text/css" "text/plain" "text/javascript" "application/javascript" "application/json" "image/svg+xml"])) ; only compress these 112 | (.setMinGzipSize 1024) 113 | (.setHandler (.getHandler server))))) 114 | 115 | (defn start-server! [entrypoint {:keys [port resources-path manifest-path] 116 | :or {port 8080 117 | resources-path "public" 118 | manifest-path "public/js/manifest.edn"} 119 | :as config}] 120 | (try 121 | (let [server (ring/run-jetty (http-middleware resources-path manifest-path) 122 | (merge {:port port 123 | :join? false 124 | :configurator add-gzip-handler 125 | ;; Jetty 9 forces us to declare WS paths out of a ring handler. 126 | ;; For Jetty 10 (NOT Java 8 compatible), drop the following and use `wrap-electric-websocket` as above 127 | :websockets {"/" (websocket-middleware 128 | (fn [ring-req] 129 | (adapter/electric-ws-adapter 130 | (partial adapter/electric-ws-message-handler 131 | (auth/basic-authentication-request ring-req authenticate) 132 | entrypoint))))}} 133 | config)) 134 | final-port (-> server (.getConnectors) first (.getPort))] 135 | (println "\n👉 App server available at" (str "http://" (:host config) ":" final-port "\n")) 136 | server) 137 | 138 | (catch IOException err 139 | (if (instance? BindException (ex-cause err)) ; port is already taken, retry with another one 140 | (do (log/warn "Port" port "was not available, retrying with" (inc port)) 141 | (start-server! entrypoint (update config :port inc))) 142 | (throw err))))) 143 | -------------------------------------------------------------------------------- /src/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %highlight(%-5level) %logger: %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/prod.clj: -------------------------------------------------------------------------------- 1 | (ns prod 2 | (:gen-class) 3 | (:require user-main ; in prod, load app into server so it can accept clients 4 | [hyperfiddle.electric :as e] 5 | electric-server-java8-jetty9)) 6 | 7 | (def electric-server-config 8 | {:host "0.0.0.0", :port 8080, :resources-path "public"}) 9 | 10 | (defn -main [& args] ; run with `clj -M -m prod` 11 | (electric-server-java8-jetty9/start-server! 12 | (fn [ring-req] (e/boot-server {} user-main/Main ring-req)) 13 | electric-server-config)) 14 | 15 | ; On CLJS side we reuse src/user.cljs for prod entrypoint -------------------------------------------------------------------------------- /src/test.clj: -------------------------------------------------------------------------------- 1 | (ns test 2 | (:require [contrib.datomic-contrib :as dx] 3 | [contrib.datomic-m :as d] 4 | [missionary.core :as m] 5 | [hyperfiddle.rcf :refer [tests]] 6 | test.mbrainz 7 | test.seattle 8 | test.person-model)) 9 | 10 | (def ^:dynamic datomic-client) 11 | (def ^:dynamic datomic-conn) 12 | (def ^:dynamic datomic-db) 13 | (def ^:dynamic schema) 14 | (def ^:dynamic fixtures []) ; local datomic tx to rebase onto the db 15 | 16 | (def pour-lamour 87960930235113) 17 | (def cobblestone 536561674378709) 18 | 19 | (def datomic-config {:server-type :dev-local :system "datomic-samples"}) 20 | 21 | (defn install-test-state [] 22 | (alter-var-root #'datomic-client (constantly (d/client datomic-config))) 23 | (assert (some? datomic-client)) 24 | 25 | (alter-var-root #'datomic-conn (constantly (m/? (d/connect datomic-client {:db-name "mbrainz-subset"})))) 26 | (assert (some? datomic-conn)) 27 | 28 | (alter-var-root #'datomic-db (constantly (:db-after (m/? (d/with (m/? (d/with-db datomic-conn)) fixtures))))) 29 | (assert (some? datomic-db)) 30 | 31 | (alter-var-root #'schema (constantly (m/? (dx/schema! datomic-db)))) 32 | (assert (some? schema))) 33 | 34 | (tests 35 | (some? schema) := true 36 | 37 | (m/? (d/pull test/datomic-db {:eid pour-lamour :selector ['*]})) 38 | := {:db/id 87960930235113, 39 | :abstractRelease/gid #uuid"f05a1be3-e383-4cd4-ad2a-150ae118f622", 40 | :abstractRelease/name "Pour l’amour des sous / Parle au patron, ma tête est malade", 41 | :abstractRelease/type #:db{:id 35435060739965075, :ident :release.type/single}, 42 | :abstractRelease/artists [#:db{:id 20512488927800905} 43 | #:db{:id 68459991991856131}], 44 | :abstractRelease/artistCredit "Jean Yanne & Michel Magne"} 45 | 46 | (m/? (d/pull test/datomic-db {:eid cobblestone :selector ['*]})) 47 | := {:db/id 536561674378709, 48 | :label/gid #uuid"066474f9-fad7-48dd-868f-04d8bb0a5253", 49 | :label/name "Cobblestone", 50 | :label/sortName "Cobblestone", 51 | :label/type #:db{:id 44604987715616963, :ident :label.type/originalProduction}, 52 | :label/country #:db{:id 63793664643563930, :ident :country/US}, 53 | :label/startYear 1972} 54 | nil) -------------------------------------------------------------------------------- /src/test/mbrainz.clj: -------------------------------------------------------------------------------- 1 | (ns test.mbrainz) 2 | 3 | ; # Scrap / old stuff 4 | ; 5 | ;* do NOT use mbrainz-1968-1973 (https://github.com/Datomic/mbrainz-importer - don't use this one, too big 6 | ; mbrainz-1968-1973 instructions (don't do this): 7 | ;3. Clone this repo: 8 | ;4. `cd` into it 9 | ;5. create a file `manifest.edn` with this content: 10 | ;```clojure 11 | ;{:client-cfg {:server-type :dev-local 12 | ; :system "datomic-samples"} 13 | ; :db-name "mbrainz-1968-1973" 14 | ; :basedir "subsets" 15 | ; :concurrency 3} 16 | ;``` 17 | ;4. In `deps.edn`, set `com.datomic/dev-local` version to `"1.0.243"` 18 | ;5. Run `clojure -M -m datomic.mbrainz.importer manifest.edn` -------------------------------------------------------------------------------- /src/test/person_model.clj: -------------------------------------------------------------------------------- 1 | (ns test.person-model 2 | (:require [contrib.data :refer [index-by]])) 3 | 4 | (def schema 5 | (->> [{:db/ident :person/name, :db/valueType {:db/ident :db.type/string}, :db/cardinality {:db/ident :db.cardinality/one} :db/unique :db.unique/identity} 6 | {:db/ident :person/liked-tags, :db/valueType {:db/ident :db.type/keyword}, :db/cardinality {:db/ident :db.cardinality/many}} 7 | {:db/ident :employee/manager, :db/valueType {:db/ident :db.type/ref}, :db/cardinality {:db/ident :db.cardinality/one}} 8 | {:db/ident :person/siblings, :db/valueType {:db/ident :db.type/ref}, :db/cardinality {:db/ident :db.cardinality/many}} 9 | {:db/ident :person/address, :db/valueType {:db/ident :db.type/ref}, :db/cardinality {:db/ident :db.cardinality/one}, :db/isComponent true} 10 | {:db/ident :person/summerHomes, :db/valueType {:db/ident :db.type/ref}, :db/cardinality {:db/ident :db.cardinality/many}, :db/isComponent true} 11 | {:db/ident :address/zip, :db/valueType {:db/ident :db.type/string}, :db/cardinality {:db/ident :db.cardinality/one}} 12 | {:db/ident :person/age, :db/valueType {:db/ident :db.type/long}, :db/cardinality {:db/ident :db.cardinality/one}} 13 | {:db/ident :person/bestFriend, :db/valueType {:db/ident :db.type/ref}, :db/cardinality {:db/ident :db.cardinality/one}} 14 | {:db/ident :person/friends, :db/valueType {:db/ident :db.type/ref}, :db/cardinality {:db/ident :db.cardinality/many}}] 15 | (index-by :db/ident))) 16 | 17 | (def bob-map-stmt "covers all combinations" 18 | {:person/name "Bob" ; scalar one 19 | :person/address {:address/zip "12345"} ; ref one component 20 | :person/summerHomes [{:address/zip "11111"} ; ref many component 21 | {:address/zip "22222"}] 22 | :person/liked-tags [:movies :ice-cream :clojure] ; scalar many 23 | :employee/manager {:person/name "Earnest"} ; ref one -> unique 24 | :person/siblings [{:person/name "Cindy"} ; ref many -> unique 25 | {:person/name "David"}] 26 | :person/bestFriend "Benjamin" 27 | :person/friends ["Harry", "Yennefer"]}) 28 | 29 | (def bob-txn [bob-map-stmt 30 | [:db/cas 1 :person/age 41 42] 31 | [:user.fn/foo 'x 'y 'z 'q 'r]]) 32 | 33 | -------------------------------------------------------------------------------- /src/test/seattle.clj: -------------------------------------------------------------------------------- 1 | (ns test.seattle 2 | (:require [contrib.data :refer [index-by]])) 3 | 4 | (def schema 5 | (->> [{:db/ident :community/name, :db/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/fulltext true, :db/doc "A community's name"} 6 | {:db/ident :community/url, :db/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/doc "A community's url"} 7 | {:db/ident :community/neighborhood, :db/valueType :db.type/ref, :db/cardinality :db.cardinality/one, :db/doc "A community's neighborhood"} 8 | {:db/ident :community/category, :db/valueType :db.type/string, :db/cardinality :db.cardinality/many, :db/fulltext true, :db/doc "All community categories"} 9 | {:db/ident :community/orgtype, :db/valueType :db.type/ref, :db/cardinality :db.cardinality/one, :db/doc "A community orgtype enum value"} 10 | {:db/ident :community/type, :db/valueType :db.type/ref, :db/cardinality :db.cardinality/many, :db/doc "Community type enum values"} 11 | {:db/ident :community/board :db/valueType :db.type/ref :db/cardinality :db.cardinality/many :db/doc "People who sit on the community board"} 12 | {:db/ident :person/name :db/valueType :db.type/string :db/cardinality :db.cardinality/one :db/doc "A person's full name"} 13 | {:db/ident :neighborhood/name, :db/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/unique :db.unique/identity, :db/doc "A unique neighborhood name (upsertable)"} 14 | {:db/ident :neighborhood/district, :db/valueType :db.type/ref, :db/cardinality :db.cardinality/one, :db/doc "A neighborhood's district"} 15 | {:db/ident :district/name, :db/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/unique :db.unique/identity, :db/doc "A unique district name (upsertable)"} 16 | {:db/ident :district/region, :db/valueType :db.type/ref, :db/cardinality :db.cardinality/one, :db/doc "A district region enum value"}] 17 | (index-by :db/ident))) -------------------------------------------------------------------------------- /src/test/sku_model.clj: -------------------------------------------------------------------------------- 1 | (ns test.sku-model) 2 | 3 | ; https://github.com/Datomic/day-of-datomic/blob/master/tutorial/filters.clj -------------------------------------------------------------------------------- /src/test/social_news.clj: -------------------------------------------------------------------------------- 1 | (ns test.social-news) 2 | 3 | ; https://gist.github.com/stuarthalloway/2948756 -------------------------------------------------------------------------------- /src/toxiproxy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ### INSTALLATION 4 | 5 | # https://github.com/Shopify/toxiproxy 6 | # brew tap shopify/shopify 7 | # brew install toxiproxy 8 | 9 | ### RUNNING 10 | 11 | # ./toxiproxy.sh 8083 8080 200 12 | # toxiproxy-cli list 13 | # killall toxiproxy-server 14 | # toxiproxy-cli toxic help 15 | # toxiproxy-cli toxic update --toxicName hf_latency_toxic --attribute latency=500 hf_dev_proxy 16 | 17 | 18 | log_level=fatal # even 'error' is too spammy 19 | 20 | LOG_LEVEL=${log_level} toxiproxy-server & 21 | echo "waiting for server to come up:" 22 | while ! nc -z localhost 8474; do 23 | printf . 24 | sleep 1 25 | done 26 | echo " server up" 27 | toxiproxy-cli create --listen 0.0.0.0:$1 --upstream localhost:$2 hf_dev_proxy && \ 28 | toxiproxy-cli toxic add --toxicName hf_latency_toxic --type latency --attribute latency=$3 hf_dev_proxy -------------------------------------------------------------------------------- /src/user.clj: -------------------------------------------------------------------------------- 1 | (ns user ; Must be ".clj" file, Clojure doesn't auto-load user.cljc 2 | ;; For fastest REPL startup, no heavy deps here, REPL conveniences only 3 | ;; (Clojure has to compile all this stuff on startup) 4 | (:require [missionary.core :as m] 5 | [hyperfiddle.electric :as e] 6 | [user-main] 7 | hyperfiddle.rcf)) 8 | 9 | ; lazy load dev stuff - for faster REPL startup and cleaner dev classpath 10 | (def shadow-start! (delay @(requiring-resolve 'shadow.cljs.devtools.server/start!))) 11 | (def shadow-watch (delay @(requiring-resolve 'shadow.cljs.devtools.api/watch))) 12 | (def shadow-compile (delay @(requiring-resolve 'shadow.cljs.devtools.api/compile))) 13 | (def shadow-release (delay @(requiring-resolve 'shadow.cljs.devtools.api/release))) 14 | (def start-electric-server! (delay (partial @(requiring-resolve 'electric-server-java8-jetty9/start-server!) 15 | (fn [ring-req] (e/boot-server {} user-main/Main ring-req))))) 16 | (def rcf-enable! (delay @(requiring-resolve 'hyperfiddle.rcf/enable!))) 17 | 18 | ; Server-side Electric userland code is lazy loaded by the shadow build. 19 | ; WARNING: make sure your REPL and shadow-cljs are sharing the same JVM! 20 | 21 | (def electric-server-config 22 | {:host "0.0.0.0", :port 8080, :resources-path "public", :manifest-path "public/js/manifest.edn"}) 23 | 24 | (defn rcf-shadow-hook {:shadow.build/stages #{:compile-prepare :compile-finish}} 25 | [build-state & args] build-state) 26 | 27 | (defn install-rcf-shadow-hook [] 28 | (alter-var-root #'rcf-shadow-hook 29 | (constantly (fn [build-state & args] 30 | ;; NOTE this won’t prevent RCF tests to run during :require-macros phase 31 | (case (:shadow.build/stage build-state) 32 | :compile-prepare (@rcf-enable! false) 33 | :compile-finish (@rcf-enable!)) 34 | build-state)))) 35 | 36 | (defn main [& args] 37 | (println "Starting Electric compiler and server...") 38 | (@shadow-start!) ; serves index.html as well 39 | (@rcf-enable! false) ; don't run cljs tests on compile (user may have enabled it at the REPL already) 40 | (@shadow-watch :dev) ; depends on shadow server 41 | #_(@shadow-release :dev {:debug false}) 42 | ;; todo report clearly if shadow build failed, i.e. due to yarn not being run 43 | (def server (@start-electric-server! electric-server-config)) 44 | (comment (.stop server)) 45 | 46 | "Datomic Cloud (optional, requires :scratch alias)" 47 | (require '[contrib.datomic-m :as d]) 48 | (when (not-empty (eval '(d/detect-datomic-products))) 49 | #_(contrib.datomic-m/install-datomic-onprem) 50 | (eval '(contrib.datomic-m/install-datomic-cloud)) 51 | (def datomic-config {:server-type :dev-local :system "datomic-samples"}) 52 | ;; install prod globals 53 | (def datomic-client (eval '(d/client datomic-config))) 54 | (def datomic-conn (m/? (eval '(d/connect datomic-client {:db-name "mbrainz-subset"})))) 55 | 56 | ;; install test globals, which are different 57 | (require 'test) 58 | (eval '(test/install-test-state))) 59 | 60 | ;; enable RCF after Datomic is loaded – to resolve circular dependency 61 | (install-rcf-shadow-hook) 62 | (@rcf-enable!)) 63 | 64 | ;; shadow-compile vs shadow-release: 65 | ;; https://shadow-cljs.github.io/docs/UsersGuide.html#_development_mode 66 | (defn release "closure optimized target" 67 | [& {:as kwargs}] (@shadow-release :dev (merge {:debug false} kwargs))) 68 | 69 | (when (= "true" (get (System/getenv) "HYPERFIDDLE_AUTO_BOOT")) 70 | (main)) 71 | 72 | (comment 73 | (main) ; Electric Clojure(JVM) REPL entrypoint 74 | (hyperfiddle.rcf/enable!) ; turn on RCF after all transitive deps have loaded 75 | (shadow.cljs.devtools.api/repl :dev) ; shadow server hosts the cljs repl 76 | ; connect a second REPL instance to it 77 | ; (DO NOT REUSE JVM REPL it will fail weirdly) 78 | (type 1)) 79 | -------------------------------------------------------------------------------- /src/user.cljs: -------------------------------------------------------------------------------- 1 | (ns user 2 | (:require hyperfiddle.electric 3 | hyperfiddle.rcf 4 | user-main)) 5 | 6 | (def electric-main (hyperfiddle.electric/boot-client {} user-main/Main nil)) 7 | (defonce reactor nil) 8 | 9 | (defn ^:dev/after-load ^:export start! [] 10 | (set! reactor (electric-main 11 | #(js/console.log "Reactor success:" %) 12 | (fn [error] 13 | (case (:hyperfiddle.electric/type (ex-data error)) 14 | :hyperfiddle.electric-client/stale-client 15 | (do (js/console.log "Client/server version mismatch, refreshing page.") 16 | (.reload (.-location js/window))) 17 | (js/console.error "Reactor failure:" error))))) 18 | (hyperfiddle.rcf/enable!)) 19 | 20 | (defn ^:dev/before-load stop! [] 21 | (when reactor (reactor)) ; teardown 22 | (set! reactor nil)) 23 | -------------------------------------------------------------------------------- /src/user.md: -------------------------------------------------------------------------------- 1 | hello world 2 | 3 | ```clojure 4 | (ns prod 5 | (:require hyperfiddle.electric 6 | user-main)) 7 | 8 | (def electric-main (hyperfiddle.electric/boot (user-main/Main.))) 9 | 10 | (defn start! [] 11 | (electric-main 12 | #(js/console.log "Reactor success:" %) 13 | #(js/console.error "Reactor failure:" %))) 14 | ``` -------------------------------------------------------------------------------- /src/user/demo_10k_dom.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-10k-dom 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [missionary.core :as m])) 5 | 6 | (def !moves #?(:clj (atom []) :cljs nil)) 7 | (def !board-size #?(:clj (atom 10000) :cljs nil)) 8 | (e/def board-size (e/server (e/watch !board-size))) 9 | 10 | (comment (do (reset! !moves []) (reset! !board-size 2000))) 11 | 12 | #?(:cljs 13 | (defn hot [el] 14 | (m/observe (fn mount [!] 15 | (dom/set-property! el "style" {:background-color "red"}) 16 | (! nil) ; initial value 17 | (fn unmount [] 18 | (dom/set-property! el "style" {:background-color nil})))))) 19 | 20 | (e/defn Dom-10k-Elements [] 21 | (e/client 22 | (dom/h1 (dom/text "10k dom elements (multiplayer)")) 23 | ; fixed width font + inline-block optimizes browser layout 24 | (dom/element "style" (dom/text ".board div { width: 1em; height: 1em; display: inline-block; border: 1px #eee solid; }")) 25 | (dom/element "style" (dom/text ".board { font-family: monospace; font-size: 7px; margin: 0; padding: 0; line-height: 0; }")) 26 | (dom/div {:class "board"} 27 | (e/for [i (range 0 board-size)] 28 | (dom/div 29 | (dom/on "mouseover" (e/fn [e] (e/server (swap! !moves conj i)))))) 30 | (e/for [i (e/server (e/watch !moves))] 31 | ; differential side effects, indexed by HTMLCollection 32 | (new (hot (.item (.. dom/node -children) i))))))) 33 | 34 | ;(defn countdown [x] ; Count down to 0 then terminate. 35 | ; (m/relieve {} (m/ap (loop [x x] 36 | ; (m/amb x 37 | ; (if (pos? x) 38 | ; (do (m/? (m/sleep 100)) 39 | ; (recur (dec x))) 40 | ; x)))))) 41 | ; 42 | ;(defn cell-color [x] 43 | ; (if (> x 1) ; In Electric-land, this conditional would introduce a switch and trigger a ws message for client-server frame coordination. 44 | ; (apply css-rgb-str (hsv->rgb (/ 0 360) 45 | ; (-> x (/ 7.5) (* 1.33)) 46 | ; 0.95)) 47 | ; "#eee")) 48 | ; 49 | ;#?(:cljs (defn x [!el] 50 | ; (m/observe (fn mount [!] 51 | ; (let [!o (js/MutationObserver !)] 52 | ; (.observe !o !el #js {"attributes" true}) 53 | ; (fn unmount [] (.disconnect !o))))))) -------------------------------------------------------------------------------- /src/user/demo_chat.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-chat 2 | (:import [hyperfiddle.electric Pending]) 3 | (:require [contrib.data :refer [pad]] 4 | [contrib.str :refer [empty->nil]] 5 | [hyperfiddle.electric :as e] 6 | [hyperfiddle.electric-dom2 :as dom])) 7 | 8 | #?(:clj (defonce !msgs (atom (list)))) 9 | (e/def msgs (e/server (pad 10 nil (e/watch !msgs)))) 10 | 11 | (e/defn Chat [] 12 | (e/client 13 | (try 14 | (dom/ul 15 | (e/server 16 | (e/for-by identity [msg (reverse msgs)] ; chat renders bottom up 17 | (e/client 18 | (dom/li (dom/style {:visibility (if (nil? msg) 19 | "hidden" "visible")}) 20 | (dom/text msg)))))) 21 | 22 | (dom/input 23 | (dom/props {:placeholder "Type a message" :maxlength 100}) 24 | (dom/on "keydown" (e/fn [e] 25 | (when (= "Enter" (.-key e)) 26 | (when-some [v (empty->nil (.substr (.. e -target -value) 0 100))] 27 | (e/server (swap! !msgs #(cons v (take 9 %)))) 28 | (set! (.-value dom/node) "")))))) 29 | (catch Pending e 30 | (dom/style {:background-color "yellow"}))))) -------------------------------------------------------------------------------- /src/user/demo_chat.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | 3 | * Chat messages are stored in an atom on the server 4 | * It's multiplayer, each connected session sees the same messages 5 | * Messages submit on enter keypress (and clear the input) 6 | * All connected clients see new messages immediately 7 | * The background flashes yellow when something is loading 8 | 9 | Novel forms 10 | 11 | * `Pending`: an exception thrown when the client accesses a remote value that is not yet available 12 | * `try/catch`: reactive try catch 13 | * `dom/input`: a low level DOM control, used here to implement submit-on-enter 14 | * `dom/node`: the live dom node, maintained in dynamic scope for local point writes 15 | 16 | Key Ideas 17 | 18 | * **multiplayer**: the server global state atom is shared by all clients connected to that server, because they share the same JVM. 19 | * **latency**: `e/client` and `e/server` return reactive values across sites. When a remote value is accessed but not yet available, Electric throws a `Pending` exception. 20 | * **reactive try/catch**: in reactive programming, exceptions (like `Pending`) are ephemeral: they can "go away" when an upstream dependency changes, causing the exception to no longer be thrown. 21 | * **JavaScript interop**: cljs interop forms work as expected, side effects are no problem 22 | * **30 LOC**: where is all the client/server framework boilerplate? No GraphQL, no fetch, no API modeling, no async types, etc. One thing missing in this tutorial is error handling (resilient state sync, i.e. optimistic updates with rollback). Electric bundles UI controls for this out of the box, to be discussed in a future tutorial. 23 | 24 | Pending details 25 | 26 | * Q: Why is Pending modeled as an exception? It's not exceptional? A: The semantics match 27 | * Any uncaught Pending exceptions are silenced and disarded by the Electric entrypoint 28 | * When Pending is thrown, the `catch` body is mounted (turning the background yellow) 29 | * Eventually the remote value becomes available and the Pending exception "goes away", unmounting the `catch` body and removing the yellow style. 30 | 31 | Why does each connected client receive realtime updates? 32 | 33 | * each client get's its own "server instance", bound to the websocket session 34 | * `(e/def msgs ...)` is global, therefore shared across all sessions (same JVM) 35 | * other than shared global state, the server instances are independent. All sesison state is isolated and bound to the websocket session, just as HTTP request handlers are bound to a request. 36 | -------------------------------------------------------------------------------- /src/user/demo_chat_extended.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-chat-extended 2 | (:require [contrib.str :refer [empty->nil]] 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom])) 5 | 6 | #?(:clj (defonce !msgs (atom (list)))) 7 | (e/def msgs (e/server (reverse (e/watch !msgs)))) 8 | 9 | #?(:clj (defonce !present (atom {}))) ; session-id -> user 10 | (e/def present (e/server (e/watch !present))) 11 | 12 | (e/defn Chat-UI [username] 13 | (e/client 14 | (dom/div (dom/text "Present: ")) 15 | (dom/ul 16 | (e/server 17 | (e/for-by first [[session-id username] present] 18 | (e/client 19 | (dom/li (dom/text username (str " (session-id: " session-id ")"))))))) 20 | 21 | (dom/hr) 22 | (dom/ul 23 | (e/server 24 | (e/for [{:keys [::username ::msg]} msgs] 25 | (e/client 26 | (dom/li (dom/strong (dom/text username)) 27 | (dom/text " " msg)))))) 28 | 29 | (dom/input 30 | (dom/props {:placeholder "Type a message" :maxlength 100}) 31 | (dom/on "keydown" (e/fn [e] 32 | (when (= "Enter" (.-key e)) 33 | (when-some [v (empty->nil (.substr (.. e -target -value) 0 100))] 34 | (dom/style {:background-color "yellow"}) ; loading 35 | (e/server 36 | (swap! !msgs #(cons {::username username ::msg v} 37 | (take 9 %)))) 38 | (set! (.-value dom/node) "")))))))) 39 | 40 | (e/defn ChatExtended [] 41 | (e/client 42 | (let [session-id 43 | (e/server (get-in e/http-request [:headers "sec-websocket-key"])) 44 | username 45 | (e/server (get-in e/http-request [:cookies "username" :value]))] 46 | (if-not (some? username) 47 | (dom/div 48 | (dom/text "Set login cookie here: ") 49 | (dom/a (dom/props {::dom/href "/auth"}) (dom/text "/auth")) 50 | (dom/text " (blank password)")) 51 | (do 52 | (e/server 53 | (swap! !present assoc session-id username) 54 | (e/on-unmount #(swap! !present dissoc session-id))) 55 | (dom/div (dom/text "Authenticated as: " username)) 56 | (Chat-UI. username)))))) 57 | -------------------------------------------------------------------------------- /src/user/demo_chat_extended.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * There is auth 3 | * Presence – logged in users can see who else is in the room (try two tabs) 4 | 5 | Novel forms 6 | * `e/*http-request*` the ring request that established the websocket connection 7 | * `do` sequences effects, returning the final result (same as Clojure). Reactive objects in the body are constructed in order and then run concurrently, so e.g. `(do (Blinker.) (Blinker.))` will have concurrent blinkers. 8 | 9 | Key ideas 10 | * **regular web server**: Electric's websocket can be hosted by any ordinary Clojure web server, you provide this. 11 | * **auth** is same as any other web app, you have access to the request, cookies, etc 12 | 13 | HTTP server details 14 | * App server for this app: [`electric_server_java8_jetty9.clj`](https://github.com/hyperfiddle/electric-examples-app/blob/main/src/electric_server_java8_jetty9.clj) 15 | * it hosts a websocket 16 | * it has a `/auth` endpoint configured with HTTP Basic Auth 17 | * Note the server is application code, it's not provided by the Electric library dependency. 18 | * Q: Why is it not included in the library? A: because it has hardcoded http routes and auth examples 19 | * Instead we provide [templates](https://github.com/hyperfiddle/electric-starter-app/tree/main/src) in the starter app for you to modify. 20 | 21 | More on Electric functions/objects and dynamic extent 22 | * "Extent" is defined in the [previous tutorial](/user.tutorial-lifecycle!Lifecycle) 23 | * Here, `dom/on`'s callback, the `e/fn`, is an *object* and has *extent* 24 | * Using the railroad switch metaphor, the extent of the object/node is the time during which the callback is active/alive/running. 25 | * `dom/on` is managing the lifetime/extent of the callback `e/fn` by observing `Pending` exceptions it throws. 26 | * `e/server` throws `Pending` until the result of the body is known to the client (did it succeed? did it throw?) 27 | * When the `Pending` exception goes away, `dom/on` will unmount the `e/fn` and remove it from the DAG. 28 | * This means you can put resources (e.g. allocate DOM) inside the callback, as we do here with `(dom/style {:background-color "yellow"})`. 29 | 30 | When you send a message and the server effect is pending, now the input flashes yellow instead of the whole page 31 | * The style is mounted when it's parent e/fn is mounted, and the style is unmounted when the e/fn is unmounted. 32 | * So the style's extent is the duration of the callback, and the callback is the duration of the Pending exception plus one tick for the final result. Once the result is known, the callback (and the style) are unmounted and removed from the DAG. -------------------------------------------------------------------------------- /src/user/demo_color.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-color 2 | (:require [contrib.data :refer [assoc-vec]] 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.history :as router] 6 | [contrib.color :as c])) 7 | 8 | ;; Goal is to demonstrate: 9 | ;; - fine-grained reactivity on CSS properties 10 | ;; - Non-trivial DOM api usage (canvas) 11 | 12 | (def CANVAS-WIDTH 360) ; px 13 | (def CANVAS-HEIGHT 100) ; px 14 | 15 | (defn format-rgb [[r g b]] (str "rgb("r","g","b")")) 16 | 17 | #?(:cljs 18 | (defn draw! [^js canvas colorf] 19 | (let [ctx (.getContext canvas "2d")] 20 | (loop [angle 0] 21 | (set! (.-strokeStyle ctx) (colorf angle)) 22 | (.beginPath ctx) 23 | (.moveTo ctx angle 0) 24 | (.lineTo ctx angle CANVAS-HEIGHT) 25 | (.closePath ctx) 26 | (.stroke ctx) 27 | (when (< angle 360) 28 | (recur (inc angle))))))) 29 | 30 | #?(:cljs 31 | (defn draw-gradient! [canvas hue colorf] 32 | (draw! canvas (fn [angle] (format-rgb (if (= angle hue) [255 255 255] (colorf angle))))))) 33 | 34 | (defn saturation->chroma [saturation] (* 0.158 (/ saturation 100))) 35 | 36 | (e/defn Tile [color] 37 | (e/client 38 | (dom/div (dom/props {:style {:display :flex 39 | :align-items :center 40 | :justify-content :center 41 | :color :white 42 | :background-color (format-rgb color) 43 | :width "100px" 44 | :height "100%" 45 | }}) 46 | (dom/text "Contrast")))) 47 | 48 | (e/defn Color [] 49 | (e/client 50 | (let [[self h s l] router/route 51 | h (or h 180) 52 | s (or s 80) 53 | l (or l 70) 54 | swap-route! router/swap-route!] 55 | (dom/div (dom/props {:style {:display :grid 56 | :grid-template-columns "auto 1fr auto" 57 | :gap "0 1rem" 58 | :align-items :center 59 | :justify-items :stretch 60 | :max-width "600px"}}) 61 | (dom/p (dom/text "Lightness")) 62 | (dom/input (dom/props {:type :range 63 | :min 0 64 | :max 100 65 | :step 1 66 | :value l}) 67 | (dom/on! "input" (fn [^js e] (swap-route! assoc-vec 3 (js/parseInt (.. e -target -value)))))) 68 | (dom/p (dom/text l "%")) 69 | 70 | (dom/p (dom/text "Saturation")) 71 | (dom/input (dom/props {:type :range 72 | :min 0 73 | :max 100 74 | :step 1 75 | :value s}) 76 | (dom/on! "input" (fn [^js e] (swap-route! assoc-vec 2 (js/parseInt (.. e -target -value)))))) 77 | (dom/p (dom/text s "%")) 78 | 79 | 80 | (dom/p (dom/text "Hue")) 81 | (dom/input (dom/props {:type :range 82 | :min 0 83 | :max 360 84 | :step 1 85 | :value h}) 86 | (dom/on! "input" (fn [^js e] (swap-route! assoc-vec 1 (js/parseInt (.. e -target -value)))))) 87 | (dom/p (dom/text h "°")) 88 | 89 | 90 | (dom/p (dom/text "HSL")) 91 | (dom/canvas (dom/props {:width 360 92 | :height 100}) 93 | (draw-gradient! dom/node h (fn [h] (c/hsl->rgb [h s l]))) 94 | ) 95 | (Tile. (c/hsl->rgb [h s l])) 96 | 97 | (dom/p (dom/text "OKLCH")) 98 | (dom/canvas (dom/props {:width 360 99 | :height 100}) 100 | (draw-gradient! dom/node h (fn [h] (c/oklch->rgb [l (saturation->chroma s) h])))) 101 | (Tile. (c/oklch->rgb [l (saturation->chroma s) h])) 102 | 103 | (dom/p (dom/text "HSLuv")) 104 | (dom/canvas (dom/props {:width 360 105 | :height 100}) 106 | (draw-gradient! dom/node h (fn [h] (c/hsluv->rgb [h s l])))) 107 | (Tile. (c/hsluv->rgb [h s l])))))) 108 | -------------------------------------------------------------------------------- /src/user/demo_explorer.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-explorer 2 | (:require [clojure.datafy :refer [datafy]] 3 | [clojure.core.protocols :refer [nav]] 4 | #?(:clj clojure.java.io) 5 | [clojure.string :as str] 6 | [contrib.data :refer [treelister]] 7 | [contrib.datafy-fs #?(:clj :as :cljs :as-alias) fs] 8 | [hyperfiddle.electric :as e] 9 | [hyperfiddle.electric-dom2 :as dom] 10 | [hyperfiddle.history :as router] 11 | [contrib.gridsheet :as gridsheet :refer [Explorer]])) 12 | 13 | (def unicode-folder "\uD83D\uDCC2") ; 📂 14 | 15 | (e/defn Render-cell [m a] 16 | (let [v (a m)] 17 | (case a 18 | ::fs/name (case (::fs/kind m) 19 | ::fs/dir (let [absolute-path (::fs/absolute-path m)] 20 | (e/client (router/link absolute-path (dom/text v)))) 21 | (::fs/other ::fs/symlink ::fs/unknown-kind) (e/client (dom/text v)) 22 | (e/client (dom/text v))) 23 | ::fs/modified (e/client (some-> v .toLocaleDateString dom/text)) 24 | ::fs/kind (case (::fs/kind m) 25 | ::fs/dir (e/client (dom/text unicode-folder)) 26 | (e/client (some-> v name dom/text))) 27 | (e/client (dom/text (str v)))))) 28 | 29 | (e/defn Dir [x] 30 | (let [m (datafy x) 31 | xs (nav m ::fs/children (::fs/children m))] 32 | (e/client (dom/div (dom/text (e/server (::fs/absolute-path m))))) 33 | (Explorer. 34 | (treelister xs ::fs/children #(str/includes? (str/lower-case (::fs/name %)) 35 | (str/lower-case (str %2)))) 36 | {::dom/style {:height "calc((20 + 1) * 24px)"} 37 | ::gridsheet/page-size 20 38 | ::gridsheet/row-height 24 39 | ::gridsheet/Format Render-cell 40 | ::gridsheet/columns [::fs/name ::fs/modified ::fs/size ::fs/kind] 41 | ::gridsheet/grid-template-columns "auto 8em 5em 3em"}))) 42 | 43 | (e/defn DirectoryExplorer [] 44 | (e/client 45 | (dom/link (dom/props {:rel :stylesheet, :href "/user/gridsheet-optional.css"})) 46 | (dom/div (dom/props {:class "user-gridsheet-demo"}) 47 | (binding [router/build-route (fn [[self state] path] 48 | ; root local links through this entrypoint 49 | [self (assoc state ::path path)])] 50 | (e/server 51 | (let [{:keys [::path]} (e/client router/route) 52 | fs-path (or path (fs/absolute-path "./"))] 53 | (Dir. (clojure.java.io/file fs-path)))))))) 54 | 55 | ; Improvements 56 | ; Native search 57 | ; lazy folding/unfolding directories (no need for pagination) 58 | ; forms (currently table hardcoded with recursive pull) 59 | -------------------------------------------------------------------------------- /src/user/demo_explorer.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * Thousands of elements efficiently streamed from server to client 3 | * virtual pagination (the same 20 divs are reused — check the DOM and confirm) 4 | * `e/for` is transparently coordinating the network (same as previous [System Properties demo](/user.demo-system-properties!SystemProperties)) 5 | 6 | Key ideas 7 | 8 | * **performance**: `e/for` is awesome, look how fast it is 9 | * **diffing**: `e/for` performs diffing to reuse child state (DOM elements) and streams only deltas (mount/unmount/update/move). 10 | * **network-transparent abstraction**: This demo is [50 LOC](https://github.com/hyperfiddle/electric-examples-app/blob/main/src/user/demo_explorer.cljc) (it also uses a gridsheet component which is [92 LOC](https://github.com/hyperfiddle/electric/blob/master/src/contrib/gridsheet.cljc), so 142 LOC total) 11 | * **compiler managed network**: How would you do this in React? 12 | 13 | 14 | Network-transparent abstraction is the whole point of Electric Clojure, see (a more filled out version of basically this same demo) for more content about network-transparent abstraction. 15 | 16 | For more detail about reactive network efficiency, see [this clojureverse answer](https://clojureverse.org/t/electric-clojure-a-signals-dsl-for-fullstack-web-ui/9788/32?u=dustingetz). 17 | -------------------------------------------------------------------------------- /src/user/demo_index.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-index 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.history :as router])) ; for link only 5 | 6 | (def pages 7 | [`user.demo-two-clocks/TwoClocks 8 | `user.demo-toggle/Toggle 9 | `user.demo-system-properties/SystemProperties 10 | `user.demo-webview/Webview 11 | `user.demo-chat/Chat 12 | `user.tutorial-lifecycle/Lifecycle 13 | `user.demo-chat-extended/ChatExtended 14 | `user.demo-todos-simple/TodoList 15 | `user.demo-reagent-interop/ReagentInterop 16 | `user.demo-svg/SVG]) 17 | 18 | (def seven-guis 19 | [`user.tutorial-7guis-1-counter/Counter 20 | `user.tutorial-7guis-2-temperature/TemperatureConverter 21 | `user.tutorial-7guis-4-timer/Timer 22 | `user.tutorial-7guis-5-crud/CRUD]) 23 | 24 | (def demos 25 | [`user.demo-explorer/DirectoryExplorer 26 | `user.demo-todomvc/TodoMVC 27 | `user.demo-todomvc-composed/TodoMVC-composed 28 | `user.demo-color/Color]) 29 | 30 | (def secret-pages 31 | [`wip.teeshirt-orders/Webview-HFQL 32 | `wip.demo-explorer2/DirectoryExplorer-HFQL 33 | ;`user.demo-10k-dom/Dom-10k-Elements 34 | `wip.demo-branched-route/RecursiveRouter 35 | `wip.tag-picker/TagPicker 36 | `wip.demo-custom-types/CustomTypes 37 | `wip.tracing/TracingDemo 38 | ;`user.demo-tic-tac-toe/TicTacToe 39 | ;`user.demo-virtual-scroll/VirtualScroll 40 | 41 | ; need extra deps alias 42 | ;::dennis-exception-leak 43 | #_`wip.demo-stage-ui4/CrudForm 44 | #_`wip.datomic-browser/DatomicBrowser]) 45 | 46 | (e/defn Demos [] 47 | (e/client 48 | (dom/h3 (dom/text "Tutorial")) 49 | (e/for [k pages] 50 | (dom/div (router/link [k] (dom/text (name k))))) 51 | 52 | (dom/h3 (dom/text "7 GUIs")) 53 | (e/for [k seven-guis] 54 | (dom/div (router/link [k] (dom/text (name k))))) 55 | 56 | (dom/h3 (dom/text "Demos")) ; no source code here, can link to GH 57 | (e/for [k demos] 58 | (dom/div (router/link [k] (dom/text (name k))))) 59 | 60 | (dom/div (dom/style {:opacity 0}) 61 | (router/link [`Secrets] (dom/text "secret-hyperfiddle-demos"))))) 62 | 63 | (e/defn Secrets [] 64 | (e/client 65 | (dom/h1 (dom/text "Wip unpublished demos (unstable/wip)") 66 | (dom/comment_ "ssh" "it's a secret")) 67 | (dom/p "Some require a database connection and are often broken.") 68 | (e/for [k secret-pages] 69 | (dom/div (router/link [k] (dom/text (name k))))))) 70 | -------------------------------------------------------------------------------- /src/user/demo_reagent_interop.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-reagent-interop 2 | #?(:cljs (:require-macros [user.demo-reagent-interop :refer [with-reagent]])) 3 | (:require [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | #?(:cljs [reagent.core :as r]) 6 | #?(:cljs ["recharts" :refer [ScatterChart Scatter LineChart Line 7 | XAxis YAxis CartesianGrid]]) 8 | #?(:cljs ["react-dom/client" :as ReactDom]))) 9 | 10 | #?(:cljs (def ReactRootWrapper 11 | (r/create-class 12 | {:component-did-mount (fn [this] (js/console.log "mounted")) 13 | :render (fn [this] 14 | (let [[_ Component & args] (r/argv this)] 15 | (into [Component] args)))}))) 16 | 17 | #?(:cljs (defn create-root 18 | "See https://reactjs.org/docs/react-dom-client.html#createroot" 19 | ([node] (create-root node (str (gensym)))) 20 | ([node id-prefix] 21 | (ReactDom/createRoot node #js {:identifierPrefix id-prefix})))) 22 | 23 | #?(:cljs (defn render [root & args] 24 | (.render root (r/as-element (into [ReactRootWrapper] args))))) 25 | 26 | (defmacro with-reagent [& args] 27 | `(dom/div ; React will hijack this element and empty it. 28 | (let [root# (create-root dom/node)] 29 | (render root# ~@args) 30 | (e/on-unmount #(.unmount root#))))) 31 | 32 | ;; Reagent World 33 | 34 | (defn TinyLineChart [data] 35 | #?(:cljs 36 | [:> LineChart {:width 400 :height 200 :data (clj->js data)} 37 | [:> CartesianGrid {:strokeDasharray "3 3"}] 38 | [:> XAxis {:dataKey "name"}] 39 | [:> YAxis] 40 | [:> Line {:type "monotone", :dataKey "pv", :stroke "#8884d8"}] 41 | [:> Line {:type "monotone", :dataKey "uv", :stroke "#82ca9d"}]])) 42 | 43 | (defn MousePosition [x y] 44 | #?(:cljs 45 | [:> ScatterChart {:width 300 :height 300 46 | :margin #js{:top 20, :right 20, :bottom 20, :left 20}} 47 | [:> CartesianGrid {:strokeDasharray "3 3"}] 48 | [:> XAxis {:type "number", :dataKey "x", :unit "px", :domain #js[0 2000]}] 49 | [:> YAxis {:type "number", :dataKey "y", :unit "px", :domain #js[0 2000]}] 50 | [:> Scatter {:name "Mouse position", 51 | :data (clj->js [{:x x, :y y}]), :fill "#8884d8"}]])) 52 | 53 | ;; Electric Clojure 54 | 55 | (e/defn ReagentInterop [] 56 | (e/client 57 | (let [[x y] (dom/on! js/document "mousemove" 58 | (fn [e] [(.-clientX e) (.-clientY e)]))] 59 | (with-reagent MousePosition x y) ; reactive 60 | ;; Adapted from https://recharts.org/en-US/examples/TinyLineChart 61 | (with-reagent TinyLineChart 62 | [{:name "Page A" :uv 4000 :amt 2400 :pv 2400} 63 | {:name "Page B" :uv 3000 :amt 2210 :pv 1398} 64 | {:name "Page C" :uv 2000 :amt 2290 :pv (+ 6000 (* -5 y))} ; reactive 65 | {:name "Page D" :uv 2780 :amt 2000 :pv 3908} 66 | {:name "Page E" :uv 1890 :amt 2181 :pv 4800} 67 | {:name "Page F" :uv 2390 :amt 2500 :pv 3800} 68 | {:name "Page G" :uv 3490 :amt 2100 :pv 4300}])))) -------------------------------------------------------------------------------- /src/user/demo_svg.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-svg 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.electric-svg :as svg])) 5 | 6 | (defn wave [time] 7 | (Math/cos (/ (* (mod (Math/round (/ time 10)) 360) Math/PI) 180))) 8 | 9 | (e/defn SVG [] 10 | (e/client 11 | (let [offset (* 3 (wave e/system-time-ms))] ; js animation clock 12 | (svg/svg (dom/props {:viewBox "0 0 300 100"}) 13 | (svg/circle 14 | (dom/props {:cx 50 :cy 50 :r (+ 30 offset) 15 | :style {:fill "#af7ac5 "}})) 16 | (svg/g 17 | (dom/props {:transform 18 | (str "translate(105,20) rotate(" (* 3 offset) ")")}) 19 | (svg/polygon (dom/props {:points "30,0 0,60 60,60" 20 | :style {:fill "#5499c7"}}))) 21 | (svg/rect 22 | (dom/props {:x 200 :y 20 :width (+ 60 offset) :height (+ 60 offset) 23 | :style {:fill "#45b39d"}})))))) -------------------------------------------------------------------------------- /src/user/demo_svg.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hyperfiddle/electric-v2-tutorial/fb5df89804ecc2d02916573897bb7ecca23940fe/src/user/demo_svg.md -------------------------------------------------------------------------------- /src/user/demo_system_properties.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-system-properties 2 | (:require [clojure.string :as str] 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.electric-ui4 :as ui])) 6 | 7 | #?(:clj 8 | (defn jvm-system-properties [?s] 9 | (->> (System/getProperties) 10 | (filter (fn [^java.util.concurrent.ConcurrentHashMap$MapEntry kv] 11 | (str/includes? (str/lower-case (str (key kv))) 12 | (str/lower-case (str ?s))))) 13 | (sort-by key)))) 14 | 15 | (e/defn SystemProperties [] 16 | (e/client 17 | (let [!search (atom "") 18 | search (e/watch !search)] 19 | (e/server 20 | (let [system-props (jvm-system-properties search) 21 | matched-count (count system-props)] 22 | (e/client 23 | (dom/div (dom/text matched-count " matches")) 24 | (ui/input search (e/fn [v] (reset! !search v)) 25 | (dom/props {:type "search" :placeholder "🔎 java.class.path"})) 26 | (dom/table 27 | (dom/tbody 28 | (e/server 29 | ; reactive for, stabilized with "react key" 30 | (e/for-by key [[k v] system-props] 31 | (e/client 32 | (println 'rendering k) 33 | (dom/tr 34 | (dom/td (dom/text k)) 35 | (dom/td (dom/text v)))))))))))))) -------------------------------------------------------------------------------- /src/user/demo_system_properties.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | 3 | * There's a HTML table on the frontend, backed by a backend "query" `jvm-system-properties` 4 | * The backend query is an ordinary Clojure function that only exists on the server. 5 | * Typing into the frontend input causes the backend query to rerun and update the table. 6 | * There's a reactive for loop to render the table. 7 | * The view code deeply nests client and server calls, arbitrarily, even through loops. 8 | 9 | Novel forms 10 | 11 | * `ui/input`: a text input control with "batteries included" loading/syncing state. 12 | * `e/for-by`: a reactive map operator, stabilized to bind each child branch state (e.g. DOM element) to an entity in the collection by id (provided by userland fn - similar to [React.js key](https://stackoverflow.com/questions/28329382/understanding-unique-keys-for-array-children-in-react-js/43892905#43892905)). 13 | 14 | Key ideas 15 | 16 | * **ordinary Clojure/Script functions**: `clojure.core/defn` works as it does in Clojure/Script, it's still a normal blocking function and is opaque to Electric. Electric does not mess with the `clojure.core/defn` macro. 17 | * **query can be any function**: return collections, SQL resultsets, whatever 18 | * **direct query/view composition**: `jvm-system-properties`, a server function, composes directly with the frontend DOM table. Thus unifying your code into one paradigm, promoting readability, and making it easier to craft complex interactions between client and server components, maintain and refactor them. 19 | * **reactive-for**: The table rows are renderered by a for loop. Reactive loops are efficient and recompute branches only precisely when needed. 20 | * **network transfer can be reasoned about clearly**: values are only transferred between sites when and if they are used. The `system-props` collection is never actually accessed from a client region and therefore never escapes the server. 21 | 22 | Reactive for details 23 | 24 | * `e/for-by` ensures that each table row is bound to a logical element of the collection, and only touched when a row dependency changes. 25 | * Notice there is a `println` inside the for loop. This is so you can see the efficient rendering in the browser console. 26 | * Open the browser console now and confirm for yourself: 27 | * On initial render, each row is rendered once 28 | * Slowly input "java.class.path" 29 | * As you narrow the filter, no rows are recomputed. (The existing dom is reused, so there is nothing to recompute because neither `k` nor `v` have changed for that row.) 30 | * Slowly backspace, one char at a time 31 | * As you widen the filter, rows are computed as they come back. That's because they were unmounted and discarded! 32 | * Quiz: Try setting an inline style "background-color: red" on element "java.class.path". When is the style retained? When is the style lost? Why? 33 | 34 | Reasoning about network transfer 35 | 36 | * Look at which remote scope values are closed over and accessed. 37 | * Only remote access is transferred. Mere *availability* in scope does not transfer. 38 | * In the `e/for-by`, `k` and `v` exist in a server scope, and yet are accessed from a client scope. 39 | * Electric tracks this and sends a stream of individual `k` and `v` updates over network. 40 | * The collection value `system-props` is not accessed from client scope, so Electric will not move it. Values are only moved if they are accessed. 41 | 42 | Network transparent composition is not the heavy, leaky abstraction you might think it is 43 | 44 | * The DAG representation of the program makes this simple to do 45 | * The electric core implementation is about 3000 LOC 46 | * Function composition laws are followed, Electric functions are truly functions. 47 | * Functions are an abstract mathematical object 48 | * Javascript already generalizes from function -> async function (`async/await`) -> generator function (`fn*/yield`) 49 | * Electric generalizes further: stream function -> reactive function -> distributed function 50 | * With Electric, you can refactor across the frontend/backend boundary, all in one place, without caring about any plumbing. 51 | * Refactoring is an algebraic activity with local reasoning, just as it should be. 52 | * Functional programming without the BS 53 | -------------------------------------------------------------------------------- /src/user/demo_tic_tac_toe.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-tic-tac-toe 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.electric-ui4 :as ui])) 5 | 6 | (def !x #?(:clj (atom (vec (repeat 10 0))) :cljs nil)) 7 | (e/def x (e/server (e/watch !x))) 8 | (defn update-board [board pos] (update board pos #(case % 0 1, 1 2, 2 0))) 9 | 10 | (e/defn Button [offset] 11 | (e/client 12 | (ui/button (e/fn [] 13 | (e/server 14 | (swap! !x update-board offset))) 15 | (dom/text 16 | (case (e/server (nth x offset)) 17 | 2 "x" 18 | 1 "o" 19 | 0 "-"))))) 20 | 21 | (e/defn TicTacToe [] 22 | (e/client 23 | (dom/h1 (dom/text "Tic Tac Toe \uD83C\uDFAE")) 24 | (dom/p (dom/text "multiplayer works, try two tabs")) 25 | (dom/table 26 | (e/for [row [[0 1 2] 27 | [3 4 5] 28 | [6 7 8]]] 29 | (dom/tr 30 | (e/for [i row] 31 | (dom/td 32 | (Button. i)))))))) 33 | -------------------------------------------------------------------------------- /src/user/demo_todomvc.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-todomvc 2 | "Requires -Xss2m to compile. The Electric compiler exceeds the default 1m JVM ThreadStackSize 3 | due to large macroexpansion resulting in false StackOverflowError during analysis." 4 | (:require 5 | contrib.str 6 | #?(:clj [datascript.core :as d]) 7 | [hyperfiddle.electric :as e] 8 | [hyperfiddle.electric-dom2 :as dom] 9 | [hyperfiddle.electric-ui4 :as ui])) 10 | 11 | #?(:clj (defonce !conn (d/create-conn {}))) ; server 12 | (e/def db) ; server 13 | (e/def transact!) ; server 14 | #?(:cljs (def !state (atom {::filter :all ; client 15 | ::editing nil 16 | ::delay 0}))) 17 | 18 | #?(:clj 19 | (defn query-todos [db filter] 20 | {:pre [filter]} 21 | (case filter 22 | :active (d/q '[:find [?e ...] :where [?e :task/status :active]] db) 23 | :done (d/q '[:find [?e ...] :where [?e :task/status :done]] db) 24 | :all (d/q '[:find [?e ...] :where [?e :task/status]] db)))) 25 | 26 | #?(:clj 27 | (defn todo-count [db filter] 28 | {:pre [filter] 29 | :post [(number? %)]} 30 | (-> (case filter 31 | :active (d/q '[:find (count ?e) . :where [?e :task/status :active]] db) 32 | :done (d/q '[:find (count ?e) . :where [?e :task/status :done]] db) 33 | :all (d/q '[:find (count ?e) . :where [?e :task/status]] db)) 34 | (or 0)))) ; datascript can return nil wtf 35 | 36 | (e/defn Filter-control [state target label] 37 | (e/client 38 | (dom/a (dom/props {:class (when (= state target) "selected")}) 39 | (dom/text label) 40 | (dom/on "click" (e/fn [_] (swap! !state assoc ::filter target)))))) 41 | 42 | 43 | (e/defn TodoStats [state] 44 | (e/client 45 | (let [active (e/server (todo-count db :active)) 46 | done (e/server (todo-count db :done))] 47 | (dom/div 48 | (dom/span (dom/props {:class "todo-count"}) 49 | (dom/strong (dom/text active)) 50 | (dom/span (dom/text " " (str (case active 1 "item" "items")) " left"))) 51 | 52 | (dom/ul (dom/props {:class "filters"}) 53 | (dom/li (Filter-control. (::filter state) :all "All")) 54 | (dom/li (Filter-control. (::filter state) :active "Active")) 55 | (dom/li (Filter-control. (::filter state) :done "Completed"))) 56 | 57 | (when (pos? done) 58 | (ui/button (e/fn [] (e/server (when-some [ids (seq (query-todos db :done))] 59 | (transact! (mapv (fn [id] [:db/retractEntity id]) ids)) nil))) 60 | (dom/props {:class "clear-completed"}) 61 | (dom/text "Clear completed " done))))))) 62 | 63 | (e/defn TodoItem [state id] 64 | (e/server 65 | (let [{:keys [:task/status :task/description]} (d/entity db id)] 66 | (e/client 67 | (dom/li 68 | (dom/props {:class [(when (= :done status) "completed") 69 | (when (= id (::editing state)) "editing")]}) 70 | (dom/div (dom/props {:class "view"}) 71 | (ui/checkbox (= :done status) (e/fn [v] 72 | (let [status (case v true :done, false :active, nil)] 73 | (e/server (transact! [{:db/id id, :task/status status}]) nil))) 74 | (dom/props {:class "toggle"})) 75 | (dom/label (dom/text description) 76 | (dom/on "dblclick" (e/fn [_] (swap! !state assoc ::editing id))))) 77 | (when (= id (::editing state)) 78 | (dom/span (dom/props {:class "input-load-mask"}) 79 | (dom/on-pending (dom/props {:aria-busy true}) 80 | (dom/input 81 | (dom/on "keydown" 82 | (e/fn [e] 83 | (case (.-key e) 84 | "Enter" (when-some [description (contrib.str/blank->nil (-> e .-target .-value))] 85 | (case (e/server (transact! [{:db/id id, :task/description description}]) nil) 86 | (swap! !state assoc ::editing nil))) 87 | "Escape" (swap! !state assoc ::editing nil) 88 | nil))) 89 | (dom/props {:class "edit" #_#_:autofocus true}) 90 | (dom/bind-value description) ; first set the initial value, then focus 91 | (case description ; HACK sequence - run focus after description is available 92 | (.focus dom/node)))))) 93 | (ui/button (e/fn [] (e/server (transact! [[:db/retractEntity id]]) nil)) 94 | (dom/props {:class "destroy"}))))))) 95 | 96 | #?(:clj 97 | (defn toggle-all! [db status] 98 | (let [ids (query-todos db (if (= :done status) :active :done))] 99 | (map (fn [id] {:db/id id, :task/status status}) ids)))) 100 | 101 | (e/defn TodoList [state] 102 | (e/client 103 | (dom/div 104 | (dom/section (dom/props {:class "main"}) 105 | (let [active (e/server (todo-count db :active)) 106 | all (e/server (todo-count db :all)) 107 | done (e/server (todo-count db :done))] 108 | (ui/checkbox (cond (= all done) true 109 | (= all active) false 110 | :else nil) 111 | (e/fn [v] (let [status (case v (true nil) :done, false :active)] 112 | (e/server (transact! (toggle-all! db status)) nil))) 113 | (dom/props {:class "toggle-all"}))) 114 | (dom/label (dom/props {:for "toggle-all"}) (dom/text "Mark all as complete")) 115 | (dom/ul (dom/props {:class "todo-list"}) 116 | (e/for [id (e/server (sort (query-todos db (::filter state))))] 117 | (TodoItem. state id))))))) 118 | 119 | (e/defn CreateTodo [] 120 | (e/client 121 | (dom/span (dom/props {:class "input-load-mask"}) 122 | (dom/on-pending (dom/props {:aria-busy true}) 123 | (dom/input 124 | (dom/on "keydown" 125 | (e/fn [e] 126 | (when (= "Enter" (.-key e)) 127 | (when-some [description (contrib.str/empty->nil (-> e .-target .-value))] 128 | (e/server (transact! [{:task/description description, :task/status :active}]) nil) 129 | (set! (.-value dom/node) ""))))) 130 | (dom/props {:class "new-todo", :placeholder "What needs to be done?"})))))) 131 | 132 | (e/defn TodoMVC-UI [state] 133 | (e/client 134 | (dom/section (dom/props {:class "todoapp"}) 135 | (dom/header (dom/props {:class "header"}) 136 | (CreateTodo.)) 137 | (when (e/server (pos? (todo-count db :all))) 138 | (TodoList. state)) 139 | (dom/footer (dom/props {:class "footer"}) 140 | (TodoStats. state))))) 141 | 142 | (e/defn TodoMVC-body [state] 143 | (e/client 144 | (dom/div (dom/props {:class "todomvc"}) 145 | (TodoMVC-UI. state) 146 | (dom/footer (dom/props {:class "info"}) 147 | (dom/p (dom/text "Double-click to edit a todo")))))) 148 | 149 | (e/defn Diagnostics [state] 150 | (e/client 151 | (dom/h1 (dom/text "Diagnostics")) 152 | (dom/dl 153 | (dom/dt (dom/text "count :all")) (dom/dd (dom/text (pr-str (e/server (todo-count db :all))))) 154 | (dom/dt (dom/text "query :all")) (dom/dd (dom/text (pr-str (e/server (query-todos db :all))))) 155 | (dom/dt (dom/text "state")) (dom/dd (dom/text (pr-str state))) 156 | (dom/dt (dom/text "delay")) (dom/dd 157 | (ui/long (::delay state) (e/fn [v] (swap! !state assoc ::delay v)) 158 | (dom/props {:step 1, :min 0, :style {:width :min-content}})) 159 | (dom/text " ms"))))) 160 | 161 | #?(:clj 162 | (defn slow-transact! [!conn delay tx] 163 | (try (Thread/sleep delay) ; artificial latency 164 | (d/transact! !conn tx) 165 | (catch InterruptedException _)))) 166 | 167 | (e/defn TodoMVC [] 168 | (e/client 169 | (let [state (e/watch !state)] 170 | (e/server 171 | (binding [db (e/watch !conn) 172 | transact! (partial slow-transact! !conn (e/client (::delay state)))] 173 | (e/client 174 | (dom/link (dom/props {:rel :stylesheet, :href "/todomvc.css"})) 175 | ; exclude #root style from todomvc-composed by inlining here 176 | (TodoMVC-body. state) 177 | #_(Diagnostics. state))))))) 178 | 179 | (comment 180 | (todo-count @!conn :all) 181 | (todo-count @!conn :active) 182 | (todo-count @!conn :done) 183 | (query-todos @!conn :all) 184 | (query-todos @!conn :active) 185 | (query-todos @!conn :done) 186 | (d/q '[:find (count ?e) . :where [?e :task/status]] @!conn)) 187 | -------------------------------------------------------------------------------- /src/user/demo_todomvc.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | 3 | - styles 4 | - it's multiplayer! 0 LOC cost 5 | - state is durable (server side database) 0 LOC cost 6 | - managed busy states 7 | 8 | Key ideas 9 | - **entire app as a function**: with everything - frontend, backend, state, network, reactivity, dom rendering, resource lifecycle, load states. Unified into just functions. 10 | 11 | App as a function: 12 | 13 | ```clojure 14 | (e/defn TodoMVC-UI [state] 15 | (dom/section (dom/props {:class "todoapp"}) 16 | (dom/header (dom/props {:class "header"}) 17 | (CreateTodo.)) 18 | (when (e/server (pos? (todo-count db :all))) 19 | (TodoList. state)) 20 | (dom/footer (dom/props {:class "footer"}) 21 | (TodoStats. state)))) 22 | ``` 23 | 24 | - `TodoMVC-UI` is truly a function, it follows function laws, it has all forms of scope, etc 25 | - How can we test this? If it's really a function, and it really composes, that means we can call it in composed ways, like for loops, 26 | -------------------------------------------------------------------------------- /src/user/demo_todomvc_composed.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-todomvc-composed 2 | (:require #?(:clj [datascript.core :as d]) 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.electric-ui4 :as ui] 6 | [user.demo-todomvc :as todomvc])) 7 | 8 | #?(:clj (def !n (atom 1))) 9 | 10 | (e/defn PopoverCascaded [i F] 11 | (e/client 12 | (let [!focused (atom false) focused (e/watch !focused)] 13 | (dom/div (dom/props {:style {:position "absolute" 14 | :left (str (* i 40) "px") 15 | :top (str (-> i (* 40) (+ 60)) "px") 16 | :z-index (+ i (if focused 1000 0))}}) 17 | (dom/on "mouseenter" (e/fn [_] (reset! !focused true))) 18 | (dom/on "mouseleave" (e/fn [_] (reset! !focused false))) 19 | (F.))))) 20 | 21 | (e/defn TodoMVC-composed [] 22 | (e/client 23 | (let [state (e/watch todomvc/!state) 24 | n (e/server (e/watch !n))] 25 | (e/server 26 | (binding [todomvc/db (e/watch todomvc/!conn) 27 | todomvc/transact! (partial d/transact! todomvc/!conn)] 28 | (e/client 29 | (dom/link (dom/props {:rel :stylesheet, :href "/todomvc.css"})) 30 | (ui/range n (e/fn [v] (e/server (reset! !n v))) 31 | (dom/props {:min 1 :max 25 :step 1})) 32 | (dom/div (dom/props {:class "todomvc" :style {:position "relative"}}) 33 | 34 | (e/for [i (range n)] ; <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 35 | (PopoverCascaded. i 36 | (e/fn [] 37 | (todomvc/TodoMVC-UI. state))))))))))) 38 | -------------------------------------------------------------------------------- /src/user/demo_todomvc_composed.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | 3 | - The slider controls a for loop which calls the `TodoMVC-UI` function repeatedly 4 | - this demo deadlocks sometimes, refresh the page and drag the slider slower. (fix is blocked until the next major revision) 5 | 6 | Novel forms 7 | * none 8 | 9 | Key ideas 10 | - **network-transparent composition**: express your frontend/backend app logic using the full composition power of Lisp - higher order functions, closures, recursion, loops, everything. Where is all the web crap? GONE! 11 | -------------------------------------------------------------------------------- /src/user/demo_todos_simple.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-todos-simple 2 | (:require [contrib.str :refer [empty->nil]] 3 | #?(:clj [datascript.core :as d]) 4 | [hyperfiddle.electric :as e] 5 | [hyperfiddle.electric-dom2 :as dom] 6 | [hyperfiddle.electric-ui4 :as ui])) 7 | 8 | #?(:clj (defonce !conn (d/create-conn {}))) ; database on server 9 | (e/def db) ; injected database ref; Electric defs are always dynamic 10 | 11 | (e/defn TodoItem [id] 12 | (e/server 13 | (let [e (d/entity db id) 14 | status (:task/status e)] 15 | (e/client 16 | (dom/li 17 | (ui/checkbox 18 | (case status :active false, :done true) 19 | (e/fn [v] 20 | (e/server 21 | (d/transact! !conn [{:db/id id 22 | :task/status (if v :done :active)}]) 23 | nil)) 24 | (dom/props {:id id})) 25 | (dom/label (dom/props {:for id}) 26 | (dom/text (e/server (:task/description e))))))))) 27 | 28 | (e/defn InputSubmit [F] 29 | ; Custom input control using lower dom interface for Enter handling 30 | (e/client 31 | (dom/input (dom/props {:placeholder "Buy milk"}) 32 | (dom/on "keydown" (e/fn [e] 33 | (when (= "Enter" (.-key e)) 34 | (when-some [v (empty->nil (-> e .-target .-value))] 35 | (new F v) 36 | (set! (.-value dom/node) "")))))))) 37 | 38 | (e/defn TodoCreate [] 39 | (e/client 40 | (InputSubmit. (e/fn [v] 41 | (e/server 42 | (d/transact! !conn [{:task/description v 43 | :task/status :active}]) 44 | nil))))) 45 | 46 | #?(:clj (defn todo-count [db] 47 | (count 48 | (d/q '[:find [?e ...] :in $ ?status 49 | :where [?e :task/status ?status]] 50 | db :active)))) 51 | 52 | #?(:clj (defn todo-records [db] 53 | (->> (d/q '[:find [(pull ?e [:db/id :task/description]) ...] 54 | :where [?e :task/status]] db) 55 | (sort-by :task/description)))) 56 | 57 | (e/defn TodoList [] 58 | (e/server 59 | (binding [db (e/watch !conn)] 60 | (e/client 61 | (dom/div (dom/props {:class "todo-list"}) 62 | (TodoCreate.) 63 | (dom/ul (dom/props {:class "todo-items"}) 64 | (e/server 65 | (e/for-by :db/id [{:keys [db/id]} (todo-records db)] 66 | (TodoItem. id)))) 67 | (dom/p (dom/props {:class "counter"}) 68 | (dom/span (dom/props {:class "count"}) 69 | (dom/text (e/server (todo-count db)))) 70 | (dom/text " items left"))))))) 71 | -------------------------------------------------------------------------------- /src/user/demo_todos_simple.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * It's a functional todo list, the first "real app" 3 | * 4 | 5 | Novel forms 6 | * `ui/checkbox` 7 | * `binding` – reactive dynamic scope; today all Electric defs are dynamic. 8 | 9 | Key ideas 10 | * dependency injection 11 | * dynamic scope 12 | * unserializable reference transfer - `d/transact!` returns an unserializable ref which cannot be moved over network, when this happens it is typically unintentional, so instead of crashing we warn and send `nil` instead. 13 | * nested transfers, even inside a loop 14 | * query diffing 15 | -------------------------------------------------------------------------------- /src/user/demo_toggle.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-toggle 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.electric-ui4 :as ui])) 5 | 6 | #?(:clj (defonce !x (atom true))) ; server state 7 | (e/def x (e/server (e/watch !x))) ; reactive signal derived from atom 8 | 9 | (e/defn Toggle [] 10 | (e/client 11 | (dom/div 12 | (dom/text "number type here is: " 13 | (case x 14 | true (e/client (pr-str (type 1))) ; javascript number type 15 | false (e/server (pr-str (type 1)))))) ; java number type 16 | 17 | (dom/div 18 | (dom/text "current site: " 19 | (if x 20 | "ClojureScript (client)" 21 | "Clojure (server)"))) 22 | 23 | (ui/button (e/fn [] 24 | (e/server (swap! !x not))) 25 | (dom/text "toggle client/server")))) -------------------------------------------------------------------------------- /src/user/demo_toggle.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * There's a button on the frontend, with a callback, that toggles a boolean, stored in a server-side atom. 3 | * That boolean is used to switch between a client expr and a server expr. 4 | * Both exprs print the platform number type, which is either a `java.lang.Long` or a javascript `Number`. 5 | * The resulting string is streamed from server to client over network, and written through to the DOM. 6 | 7 | Novel forms 8 | * `e/def`: defines a reactive value `x`, the body is Electric code 9 | * `e/watch`: derives a reactive flow from a Clojure atom by watching for changes 10 | * `hyperfiddle.electric-ui4`: high level form controls (inputs, typeaheads, etc) with managed loading/syncing states 11 | * `ui/button`: a button with managed load state. On click, it becomes disabled until the callback result —possibly remote— is available. 12 | * `e/fn`: reactive lambda, supports client/server transfer 13 | 14 | Key Ideas 15 | * **distributed lambda**: the button callback spans both client and server. As does the `Toggle` function itself. 16 | * **"single state atom"** UI pattern, except the atom is on the server. 17 | * **reactive control flow**: `if`, `case` and other Clojure control flow forms are reactive. Here, when `x` toggles, `(case x)` will *switch* between branches. In the DAG, if-nodes look like a railroad switch: 18 | -

![railroad switch](https://clojureverse.org/uploads/default/original/2X/7/7b52e4535db802fb51a368bae4461829e7c0bfe5.jpeg)

19 | * **Clojure/Script interop**: The atom definition is ordinary Clojure code, which works because this is an ordinary `.cljc` file. 20 | 21 | Client/server value transfer 22 | * Only values can be serialized and moved across the network. 23 | * Reference types (e.g. atoms, database connections, Java classes) are unserializable and therefore cannot be moved. 24 | * Quiz: in `(e/server (pr-str (type 1)))`, why do we `pr-str` on the server? Hint: what type is `java.lang.Long`? 25 | * Quiz: in `(e/def x (e/server (e/watch !x)))`, why do we `e/watch` on the server? 26 | 27 | We target full Clojure/Script compatibility 28 | * That means, any valid Clojure expression, when pasted into an Electric body, will evaluate to the same result, and produce the same side effects, in the same order. Electric is additive Clojure, it takes nothing away. 29 | * To achieve this, Electric implements a proper Clojure/Script analyzer to support all Clojure special forms. 30 | * There are minor edge cases (especially as our compiler matures), mostly inconsequential. 31 | * macros work, side effects work, platform interop works, data structures are the same, clojure.core works 32 | * **It's just Clojure!** -------------------------------------------------------------------------------- /src/user/demo_two_clocks.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-two-clocks 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom])) 4 | 5 | (e/defn TwoClocks [] 6 | (e/client 7 | (let [c (e/client e/system-time-ms) 8 | s (e/server e/system-time-ms)] 9 | 10 | (dom/div (dom/text "client time: " c)) 11 | (dom/div (dom/text "server time: " s)) 12 | (dom/div (dom/text "difference: " (- s c)))))) -------------------------------------------------------------------------------- /src/user/demo_two_clocks.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | 3 | * There are two reactive clocks, one on the frontend and one on the server 4 | * When a clock updates, the view incrementally recomputes to stay consistent 5 | * The server clock streams to the client over websocket 6 | * The expression is full-stack – it has frontend parts and backend parts 7 | * The Electric compiler infers the backend/frontend boundary and generates the full-stack app (client and server that coordinate) 8 | * Network sync is automatic and invisible 9 | 10 | Novel forms 11 | 12 | * `e/defn`: defines an Electric function, which is reactive. Electric fns follow all the same rules as ordinary Clojure functions. 13 | * `e/client`, `e/server`: compile time markers, valid in any Electric body 14 | * `e/system-time-ms`: reactive clock, defined with [Missionary](https://github.com/leonoel/missionary) 15 | * `hyperfiddle.electric-dom`: reactive DOM rendering combinators 16 | 17 | Key ideas 18 | 19 | * **multi-tier**: the `TwoClocks` function spans both frontend and backend, which are developed together in a single programming language and compilation unit. See [Multitier programming (wikipedia)](https://en.wikipedia.org/wiki/Multitier_programming) 20 | * **reactive**: the function body is reactive, keeping the DOM in sync with the clocks. Both the frontend and backend parts of the function are reactive. 21 | * **network-transparent**: the network is also reactive. the function transmits data over the network (as implied by the AST) in a way which is invisible to the application programmer. See: [Network transparency (wikipedia)](https://en.wikipedia.org/wiki/Network_transparency) 22 | * **streaming lexical scope**: this is not RPC (request/response), that would be too slow. The server streams `s` without being asked, because it knows the client depends on it. If the client had to request each server clock tick, the timer would pause visibly between each request. 23 | * **dom rendering is free**: Electric is already a general-purpose reactive engine, so electric-dom is nearly trivial, it's just 300 LOC of mostly syntax helpers only. There is neither virtual dom, reconcilier, nor diffing. 24 | 25 | Electric is a reactivity compiler 26 | 27 | * Electric has a DAG-based reactive evaluation model for fine-grained reactivity. 28 | * Electric uses macros to compile actual Clojure syntax into a DAG, using an actual Clojure/Script analyzer. 29 | * Unlike React.js, reactivity is granular to the expression level, not the function level. 30 | * (This is unlike React.js, where reactivity is granular to the function level.) 31 | 32 | Electric code is analyzed at the expression level. 33 | 34 | * `e/defn` defines functions that Electric can analyze the body of 35 | * Each expression, e.g. `(- s c)`, is a node in the DAG. 36 | * Each expression is async 37 | * Each expression is reactive 38 | * Arguments, e.g. `s`, is an edge in the DAG. 39 | * Expressions are recomputed when any argument updates. 40 | 41 | To visualize the DAG, node `(- s c)` has: 42 | * three incoming edges `#{- s c}`, and 43 | * one outgoing edge—the anonymous result—pointing to `(dom/text "skew: " _)`. 44 | * So, if `s` changes, `(- s c)` is recomputed using memoized `c`, and then the `dom/text` reruns (a point write). 45 | 46 | There is an isomorphism between programs and DAGs 47 | 48 | * you already knew this, if you think about it – see [call graph (wikipedia)](https://en.wikipedia.org/wiki/Call_graph) 49 | * The DAG is an abstract representation of a program 50 | * The DAG contains everything there is to know about the flow of data through the Electric program 51 | * Electric uses this DAG to drive reactivity, so we sometimes call the DAG a "reactivity graph". 52 | * But in theory, this DAG is abstract and there could be evaluated (interpreted or compiled) in many ways. 53 | * E.g., in addition to driving reactivity, Electric uses the DAG to drive network topology, which is just a graph coloring problem. 54 | 55 | Network is reactive at the granularity of individual scope values 56 | * when server clock `s` updates, the new value is streamed over network, bound to `s` on the client, ... 57 | * Everything is already async, so adding a 10ms websocket delay does not add impedance, complexity or code weight! 58 | 59 | For a 10min video explainer, see [UIs are streaming DAGs](https://hyperfiddle.notion.site/UIs-are-streaming-DAGs-e181461681a8452bb9c7a9f10f507991). -------------------------------------------------------------------------------- /src/user/demo_virtual_scroll.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-virtual-scroll 2 | (:require [contrib.data :refer [unqualify]] 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.electric-ui4 :as ui] 6 | #?(:cljs goog.object))) 7 | 8 | (e/defn DemoFixedHeightCounted 9 | "Scrolls like google sheets. this can efficiently jump through a large indexed collection" 10 | [] 11 | (let [row-count 500 12 | xs (vec (range row-count)) ; counted 13 | page-size 100 14 | row-height 22] ; todo use relative measurement (browser zoom impacts px height) 15 | (e/client 16 | (dom/div (dom/props {:class "viewport" :style {:overflowX "hidden" :overflowY "auto"}}) 17 | (let [[scrollTop] (new (ui/scroll-state< dom/node)) 18 | max-height (* row-count row-height) 19 | clamped-scroll-top (js/Math.min scrollTop max-height) 20 | start (/ clamped-scroll-top row-height)] ; (js/Math.floor) 21 | (dom/div (dom/props {:style {:height (str (* row-height row-count) "px") ; optional absolute scrollbar 22 | :padding-top (str clamped-scroll-top "px") ; seen elements are replaced with padding 23 | :padding-bottom (str (- max-height clamped-scroll-top) "px")}}) 24 | (e/server 25 | ; seen elements are unmounted 26 | (e/for [x #_(subvec xs 27 | (Math/min start row-count) 28 | (Math/min (+ start page-size) row-count)) 29 | (->> xs (drop start) (take page-size))] 30 | (e/client (dom/div (dom/text x))))))))))) 31 | 32 | (e/defn DemoVariableHeightInfinite 33 | "Scrolls like newsfeed. Natural browser layout for variable height rows. Leaves seen elements 34 | mounted in the DOM." 35 | [] 36 | (let [xs (range) ; infinite 37 | page-size 100] 38 | (e/client 39 | (dom/div (dom/props {:class "viewport"}) 40 | (let [!pages (atom 1) pages (e/watch !pages) 41 | [scrollTop scrollHeight clientHeight] (new (ui/scroll-state< dom/node))] 42 | (when (>= scrollTop (- scrollHeight clientHeight clientHeight)) ; scrollThresholdPx = clientHeight 43 | (swap! !pages inc)) ; can this get spammed by Electric? 44 | (dom/div ; content is unstyled, uses natural layout 45 | (e/server 46 | (e/for [x (->> xs (take (* pages page-size)))] ; leave dom 47 | (e/client (dom/div (dom/text x))))))))))) 48 | 49 | #?(:clj (defonce !demo (atom {:text "DemoFixedHeightCounted" ::value `DemoFixedHeightCounted}))) 50 | 51 | (e/def demo (e/server (e/watch !demo))) 52 | 53 | (e/def demos {`DemoVariableHeightInfinite DemoVariableHeightInfinite 54 | `DemoFixedHeightCounted DemoFixedHeightCounted}) 55 | 56 | (e/defn VirtualScroll [] 57 | (e/client 58 | ; Requires css {box-sizing: border-box;} 59 | (dom/element "style" (dom/text ".header { position: fixed; z-index:1; top: 0; left: 0; right: 0; height: 100px; background-color: #abcdef; }" 60 | ".footer { position: fixed; bottom: 0; left: 0; right: 0; height: 100px; background-color: #abcdef; }" 61 | ".viewport { position: fixed; top: 100px; bottom: 100px; left: 0; right: 0; background-color: #F63; overflow: auto; }")) 62 | (dom/div (dom/props {:class "header"}) 63 | (dom/dl 64 | (dom/dt (dom/text "scroll debug state")) 65 | (dom/dd (dom/pre (dom/text (pr-str (update-keys (e/watch ui/!scrollStateDebug) unqualify)))))) 66 | (e/server 67 | (ui/select 68 | demo 69 | (e/fn V! [v] (reset! !demo v)) 70 | (e/fn Options [] [{:text "DemoFixedHeightCounted" ::value `DemoFixedHeightCounted} 71 | {:text "DemoVariableHeightInfinite" ::value `DemoVariableHeightInfinite}]) 72 | (e/fn OptionLabel [x] (:text x))))) 73 | (e/server (new (get demos (::value demo)))) 74 | (dom/div (dom/props {:class "footer"}) 75 | (dom/text "Try scrolling to the top, and resizing the window.")))) 76 | -------------------------------------------------------------------------------- /src/user/demo_webview.cljc: -------------------------------------------------------------------------------- 1 | (ns user.demo-webview 2 | (:require #?(:clj [datascript.core :as d]) 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.electric-ui4 :as ui])) 6 | 7 | #?(:clj 8 | (defonce conn ; state survives reload 9 | (doto (d/create-conn {:order/email {}}) 10 | (d/transact! ; test data 11 | [{:order/email "alice@example.com" :order/gender :order/female} 12 | {:order/email "bob@example.com" :order/gender :order/male} 13 | {:order/email "charlie@example.com" :order/gender :order/male}])))) 14 | 15 | #?(:clj 16 | (defn teeshirt-orders [db ?email] 17 | (sort 18 | (d/q '[:find [?e ...] 19 | :in $ ?needle :where 20 | [?e :order/email ?email] 21 | [(clojure.string/includes? ?email ?needle)]] 22 | db (or ?email ""))))) 23 | 24 | (e/defn Teeshirt-orders-view [db] 25 | (e/client 26 | (dom/div 27 | (let [!search (atom ""), search (e/watch !search)] 28 | (ui/input search (e/fn [v] (reset! !search v)) 29 | (dom/props {:placeholder "Filter..."})) 30 | (dom/table (dom/props {:class "hyperfiddle"}) 31 | (e/server 32 | (e/for [id (teeshirt-orders db search)] 33 | (let [!e (d/entity db id)] 34 | (e/client 35 | (dom/tr 36 | (dom/td (dom/text id)) 37 | (dom/td (dom/text (e/server (:order/email !e)))) 38 | (dom/td (dom/text (e/server (:order/gender !e)))))))))))))) 39 | 40 | (e/defn Webview [] 41 | (e/server 42 | (let [db (e/watch conn)] ; reactive "database value" 43 | (Teeshirt-orders-view. db)))) 44 | 45 | (comment 46 | #?(:clj (d/transact conn [{:db/id 2 :order/email "bob2@example.com"}])) 47 | #?(:clj (d/transact conn [{:order/email "dan@example.com"}])) 48 | #?(:clj (d/transact conn [{:order/email "erin@example.com"}])) 49 | #?(:clj (d/transact conn [{:order/email "frank@example.com"}])) 50 | ) 51 | -------------------------------------------------------------------------------- /src/user/demo_webview.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * The webview is subscribed to the database, which updates with each transaction. 3 | * If you ran the transact forms at the bottom in your REPL, the view would update reactively. 4 | 5 | Novel forms 6 | * `e/watch` on datascript connection 7 | 8 | Key ideas 9 | * Datascript is on the server, it can be any database 10 | * Direct query/view composition, with a loop -------------------------------------------------------------------------------- /src/user/example_datascript_db.clj: -------------------------------------------------------------------------------- 1 | ;; An example databsae of tee-shirt orders 2 | ;; server side only - for demos 3 | (ns user.example-datascript-db 4 | (:require [datascript.core :as d] 5 | [datascript.impl.entity :as de] ; for `entity?` predicate 6 | [hyperfiddle.api :as hf] 7 | [hyperfiddle.rcf :refer [tests % tap]])) 8 | 9 | (def schema ; user orders a tee-shirt and select a tee-shirt gender and size + optional tags 10 | ;; :hf/valueType is an annotation, used by hyperfiddle to auto render a UI (To be improved. Datascript only accepts :db/valueType on refs) 11 | {:order/email {:hf/valueType :db.type/string, :db/cardinality :db.cardinality/one, :db/unique :db.unique/identity} 12 | :order/gender {:db/valueType :db.type/ref, :db/cardinality :db.cardinality/one} 13 | :order/shirt-size {:db/valueType :db.type/ref, :db/cardinality :db.cardinality/one} 14 | :order/type {:db/cardinality :db.cardinality/one} 15 | :order/tags {:db/cardinality :db.cardinality/many} 16 | :db/ident {:db/unique :db.unique/identity, :hf/valueType :db.type/keyword}}) 17 | 18 | (defn fixtures [db] 19 | (-> db 20 | ;; Add tee-shirt types 21 | (d/with [{:db/id 1, :order/type :order/gender, :db/ident :order/male} ; straight cut 22 | {:db/id 2, :order/type :order/gender, :db/ident :order/female}]) ; fitted 23 | :db-after 24 | ;; Add tee-shirt sizes by type 25 | (d/with [{:db/id 3 :order/type :order/shirt-size :db/ident :order/mens-small :order/gender :order/male} 26 | {:db/id 4 :order/type :order/shirt-size :db/ident :order/mens-medium :order/gender :order/male} 27 | {:db/id 5 :order/type :order/shirt-size :db/ident :order/mens-large :order/gender :order/male} 28 | {:db/id 6 :order/type :order/shirt-size :db/ident :order/womens-small :order/gender :order/female} 29 | {:db/id 7 :order/type :order/shirt-size :db/ident :order/womens-medium :order/gender :order/female} 30 | {:db/id 8 :order/type :order/shirt-size :db/ident :order/womens-large :order/gender :order/female}]) 31 | :db-after 32 | ;; Add example orders 33 | (d/with [{:db/id 9, :order/email "alice@example.com", :order/gender :order/female, :order/shirt-size :order/womens-large, :order/tags [:a :b :c]} 34 | {:db/id 10, :order/email "bob@example.com", :order/gender :order/male, :order/shirt-size :order/mens-large, :order/tags [:b]} 35 | {:db/id 11, :order/email "charlie@example.com", :order/gender :order/male, :order/shirt-size :order/mens-medium}]) 36 | :db-after)) 37 | 38 | (declare conn) 39 | 40 | (defn setup-db! [] 41 | (def conn (d/create-conn schema)) 42 | (alter-var-root #'hf/*$* (constantly (fixtures (d/db conn))))) 43 | 44 | (setup-db!) 45 | 46 | 47 | (def db hf/*$*) ; for @(requiring-resolve 'user.example-datascript-db/db) 48 | 49 | (defn get-schema [db a] (get (:schema db) a)) 50 | 51 | (defn nav! 52 | ([_ e] e) 53 | ([db e a] (let [v (a (if (de/entity? e) e (d/entity db e)))] 54 | (if (de/entity? v) 55 | (or (:db/ident v) (:db/id v)) 56 | v))) 57 | ([db e a & as] (reduce (partial nav! db) (nav! db e a) as))) 58 | 59 | (def male 1 #_:order/male #_17592186045418) 60 | (def female 2 #_:order/female #_17592186045419) 61 | (def m-sm 3 #_17592186045421) 62 | (def m-md 4 #_nil) 63 | (def m-lg 5 #_nil) 64 | (def w-sm 6 #_nil) 65 | (def w-md 7 #_nil) 66 | (def w-lg 8 #_nil) 67 | (def alice 9 #_17592186045428) 68 | (def bob 10 #_nil) 69 | (def charlie 11 #_nil) 70 | 71 | (comment 72 | (hyperfiddle.rcf/enable!)) 73 | 74 | (tests 75 | (def e [:order/email "alice@example.com"]) 76 | 77 | (tests 78 | "(d/pull ['*]) is best for tests" 79 | (d/pull db ['*] e) 80 | := {:db/id 9, 81 | :order/email "alice@example.com", 82 | :order/shirt-size #:db{:id 8}, 83 | :order/gender #:db{:id 2} 84 | :order/tags [:a :b :c]}) 85 | 86 | (comment #_tests 87 | "careful, entity type is not= to equivalent hashmap" 88 | (d/touch (d/entity db e)) 89 | ; expected failure 90 | := {:order/email "alice@example.com", 91 | :order/gender #:db{:id 2}, 92 | :order/shirt-size #:db{:id 8}, 93 | :order/tags #{:c :b :a}, 94 | :db/id 9}) 95 | 96 | (tests 97 | "entities are not maps" 98 | (type (d/touch (d/entity db e))) 99 | *1 := datascript.impl.entity.Entity) ; not a map 100 | 101 | (comment #_tests 102 | "careful, entity API tests are fragile and (into {}) is insufficient" 103 | (->> (d/touch (d/entity db e)) ; touch is the best way to inspect an entity 104 | (into {})) ; but it's hard to convert to a map... 105 | := #:order{#_#_:id 9 ; db/id is not present! 106 | :email "alice@example.com", 107 | :gender _ #_#:db{:id 2}, ; entity ref not =, RCF can’t unify with entities 108 | :shirt-size _ #_#:db{:id 8}, ; entity ref not = 109 | :tags #{:c :b :a}} 110 | 111 | "select keys doesn't fix the problem as it's not recursive" 112 | (-> (d/touch (d/entity db e)) 113 | (select-keys [:order/email :order/shirt-size :order/gender])) 114 | := #:order{:email "alice@example.com", 115 | :shirt-size _ #_#:db{:id 8}, ; still awkward, need recursive pull 116 | :gender _ #_#:db{:id 2}}) ; RCF can’t unify with an entities 117 | 118 | "TLDR is use (d/pull ['*]) like the first example" 119 | (tests 120 | (d/pull db ['*] :order/female) 121 | := {:db/id female :db/ident :order/female :order/type :order/gender}) 122 | 123 | (tests 124 | (d/q '[:find [?e ...] :where [_ :order/gender ?e]] db) 125 | := [2 1] #_[:order/male :order/female]) 126 | ) 127 | -------------------------------------------------------------------------------- /src/user/tutorial_7guis_1_counter.cljc: -------------------------------------------------------------------------------- 1 | (ns user.tutorial-7guis-1-counter 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.electric-ui4 :as ui])) 5 | 6 | (e/defn Counter [] 7 | (e/client 8 | (let [!state (atom 0)] 9 | (dom/p (dom/text (e/watch !state))) 10 | (ui/button (e/fn [] (swap! !state inc)) 11 | (dom/text "Count"))))) -------------------------------------------------------------------------------- /src/user/tutorial_7guis_2_temperature.cljc: -------------------------------------------------------------------------------- 1 | (ns user.tutorial-7guis-2-temperature 2 | (:require clojure.math 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.electric-ui4 :as ui] 6 | [missionary.core :as m])) 7 | 8 | (defn c->f [c] (+ (* c (/ 9 5)) 32)) 9 | (defn f->c [f] (* (- f 32) (/ 5 9))) 10 | (defn random-value [_] (m/sp (m/? (m/sleep 1000)) (rand-int 40))) 11 | 12 | (e/defn TemperatureConverter [] 13 | (e/client 14 | (let [!t (atom 0), t (e/watch !t)] 15 | (reset! !t (new (e/task->cp (random-value t)))) ; test concurrent updates 16 | (dom/dl 17 | (dom/dt (dom/text "Celsius")) 18 | (dom/dd (ui/long (clojure.math/round t) 19 | (e/fn [v] (reset! !t v)))) 20 | (dom/dt (dom/text "Farenheit")) 21 | (dom/dd (ui/long (clojure.math/round (c->f t)) 22 | (e/fn [v] (reset! !t (f->c v))))))))) -------------------------------------------------------------------------------- /src/user/tutorial_7guis_4_timer.cljc: -------------------------------------------------------------------------------- 1 | (ns user.tutorial-7guis-4-timer 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.electric-ui4 :as ui4])) 5 | 6 | (def initial-goal 10) 7 | (defn seconds [milliseconds] (/ (Math/floor (/ milliseconds 100)) 10)) 8 | (defn second-precision [milliseconds] 9 | (-> milliseconds (/ 1000) (Math/floor) (* 1000))) ; drop milliseconds 10 | 11 | (defn now [] #?(:cljs (second-precision (js/Date.now)))) 12 | 13 | (e/defn Timer [] 14 | (e/client 15 | (let [!goal (atom initial-goal) 16 | !start (atom (now)) 17 | goal (e/watch !goal) 18 | goal-ms (* 1000 goal) 19 | start (e/watch !start) 20 | time (min goal-ms (- (second-precision e/system-time-ms) start))] 21 | (dom/div (dom/props {:style {:display :grid ;:margin-left "20rem" 22 | :width "20em" 23 | :grid-gap "0 1rem" 24 | :align-items :center}}) 25 | (dom/span (dom/text "Elapsed Time:")) 26 | (dom/progress (dom/props {:max goal-ms, :value time, :style {:grid-column 2}})) 27 | (dom/span (dom/text (seconds time) " s")) 28 | (dom/span (dom/props {:style {:grid-row 3}}) (dom/text "Duration: " goal "s")) 29 | (ui4/range goal (e/fn [v] (reset! !goal v)) 30 | (dom/props {:min 0, :max 60, :style {:grid-row 3}})) 31 | (ui4/button (e/fn [] (reset! !start (now))) 32 | (dom/props {:style {:grid-row 4, :grid-column "1/3"}}) 33 | (dom/text "Reset")))))) 34 | -------------------------------------------------------------------------------- /src/user/tutorial_7guis_5_crud.cljc: -------------------------------------------------------------------------------- 1 | (ns user.tutorial-7guis-5-crud 2 | (:require [clojure.string :as str] 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-dom2 :as dom] 5 | [hyperfiddle.electric-ui4 :as ui4])) 6 | 7 | (def !state (atom {:selected nil 8 | :stage {:name "" 9 | :surname ""} 10 | :names (sorted-map 0 {:name "Emil", :surname "Hans"})})) 11 | 12 | (def next-id (partial swap! (atom 0) inc)) 13 | 14 | (defn select! [id] 15 | (swap! !state (fn [state] 16 | (assoc state :selected id 17 | :stage (get-in state [:names id]))))) 18 | 19 | (defn set-name! [name] 20 | (swap! !state assoc-in [:stage :name] name)) 21 | 22 | (defn set-surname! [surname] 23 | (swap! !state assoc-in [:stage :surname] surname)) 24 | 25 | (defn create! [] 26 | (swap! !state (fn [{:keys [stage] :as state}] 27 | (-> state 28 | (update :names assoc (next-id) stage) 29 | (assoc :stage {:name "", :surname ""}))))) 30 | (defn delete! [] 31 | (swap! !state (fn [{:keys [selected] :as state}] 32 | (update state :names dissoc selected)))) 33 | 34 | (defn update! [] 35 | (swap! !state (fn [{:keys [selected stage] :as state}] 36 | (assoc-in state [:names selected] stage)))) 37 | 38 | (defn filter-names [names-map needle] 39 | (if (empty? needle) 40 | names-map 41 | (let [needle (str/lower-case needle)] 42 | (reduce-kv (fn [r k {:keys [name surname]}] 43 | (if (or (str/includes? (str/lower-case name) needle) 44 | (str/includes? (str/lower-case surname) needle)) 45 | r 46 | (dissoc r k))) 47 | names-map names-map)))) 48 | 49 | (e/defn CRUD [] 50 | (e/client 51 | (let [state (e/watch !state) 52 | selected (:selected state)] 53 | (dom/div (dom/props {:style {:display :grid 54 | :grid-gap "0.5rem" 55 | :align-items :baseline 56 | :grid-template-areas "'a b c c'\n 57 | 'd d e f'\n 58 | 'd d g h'\n 59 | 'd d i i'\n 60 | 'j j j j'"}}) 61 | (dom/span (dom/props {:style {:grid-area "a"}}) 62 | (dom/text "Filter prefix:")) 63 | (let [!needle (atom ""), needle (e/watch !needle)] 64 | (ui4/input needle (e/fn [v] (reset! !needle v)) 65 | (dom/props {:style {:grid-area "b"}})) 66 | (dom/ul (dom/props {:style {:grid-area "d" 67 | :background-color :white 68 | :list-style-type :none 69 | :padding 0 70 | :border "1px gray solid" 71 | :height "100%"}}) 72 | (e/for [entry (filter-names (:names state) needle)] 73 | (let [id (key entry) 74 | value (val entry)] 75 | (dom/li (dom/text (:surname value) ", " (:name value)) 76 | (dom/props 77 | {:style {:cursor :pointer 78 | :color (if (= selected id) :white :inherit) 79 | :background-color (if (= selected id) :blue :inherit) 80 | :padding "0.1rem 0.5rem"}}) 81 | (dom/on "click" (e/fn [_] (select! id)))))))) 82 | (let [stage (:stage state)] 83 | (dom/span (dom/props {:style {:grid-area "e"}}) (dom/text "Name:")) 84 | (ui4/input (:name stage) (e/fn [v] (set-name! v)) 85 | (dom/props {:style {:grid-area "f"}})) 86 | (dom/span (dom/props {:style {:grid-area "g"}}) (dom/text "Surname:")) 87 | (ui4/input (:surname stage) (e/fn [v] (set-surname! v)) 88 | (dom/props {:style {:grid-area "h"}}))) 89 | (dom/div (dom/props 90 | {:style {:grid-area "j" 91 | :display :grid 92 | :grid-gap "0.5rem" 93 | :grid-template-columns "auto auto auto 1fr"}}) 94 | (ui4/button (e/fn [] (create!)) (dom/text "Create")) 95 | (ui4/button (e/fn [] (update!)) (dom/text "Update") 96 | (dom/props {:disabled (not selected)})) 97 | (ui4/button (e/fn [] (delete!)) (dom/text "Delete") 98 | (dom/props {:disabled (not selected)}))))))) 99 | -------------------------------------------------------------------------------- /src/user/tutorial_backpressure.cljc: -------------------------------------------------------------------------------- 1 | (ns user.tutorial-backpressure 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom])) 4 | 5 | (e/defn Backpressure [] 6 | (e/client 7 | (let [c (e/client e/system-time-secs) 8 | s (e/server (double e/system-time-secs))] 9 | 10 | (println "s" (int s)) 11 | (println "c" (int c)) 12 | 13 | (dom/div (dom/text "client time: " c)) 14 | (dom/div (dom/text "server time: " s)) 15 | (dom/div (dom/text "difference: " (.toPrecision (- s c) 2)))))) -------------------------------------------------------------------------------- /src/user/tutorial_backpressure.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * The timer `e/system-time-secs` is a float and updates at the browser animation rate, let's say 120hz. 3 | * Clocks are printed to both the DOM (at 120hz) and also the browser console (at 1hz) 4 | * The clocks pause when the browser page is not visible (i.e. you switch tabs), confirm it 5 | 6 | Novel forms 7 | * None 8 | 9 | Key ideas 10 | * **work-skipping**: expressions are only recomputed if an argument actually changes, otherwise the previous value is reused. Here, the truncated clock timer does not needlessly spam the println; downstream nodes are only recomputed on the transition. 11 | * **signals**: Electric reactive functions model *signals*, not *streams*. Streams are like mouse clicks or database transactions – you can't skip one. Signals are like mouse coordinates, audio signals or game animations – you mostly only care about latest, nobody wants an animation frame from 2 seconds ago. 12 | * **the reactive clocks are lazy**: the clocks do not tick until the previous tick was *sampled* (i.e. consumed). That means if the rendering process falls behind the requestAnimationFrame tick rate, it will simply skip frames like a video game. 13 | * **lazy sampling**: All electric expressions are lazily sampled, not just the clocks. Electric fns don't compute anything at all until sampled by the Electric entrypoint. 14 | 15 | Clock details 16 | * `e/system-time-secs` is implemented as a missionary flow. 17 | * on the client, the underlying clock is implemented with `requestAnimationFrame` such that it only schedules work once the current tick has been sampled. 18 | * That means, if you switch to another tab, the browser will stop scheduling animation frames and the clock will pause. 19 | * The server clock is implemented similarly, it will tick as fast as possible but only when sampled. If nobody is consuming the clock — i.e. no websockets are connected — the clock will not tick at all! 20 | 21 | Work-skipping 22 | * We use `int` to truncate the precision. The truncation is performed at 120hz, as we want to switch *precisely* on the rising edge of the transition. 23 | * Since `int` is recomputing at 120hz, that means anything immediately downstream will be checked 120 times per second 24 | * so both `(println "s" (int s))` and `(- s c)` are both checked at 120hz 25 | * However, expressions only run if at least one argument has changed 26 | * So, at this point the memoization kicks in and we will skip the work, this is called "work-skipping" 27 | * `(println "s" (int s))` prints at 1hz not 120hz 28 | 29 | Lazy sampling 30 | * Q: Truncating at 120hz is inefficient right? Why not use `(js/setTimeout 1000)` to make a slower clock? 31 | * A: Actually, the fast clock is perfectly efficient due to Electric's backpressure. 32 | * If the rendering process can't keep up with the requestAnimationFrame tick rate, it will simply skip frames, and this will actually slow down the clock because the clock itself is lazy too. 33 | * This is a form of backpressure. See [Streams vs Signals](https://www.dustingetz.com/#/page/signals%20vs%20streams%2C%20in%20terms%20of%20backpressure%20%282023%29) 34 | * Note: Computers can do 3D transforms in realtime, running this little clock at the browser animation rate is negligible. 35 | * If your computer is powered on and plugged in, you generally want to animate at the highest framerate the device is capable of. This is Electric's default behavior out of the box. 36 | 37 | Backpressure 38 | * What if you're on an old mobile device that can't keep up with the server clock streaming? 39 | * In this case, incoming messages from server will saturate the websocket buffer, 40 | * then the browser will propagate backpressure at the TCP layer, 41 | * then the server will decrease the *sampling rate*. 42 | * Same behavior in the opposite direction (e.g if the client generates lots of events and the server is overloaded). 43 | * See: [Signals vs Streams, in terms of backpressure](https://www.dustingetz.com/#/page/signals%20vs%20streams%2C%20in%20terms%20of%20backpressure%20%282023%29) 44 | * Electric is a Clojure to [Missionary](https://github.com/leonoel/missionary) compiler; it compiles lifts each Clojure form into a Missionary continuous flow. 45 | * Thereby automatically backpressuring every point in the Electric DAG. 46 | * If you want to customize the backpressure locally at any point — maybe you want to run a chat view with realtime network but run a slow query less aggressively — you can drop down into missionary's rich suite of concurrency combinators. 47 | -------------------------------------------------------------------------------- /src/user/tutorial_lifecycle.cljc: -------------------------------------------------------------------------------- 1 | (ns user.tutorial-lifecycle 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom])) 4 | 5 | (e/defn BlinkerComponent [] 6 | (e/client 7 | (dom/h1 (dom/text "blink!")) 8 | (println 'component-did-mount) 9 | (e/on-unmount #(println 'component-will-unmount)))) 10 | 11 | (e/defn Lifecycle [] 12 | (e/client 13 | (if (= 0 (int (mod e/system-time-secs 2))) 14 | (BlinkerComponent.)))) 15 | -------------------------------------------------------------------------------- /src/user/tutorial_lifecycle.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | 3 | * The string "blink!" is being mounted/unmounted every 2 seconds 4 | * The mount/unmount "component lifecycle" is logged to the browser console with `println` 5 | * `(BlinkerComponent.)` is being constructed and destructed 6 | 7 | Novel forms 8 | 9 | * `new`: calls an `e/fn` or `e/defn`. Here, `(BlinkerComponent.)` is desugared by Clojure to `(new BlinkerComponent)`, these two forms are identical. 10 | * `e/on-unmount` : takes a regular (non-reactive) function to run before unmount. 11 | * Why no `e/mount`? The `println` here runs on mount without extra syntax needed, we'd like to see a concrete use case not covered by this. 12 | 13 | Key ideas 14 | 15 | * **Electric functions have object lifecycle**: Reactive expressions have a "mount" and "unmount" lifecycle. `println` here runs on "mount" and never again since it has only constant arguments, unless the component is destroyed and recreated. 16 | * **Call Electric fns with `new`**: Reagent has ctor syntax too; in Reagent we call components with square brackets. This syntax distinguishes between calling Electric fns vs ordinary Clojure fns. To help remember, we capitalize Electric functions, same as Reagent/React components. 17 | * **Electric fns are both functions and objects**: They compose as functions, they have object lifecycle, and they have state. From here on we will refer to Electric fns as both "function" or "object" as appropriate, depending on which aspects are under discussion. We also sometimes refer to "calling" Electric fns as "booting" or "mounting". 18 | * **DAG as a value**: Electric lambdas `e/fn` are values, these can be thought of as "higher order DAGs", "DAG values" or "pieces of DAG". 19 | * **process supervision** 20 | 21 | New 22 | 23 | * Electric `new` is backwards compatible with Clojure/Script's new; if you pass it a class it will do the right thing. 24 | * Q: Why do we need syntax to call Electric fns, why not just use metadata on the var? A: Because lambdas. 25 | * Electric expressions can call both Electric lambdas and ordinary Clojure lambdas (like the sharp-lambda passed to e-unmount). 26 | * Due to Clojure being dynamically typed, there's no static information available for the compiler to infer the right call convention in this case. 27 | * That's why Reagent uses `[F]` and Electric uses `(F.)`. Note both capitalize `F`! 28 | 29 | Dynamic extent 30 | 31 | - Electric objects have *dynamic extent*. 32 | - "Dynamic extent refers to things that exist for a fixed period of time and are explicitly “destroyed” at the end of that period, usually when control returns to the code that created the thing." — from [On the Perils of Dynamic Scope (Sierra 2013)](https://stuartsierra.com/2013/03/29/perils-of-dynamic-scope) 33 | - Like [RAII](https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization), this lifecycle is deterministic and intended for performing resource management effects. 34 | 35 | Process supervision 36 | - Electric `if` and other control flow nodes will mount and unmount their child branches (like switching the railroad track). 37 | - If an `e/fn` were to be booted inside of an `if`, the lifetime of the booted lambda is the duration for which the branch of the `if` is active. 38 | - Electric objects can manage references (e.g. DOM node or atom in lexical scope). 39 | - A managed reference's lifetime is tied to the supervising object's lifetime. 40 | 41 | Object state 42 | 43 | - Recall that Electric functions are auto-memoized. This memo buffer can be seen as the *object state*. 44 | - The memo buffer is discarded and reset when this happens. 45 | - In other words: Electric flows are not [*history sensitive*](https://blog.janestreet.com/breaking-down-frp/). (I hesitate to link to this article from 2014 because it contains confusion/FUD around the importance of continuous time, but the coverage of history sensitivity is good.) -------------------------------------------------------------------------------- /src/user_main.cljc: -------------------------------------------------------------------------------- 1 | (ns user-main 2 | (:require clojure.edn 3 | clojure.string 4 | contrib.data 5 | contrib.ednish 6 | contrib.uri ; data_readers 7 | #?(:clj markdown.core) 8 | [contrib.electric-codemirror :refer [CodeMirror]] 9 | [hyperfiddle.electric :as e] 10 | [hyperfiddle.electric-dom2 :as dom] 11 | [hyperfiddle.electric-svg :as svg] 12 | [hyperfiddle.history :as history] 13 | user.demo-index 14 | 15 | user.demo-two-clocks 16 | user.demo-toggle 17 | user.demo-system-properties 18 | user.demo-chat 19 | user.demo-chat-extended 20 | user.demo-webview 21 | user.demo-todomvc 22 | user.demo-todomvc-composed 23 | 24 | user.demo-explorer 25 | #_wip.demo-explorer2 26 | user.demo-10k-dom 27 | user.demo-svg 28 | user.demo-todos-simple 29 | user.tutorial-7guis-1-counter 30 | user.tutorial-7guis-2-temperature 31 | user.tutorial-7guis-4-timer 32 | user.tutorial-7guis-5-crud 33 | user.demo-virtual-scroll 34 | user.demo-color 35 | user.demo-tic-tac-toe 36 | user.tutorial-lifecycle 37 | user.tutorial-backpressure 38 | #_wip.demo-branched-route 39 | #_wip.hfql 40 | wip.tag-picker 41 | #_wip.teeshirt-orders ; sensitive to electric dep, HFQL moved 42 | wip.demo-custom-types 43 | wip.tracing 44 | user.demo-reagent-interop ; yarn 45 | wip.demo-stage-ui4 ; yarn 46 | wip.js-interop)) 47 | 48 | (e/defn NotFoundPage [] 49 | (e/client (dom/h1 (dom/text "Page not found")))) 50 | 51 | ; todo: macro to auto-install demos by attaching clj metadata to e/defn vars? 52 | 53 | (e/def pages 54 | {`user.demo-two-clocks/TwoClocks user.demo-two-clocks/TwoClocks 55 | `user.demo-toggle/Toggle user.demo-toggle/Toggle 56 | `user.demo-system-properties/SystemProperties user.demo-system-properties/SystemProperties 57 | `user.demo-chat/Chat user.demo-chat/Chat 58 | `user.tutorial-backpressure/Backpressure user.tutorial-backpressure/Backpressure 59 | `user.tutorial-lifecycle/Lifecycle user.tutorial-lifecycle/Lifecycle 60 | `user.demo-chat-extended/ChatExtended user.demo-chat-extended/ChatExtended 61 | `user.demo-webview/Webview user.demo-webview/Webview 62 | `user.demo-todos-simple/TodoList user.demo-todos-simple/TodoList 63 | `user.demo-reagent-interop/ReagentInterop user.demo-reagent-interop/ReagentInterop 64 | ;; `wip.demo-stage-ui4/CrudForm wip.demo-stage-ui4/CrudForm 65 | `user.demo-svg/SVG user.demo-svg/SVG 66 | ; -- `wip.tracing/TracingDemo wip.tracing/TracingDemo 67 | `wip.demo-custom-types/CustomTypes wip.demo-custom-types/CustomTypes 68 | 69 | ; 7 GUIs 70 | `user.tutorial-7guis-1-counter/Counter user.tutorial-7guis-1-counter/Counter 71 | `user.tutorial-7guis-2-temperature/TemperatureConverter user.tutorial-7guis-2-temperature/TemperatureConverter 72 | `user.tutorial-7guis-4-timer/Timer user.tutorial-7guis-4-timer/Timer 73 | `user.tutorial-7guis-5-crud/CRUD user.tutorial-7guis-5-crud/CRUD 74 | 75 | ; Demos 76 | ;; `user.demo-todomvc/TodoMVC user.demo-todomvc/TodoMVC 77 | ;; `user.demo-todomvc-composed/TodoMVC-composed user.demo-todomvc-composed/TodoMVC-composed 78 | ;; `user.demo-explorer/DirectoryExplorer user.demo-explorer/DirectoryExplorer 79 | ;-- `wip.datomic-browser/DatomicBrowser wip.datomic-browser/DatomicBrowser -- separate repo now, should it come back? 80 | ; `user.demo-color/Color user.demo-color/Color 81 | ; -- user.demo-10k-dom/Dom-10k-Elements user.demo-10k-dom/Dom-10k-Elements ; todo too slow to unmount, crashes 82 | 83 | ; Hyperfiddle demos 84 | ;`wip.teeshirt-orders/Webview-HFQL wip.teeshirt-orders/Webview-HFQL 85 | ; `wip.demo-branched-route/RecursiveRouter wip.demo-branched-route/RecursiveRouter 86 | ; `wip.demo-explorer2/DirectoryExplorer-HFQL wip.demo-explorer2/DirectoryExplorer-HFQL 87 | 88 | ; Hyperfiddle tutorials 89 | ; `wip.tag-picker/TagPicker wip.tag-picker/TagPicker 90 | 91 | ; Triage 92 | ; `user.demo-virtual-scroll/VirtualScroll user.demo-virtual-scroll/VirtualScroll 93 | ; `user.demo-tic-tac-toe/TicTacToe user.demo-tic-tac-toe/TicTacToe 94 | 95 | ; Tests 96 | ; ::demos/dennis-exception-leak wip.dennis-exception-leak/App2 97 | 98 | ;`wip.js-interop/QRCode wip.js-interop/QRCode 99 | }) 100 | 101 | #?(:clj (defn resolve-var-or-ns [sym] 102 | (if (qualified-symbol? sym) 103 | (ns-resolve *ns* sym) 104 | (the-ns sym)))) 105 | 106 | #?(:clj (defn get-src [sym] 107 | (try (-> (resolve-var-or-ns sym) meta :file 108 | (->> (str "src/")) slurp) 109 | (catch java.io.FileNotFoundException _)))) 110 | 111 | #?(:clj (defn get-readme [sym] 112 | (try (-> (resolve-var-or-ns sym) meta :file 113 | (clojure.string/split #"\.(clj|cljs|cljc)") first (str ".md") 114 | (->> (str "src/")) slurp) 115 | (catch java.io.FileNotFoundException _)))) 116 | 117 | (comment 118 | (get-src `user.demo-two-clocks/TwoClocks) 119 | (get-src 'user) 120 | (get-readme 'user) 121 | (-> (resolve-var-or-ns 'user) meta :file) 122 | (get-readme `user.demo-two-clocks/TwoClocks)) 123 | 124 | (e/defn Code [page] 125 | (e/client 126 | (dom/fieldset 127 | (dom/props {:class "user-examples-code"}) 128 | (dom/legend (dom/text "Code")) 129 | #_(dom/pre (dom/text (e/server (get-src page)))) 130 | (CodeMirror. {:parent dom/node :readonly true} identity identity (e/server (get-src page)))))) 131 | 132 | (e/defn App [page] 133 | (e/client 134 | (dom/fieldset 135 | (dom/props {:class ["user-examples-target" (some-> page name)]}) 136 | (dom/legend (dom/text "Result")) 137 | (e/server (new (get pages page NotFoundPage)))))) 138 | 139 | (e/defn Markdown [?md-str] 140 | (e/client 141 | (let [html (e/server (some-> ?md-str markdown.core/md-to-html-string))] 142 | (set! (.-innerHTML dom/node) html)))) 143 | 144 | (e/defn Readme [page] 145 | (e/client 146 | (dom/div 147 | (dom/props {:class "user-examples-readme markdown-body"}) 148 | (e/server (Markdown. (get-readme page)))))) 149 | 150 | (def tutorials 151 | [["Electric" 152 | [{::id `user.demo-two-clocks/TwoClocks 153 | ::title "Two Clocks – Hello World" 154 | ::lead "Streaming lexical scope. The server clock is streamed to the client."} 155 | {::id `user.demo-toggle/Toggle 156 | ::lead "This demo toggles between client and server with a button."} 157 | {::id `user.demo-system-properties/SystemProperties 158 | ::lead "A larger example of a HTML table backed by a server-side query. Type into the input and see the query update live."} 159 | {::id `user.demo-chat/Chat ::lead "A multiplayer chat app in 30 LOC, all one file. Try two tabs."} 160 | {::id `user.tutorial-backpressure/Backpressure ::lead "This is just the Two Clocks demo with slight modifications, there is more to learn here."} 161 | {::id `user.tutorial-lifecycle/Lifecycle ::title "Component Lifecycle" ::lead "mount/unmount component lifecycle"} 162 | #_{::id 'user ::title "Electric Entrypoint" ::suppress-demo true 163 | ::lead "This is the Electric entrypoint (in user.cljs). `hyperfiddle.electric/boot` is the Electric compiler entrypoint."} 164 | {::id `user.demo-chat-extended/ChatExtended 165 | ::lead "Extended chat demo with auth and presence. When multiple sessions are connected, you can see who else is present."} 166 | {::id `user.demo-webview/Webview 167 | ::lead "A database backed webview with reactive updates."} 168 | {::id `user.demo-todos-simple/TodoList 169 | ::lead "minimal todo list. it's multiplayer, try two tabs"} 170 | {::id `user.demo-reagent-interop/ReagentInterop 171 | ::lead "Reagent (React.js) embedded inside Electric. The reactive mouse coordinates cross from Electric to Reagent via props."} 172 | {::id `user.demo-svg/SVG 173 | ::lead "SVG support. Note the animation is reactive and driven by javascript cosine."} 174 | 175 | ; 7 GUIs 176 | {::id `user.tutorial-7guis-1-counter/Counter ::title "7GUIs Counter" 177 | ::lead "See "} 178 | {::id `user.tutorial-7guis-2-temperature/TemperatureConverter ::title "7GUIs Temperature Converter" 179 | ::lead "See "} 180 | {::id `user.tutorial-7guis-4-timer/Timer ::title "7GUIs Timer" 181 | ::lead "See "} 182 | {::id `user.tutorial-7guis-5-crud/CRUD ::title "7GUIs CRUD" 183 | ::lead "See "} 184 | 185 | ; Demos 186 | #_{::id `user.demo-todomvc/TodoMVC ::suppress-code true ::lead "TodoMVC as a function"} 187 | #_{::id `user.demo-todomvc-composed/TodoMVC-composed ::suppress-code true ::lead "Demo of app composition by putting a whole fullstack app inside a for loop."} 188 | #_{::id `user.demo-explorer/DirectoryExplorer ::suppress-code true ::lead "Server-streamed virtual pagination over node_modules. Check the DOM!"} 189 | ;; #_{::id `wip.demo-stage-ui4/CrudForm ::lead "Database-backed CRUD form using Datomic"} 190 | ;; {::id `wip.demo-custom-types/CustomTypes ::lead "Custom transit serializers example"} 191 | #_{::id `wip.js-interop/QRCode ::lead "Generate QRCodes with a lazily loaded JS library"} 192 | ]] 193 | #_["HFQL" 194 | [{::id `wip.teeshirt-orders/Webview-HFQL 195 | ::lead "HFQL hello world. HFQL is a data notation for CRUD apps."}]]]) 196 | 197 | (def tutorials-index (->> tutorials 198 | (mapcat (fn [[_group entries]] entries)) 199 | (map-indexed (fn [idx entry] (assoc entry ::order idx))) 200 | (contrib.data/index-by ::id))) 201 | (def tutorials-seq (vec (sort-by ::order (vals tutorials-index)))) 202 | 203 | (defn get-prev-next [page] 204 | (when-let [order (::order (tutorials-index page))] 205 | [(get tutorials-seq (dec order)) 206 | (get tutorials-seq (inc order))])) 207 | 208 | (defn title [{:keys [::id ::title]}] (or title (name id))) 209 | 210 | (e/defn Nav [page footer?] 211 | (e/client 212 | (let [[prev next] (get-prev-next page)] 213 | (dom/div {} (dom/props {:class [(if footer? "user-examples-footer-nav" "user-examples-nav") 214 | (when-not prev "user-examples-nav-start") 215 | (when-not next "user-examples-nav-end")]}) 216 | (when prev 217 | (history/link [(::id prev)] (dom/props {:class "user-examples-nav-prev"}) (dom/text (str "< " (title prev))))) 218 | (dom/div (dom/props {:class "user-examples-select"}) 219 | (svg/svg (dom/props {:viewBox "0 0 20 20"}) 220 | (svg/path (dom/props {:d "M19 4a1 1 0 01-1 1H2a1 1 0 010-2h16a1 1 0 011 1zm0 6a1 1 0 01-1 1H2a1 1 0 110-2h16a1 1 0 011 1zm-1 7a1 1 0 100-2H2a1 1 0 100 2h16z"}))) 221 | (dom/select 222 | (e/for [[group-label entries] tutorials] 223 | (dom/optgroup (dom/props {:label group-label}) 224 | (e/for [{:keys [::id]} entries] 225 | (let [entry (tutorials-index id)] 226 | (dom/option 227 | (dom/props {:value (str id) :selected (= page id)}) 228 | (dom/text (str (inc (::order entry)) ". " (title entry)))))))) 229 | (dom/on "change" (e/fn [^js e] 230 | (history/navigate! history/!history [(clojure.edn/read-string (.. e -target -value))]))))) 231 | (when next 232 | (history/link [(::id next)] (dom/props {:class "user-examples-nav-next"}) (dom/text (str (title next) " >")))))))) 233 | 234 | (e/defn Examples [] 235 | (e/client 236 | (let [[page & [?panel]] history/route 237 | suppress-code? (::suppress-code (get tutorials-index page)) 238 | suppress-demo? (::suppress-demo (get tutorials-index page))] 239 | (case ?panel 240 | code (Code. page) ; iframe url for just code 241 | app (history/router 1 (App. page)) ; iframe url for just app 242 | (do 243 | (when suppress-code? 244 | (.. dom/node -classList (add "user-examples-demo")) 245 | (e/on-unmount #(.. dom/node -classList (remove "user-examples-demo")))) 246 | (dom/h1 (dom/text "Tutorial – Electric Clojure")) 247 | (Nav. page false) 248 | (dom/div (dom/props {:class "user-examples-lead"}) 249 | (e/server (Markdown. (::lead (get tutorials-index page))))) 250 | (when-not suppress-demo? 251 | (history/router 1 ; focus route slot 1 to store state: `[page ] 252 | (App. page))) 253 | (when-not suppress-code? 254 | (Code. page)) 255 | (Readme. page) 256 | (Nav. page true)))))) 257 | 258 | (defn route->path [route] (clojure.string/join "/" (map contrib.ednish/encode-uri route))) 259 | (defn path->route [s] 260 | (let [s (contrib.ednish/discard-leading-slash s)] 261 | (case s "" nil (->> (clojure.string/split s #"/") (mapv contrib.ednish/decode-uri))))) 262 | 263 | ; nested routes don't work yet, check dir explorer etc 264 | 265 | (comment 266 | (clojure.string/split "/user.demo-two-clocks!TwoClocks" #"/") 267 | (clojure.string/split "user.demo-two-clocks!TwoClocks" #"/") 268 | (clojure.string/split "" #"/") 269 | (route->path [`user.demo-two-clocks/TwoClocks]) := "user.demo-two-clocks!TwoClocks" 270 | (path->route "user.demo-two-clocks!TwoClocks") := [`user.demo-two-clocks/TwoClocks] 271 | (path->route "/user.demo-two-clocks!TwoClocks") := [`user.demo-two-clocks/TwoClocks] 272 | (path->route "/user.demo-two-clocks!TwoClocks/") := [`user.demo-two-clocks/TwoClocks] 273 | (path->route "/user.demo-two-clocks!TwoClocks/foo") := [`user.demo-two-clocks/TwoClocks 'foo] 274 | (path->route "") := nil 275 | ) 276 | 277 | (e/defn Main [ring-req] 278 | (binding [e/http-request ring-req] 279 | (e/client 280 | (binding [history/encode route->path 281 | history/decode #(or (path->route %) [`user.demo-two-clocks/TwoClocks])] 282 | (history/router (history/HTML5-History.) 283 | (set! (.-title js/document) (str #_(clojure.string/capitalize) (some-> (identity history/route) first name) " – Electric Clojure")) 284 | (binding [dom/node js/document.body] 285 | (Examples.))))))) 286 | -------------------------------------------------------------------------------- /src/wip/demo_branched_route.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.demo-branched-route 2 | (:require datascript.core 3 | #?(:clj user.example-datascript-db) 4 | [hyperfiddle.api :as hf] 5 | [hyperfiddle.hfql-tree-grid :as ttgui] 6 | [hyperfiddle.electric :as e] 7 | [hyperfiddle.electric-dom2 :as dom] 8 | hyperfiddle.popover 9 | [hyperfiddle.history :as router] 10 | wip.orders-datascript)) 11 | 12 | (e/def Page) 13 | (e/defn Page-impl [] 14 | (dom/h1 (dom/text "Branched route")) 15 | (dom/pre (dom/text (contrib.str/pprint-str router/route))) 16 | 17 | (router/router :hfql 18 | (e/server 19 | (binding [hf/*nav!* wip.orders-datascript/nav! 20 | hf/*schema* wip.orders-datascript/schema 21 | hf/db hf/*$*] 22 | (ttgui/with-gridsheet-renderer 23 | (binding [ttgui/grid-width 2 24 | hf/db-name "$"] 25 | (e/server 26 | (hf/hfql {(wip.orders-datascript/orders .) [:db/id]}))))))) 27 | 28 | (dom/hr) 29 | (router/router :left (hyperfiddle.popover/popover "Recur Left" (Page.))) 30 | (router/router :right (hyperfiddle.popover/popover "Recur Right" (Page.)))) 31 | 32 | (e/defn RecursiveRouter [] 33 | (hf/branch 34 | (e/client 35 | (binding [Page Page-impl] 36 | (router/router 1 ; ordinal to nominal - representation only 37 | (Page.)))))) 38 | 39 | (comment 40 | (e/for [[page nested] s] 41 | (router/router page (hyperfiddle.popover/popover "Recur Left" 42 | (hf/eval-as-iframe nested)))) 43 | 44 | `(wip.demo-branched-route/RecursiveRouter 45 | {::needle "root" 46 | ::left {::needle "" 47 | ::left {::needle "" 48 | ::right {}}} 49 | ::right {::needle ""}}) 50 | 51 | `(wip.demo-branched-route/RecursiveRouter 52 | {::left `(wip.demo-branched-route/PDF) 53 | ::right `(wip.demo-branched-route/HTML)}) 54 | ) -------------------------------------------------------------------------------- /src/wip/demo_custom_types.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.demo-custom-types 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [cognitect.transit :as t])) 5 | 6 | (defrecord MyCustomType [field]) ; custom type 7 | 8 | (def write-handler 9 | (t/write-handler 10 | (fn [_] "wip.demo-custom-types/MyCustomType") ; this must be namespaced! 11 | (fn [x] (into {} x)))) 12 | 13 | (def read-handler (t/read-handler map->MyCustomType)) 14 | 15 | ; Todo refactor and cleanup, there are better ways to do this 16 | #?(:clj (alter-var-root #'hyperfiddle.electric.impl.io/*write-handlers* 17 | assoc MyCustomType write-handler)) ; server: write only 18 | #?(:cljs (set! hyperfiddle.electric.impl.io/*read-handlers* ; client: read only 19 | (assoc hyperfiddle.electric.impl.io/*read-handlers* 20 | "wip.demo-custom-types/MyCustomType" read-handler))) 21 | 22 | (e/defn CustomTypes [] 23 | (e/server 24 | (let [object (MyCustomType. "value")] 25 | (e/client 26 | (dom/dl 27 | (dom/dt (dom/text "type")) (dom/dd (dom/text (pr-str (type object)))) 28 | (dom/dt (dom/text "value")) (dom/dd (dom/text (pr-str object)))))))) 29 | -------------------------------------------------------------------------------- /src/wip/demo_explorer2.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.demo-explorer2 2 | (:require [clojure.datafy :refer [datafy]] 3 | [clojure.core.protocols :refer [nav]] 4 | #?(:clj clojure.java.io) 5 | [clojure.spec.alpha :as s] 6 | [contrib.datafy-fs #?(:clj :as :cljs :as-alias) fs] 7 | [hyperfiddle.api :as hf] 8 | [hyperfiddle.electric :as e] 9 | [hyperfiddle.electric-dom2 :as dom] 10 | [hyperfiddle.history :as router] 11 | [hyperfiddle.hfql-tree-grid :as ttgui])) 12 | 13 | (def unicode-folder "\uD83D\uDCC2") ; 📂 14 | 15 | (e/defn DirectoryExplorer-HFQL [] 16 | (ttgui/with-gridsheet-renderer 17 | (binding [hf/db-name "$"] 18 | (dom/style {:grid-template-columns "repeat(5, 1fr)"}) 19 | (e/server 20 | (binding [hf/*nav!* (fn [db e a] (a (datafy e))) ;; FIXME db is specific, hfql should be general 21 | hf/*schema* (constantly nil)] ;; FIXME this is datomic specific, hfql should be general 22 | (let [path (fs/absolute-path "node_modules")] 23 | (hf/hfql {(props (fs/list-files (props path {::dom/disabled true})) ;; FIXME forward props 24 | {::hf/height 30}) 25 | [(props ::fs/name #_{::hf/render (e/fn [{::hf/keys [Value]}] 26 | (let [v (Value.)] 27 | (case (::fs/kind m) 28 | ::fs/dir (let [absolute-path (::fs/absolute-path m)] 29 | (e/client (router/Link. [::fs/dir absolute-path] v))) 30 | (::fs/other ::fs/symlink ::fs/unknown-kind) v 31 | v #_(e/client (router/Link. [::fs/file x] v)))))}) 32 | 33 | ;; TODO add links and indentation 34 | 35 | (props ::fs/modified {::hf/render (e/fn [{::hf/keys [Value]}] 36 | (e/client 37 | (dom/text 38 | (-> (e/server (Value.)) 39 | .toISOString 40 | (.substring 0 10)))))}) 41 | ::fs/size 42 | (props ::fs/kind {::hf/render (e/fn [{::hf/keys [Value]}] 43 | (let [v (Value.)] 44 | (e/client 45 | (case v 46 | ::fs/dir (dom/text unicode-folder) 47 | (dom/text (some-> v name))))))}) 48 | ]}))))))) 49 | -------------------------------------------------------------------------------- /src/wip/demo_stage_ui4.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.demo-stage-ui4 2 | (:require [contrib.css :refer [css-slugify]] 3 | [contrib.str :refer [pprint-str]] 4 | #?(:clj [contrib.datomic-contrib :as dx]) 5 | #?(:clj [datomic.client.api :as d]) 6 | [hyperfiddle.api :as hf] 7 | [hyperfiddle.electric :as e] 8 | [hyperfiddle.electric-dom2 :as dom] 9 | [hyperfiddle.electric-ui4 :as ui] 10 | [hyperfiddle.popover :refer [Popover]])) 11 | 12 | (def cobblestone 536561674378709) 13 | 14 | (def label-form-spec 15 | [:db/id 16 | :label/gid 17 | :label/name 18 | :label/sortName 19 | {:label/type [:db/ident]} 20 | {:label/country [:db/ident]} 21 | :label/startYear]) 22 | 23 | (comment (d/pull test/datomic-db ['*] cobblestone)) 24 | 25 | #?(:clj (defn type-options [db & [needle]] 26 | (->> (d/q '[:find (pull ?e [:db/ident]) :in $ ?needle :where 27 | [?e :db/ident ?ident] 28 | [(namespace ?ident) ?x-ns] [(= ?x-ns "label.type")] 29 | [(name ?ident) ?x-label] 30 | [(contrib.str/includes-str? ?x-label ?needle)]] 31 | db (or needle "")) 32 | (map first)))) 33 | 34 | (comment 35 | (type-options test/datomic-db "") 36 | (type-options test/datomic-db "prod") 37 | (type-options test/datomic-db "bootleg") 38 | (type-options test/datomic-db nil)) 39 | 40 | 41 | (e/defn Form [e] 42 | (e/server 43 | (let [record (d/pull hf/db label-form-spec e)] 44 | (e/client 45 | (dom/dl 46 | 47 | (dom/dt (dom/text "id")) 48 | (dom/dd 49 | (ui/input (:db/id record) nil 50 | (dom/props {::dom/disabled true}))) 51 | 52 | (dom/dt "gid") 53 | (dom/dd (ui/uuid (:label/gid record) nil 54 | (dom/props {::dom/disabled true}))) 55 | 56 | (dom/dt (dom/text "name")) 57 | (dom/dd (ui/input (:label/name record) 58 | (e/fn [v] 59 | (println 'input! v) 60 | (e/server 61 | (hf/Transact!. [[:db/add e :label/name v]]))))) 62 | 63 | (dom/dt (dom/text "sortName")) 64 | (dom/dd (ui/input (:label/sortName record) 65 | (e/fn [v] 66 | (e/server 67 | (hf/Transact!. [[:db/add e :label/sortName v]]))))) 68 | 69 | 70 | (dom/dt (dom/text "type")) 71 | (dom/dd 72 | (e/server 73 | (ui/typeahead 74 | (:label/type record) 75 | (e/fn V! [option] 76 | (hf/Transact!. [[:db/add e :label/type (:db/ident option)]])) 77 | (e/fn Options [search] (type-options hf/db search)) 78 | (e/fn OptionLabel [option] (some-> option :db/ident name))))) 79 | 80 | ; country 81 | 82 | (dom/dt (dom/text "startYear")) 83 | (dom/dd (ui/long 84 | (:label/startYear record) 85 | (e/fn [v] 86 | (e/server 87 | (hf/Transact!. [[:db/add e :label/startYear v]])))))) 88 | 89 | (dom/pre (dom/text (pprint-str record))))))) 90 | 91 | (e/defn Page [] 92 | #_(e/client (dom/div (if hf/loading "loading" "idle") " " 93 | (str (hf/Load-timer.)) "ms")) 94 | (Form. cobblestone) 95 | #_(Form. cobblestone) 96 | (e/client (Popover. "open" (e/fn [] (e/server (Form. cobblestone)))))) 97 | 98 | (e/defn CrudForm [] 99 | (e/server 100 | (let [conn @(requiring-resolve 'test/datomic-conn) 101 | secure-db (d/with-db conn)] ; todo datomic-tx-listener 102 | (binding [hf/schema (new (dx/schema> secure-db)) 103 | hf/into-tx' hf/into-tx 104 | hf/with (fn [db tx] ; inject datomic 105 | (try (:db-after (d/with db {:tx-data tx})) 106 | (catch Exception e 107 | (println "...failure, e: " e) 108 | db))) 109 | hf/db secure-db] 110 | (hf/branch 111 | (Page.) 112 | (e/client 113 | (dom/hr) 114 | (ui/edn 115 | (e/server hf/stage) nil 116 | (dom/props {:disabled true :class (css-slugify `staged)})))))))) 117 | -------------------------------------------------------------------------------- /src/wip/js_interop.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.js-interop 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [hyperfiddle.electric-ui4 :as ui] 5 | [hyperfiddle.history :as history])) 6 | 7 | (e/defn QRCodeApp [] 8 | (e/client 9 | (let [value (or (::value history/route) (str js/window.location.origin)) 10 | !ready (atom false)] 11 | (dom/script 12 | (dom/props {:src "https://cdn.rawgit.com/davidshimjs/qrcodejs/gh-pages/qrcode.min.js"}) 13 | (dom/on! "load" (fn [_] (reset! !ready true)))) 14 | (if-not (e/watch !ready) 15 | (dom/p (dom/text "Loading...")) 16 | (do 17 | (ui/input value (e/fn [value] (history/swap-route! assoc ::value value))) 18 | (dom/div 19 | (let [^js qrcode (js/QRCode. dom/node (clj->js {:width 128 20 | :height 128 21 | :colorDark "#000000" 22 | :colorLight "#ffffff" 23 | :correctLevel js/QRCode.CorrectLevel.H}))] 24 | ((fn [value] 25 | (.clear qrcode) 26 | (.makeCode qrcode value)) 27 | value)))))))) 28 | -------------------------------------------------------------------------------- /src/wip/orders_datascript.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.orders-datascript 2 | "query functions used in tee-shirt orders demo" 3 | (:require [clojure.spec.alpha :as s] 4 | clojure.string 5 | contrib.str 6 | [datascript.core :as d] 7 | [hyperfiddle.api :as hf] 8 | [hyperfiddle.rcf :refer [tap % tests]])) 9 | 10 | (s/fdef genders :args (s/cat) :ret (s/coll-of number?)) 11 | (defn genders [] 12 | (into [] (sort (d/q '[:find [?e ...] :where [_ :order/gender ?e]] hf/*$*)))) 13 | 14 | (s/fdef shirt-sizes :args (s/cat :gender keyword? 15 | :needle string?) 16 | :ret (s/coll-of number?)) 17 | 18 | (defn shirt-sizes [gender needle] 19 | ; resolve db/id and db/ident genders to same entity 20 | ; datomic does this transparently 21 | ; datascript does not 22 | (sort 23 | (if gender 24 | (d/q '[:in $ ?gender ?needle 25 | :find [?e ...] 26 | :where 27 | [?e :order/type :order/shirt-size] 28 | [?e :order/gender ?g] 29 | [?g :db/ident ?gender] 30 | [?e :db/ident ?ident] ; remove 31 | [(name ?ident) ?nm] 32 | [(contrib.str/includes-str? ?nm ?needle)]] 33 | hf/*$* 34 | gender (or needle "")) 35 | (d/q '[:in $ ?needle 36 | :find [?e ...] 37 | :where 38 | [?e :order/type :order/shirt-size] 39 | [?e :db/ident ?ident] 40 | [(name ?ident) ?nm] 41 | [(contrib.str/includes-str? ?nm ?needle)]] 42 | hf/*$* 43 | (or needle ""))))) 44 | 45 | (tests 46 | (shirt-sizes :order/female #_2 "") := [6 7 8] 47 | (shirt-sizes :order/female #_2 "med") := [7] 48 | (shirt-sizes :order/female #_2 "d") := [7]) 49 | 50 | (defn orders [needle] 51 | (sort (d/q '[:find [?e ...] :in $ ?needle :where 52 | [?e :order/email ?email] 53 | [(clojure.string/includes? ?email ?needle)]] 54 | hf/*$* (or needle "")))) 55 | 56 | (tests 57 | (orders "") := [9 10 11] 58 | (orders "example") := [9 10 11] 59 | (orders "b") := [10]) 60 | 61 | (s/fdef orders :args (s/cat :needle string?) 62 | :ret (s/coll-of (s/keys :req [:order/email 63 | :order/email1 64 | :order/gender 65 | :order/shirt-size]))) 66 | 67 | (s/fdef order :args (s/cat :needle string?) :ret number?) 68 | (defn order [needle] (first (orders needle))) 69 | 70 | (tests 71 | (order "") := 9 72 | (order "bob") := 10) 73 | 74 | (s/fdef one-order :args (s/cat :sub any?) :ret any?) 75 | (defn one-order [sub] (hf/*nav!* hf/*$* sub :db/id)) 76 | 77 | -------------------------------------------------------------------------------- /src/wip/orders_datomic.clj: -------------------------------------------------------------------------------- 1 | (ns wip.orders-datomic 2 | "query functions used in tee-shirt orders demo" 3 | (:require [clojure.spec.alpha :as s] 4 | contrib.str 5 | [hyperfiddle.api :as hf] 6 | [hyperfiddle.rcf :refer [tap % tests]])) 7 | 8 | (try (require '[datomic.api :as d]) 9 | (catch java.io.FileNotFoundException e 10 | (throw (ex-info "datomic.api not available, check Datomic pro version >= 1.0.6527" {})))) 11 | 12 | (defn fixtures [$] 13 | ; portable 14 | (-> $ 15 | (d/with [{:db/ident :order/male} 16 | {:db/ident :order/female}]) 17 | :db-after 18 | (d/with [{:order/type :order/shirt-size :db/ident :order/mens-small :order/gender :order/male} 19 | {:order/type :order/shirt-size :db/ident :order/mens-medium :order/gender :order/male} 20 | {:order/type :order/shirt-size :db/ident :order/mens-large :order/gender :order/male} 21 | {:order/type :order/shirt-size :db/ident :order/womens-small :order/gender :order/female} 22 | {:order/type :order/shirt-size :db/ident :order/womens-medium :order/gender :order/female} 23 | {:order/type :order/shirt-size :db/ident :order/womens-large :order/gender :order/female}]) 24 | :db-after 25 | (d/with [{:order/email "alice@example.com" :order/gender :order/female :order/shirt-size :order/womens-large 26 | :order/tags [:a :b :c]} 27 | {:order/email "bob@example.com" :order/gender :order/male :order/shirt-size :order/mens-large 28 | :order/tags [:b]} 29 | {:order/email "charlie@example.com" :order/gender :order/male :order/shirt-size :order/mens-medium}]) 30 | :db-after 31 | #_(d/with [{:db/id 12 :order/email "alice@example.com" :order/gender :order/female :order/shirt-size :order/womens-large} 32 | {:order/email "bob@example.com" :order/gender :order/male :order/shirt-size :order/mens-large} 33 | {:order/email "charlie@example.com" :order/gender :order/male :order/shirt-size :order/mens-medium}]) 34 | 35 | #_:db-after)) 36 | 37 | (defn init-datomic [] 38 | (let [schema [{:db/ident :order/email :db/valueType :db.type/string :db/cardinality :db.cardinality/one :db/unique :db.unique/identity} 39 | {:db/ident :order/gender :db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 40 | {:db/ident :order/shirt-size :db/valueType :db.type/ref :db/cardinality :db.cardinality/one} 41 | {:db/ident :order/type :db/valueType :db.type/keyword :db/cardinality :db.cardinality/one} 42 | {:db/ident :order/tags :db/valueType :db.type/keyword :db/cardinality :db.cardinality/many}]] 43 | (d/create-database "datomic:mem://hello-world") 44 | (def ^:dynamic *$* (-> (d/connect "datomic:mem://hello-world") d/db (d/with schema) :db-after fixtures)))) 45 | 46 | 47 | (init-datomic) 48 | 49 | 50 | (s/fdef genders :args (s/cat) :ret (s/coll-of number?)) 51 | (defn genders [] 52 | (into [] (sort (d/q '[:find [?ident ...] :where [_ :order/gender ?e] [?e :db/ident ?ident]] hf/*$*)))) 53 | 54 | (tests 55 | (binding [hf/*$* *$*] 56 | (genders)) := [:order/female :order/male]) 57 | 58 | (s/fdef shirt-sizes :args (s/cat :gender keyword? 59 | :needle string?) 60 | :ret (s/coll-of number?)) 61 | 62 | (defn shirt-sizes [gender needle] 63 | ;; resolve db/id and db/ident genders to same entity datomic does this 64 | ;; transparently datascript does not 65 | (sort 66 | (if gender 67 | (d/q '[:in $ ?gender ?needle 68 | :find [?ident ...] 69 | :where 70 | [?e :order/type :order/shirt-size] 71 | [?e :order/gender ?g] 72 | [?g :db/ident ?gender] 73 | [?e :db/ident ?ident] ; remove 74 | [(contrib.str/includes-str? ?ident ?needle)]] 75 | hf/*$* 76 | gender (or needle "")) 77 | (d/q '[:in $ ?needle 78 | :find [?e ...] 79 | :where 80 | [?e :order/type :order/shirt-size] 81 | [?e :db/ident ?ident] 82 | [(contrib.str/includes-str? ?ident ?needle)]] 83 | hf/*$* 84 | (or needle ""))))) 85 | 86 | (tests 87 | (binding [hf/*$* *$*] 88 | (shirt-sizes :order/female #_2 "") := [:order/womens-large :order/womens-medium :order/womens-small] 89 | (shirt-sizes :order/female #_2 "med") := [:order/womens-medium])) 90 | 91 | (defn orders [needle] 92 | (sort (d/q '[:find [?e ...] :in $ ?needle :where 93 | [?e :order/email ?email] 94 | [(clojure.string/includes? ?email ?needle)]] 95 | hf/*$* (or needle "")))) 96 | 97 | (tests 98 | (binding [hf/*$* *$*] 99 | (orders "") := [17592186045428 17592186045429 17592186045430] 100 | (orders "example") := [17592186045428 17592186045429 17592186045430] 101 | (orders "b") := [17592186045429])) 102 | 103 | (s/fdef orders :args (s/cat :needle string?) 104 | :ret (s/coll-of (s/keys :req [:order/email 105 | :order/email1 106 | :order/gender 107 | :order/shirt-size]))) 108 | 109 | (s/fdef order :args (s/cat :needle string?) :ret number?) 110 | (defn order [needle] (first (orders needle))) 111 | 112 | (tests 113 | (binding [hf/*$* *$*] 114 | (order "") := 17592186045428 115 | (order "bob") := 17592186045429)) 116 | 117 | (s/fdef one-order :args (s/cat :sub any?) :ret any?) 118 | (defn one-order [sub] (hf/*nav!* hf/*$* sub :db/id)) 119 | 120 | 121 | (defn nav! [db e a] (let [v (get (d/entity db e) a)] 122 | (prn "nav! datiomic - " e a v) 123 | v) ) 124 | 125 | (defn schema [db a] (when (qualified-keyword? a) (d/entity db a))) 126 | -------------------------------------------------------------------------------- /src/wip/tag_picker.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.tag-picker 2 | (:require [clojure.string :as str] 3 | [hyperfiddle.electric :as e] 4 | [hyperfiddle.electric-ui4 :as ui])) 5 | 6 | (def data {:alice {:name "Alice B"} 7 | :bob {:name "Bob C"} 8 | :charlie {:name "Charlie D"} 9 | :derek {:name "Derek E"}}) 10 | (defn q [search] (into [] (keep (fn [[k {nm :name}]] (when (str/includes? nm search) k))) data)) 11 | 12 | (e/defn TagPicker [] 13 | (e/server 14 | (let [!v (atom #{:alice :bob})] 15 | (ui/tag-picker (e/watch !v) 16 | (e/fn [v] (e/client (prn [:V! v])) (swap! !v conj v)) 17 | (e/fn [v] (prn [:unV! v]) (swap! !v disj v)) 18 | (e/fn [search] (e/client (prn [:Options search])) (q search)) 19 | (e/fn [id] (e/client (prn [:OptionLabel id])) (-> data id :name)))))) 20 | -------------------------------------------------------------------------------- /src/wip/teeshirt_orders.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.teeshirt-orders 2 | (:require [clojure.spec.alpha :as s] 3 | #?(:clj [datascript.core :as d]) 4 | #?(:clj [datascript.impl.entity :refer [entity?]]) 5 | [hyperfiddle.api :as hf] 6 | [hyperfiddle.hfql :refer [hfql]] 7 | [hyperfiddle.hfql-tree-grid :as hfql-tree-grid] 8 | [hyperfiddle.electric :as e])) 9 | 10 | (declare conn schema nav orders) 11 | (def ^:dynamic db) 12 | 13 | (e/defn Teeshirt-orders [] 14 | (hfql [db hf/db] ; convey reactive db to clojure dynamic 15 | {(orders .) 16 | [:db/id 17 | :order/email 18 | :order/gender]})) 19 | 20 | (s/fdef orders :args (s/cat :needle string?) :ret (s/coll-of any?)) 21 | (defn orders [needle] 22 | #?(:clj 23 | (sort (d/q '[:find [?e ...] :in $ ?needle :where 24 | [?e :order/email ?email] 25 | [(clojure.string/includes? ?email ?needle)]] 26 | db (or needle ""))))) 27 | 28 | (e/defn Webview-HFQL [] 29 | (e/client 30 | (hfql-tree-grid/with-gridsheet-renderer 31 | (e/server 32 | (binding [hf/db (e/watch conn) 33 | hf/*schema* schema 34 | hf/*nav!* nav] 35 | (Teeshirt-orders.)))))) 36 | 37 | #?(:clj (defn schema [db a] (get-in db [:schema a]))) 38 | #?(:clj (defn nav [db e a] 39 | (let [v (a (d/entity db e))] 40 | (if (entity? v) (or (:db/ident v) (:db/id v)) v)))) 41 | 42 | #?(:clj 43 | (defonce conn 44 | (doto (d/create-conn {}) 45 | (d/transact! ; test data 46 | [{:order/email "alice@example.com" :order/gender :order/female} 47 | {:order/email "bob@example.com" :order/gender :order/male} 48 | {:order/email "charlie@example.com" :order/gender :order/male}])))) -------------------------------------------------------------------------------- /src/wip/teeshirt_orders.md: -------------------------------------------------------------------------------- 1 | What's happening 2 | * there's a CRUD table, backed by a query 3 | * the table is specified by 4 lines of HFQL + database schema + the clojure.spec for the query 4 | * the filter input labeled `needle` is reflected from the clojure.spec on `orders`, which specifies that the `orders` query accepts a single `string?` parameter named `:needle` 5 | * type "alice" into the input and see the query refresh live 6 | 7 | Novel forms 8 | * `hf/hfql` 9 | * `binding`: Electric dynamic scope, it's reactive and used for dependency injection 10 | * `hf/db` 11 | * `hf/*schema*` 12 | * `hf/*nav!*` 13 | 14 | Key ideas 15 | * HFQL's mission is to let you model CRUD apps in as few LOC as possible. 16 | * HFQL generalizes graph-pull query notation into a declarative UI specification language. 17 | * spec-driven UI 18 | * macroexpands down to Electric 19 | * network-transparent 20 | * composes with Electric as e/defn 21 | * scope 22 | 23 | Dependency injection with Electric bindings -------------------------------------------------------------------------------- /src/wip/tracing.cljc: -------------------------------------------------------------------------------- 1 | (ns wip.tracing 2 | (:require [hyperfiddle.electric :as e] 3 | [hyperfiddle.electric-dom2 :as dom] 4 | [contrib.trace :as ct] 5 | [contrib.trace.datascript-tracer :as ds-tracer] 6 | [hyperfiddle.electric-ui4 :as ui] 7 | [clojure.string :as str])) 8 | 9 | (e/defn FizzBuzzText [n] 10 | (ct/trace :text 11 | (ct/trace :n n) 12 | (if-some [strs (ct/trace :strs 13 | (transduce (filter identity) conj nil 14 | [(when (zero? (mod n 5)) "Buzz") 15 | (when (zero? (mod n 3)) "Fizz")]))] 16 | (str/join "" strs) 17 | (str n)))) 18 | 19 | (e/defn CodeFor [sym height] 20 | (e/client 21 | (ui/edn (e/server (nth (:hyperfiddle.electric.impl.compiler/node (meta (eval `(var ~sym)))) 3)) 22 | (e/fn [_]) 23 | (dom/props {:disabled true}) 24 | (dom/style {:display "block" :width "50em", :height height})))) 25 | 26 | (e/defn FizzBuzz [] 27 | (e/client 28 | (dom/div 29 | (dom/h2 (dom/text "an over-engineered fizzbuzz")) 30 | (CodeFor. `FizzBuzzText "22em") 31 | (dom/br) 32 | (let [!n (atom 0), n (e/watch !n)] 33 | (ui/long n (e/fn [v] (reset! !n v)) (dom/style {:width "8em"})) 34 | (dom/span (dom/text (FizzBuzzText. n))))))) 35 | 36 | (e/defn ExceptionOrValue [n] 37 | (ct/trace :result 38 | [(ct/trace :throw-on-even (if (even? n) (throw (ex-info "even!" {:n n})) n)) 39 | (ct/trace :triple (* n 3))])) 40 | 41 | (e/defn Exceptions [] 42 | (e/client 43 | (dom/div 44 | (dom/h2 (dom/text "exceptions and concurrency")) 45 | (CodeFor. `ExceptionOrValue "8em") 46 | (let [!n (atom 0), n (e/watch !n)] 47 | (ui/long n (e/fn [v] (reset! !n v)) (dom/style {:width "8em"})) 48 | (ExceptionOrValue. n))))) 49 | 50 | (e/defn Note [text] (e/client (dom/em (dom/style {:display "block"}) (dom/text text)))) 51 | 52 | (e/defn TracingDemo [] 53 | (e/client 54 | (ds-tracer/with-defaults 55 | (FizzBuzz.) 56 | (dom/br) 57 | (ds-tracer/DatascriptTraceView.) 58 | (dom/br) 59 | (Note. "It's like println's, but better!") 60 | (Note. "Click on 2 trace values to get the distance between them!") 61 | (Note. "If the values are too close to each other increase the time granularity.")) 62 | (ds-tracer/with-defaults 63 | (Exceptions.) 64 | (dom/br) 65 | (ds-tracer/DatascriptTraceView.) 66 | (dom/br) 67 | (Note. "Note that `:triple` runs even if `:throw-on-even!` throws!") 68 | (Note. "That's because Electric code runs concurrently")))) 69 | --------------------------------------------------------------------------------