39 | <%= for {column, index} <- Enum.with_index(stats) do %>
40 |
41 |
42 |
43 | <%= column.title %>
44 |
45 |
46 |
47 |
48 | <%= Utils.print_number(column.value) %>
49 |
50 |
51 | <%= column.unit %>
52 |
53 |
54 |
55 | <% end %>
56 |
57 | <% _ -> %>
58 |
115 |
116 |
121 |
122 |
123 | <%= if data = Dashboard.get_data(@dashboard, @panel.id) do %>
124 | <.panel_statistics stats={Enum.map(data.datasets, & &1.stats)} />
125 | <% end %>
126 |
127 | """
128 | end
129 |
130 | @impl true
131 | def actions() do
132 | [
133 | %{event: "download:csv", label: "Download CSV"},
134 | %{event: "download:png", label: "Download Image"}
135 | ]
136 | end
137 |
138 | def statistics(rows, label) do
139 | init_stats = %{n: 0, sum: nil, min: nil, max: nil, max_decimal_digits: 0}
140 |
141 | stats =
142 | Enum.reduce(rows, init_stats, fn %{y: y}, stats ->
143 | min = Map.fetch!(stats, :min) || y
144 | max = Map.fetch!(stats, :max) || y
145 | sum = Map.fetch!(stats, :sum)
146 | n = Map.fetch!(stats, :n)
147 | max_decimal_digits = Map.fetch!(stats, :max_decimal_digits)
148 |
149 | new_sum =
150 | case {sum, y} do
151 | {nil, y} -> y
152 | {sum, nil} -> sum
153 | {sum, y} -> Decimal.add(y, sum)
154 | end
155 |
156 | decimal_digits =
157 | with y when not is_nil(y) <- y,
158 | [_, dec] <- Decimal.to_string(y, :normal) |> String.split(".") do
159 | String.length(dec)
160 | else
161 | _ -> 0
162 | end
163 |
164 | stats
165 | |> Map.put(:min, if(!is_nil(y) && Decimal.lt?(y, min), do: y, else: min))
166 | |> Map.put(:max, if(!is_nil(y) && Decimal.gt?(y, max), do: y, else: max))
167 | |> Map.put(:sum, new_sum)
168 | |> Map.put(:n, if(is_nil(y), do: n, else: n + 1))
169 | |> Map.put(
170 | :max_decimal_digits,
171 | if(decimal_digits > max_decimal_digits, do: decimal_digits, else: max_decimal_digits)
172 | )
173 | end)
174 |
175 | # we use this to determine the rounding for the average dataset value
176 | max_decimal_digits = Map.fetch!(stats, :max_decimal_digits)
177 |
178 | # calculate the average
179 | avg =
180 | cond do
181 | stats[:n] == 0 ->
182 | nil
183 |
184 | is_nil(stats[:sum]) ->
185 | nil
186 |
187 | true ->
188 | Decimal.div(stats[:sum], Decimal.new(stats[:n])) |> Decimal.round(max_decimal_digits)
189 | end
190 |
191 | stats
192 | |> Map.put(:avg, avg)
193 | |> Map.put(:label, label)
194 | |> Map.delete(:max_decimal_digits)
195 | end
196 |
197 | defp convert_to_decimal(nil), do: nil
198 |
199 | defp convert_to_decimal(value) do
200 | case Decimal.cast(value) do
201 | {:ok, dec} -> dec
202 | _ -> value
203 | end
204 | end
205 |
206 | # example: [{:time, #DateTime<2022-10-01 01:00:00+00:00 UTC UTC>}, {"foo", #Decimal<0.65>}]
207 | defp extract_labels(rows) when is_list(rows) do
208 | rows
209 | |> Enum.flat_map(fn
210 | row ->
211 | row
212 | |> Enum.map(fn {label, _value} -> label end)
213 | |> Enum.reject(&(&1 == :time))
214 | end)
215 | |> Enum.uniq()
216 | end
217 |
218 | attr :stats, :map, required: true
219 |
220 | def panel_statistics(assigns) do
221 | if is_nil(assigns.stats) || length(assigns.stats) == 0 do
222 | ~H""
223 | else
224 | ~H"""
225 |
226 |
227 |
N
228 |
Min
229 |
Max
230 |
Avg
231 |
Total
232 |
233 | <%= for var <- @stats do %>
234 |
<%= var.label %>
235 |
<%= var.n %>
236 |
<%= Utils.print_number(var.min) %>
237 |
<%= Utils.print_number(var.max) %>
238 |
<%= Utils.print_number(var.avg) %>
239 |
<%= Utils.print_number(var.sum) %>
240 | <% end %>
241 |
242 | """
243 | end
244 | end
245 | end
246 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/elinverd/luminous/actions/workflows/test.yml)
2 | [](https://hex.pm/packages/luminous)
3 |
4 | # Luminous
5 |
6 | Luminous is a framework for creating dashboards within [Phoenix Live
7 | View](https://www.phoenixframework.org/).
8 |
9 | Dashboards are defined by the client application (framework consumer)
10 | using elixir code and consist of Panels (`Luminous.Panel`) which are
11 | responsible for visualizing the results of multiple client-side
12 | queries (`Luminous.Query`).
13 |
14 | Three different types of Panels are currently offered out of the box
15 | by Luminous:
16 |
17 | - `Luminous.Panel.Chart` for visualizing 2-d data (including time
18 | series) using the [chartjs](https://www.chartjs.org/) library
19 | (embedded in a JS hook). Currently, only `:line` and `:bar`are
20 | supported.
21 | - `Luminous.Panel.Stat` for displaying single or multiple numerical or
22 | other values (e.g. strings)
23 | - `Luminous.Panel.Table` for displaying tabular data
24 |
25 | A client application can implement its own custom panels by
26 | implementing the `Luminous.Panel` behaviour.
27 |
28 | Dashboards are parameterized by:
29 |
30 | - a date range (using the [flatpickr](https://flatpickr.js.org/) library)
31 | - user-defined variables (`Luminous.Variable`) in the form of dropdown menus
32 |
33 | All panels are refreshed whenever at least one of these paramaters
34 | (date range, variables) change. The parameter values are available to
35 | client-side queries.
36 |
37 | ## Features
38 |
39 | - Date range selection and automatic asynchronous (i.e. non-blocking
40 | for the UI) refresh of all dashboard panel queries
41 | - User-facing variable dropdowns (with single- or multi- selection)
42 | whose selected values are available to panel queries
43 | - Client-side zoom in charts with automatic update of the entire
44 | dashboard with the new date range
45 | - Panel data downloads depending on the panel type (CSV, PNG)
46 | - Stat panels (show single or multiple stats)
47 | - Table panels using [tabulator](https://tabulator.info/)
48 | - Summary statistics in charts
49 |
50 | ## Installation
51 |
52 | The package can be installed from `hex.pm` as follows:
53 |
54 | ```elixir
55 | def deps do
56 | [
57 | {:luminous, "~> 2.6.1"}
58 | ]
59 | end
60 | ```
61 |
62 | In order to be able to use the provided components, the library's
63 | `javascript` and `CSS` files must be imported to your project:
64 |
65 | In `assets/js/app.js`:
66 |
67 | ```javascript
68 | import { ChartJSHook, TableHook, TimeRangeHook, MultiSelectVariableHook } from "luminous"
69 |
70 | let Hooks = {
71 | TimeRangeHook: new TimeRangeHook(),
72 | ChartJSHook: new ChartJSHook(),
73 | TableHook: new TableHook(),
74 | MultiSelectVariableHook: new MultiSelectVariableHook()
75 | }
76 |
77 | ...
78 |
79 | let liveSocket = new LiveSocket("/live", Socket, {
80 | ...
81 | hooks: Hooks
82 | })
83 | ...
84 | ```
85 |
86 | Finally, in `assets/css/app.css`:
87 | ```CSS
88 | @import "../../deps/luminous/dist/luminous.css";
89 | ```
90 |
91 | ## Usage
92 |
93 | ### Live View
94 |
95 | The dashboard live view is defined client-side like so:
96 |
97 | ```elixir
98 | defmodule ClientApp.DashboardLive do
99 | alias ClientApp.Router.Helpers, as: Routes
100 |
101 | use Luminous.Live,
102 | title: "My Title",
103 | time_zone: "Europe/Paris",
104 | panels: [
105 | ...
106 | ],
107 | variables: [
108 | ...
109 | ]
110 |
111 | # the dashboard can be rendered by leveraging the corresponding functionality
112 | # from `Luminous.Components`
113 | def render(assigns) do
114 | ~H"""
115 |
91 |
92 |
98 |
105 |
109 |
113 |
114 |
Loading...
115 |
116 |
117 | <%= if has_panel_actions?(@panel) do %>
118 |
155 | <% end %>
156 |
157 |
158 |
159 | <%= interpolate(@panel.title, @dashboard.variables) %>
160 |
161 | <%= unless is_nil(@panel.description) do %>
162 |
181 | <% end %>
182 |
183 |
184 |
185 | <%= apply(@panel.type, :render, [assigns]) %>
186 |
187 | """
188 | end
189 |
190 | defp has_panel_actions?(panel), do: panel |> get_panel_actions |> length > 0
191 |
192 | defp get_panel_actions(panel) do
193 | if function_exported?(panel.type, :actions, 0) do
194 | apply(panel.type, :actions, [])
195 | else
196 | []
197 | end
198 | end
199 |
200 | @doc """
201 | This component is responsible for rendering the `Luminous.TimeRange` component.
202 | It consists of a date range picker and a presets dropdown.
203 | """
204 | attr :time_zone, :string, required: true
205 | attr :presets, :list, required: false, default: nil
206 | attr :class, :string, required: false, default: ""
207 |
208 | def time_range(assigns) do
209 | presets =
210 | if is_nil(assigns.presets), do: Luminous.TimeRangeSelector.presets(), else: assigns.presets
211 |
212 | assigns = assign(assigns, presets: presets)
213 |
214 | ~H"""
215 | JS.dispatch("clickAway", detail: %{"var_id" => @variable.id})
338 | }
339 | >
340 |
JS.dispatch("dropdownOpen",
345 | detail: %{"values" => Variable.extract_value(@variable.current)}
346 | )
347 | }
348 | >
349 |
350 | <%= "#{@variable.label}: " %> <%= Variable.get_current_label(
351 | @variable
352 | ) %>
353 |
354 |
360 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
"#{@variable.id}-dropdown-search-input",
381 | "list_id" => "#{@variable.id}-items-list"
382 | }
383 | )
384 | }
385 | />
386 |
394 |
399 |
400 |
401 |
402 | "#{@variable.id}-items-list"})
405 | }
406 | class="text-xs underline cursor-pointer"
407 | >
408 | Clear selection
409 |
410 | "#{@variable.id}-items-list"})
413 | }
414 | class="text-xs underline cursor-pointer"
415 | >
416 | Select all
417 |
418 |
419 |
420 |
443 |
444 |
445 | """
446 | end
447 |
448 | defp show_dropdown(dropdown_id) do
449 | JS.show(
450 | to: "##{dropdown_id}",
451 | transition:
452 | {"lmn-dropdown-transition-enter", "lmn-dropdown-transition-start",
453 | "lmn-dropdown-transition-end"}
454 | )
455 | end
456 |
457 | defp hide_dropdown(dropdown_id) do
458 | JS.hide(to: "##{dropdown_id}")
459 | end
460 |
461 | # Interpolate all occurences of variable IDs in the format `$variable.id` in the string
462 | # with the variable's descriptive value label. For example, the string: "Energy for asset $asset_var"
463 | # will be replaced by the label of the variable with id `:asset_var` in variables.
464 | defp interpolate(nil, _), do: ""
465 |
466 | defp interpolate(string, variables) do
467 | Enum.reduce(variables, string, fn var, title ->
468 | String.replace(title, "$#{var.id}", "#{Variable.get_current_label(var)}")
469 | end)
470 | end
471 | end
472 |
--------------------------------------------------------------------------------
/test/luminous/live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Luminous.LiveTest do
2 | alias Luminous.TimeRange
3 | use Luminous.ConnCase, async: true
4 | use AssertHTML
5 | use AssertEventually, timeout: 500, interval: 10
6 |
7 | alias Luminous.{Attributes, Query, Panel, TimeRange, Variable}
8 |
9 | defmodule Variables do
10 | @moduledoc false
11 |
12 | @behaviour Variable
13 | @impl true
14 | def variable(:var1, _), do: ["a", "b", "c"]
15 | def variable(:var2, _), do: ["1", "2", "3"]
16 | def variable(:multi_var, _), do: ["north", "south", "east", "west"]
17 | def variable(:empty, _), do: []
18 | end
19 |
20 | defmodule Queries do
21 | @moduledoc false
22 |
23 | @behaviour Query
24 | @impl true
25 | def query(:q1, _time_range, _variables) do
26 | [
27 | [{:time, ~U[2022-08-19T10:00:00Z]}, {"foo", 10}, {"bar", 100}],
28 | [{:time, ~U[2022-08-19T11:00:00Z]}, {"foo", 11}, {"bar", 101}]
29 | ]
30 | end
31 |
32 | def query(:q2, _time_range, _variables) do
33 | %{"foo" => 666}
34 | end
35 |
36 | def query(:q3, time_range, _variables) do
37 | val =
38 | if DateTime.compare(time_range.to, ~U[2022-09-24T20:59:59Z]) == :eq do
39 | 666
40 | else
41 | Decimal.new(0)
42 | end
43 |
44 | %{foo: val}
45 | end
46 |
47 | def query(:q4, _time_range, _variables) do
48 | %{"foo" => 666}
49 | end
50 |
51 | def query(:q5, _time_range, _variables) do
52 | %{"foo" => 66, "bar" => 88}
53 | end
54 |
55 | def query(:q6, _time_range, _variables) do
56 | %{"str" => "Just show this"}
57 | end
58 |
59 | def query(:q7, _time_range, _variables) do
60 | [
61 | %{"label" => "row1", "foo" => 3, "bar" => 88},
62 | %{"label" => "row2", "foo" => 4, "bar" => 99}
63 | ]
64 | end
65 |
66 | def query(:q8, _time_range, _variables) do
67 | 11
68 | end
69 |
70 | def query(:q9, _time_range, _variables) do
71 | []
72 | end
73 |
74 | def query(:q10, _time_range, _variables) do
75 | [
76 | {"foo", "452,64"},
77 | {"bar", "260.238,4"}
78 | ]
79 | end
80 |
81 | def query(:q11, _time_range, _variables) do
82 | [{"foo", nil}]
83 | end
84 | end
85 |
86 | def set_dashboard(view, dashboard), do: send(view.pid, {self(), {:dashboard, dashboard}})
87 |
88 | describe "panels" do
89 | test "sends the correct data to the chart panel", %{conn: conn} do
90 | dashboard = [
91 | title: "Test",
92 | panels: [
93 | Panel.define!(
94 | type: Panel.Chart,
95 | id: :p1,
96 | title: "Panel 1",
97 | queries: [Query.define(:q1, Queries)],
98 | ylabel: "Foo (μCKR)",
99 | data_attributes: %{
100 | "foo" => [type: :line, unit: "μCKR", fill: true],
101 | "bar" => [type: :bar, unit: "μCKR"]
102 | }
103 | )
104 | ]
105 | ]
106 |
107 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
108 |
109 | set_dashboard(view, dashboard)
110 |
111 | assert view |> element("#panel-p1-title") |> render() =~ "Panel 1"
112 |
113 | schema = Attributes.Schema.data() ++ Panel.Chart.data_attributes()
114 |
115 | expected_data = %{
116 | datasets: [
117 | %{
118 | attrs:
119 | Attributes.parse!(
120 | [
121 | fill: true,
122 | type: :line,
123 | unit: "μCKR"
124 | ],
125 | schema
126 | ),
127 | label: "foo",
128 | rows: [
129 | %{x: 1_660_903_200_000, y: Decimal.new(10)},
130 | %{x: 1_660_906_800_000, y: Decimal.new(11)}
131 | ],
132 | stats: %{
133 | avg: Decimal.new(11),
134 | label: "foo",
135 | max: Decimal.new(11),
136 | min: Decimal.new(10),
137 | n: 2,
138 | sum: Decimal.new(21)
139 | }
140 | },
141 | %{
142 | attrs:
143 | Attributes.parse!(
144 | [
145 | type: :bar,
146 | unit: "μCKR"
147 | ],
148 | schema
149 | ),
150 | label: "bar",
151 | rows: [
152 | %{x: 1_660_903_200_000, y: Decimal.new(100)},
153 | %{x: 1_660_906_800_000, y: Decimal.new(101)}
154 | ],
155 | stats: %{
156 | avg: Decimal.new(101),
157 | label: "bar",
158 | max: Decimal.new(101),
159 | min: Decimal.new(100),
160 | n: 2,
161 | sum: Decimal.new(201)
162 | }
163 | }
164 | ],
165 | stacked_x: false,
166 | stacked_y: false,
167 | time_zone: "Europe/Athens",
168 | xlabel: "",
169 | ylabel: "Foo (μCKR)",
170 | y_min_value: nil,
171 | y_max_value: nil
172 | }
173 |
174 | assert_push_event(view, "panel-p1::refresh-data", ^expected_data)
175 | end
176 |
177 | test "renders the correct data in the stat panels", %{conn: conn} do
178 | dashboard = [
179 | title: "Test",
180 | panels: [
181 | Panel.define!(
182 | type: Panel.Stat,
183 | id: :p2,
184 | title: "Panel 2",
185 | queries: [Query.define(:q2, Queries)],
186 | data_attributes: %{
187 | "foo" => [unit: "$", title: "Bar ($)"]
188 | }
189 | ),
190 | Panel.define!(
191 | type: Panel.Stat,
192 | id: :p4,
193 | title: "Panel 4",
194 | queries: [Query.define(:q4, Queries)],
195 | data_attributes: %{"foo" => [unit: "$"]}
196 | ),
197 | Panel.define!(
198 | type: Panel.Stat,
199 | id: :p5,
200 | title: "Panel 5",
201 | queries: [Query.define(:q5, Queries)],
202 | data_attributes: %{
203 | "foo" => [unit: "$"],
204 | "bar" => [unit: "€"]
205 | }
206 | ),
207 | Panel.define!(
208 | type: Panel.Stat,
209 | id: :p6,
210 | title: "Panel 6",
211 | queries: [Query.define(:q6, Queries)]
212 | ),
213 | Panel.define!(
214 | type: Panel.Stat,
215 | id: :p8,
216 | title: "Panel 8 (stat with simple value)",
217 | queries: [Query.define(:q8, Queries)]
218 | ),
219 | Panel.define!(
220 | type: Panel.Stat,
221 | id: :p9,
222 | title: "Panel 9 (empty stat)",
223 | queries: [Query.define(:q9, Queries)]
224 | ),
225 | Panel.define!(
226 | type: Panel.Stat,
227 | id: :p10,
228 | title: "Panel 10 (stats as list of 2-tuples)",
229 | queries: [Query.define(:q10, Queries)],
230 | data_attributes: %{
231 | "foo" => [unit: "$"],
232 | "bar" => [unit: "€"]
233 | }
234 | ),
235 | Panel.define!(
236 | type: Panel.Stat,
237 | id: :p11,
238 | title: "Panel 11 (nil stat)",
239 | queries: [Query.define(:q11, Queries)]
240 | )
241 | ]
242 | ]
243 |
244 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
245 | set_dashboard(view, dashboard)
246 |
247 | view
248 | |> render()
249 | |> assert_html("#panel-p2-stat-0-value", text: "666")
250 | |> assert_eventually()
251 |
252 | html = render(view)
253 |
254 | assert_html(html, "#panel-p2-title", text: "Panel 2")
255 | assert_html(html, "#panel-p2-stat-0-unit", text: "$")
256 | assert_html(html, "#panel-p2-stat-0-column-title", text: "Bar ($)")
257 |
258 | assert_html(html, "#panel-p4-title", text: "Panel 4")
259 | assert_html(html, "#panel-p4-stat-0-value", text: "666")
260 | assert_html(html, "#panel-p4-stat-0-unit", text: "$")
261 |
262 | assert_html(html, "#panel-p5-title", text: "Panel 5")
263 | assert_html(html, "#panel-p5-stat-0-value", text: "88")
264 | assert_html(html, "#panel-p5-stat-0-unit", text: "€")
265 | assert_html(html, "#panel-p5-stat-1-value", text: "66")
266 | assert_html(html, "#panel-p5-stat-1-unit", text: "$")
267 |
268 | assert_html(html, "#panel-p6-title", text: "Panel 6")
269 | assert_html(html, "#panel-p6-stat-0-value", text: "Just show this")
270 |
271 | assert_html(html, "#panel-p8-title", text: "Panel 8 (stat with simple value)")
272 | assert_html(html, "#panel-p8-stat-0-value", text: "11")
273 |
274 | assert_html(html, "#panel-p9-title", text: "Panel 9 (empty stat)")
275 | assert_html(html, "#panel-p9-stat-values", text: "-")
276 |
277 | assert_html(html, "#panel-p10-title", text: "Panel 10 (stats as list of 2-tuples)")
278 | assert_html(html, "#panel-p10-stat-0-value", text: "452,64")
279 | assert_html(html, "#panel-p10-stat-0-unit", text: "$")
280 | assert_html(html, "#panel-p10-stat-1-value", text: "260.238,4")
281 | assert_html(html, "#panel-p10-stat-1-unit", text: "€")
282 |
283 | assert_html(html, "#panel-p11-title", text: "Panel 11 (nil stat)")
284 | assert_html(html, "#panel-p11-stat-0-value", text: "-")
285 | end
286 |
287 | test "does not push the event in the case of the stat panel", %{conn: conn} do
288 | dashboard = [
289 | title: "Test",
290 | panels: [
291 | Panel.define!(
292 | type: Panel.Stat,
293 | id: :p2,
294 | title: "Panel 2",
295 | queries: [Query.define(:q2, Queries)],
296 | data_attributes: %{
297 | "foo" => [unit: "$", title: "Bar ($)"]
298 | }
299 | )
300 | ]
301 | ]
302 |
303 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
304 | set_dashboard(view, dashboard)
305 |
306 | # let's assert that we will not receive the message in the case of the Stat panel
307 | # not the most solid of tests but with a timeout of 500ms it should do the job
308 |
309 | assert %{proxy: {ref, _topic, _}} = view
310 |
311 | refute_receive {^ref,
312 | {:push_event, "panel-p2::refresh-data",
313 | %{stats: [%{title: "Bar ($)", unit: "$", value: 666}]}}},
314 | 200
315 | end
316 |
317 | test "sends the correct data to the table panel", %{conn: conn} do
318 | dashboard = [
319 | title: "Test",
320 | panels: [
321 | Panel.define!(
322 | type: Panel.Table,
323 | id: :p7,
324 | title: "Panel 7 (table)",
325 | queries: [Query.define(:q7, Queries)],
326 | data_attributes: %{
327 | "label" => [title: "Label", order: 0, halign: :center],
328 | "foo" => [title: "Foo", order: 1, halign: :right, table_totals: :sum],
329 | "bar" => [
330 | title: "Bar",
331 | order: 2,
332 | halign: :right,
333 | table_totals: :avg,
334 | number_formatting: [
335 | thousand_separator: ".",
336 | decimal_separator: ",",
337 | precision: 2
338 | ]
339 | ]
340 | }
341 | )
342 | ]
343 | ]
344 |
345 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
346 | set_dashboard(view, dashboard)
347 |
348 | assert view |> element("#panel-p7-title") |> render() =~ "Panel 7 (table)"
349 |
350 | expected_data = %{
351 | columns: [
352 | %{
353 | field: "label",
354 | headerHozAlign: :center,
355 | hozAlign: :center,
356 | title: "Label",
357 | formatter: "textarea"
358 | },
359 | %{
360 | field: "foo",
361 | headerHozAlign: :right,
362 | hozAlign: :right,
363 | title: "Foo",
364 | bottomCalc: :sum,
365 | formatter: "textarea"
366 | },
367 | %{
368 | field: "bar",
369 | headerHozAlign: :right,
370 | hozAlign: :right,
371 | title: "Bar",
372 | formatter: "money",
373 | formatterParams: %{decimal: ",", thousand: ".", precision: 2},
374 | bottomCalc: :avg,
375 | bottomCalcFormatter: "money",
376 | bottomCalcFormatterParams: %{decimal: ",", thousand: ".", precision: 2}
377 | }
378 | ],
379 | rows: [
380 | %{"bar" => 88, "foo" => 3, "label" => "row1"},
381 | %{"bar" => 99, "foo" => 4, "label" => "row2"}
382 | ],
383 | attributes: %{page_size: 10}
384 | }
385 |
386 | assert_push_event(view, "panel-p7::refresh-data", ^expected_data)
387 | end
388 |
389 | test "sends the loading/loaded event to all panels", %{conn: conn} do
390 | dashboard = [
391 | title: "Test",
392 | panels: [
393 | Panel.define!(
394 | type: Panel.Chart,
395 | id: :p1,
396 | title: "Panel 1",
397 | queries: [Query.define(:q1, Queries)],
398 | ylabel: "Foo (μCKR)",
399 | data_attributes: %{
400 | "foo" => [type: :line, unit: "μCKR", fill: true],
401 | "bar" => [type: :bar, unit: "μCKR"]
402 | }
403 | ),
404 | Panel.define!(
405 | type: Panel.Stat,
406 | id: :p2,
407 | title: "Panel 2",
408 | queries: [Query.define(:q2, Queries)],
409 | data_attributes: %{
410 | "foo" => [unit: "$", title: "Bar ($)"]
411 | }
412 | )
413 | ]
414 | ]
415 |
416 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
417 | set_dashboard(view, dashboard)
418 |
419 | assert_push_event(view, "panel:load:start", %{id: :p1})
420 | assert_push_event(view, "panel:load:start", %{id: :p2})
421 |
422 | assert_push_event(view, "panel:load:end", %{id: :p1})
423 | assert_push_event(view, "panel:load:end", %{id: :p2})
424 | end
425 | end
426 |
427 | describe "time range" do
428 | test "when the selected time range changes", %{conn: conn} do
429 | dashboard = [
430 | title: "Test",
431 | panels: [
432 | Panel.define!(
433 | type: Panel.Stat,
434 | id: :p3,
435 | title: "Panel 3",
436 | queries: [Query.define(:q3, Queries)],
437 | data_attributes: %{
438 | foo: [unit: "$", title: "Bar ($)"]
439 | }
440 | )
441 | ]
442 | ]
443 |
444 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
445 | set_dashboard(view, dashboard)
446 |
447 | assert has_element?(view, "#panel-p3-title", "Panel 3")
448 | assert has_element?(view, "#panel-p3-stat-values", "0")
449 | assert has_element?(view, "#panel-p3-stat-values", "$")
450 | assert has_element?(view, "#panel-p3-stat-values", "Bar ($)")
451 |
452 | from = DateTime.new!(~D[2022-09-19], ~T[00:00:00], "Europe/Athens")
453 | to = DateTime.new!(~D[2022-09-24], ~T[23:59:59], "Europe/Athens")
454 |
455 | # select a different time range
456 | view
457 | |> element("#time-range-selector")
458 | |> render_hook("lmn_time_range_change", %{
459 | "from" => DateTime.to_iso8601(from),
460 | "to" => DateTime.to_iso8601(to)
461 | })
462 |
463 | refute has_element?(view, "#panel-p3-stat-values", "0")
464 | assert has_element?(view, "#panel-p3-stat-values", "666")
465 | end
466 |
467 | test "when a time range preset is selected", %{conn: conn} do
468 | dashboard = [
469 | title: "Test",
470 | variables: [
471 | Variable.define!(id: :var1, label: "Var 1", module: Variables)
472 | ]
473 | ]
474 |
475 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
476 |
477 | set_dashboard(view, dashboard)
478 |
479 | view
480 | |> element("#time-range-preset-Yesterday")
481 | |> render_click()
482 |
483 | # we use "Europe/Athens" because this is the time zone defined in TestDashboardLive module
484 | yesterday = DateTime.now!("Europe/Athens") |> DateTime.to_date() |> Date.add(-1)
485 |
486 | from =
487 | yesterday
488 | |> DateTime.new!(~T[00:00:00], "Europe/Athens")
489 | |> DateTime.to_unix()
490 |
491 | to = DateTime.new!(yesterday, ~T[23:59:59], "Europe/Athens") |> DateTime.to_unix()
492 |
493 | assert_patched(
494 | view,
495 | Routes.dashboard_path(conn, :index,
496 | var1: "a",
497 | from: from,
498 | to: to
499 | )
500 | )
501 | end
502 |
503 | test "when the default time range preset is selected", %{conn: conn} do
504 | dashboard = [
505 | title: "Test"
506 | ]
507 |
508 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
509 |
510 | set_dashboard(view, dashboard)
511 |
512 | view
513 | |> element("#time-range-preset-Default")
514 | |> render_click()
515 |
516 | default = TimeRange.default(TimeRange.default_time_zone())
517 |
518 | assert_patched(
519 | view,
520 | Routes.dashboard_path(conn, :index,
521 | from: DateTime.to_unix(default.from),
522 | to: DateTime.to_unix(default.to)
523 | )
524 | )
525 | end
526 | end
527 |
528 | describe "variables" do
529 | test "displays all current variable values", %{conn: conn} do
530 | dashboard = [
531 | title: "Test",
532 | variables: [
533 | Variable.define!(id: :var1, label: "Var 1", module: Variables),
534 | Variable.define!(id: :var2, label: "Var 2", module: Variables)
535 | ]
536 | ]
537 |
538 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
539 |
540 | set_dashboard(view, dashboard)
541 |
542 | assert has_element?(view, "#var1-dropdown li", "a")
543 | assert has_element?(view, "#var2-dropdown li", "1")
544 | end
545 |
546 | test "when a variable value is selected", %{conn: conn} do
547 | dashboard = [
548 | title: "Test",
549 | variables: [
550 | Variable.define!(id: :var1, label: "Var 1", module: Variables),
551 | Variable.define!(id: :var2, label: "Var 2", module: Variables)
552 | ]
553 | ]
554 |
555 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
556 |
557 | set_dashboard(view, dashboard)
558 |
559 | view |> element("#var1-b") |> render_click()
560 |
561 | # we use "Europe/Athens" because this is the time zone defined in TestDashboardLive module
562 | default = Luminous.TimeRange.default("Europe/Athens")
563 |
564 | assert_patched(
565 | view,
566 | Routes.dashboard_path(conn, :index,
567 | var1: "b",
568 | var2: 1,
569 | from: DateTime.to_unix(default.from),
570 | to: DateTime.to_unix(default.to)
571 | )
572 | )
573 |
574 | view |> element("#var2-3") |> render_click()
575 |
576 | assert_patched(
577 | view,
578 | Routes.dashboard_path(conn, :index,
579 | var1: "b",
580 | var2: 3,
581 | from: DateTime.to_unix(default.from),
582 | to: DateTime.to_unix(default.to)
583 | )
584 | )
585 | end
586 |
587 | test "should not display hidden variables", %{conn: conn} do
588 | dashboard = [
589 | title: "Test",
590 | variables: [
591 | Variable.define!(id: :var1, label: "Var 1", module: Variables),
592 | Variable.define!(id: :var2, label: "Var 2", module: Variables, hidden: true),
593 | Variable.define!(id: :multi_var, label: "Multi", module: Variables)
594 | ]
595 | ]
596 |
597 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
598 |
599 | set_dashboard(view, dashboard)
600 |
601 | assert has_element?(view, "#var1-dropdown")
602 | refute has_element?(view, "#var2-dropdown")
603 | assert has_element?(view, "#multi_var-dropdown")
604 | end
605 |
606 | test "should handle current variable value that is nil", %{conn: conn} do
607 | dashboard = [
608 | title: "Test",
609 | variables: [
610 | Variable.define!(id: :var1, label: "Var 1", module: Variables),
611 | Variable.define!(id: :empty, label: "Empty", module: Variables)
612 | ]
613 | ]
614 |
615 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
616 |
617 | set_dashboard(view, dashboard)
618 |
619 | assert view |> element("#empty-dropdown-label") |> render() =~ ">Empty: <"
620 | end
621 | end
622 |
623 | describe "multi-select variables" do
624 | setup do
625 | # we use "Europe/Athens" because this is the time zone defined in TestDashboardLive module
626 | default = Luminous.TimeRange.default("Europe/Athens")
627 |
628 | %{from: DateTime.to_unix(default.from), to: DateTime.to_unix(default.to)}
629 | end
630 |
631 | test "when a single value is selected", %{conn: conn, from: from, to: to} do
632 | dashboard = [
633 | title: "Test",
634 | variables: [
635 | Variable.define!(id: :multi_var, type: :multi, label: "Multi", module: Variables)
636 | ]
637 | ]
638 |
639 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
640 |
641 | set_dashboard(view, dashboard)
642 |
643 | assert has_element?(view, "#multi_var-dropdown", "Multi: All")
644 |
645 | view
646 | |> element("#multi_var-dropdown")
647 | |> render_hook("lmn_variable_updated", %{variable: "multi_var", value: ["north"]})
648 |
649 | assert_patched(
650 | view,
651 | Routes.dashboard_path(conn, :index,
652 | multi_var: ["north"],
653 | from: from,
654 | to: to
655 | )
656 | )
657 |
658 | assert has_element?(view, "#multi_var-dropdown", "Multi: north")
659 | end
660 |
661 | test "when two values are selected", %{conn: conn, from: from, to: to} do
662 | dashboard = [
663 | title: "Test",
664 | variables: [
665 | Variable.define!(id: :multi_var, type: :multi, label: "Multi", module: Variables)
666 | ]
667 | ]
668 |
669 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
670 |
671 | set_dashboard(view, dashboard)
672 |
673 | assert has_element?(view, "#multi_var-dropdown", "Multi: All")
674 |
675 | view
676 | |> element("#multi_var-dropdown")
677 | |> render_hook("lmn_variable_updated", %{variable: "multi_var", value: ["north", "south"]})
678 |
679 | assert_patched(
680 | view,
681 | Routes.dashboard_path(conn, :index,
682 | multi_var: ["north", "south"],
683 | from: from,
684 | to: to
685 | )
686 | )
687 |
688 | assert has_element?(view, "#multi_var-dropdown", "Multi: 2 selected")
689 | end
690 |
691 | test "when no value is selected", %{conn: conn, from: from, to: to} do
692 | dashboard = [
693 | title: "Test",
694 | variables: [
695 | Variable.define!(id: :multi_var, type: :multi, label: "Multi", module: Variables)
696 | ]
697 | ]
698 |
699 | {:ok, view, _} = live(conn, Routes.dashboard_path(conn, :index))
700 |
701 | set_dashboard(view, dashboard)
702 |
703 | assert has_element?(view, "#multi_var-dropdown", "Multi: All")
704 |
705 | view
706 | |> element("#multi_var-dropdown")
707 | |> render_hook("lmn_variable_updated", %{variable: "multi_var", value: []})
708 |
709 | assert_patched(
710 | view,
711 | Routes.dashboard_path(conn, :index, multi_var: "none", from: from, to: to)
712 | )
713 |
714 | assert has_element?(view, "#multi_var-dropdown", "Multi: None")
715 | end
716 | end
717 | end
718 |
--------------------------------------------------------------------------------