janela 0.9.0 → 0.11.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 +31 -0
- data/README.md +15 -5
- data/UPGRADING.md +81 -0
- data/app/assets/javascripts/janela/chart_controller.js +76 -10
- data/app/assets/javascripts/janela/frame_controller.js +25 -5
- data/app/assets/stylesheets/janela.css +41 -0
- data/app/models/janela/frame.rb +29 -0
- data/app/models/janela/pane.rb +2 -2
- data/app/models/janela/query.rb +144 -12
- data/app/views/janela/queries/_query.html.erb +14 -11
- data/app/views/janela/queries/_ring.html.erb +41 -0
- data/config/locales/en.yml +6 -0
- data/db/migrate/20260928000001_add_default_filter_to_janela_frames.rb +10 -0
- data/docs/composing.md +19 -0
- data/docs/decisions/006-time-dimensions-with-groupdate.md +1 -1
- data/docs/decisions/043-a-frames-default-filter-names-the-model-it-narrows.md +163 -0
- data/docs/decisions/044-a-categorical-dimension-can-say-what-it-excludes.md +140 -0
- data/docs/decisions/045-clicking-a-time-bucket-filters-the-frame-to-its-range.md +206 -0
- data/docs/decisions/046-a-ring-is-server-drawn-svg-and-a-palette-is-eight-fixed-colours.md +199 -0
- data/docs/decisions/INDEX.md +15 -11
- data/docs/roadmap.md +15 -20
- data/docs/theming.md +14 -2
- data/lib/janela/definition.rb +20 -3
- data/lib/janela/dimension.rb +31 -2
- data/lib/janela/version.rb +1 -1
- metadata +15 -15
data/app/models/janela/query.rb
CHANGED
|
@@ -5,9 +5,20 @@ module Janela
|
|
|
5
5
|
# rather than collapsing this one to the value clicked. A query read from a
|
|
6
6
|
# snapshot shows stored results and cannot be clicked at all.
|
|
7
7
|
class Query
|
|
8
|
-
RENDERERS = %w[table bar line].freeze
|
|
8
|
+
RENDERERS = %w[table bar line doughnut pie].freeze
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Drawn by Chart.js on a canvas, and so blank without a chart runtime.
|
|
11
|
+
CANVAS = %w[bar line].freeze
|
|
12
|
+
|
|
13
|
+
# Drawn on the server as SVG instead (ADR 046).
|
|
14
|
+
RINGS = %w[doughnut pie].freeze
|
|
15
|
+
|
|
16
|
+
# Palette slots before the neutral. Colour is chosen by position and never
|
|
17
|
+
# cycled: a ninth category drawn in the first colour would be two
|
|
18
|
+
# categories the same beside a legend that says they differ (ADR 046).
|
|
19
|
+
SERIES = 8
|
|
20
|
+
|
|
21
|
+
attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :fixed, :default, :snapshot
|
|
11
22
|
|
|
12
23
|
# The helper renders the turbo frame and the controller renders its
|
|
13
24
|
# replacement, so both derive the id the same way from the same parameters.
|
|
@@ -17,7 +28,7 @@ module Janela
|
|
|
17
28
|
parts.compact.join("_")
|
|
18
29
|
end
|
|
19
30
|
|
|
20
|
-
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, snapshot: nil, title: nil)
|
|
31
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
21
32
|
@definition = definition
|
|
22
33
|
@title = title
|
|
23
34
|
@measure = measure
|
|
@@ -25,6 +36,7 @@ module Janela
|
|
|
25
36
|
@renderer = renderer.to_s
|
|
26
37
|
@filters = filters
|
|
27
38
|
@fixed = fixed
|
|
39
|
+
@default = default
|
|
28
40
|
@snapshot = snapshot
|
|
29
41
|
|
|
30
42
|
raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
@@ -59,15 +71,87 @@ module Janela
|
|
|
59
71
|
@granularity || (dimension_definition.granularity if time?)
|
|
60
72
|
end
|
|
61
73
|
|
|
62
|
-
# Clicking a category adds one Ransack condition; clicking a time bucket
|
|
63
|
-
# would need two, and the dashboard toggles one key at a time (ADR 006).
|
|
64
74
|
# A stored pane is the record of a moment and is not clickable (ADR 009).
|
|
75
|
+
# A time pane is: a bucket writes the pair of conditions for its range
|
|
76
|
+
# (ADR 045).
|
|
65
77
|
def clickable?
|
|
66
|
-
!single_value? && !
|
|
78
|
+
!single_value? && !frozen?
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
def chart?
|
|
70
|
-
!single_value? && renderer
|
|
82
|
+
!single_value? && CANVAS.include?(renderer)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ring?
|
|
86
|
+
!single_value? && RINGS.include?(renderer)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A part of a whole cannot be negative, and a ring of nothing draws
|
|
90
|
+
# nothing. Either way the pane is a table that says why, not a wrong
|
|
91
|
+
# picture (ADR 046).
|
|
92
|
+
def hole?
|
|
93
|
+
renderer == "doughnut"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ringable?(result)
|
|
97
|
+
values = result.values.map(&:to_f)
|
|
98
|
+
values.none?(&:negative?) && values.sum.positive?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The custom property a category at this position is drawn with.
|
|
102
|
+
def series_property(index)
|
|
103
|
+
index < SERIES ? "--janela-series-#{index + 1}" : "--janela-series-other"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# One entry per label, in result order, with the arc it covers. A zero is
|
|
107
|
+
# kept for the legend and has no arc. The filter is the one a click on
|
|
108
|
+
# that label toggles, so the slice and its legend row cannot disagree.
|
|
109
|
+
CENTRE = 50.0
|
|
110
|
+
OUTER = 48.0
|
|
111
|
+
INNER = 27.0
|
|
112
|
+
|
|
113
|
+
Slice = Struct.new(:label, :formatted, :property, :click, :selected, :fraction, :from, keyword_init: true) do
|
|
114
|
+
# The SVG path of this slice on a 100 by 100 canvas, from twelve o'clock
|
|
115
|
+
# clockwise, or nil when it covers nothing. One slice covering the whole
|
|
116
|
+
# circle is two half arcs, since an arc from a point to itself is not
|
|
117
|
+
# drawn. A doughnut's hole is a second sub-path, cut out by the
|
|
118
|
+
# stylesheet's even-odd fill rule.
|
|
119
|
+
def path(hole:)
|
|
120
|
+
return unless fraction.positive?
|
|
121
|
+
return whole(hole) if fraction >= 0.9999
|
|
122
|
+
|
|
123
|
+
large = fraction > 0.5 ? 1 : 0
|
|
124
|
+
outer_from, outer_to = point(OUTER, from), point(OUTER, from + fraction)
|
|
125
|
+
if hole
|
|
126
|
+
inner_from, inner_to = point(INNER, from), point(INNER, from + fraction)
|
|
127
|
+
"M #{outer_from} A #{OUTER} #{OUTER} 0 #{large} 1 #{outer_to} L #{inner_to} A #{INNER} #{INNER} 0 #{large} 0 #{inner_from} Z"
|
|
128
|
+
else
|
|
129
|
+
"M #{CENTRE} #{CENTRE} L #{outer_from} A #{OUTER} #{OUTER} 0 #{large} 1 #{outer_to} Z"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
def whole(hole)
|
|
135
|
+
circle = ->(radius) { "M #{CENTRE} #{CENTRE - radius} A #{radius} #{radius} 0 1 1 #{CENTRE} #{CENTRE + radius} A #{radius} #{radius} 0 1 1 #{CENTRE} #{CENTRE - radius} Z" }
|
|
136
|
+
hole ? "#{circle.(OUTER)} #{circle.(INNER)}" : circle.(OUTER)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def point(radius, turns)
|
|
140
|
+
angle = turns * 2 * Math::PI - Math::PI / 2
|
|
141
|
+
format("%.3f %.3f", CENTRE + radius * Math.cos(angle), CENTRE + radius * Math.sin(angle))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def slices(result)
|
|
146
|
+
total = result.values.sum(&:to_f)
|
|
147
|
+
from = 0.0
|
|
148
|
+
result.each_with_index.map do |(label, measured), index|
|
|
149
|
+
fraction = measured.to_f / total
|
|
150
|
+
slice = Slice.new(label: label.to_s, formatted: format(measured), property: series_property(index),
|
|
151
|
+
click: (click_data(label) if clickable?), selected: selected?(label), fraction: fraction, from: from)
|
|
152
|
+
from += fraction
|
|
153
|
+
slice
|
|
154
|
+
end
|
|
71
155
|
end
|
|
72
156
|
|
|
73
157
|
def turbo_frame_id
|
|
@@ -98,13 +182,35 @@ module Janela
|
|
|
98
182
|
def result(on: nil)
|
|
99
183
|
return snapshot.stored_result(self) if frozen?
|
|
100
184
|
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
185
|
+
# Default, then fixed, then the reader's: a frame's own permanent
|
|
186
|
+
# filter narrows first, a host's per-record filter narrows again, and
|
|
187
|
+
# both apply even on this pane's own dimension, unlike the reader's
|
|
188
|
+
# selection, which this pane shows the alternatives to (ADR 040, 043).
|
|
189
|
+
on = definition.narrow(on || model.all, default) if default.present?
|
|
104
190
|
on = definition.narrow(on || model.all, fixed) if fixed.present?
|
|
191
|
+
return time_result(on) if time?
|
|
192
|
+
|
|
105
193
|
definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
106
194
|
end
|
|
107
195
|
|
|
196
|
+
# The two filters a click on this label writes, for a time pane: the start
|
|
197
|
+
# of its bucket and the start of the next (ADR 045).
|
|
198
|
+
def range_for(label)
|
|
199
|
+
from, to = dimension_definition.bounds(@buckets.fetch(label.to_s), granularity)
|
|
200
|
+
{ "#{ransack_name}_gteq" => from, "#{ransack_name}_lt" => to }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The data attributes a table button or legend row carries: one key and
|
|
204
|
+
# value for a category, the pair of conditions for a bucket.
|
|
205
|
+
def click_data(label)
|
|
206
|
+
if time?
|
|
207
|
+
{ janela__frame_filters_param: range_for(label).to_json }
|
|
208
|
+
else
|
|
209
|
+
key, value = filter_params(label)
|
|
210
|
+
key ? { janela__frame_key_param: key, janela__frame_value_param: value } : nil
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
108
214
|
# The Ransack key and value a click on this label should toggle. A null
|
|
109
215
|
# group filters with the null predicate, not an empty string (ADR 009 has
|
|
110
216
|
# no say here; see issue #21).
|
|
@@ -113,7 +219,7 @@ module Janela
|
|
|
113
219
|
# _eq link is still read, since a URL somebody already sent should not stop
|
|
114
220
|
# working to suit us (ADR 024).
|
|
115
221
|
def filter_params(label)
|
|
116
|
-
return [ nil, nil ] unless clickable?
|
|
222
|
+
return [ nil, nil ] unless clickable? && !time?
|
|
117
223
|
return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
|
|
118
224
|
|
|
119
225
|
[ "#{ransack_name}_in", label.to_s ]
|
|
@@ -123,6 +229,7 @@ module Janela
|
|
|
123
229
|
# to look up by label when a bar is clicked.
|
|
124
230
|
def filters_for(labels)
|
|
125
231
|
return {} unless clickable?
|
|
232
|
+
return labels.to_h { |label| [ label.to_s, range_for(label) ] } if time?
|
|
126
233
|
|
|
127
234
|
labels.to_h { |label| [ label.to_s, filter_params(label) ] }
|
|
128
235
|
end
|
|
@@ -134,6 +241,7 @@ module Janela
|
|
|
134
241
|
# like any other label.
|
|
135
242
|
def selected_values
|
|
136
243
|
return [] unless clickable?
|
|
244
|
+
return selected_buckets if time?
|
|
137
245
|
|
|
138
246
|
values = Array(filter("#{ransack_name}_in")) + Array(filter("#{ransack_name}_eq"))
|
|
139
247
|
values = values.map(&:to_s)
|
|
@@ -158,8 +266,32 @@ module Janela
|
|
|
158
266
|
dimension_definition.ransack_name
|
|
159
267
|
end
|
|
160
268
|
|
|
269
|
+
# A time pane's series is read as buckets, not labels, and the buckets
|
|
270
|
+
# are kept: the labels alone cannot say what range a click covers.
|
|
271
|
+
def time_result(on)
|
|
272
|
+
series = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
|
|
273
|
+
@buckets = series.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
|
|
274
|
+
series.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The buckets that lie wholly inside the range the filters name. Janela
|
|
278
|
+
# writes both ends, so a range with one is somebody else's and selects
|
|
279
|
+
# nothing here. Read before the first result there are no buckets yet.
|
|
280
|
+
def selected_buckets
|
|
281
|
+
from, to = filter("#{ransack_name}_gteq"), filter("#{ransack_name}_lt")
|
|
282
|
+
return [] if from.blank? || to.blank? || @buckets.nil?
|
|
283
|
+
|
|
284
|
+
from, to = Time.zone.parse(from.to_s), Time.zone.parse(to.to_s)
|
|
285
|
+
return [] if from.nil? || to.nil?
|
|
286
|
+
|
|
287
|
+
@buckets.select { |_, bucket|
|
|
288
|
+
start, finish = dimension_definition.span(bucket, granularity)
|
|
289
|
+
start >= from && finish <= to
|
|
290
|
+
}.keys
|
|
291
|
+
end
|
|
292
|
+
|
|
161
293
|
def applicable_filters
|
|
162
|
-
return filters if single_value?
|
|
294
|
+
return filters if single_value?
|
|
163
295
|
|
|
164
296
|
filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
165
297
|
end
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
</p>
|
|
8
8
|
<% elsif result.empty? %>
|
|
9
9
|
<p class="janela-pane janela-empty"><%= query.title %>: no data</p>
|
|
10
|
+
<% elsif query.ring? && query.ringable?(result) %>
|
|
11
|
+
<%= render "janela/queries/ring", query: query, result: result %>
|
|
10
12
|
<% elsif query.chart? %>
|
|
11
13
|
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
12
14
|
<figure class="janela-pane">
|
|
@@ -21,24 +23,20 @@
|
|
|
21
23
|
data-janela--chart-values-value="<%= result.values.map(&:to_f).to_json %>"
|
|
22
24
|
data-janela--chart-formatted-value="<%= result.values.map { |measured| query.format(measured) }.to_json %>"
|
|
23
25
|
data-janela--chart-filters-value="<%= query.filters_for(result.keys).to_json %>"
|
|
26
|
+
<%= tag.attributes(aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) }) %>
|
|
24
27
|
role="img" aria-labelledby="<%= title_id %>"></canvas>
|
|
25
28
|
</figure>
|
|
26
29
|
<% else %>
|
|
27
|
-
|
|
30
|
+
<%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
|
|
28
31
|
<caption><%= query.title %></caption>
|
|
29
32
|
<tbody>
|
|
30
33
|
<% result.each do |label, measured| %>
|
|
31
|
-
<%
|
|
34
|
+
<% click = query.click_data(label) if query.clickable? %>
|
|
32
35
|
<tr>
|
|
33
36
|
<td>
|
|
34
|
-
<% if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
data-action="janela--frame#toggle"
|
|
38
|
-
data-janela--frame-key-param="<%= key %>"
|
|
39
|
-
data-janela--frame-value-param="<%= value %>">
|
|
40
|
-
<%= label %>
|
|
41
|
-
</button>
|
|
37
|
+
<% if click %>
|
|
38
|
+
<%= tag.button label, type: "button", aria: { pressed: query.selected?(label) },
|
|
39
|
+
data: { action: "janela--frame#toggle" }.merge(click) %>
|
|
42
40
|
<% else %>
|
|
43
41
|
<span><%= label %></span>
|
|
44
42
|
<% end %>
|
|
@@ -47,5 +45,10 @@
|
|
|
47
45
|
</tr>
|
|
48
46
|
<% end %>
|
|
49
47
|
</tbody>
|
|
50
|
-
|
|
48
|
+
<% end %>
|
|
49
|
+
<%# A ring cannot draw a negative part or a total of nothing, so it says it
|
|
50
|
+
is a table rather than drawing something wrong (ADR 046). %>
|
|
51
|
+
<% if query.ring? %>
|
|
52
|
+
<p class="janela-muted"><%= t("janela.rings.not_drawable") %></p>
|
|
53
|
+
<% end %>
|
|
51
54
|
<% end %>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# A doughnut or a pie, drawn here rather than on a canvas so it is in the
|
|
2
|
+
HTML before any JavaScript runs, prints, and can be read and operated by
|
|
3
|
+
a keyboard through the legend (ADR 046, ADR 026). A slice and its legend
|
|
4
|
+
row toggle the same filter, and the legend is the control a keyboard and a
|
|
5
|
+
screen reader use, so the slices are not offered to them twice. %>
|
|
6
|
+
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
7
|
+
<% slices = query.slices(result) %>
|
|
8
|
+
<% selecting = slices.any?(&:selected) %>
|
|
9
|
+
<figure class="janela-pane janela-ring">
|
|
10
|
+
<figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
|
|
11
|
+
<svg class="janela-ring-svg" viewBox="0 0 100 100" role="img" aria-labelledby="<%= title_id %>"
|
|
12
|
+
data-hole="<%= query.hole? %>">
|
|
13
|
+
<% slices.each do |slice| %>
|
|
14
|
+
<% path = slice.path(hole: query.hole?) %>
|
|
15
|
+
<% next unless path %>
|
|
16
|
+
<%= tag.path d: path, class: [ "janela-ring-slice", ("janela-dim" if selecting && !slice.selected) ],
|
|
17
|
+
style: "fill: var(#{slice.property})",
|
|
18
|
+
data: (slice.click ? { action: "click->janela--frame#toggle" }.merge(slice.click) : {}) do %>
|
|
19
|
+
<title><%= slice.label %>: <%= slice.formatted %></title>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</svg>
|
|
23
|
+
<table class="janela-legend">
|
|
24
|
+
<tbody>
|
|
25
|
+
<% slices.each do |slice| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td>
|
|
28
|
+
<span class="janela-swatch" style="background: var(<%= slice.property %>)" aria-hidden="true"></span>
|
|
29
|
+
<% if slice.click %>
|
|
30
|
+
<%= tag.button slice.label, type: "button", aria: { pressed: slice.selected },
|
|
31
|
+
data: { action: "janela--frame#toggle" }.merge(slice.click) %>
|
|
32
|
+
<% else %>
|
|
33
|
+
<span><%= slice.label %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
</td>
|
|
36
|
+
<td><%= slice.formatted %></td>
|
|
37
|
+
</tr>
|
|
38
|
+
<% end %>
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</figure>
|
data/config/locales/en.yml
CHANGED
|
@@ -66,5 +66,11 @@ en:
|
|
|
66
66
|
title_placeholder: Janela writes one if you leave this blank
|
|
67
67
|
renderers:
|
|
68
68
|
bar: Bar chart
|
|
69
|
+
doughnut: Doughnut chart
|
|
69
70
|
line: Line chart
|
|
71
|
+
pie: Pie chart
|
|
70
72
|
table: Table
|
|
73
|
+
time:
|
|
74
|
+
click_hint: Choose a period to filter the other panes to it. Ctrl or Cmd does not add a second period.
|
|
75
|
+
rings:
|
|
76
|
+
not_drawable: A ring cannot show a negative value or a total of nothing, so this is drawn as a table.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class AddDefaultFilterToJanelaFrames < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# A frame's own permanent filter (ADR 043): only a pane over
|
|
4
|
+
# default_model takes it, so a frame that holds panes from more than one
|
|
5
|
+
# model is never narrowed by a condition that is not about it. Present
|
|
6
|
+
# or absent together; every existing frame has neither.
|
|
7
|
+
add_column :janela_frames, :default_model, :string
|
|
8
|
+
add_column :janela_frames, :default_where, :json
|
|
9
|
+
end
|
|
10
|
+
end
|
data/docs/composing.md
CHANGED
|
@@ -93,6 +93,25 @@ pane the reader can click: any value but the fixed one returns nothing.
|
|
|
93
93
|
A figure you compute yourself for the same page, such as the progress
|
|
94
94
|
bar below, takes the same condition in its own `where:`.
|
|
95
95
|
|
|
96
|
+
## A frame's own permanent filter
|
|
97
|
+
|
|
98
|
+
Some conditions are not about which record's page this is, they are
|
|
99
|
+
true every time: "this queue never counts an archived row." `where:` is
|
|
100
|
+
code, written into a view, run again on every render. A frame's own
|
|
101
|
+
default filter is data instead, so an analyst changes it without a
|
|
102
|
+
deploy (ADR 043):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
frame.update!(default_model: "orders", default_where: { status_in: %w[paid pending] })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`default_model` says which model the condition is about, so a frame
|
|
109
|
+
holding panes from more than one model is never narrowed by a condition
|
|
110
|
+
that was never about the pane reading it. It is validated when you save
|
|
111
|
+
it, against that model's declared dimensions, rather than only
|
|
112
|
+
discovered wrong the next time a pane renders. Set both columns
|
|
113
|
+
together; either alone is a validation error.
|
|
114
|
+
|
|
96
115
|
## Components
|
|
97
116
|
|
|
98
117
|
Each of these is plain HTML. The class names that start `janela-` are
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-28
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 012, ADR 014, ADR 020, ADR 025, ADR 040
|
|
5
|
+
Triggers:
|
|
6
|
+
- a frame that should always exclude some rows, regardless of who renders it
|
|
7
|
+
- adding a column to Janela::Frame or Janela::Pane
|
|
8
|
+
- a host copying the same where: condition into every view that renders one frame
|
|
9
|
+
- validating a stored Ransack condition at save time rather than at render
|
|
10
|
+
Topics: frames, filters, cross-filtering, persistence, ransack
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 043: A Frame's Default Filter Names the Model It Narrows
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
#63, found while dogfooding: ADR 040 gives a host a way to narrow a
|
|
18
|
+
frame to the current record, `where: { project_id_eq: @project.id }`,
|
|
19
|
+
code, per request, correctly not data because the value is inherently
|
|
20
|
+
per-record. It does not give a frame a way to say something that is
|
|
21
|
+
true on every render, forever: "this queue never counts an archived
|
|
22
|
+
row." That is not per-record narrowing, it is a permanent property of
|
|
23
|
+
what the dashboard means, and today it can only be expressed by typing
|
|
24
|
+
the same Ransack condition into every view that renders the frame.
|
|
25
|
+
|
|
26
|
+
ADR 012's own argument is that composition moved out of ERB because
|
|
27
|
+
the analyst, not the developer, owns a dashboard, and a decision typed
|
|
28
|
+
into a view is "a file held by the wrong owner." A permanent filter is
|
|
29
|
+
exactly that file, with nowhere on the data side to move to: `Frame`
|
|
30
|
+
and `Pane` have no column that could hold one.
|
|
31
|
+
|
|
32
|
+
### What breaks the obvious shape
|
|
33
|
+
|
|
34
|
+
The obvious answer, a `default_where` column on `Frame` applied to
|
|
35
|
+
every pane in it, runs into a fact ADR 020 already used to reject the
|
|
36
|
+
frame as the layer for a different per-pane setting: a frame can hold
|
|
37
|
+
panes from more than one model. Measured against the demo, reusing the
|
|
38
|
+
existing bound check rather than assuming it:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Order.janela.narrow(Order.all, "expedited_eq" => "false")
|
|
42
|
+
# => Janela::BadRequest: Order does not allow filtering on expedited_eq.
|
|
43
|
+
# Declare a janela dimension, or add it to ransackable_attributes.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`expedited` is a real column on `Order`, not a declared dimension. A
|
|
47
|
+
condition applied uniformly to every pane in a frame raises for any
|
|
48
|
+
pane whose model, or whose declared dimension set, does not carry the
|
|
49
|
+
attribute the condition names. ADR 020's own case was a format string
|
|
50
|
+
being "right for the currency and wrong for the count sitting next to
|
|
51
|
+
it"; a stored Ransack condition is at least as specific to one model.
|
|
52
|
+
|
|
53
|
+
### What was considered for where the mismatch goes
|
|
54
|
+
|
|
55
|
+
**Raise for every pane whose model does not match.** Rejected: the
|
|
56
|
+
frame breaks the moment an analyst adds a pane over a second model, or
|
|
57
|
+
the moment a declared dimension is removed from the first, and the
|
|
58
|
+
failure is frame-wide rather than local to the pane that changed.
|
|
59
|
+
|
|
60
|
+
**Skip the condition quietly for a pane it does not apply to.** Rejected
|
|
61
|
+
outright. ADR 024, ADR 032 and ADR 034 all refuse the same shape of
|
|
62
|
+
quiet: a filter that means one thing for one pane and nothing for
|
|
63
|
+
another, on the same frame, with nothing on the page saying so.
|
|
64
|
+
|
|
65
|
+
**Restrict a frame with a default to panes over one model.** Considered
|
|
66
|
+
and rejected as an unnecessary restriction on `Pane`, which already
|
|
67
|
+
allows any model with a `janela` block. The mismatch is the default's
|
|
68
|
+
problem to solve, not a new constraint on what a frame may hold.
|
|
69
|
+
|
|
70
|
+
## Decision
|
|
71
|
+
|
|
72
|
+
**A frame's default filter names the one model it narrows, and only a
|
|
73
|
+
pane over that model takes it.** `Janela::Frame` gains two columns,
|
|
74
|
+
`default_model` and `default_where`, both nullable and present or
|
|
75
|
+
absent together:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
frame.update!(default_model: "orders", default_where: { "status_not_eq" => "archived" })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**`default_model` is validated the same way `Pane#model` already is**:
|
|
82
|
+
blank, or a route key naming a model with a `janela` block, checked
|
|
83
|
+
through `Janela.definition!` and rescued the same way
|
|
84
|
+
`declared_by_a_janela_block` does. It does not have to match any pane
|
|
85
|
+
the frame currently holds. A frame can carry a default before its first
|
|
86
|
+
matching pane exists, and a pane over a different model is simply never
|
|
87
|
+
narrowed by it, no error, because the condition was never claimed to be
|
|
88
|
+
about it.
|
|
89
|
+
|
|
90
|
+
**`default_where` is validated against that one model's `Definition` at
|
|
91
|
+
save time**, not only discovered wrong at render. `Definition#narrow`
|
|
92
|
+
already runs `Ransack#ransack` and the three checks ADR 025 built
|
|
93
|
+
(dropped filters, disallowed predicates, oversized value lists); a new
|
|
94
|
+
validation calls it against `default_model`'s definition and turns a
|
|
95
|
+
raised `Janela::BadRequest` into a validation error on `default_where`,
|
|
96
|
+
the same sentence a request would have raised, read at the point an
|
|
97
|
+
analyst can still fix it. This is new ground: nothing today validates a
|
|
98
|
+
persisted, arbitrary Ransack condition at save time, only `fixed` and
|
|
99
|
+
the reader's `q[...]`, which are validated fresh on every render because
|
|
100
|
+
neither is ever stored.
|
|
101
|
+
|
|
102
|
+
**It composes as a third layer, ahead of the two ADR 040 already
|
|
103
|
+
built.** `Pane#query` asks its frame for the default that applies to its
|
|
104
|
+
own model and passes it to `Query`, which narrows with it before `fixed`
|
|
105
|
+
narrows again, before the reader's own filters:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Janela::Frame
|
|
109
|
+
def default_for(model)
|
|
110
|
+
default_model == model.model_name.route_key ? default_where.to_h : {}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Janela::Query#result
|
|
114
|
+
on = definition.narrow(on || model.all, default) if default.present? # frame, permanent
|
|
115
|
+
on = definition.narrow(on, fixed) if fixed.present? # host, per-record (ADR 040)
|
|
116
|
+
definition.query(measure, by: dimension, where: applicable_filters, on: on, ...) # reader
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Applied even on a pane's own dimension, the same as `fixed`: the point
|
|
120
|
+
of "this queue never counts an archived row" is that no pane on it ever
|
|
121
|
+
shows archived as one of its own bars either. Nothing the reader does
|
|
122
|
+
in `q[...]` can remove it, for the same reason nothing the reader does
|
|
123
|
+
can remove a host's fixed filter.
|
|
124
|
+
|
|
125
|
+
**Only a stored frame can have one.** A hand composed `janela_frame do
|
|
126
|
+
... end` block has no `Frame` row for a default to live on; a host
|
|
127
|
+
composing a page in ERB already writes its own permanent conditions in
|
|
128
|
+
its own code, in one place, which is what ADR 012 left unchanged for
|
|
129
|
+
that path. This is additive to the data-driven half only.
|
|
130
|
+
|
|
131
|
+
**Vocabulary, so the three layers stay distinct.** *Default* is the
|
|
132
|
+
frame's own, permanent, data. *Fixed* stays ADR 040's word for the
|
|
133
|
+
host's own, per-record, code. *Filters*, or `q[...]`, stays the
|
|
134
|
+
reader's. Three words, three owners, and none of them is allowed to
|
|
135
|
+
mean another.
|
|
136
|
+
|
|
137
|
+
## Consequences
|
|
138
|
+
|
|
139
|
+
- An analyst declares "this queue never counts an archived row" once,
|
|
140
|
+
as a row, and it holds across every page that ever renders the frame,
|
|
141
|
+
including one that does not exist yet.
|
|
142
|
+
- **A stale default is a frame-wide failure, not a pane-wide one.**
|
|
143
|
+
If `default_model`'s `janela` block later drops the dimension
|
|
144
|
+
`default_where` names, every pane over that model in the frame raises
|
|
145
|
+
at render, the same "missing pane" treatment `declared_by_a_janela_block`
|
|
146
|
+
already gives a stale measure or dimension, just wider: one condition
|
|
147
|
+
now speaks for every pane that shares its model rather than for one
|
|
148
|
+
row. Worth a line in the engine's own edit form once built.
|
|
149
|
+
- `janela_frames` gains `default_model` and `default_where`, both
|
|
150
|
+
nullable. Existing frames are unaffected either way; needs a migration
|
|
151
|
+
and an `UPGRADING.md` entry (ADR 015).
|
|
152
|
+
- **Not decided here: a frame with panes over two models, each wanting
|
|
153
|
+
its own permanent default.** One `(model, where)` pair per frame is
|
|
154
|
+
what is built. A second model needing one of its own is the trigger to
|
|
155
|
+
revisit this as a has-many rather than a second pair of columns, the
|
|
156
|
+
same way ADR 040 left a signed filter for the day a host's need for
|
|
157
|
+
one is real rather than guessed at.
|
|
158
|
+
- The engine's own frame form needs a way to set both columns together,
|
|
159
|
+
and to clear both together; not built here.
|
|
160
|
+
- What would change this decision: a host needing a permanent filter
|
|
161
|
+
that is not one model's own condition, such as one spanning an
|
|
162
|
+
association two different pane models both reach through. Nothing
|
|
163
|
+
proposes that today.
|