janela 0.11.0 → 0.12.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 +23 -0
- data/README.md +26 -6
- data/UPGRADING.md +19 -0
- data/app/assets/javascripts/janela/chart_controller.js +6 -1
- data/app/assets/javascripts/janela/frame_controller.js +19 -9
- data/app/assets/stylesheets/janela.css +23 -1
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +3 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
- data/app/helpers/janela/frames_helper.rb +12 -4
- data/app/models/janela/pane.rb +32 -1
- data/app/models/janela/query.rb +116 -4
- data/app/views/janela/panes/_form.html.erb +25 -0
- data/app/views/janela/queries/_query.html.erb +24 -2
- data/config/locales/en.yml +19 -0
- data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
- data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
- data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
- data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
- data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
- data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
- data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
- data/docs/decisions/052-how-the-project-is-run.md +93 -0
- data/docs/decisions/INDEX.md +24 -18
- data/docs/roadmap.md +12 -27
- data/docs/theming.md +4 -0
- data/lib/janela/definition.rb +71 -4
- data/lib/janela/dimension.rb +7 -0
- data/lib/janela/engine.rb +13 -1
- data/lib/janela/measure.rb +56 -7
- data/lib/janela/version.rb +1 -1
- metadata +17 -10
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<%# One pane's content, without its turbo frame: the same markup whether the
|
|
2
2
|
pane came from a URL, a row, or a frame rendered inline. %>
|
|
3
3
|
<% if query.single_value? %>
|
|
4
|
-
|
|
4
|
+
<%= tag.p class: [ "janela-pane", "janela-value", ("janela-prominence-#{query.prominence}" if query.prominent?) ] do %>
|
|
5
5
|
<span class="janela-value-label"><%= query.title %></span>
|
|
6
6
|
<strong class="janela-value-number"><%= query.format(result || 0) %></strong>
|
|
7
|
-
|
|
7
|
+
<% end %>
|
|
8
8
|
<% elsif result.empty? %>
|
|
9
9
|
<p class="janela-pane janela-empty"><%= query.title %>: no data</p>
|
|
10
10
|
<% elsif query.ring? && query.ringable?(result) %>
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
14
14
|
<figure class="janela-pane">
|
|
15
15
|
<figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
|
|
16
|
+
<%# A height puts the canvas in a box of fixed size for the chart to fill;
|
|
17
|
+
with none there is no box and the chart is what it always was (ADR 047). %>
|
|
18
|
+
<% canvas = capture do %>
|
|
16
19
|
<canvas class="janela-chart"
|
|
17
20
|
data-controller="janela--chart"
|
|
18
21
|
data-action="janela--chart:toggle->janela--frame#toggle"
|
|
@@ -24,11 +27,27 @@
|
|
|
24
27
|
data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
|
|
25
28
|
data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
|
|
26
29
|
<%= tag.attributes(aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) }) %>
|
|
30
|
+
<%= "data-janela--chart-fixed-height-value=true".html_safe if query.boxed? %>
|
|
27
31
|
role="img" aria-labelledby="<%= title_id %>"></canvas>
|
|
32
|
+
<% end %>
|
|
33
|
+
<%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
|
|
28
34
|
</figure>
|
|
29
35
|
<% else %>
|
|
30
36
|
<%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
|
|
31
37
|
<caption><%= query.title %></caption>
|
|
38
|
+
<%# A header row only when there are companions to name: two columns read
|
|
39
|
+
without one, and a table with none is what it always was (ADR 051). %>
|
|
40
|
+
<% if query.companion_columns.any? %>
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<th scope="col"><%= query.dimension_header %></th>
|
|
44
|
+
<th scope="col"><%= query.measure_header %></th>
|
|
45
|
+
<% query.companion_columns.each do |column| %>
|
|
46
|
+
<%= tag.th column.header, scope: "col", class: ("janela-fact" if column.fact) %>
|
|
47
|
+
<% end %>
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<% end %>
|
|
32
51
|
<tbody>
|
|
33
52
|
<% result.each do |label, measured| %>
|
|
34
53
|
<% click = query.click_data(label) if query.clickable? %>
|
|
@@ -42,6 +61,9 @@
|
|
|
42
61
|
<% end %>
|
|
43
62
|
</td>
|
|
44
63
|
<td><%= query.format(measured) %></td>
|
|
64
|
+
<% query.companion_columns.each do |column| %>
|
|
65
|
+
<%= tag.td column.cells[label], class: ("janela-fact" if column.fact) %>
|
|
66
|
+
<% end %>
|
|
45
67
|
</tr>
|
|
46
68
|
<% end %>
|
|
47
69
|
</tbody>
|
data/config/locales/en.yml
CHANGED
|
@@ -20,6 +20,9 @@ en:
|
|
|
20
20
|
granularity: Granularity
|
|
21
21
|
limit: Rows
|
|
22
22
|
span: Width
|
|
23
|
+
height: Height
|
|
24
|
+
prominence: Prominence
|
|
25
|
+
companions: Beside the label
|
|
23
26
|
title: Title
|
|
24
27
|
janela:
|
|
25
28
|
actions:
|
|
@@ -64,6 +67,22 @@ en:
|
|
|
64
67
|
text: Text
|
|
65
68
|
link_hint: "A path on this site, such as /orders. Makes the heading a link."
|
|
66
69
|
title_placeholder: Janela writes one if you leave this blank
|
|
70
|
+
companions:
|
|
71
|
+
measures: Measures
|
|
72
|
+
dimensions: Dimensions
|
|
73
|
+
hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
|
|
74
|
+
prominences:
|
|
75
|
+
automatic: Automatic
|
|
76
|
+
"1": Footnote
|
|
77
|
+
"2": Normal
|
|
78
|
+
"3": Hero
|
|
79
|
+
heights:
|
|
80
|
+
automatic: Automatic
|
|
81
|
+
"1": Very short
|
|
82
|
+
"2": Short
|
|
83
|
+
"3": Medium
|
|
84
|
+
"4": Tall
|
|
85
|
+
"5": Very tall
|
|
67
86
|
renderers:
|
|
68
87
|
bar: Bar chart
|
|
69
88
|
doughnut: Doughnut chart
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddHeightToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# How tall a bar or line chart is drawn, one of five steps (ADR 047). Null
|
|
4
|
+
# is what every existing pane is: unset, and drawn exactly as before.
|
|
5
|
+
add_column :janela_panes, :height, :integer
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddProminenceToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# How prominent a single value is drawn, one of three steps (ADR 050).
|
|
4
|
+
# Null is what every existing pane is: unset, and drawn exactly as before.
|
|
5
|
+
add_column :janela_panes, :prominence, :integer
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddCompanionsToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Measures and dimensions a table pane draws as columns beside its label
|
|
4
|
+
# (ADR 051), by the names the model declared them under. Null is what
|
|
5
|
+
# every existing pane is: none, and drawn exactly as before.
|
|
6
|
+
add_column :janela_panes, :companions, :json
|
|
7
|
+
end
|
|
8
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-30
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 005, ADR 012, ADR 016, ADR 029, ADR 036, ADR 037, ADR 042, ADR 046
|
|
5
|
+
Triggers:
|
|
6
|
+
- giving a chart pane a height, an aspect ratio or any size of its own
|
|
7
|
+
- adding a parameter to the pane URL, or a column to janela_panes
|
|
8
|
+
- adding a class to the scale in janela.css, or a wrapper inside a chart pane
|
|
9
|
+
- a chart that is too tall, too short or squashed in a stored frame
|
|
10
|
+
- deciding what a renderer does with a setting that is not meant for it
|
|
11
|
+
Topics: charts, css, layout, styling, public-api, panes, frames, urls
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 047: A Chart's Height Is One of Five Steps, and Unset Changes Nothing
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
#24: a chart draws with Chart.js's defaults, `responsive` and
|
|
19
|
+
`maintainAspectRatio: true` at 2:1, and a host cannot correct it from
|
|
20
|
+
outside. A second report widened it: a stored frame has no height
|
|
21
|
+
vocabulary at all. Its layout is width only, `columns`, `gap` and `span`
|
|
22
|
+
(ADR 012), so an analyst with a three-row frame of charts could make it
|
|
23
|
+
shorter only by swapping the charts for tables, which changes what the
|
|
24
|
+
dashboard says to change how tall it is.
|
|
25
|
+
|
|
26
|
+
Measured in a browser against the demo before deciding, and two things the
|
|
27
|
+
issue assumed turned out to be wrong.
|
|
28
|
+
|
|
29
|
+
**A chart is not enormous today.** A full width line in a 1020px column
|
|
30
|
+
renders 1020 by 320 and a bar in one column of three renders 301 by 150.
|
|
31
|
+
`canvas.janela-chart { max-height: 20rem }` already caps it at 320px, and
|
|
32
|
+
the canvas bitmap and its CSS size agree.
|
|
33
|
+
|
|
34
|
+
**A host can lower a chart from outside and cannot set one.** A host's
|
|
35
|
+
`max-height: 120px !important` took the line to 1020 by 120, redrawn
|
|
36
|
+
rather than squashed, so it does not "fight the resize loop". But
|
|
37
|
+
`height: 240px !important` on the bar is ignored (still 150, because the
|
|
38
|
+
height is derived from the width), and `min-height: 240px` stretches the
|
|
39
|
+
bitmap: the box is 240 and the chart inside it is 150, so it is drawn
|
|
40
|
+
distorted. Exactly N tall, or taller than 2:1, cannot be said from outside.
|
|
41
|
+
|
|
42
|
+
**What works is a box of fixed height around the canvas.** Built fresh with
|
|
43
|
+
`maintainAspectRatio: false` inside a `position: relative` box, a chart
|
|
44
|
+
takes the box's height exactly, redraws when the box is resized, and does
|
|
45
|
+
the same in a 300px column. (Patching the option onto a chart that was
|
|
46
|
+
built with the aspect ratio on gave unreliable readings, so a height has
|
|
47
|
+
to be known when the chart is made.) Five heights derived from the spacing
|
|
48
|
+
unit, `--janela-space` at its default of 0.25rem, measured at 96, 160, 224,
|
|
49
|
+
320 and 448 pixels and each came out exactly. The fifth is squashed to 320
|
|
50
|
+
by the existing `max-height: 20rem` unless that rule is lifted inside the
|
|
51
|
+
box.
|
|
52
|
+
|
|
53
|
+
### What was considered
|
|
54
|
+
|
|
55
|
+
**Pixels, `height: 240`, as the issue proposed.** Works for the helper and
|
|
56
|
+
not for a row. ADR 016 keeps an analyst's number out of CSS: a stored
|
|
57
|
+
integer selects a class that is already written, and a pane row taking
|
|
58
|
+
pixels would be the first place an analyst's number reaches a style. A
|
|
59
|
+
scale is the vocabulary `span`, `columns` and `gap` already speak.
|
|
60
|
+
|
|
61
|
+
**`aspect:` instead of, or beside, `height`.** An aspect ratio means the
|
|
62
|
+
height still follows the width, so it does not help a stored frame whose
|
|
63
|
+
panes sit in different columns, and it would be a second way to say what a
|
|
64
|
+
height says. Not needed, and the issue itself said one of the two was
|
|
65
|
+
probably enough.
|
|
66
|
+
|
|
67
|
+
**A frame-level maximum.** It sizes every pane at once but is a second way
|
|
68
|
+
to say what per pane heights already say, and it cannot make one chart
|
|
69
|
+
shorter than its neighbours. Not needed once panes have a height.
|
|
70
|
+
|
|
71
|
+
**`rows`, a grid row span.** A different thing: a tall pane beside two
|
|
72
|
+
short ones. It is worth having and is kept apart, since folding it into a
|
|
73
|
+
chart height would make one field mean two things.
|
|
74
|
+
|
|
75
|
+
**Lift the 20rem cap for everyone.** Changes every existing chart. Unset
|
|
76
|
+
must change nothing.
|
|
77
|
+
|
|
78
|
+
## Decision
|
|
79
|
+
|
|
80
|
+
**A pane may carry a `height` of 1 to 5, drawn as a box of that fixed
|
|
81
|
+
height around a bar or line chart. Unset is exactly what it is today.**
|
|
82
|
+
|
|
83
|
+
| height | box | default size |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| 1 | `calc(var(--janela-space) * 24)` | 96px |
|
|
86
|
+
| 2 | `calc(var(--janela-space) * 40)` | 160px |
|
|
87
|
+
| 3 | `calc(var(--janela-space) * 56)` | 224px |
|
|
88
|
+
| 4 | `calc(var(--janela-space) * 80)` | 320px |
|
|
89
|
+
| 5 | `calc(var(--janela-space) * 112)` | 448px |
|
|
90
|
+
|
|
91
|
+
Multiples of the base unit, so a theme that sets `--janela-space` moves the
|
|
92
|
+
whole scale with the rest (ADR 016), and step 4 is today's cap exactly, so
|
|
93
|
+
"tall" means what a chart already is at full width. The class names follow
|
|
94
|
+
ADR 016's `janela-{property}-{scale}`: `janela-h-1` to `janela-h-5`, on a
|
|
95
|
+
`div.janela-chart-box` that wraps the canvas inside the figure, between the
|
|
96
|
+
`figcaption` and the canvas, so ADR 042's structure otherwise stands.
|
|
97
|
+
A host that wants an exact pixel value sets it against the class in its own
|
|
98
|
+
CSS. Inside the box the canvas takes the box's height and the 20rem cap
|
|
99
|
+
does not apply to it; the chart controller builds the chart with
|
|
100
|
+
`maintainAspectRatio: false` when the pane carries a height.
|
|
101
|
+
|
|
102
|
+
**With no height there is no box.** The markup, the classes, the cap and
|
|
103
|
+
the 2:1 rule are unchanged, so nothing that renders today renders
|
|
104
|
+
differently and `//figure[...]/canvas` still finds a canvas.
|
|
105
|
+
|
|
106
|
+
**One vocabulary in three places.**
|
|
107
|
+
|
|
108
|
+
```erb
|
|
109
|
+
<%= janela_pane Order, :revenue, by: :placed_on, as: :line, height: 2 %>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `Janela::Pane#height`, an integer column that is null when unset,
|
|
113
|
+
validated to `1..5` the way `span` is to `1..12`, and offered as a
|
|
114
|
+
select on the pane form (blank is "Automatic").
|
|
115
|
+
- `janela_pane` and `janela_snapshot_pane` take `height:`.
|
|
116
|
+
- The pane URL carries it as `?height=2`, because the pane's content is
|
|
117
|
+
rendered by the server from that URL, lazily and again on every
|
|
118
|
+
cross-filter, and a height left out of the URL would be lost on the first
|
|
119
|
+
refresh. A stored pane already refreshes from its row, which supplies it.
|
|
120
|
+
|
|
121
|
+
**Height is not part of the pane's identity.** ADR 029 identifies a frame
|
|
122
|
+
by who it is and not what it shows, and a height is a matter of drawing, not
|
|
123
|
+
of which query this is. It stays out of `Query.turbo_frame_id`, which is
|
|
124
|
+
also what lets a host change a pane's height in place without Turbo
|
|
125
|
+
reconciling into a different frame. Two helper panes over an identical query
|
|
126
|
+
that differ only in height still need an `id:`, exactly as two identical
|
|
127
|
+
panes do now.
|
|
128
|
+
|
|
129
|
+
**A height on a renderer that does not draw a canvas is ignored, not an
|
|
130
|
+
error.** A ring is SVG that scales to its width to a maximum of 16rem
|
|
131
|
+
(ADR 046), a table and a single value have no chart, and a snapshot pane
|
|
132
|
+
draws as its own renderer says. A pane whose renderer is switched from bar
|
|
133
|
+
to doughnut and back keeps the height it had, and a row is not made invalid
|
|
134
|
+
by a setting that is meaningless for what it now shows. What a ring's size
|
|
135
|
+
should be is left open.
|
|
136
|
+
|
|
137
|
+
## Consequences
|
|
138
|
+
|
|
139
|
+
- An analyst can shorten or lengthen a chart in a stored frame without
|
|
140
|
+
changing what it says, and a host can do the same in the helper, from one
|
|
141
|
+
vocabulary. Nothing about pixels reaches CSS.
|
|
142
|
+
- **A migration for stored frames.** `janela_panes` gains a nullable
|
|
143
|
+
`height`, additive, so every existing pane is unset and unchanged. A
|
|
144
|
+
host that uses stored frames runs `bin/rails janela:install:migrations`
|
|
145
|
+
and `db:migrate`, in `UPGRADING.md` and the post install message
|
|
146
|
+
(ADR 015). A host that does not use stored frames has nothing to do.
|
|
147
|
+
- **The pane URL grammar grows by one optional query parameter,** which
|
|
148
|
+
ADR 037 counts as public surface, so it belongs before 1.0 and not after.
|
|
149
|
+
`docs/theming.md` gains `janela-h-1` to `janela-h-5` and
|
|
150
|
+
`janela-chart-box` (ADR 036), and the README documents `height:`.
|
|
151
|
+
- The chart controller reads one new value, which says whether a height was
|
|
152
|
+
set. A height changes when the chart is *built*, so `repoint` to a URL
|
|
153
|
+
with a different height replaces the pane content and the chart is made
|
|
154
|
+
again, which Turbo already does on every replacement.
|
|
155
|
+
- A pane can now look too small to read: step 1 is 96px, tall enough for
|
|
156
|
+
a sparkline and not for a chart with a legend and axis labels. That is the
|
|
157
|
+
analyst's choice and the scale's lowest rung is deliberately that small.
|
|
158
|
+
- Vitral, the optional theme, does not touch heights: it has no rule for
|
|
159
|
+
chart sizing and does not set `--janela-space`, so the scale reads the
|
|
160
|
+
same under it. Sizing is structure and belongs to `janela.css` (ADR 023),
|
|
161
|
+
and because the classes join the published contract a theme may override
|
|
162
|
+
them; none has to.
|
|
163
|
+
- Not covered: rings, `rows`, a frame level maximum, and the gallery's
|
|
164
|
+
configuration form, which offers renderer, granularity and limit and can
|
|
165
|
+
offer height in a later change without a decision.
|
|
166
|
+
- What would change this decision: a real need for a height between two
|
|
167
|
+
steps, which is an argument for a sixth step or for a host's own class
|
|
168
|
+
and not for pixels; or a need for the height to follow a container rather
|
|
169
|
+
than be fixed, which is a different feature.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-30
|
|
3
|
+
Status: Proposed
|
|
4
|
+
Related: ADR 003, ADR 009, ADR 025, ADR 029, ADR 030, ADR 032, ADR 034, ADR 037, ADR 039
|
|
5
|
+
Triggers:
|
|
6
|
+
- a dashboard that should update on a timer, on a push, or when a record changes
|
|
7
|
+
- a host reaching for Turbo Streams, ActionCable or a broadcast to update a pane
|
|
8
|
+
- adding an action or an event to the frame controller
|
|
9
|
+
- a request for a refresh interval, a polling option or a stream_from helper
|
|
10
|
+
- reloading a pane's turbo frame from a host's own JavaScript
|
|
11
|
+
Topics: cross-filtering, frames, javascript, hotwire, performance, authorisation, public-api
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 048: A Frame Can Be Told to Refresh, and Janela Never Decides When
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
The question: should a frame be able to refresh itself every few seconds,
|
|
19
|
+
or stream from something, so a dashboard on a wall stays current? Janela
|
|
20
|
+
has nothing for it. ADR 003 chose Turbo Frames for cross-filtering on the
|
|
21
|
+
ground that a dashboard needs no streams, sockets or client state, and the
|
|
22
|
+
roadmap lists a refresh-scheduling interface among the things that are not
|
|
23
|
+
coming, on the reasoning that a host already has a scheduler and snapshots
|
|
24
|
+
are an ActiveJob. That reasoning was about *snapshots*. Refreshing what a
|
|
25
|
+
reader is looking at, in their browser, is a different request and is not
|
|
26
|
+
answered by it.
|
|
27
|
+
|
|
28
|
+
Three things were established before deciding.
|
|
29
|
+
|
|
30
|
+
**Pushing rendered content cannot work, and would leak if it did.** A
|
|
31
|
+
pane's numbers depend on who is asking. `QueriesController#show` renders
|
|
32
|
+
through `janela_scope`, which calls the *request's own controller* for
|
|
33
|
+
`policy_scope` (ADR 032), and the reader's filters live in their browser as
|
|
34
|
+
`q[...]` (ADR 008). A Turbo Stream broadcast renders once, outside any
|
|
35
|
+
request, with no reader whose scope or filters exist, and sends the same
|
|
36
|
+
HTML to every subscriber. Where it rendered at all it would show one
|
|
37
|
+
person's scope to everyone else, which is the failure ADR 032 and ADR 034
|
|
38
|
+
exist to prevent. So the server can never usefully push a pane's content.
|
|
39
|
+
|
|
40
|
+
**What can be pushed is a nudge, and the refetch belongs to each browser.**
|
|
41
|
+
"Something changed" carries no data. Each browser then re-fetches its own
|
|
42
|
+
panes with its own filters, through the same scoped request it always made,
|
|
43
|
+
so a viewer only ever gets numbers they were entitled to.
|
|
44
|
+
|
|
45
|
+
**A host can already do the refetch, and it already behaves.** Measured in
|
|
46
|
+
a browser against the demo, with all nine panes of the orders dashboard
|
|
47
|
+
loaded and one value selected:
|
|
48
|
+
|
|
49
|
+
- Calling `reload()` on every pane, as a timer would, refetched each at the
|
|
50
|
+
address Janela last asked for, with the filters. None of it was
|
|
51
|
+
unfiltered.
|
|
52
|
+
- With a click and a timer tick landing in the same instant, so that every
|
|
53
|
+
pane was reloaded while the click's own refetch was in flight, all seven
|
|
54
|
+
text panes ended on the filtered numbers, matching the server's answer for
|
|
55
|
+
each. The supersede rule in the frame controller (#33) already drops a
|
|
56
|
+
request for an address that is no longer what was asked, so a tick cannot
|
|
57
|
+
put stale numbers back. One run, not a proof, but the mechanism is the one
|
|
58
|
+
#33 built for exactly this.
|
|
59
|
+
- Before the panes had scrolled into view, a reload of all nine made seven
|
|
60
|
+
requests, consistent with a lazy pane that has not loaded not being
|
|
61
|
+
fetched by a reload.
|
|
62
|
+
|
|
63
|
+
So the mechanism is not missing. What is missing is a name for it that a
|
|
64
|
+
host can rely on. ADR 030 says a host talks to the frame and does not write
|
|
65
|
+
a pane's `src`, and `reload()` on each frame is the host reaching past the
|
|
66
|
+
frame controller into Turbo, with no promise about what it does for a
|
|
67
|
+
pane that holds words and has no `src` (ADR 039), for a pane whose request
|
|
68
|
+
is already in flight, or for a snapshot pane that is never live (ADR 009).
|
|
69
|
+
|
|
70
|
+
### What was considered
|
|
71
|
+
|
|
72
|
+
**A `refresh_every:` option on a frame or a pane.** Rejected. Every tick is
|
|
73
|
+
one query per pane per viewer, so an interval is a decision about load, and
|
|
74
|
+
ADR 025 bounds what one query may ask for and says nothing about how often
|
|
75
|
+
a page asks. Somebody has to own that number, and it is the host, who knows
|
|
76
|
+
its database, its audience and whether the wall display is one browser or
|
|
77
|
+
two hundred. A setting would also put a polling interval where an analyst
|
|
78
|
+
can edit it, which ADR 016 keeps numbers out of. If the numbers are
|
|
79
|
+
expensive, snapshots are the answer, not a faster poll.
|
|
80
|
+
|
|
81
|
+
**A `stream_from` helper, or broadcasting the rendered pane.** Rejected for
|
|
82
|
+
the scope reason above. A helper that only sent the nudge would be a thin
|
|
83
|
+
wrapper over `turbo_stream_from` and a custom action, which the host writes
|
|
84
|
+
in a line, and Janela would then own a transport it has no view of.
|
|
85
|
+
|
|
86
|
+
**Doing nothing, and telling hosts to call `reload()`.** Rejected, because
|
|
87
|
+
the unsupported parts above are exactly where it goes wrong quietly: a
|
|
88
|
+
content pane, a snapshot pane, a lazy pane forced to load early, or a pane
|
|
89
|
+
restarted for no reason while it is already fetching the newest thing.
|
|
90
|
+
|
|
91
|
+
## Decision
|
|
92
|
+
|
|
93
|
+
**The frame controller gains one action, `refresh`, which re-fetches the
|
|
94
|
+
panes that are live and loaded at what was last asked, and Janela provides
|
|
95
|
+
no timer, interval, transport or stream.**
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<button data-action="janela--frame#refresh">Refresh</button>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
// a timer the host owns, paused while the tab is hidden
|
|
103
|
+
setInterval(() => { if (!document.hidden) frame.dispatchEvent(new CustomEvent("janela--frame:refresh")) }, 3000)
|
|
104
|
+
|
|
105
|
+
// or a nudge from ActionCable, a Turbo Stream custom action, anything
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It is also an event the frame listens for, `janela--frame:refresh`, wired
|
|
109
|
+
the way `repoint` is (ADR 030), so code outside the frame can ask without
|
|
110
|
+
holding a reference to the controller.
|
|
111
|
+
|
|
112
|
+
**What refresh does.** For each pane the frame controller manages, it
|
|
113
|
+
re-fetches the address last asked for, with the reader's filters and any
|
|
114
|
+
host-fixed `where` (ADR 040), through the same supersede rules a click uses.
|
|
115
|
+
It leaves alone:
|
|
116
|
+
|
|
117
|
+
- a pane with no live address: a pane that holds words or a host partial
|
|
118
|
+
(ADR 039), and a snapshot pane (ADR 009);
|
|
119
|
+
- a pane that has not loaded yet, so a lazy pane still loads when it scrolls
|
|
120
|
+
into view and a refresh does not pull the whole page forward;
|
|
121
|
+
- a pane whose request for the current address is already in flight, since
|
|
122
|
+
it is about to show the newest thing.
|
|
123
|
+
|
|
124
|
+
**It is a nudge in the other direction too.** Refresh reads no data and
|
|
125
|
+
sends none, so it cannot show anyone anything they could not already see:
|
|
126
|
+
every fetch it causes is the ordinary scoped request (ADR 032).
|
|
127
|
+
|
|
128
|
+
**Where the timer and the transport live.** In the host's code, documented
|
|
129
|
+
as recipes with the cost stated: a tick is one query per pane per viewer,
|
|
130
|
+
so pause it while the tab is hidden and choose an interval the database can
|
|
131
|
+
carry. The README gains a short section, and `docs/composing.md` may carry
|
|
132
|
+
the longer recipes.
|
|
133
|
+
|
|
134
|
+
## Consequences
|
|
135
|
+
|
|
136
|
+
- A wall dashboard is a `setInterval` and one line, a "refresh" button is
|
|
137
|
+
one attribute, and a push from a background job is an ActionCable channel
|
|
138
|
+
and one line of JavaScript. Janela owns none of the three, and none of
|
|
139
|
+
them can leak across readers.
|
|
140
|
+
- **Additive, so it does not need to land before 1.0.** A new action and a
|
|
141
|
+
new event are additions to the frame controller's surface, which ADR 037
|
|
142
|
+
freezes for changes and not for additions. That is why this is filed for
|
|
143
|
+
after 1.0. The name is worth choosing well (`refresh` and
|
|
144
|
+
`janela--frame:refresh` sit beside `clear` and `repoint`).
|
|
145
|
+
- The supersede rule (#33) becomes load bearing for something new: a tick
|
|
146
|
+
arriving during a click's fetch. Build must test that deliberately, by
|
|
147
|
+
holding a response in flight, rather than rely on the single run above.
|
|
148
|
+
- A host that polls carries the load of it. This ADR states that; it does
|
|
149
|
+
not bound it.
|
|
150
|
+
- Nothing changes for a host that does not use it.
|
|
151
|
+
- What would change this decision: a measured case where a host cannot
|
|
152
|
+
reasonably own the timer, which would be an argument for a helper that
|
|
153
|
+
only wires a host-supplied interval, never one that chooses it; or a
|
|
154
|
+
way to broadcast that renders per reader, which Turbo does not offer.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-30
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 008, ADR 024, ADR 025, ADR 028, ADR 037, ADR 040, ADR 043
|
|
5
|
+
Triggers:
|
|
6
|
+
- selecting the (none) group together with a value
|
|
7
|
+
- a filter that carries both _in and _null for one dimension
|
|
8
|
+
- changing what a pair of filter parameters means, or how they combine
|
|
9
|
+
- the frame controller's handling of the null group in toggle
|
|
10
|
+
- a host's where: or default_where naming a value and the null group
|
|
11
|
+
Topics: cross-filtering, ransack, urls, dsl, accessibility, roadmap
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 049: The Null Group Is One More Value in a Selection
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
#69: with a chart of A, B and (none) you can select A and B together but not
|
|
19
|
+
A and (none). This is ADR 024 working as written. It made the null group
|
|
20
|
+
exclusive within its dimension, because a click writes `channel_in[]=web` for
|
|
21
|
+
a value and `channel_null=1` for the group, Ransack ANDs its conditions, and
|
|
22
|
+
the pair asks for rows whose channel is both null and web. That returns zero
|
|
23
|
+
rows without saying so, and the ADR chose to refuse the combination over
|
|
24
|
+
rendering an empty dashboard. What it could not offer was a way to say "web
|
|
25
|
+
or none", and a reader now wants exactly that.
|
|
26
|
+
|
|
27
|
+
Measured against the demo (channel is nil on one row, `phone` on one and
|
|
28
|
+
`web` on two), through Janela's own filter path:
|
|
29
|
+
|
|
30
|
+
| Filter | Rows |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `channel_in: [web]` and `channel_null: 1` | 0 |
|
|
33
|
+
| `channel_in: [web, nil]` | 2 (Ransack drops the nil: no null rows) |
|
|
34
|
+
| `channel_in: [web, ""]` | 2 (same) |
|
|
35
|
+
| `channel_eq_any: [web, nil]` | refused, not an allowed predicate (ADR 025) |
|
|
36
|
+
| `g[]` with `m: or` | refused, and ADR 024 rejected it for an unreadable URL |
|
|
37
|
+
| `channel_not_in: [phone]` | 2 (SQL drops the null rows) |
|
|
38
|
+
|
|
39
|
+
So nothing Ransack offers through Janela's allowed predicates says "A or none".
|
|
40
|
+
|
|
41
|
+
**But Ransack can say it if Janela asks for it.** Given Ransack's own
|
|
42
|
+
grouping, built by Janela and never appearing in a URL:
|
|
43
|
+
|
|
44
|
+
- `g: [{ m: "or", channel_in: ["web"], channel_null: "1" }]` returns 3, as
|
|
45
|
+
`WHERE channel IN ('web') OR channel IS NULL`.
|
|
46
|
+
- Merged with another filter it combines as AND:
|
|
47
|
+
`status IN ('paid') AND (channel IN ('web') OR channel IS NULL)`, 2 rows.
|
|
48
|
+
- Through an association it keeps the join:
|
|
49
|
+
`LEFT OUTER JOIN customers ... WHERE (customers.region IN ('APAC') OR
|
|
50
|
+
customers.region IS NULL)`.
|
|
51
|
+
|
|
52
|
+
So the union is expressible without a new predicate, a new grammar, or
|
|
53
|
+
anything in the URL a person has to learn.
|
|
54
|
+
|
|
55
|
+
### What was considered
|
|
56
|
+
|
|
57
|
+
**A new predicate, `in_or_null`.** `q[channel_in_or_null][]=web`. Rejected.
|
|
58
|
+
It is a second way to spell a selection that Janela already spells with two
|
|
59
|
+
keys it already reads, it would be a new entry in ADR 025's list for a
|
|
60
|
+
question that list already answers, and the frame controller would have to
|
|
61
|
+
translate between the two forms when a value is added to a selection that
|
|
62
|
+
held the null group.
|
|
63
|
+
|
|
64
|
+
**The null inside the `_in` list, as a token such as `(none)`.** Rejected.
|
|
65
|
+
It collides with a real value that is spelled `(none)`, and Ransack drops the
|
|
66
|
+
nil it would have to become.
|
|
67
|
+
|
|
68
|
+
**`g[]` grouping in the URL.** Still rejected, for ADR 024's reason: the URL
|
|
69
|
+
is one a person reads and edits (ADR 008). Using the grouping *inside* Janela
|
|
70
|
+
is a different thing; nothing in the URL changes.
|
|
71
|
+
|
|
72
|
+
**Leave it exclusive.** The present state. It costs a question a dashboard
|
|
73
|
+
plainly has ("web or unknown") that can be answered by no gesture.
|
|
74
|
+
|
|
75
|
+
## Decision
|
|
76
|
+
|
|
77
|
+
**The null group is one more member of a selection. A dimension that carries
|
|
78
|
+
`_in` (or `_eq`) and `_null` means the union, and the frame controller adds
|
|
79
|
+
and removes the null group like any other value.**
|
|
80
|
+
|
|
81
|
+
**At the server.** `Definition#filter`, the one choke point every filter
|
|
82
|
+
passes through (ADR 025), validates the parameters exactly as it does now:
|
|
83
|
+
declared dimensions, the allowed predicates, the value ceiling. It then builds
|
|
84
|
+
the result with each such pair for one dimension taken out of the AND and
|
|
85
|
+
applied as an OR of the two, merged back with everything else. The URL is
|
|
86
|
+
what it was:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
q[channel_in][]=web&q[channel_null]=1
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This applies to a reader's `q[...]`, a host's `where:` (ADR 040) and a
|
|
93
|
+
frame's `default_where` (ADR 043) alike, because they share the choke point.
|
|
94
|
+
Today that pair returns zero rows, so no working link or stored filter depends
|
|
95
|
+
on it.
|
|
96
|
+
|
|
97
|
+
**It is the inclusions that union, and the exclusions stay an intersection.**
|
|
98
|
+
"A or none" is two ways of being selected, so they OR. `not_in` with
|
|
99
|
+
`not_null` is two ways of being ruled out ("anything but A, and not none"),
|
|
100
|
+
so they AND, as they do now. `_null` with `_not_null` remains a contradiction
|
|
101
|
+
and returns nothing.
|
|
102
|
+
|
|
103
|
+
**At the frame controller.** `toggle` stops treating the null key as
|
|
104
|
+
exclusive. The gestures are ADR 024's, unchanged:
|
|
105
|
+
|
|
106
|
+
| Gesture | Result |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| Click (none) | (none) alone is selected |
|
|
109
|
+
| Click a value while (none) is selected | that value alone replaces it |
|
|
110
|
+
| Ctrl or Cmd click (none) | (none) is added to, or removed from, the selection |
|
|
111
|
+
| Ctrl or Cmd click a value while (none) is selected | the value is added, (none) stays |
|
|
112
|
+
|
|
113
|
+
`clearDimension` still clears the dimension's keys when a plain click replaces
|
|
114
|
+
the selection. The highlight needs nothing: a pane already marks `(none)`
|
|
115
|
+
among the selected values (`Query#selected_values`).
|
|
116
|
+
|
|
117
|
+
## Consequences
|
|
118
|
+
|
|
119
|
+
- A dashboard can answer "A or none", which nothing could before, and a
|
|
120
|
+
selection of A, B and (none) is three ordinary members of one set.
|
|
121
|
+
- **ADR 024's exclusivity paragraph is superseded.** Its measurement of the
|
|
122
|
+
AND stands and is why the union is built explicitly. The rest of 024, the
|
|
123
|
+
gestures, `_in`, the keyboard, stands. ADR 024's status line says so.
|
|
124
|
+
- **The meaning of a documented pair of parameters changes**, which ADR 037
|
|
125
|
+
counts as public surface, and is why this belongs before 1.0. It is
|
|
126
|
+
narrowly breaking: the pair returned no rows before, so the only thing that
|
|
127
|
+
could depend on it is a link or a stored filter that always showed an empty
|
|
128
|
+
pane. `CHANGELOG.md` says so under Changed, and no `UPGRADING.md` entry is
|
|
129
|
+
needed because there is nothing to change.
|
|
130
|
+
- The chart and the table need no change of their own: the null group is
|
|
131
|
+
already a label with a filter, and the selection highlight already includes
|
|
132
|
+
it.
|
|
133
|
+
- A snapshot taken under the pair stores its filters as it does any others
|
|
134
|
+
(ADR 009), and a snapshot taken before this holds the zero rows it always
|
|
135
|
+
did. Stored results are never re-run.
|
|
136
|
+
- Build must test the choke point for `_in` and `_eq` each with `_null`,
|
|
137
|
+
through an association, alongside a filter on another dimension, with the
|
|
138
|
+
value ceiling still enforced, with `not_in` and `not_null` still
|
|
139
|
+
intersecting, and the frame controller in a browser for the four gestures
|
|
140
|
+
above.
|
|
141
|
+
- What would change this decision: a host needing the union for a *range* on
|
|
142
|
+
a time dimension, which is an OR of two ranges and a different feature
|
|
143
|
+
(ADR 045), or a dimension where "none" is not a value at all but an error.
|