
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/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/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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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.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/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_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_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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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