janela 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +33 -2
- data/UPGRADING.md +48 -0
- data/app/assets/javascripts/janela/frame_controller.js +15 -0
- data/app/assets/stylesheets/janela.css +16 -8
- data/app/controllers/janela/application_controller.rb +7 -0
- data/app/controllers/janela/panes_controller.rb +22 -7
- data/app/controllers/janela/queries_controller.rb +2 -1
- data/app/helpers/janela/frames_helper.rb +26 -7
- data/app/models/janela/frame.rb +20 -0
- data/app/models/janela/pane.rb +67 -4
- data/app/models/janela/query.rb +7 -2
- data/app/views/janela/frames/_content.html.erb +26 -0
- data/app/views/janela/frames/_frame.html.erb +9 -1
- data/app/views/janela/frames/_pane.html.erb +2 -2
- data/app/views/janela/panes/_content_form.html.erb +33 -0
- data/app/views/janela/panes/_row.html.erb +1 -1
- data/app/views/janela/panes/edit.html.erb +5 -1
- data/app/views/janela/panes/new.html.erb +6 -1
- data/app/views/janela/queries/_query.html.erb +15 -11
- data/config/locales/en.yml +5 -0
- data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
- data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
- data/docs/composing.md +269 -0
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
- data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
- data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
- data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
- data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
- data/docs/decisions/INDEX.md +18 -12
- data/docs/multi-tenancy.md +22 -0
- data/docs/naming.md +7 -0
- data/docs/roadmap.md +129 -8
- data/docs/theming.md +17 -15
- data/lib/janela/definition.rb +8 -0
- data/lib/janela/version.rb +1 -1
- metadata +25 -17
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<li class="janela-list-row">
|
|
2
2
|
<span class="janela-list-name"><%= pane.label %></span>
|
|
3
|
-
<span class="janela-card-meta"><%= t("janela.panes.summary", renderer: pane.renderer, span: pane.span) %></span>
|
|
3
|
+
<span class="janela-card-meta"><%= pane.query? ? t("janela.panes.summary", renderer: pane.renderer, span: pane.span) : t("janela.panes.content_summary", kind: pane.partial? ? pane.partial.humanize : t("janela.panes.text"), span: pane.span) %></span>
|
|
4
4
|
<span class="janela-actions">
|
|
5
5
|
<%= button_to t("janela.actions.move_up"), move_up_frame_pane_path(frame, pane), method: :patch,
|
|
6
6
|
class: "janela-button", disabled: pane == frame.panes.first %>
|
|
@@ -2,4 +2,8 @@
|
|
|
2
2
|
<h1 class="janela-heading"><%= @pane.label %></h1>
|
|
3
3
|
<p class="janela-crumb"><%= link_to @frame.name, edit_frame_path(@frame) %></p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<% if @pane.query? %>
|
|
6
|
+
<%= render "form", frame: @frame, pane: @pane, definition: @definition %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<%= render "content_form", frame: @frame, pane: @pane %>
|
|
9
|
+
<% end %>
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
<% if @definition %>
|
|
6
6
|
<%= render "form", frame: @frame, pane: @pane, definition: @definition %>
|
|
7
|
+
<% elsif !@pane.query? %>
|
|
8
|
+
<%= render "content_form", frame: @frame, pane: @pane %>
|
|
7
9
|
<% else %>
|
|
8
10
|
<%# Step one of two. The engine's pages run no JavaScript, so one select
|
|
9
11
|
cannot refill another: picking the model first is what lets step two
|
|
@@ -11,7 +13,10 @@
|
|
|
11
13
|
<%= form_with url: new_frame_pane_path(@frame), method: :get, class: "janela-form" do |form| %>
|
|
12
14
|
<div class="janela-field">
|
|
13
15
|
<%= form.label :model, Janela::Pane.human_attribute_name(:model) %>
|
|
14
|
-
<%= form.select :model,
|
|
16
|
+
<%= form.select :model, grouped_options_for_select(
|
|
17
|
+
t("janela.panes.numbers") => Janela.definitions.map { |definition| [ definition.model.model_name.human, definition.model.model_name.route_key ] },
|
|
18
|
+
t("janela.panes.words") => [ [ t("janela.panes.text"), "text" ] ] +
|
|
19
|
+
Janela::Pane.content_partials.map { |name| [ name.humanize, "partial:#{name}" ] }) %>
|
|
15
20
|
</div>
|
|
16
21
|
|
|
17
22
|
<div class="janela-actions">
|
|
@@ -8,17 +8,21 @@
|
|
|
8
8
|
<% elsif result.empty? %>
|
|
9
9
|
<p class="janela-pane janela-empty"><%= query.title %>: no data</p>
|
|
10
10
|
<% elsif query.chart? %>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
12
|
+
<figure class="janela-pane">
|
|
13
|
+
<figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
|
|
14
|
+
<canvas class="janela-chart"
|
|
15
|
+
data-controller="janela--chart"
|
|
16
|
+
data-action="janela--chart:toggle->janela--frame#toggle"
|
|
17
|
+
data-janela--chart-type-value="<%= query.renderer %>"
|
|
18
|
+
data-janela--chart-title-value="<%= query.title %>"
|
|
19
|
+
data-janela--chart-selected-value="<%= query.selected_values.to_json %>"
|
|
20
|
+
data-janela--chart-labels-value="<%= result.keys.to_json %>"
|
|
21
|
+
data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
|
|
22
|
+
data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
|
|
23
|
+
data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
|
|
24
|
+
role="img" aria-labelledby="<%= title_id %>"></canvas>
|
|
25
|
+
</figure>
|
|
22
26
|
<% else %>
|
|
23
27
|
<table class="janela-pane">
|
|
24
28
|
<caption><%= query.title %></caption>
|
data/config/locales/en.yml
CHANGED
|
@@ -58,6 +58,11 @@ en:
|
|
|
58
58
|
no_dimension: "No breakdown, one number"
|
|
59
59
|
no_limit: Every row
|
|
60
60
|
summary: "%{renderer}, %{span} wide"
|
|
61
|
+
content_summary: "%{kind}, %{span} wide"
|
|
62
|
+
numbers: Numbers
|
|
63
|
+
words: Words
|
|
64
|
+
text: Text
|
|
65
|
+
link_hint: "A path on this site, such as /orders. Makes the heading a link."
|
|
61
66
|
title_placeholder: Janela writes one if you leave this blank
|
|
62
67
|
renderers:
|
|
63
68
|
bar: Bar chart
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class AddContentToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# A pane can hold words instead of a query (ADR 039). Every existing row
|
|
4
|
+
# is a query, which is the default.
|
|
5
|
+
add_column :janela_panes, :kind, :string, null: false, default: "query"
|
|
6
|
+
add_column :janela_panes, :heading, :string
|
|
7
|
+
add_column :janela_panes, :body, :text
|
|
8
|
+
add_column :janela_panes, :link, :string
|
|
9
|
+
add_column :janela_panes, :partial, :string
|
|
10
|
+
|
|
11
|
+
# A content pane has no query, so these are required by kind in the
|
|
12
|
+
# model rather than by the column.
|
|
13
|
+
change_column_null :janela_panes, :model, true
|
|
14
|
+
change_column_null :janela_panes, :measure, true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class AddKeyToJanelaFrames < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# The host's own name for a frame, so it finds one of an owner's several
|
|
4
|
+
# from code (ADR 041). Janela reads it only in Frame.for.
|
|
5
|
+
add_column :janela_frames, :key, :string
|
|
6
|
+
# One frame per owner and key. Frames without a key, every one an analyst
|
|
7
|
+
# makes, are not constrained: a null is never equal to another.
|
|
8
|
+
add_index :janela_frames, [ :owner_type, :owner_id, :key ], unique: true
|
|
9
|
+
end
|
|
10
|
+
end
|
data/docs/composing.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
---
|
|
2
|
+
Topics: composing, html, layout, host-integration, styling
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Composing a Page Around Panes
|
|
6
|
+
|
|
7
|
+
A pane is a number, a table or a chart. Everything a dashboard has that
|
|
8
|
+
is not a query result, the heading, the icon beside it, a sentence of
|
|
9
|
+
explanation, a link to the full list, a progress bar, is ordinary HTML
|
|
10
|
+
that your application writes. This page is the reference for writing it
|
|
11
|
+
so that it sits properly beside Janela's own markup.
|
|
12
|
+
|
|
13
|
+
The short version: compose with the block form of `janela_frame`, put
|
|
14
|
+
your markup and the panes inside a `janela-frame` grid, and style both
|
|
15
|
+
with your own classes plus the hooks in [Theming Janela](theming).
|
|
16
|
+
|
|
17
|
+
## Where your markup can go
|
|
18
|
+
|
|
19
|
+
`janela_frame` has two forms, and only one of them takes your HTML.
|
|
20
|
+
|
|
21
|
+
| Form | Your own HTML | Use it when |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `janela_frame do ... end` | anywhere inside the block | the page needs a heading, text, links or a layout of its own |
|
|
24
|
+
| `janela_frame @frame` | none | the dashboard is data an analyst edits without a deploy |
|
|
25
|
+
|
|
26
|
+
A stored frame renders panes and nothing else. That is deliberate (ADR
|
|
27
|
+
012): a row names a measure the model declared, so an analyst arranges
|
|
28
|
+
what is shown and cannot put arbitrary content on the page. If a
|
|
29
|
+
dashboard needs a heading and an explanation, write them in the page
|
|
30
|
+
around the frame, or use the block form. Whether a stored frame should
|
|
31
|
+
hold content of its own, such as text or an image, has not been decided.
|
|
32
|
+
|
|
33
|
+
```erb
|
|
34
|
+
<h2>Orders</h2>
|
|
35
|
+
<p>Everything placed this financial year.</p>
|
|
36
|
+
<%= janela_frame @frame %>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Inside the block form, anything goes. The block's own `<div>` carries
|
|
40
|
+
the cross-filtering controller, so a button or a link inside it can
|
|
41
|
+
clear or re-point the panes, and markup that has nothing to do with
|
|
42
|
+
filtering is simply ignored by it.
|
|
43
|
+
|
|
44
|
+
## The grid
|
|
45
|
+
|
|
46
|
+
The block form's wrapper is not a grid. Write the grid yourself with the
|
|
47
|
+
same classes a stored frame gets from its integers:
|
|
48
|
+
|
|
49
|
+
```erb
|
|
50
|
+
<%= janela_frame do %>
|
|
51
|
+
<div class="janela-frame janela-cols-3 janela-gap-4">
|
|
52
|
+
<%= janela_pane Order, :revenue %>
|
|
53
|
+
<%= janela_pane Order, :orders %>
|
|
54
|
+
<%= janela_pane Order, :average_order %>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Every direct child is a grid item, your own elements included, so a
|
|
60
|
+
heading can take a whole row and a text box can sit between two panes.
|
|
61
|
+
`janela_pane` takes no class of its own yet
|
|
62
|
+
([#29](https://github.com/retail-tasker/janela/issues/29)), so wrap a
|
|
63
|
+
pane to span it:
|
|
64
|
+
|
|
65
|
+
```erb
|
|
66
|
+
<div class="janela-span-2"><%= janela_pane Order, :revenue, by: :status, as: :bar %></div>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Below `40rem` the grid is one column whatever you asked for.
|
|
70
|
+
|
|
71
|
+
Under the vitral theme every direct child of `janela-frame` is drawn as a
|
|
72
|
+
pane of glass, your heading and your notes included. That is usually
|
|
73
|
+
what you want, since the page reads as one window. When it is not, put
|
|
74
|
+
the element above the grid rather than in it.
|
|
75
|
+
|
|
76
|
+
## A frame for one record
|
|
77
|
+
|
|
78
|
+
One frame is often wanted on every record's page, each copy narrowed to
|
|
79
|
+
its own record. Pass the condition as `where:` and every pane is
|
|
80
|
+
filtered by it before anything the reader selects:
|
|
81
|
+
|
|
82
|
+
```erb
|
|
83
|
+
<%= janela_frame @frame, where: { queue_id_eq: @queue.id } %>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The block form takes the same argument. Do not carry the record in the
|
|
87
|
+
page URL's `q[...]` instead: that is the reader's selection, and Clear
|
|
88
|
+
filters or Escape takes it off. `where:` is a view filter, not a
|
|
89
|
+
permission, so a record a reader must not see is kept out of reach by
|
|
90
|
+
your `policy_scope` (ADR 040). Do not also offer the fixed dimension as a
|
|
91
|
+
pane the reader can click: any value but the fixed one returns nothing.
|
|
92
|
+
|
|
93
|
+
A figure you compute yourself for the same page, such as the progress
|
|
94
|
+
bar below, takes the same condition in its own `where:`.
|
|
95
|
+
|
|
96
|
+
## Components
|
|
97
|
+
|
|
98
|
+
Each of these is plain HTML. The class names that start `janela-` are
|
|
99
|
+
the contract and will not change without an entry in `UPGRADING.md`.
|
|
100
|
+
The ones that do not are yours to name; the examples use a `card-`
|
|
101
|
+
prefix only so they read clearly.
|
|
102
|
+
|
|
103
|
+
### A heading with an icon
|
|
104
|
+
|
|
105
|
+
A heading belongs to your page, not to a pane. Make it a full-width grid
|
|
106
|
+
item so it sits above the panes it introduces.
|
|
107
|
+
|
|
108
|
+
```erb
|
|
109
|
+
<%= janela_frame do %>
|
|
110
|
+
<div class="janela-frame janela-cols-3 janela-gap-4">
|
|
111
|
+
<header class="janela-span-3 card-heading">
|
|
112
|
+
<svg aria-hidden="true" class="card-icon">...</svg>
|
|
113
|
+
<h2>Orders overview</h2>
|
|
114
|
+
</header>
|
|
115
|
+
<%= janela_pane Order, :revenue %>
|
|
116
|
+
...
|
|
117
|
+
</div>
|
|
118
|
+
<% end %>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Mark the icon `aria-hidden="true"`. The heading's text is its name, and
|
|
122
|
+
an icon read aloud as "image" adds nothing.
|
|
123
|
+
|
|
124
|
+
### A text box
|
|
125
|
+
|
|
126
|
+
Explanation, a caveat, what the numbers exclude. A grid item like any
|
|
127
|
+
other, so it can span the row or sit beside a pane.
|
|
128
|
+
|
|
129
|
+
```erb
|
|
130
|
+
<p class="janela-span-3 card-note">
|
|
131
|
+
Refunds are excluded. Figures are in the store's own currency.
|
|
132
|
+
</p>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Write it in the page rather than in a pane's title. A title is the
|
|
136
|
+
pane's accessible name and should say what it measures, not carry a
|
|
137
|
+
paragraph.
|
|
138
|
+
|
|
139
|
+
### A labelled number
|
|
140
|
+
|
|
141
|
+
A single value pane already renders its own label and number
|
|
142
|
+
(`janela-value-label`, `janela-value-number`). When your card already
|
|
143
|
+
says what the number is, hide the pane's label with
|
|
144
|
+
`janela-own-headings` so it is not on screen twice. It stays in the
|
|
145
|
+
accessibility tree.
|
|
146
|
+
|
|
147
|
+
```erb
|
|
148
|
+
<div class="card janela-own-headings">
|
|
149
|
+
<p class="card-label">Revenue</p>
|
|
150
|
+
<%= janela_pane Order, :revenue %>
|
|
151
|
+
</div>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Several numbers in one card
|
|
155
|
+
|
|
156
|
+
Two single value panes side by side read as one figure. Each is its own
|
|
157
|
+
query, so each cross-filters on its own.
|
|
158
|
+
|
|
159
|
+
```erb
|
|
160
|
+
<div class="card janela-own-headings">
|
|
161
|
+
<p class="card-label">Revenue, from orders</p>
|
|
162
|
+
<div class="card-figure">
|
|
163
|
+
<%= janela_pane Order, :revenue %>
|
|
164
|
+
<span>from</span>
|
|
165
|
+
<%= janela_pane Order, :orders %>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```css
|
|
171
|
+
.card-figure { display: flex; align-items: baseline; gap: .5rem; }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A single value pane is a block of its own, a `<p>` holding its label
|
|
175
|
+
and number, so the figure needs a `<div>` rather than a `<p>` around it,
|
|
176
|
+
and a line of CSS to put the numbers side by side. Without it they
|
|
177
|
+
stack.
|
|
178
|
+
|
|
179
|
+
A done out of total figure cannot be two panes yet. A measure has no
|
|
180
|
+
condition of its own, so there is no pane for the done half. Compute it
|
|
181
|
+
as the progress bar below does. A measure that is itself the ratio is
|
|
182
|
+
proposed in ADR 038
|
|
183
|
+
([#27](https://github.com/retail-tasker/janela/issues/27)).
|
|
184
|
+
|
|
185
|
+
### A progress bar
|
|
186
|
+
|
|
187
|
+
There is no progress renderer. A bar is your own markup, from a value
|
|
188
|
+
your controller computes through the same scope Janela uses.
|
|
189
|
+
`where:` takes the same Ransack conditions a pane's filters do, on the
|
|
190
|
+
dimensions the model declared:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
scope = policy_scope(Order)
|
|
194
|
+
done = Order.janela.query(:orders, where: { status_eq: "paid" }, on: scope)
|
|
195
|
+
total = Order.janela.query(:orders, on: scope)
|
|
196
|
+
@percent = total.zero? ? 0 : (100.0 * done / total).round
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```erb
|
|
200
|
+
<div class="janela-span-3 card-progress">
|
|
201
|
+
<p><span>Paid</span> <span><%= @percent %>%</span></p>
|
|
202
|
+
<progress max="100" value="<%= @percent %>"><%= @percent %>%</progress>
|
|
203
|
+
</div>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Use `<progress>` rather than two nested `<div>`s: it has a role and a
|
|
207
|
+
value a screen reader can announce without any ARIA of your own. It is
|
|
208
|
+
computed once, when the page renders, so it does not move when a click
|
|
209
|
+
cross-filters the panes around it. Put it outside the frame, or say
|
|
210
|
+
that it is the unfiltered figure, so nobody reads it as filtered.
|
|
211
|
+
|
|
212
|
+
### A link or an action
|
|
213
|
+
|
|
214
|
+
A link to the full list, or a button that clears the filters.
|
|
215
|
+
|
|
216
|
+
```erb
|
|
217
|
+
<%= link_to "View all", orders_path, class: "card-link" %>
|
|
218
|
+
<button type="button" data-action="janela--frame#clear">Clear filters</button>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A button only reaches `janela--frame` from inside the block. Outside
|
|
222
|
+
it, it has no frame to clear.
|
|
223
|
+
|
|
224
|
+
## Putting it together
|
|
225
|
+
|
|
226
|
+
An overview card: a heading with an icon and a link, two numbers and a
|
|
227
|
+
note, then a progress bar under it.
|
|
228
|
+
|
|
229
|
+
```erb
|
|
230
|
+
<%= janela_frame do %>
|
|
231
|
+
<div class="janela-frame janela-cols-3 janela-gap-4">
|
|
232
|
+
<header class="janela-span-3 card-heading">
|
|
233
|
+
<svg aria-hidden="true" class="card-icon">...</svg>
|
|
234
|
+
<h2>Orders overview</h2>
|
|
235
|
+
<%= link_to "View all", orders_path, class: "card-link" %>
|
|
236
|
+
</header>
|
|
237
|
+
|
|
238
|
+
<div class="janela-span-2 card janela-own-headings">
|
|
239
|
+
<p class="card-label">Revenue, from orders</p>
|
|
240
|
+
<div class="card-figure">
|
|
241
|
+
<%= janela_pane Order, :revenue %>
|
|
242
|
+
<span>from</span>
|
|
243
|
+
<%= janela_pane Order, :orders %>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<p class="card-note">Refunds are excluded.</p>
|
|
248
|
+
</div>
|
|
249
|
+
<% end %>
|
|
250
|
+
|
|
251
|
+
<div class="card-progress">
|
|
252
|
+
<p><span>Paid, across every order</span> <span><%= @percent %>%</span></p>
|
|
253
|
+
<progress max="100" value="<%= @percent %>"><%= @percent %>%</progress>
|
|
254
|
+
</div>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The progress bar sits below the frame, and its label says it covers
|
|
258
|
+
every order, because it does not move when a click filters the panes
|
|
259
|
+
above it.
|
|
260
|
+
|
|
261
|
+
## What this page is not
|
|
262
|
+
|
|
263
|
+
It is not a component library. Janela ships no `card` class and will
|
|
264
|
+
not: a heading, a paragraph and a link already exist, and your
|
|
265
|
+
application already has a way of drawing them. What Janela promises is
|
|
266
|
+
the grid and the pane hooks, so that your markup and its markup can sit
|
|
267
|
+
in one layout. If a component keeps needing something from a pane that
|
|
268
|
+
the hooks cannot give it, that is worth an issue rather than a
|
|
269
|
+
workaround.
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-24
|
|
3
|
+
Status: Proposed
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 007, ADR 020, ADR 025
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a measure kind, or a sixth aggregate to the DSL
|
|
7
|
+
- deciding how a measure's value is scaled or formatted
|
|
8
|
+
- changing how a grouped pane is ordered
|
|
9
|
+
- a host wanting a rate or a percentage over a boolean column
|
|
10
|
+
- wondering whether a measure option belongs on the measure or on a pane
|
|
11
|
+
Topics: DSL, query layer, measures, formatting, ordering
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 038: A Ratio Is a Measure of Its Own, Stored as a Fraction and Read as a Percentage
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
A boolean column is how an application stores a yes or no fact, and
|
|
19
|
+
averaging one is how a person asks what share of rows are yes. Janela
|
|
20
|
+
refuses that today, correctly, and the refusal leaves nowhere to go
|
|
21
|
+
(#20, #27).
|
|
22
|
+
|
|
23
|
+
Four things were measured against the demo on `c6922da`, Rails 8.1.3.1
|
|
24
|
+
and SQLite, with `orders.expedited` as a real boolean column.
|
|
25
|
+
|
|
26
|
+
**The cast is total, not partial.** `Order.average(:expedited)` returns
|
|
27
|
+
`true` where the true ratio is `0.333`, and returns `true` again where
|
|
28
|
+
the true ratio is `0.0`, because Rails' boolean cast does not read a
|
|
29
|
+
float `0.0` as false. There is no value of the ratio that renders as
|
|
30
|
+
anything other than `true`. #20 described this as a cast losing
|
|
31
|
+
information; it loses all of it.
|
|
32
|
+
|
|
33
|
+
**The existing machinery already carries the fix.**
|
|
34
|
+
`relation.average(Arel.sql("CASE WHEN ... THEN 1.0 ELSE 0.0 END"))`
|
|
35
|
+
answers `0.3333333333333333` as a Float ungrouped, and grouped it
|
|
36
|
+
answers a hash per bucket. Nothing new is needed for grouping,
|
|
37
|
+
gap filling, cross filtering or snapshots, because the ratio goes
|
|
38
|
+
through the same `Measure#apply` every other measure goes through.
|
|
39
|
+
|
|
40
|
+
**Ordering breaks, which #27 did not mention.** `Definition` orders a
|
|
41
|
+
grouped query with `order(Arel.sql("#{measure.sql_alias} DESC"))`, and
|
|
42
|
+
`sql_alias` is `"#{aggregate}_#{column}"`, the alias ADR 007 names.
|
|
43
|
+
There is no such alias for an expression: the string becomes
|
|
44
|
+
`average_CASE WHEN orders.expedited THEN 1.0 ELSE 0.0 END DESC` and
|
|
45
|
+
raises `ActiveRecord::StatementInvalid`. Ordering by the expression
|
|
46
|
+
itself works and sorts correctly.
|
|
47
|
+
|
|
48
|
+
**The null claim in #27 is backwards.** On one true, one false and one
|
|
49
|
+
null row:
|
|
50
|
+
|
|
51
|
+
| expression | result |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `AVG(CASE WHEN f THEN 1.0 ELSE 0.0 END)`, null as false | 0.333 |
|
|
54
|
+
| the same with a `WHEN f IS NULL THEN NULL` arm | 0.5 |
|
|
55
|
+
| `AVG(f)`, plain | 0.5 |
|
|
56
|
+
|
|
57
|
+
#27 says excluding null "differs from `AVG` in SQL". Excluding null is
|
|
58
|
+
exactly what `AVG` does. The naive `CASE` is the form that differs, and
|
|
59
|
+
it counts every unknown as a failure, which is this library's worst
|
|
60
|
+
failure mode: a wrong number that looks like a right one.
|
|
61
|
+
|
|
62
|
+
### Fraction or percentage, and where that choice lives
|
|
63
|
+
|
|
64
|
+
The formatter does not scale. `format(0.6667)` with `suffix: "%"`
|
|
65
|
+
renders `"0.7%"`, wrong by a hundred; `format(66.67)` renders
|
|
66
|
+
`"66.7%"`. ADR 020's own example, `measure :pass_rate, average: :score,
|
|
67
|
+
precision: 1, suffix: "%"`, only reads correctly if the value is
|
|
68
|
+
already on a nought to a hundred scale.
|
|
69
|
+
|
|
70
|
+
Three positions were considered.
|
|
71
|
+
|
|
72
|
+
**Storing nought to a hundred** was rejected. ADR 020 holds that the
|
|
73
|
+
number itself reaches a snapshot, an order clause and a comparison at
|
|
74
|
+
full precision. Storing a presentation scale puts a display choice into
|
|
75
|
+
a frozen snapshot and into an `ORDER BY`, where it is no longer a
|
|
76
|
+
display choice at all. A ratio is a proportion, and a proportion is
|
|
77
|
+
nought to one.
|
|
78
|
+
|
|
79
|
+
**Leaving the scale to each pane** was rejected, and ADR 020 had
|
|
80
|
+
already rejected it: `janela_panes` carries `model`, `measure`,
|
|
81
|
+
`dimension`, `renderer`, `granularity`, `limit` and `position`, and no
|
|
82
|
+
formatting columns, deliberately. Two panes of one measure could
|
|
83
|
+
otherwise freeze different numbers into different snapshots and serve
|
|
84
|
+
both as fact. ADR 020's consequences say per pane formatting, if it
|
|
85
|
+
ever arrives, overrides the measure's format rather than replacing the
|
|
86
|
+
idea, and calls that the reason to think twice.
|
|
87
|
+
|
|
88
|
+
**A fraction with an opt in percentage**, such as `percent: true` or a
|
|
89
|
+
remembered `suffix: "%"`, was considered and is the option this
|
|
90
|
+
decision narrows rather than takes. A proportion shown to a person is a
|
|
91
|
+
percentage; making that a flag means every host that declares a rate
|
|
92
|
+
also remembers a second thing, and a host that forgets gets `0.31` on a
|
|
93
|
+
dashboard where `31.2%` was meant. ADR 001 prefers one obvious way over
|
|
94
|
+
a knob.
|
|
95
|
+
|
|
96
|
+
## Decision
|
|
97
|
+
|
|
98
|
+
**A ratio is a measure kind of its own. It computes a fraction, and it
|
|
99
|
+
renders as a percentage.**
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
janela do
|
|
103
|
+
measure :expedited_rate, ratio: :expedited # a boolean column
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**The kind sits beside the five aggregates rather than inside them.**
|
|
108
|
+
ADR 002 says a measure takes exactly one aggregate of `sum`, `count`,
|
|
109
|
+
`average`, `minimum`, `maximum`. `ratio:` is a sixth option in that
|
|
110
|
+
position and not a sixth aggregate, because it names a column and an
|
|
111
|
+
intent rather than an SQL function. `Measure::AGGREGATES` is unchanged
|
|
112
|
+
and the error a host already sees when it declares two of them is
|
|
113
|
+
unchanged.
|
|
114
|
+
|
|
115
|
+
**It requires a boolean column, which is the mirror of the refusal it
|
|
116
|
+
answers.** `average:` rejects a boolean column and names `ratio:`;
|
|
117
|
+
`ratio:` rejects anything that is not one. Both look the column up
|
|
118
|
+
through `model.type_for_attribute`, so the expression is built from a
|
|
119
|
+
column the model has confirmed, never from a string that arrived over
|
|
120
|
+
HTTP. ADR 025 bounds what a filter may ask for; this is the same
|
|
121
|
+
principle one layer down.
|
|
122
|
+
|
|
123
|
+
**Null is excluded, so a ratio agrees with `AVG`.**
|
|
124
|
+
|
|
125
|
+
```sql
|
|
126
|
+
AVG(CASE WHEN col IS NULL THEN NULL WHEN col THEN 1.0 ELSE 0.0 END)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
An unknown is not a failure. A host that wants unknowns counted as
|
|
130
|
+
failures says so in its own schema with a `NOT NULL` default, which is
|
|
131
|
+
a decision about the data rather than about the dashboard.
|
|
132
|
+
|
|
133
|
+
**A condition form is not included.** `ratio: { status: "paid" }` was
|
|
134
|
+
considered and rejected: it is a second query language growing inside
|
|
135
|
+
the measure, it needs the same bounding ADR 025 gives filters, and the
|
|
136
|
+
case it serves is already served. Where a column is not already a yes
|
|
137
|
+
or no fact, the split is the honest answer and cross filters better,
|
|
138
|
+
which is what #20's refusal says and what ADR 002 says to every other
|
|
139
|
+
variation. The boolean case earns its own kind precisely because the
|
|
140
|
+
split cannot answer it: "the share that passed, over time" is one
|
|
141
|
+
series, and a dimension gives two.
|
|
142
|
+
|
|
143
|
+
**The stored number is the fraction. The rendered string is the
|
|
144
|
+
percentage.** The value that reaches a snapshot, an `ORDER BY`, a chart
|
|
145
|
+
axis and a comparison is `0.3119`. The string a table cell, a single
|
|
146
|
+
value and a chart tooltip show is `31.2%`. This follows ADR 020 rather
|
|
147
|
+
than bending it: that decision forbids transforming the number, and a
|
|
148
|
+
ratio's number is never transformed. Only the string is.
|
|
149
|
+
|
|
150
|
+
Precision defaults to one decimal place for a ratio rather than ADR
|
|
151
|
+
020's fallback of two, because a percentage carries two more significant
|
|
152
|
+
figures than the fraction it came from and `31.19%` is noise. A measure
|
|
153
|
+
may still declare its own.
|
|
154
|
+
|
|
155
|
+
**Declaring `prefix:` or `suffix:` on a ratio raises.** The kind already
|
|
156
|
+
says what unit it is, and a host that writes `suffix: "%"` out of habit
|
|
157
|
+
would otherwise render `31.2%%`. Raising names the conflict rather than
|
|
158
|
+
guessing which was meant, which is what this library does everywhere
|
|
159
|
+
else it is given two answers.
|
|
160
|
+
|
|
161
|
+
**A measure answers what it is ordered by, rather than what its column
|
|
162
|
+
alias is.** `Measure#sql_alias` becomes `Measure#order_by`: for an
|
|
163
|
+
aggregate it returns the alias ActiveRecord already gives, unchanged,
|
|
164
|
+
and for a ratio it returns the `AVG(CASE ...)` expression. ADR 007's
|
|
165
|
+
decision is untouched, since a category pane is still ordered by its
|
|
166
|
+
measure, largest first, with no way to turn it off. Only the mechanism
|
|
167
|
+
it named has to widen, because that ADR assumed every measure is an
|
|
168
|
+
aggregate with an alias and a ratio is not.
|
|
169
|
+
|
|
170
|
+
## Consequences
|
|
171
|
+
|
|
172
|
+
- A host with a boolean column declares one line and gets a rate that
|
|
173
|
+
cross filters, gap fills, snapshots and orders like any other measure.
|
|
174
|
+
- `rails janela:doctor` has nothing to add. The declaration either
|
|
175
|
+
raises at boot or is correct, so there is no silent state for a check
|
|
176
|
+
to find.
|
|
177
|
+
- **A measure that wants the fraction rather than the percentage cannot
|
|
178
|
+
have it.** That is the cost of refusing the flag, and it is a real
|
|
179
|
+
one: a host wanting `0.31` on a dashboard has to use `average:` over
|
|
180
|
+
a numeric column of its own. If that turns up in practice, the answer
|
|
181
|
+
is a format on the measure, not on the pane, and ADR 020 already says
|
|
182
|
+
which layer that is.
|
|
183
|
+
- `Measure#sql_alias` is gone. It is internal rather than documented
|
|
184
|
+
surface, with one call site in the gem, but a fork that reached for it
|
|
185
|
+
will not find it, so it needs a line in `CHANGELOG.md` under Changed
|
|
186
|
+
rather than an `UPGRADING.md` step (ADR 015): nothing a host declares
|
|
187
|
+
changes.
|
|
188
|
+
- #20's error message names `ratio:`, which closes the loop the refusal
|
|
189
|
+
opened. That string is what a host actually meets, so it is part of
|
|
190
|
+
the work rather than a nicety.
|
|
191
|
+
- What would change this decision: a host that needs a rate over
|
|
192
|
+
something that is not a boolean column and for which the split is
|
|
193
|
+
genuinely wrong. That is the case the condition form was rejected for,
|
|
194
|
+
and one real report of it is better evidence than the argument above.
|