Skip to content
Snippets Groups Projects
Commit e1f9f9ca authored by Ryan Senior's avatar Ryan Senior
Browse files

Driver pulse table cell color from visualiation settings

This creates a new shared javascript file in
`resources/frontend_shared` that will be used by the frontend and the
backend to drive the background color of cells using conditional
formatting features the frontend has.

This uses the JDK 8 included Nashorn javascript engine to eval and
invoke the functions in that shared file.
parent 1a5f1fed
No related branches found
No related tags found
No related merge requests found
function makeCellBackgroundGetter(data, settings) {
return function(value, columnName, rowIndex) {
return "#ff0000";
}
}
(ns metabase.pulse.color
"Namespaces that uses the Nashorn javascript engine to invoke some shared javascript code that we use to determine
the background color of pulse table cells"
(:require [clojure.walk :as walk]
[puppetlabs.i18n.core :refer [trs]])
(:import java.io.InputStream
[javax.script Invocable ScriptEngineManager]
jdk.nashorn.api.scripting.JSObject))
(defn- make-js-engine-with-script [^String script]
(let [engine-mgr (ScriptEngineManager.)
js-engine (.getEngineByName engine-mgr "nashorn")]
(.eval js-engine script)
js-engine))
(defn- ^InputStream get-classpath-resource [path]
(.getResourceAsStream (class []) path))
(def ^:private js-engine
(let [js-file-path "/frontend_shared/color_selector.js"]
;; The code that loads the JS engine is behind a delay so that we don't incur that cost on startup. The below
;; assertion till look for the javascript file at startup and fail if it doesn't find it. This is to avoid a big
;; delay in finding out that the system is broken
(assert (get-classpath-resource js-file-path)
(trs "Can't find JS color selector at ''{0}''" js-file-path))
(delay
(with-open [stream (get-classpath-resource js-file-path)]
(make-js-engine-with-script (slurp stream))))))
(defn- make-args-array
"Useful for converting `args` into an object array which is necessary for invoking a varargs Java method via
Clojure"
[& args]
(let [^objects args-array (make-array Object (count args))]
(doall (map-indexed (fn [idx arg]
(aset args-array idx arg)) args))
args-array))
(defn make-color-selector
"Returns a curried javascript function (object) that can be used with `get-background-color` for delegating to JS
code to pick out the correct color for a given cell in a pulse table. The logic for picking a color is somewhat
complex, but defined in a set of rules in `viz-settings`. There are some colors that are picked based on a
particular cell value, others affect the row, so it's necessary to call this once for the resultset and then
`get-background-color` on each cell."
[data viz-settings]
(let [^Invocable engine @js-engine]
(->> viz-settings
;; Keyword strings don't serialize correctly when being passed to the JS engine
walk/stringify-keys
(make-args-array data)
(.invokeFunction engine "makeCellBackgroundGetter"))))
(defn get-background-color
"Get the correct color for a cell in a pulse table. This is intended to be invoked on each cell of every row in the
table. See `make-color-selector` for more info."
[^JSObject color-selector cell-value column-name row-index]
(.call color-selector color-selector (make-args-array cell-value column-name row-index)))
......@@ -10,6 +10,7 @@
[hiccup
[core :refer [h html]]
[util :as hutil]]
[metabase.pulse.color :as color]
[metabase.util :as u]
[metabase.util
[date :as du]
......@@ -366,32 +367,38 @@
(bar-td-style)))
(defn- render-table
[header+rows]
[:table {:style (style {:max-width (str "100%"), :white-space :nowrap, :padding-bottom :8px, :border-collapse :collapse})}
(let [{header-row :row bar-width :bar-width} (first header+rows)]
[card header+rows]
(let [{bar-width :bar-width :as header} (first header+rows)
header-row (vec (:row header))
color-selector (color/make-color-selector header+rows (:visualization_settings card))]
[:table {:style (style {:max-width (str "100%"), :white-space :nowrap, :padding-bottom :8px, :border-collapse :collapse})}
[:thead
[:tr
(for [header-cell header-row]
[:th {:style (style (row-style-for-type header-cell) (heading-style-for-type header-cell) {:min-width :60px})}
(h header-cell)])
(when bar-width
[:th {:style (style (bar-td-style) (bar-th-style) {:width (str bar-width "%")})}])]])
[:tbody
(map-indexed (fn [row-idx {:keys [row bar-width]}]
[:tr {:style (style {:color color-gray-3})}
(map-indexed (fn [col-idx cell]
[:td {:style (style (row-style-for-type cell) (when (and bar-width (= col-idx 1)) {:font-weight 700}))}
(h cell)])
row)
(when bar-width
[:td {:style (style (bar-td-style) {:width :99%})}
[:div {:style (style {:background-color color-purple
:max-height :10px
:height :10px
:border-radius :2px
:width (str bar-width "%")})}
" "]])])
(rest header+rows))]])
[:th {:style (style (bar-td-style) (bar-th-style) {:width (str bar-width "%")})}])]]
[:tbody
(map-indexed (fn [row-idx {:keys [row bar-width]}]
[:tr {:style (style {:color color-gray-3})}
(map-indexed (fn [col-idx cell]
(let [bg-color (color/get-background-color color-selector cell (get header-row col-idx) row-idx)]
[:td {:style (style (row-style-for-type cell)
(merge {:background-color bg-color}
(when (and bar-width (= col-idx 1))
{:font-weight 700})))}
(h cell)]))
row)
(when bar-width
[:td {:style (style (bar-td-style) {:width :99%})}
[:div {:style (style {:background-color color-purple
:max-height :10px
:height :10px
:border-radius :2px
:width (str bar-width "%")})}
" "]])])
(rest header+rows))]]))
(defn- create-remapping-lookup
"Creates a map with from column names to a column index. This is used to figure out what a given column name or value
......@@ -493,7 +500,7 @@
(s/defn ^:private render:table :- RenderedPulseCard
[render-type timezone card {:keys [cols rows] :as data}]
(let [table-body [:div
(render-table (prep-for-html-rendering timezone cols rows nil nil cols-limit))
(render-table card (prep-for-html-rendering timezone cols rows nil nil cols-limit))
(render-truncation-warning cols-limit (count-displayed-columns cols) rows-limit (count rows))]]
{:attachments nil
:content (if-let [results-attached (attached-results-text render-type cols cols-limit rows rows-limit)]
......@@ -506,7 +513,7 @@
max-value (apply max (map y-axis-rowfn rows))]
{:attachments nil
:content [:div
(render-table (prep-for-html-rendering timezone cols rows y-axis-rowfn max-value 2))
(render-table card (prep-for-html-rendering timezone cols rows y-axis-rowfn max-value 2))
(render-truncation-warning 2 (count-displayed-columns cols) rows-limit (count rows))]}))
(s/defn ^:private render:scalar :- RenderedPulseCard
......
(ns metabase.pulse.color-test
(:require [expectations :refer :all]
[metabase.pulse.color :as color :refer :all]))
(def ^:private red "#ff0000")
(def ^:private green "#00ff00")
(def ^:private ^String test-script
"function makeCellBackgroundGetter(data, settings) {
return function(value, columnName, rowIndex) {
if(rowIndex % 2 == 0){
return settings[\"even\"]
} else {
return settings[\"odd\"]
}
}
}")
(defmacro ^:private with-test-js-engine
"Setup a javascript engine with a stubbed script useful making sure `get-background-color` works independently from
the real color picking script"
[& body]
`(with-redefs [color/js-engine (delay (#'color/make-js-engine-with-script test-script))]
~@body))
;; The test script above should return red on even rows, green on odd rows
(expect
[red green red green]
(with-test-js-engine
(let [color-selector (make-color-selector [] {"even" red, "odd" green})]
(for [row-index (range 0 4)]
(get-background-color color-selector "any value" "any column" row-index)))))
;; Same test as above, but make sure we convert any keywords as keywords don't get converted to strings automatically
;; when passed to a nashorn function
(expect
[red green red green]
(with-test-js-engine
(let [color-selector (make-color-selector [] {:even red, :odd green})]
(for [row-index (range 0 4)]
(get-background-color color-selector "any value" "any column" row-index)))))
......@@ -2,7 +2,8 @@
(:require [clojure.walk :as walk]
[expectations :refer :all]
[hiccup.core :refer [html]]
[metabase.pulse.render :as render :refer :all])
[metabase.pulse.render :as render :refer :all]
[metabase.query-processor.util :as qputil])
(:import java.util.TimeZone))
(def ^:private pacific-tz (TimeZone/getTimeZone "America/Los_Angeles"))
......@@ -288,3 +289,51 @@
4
(count-displayed-columns
(concat test-columns [description-col detail-col sensitive-col retired-col])))
(defn- find-table-body
"Given the hiccup data structure, find the table body and return it"
[results]
(qputil/postwalk-collect (every-pred vector? #(= :tbody (first %)))
;; The Hiccup form is [:tbody (...rows...)], so grab the second item
second
results))
(defn- style-map->background-color
"Finds the background color in the style string of a Hiccup style map"
[{:keys [style]}]
(let [[_ color-str] (re-find #".*background-color: (.*);" style)]
color-str))
(defn- cell-value->background-color
"Returns a map of cell values to background colors of the pulse table found in the hiccup `results` data
structure. This only includes the data cell values, not the header values."
[results]
(into {} (qputil/postwalk-collect (every-pred vector? #(= :td (first %)))
(fn [[_ style-map cell-value]]
[cell-value (style-map->background-color style-map)])
results)))
(defn- make-row
"Makes a pulse header or data row with no bar-width. Including bar-width just adds extra HTML that will be ignored."
[row-values]
{:row row-values
:bar-width nil})
;; Smoke test for background color selection. Background color decided by some shared javascript code. It's being
;; invoked and included in the cell color of the pulse table. This is somewhat fragile code as the only way to find
;; that style information is to crawl the clojure-ized HTML datastructure and pick apart the style string associated
;; with the cell value. The script right now is hard coded to always return #ff0000. Once the real script is in place,
;; we should find some similar basic values that can rely on. The goal isn't to test out the javascript choosing in
;; the color (that should be done in javascript) but to verify that the pieces are all connecting correctly
(expect
(zipmap (map str (range 1 7))
(repeat "#ff0000"))
(let [viz-settings {:visualization_settings {}}
query-results (map make-row [["a" "b"]
[1 2]
[3 4]
[5 6]])]
(->> query-results
(#'render/render-table viz-settings)
find-table-body
cell-value->background-color)))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment