├── .gitignore ├── LICENSE ├── README.md ├── datafrisk-shell.gif ├── dev └── datafrisk │ └── demo.cljs ├── devcards └── datafrisk │ ├── cards.cljs │ ├── spec_card.cljs │ └── ui_card.cljs ├── devresources └── public │ ├── cards.html │ └── index.html ├── project.clj ├── spectitleview.png ├── specview.png ├── src └── datafrisk │ ├── core.cljs │ ├── shell.cljs │ ├── spec.cljs │ ├── util.cljs │ └── view.cljs └── test └── datafrisk ├── test_runner.cljs └── view_test.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea 3 | 4 | .nrepl-port 5 | figwheel_server.log 6 | 7 | target 8 | todo.txt 9 | 10 | resources/public/js 11 | 12 | pom.xml 13 | pom.xml.asc 14 | 15 | .DS_Store 16 | out -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Odin Hole Standal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # data-frisk-reagent 2 | 3 | > "Get your facts first, then you can distort them as you please" - Mark Twain 4 | 5 | Visualize your data in your Reagent apps as a tree structure. 6 | 7 | Suitable for use during development. 8 | 9 | 10 | 11 | [Live demo](http://odinodin.no/x/datafrisk) 12 | 13 | 14 | ## Install 15 | 16 | ![](https://clojars.org/data-frisk-reagent/latest-version.svg) 17 | 18 | Add `data-frisk-reagent` to the dev `:dependencies` in your `project.clj` 19 | 20 | ## Usage 21 | 22 | This library's public API consists of two reagent components: `datafrisk.core/DataFriskShell` and `datafrisk.core/DataFriskView`. 23 | 24 | 25 | ### DataFriskShell 26 | 27 | This is what you see in the animation above. The component renders as a single data navigation "shell" fixed to the bottom of the window. 28 | It can be expanded/hidden via a toggle at the bottom right hand corner of the screen. 29 | 30 | Example: 31 | 32 | ```clojure 33 | (ns datafrisk.demo 34 | (:require [reagent.core :as r] 35 | [datafrisk.core :as d])) 36 | 37 | (defn mount-root [] 38 | (r/render 39 | [d/DataFriskShell 40 | ;; List of arguments you want to visualize 41 | {:data {:some-string "a" 42 | :vector-with-map [1 2 3 3 {:a "a" :b "b"}] 43 | :a-set #{1 2 3} 44 | :a-map {:x "x" :y "y" :z [1 2 3 4]} 45 | :a-list '(1 2 3) 46 | :a-seq (seq [1 2]) 47 | :an-object (clj->js {:a "a"}) 48 | :this-is-a-very-long-keyword :g}} 49 | {:a :b :c :d}] 50 | (js/document.getElementById "app"))) 51 | ``` 52 | 53 | ### DataFriskView 54 | 55 | This component lets you dig in to any data structure. Here's an example of its use: 56 | 57 | 58 | ```clojure 59 | (ns datafrisk.demo 60 | (:require [reagent.core :as r] 61 | [datafrisk.core :as d])) 62 | 63 | (def app-state {:animals [{:species "Giraffe" :age 10} 64 | {:species "Rhino" :age 4} 65 | {:species "Monkey" :age 4}]}) 66 | 67 | (defn AnimalSalute [animal] 68 | [:div 69 | (str "Hi " (:species animal) "!") 70 | [d/DataFriskView animal]]) 71 | 72 | (defn mount-root [] 73 | (r/render 74 | [:div 75 | (for [animal (:animals app-state)] 76 | [AnimalSalute animal])] 77 | (js/document.getElementById "app"))) 78 | ``` 79 | 80 | ### SpecView 81 | This component shows spec error messages in a human friendly way. 82 | 83 | ```clojure 84 | (s/def :person/name string?) 85 | (s/def :person/age number?) 86 | (s/def :person/address string?) 87 | 88 | (s/def :person/person (s/keys :req [:person/name 89 | :person/age 90 | :person/address])) 91 | 92 | (s/def :app/persons (s/coll-of :person/person)) 93 | 94 | ;; Render 95 | [SpecView 96 | {:errors (s/explain-data :person/person {:likes 2 97 | :person/name 1 98 | :person/age "Jane"})}] 99 | ``` 100 | 101 | 102 | 103 | ### SpecTitleView 104 | 105 | This is a convenience component that adds a title above the SpecView. 106 | 107 | ```clojure 108 | [SpecTitleView 109 | {:errors (s/explain-data :person/person {:likes 2 110 | :person/name 1 111 | :person/age "Jane"})}] 112 | ``` 113 | 114 | 115 | 116 | You can also override the title. 117 | 118 | ```clojure 119 | [SpecTitleView 120 | {:title {:style {:font-weight "700" :color "red"} 121 | :text "What ever you want"} 122 | :errors (s/explain-data :person/person {:likes 2 123 | :person/name 1 124 | :person/age "Jane"})}] 125 | 126 | ``` 127 | 128 | #### Parsing exceptions thrown when instrument is enabled 129 | 130 | Here is an example of how you can render spec errors that are 131 | thrown when spec instrumentation finds an error. 132 | 133 | ```clojure 134 | 135 | ;; Instrumentation is enabled 136 | (cljs.spec.test.alpha/instrument) 137 | 138 | (def state (atom {})) 139 | 140 | (try 141 | (do-stuff) 142 | (catch js/Error e 143 | ;; Hack to get the name of the fdef'ed var from message, see why https://dev.clojure.org/jira/browse/CLJ-2166 144 | (when (:cljs.spec.alpha/problems (ex-data e)) 145 | (swap! state assoc :current-error {:errors (ex-data e) 146 | :title {:text (second (re-find #"Call\sto\s#'(.*)\sdid" 147 | (aget e "message")))}})) 148 | nil)) 149 | 150 | 151 | ;;;; In your render code 152 | 153 | (defn my-exception-view-comp [state] 154 | [SpecTitleView (:current-error state)]) 155 | 156 | ``` 157 | 158 | ### Re-frame 159 | 160 | See the [re-frisk](https://github.com/flexsurfer/re-frisk) project. 161 | 162 | ### For more 163 | 164 | See the dev/demo.cljs namespace for example use. There are also devcards that you can look at. 165 | 166 | ## License 167 | 168 | Copyright © 2017 Odin Standal 169 | 170 | Distributed under the MIT License (MIT) 171 | -------------------------------------------------------------------------------- /datafrisk-shell.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Odinodin/data-frisk-reagent/5b143e38674e09f89481826b80d5add879a7e255/datafrisk-shell.gif -------------------------------------------------------------------------------- /dev/datafrisk/demo.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.demo 2 | (:require [reagent.core :as r] 3 | [datafrisk.core :as d])) 4 | 5 | (enable-console-print!) 6 | 7 | (defn Animals [data] 8 | [:div "Awesome animals" 9 | (into [:ul] 10 | (map-indexed (fn [i {:keys [animal age]}] 11 | ^{:key i} [:li (str animal ", " age " years old")]) 12 | (:animals data)))]) 13 | 14 | (def state (r/atom {:animals '({:animal "Monkey", :age 22222} 15 | {:animal "Giraffe", :age 45} 16 | {:animal "Zebra" :age 3}) 17 | :some-string "a" 18 | :vector-with-map [1 2 3 3 {:a "a" :b "b"}] 19 | :a-set #{1 2 3} 20 | :a-map {:x "x" :y "y" :z [1 2 3 4]} 21 | :atom (atom {:x "x" :y "y" :z [1 2 3 4]}) 22 | :a-seq (seq [1 2]) 23 | :an-object (clj->js {:a "a"}) 24 | :this-is-a-very-long-keyword :g})) 25 | 26 | (defn App [state] 27 | (let [state @state] 28 | [:div 29 | [Animals state] 30 | [d/DataFriskShell 31 | ;; List of arguments you want to visualize 32 | state 33 | {:a :b :c :d :e :f}]])) 34 | 35 | (defn mount-root [] 36 | (r/render 37 | [App state] 38 | (js/document.getElementById "app"))) 39 | 40 | (defn ^:export main [] 41 | (mount-root)) 42 | 43 | (defn on-js-reload [] 44 | (mount-root)) -------------------------------------------------------------------------------- /devcards/datafrisk/cards.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.cards 2 | (:require datafrisk.ui-card 3 | datafrisk.spec-card) 4 | (:require-macros [devcards.core :as dc])) 5 | 6 | (dc/start-devcard-ui!) -------------------------------------------------------------------------------- /devcards/datafrisk/spec_card.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.spec-card 2 | (:require [devcards.core] 3 | [cljs.spec.alpha :as s] 4 | [reagent.core :as r] 5 | [datafrisk.spec :refer [SpecView SpecTitleView]]) 6 | (:require-macros [devcards.core :as dc :refer [defcard-rg]])) 7 | 8 | (s/def :person/name string?) 9 | (s/def :person/age number?) 10 | (s/def :person/address string?) 11 | 12 | (s/def :person/person (s/keys :req [:person/name 13 | :person/age 14 | :person/address])) 15 | 16 | (s/def :app/persons (s/coll-of :person/person)) 17 | 18 | (defcard-rg spec-view 19 | [SpecView 20 | {:errors (s/explain-data :person/person {:likes 2 21 | :person/name 1 22 | :person/age "Jane"})}]) 23 | 24 | (defcard-rg spec-title-view 25 | [SpecTitleView 26 | {:errors (s/explain-data :person/person {:likes 2 27 | :person/name 1 28 | :person/age "Jane"})}]) 29 | 30 | (defcard-rg bad-vec 31 | [SpecTitleView 32 | {:errors (s/explain-data :app/persons [1 2 3 [4 5]])}]) 33 | 34 | (defcard-rg bad-list 35 | [SpecTitleView 36 | {:errors (s/explain-data :app/persons '(1 2 3 (4 5 )))}]) 37 | 38 | (defcard-rg bad-set 39 | [SpecTitleView 40 | {:errors (s/explain-data :app/persons #{1 2 #{3 4} 5})}]) 41 | 42 | (defcard-rg bad-nested-map 43 | [SpecTitleView 44 | {:errors (s/explain-data :app/persons [{:likes 2 45 | :person/name 1 46 | :person/age "Jane"} 47 | {:likes 3 48 | :person/name 2 49 | :person/age "Jenna"}])}]) 50 | 51 | (defcard-rg bad-string 52 | [SpecTitleView 53 | {:errors (s/explain-data :app/persons "some string")}]) 54 | 55 | (defcard-rg override-spec-title 56 | [SpecTitleView 57 | {:title {:style {:font-weight "700" :color "red"} 58 | :text "What ever you want"} 59 | :errors (s/explain-data :app/persons "some string")}]) -------------------------------------------------------------------------------- /devcards/datafrisk/ui_card.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.ui-card 2 | (:require [devcards.core] 3 | [reagent.core :as r] 4 | [datafrisk.view :refer [Root]]) 5 | (:require-macros [devcards.core :as dc :refer [defcard-rg]])) 6 | 7 | (defcard-rg modifiable-data 8 | "When the data you are watching is swappable, you can edit it." 9 | [Root 10 | (r/atom 3) 11 | "root" 12 | (r/atom {})]) 13 | 14 | (defcard-rg modifiable-nested-data 15 | "When the data you are watching is nested in a swappable, you can edit the values." 16 | [Root 17 | (r/atom {:foo 2 18 | 3 "bar"}) 19 | "root" 20 | (r/atom {})]) 21 | 22 | (defcard-rg data-types 23 | [Root 24 | {:a "I'm a string" 25 | :b :imakeyword 26 | :c [1 2 3] 27 | :d '(1 2 3) 28 | :e #{1 2 3} 29 | :f (clj->js {:i-am "an-object"}) 30 | "g" "String key" 31 | 0 nil 32 | "not a number" js/NaN 33 | } 34 | "root" 35 | (r/atom {})]) 36 | 37 | (defcard-rg first-level-expanded 38 | [Root 39 | {:a "a" 40 | :b [1 2 3] 41 | :c :d} 42 | "root" 43 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true}}}}})]) 44 | 45 | (defcard-rg second-level-expanded 46 | [Root {:a "a" 47 | :b [1 2 3] 48 | :c :d} 49 | "root" 50 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true} 51 | [:b] {:expanded? true}}}}})]) 52 | 53 | (defcard-rg empty-collections 54 | [Root {:set #{} 55 | :vec [] 56 | :list '()} 57 | "root" 58 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true}}}}})]) 59 | 60 | (defcard-rg nil-in-collections 61 | [Root {:set #{nil} 62 | :vec [nil] 63 | :list '(nil nil)} 64 | "root" 65 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true} 66 | [:set] {:expanded? true} 67 | [:vec] {:expanded? true} 68 | [:list] {:expanded? true}}}}})]) 69 | 70 | (defcard-rg list-of-maps 71 | [Root {:my-list '("a string" [1 2 3] {:name "Jim" :age 10} {:name "Jane" :age 7})} 72 | "root" 73 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true} 74 | [:my-list] {:expanded? true}}}}})]) 75 | 76 | (defcard-rg list-of-lists 77 | [Root '(1 (1 2 3)) 78 | "root" 79 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true} 80 | [:my-list] (:expanded? true)}}}})]) 81 | 82 | (defcard-rg set-with-list 83 | [Root #{1 '(1 2 3) [4 5 6]} 84 | "root" 85 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:expanded? true} 86 | [:my-list] (:expanded? true)}}}})]) 87 | 88 | (defcard-rg meta-data 89 | [Root {:a 1 :b 2} 90 | "root" 91 | (r/atom {:data-frisk {"root" {:metadata-paths {[] {:error "bad stuff" 92 | :expanded? true} 93 | [:a] {:error "very bad stuff"}}}}})]) -------------------------------------------------------------------------------- /devresources/public/cards.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /devresources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject data-frisk-reagent "0.4.5" 2 | :description "Frisking EDN since 2016!" 3 | :url "http://github.com/odinodin/data-frisk-reagent" 4 | :license {:name "MIT" 5 | :url "https://opensource.org/licenses/MIT"} 6 | :min-lein-version "2.7.1" 7 | :dependencies [[reagent "0.7.0"]] 8 | :plugins [[lein-figwheel "0.5.14"] 9 | [lein-doo "0.1.8"] 10 | [lein-cljsbuild "1.1.7" :exclusions [[org.clojure/clojure]]]] 11 | :source-paths ["src"] 12 | 13 | :figwheel {:http-server-root "public" 14 | :server-port 3999} 15 | 16 | :aliases {"testing" ["do" ["clean"] ["doo" "phantom" "test" "once"]]} 17 | 18 | :profiles {:dev {:dependencies [[org.clojure/clojure "1.9.0-beta2"] 19 | [org.clojure/clojurescript "1.9.946"] 20 | [doo "0.1.8"] 21 | [com.cemerick/piggieback "0.2.2"] 22 | [figwheel-sidecar "0.5.14"] 23 | [devcards "0.2.4" :exclusions [[cljsjs/react]]]] 24 | :source-paths ["src" "devcards"] 25 | :resource-paths ["devresources"] 26 | :cljsbuild {:builds [{:id "dev" 27 | :source-paths ["src" "dev"] 28 | :figwheel {:on-jsload "datafrisk.demo/on-js-reload"} 29 | :compiler {:main "datafrisk.demo" 30 | :asset-path "js/out" 31 | :output-to "resources/public/js/main.js" 32 | :output-dir "resources/public/js/out"}} 33 | {:id "cards" 34 | :source-paths ["src" "devcards"] 35 | :figwheel {:devcards true} 36 | :compiler {:main "datafrisk.cards" 37 | :asset-path "js/out-cards" 38 | :output-to "resources/public/js/cards.js" 39 | :output-dir "resources/public/js/out-cards"}} 40 | {:id "test" 41 | :source-paths ["src" "test"] 42 | :compiler {:output-to "resources/public/js/compiled/test.js" 43 | :main datafrisk.test-runner 44 | :optimizations :none}} 45 | {:id "demo-site" 46 | :source-paths ["src" "dev"] 47 | :compiler {:main "datafrisk.demo" 48 | :optimizations :advanced 49 | :output-to "demo-site/main.js" 50 | :output-dir "demo-site/out"}}]}}} 51 | :clean-targets ^{:protect false} ["resources/public/js" "target"]) 52 | -------------------------------------------------------------------------------- /spectitleview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Odinodin/data-frisk-reagent/5b143e38674e09f89481826b80d5add879a7e255/spectitleview.png -------------------------------------------------------------------------------- /specview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Odinodin/data-frisk-reagent/5b143e38674e09f89481826b80d5add879a7e255/specview.png -------------------------------------------------------------------------------- /src/datafrisk/core.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.core 2 | (:require [datafrisk.view :as view] 3 | [datafrisk.shell :as shell])) 4 | 5 | (defn DataFriskShell [& data] 6 | (apply shell/DataFriskShell data)) 7 | 8 | (defn DataFriskView [& data] 9 | (apply view/DataFriskView data)) 10 | 11 | ;; Deprecated 12 | (defn FriskInline [& data] 13 | (apply view/DataFriskView data)) 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/datafrisk/shell.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.shell 2 | (:require [datafrisk.view :as view] 3 | [reagent.core :as r])) 4 | 5 | (def styles 6 | {:shell {:backgroundColor "#FAFAFA" 7 | :fontFamily "Consolas,Monaco,Courier New,monospace" 8 | :fontSize "12px" 9 | :z-index 9999} 10 | :shell-visible-button {:backgroundColor "#4EE24E"}}) 11 | 12 | (defn DataFriskShellVisibleButton [visible? toggle-visible-fn] 13 | [:button {:onClick toggle-visible-fn 14 | :style (merge {:border 0 15 | :cursor "pointer" 16 | :font "inherit" 17 | :padding "8px 12px" 18 | :position "fixed" 19 | :right 0 20 | :width "80px" 21 | :text-align "center"} 22 | (:shell-visible-button styles) 23 | (when-not visible? {:bottom 0}))} 24 | (if visible? "Hide" "Data frisk")]) 25 | 26 | (defn DataFriskShellView [shell-state & data] 27 | (let [visible? (:shell-visible? @shell-state)] 28 | [:div {:style (merge {:position "fixed" 29 | :right 0 30 | :bottom 0 31 | :width "100%" 32 | :height "50%" 33 | :max-height (if visible? "50%" 0) 34 | :transition "all 0.3s ease-out" 35 | :padding 0} 36 | (:shell styles))} 37 | [DataFriskShellVisibleButton visible? (fn [_] (swap! shell-state assoc :shell-visible? (not visible?)))] 38 | [:div {:style {:padding "10px" 39 | :height "100%" 40 | :box-sizing "border-box" 41 | :overflow-y "scroll"}} 42 | (map-indexed (fn [id x] 43 | ^{:key id} [view/Root x id shell-state]) data)]])) 44 | 45 | (defn DataFriskShell [& data] 46 | (let [expand-by-default (reduce #(assoc-in %1 [:data-frisk %2 :expanded-paths] #{[]}) {} (range (count data))) 47 | shell-state (r/atom expand-by-default)] 48 | (fn [& data] 49 | (apply DataFriskShellView shell-state data)))) 50 | 51 | -------------------------------------------------------------------------------- /src/datafrisk/spec.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.spec 2 | (:require [datafrisk.view :refer [Root]] 3 | [reagent.core :as r])) 4 | 5 | (defn spec-problem->metadata-path [{:keys [in path pred val via]}] 6 | [in {:error (str "(not " 7 | (clojure.string/replace (str pred) "cljs.core/" "") 8 | ")")}]) 9 | 10 | (defn frisk-errors [id errors] 11 | {:data (:cljs.spec.alpha/value errors) 12 | :state (r/atom {:data-frisk {id {:metadata-paths (-> (into {} (map spec-problem->metadata-path (:cljs.spec.alpha/problems errors))) 13 | (update [] assoc :expanded? true))}}})}) 14 | 15 | (defn SpecView [{:keys [errors]}] 16 | (let [mangled (frisk-errors "spec-errors" errors)] 17 | [Root (:data mangled) "spec-errors" (:state mangled)])) 18 | 19 | (defn SpecTitleView [{:keys [errors title] :as args}] 20 | [:div {:style {:background-color "white" 21 | :padding "10px"}} 22 | (if title 23 | [:div {:style (:style title {})} (:text title)] 24 | [:div {} 25 | [:span {:style {:font-weight "700" :color "red"}} "Did not comply with spec "] 26 | [:span {:style {}} (str (:cljs.spec.alpha/spec errors))]]) 27 | [SpecView args]]) -------------------------------------------------------------------------------- /src/datafrisk/util.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.util) 2 | 3 | (defn map-vals [f m] 4 | (zipmap (keys m) 5 | (map f (vals m)))) -------------------------------------------------------------------------------- /src/datafrisk/view.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.view 2 | (:require [cljs.pprint :refer [pprint]] 3 | [reagent.core :as r] 4 | [datafrisk.util :as u])) 5 | 6 | (declare DataFrisk) 7 | 8 | (def styles 9 | {:shell {:backgroundColor "#FAFAFA" 10 | :fontFamily "Consolas,Monaco,Courier New,monospace" 11 | :fontSize "12px" 12 | :z-index 9999} 13 | :strings {:color "#4Ebb4E"} 14 | :keywords {:color "purple"} 15 | :numbers {:color "blue"} 16 | :nil {:color "red"} 17 | :shell-visible-button {:backgroundColor "#4EE24E"}}) 18 | 19 | (defn ErrorIcon [] 20 | [:svg {:viewBox "0 0 30 42" :width "100%" :height "100%"} 21 | [:path {:fill "darkorange" 22 | :stroke "red" 23 | :stroke-width "2" 24 | :d "M15 3 25 | Q16.5 6.8 25 18 26 | A12.8 12.8 0 1 1 5 18 27 | Q13.5 6.8 15 3z"}] 28 | [:circle {:cx 15 :cy 32 :r 7 :fill "yellow"}]]) 29 | 30 | (defn ErrorText [text] 31 | [:div {:style {:fontSize "0.7em" 32 | :display "flex" 33 | :align-items "center" 34 | :color "red"}} text]) 35 | 36 | (defn ExpandButton [{:keys [expanded? path emit-fn]}] 37 | [:button {:style {:border 0 38 | :padding "5px 4px 5px 2px" 39 | :textAlign "center" 40 | :backgroundColor "transparent" 41 | :width "20px" 42 | :height "20px" 43 | :cursor "pointer"} 44 | :onClick #(emit-fn (if expanded? :contract :expand) path)} 45 | [:svg {:viewBox "0 0 100 100" 46 | :width "100%" :height "100%" 47 | :style {:transition "all 0.2s ease" 48 | :transform (when expanded? "rotate(90deg)")}} 49 | [:polygon {:points "0,0 0,100 100,50" :stroke "black"}]]]) 50 | 51 | (def button-style {:padding "1px 3px" 52 | :cursor "pointer" 53 | :background-color "white"}) 54 | 55 | (defn ExpandAllButton [emit-fn data] 56 | [:button {:onClick #(emit-fn :expand-all data) 57 | :style (merge button-style 58 | {:borderTopLeftRadius "2px" 59 | :borderBottomLeftRadius "2px" 60 | :border "1px solid darkgray"})} 61 | "Expand"]) 62 | 63 | (defn CollapseAllButton [emit-fn data] 64 | [:button {:onClick #(emit-fn :collapse-all) 65 | :style 66 | (merge button-style 67 | {:borderTop "1px solid darkgray" 68 | :borderBottom "1px solid darkgray" 69 | :borderRight "1px solid darkgray" 70 | :borderLeft "0"})} 71 | "Collapse"]) 72 | 73 | (defn CopyButton [emit-fn data] 74 | [:button {:onClick #(emit-fn :copy data) 75 | :style (merge button-style 76 | {:borderTopRightRadius "2px" 77 | :borderBottomRightRadius "2px" 78 | :borderTop "1px solid darkgray" 79 | :borderBottom "1px solid darkgray" 80 | :borderRight "1px solid darkgray" 81 | :borderLeft "0"})} 82 | "Copy"]) 83 | 84 | (defn NilText [] 85 | [:span {:style (:nil styles)} (pr-str nil)]) 86 | 87 | (defn StringText [data] 88 | [:span {:style (:strings styles)} (pr-str data)]) 89 | 90 | (defn KeywordText [data] 91 | [:span {:style (:keywords styles)} (str data)]) 92 | 93 | (defn NumberText [data] 94 | [:span {:style (:numbers styles)} data]) 95 | 96 | (defn KeySet [keyset] 97 | [:span 98 | (->> keyset 99 | (sort-by str) 100 | (map-indexed 101 | (fn [i data] ^{:key i} [:span 102 | (cond (nil? data) [NilText] 103 | (string? data) [StringText data] 104 | (keyword? data) [KeywordText data] 105 | (number? data) [NumberText data] 106 | :else (str data))])) 107 | (interpose " "))]) 108 | 109 | (defn Node [{:keys [data path emit-fn swappable metadata-paths]}] 110 | [:div {:style {:display "flex"}} (cond 111 | (nil? data) 112 | [NilText] 113 | 114 | (string? data) 115 | (if swappable 116 | [:input {:type "text" 117 | :default-value (str data) 118 | :on-change 119 | (fn string-changed [e] 120 | (emit-fn :changed path (.. e -target -value)))}] 121 | [StringText data]) 122 | 123 | (keyword? data) 124 | (if swappable 125 | [:input {:type "text" 126 | :default-value (name data) 127 | :on-change 128 | (fn keyword-changed [e] 129 | (emit-fn :changed path (keyword (.. e -target -value))))}] 130 | [KeywordText data]) 131 | 132 | (object? data) 133 | (str data " " (.stringify js/JSON data)) 134 | 135 | (number? data) 136 | (if swappable 137 | [:input {:type "number" 138 | :default-value data 139 | :on-change 140 | (fn number-changed [e] 141 | (emit-fn :changed path (js/Number (.. e -target -value))))}] 142 | [NumberText data]) 143 | :else 144 | (str data)) 145 | (when-let [errors (:error (get metadata-paths path))] 146 | [ErrorText (str "\u00A0 " errors)])]) 147 | 148 | (defn expandable? [v] 149 | (or (map? v) (seq? v) (coll? v))) 150 | 151 | (defn CollectionSummary [{:keys [data]}] 152 | (cond (map? data) [:div {:style {:flex "0 1 auto"}} 153 | [:span "{"] 154 | [KeySet (keys data)] 155 | [:span "}"]] 156 | (set? data) [:div {:style {:flex "0 1 auto"}} [:span "#{"] 157 | (str (count data) " items") 158 | [:span "}"]] 159 | (or (seq? data) 160 | (vector? data)) [:div {:style {:flex 1}} 161 | [:span (if (vector? data) "[" "(")] 162 | (str (count data) " items") 163 | [:span (if (vector? data) "]" ")")]])) 164 | 165 | (defn KeyValNode [{[k v] :data :keys [path metadata-paths emit-fn swappable]}] 166 | (let [path-to-here (conj path k) 167 | expandable-node? (and (expandable? v) 168 | (not (empty? v))) 169 | metadata (get metadata-paths path-to-here) 170 | expanded? (:expanded? metadata)] 171 | [:div {:style {:display "flex" 172 | :flex-flow "column"}} 173 | [:div {:style {:display "flex"}} 174 | [:div {:style {:flex "0 0 20px"}} 175 | (when expandable-node? 176 | [ExpandButton {:expanded? expanded? 177 | :path path-to-here 178 | :emit-fn emit-fn}])] 179 | [:div {:style {:flex "0 1 auto"}} 180 | [:div {:style {:display "flex" 181 | :flex-flow "row"}} 182 | [:div {:style {:flex "0 1 auto"}} 183 | [Node {:data k}]] 184 | [:div {:style {:flex "0 1 auto" :paddingLeft "4px"}} 185 | (if (expandable? v) 186 | [CollectionSummary {:data v}] 187 | [Node {:data v 188 | :swappable swappable 189 | :path path-to-here 190 | :metadata-paths metadata-paths 191 | :emit-fn emit-fn}])]]]] 192 | (when expanded? 193 | [:div {:style {:flex "1"}} 194 | [DataFrisk {:hide-header? true 195 | :data v 196 | :swappable swappable 197 | :path path-to-here 198 | :metadata-paths metadata-paths 199 | :emit-fn emit-fn}]])])) 200 | 201 | (defn ListVecNode [{:keys [data path metadata-paths emit-fn swappable hide-header?]}] 202 | (let [metadata (get metadata-paths path) 203 | expanded? (:expanded? metadata)] 204 | [:div {:style {:display "flex" 205 | :flex-flow "column"}} 206 | (when-not hide-header? 207 | [:div {:style {:display "flex"}} 208 | (when (:error metadata) 209 | [:div {:style {:margin-left "-1em" 210 | :width "1em" 211 | :height "1.2em"}} 212 | [ErrorIcon]]) 213 | [ExpandButton {:expanded? expanded? 214 | :path path 215 | :emit-fn emit-fn}] 216 | [:div {:style {:flex "0 1 auto"}} 217 | [:span (if (vector? data) "[" "(")] 218 | (str (count data) " items") 219 | [:span (if (vector? data) "]" ")")]]]) 220 | (when expanded? 221 | [:div {:style {:flex "0 1 auto" :padding "0 0 0 20px"}} 222 | (when (:error metadata) 223 | [:div {:style {:paddingBottom "4px"}} 224 | [ErrorText (:error metadata)]]) 225 | (map-indexed (fn [i x] ^{:key i} [DataFrisk {:data x 226 | :swappable swappable 227 | :path (conj path i) 228 | :metadata-paths metadata-paths 229 | :emit-fn emit-fn}]) data)])])) 230 | 231 | (defn SetNode [{:keys [data path metadata-paths emit-fn swappable hide-header?]}] 232 | (let [metadata (get metadata-paths path) 233 | expanded? (:expanded? metadata)] 234 | [:div {:style {:display "flex" 235 | :flex-flow "column"}} 236 | (when-not hide-header? 237 | [:div {:style {:display "flex"}} 238 | (when (:error metadata) 239 | [:div {:style {:margin-left "-1em" 240 | :width "1em" 241 | :height "1.2em"}} 242 | [ErrorIcon]]) 243 | [ExpandButton {:expanded? expanded? 244 | :path path 245 | :emit-fn emit-fn}] 246 | [:div {:style {:flex "0 1 auto"}} 247 | [:span "#{"] 248 | (str (count data) " items") 249 | [:span "}"]]]) 250 | (when expanded? 251 | [:div {:style {:flex "0 1 auto" :paddingLeft "20px"}} 252 | (when (:error metadata) 253 | [:div {:style {:paddingBottom "4px"}} 254 | [ErrorText (:error metadata)]]) 255 | (map-indexed (fn [i x] ^{:key i} [DataFrisk {:data x 256 | :swappable swappable 257 | :path (conj path i) 258 | :metadata-paths metadata-paths 259 | :emit-fn emit-fn}]) data)])])) 260 | 261 | (defn MapNode [{:keys [data path metadata-paths emit-fn hide-header?] :as all}] 262 | (let [metadata (get metadata-paths path) 263 | expanded? (:expanded? metadata)] 264 | [:div {:style {:display "flex" 265 | :flex-flow "column"}} 266 | (when-not hide-header? 267 | [:div {:style {:display "flex"}} 268 | (when (:error metadata) 269 | [:div {:style {:margin-left "-1em" 270 | :width "1em" 271 | :height "1.2em"}} 272 | [ErrorIcon]]) 273 | [ExpandButton {:expanded? expanded? 274 | :path path 275 | :emit-fn emit-fn}] 276 | [:div {:style {:flex "0 1 auto"}} 277 | [:span (str "{")] 278 | [KeySet (keys data)] 279 | [:span "}"]]]) 280 | (when expanded? 281 | [:div {:style {:flex "0 1 auto" :paddingLeft "20px"}} 282 | (when (:error metadata) 283 | [:div {:style {:paddingBottom "4px"}} 284 | [ErrorText (:error metadata)]]) 285 | (->> data 286 | (sort-by (fn [[k _]] (str k))) 287 | (map-indexed (fn [i x] ^{:key i} [KeyValNode (assoc all :data x)])))])])) 288 | 289 | (defn DataFrisk [{:keys [data] :as all}] 290 | (cond (map? data) [MapNode all] 291 | (set? data) [SetNode all] 292 | (or (seq? data) (vector? data)) [ListVecNode all] 293 | (satisfies? IDeref data) [DataFrisk (assoc all :data @data)] 294 | :else [:div {:style {:paddingLeft "20px"}} [Node all]])) 295 | 296 | (defn conj-to-set [coll x] 297 | (conj (or coll #{}) x)) 298 | 299 | (defn expand-all-paths [root-value current-expanded-paths] 300 | (loop [remaining [{:path [] :node root-value}] 301 | expanded-paths current-expanded-paths] 302 | (if (seq remaining) 303 | (let [[current & rest] remaining 304 | current-node (if (satisfies? IDeref (:node current)) @(:node current) (:node current))] 305 | (cond (map? current-node) 306 | (recur 307 | (concat rest (map (fn [[k v]] {:path (conj (:path current) k) 308 | :node v}) 309 | current-node)) 310 | (assoc-in expanded-paths [(:path current) :expanded?] true)) 311 | 312 | (or (seq? current-node) 313 | (vector? current-node) 314 | (set? current-node)) 315 | (recur 316 | (concat rest (map-indexed (fn [i node] {:path (conj (:path current) i) 317 | :node node}) 318 | current-node)) 319 | (assoc-in expanded-paths [(:path current) :expanded?] true)) 320 | 321 | :else 322 | (recur 323 | rest 324 | (if (coll? current-node) 325 | (assoc-in expanded-paths [(:path current) :expanded?] true) 326 | expanded-paths)))) 327 | expanded-paths))) 328 | 329 | (defn copy-to-clipboard [data] 330 | (let [pretty (with-out-str (pprint data)) 331 | textArea (.createElement js/document "textarea")] 332 | (doto textArea 333 | ;; Put in top left corner of screen 334 | (aset "style" "position" "fixed") 335 | (aset "style" "top" 0) 336 | (aset "style" "left" 0) 337 | ;; Make it small 338 | (aset "style" "width" "2em") 339 | (aset "style" "height" "2em") 340 | (aset "style" "padding" 0) 341 | (aset "style" "border" "none") 342 | (aset "style" "outline" "none") 343 | (aset "style" "boxShadow" "none") 344 | ;; Avoid flash of white box 345 | (aset "style" "background" "transparent") 346 | (aset "value" pretty)) 347 | 348 | (.appendChild (.-body js/document) textArea) 349 | (.select textArea) 350 | 351 | (.execCommand js/document "copy") 352 | (.removeChild (.-body js/document) textArea))) 353 | 354 | (defn collapse-all [metadata-paths] 355 | (u/map-vals #(assoc % :expanded? false) metadata-paths)) 356 | 357 | (defn emit-fn-factory [state-atom id swappable] 358 | (fn [event & args] 359 | (case event 360 | :expand (swap! state-atom assoc-in [:data-frisk id :metadata-paths (first args) :expanded?] true) 361 | :expand-all (swap! state-atom update-in [:data-frisk id :metadata-paths] (partial expand-all-paths (first args))) 362 | :contract (swap! state-atom assoc-in [:data-frisk id :metadata-paths (first args) :expanded?] false) 363 | :collapse-all (swap! state-atom update-in [:data-frisk id :metadata-paths] collapse-all) 364 | :copy (copy-to-clipboard (first args)) 365 | :changed (let [[path value] args] 366 | (if (seq path) 367 | (swap! swappable assoc-in path value) 368 | (reset! swappable value)))))) 369 | 370 | (defn Root [data id state-atom] 371 | (let [data-frisk (:data-frisk @state-atom) 372 | swappable (when (satisfies? IAtom data) 373 | data) 374 | emit-fn (emit-fn-factory state-atom id swappable) 375 | metadata-paths (get-in data-frisk [id :metadata-paths])] 376 | [:div 377 | [:div {:style {:padding "4px 2px"}} 378 | [ExpandAllButton emit-fn data] 379 | [CollapseAllButton emit-fn] 380 | [CopyButton emit-fn data]] 381 | [:div {:style {:flex "0 1 auto"}} 382 | [DataFrisk {:data data 383 | :swappable swappable 384 | :path [] 385 | :metadata-paths metadata-paths 386 | :emit-fn emit-fn}]]])) 387 | 388 | (defn VisibilityButton 389 | [visible? update-fn] 390 | [:button {:style {:border 0 391 | :backgroundColor "transparent" :width "20px" :height "20px"} 392 | :onClick update-fn} 393 | [:svg {:viewBox "0 0 100 100" 394 | :width "100%" :height "100%" 395 | :style {:transition "all 0.2s ease" 396 | :transform (when visible? "rotate(90deg)")}} 397 | [:polygon {:points "0,0 0,100 100,50" :stroke "black"}]]]) 398 | 399 | (defn DataFriskView [& data] 400 | (let [expand-by-default (reduce #(assoc-in %1 [:data-frisk %2 :metadata-paths [] :expanded?] true) {} (range (count data))) 401 | state-atom (r/atom expand-by-default)] 402 | (fn [& data] 403 | (let [data-frisk (:data-frisk @state-atom) 404 | visible? (:visible? data-frisk)] 405 | [:div {:style (merge {:flex-flow "row nowrap" 406 | :transition "all 0.3s ease-out" 407 | :z-index "5"} 408 | (when-not visible? 409 | {:overflow-x "hide" 410 | :overflow-y "hide" 411 | :max-height "30px" 412 | :max-width "100px"}) 413 | (:shell styles))} 414 | [VisibilityButton visible? (fn [_] (swap! state-atom assoc-in [:data-frisk :visible?] (not visible?)))] 415 | [:span "Data frisk"] 416 | (when visible? 417 | [:div {:style {:padding "10px" 418 | ;; TODO Make the max height and width adjustable 419 | ;:max-height "400px" 420 | ;:max-width "800px" 421 | :resize "both" 422 | :box-sizing "border-box" 423 | :overflow-x "auto" 424 | :overflow-y "auto"}} 425 | (map-indexed (fn [id x] 426 | ^{:key id} [Root x id state-atom]) data)])])))) 427 | -------------------------------------------------------------------------------- /test/datafrisk/test_runner.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.test-runner 2 | (:require [doo.runner :refer-macros [doo-tests]] 3 | datafrisk.view-test)) 4 | 5 | (doo-tests 6 | 'datafrisk.view-test) 7 | -------------------------------------------------------------------------------- /test/datafrisk/view_test.cljs: -------------------------------------------------------------------------------- 1 | (ns datafrisk.view-test 2 | (:require [cljs.test :refer-macros [are deftest is]] 3 | [datafrisk.view :as sut] 4 | [reagent.core :as r])) 5 | 6 | (deftest first-test 7 | (is (= (sut/expand-all-paths 8 | {:a 1} {}) 9 | {[] {:expanded? true}})) 10 | 11 | (is (= (sut/expand-all-paths 12 | {:a {:b 1}} {}) 13 | {[] {:expanded? true} 14 | [:a] {:expanded? true}})) 15 | 16 | (is (= (sut/expand-all-paths 17 | {:a {:c 1} :b {:d 2}} {}) 18 | {[] {:expanded? true} 19 | [:a] {:expanded? true} 20 | [:b] {:expanded? true}})) 21 | 22 | (is (= (sut/expand-all-paths 23 | {:a [1 2 3]} {}) 24 | {[] {:expanded? true} 25 | [:a] {:expanded? true}})) 26 | 27 | (is (= (sut/expand-all-paths 28 | {:a [1 {:b [2 3 4]}]} {}) 29 | {[] {:expanded? true} 30 | [:a] {:expanded? true} 31 | [:a 1] {:expanded? true} 32 | [:a 1 :b] {:expanded? true}})) 33 | 34 | (is (= (sut/expand-all-paths 35 | (r/atom {:a 1}) {}) 36 | {[] {:expanded? true}})) 37 | 38 | (is (= (sut/expand-all-paths 39 | (r/atom {:a {:b 1}}) {}) 40 | {[] {:expanded? true} 41 | [:a] {:expanded? true}}))) --------------------------------------------------------------------------------