(ns metabase.channel.render.card (:require [hiccup.core :refer [h]] [metabase.channel.render.body :as body] [metabase.channel.render.image-bundle :as image-bundle] [metabase.channel.render.png :as png] [metabase.channel.render.style :as style] [metabase.models.dashboard-card :as dashboard-card] [metabase.query-processor.timezone :as qp.timezone] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.registry :as mr] [metabase.util.markdown :as markdown] [metabase.util.urls :as urls] [toucan2.core :as t2])) | |
I gave these keys below namespaces to make them easier to find usages for but didn't use | (mr/def ::options
"Options for Pulse (i.e. Alert/Dashboard Subscription) rendering."
[:map
[:channel.render/include-buttons? {:description "default: false", :optional true} :boolean]
[:channel.render/include-title? {:description "default: false", :optional true} :boolean]
[:channel.render/include-description? {:description "default: false", :optional true} :boolean]]) |
(defn- card-href [card] (h (urls/card-url (u/the-id card)))) | |
(mu/defn- make-title-if-needed :- [:maybe ::body/RenderedPartCard]
[render-type card dashcard options :- [:maybe ::options]]
(when (:channel.render/include-title? options)
(let [card-name (or (-> dashcard :visualization_settings :card.title)
(-> card :name))
image-bundle (when (:channel.render/include-buttons? options)
(image-bundle/external-link-image-bundle render-type))]
{:attachments (when image-bundle
(image-bundle/image-bundle->attachment image-bundle))
:content [:table {:style (style/style {:margin-bottom :2px
:border-collapse :collapse
:width :100%})}
[:tbody
[:tr
[:td {:style (style/style {:padding :0
:margin :0})}
[:a {:style (style/style (style/header-style))
:href (card-href card)
:target "_blank"
:rel "noopener noreferrer"}
(h card-name)]]
[:td {:style (style/style {:text-align :right})}
(when (:channel.render/include-buttons? options)
[:img {:style (style/style {:width :16px})
:width 16
:src (:image-src image-bundle)}])]]]]}))) | |
(mu/defn- make-description-if-needed :- [:maybe ::body/RenderedPartCard]
[dashcard card options :- [:maybe ::options]]
(when (:channel.render/include-description? options)
(when-let [description (or (get-in dashcard [:visualization_settings :card.description])
(:description card))]
{:attachments {}
:content [:div {:style (style/style {:color style/color-text-medium
:font-size :12px
:margin-bottom :8px})}
(markdown/process-markdown description :html)]}))) | |
Determine the pulse (visualization) type of a | (defn detect-pulse-chart-type
[{display-type :display card-name :name} maybe-dashcard {:keys [cols rows] :as data}]
(let [col-sample-count (delay (count (take 3 cols)))
row-sample-count (delay (count (take 2 rows)))]
(letfn [(chart-type [tyype reason & args]
(log/tracef "Detected chart type %s for Card %s because %s"
tyype (pr-str card-name) (apply format reason args))
tyype)]
(cond
(or (empty? rows)
;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters
(= [[nil]] (-> data :rows)))
(chart-type :empty "there are no rows in results")
(#{:pin_map :state :country} display-type)
(chart-type nil "display-type is %s" display-type)
(and (some? maybe-dashcard)
(pos? (count (dashboard-card/dashcard->multi-cards maybe-dashcard))))
(chart-type :javascript_visualization "result has multiple card semantics, a multiple chart")
;; for scalar/smartscalar, the display-type might actually be :line, so we can't have line above
(and (not (contains? #{:progress :gauge} display-type))
(= @col-sample-count @row-sample-count 1))
(chart-type :scalar "result has one row and one column")
(#{:scalar
:row
:progress
:gauge
:table
:funnel} display-type)
(chart-type display-type "display-type is %s" display-type)
(#{:smartscalar
:sankey
:scalar
:pie
:scatter
:waterfall
:line
:area
:bar
:combo} display-type)
(chart-type :javascript_visualization "display-type is javascript_visualization")
:else
(chart-type :table "no other chart types match"))))) |
(defn- is-attached? [card] ((some-fn :include_csv :include_xls) card)) | |
(mu/defn- render-pulse-card-body :- ::body/RenderedPartCard
[render-type
timezone-id :- [:maybe :string]
card
dashcard
{:keys [data error] :as results}]
(try
(when error
(throw (ex-info (tru "Card has errors: {0}" error) (assoc results :card-error true))))
(let [chart-type (or (detect-pulse-chart-type card dashcard data)
(when (is-attached? card)
:attached)
:unknown)]
(log/debugf "Rendering pulse card with chart-type %s and render-type %s" chart-type render-type)
(body/render chart-type render-type timezone-id card dashcard data))
(catch Throwable e
(if (:card-error (ex-data e))
(do
(log/error e "Pulse card query error")
(body/render :card-error nil nil nil nil nil))
(do
(log/error e "Pulse card render error")
(body/render :render-error nil nil nil nil nil)))))) | |
(mu/defn render-pulse-card :- ::body/RenderedPartCard
"Render a single `card` for a `Pulse` to Hiccup HTML. `result` is the QP results. Returns a map with keys
- attachments
- content (a hiccup form suitable for rendering on rich clients or rendering into an image)
- render/text : raw text suitable for substituting on clients when text is preferable. (Currently slack uses this for
scalar results where text is preferable to an image of a div of a single result."
([render-type timezone-id card dashcard results]
(render-pulse-card render-type timezone-id card dashcard results nil))
([render-type
timezone-id :- [:maybe :string]
card
dashcard
results
options :- [:maybe ::options]]
(let [{title :content
title-attachments :attachments} (make-title-if-needed render-type card dashcard options)
{description :content} (make-description-if-needed dashcard card options)
{pulse-body :content
body-attachments :attachments
text :render/text} (render-pulse-card-body render-type timezone-id card dashcard results)]
(cond-> {:attachments (merge title-attachments body-attachments)
:content [:p
;; Provide a horizontal scrollbar for tables that overflow container width.
;; Surrounding <p> element prevents buggy behavior when dragging scrollbar.
[:div
[:a {:href (card-href card)
:target "_blank"
:rel "noopener noreferrer"
:style (style/style
(style/section-style)
{:display :block
:text-decoration :none})}
title
description
[:div {:class "pulse-body"
:style (style/style {:overflow-x :auto ;; when content is wide enough, automatically show a horizontal scrollbar
:display :block
:margin :16px})}
(if-let [more-results-message (body/attached-results-text render-type card)]
(conj more-results-message (list pulse-body))
pulse-body)]]]]}
text (assoc :render/text text))))) | |
Same as | (mu/defn render-pulse-card-for-display ([timezone-id card results] (render-pulse-card-for-display timezone-id card results nil)) ([timezone-id card results options :- [:maybe ::options]] (:content (render-pulse-card :inline timezone-id card nil results options)))) |
(mu/defn render-pulse-section :- ::body/RenderedPartCard
"Render a single Card section of a Pulse to a Hiccup form (representating HTML)."
([timezone-id part]
(render-pulse-section timezone-id part {}))
([timezone-id
{card :card, dashcard :dashcard, result :result, :as _part}
options :- [:maybe ::options]]
(let [options (merge {:channel.render/include-title? true
:channel.render/include-description? true}
options)
{:keys [attachments content]} (render-pulse-card :attachment timezone-id card dashcard result options)]
{:attachments attachments
:content [:div {:style (style/style {:margin-top :20px
:margin-bottom :20px})}
content]}))) | |
(mu/defn render-pulse-card-to-png :- bytes?
"Render a `pulse-card` as a PNG. `data` is the `:data` from a QP result."
(^bytes [timezone-id pulse-card result width]
(render-pulse-card-to-png timezone-id pulse-card result width nil))
(^bytes [timezone-id :- [:maybe :string]
pulse-card
result
width
options :- [:maybe ::options]]
(png/render-html-to-png (render-pulse-card :inline timezone-id pulse-card nil result options) width))) | |
(mu/defn render-pulse-card-to-base64 :- string?
"Render a `pulse-card` as a PNG and return it as a base64 encoded string."
^String [timezone-id card dashcard result width]
(-> (render-pulse-card :inline timezone-id card dashcard result)
(png/render-html-to-png width)
image-bundle/render-img-data-uri)) | |
(mu/defn png-from-render-info :- bytes? "Create a PNG file (as a byte array) from rendering info." ^bytes [rendered-info :- ::body/RenderedPartCard width] ;; TODO huh? why do we need this indirection? (png/render-html-to-png rendered-info width)) | |
(mu/defn defaulted-timezone :- :string
"Returns the timezone ID for the given `card`. Either the report timezone (if applicable) or the JVM timezone."
[card]
(or (some->> card :database_id (t2/select-one :model/Database :id) qp.timezone/results-timezone-id)
(qp.timezone/system-timezone-id))) | |