├── .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 | 
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}})))
--------------------------------------------------------------------------------