janela 0.4.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00b7cab2c2ce57d7d9457affeb3d5f647b177f37d4f78349d4a12fe3fe298bd6
4
- data.tar.gz: 5092f072ec8c4e282f09321fdff541e9eac613f557ae2f9697de830468c52778
3
+ metadata.gz: 2fd31f19d1b100c312826e8077aefc291873c0cc153410aa9c53199dcdf5ba18
4
+ data.tar.gz: ec551cd6c640de00e6ba13266ae2920945f68ad4a576c344e157830c9442563d
5
5
  SHA512:
6
- metadata.gz: 1bfea2cbc91408fa6c8879f2cd64b3c7cd51cc53b075da9268553ff9125716054afa68b8abc6f714e7b25c70a63f7a295f1c16353d5f31779a76ca326affbf6a
7
- data.tar.gz: d9303b22989d5951d847bd56b3337f4576aa352c5887a2cc73b82d15e77d9967230a2984cd06bb2c428abed4f08065cd612461687abe7c91548cab4e88857669
6
+ metadata.gz: 692add30c86d3633d3144b6fe1fbb138246762e974f618430db2f290f1980a91bb55441104b2a1d183e67e6e16ca3ffd75aaab8d8e0156d77444950bc388dc21
7
+ data.tar.gz: 794ac1f1352ee3e30357d8ef2ff7bd2d0692895f932b3aec5d3acc5df55ab21e592397f111df855a120c547eea271273807e870d988a602a49c6575987ddaa9a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-09-20
9
+
10
+ ### Added
11
+
12
+ - `janela_pane` takes an optional `id:`, naming the pane's frame instead of fingerprinting it from the query. A host that reconfigures a pane in place, a renderer toggle, a granularity switcher, a "show top 20" link, keeps one stable frame for Turbo to reconcile into rather than a different id every time the query changes. Without `id:`, nothing changes.
13
+ - A `janela--frame:repoint` event, dispatched on a pane or anything inside one with `detail: { url }`, sends that pane to a different query. `janela_frame` listens for it, so nothing has to be wired up and nothing has to reach for the controller. This is how a host changes what a pane shows: a pane's `src` belongs to Turbo, which writes it back whenever a response lands, so Janela keeps its own record of what was asked for and cancels anything else, a host's `src` write included. The event says the query and nothing about filters, because the frame reapplies whatever it is currently filtered to. `data-janela-asked` and `data-janela-src` are how the frame remembers, not an interface, and a host reading or writing them is relying on something that may change (ADR 030, #43).
14
+ - A subclass inherits the dashboard its parent declared and is addressable on its own route key, with nothing to declare: `class WholesaleOrder < Order; end` answers at `/dashboards/wholesale_orders/revenue` and totals its own rows, because the query runs on the subclass and ActiveRecord adds the type condition itself. This is a change of posture, since only a model that declared a `janela` block was addressable before, and it is one of URL surface rather than data surface: an STI subclass is a subset of rows its parent already totals, read through the same scope as everything else. A subclass that wants a different dashboard declares its own block, which replaces its parent's. The cost is noise: a family of a dozen STI types is a dozen entries in `Janela.definitions` and in the form that offers a choice of model, where a host expected one (ADR 031, #11).
15
+
16
+ ### Fixed
17
+
18
+ - A subclass came out half declared: it inherited the Ransack allowlist, because Janela defined that as singleton methods on the parent and singleton methods inherit, while `.janela` returned nil and nothing was registered. A host calling `WholesaleOrder.ransack(...)` in its own code was filtering on dimensions no definition behind that class declared. The allowlist Janela generates is now the allowlist of the definition a class reports, whichever class declared it, and a subclass declaring its own block no longer replaces an allowlist the host wrote on a parent (ADR 031, #11).
19
+ - Pointing a pane's turbo frame at a URL differing only in `limit`, `granularity` or `as` left the frame stale with no error. The response was fingerprinted from the query, so it wore an id the frame never had and Turbo had nothing to reconcile it against. A pane rendered into a turbo frame request now answers to the frame that asked, using the id Turbo already sends in its `Turbo-Frame` header, rather than deriving one again from the query. A pane rendered any other way keeps deriving its own id as before. The demo's gallery config controller no longer fetches and swaps a pane's frame by hand: it names each configurable pane and asks the frame to repoint it, which is 22 lines shorter than where it started (ADR 029, #42).
20
+ - Reconfiguring a pane while the frame was filtered refetched that pane unfiltered, so it showed numbers for a filter state nobody was in while every pane beside it stayed filtered, and nothing on the page said so. Reapplying the frame's current filters is part of repointing now rather than something a caller has to remember, which is what the demo's gallery control got wrong: change a pane's granularity there with a value selected and it went back to showing the year (ADR 003, ADR 030, #43).
21
+
22
+ ## [0.5.0] - 2026-09-18
23
+
24
+ ### Added
25
+
26
+ - `Janela.renderers`, `Janela.granularities` and `Janela.offered_limits`, alongside the existing `Janela.definitions`, so a gallery of what Janela can draw asks the gem rather than reading `Janela::Query::RENDERERS`, `Janela::Dimension::GRANULARITIES` or `Janela::Pane::OFFERED_LIMITS` directly. `test/dummy`'s `/gallery` is the reference page ADR 027 describes, built from exactly that surface plus `janela_pane`: a live pane per renderer per model, with the declaration that produced it beside it. A renderer a model cannot demonstrate, for want of a suitable dimension, is shown as unavailable rather than hidden, and a host with no `janela` models yet gets an explanation rather than a blank page (ADR 026, ADR 027, #39).
27
+
28
+ ### Changed
29
+
30
+ - **Breaking.** A filter is bound to what kind of dimension it names rather than to every predicate Ransack knows. Every one of Ransack's 62 predicates worked on any allowed attribute, `_matches` sharpest among them: an arbitrary `LIKE` pattern, a leading wildcard scan away, on a page a host had already authorised someone to read. A categorical dimension now takes `eq`, `in`, `null` and `not_null`; a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, which is exactly what a click produces (ADR 024) plus the range narrowing ADR 006 already documented. Anything else raises `Janela::BadRequest` naming the filter and what the dimension allows, rather than Ransack silently dropping it and a pane showing a number nobody asked for. A grouped query with no `limit` now gets one anyway, at the existing ceiling of 1000 (ADR 007); a single filter may carry at most 1000 values. `rails janela:doctor` finds a hardcoded filter that used a predicate no longer allowed, when it is written in the host's own source rather than read from a URL (ADR 025, #8).
31
+
8
32
  ## [0.4.1] - 2026-09-17
9
33
 
10
34
  ### Fixed
@@ -128,6 +152,8 @@ First alpha, installed from GitHub for testing in a single host application.
128
152
  - Only models that declare a `janela` block are addressable over HTTP.
129
153
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
130
154
 
155
+ [0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.0
156
+ [0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
131
157
  [0.4.1]: https://github.com/retail-tasker/janela/releases/tag/v0.4.1
132
158
  [0.4.0]: https://github.com/retail-tasker/janela/releases/tag/v0.4.0
133
159
  [0.3.0]: https://github.com/retail-tasker/janela/releases/tag/v0.3.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.4"
30
+ gem "janela", "~> 0.6"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -129,6 +129,21 @@ end
129
129
 
130
130
  A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
131
131
 
132
+ ### A subclass inherits
133
+
134
+ Declaring is what a family of classes does once. A subclass of a model with a `janela` block has the same measures and dimensions and its own pane URLs, with nothing to declare:
135
+
136
+ ```ruby
137
+ class WholesaleOrder < Order
138
+ end
139
+ ```
140
+
141
+ `/dashboards/wholesale_orders/revenue` totals the wholesale orders and `/dashboards/orders/revenue` totals all of them. Janela knows nothing about single table inheritance: the query runs on the subclass and ActiveRecord adds the type condition itself. A subclass is read through your scope like any other model, so if you authorise per class, the subclass needs an answer of its own.
142
+
143
+ A subclass that wants a different dashboard declares its own `janela` block, which replaces its parent's rather than adding to it. Either way, the Ransack allowlist Janela generates is the allowlist of the definition that class reports, so the two cannot disagree.
144
+
145
+ Every subclass is a definition, so a model with a dozen STI types offers a dozen of them wherever Janela lists what it can draw, such as the form for adding a pane (ADR 031).
146
+
132
147
  ### How numbers read
133
148
 
134
149
  A measure says what its own number means, and every renderer asks it, so a table cell, a single value and a chart tooltip cannot disagree (ADR 020):
@@ -166,6 +181,8 @@ Scope a query to whatever the current user is allowed to see with `on:`:
166
181
  Order.janela.query(:revenue, by: :status, on: policy_scope(Order))
167
182
  ```
168
183
 
184
+ A filter is bound to what kind of dimension it names, not to every predicate Ransack knows (ADR 025). A categorical dimension takes `eq`, `in`, `null` and `not_null`; a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, so a range still narrows it. Anything else, such as `_cont` or `_matches`, raises `Janela::BadRequest` naming what is allowed. A grouped query with no `limit` gets one anyway, capped at 1000, and a single filter may carry at most 1000 values.
185
+
169
186
  Declaring a dimension makes that attribute filterable, so Janela defines the model's Ransack allowlist for you. A model that already defines its own keeps it. A `through:` dimension also needs the **associated** model to allow the attribute, because Ransack's allowlist is per-class:
170
187
 
171
188
  ```ruby
@@ -192,6 +209,23 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
192
209
 
193
210
  A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
194
211
 
212
+ **Reconfiguring a pane in place**, a renderer toggle, a granularity switcher, a "show top 20" control, takes two things: name the pane with `id:`, then ask the frame to repoint it.
213
+
214
+ ```erb
215
+ <%= janela_pane Order, :revenue, by: :status, as: :bar, id: "revenue-by-status" %>
216
+ ```
217
+
218
+ ```js
219
+ const pane = document.getElementById("revenue-by-status")
220
+ pane.dispatchEvent(new CustomEvent("janela--frame:repoint", {
221
+ bubbles: true, detail: { url: "/dashboards/orders/revenue/status?limit=20" }
222
+ }))
223
+ ```
224
+
225
+ `id:` gives the frame a name you chose rather than a fingerprint of its own query, which would move every time that query changed and leave Turbo nothing to reconcile into. That is necessary and it is not enough on its own: a pane's `src` belongs to Turbo, which writes it back whenever a response lands, so writing `src` yourself has the request cancelled and the pane put back where it was, with no error and the old numbers still on screen. The event is how you say what you want instead. `janela_frame` listens for it, so anything inside a frame can dispatch it, from a Stimulus controller (`this.dispatch("repoint", { prefix: "janela--frame", target: pane, detail: { url } })`) or from plain JavaScript as above.
226
+
227
+ Say the query and nothing about filters: the frame reapplies whatever it is currently filtered to, so a repointed pane still agrees with the panes beside it, and any `q[...]` on the URL you pass is dropped in favour of them (ADR 029, ADR 030).
228
+
195
229
  ### Frames
196
230
 
197
231
  A dashboard does not have to be written in ERB. A frame is a record, so the person who decides which panes a dashboard has and how wide each one is does not need a deploy to change it (ADR 012):
@@ -294,6 +328,21 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
294
328
 
295
329
  The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the grammar, ADR 011 the layout it renders in.
296
330
 
331
+ **Sending an existing pane to one of these URLs takes both halves of Reconfiguring a pane in place, above: name it with `id:`, and repoint it through the frame.** Without the `id:` the response wears an id the frame never had and Turbo has nothing to reconcile, so the pane keeps its old numbers with no error at all. Without the event, `src` is not yours to write and the request is cancelled, with the same silence (ADR 029, ADR 030).
332
+
333
+ ### What Janela can draw
334
+
335
+ `Janela.renderers`, `Janela.granularities` and `Janela.offered_limits` answer what a pane can be drawn as, without reaching into `Janela::Query::RENDERERS`, `Janela::Dimension::GRANULARITIES` or `Janela::Pane::OFFERED_LIMITS`. `Janela.definitions` answers the other half: every model that declares a `janela` block, with its own measures and dimensions. A gallery of every renderer, live against your own data, is a page you build from those four calls and `janela_pane`, not one the engine serves (ADR 026, ADR 027):
336
+
337
+ ```erb
338
+ <% Janela.definitions.each do |definition| %>
339
+ <h2><%= definition.model.model_name.human %></h2>
340
+ <%= janela_pane definition.model, definition.measures.keys.first %>
341
+ <% end %>
342
+ ```
343
+
344
+ `test/dummy`'s `/gallery` is the reference: every renderer, per model, with the declaration that produced it beside it. A renderer a model cannot demonstrate, for want of a suitable dimension, shows as unavailable rather than disappearing, and a model with no `janela` block anywhere yet gets told so rather than an empty page.
345
+
297
346
  ### Snapshots
298
347
 
299
348
  A snapshot freezes the results of several panes at one instant, under one set of filters, so an audience sees exactly what was signed off while the live dashboard stays editable. Results are stored, not HTML; a stored pane can still be drawn as a table or a chart. It needs the same migrations frames do.
@@ -435,7 +484,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
435
484
 
436
485
  ## Status
437
486
 
438
- **v0.4.1 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests. Not yet built: a visual editor, drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
487
+ **v0.6.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests. Not yet built: a visual editor, drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
439
488
 
440
489
  ## Development
441
490
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,113 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.5.0 to 0.6.0
16
+
17
+ How single table inheritance is handled changed (ADR 031). Nothing here
18
+ applies unless your application has STI subclasses under a model that
19
+ declares a `janela` block. If it does not, upgrade and read no further.
20
+
21
+ **1. A named subclass is now registered and addressable.**
22
+
23
+ Before, only a class with its own `janela` block answered at a URL. Now
24
+ every named subclass of one does, on its own route key:
25
+
26
+ ```
27
+ /insights/orders/revenue as before
28
+ + /insights/wholesale_orders/revenue new in 0.6.0
29
+ ```
30
+
31
+ The numbers are the subclass's own rows, because the query runs on the
32
+ subclass and ActiveRecord adds the type condition itself. There is
33
+ nothing to declare and nothing to register.
34
+
35
+ Scoping is unchanged: a subclass reads through `janela_scope` like every
36
+ other model, so an application authorising with `policy_scope` already
37
+ covers the new addresses. One that instead gates on the request path now
38
+ has paths it has not listed, and should list them.
39
+
40
+ **2. `Janela.definitions` returns one entry per subclass.**
41
+
42
+ A family of a dozen STI types is a dozen entries, where a form offering a
43
+ choice of model previously showed one. If you want only the classes that
44
+ declared a dashboard:
45
+
46
+ ```ruby
47
+ - Janela.definitions
48
+ + Janela.definitions.select { |definition| definition.model.base_class == definition.model }
49
+ ```
50
+
51
+ **3. A subclass's Ransack allowlist now matches the dashboard it reports.**
52
+
53
+ This was wrong before rather than merely different. A subclass inherited
54
+ the allowlist Janela generated on its parent, because those are singleton
55
+ methods and singleton methods inherit, while `.janela` returned nil and
56
+ nothing was registered. So `WholesaleOrder.ransack(...)` in your own code
57
+ filtered on dimensions no definition behind that class had declared. The
58
+ allowlist is now the allowlist of the definition a class reports,
59
+ whichever class declared it.
60
+
61
+ Janela also no longer replaces an allowlist you wrote yourself on a
62
+ parent class when a subclass declares its own block.
63
+
64
+ **What did not change.** A model that declares its own `janela` block, an
65
+ application with no STI, every `janela_frame` and `janela_pane` call you
66
+ have already written, and every filter already in a URL. An anonymous
67
+ subclass is still not registered, having no route key to be addressed by.
68
+
69
+ ## 0.4.1 to 0.5.0
70
+
71
+ A filter is now bound to what kind of dimension it names (ADR 025). Most
72
+ hosts do nothing: a click already writes `_eq` or `_in`, both still
73
+ allowed. Three things need you only if you have gone further than that.
74
+
75
+ **1. A predicate outside a dimension's allowlist now raises.**
76
+
77
+ A categorical dimension (`dimension :status`) allows `eq`, `in`, `null`
78
+ and `not_null`. A time dimension (`dimension :placed_on, granularity:
79
+ :day`) additionally allows `gteq`, `gt`, `lteq` and `lt`. Anything else,
80
+ most often `_cont`, `_matches`, `_start` or `_end`, now raises
81
+ `Janela::BadRequest` instead of quietly filtering:
82
+
83
+ ```ruby
84
+ - Order.janela.query(:revenue, where: { status_cont: params[:q] })
85
+ + Order.janela.query(:revenue, where: { status_eq: params[:q] })
86
+ ```
87
+
88
+ If you need a real pattern search, pass a relation you have already
89
+ filtered through `on:`, where you write the condition yourself, under
90
+ your own authorisation, rather than accepting one from a URL:
91
+
92
+ ```ruby
93
+ Order.janela.query(:revenue, on: Order.where("status LIKE ?", "%#{params[:q]}%"))
94
+ ```
95
+
96
+ Run `bin/rails janela:doctor` after upgrading: it finds a disallowed
97
+ predicate hardcoded in your own source, the same way it finds a stale
98
+ identifier. It cannot find one built from a URL param at request time;
99
+ there is nothing in your source to read.
100
+
101
+ **2. A grouped query with no `limit` now gets one anyway.**
102
+
103
+ A breakdown over more than 1000 values used to return all of them and
104
+ now returns the top 1000, ordered by the measure (ADR 007). Pass
105
+ `limit:` yourself if you want a different cut:
106
+
107
+ ```ruby
108
+ - Order.janela.query(:revenue, by: :customer)
109
+ + Order.janela.query(:revenue, by: :customer, limit: 1000) # unchanged
110
+ ```
111
+
112
+ Nothing to do if your dashboard already has fewer than 1000 groups, or
113
+ already passes `limit:`.
114
+
115
+ **3. A single filter may not carry more than 1000 values.**
116
+
117
+ `status_in` (or any other array predicate) with more than 1000 values
118
+ now raises `Janela::BadRequest` instead of being answered. Nothing to
119
+ do unless you build a filter with more values than that yourself; a
120
+ click never does.
121
+
15
122
  ## 0.3.0 to 0.4.0
16
123
 
17
124
  Selecting more than one value in a dimension (ADR 024). Most hosts do
@@ -102,6 +102,34 @@ export default class extends Controller {
102
102
  if (Object.keys(this.filtersValue).length) this.filtersValue = {}
103
103
  }
104
104
 
105
+ // A host changes what a pane shows by asking here, and never by writing
106
+ // src or one of the dataset records below. Turbo writes src back onto a
107
+ // frame when a response lands, so src does not say what was asked for and
108
+ // this controller keeps its own record instead (#33); a host writing that
109
+ // record by hand has to write two attributes in the right order and
110
+ // reapply the frame's filters itself, and only this controller knows what
111
+ // those filters are. Getting it wrong is silent: the pane shows numbers
112
+ // for a filter state nobody is in, beside panes that are still filtered
113
+ // (ADR 003, ADR 030, #43).
114
+ //
115
+ // Dispatched on the pane, or on anything inside it, with the query to go
116
+ // to. Filters are the frame's, so a caller says nothing about them:
117
+ //
118
+ // pane.dispatchEvent(new CustomEvent("janela--frame:repoint",
119
+ // { bubbles: true, detail: { url: "/dashboards/orders/revenue/status?limit=5" } }))
120
+ repoint(event) {
121
+ const pane = this.paneTargets.find((each) => each.contains(event.target))
122
+ const query = new URL(event.detail.url, window.location.origin)
123
+ this.stripFilters(query)
124
+
125
+ // The query without filters first, since it is what this pane's URL is
126
+ // rebuilt from on the next click as well as on the line below.
127
+ pane.dataset.janelaSrc = query.pathname + query.search
128
+ const url = this.urlFor(pane)
129
+ pane.dataset.janelaAsked = url.href
130
+ pane.src = url.href
131
+ }
132
+
105
133
  // Whatever is selected for one key, as a set of strings. A filter arrives as
106
134
  // an array from a click and as a string from a hand written _eq link.
107
135
  valuesFor(filters, key) {
@@ -160,9 +188,7 @@ export default class extends Controller {
160
188
  // pushed: a click is not a place the back button should return to.
161
189
  syncPageUrl() {
162
190
  const url = new URL(window.location.href)
163
- for (const key of [...url.searchParams.keys()]) {
164
- if (key.startsWith("q[")) url.searchParams.delete(key)
165
- }
191
+ this.stripFilters(url)
166
192
  this.writeFilters(url)
167
193
  if (url.href !== window.location.href) history.replaceState(history.state, "", url)
168
194
  }
@@ -173,6 +199,15 @@ export default class extends Controller {
173
199
  return url
174
200
  }
175
201
 
202
+ // The filters on a URL are Janela's to write, so whatever is already there
203
+ // comes off before the current selection goes on: a page URL carrying the
204
+ // filters a page was opened with, or a query a host handed to repoint.
205
+ stripFilters(url) {
206
+ for (const key of [ ...url.searchParams.keys() ]) {
207
+ if (key.startsWith("q[")) url.searchParams.delete(key)
208
+ }
209
+ }
210
+
176
211
  // Sorted, keys and values both, so the browser serialises a selection the
177
212
  // same way every time and an unchanged src is never reloaded.
178
213
  writeFilters(url) {
@@ -9,16 +9,20 @@ module Janela
9
9
  def janela_frame(frame = nil, charts: true, &block)
10
10
  return render("janela/frames/frame", frame: frame, filters: janela_page_filters, charts: charts) if frame
11
11
 
12
- tag.div(data: { controller: "janela--frame", action: "keydown.esc->janela--frame#clear",
12
+ tag.div(data: { controller: "janela--frame", action: janela_frame_actions,
13
13
  janela__frame_filters_value: janela_page_filters.to_json }, &block)
14
14
  end
15
15
 
16
- def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil)
16
+ # id: names the pane's frame instead of fingerprinting it from the query,
17
+ # so a host that changes the query in place (a renderer, granularity or
18
+ # limit control) keeps one stable frame for Turbo to reconcile into
19
+ # rather than a different id every time the query changes (ADR 029).
20
+ def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, id: nil)
17
21
  query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
18
22
  base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
19
23
  src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
20
24
 
21
- turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
25
+ turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
22
26
  src: src,
23
27
  loading: :lazy,
24
28
  data: { janela__frame_target: "pane", janela_src: base }
@@ -35,6 +39,13 @@ module Janela
35
39
  end
36
40
 
37
41
  private
42
+ # Wired once per frame, so a host asks a pane to go to a different query
43
+ # by dispatching an event from anywhere inside rather than by reaching
44
+ # for the controller itself (ADR 030).
45
+ def janela_frame_actions
46
+ "keydown.esc->janela--frame#clear janela--frame:repoint->janela--frame#repoint"
47
+ end
48
+
38
49
  def janela_frame_classes(frame)
39
50
  [ "janela-frame", "janela-cols-#{frame.columns}", "janela-gap-#{frame.gap}" ]
40
51
  end
@@ -1,5 +1,5 @@
1
1
  <%= tag.div class: janela_frame_classes(frame),
2
- data: { controller: "janela--frame", action: "keydown.esc->janela--frame#clear",
2
+ data: { controller: "janela--frame", action: janela_frame_actions,
3
3
  janela__frame_filters_value: filters.to_json } do %>
4
4
  <%= render partial: "janela/frames/pane", collection: frame.panes, as: :pane, locals: { filters: filters, charts: charts } %>
5
5
  <% end %>
@@ -1,4 +1,9 @@
1
+ <%# A pane rendered into a turbo frame request answers to the frame that
2
+ asked, using the id Turbo already sends in its Turbo-Frame header,
3
+ rather than fingerprinting the id again from a query that may have just
4
+ changed underneath it (ADR 029). Rendered any other way, it still
5
+ derives its own id. %>
1
6
  <% content_for :title, @query.title %>
2
- <%= turbo_frame_tag @query.turbo_frame_id do %>
7
+ <%= turbo_frame_tag turbo_frame_request_id.presence || @query.turbo_frame_id do %>
3
8
  <%= render "janela/queries/query", query: @query, result: @result %>
4
9
  <% end %>
@@ -0,0 +1,93 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 016, ADR 023, ADR 001, ADR 012
5
+ Triggers:
6
+ - adding a visualisation or a renderer
7
+ - reaching for a charting library
8
+ - anything that would make how a pane is drawn configurable
9
+ - building the gallery
10
+ Topics: rendering, styling, configuration, host-integration
11
+ ---
12
+
13
+ # ADR 026: A Renderer Is the Seam, and HTML Comes First
14
+
15
+ ## Context
16
+
17
+ Janela draws bar and line panes with Chart.js today, tables with plain
18
+ HTML, and a value pane with a number in a div. That mix happened rather
19
+ than being decided, and the next visualisation makes the question
20
+ unavoidable: is Janela a wrapper around a charting library, or something
21
+ that draws with whatever suits and reaches for a library only when it
22
+ has to?
23
+
24
+ Three things are being asked at once, and they have one answer between
25
+ them.
26
+
27
+ **Where a charting library sits.** A pane already names its renderer, so
28
+ `renderer: "bar"` is a name on a record and the code behind it is ours.
29
+ Nothing about Chart.js is in that name. Treating the renderer as the
30
+ seam means a visualisation can change how it is drawn without any host
31
+ noticing, and a host that wants a different bar chart replaces one small
32
+ piece rather than the library.
33
+
34
+ **Whether a visualisation needs JavaScript at all.** Most do not. A bar
35
+ chart is a div with a percentage width. A ranked list is a table with a
36
+ bar behind each row. A sparkline is inline SVG the server can render
37
+ whole. Drawn that way the pane is in the HTML: it is there before any
38
+ JavaScript loads, it prints, a screen reader can read it, and a click to
39
+ cross-filter is a real button, which is already how the table renderer
40
+ behaves and already how ADR 024's keyboard support works. A canvas can
41
+ do none of that without being taught each one.
42
+
43
+ The engine's own frame page already proves the point from the other
44
+ side: it loads no chart runtime, so it renders a chart pane as a table,
45
+ and the pane is still useful.
46
+
47
+ **Whether a host should choose the charting library.** A setting for
48
+ this is the obvious idea and the wrong one. It would mean every renderer
49
+ written against an interface wide enough for any library, an adapter per
50
+ library, and a test matrix multiplied by the number of libraries anyone
51
+ has configured. ADR 001 kept Janela to a load bearing 5%, and the
52
+ project's stance is that forking a small piece is a normal way to use
53
+ this library, not a fallback. A renderer small enough to read in one
54
+ sitting is worth more than a plugin system.
55
+
56
+ ## Decision
57
+
58
+ **A renderer is the seam. Server rendered HTML and CSS are the default,
59
+ a JavaScript library is an implementation detail of the renderers that
60
+ need one, and there is no setting for choosing it.**
61
+
62
+ 1. A pane's `renderer` names a way of drawing, not a technology. What is
63
+ behind that name can change without a host changing anything.
64
+ 2. A new visualisation is built in HTML and CSS unless it cannot be. A
65
+ library earns its place by doing something the document cannot:
66
+ dense data, animation, interaction a button cannot express.
67
+ 3. Every renderer degrades to something readable with no JavaScript,
68
+ because a pane is rendered on the server before anything runs.
69
+ 4. No `Janela.chart_library` or equivalent. A host that wants a
70
+ different chart replaces a renderer, and the renderers stay small
71
+ enough that this is reasonable.
72
+ 5. Vitral reaches into the visualisation, not only the page around it.
73
+ The palette a bar takes, how a pane is framed and how a selection is
74
+ shown are part of the theme (ADR 023), which is what makes a
75
+ visualisation gallery a property of the gem rather than of the demo.
76
+
77
+ ## Consequences
78
+
79
+ The gallery becomes gem work: a mountable page listing every renderer as
80
+ a live pane against whatever data the host has, with the declaration
81
+ that produced it beside it, and the theme applied. It is documentation
82
+ that cannot go stale, because it is the real thing rendering. It is also
83
+ the roadmap, since a renderer that does not exist yet is a visible gap
84
+ rather than a line in a backlog.
85
+
86
+ Chart.js stays for bar and line until a renderer that does not need it
87
+ is written and measured against it. Nothing here asks a host to change
88
+ anything today.
89
+
90
+ The cost is that some visualisations are more work in HTML and CSS than
91
+ in a library, and we accept that where the result stays readable without
92
+ JavaScript. Where it does not, the library is the right answer and the
93
+ renderer says so.
@@ -0,0 +1,87 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 011, ADR 016, ADR 018, ADR 023, ADR 026
5
+ Triggers:
6
+ - building or changing the gallery
7
+ - adding a renderer
8
+ - exposing what Janela can draw to a host
9
+ - anything that would put JavaScript on the engine's own pages
10
+ Topics: rendering, host-integration, layouts, styling
11
+ ---
12
+
13
+ # ADR 027: The Gallery Is a Host Page, Built from the Gem's Helpers
14
+
15
+ ## Context
16
+
17
+ ADR 026 decided that a gallery of renderers belongs in the gem rather
18
+ than in the demo, because vitral reaches into the visualisation and a
19
+ host should see its own data drawn its own way. It did not say what "in
20
+ the gem" means, and investigating #39 showed that the obvious reading is
21
+ the one that cannot work.
22
+
23
+ The obvious reading is a page the engine serves, at the mount path,
24
+ alongside the frames index. ADR 018 rules it out in its own words: "the
25
+ engine's own pages deliberately load no Stimulus and no Chart.js." That
26
+ is why a chart pane renders as a table there, and `frames_test.rb`
27
+ asserts exactly that. A gallery of every renderer served by the engine
28
+ would render table as a table, bar as a table and line as a table. It
29
+ could not demonstrate the thing it exists to demonstrate.
30
+
31
+ The same constraint takes the live configuration panel with it. The
32
+ two-step form for adding a pane exists because, as the comment on
33
+ `panes/new.html.erb` puts it, "the engine's pages run no JavaScript, so
34
+ one select cannot refill another". A panel where changing a select
35
+ re-renders the pane beside it is that, exactly.
36
+
37
+ The way out is already built. `janela_pane(model, measure, by:, as:,
38
+ granularity:, limit:)` renders any pane into a host's own page, where
39
+ that host's JavaScript, layout and theme already are. ADR 011 sent panes
40
+ there deliberately. A gallery is a page of panes.
41
+
42
+ ## Decision
43
+
44
+ **The gallery is a page the host owns, built from helpers and
45
+ enumerations the gem provides. The engine serves no gallery, and no
46
+ JavaScript is added to the engine's own pages.**
47
+
48
+ 1. The gem's job is to make the page trivial to build: rendering a pane
49
+ into a host page already works, and what can be drawn becomes a
50
+ public enumeration rather than something a host reads out of a
51
+ constant. A gallery has to ask what renderers exist, what
52
+ granularities and limits are offered, and what a host's models
53
+ declare, and each of those is a supported question.
54
+ 2. The page itself is the host's: its route, its layout, its words. That
55
+ is the same trade ADR 011 made and the reason a host's theme and
56
+ assets are present at all.
57
+ 3. The demo carries the reference implementation, and it is the one we
58
+ look at. It is a host page like any other, so what works there works
59
+ for a host that copies it.
60
+ 4. Anything interactive in it is the host's JavaScript, which means the
61
+ gem's own Stimulus controllers are available there as they already
62
+ are for frames and charts. Nothing changes on the engine's pages,
63
+ and ADR 011 and ADR 018 stand untouched.
64
+
65
+ ## Consequences
66
+
67
+ A host gets a gallery by mounting a page rather than by installing the
68
+ gem, which is a real cost: it is not there on day one the way the frames
69
+ index is (ADR 013). We accept it, because a gallery that cannot draw a
70
+ chart would be worse than no gallery, and because the page is small when
71
+ the helpers and enumerations are right.
72
+
73
+ What the gem owes the page is now the work: an enumeration of renderers
74
+ and their options that does not require reaching into
75
+ `Janela::Query::RENDERERS`, and an answer for a host whose models
76
+ declare nothing yet, since a gallery with no data to draw still has to
77
+ say something useful.
78
+
79
+ There are three renderers today, table, bar and line, one of which is
80
+ the fallback the others degrade to. The gallery is therefore mostly a
81
+ frame for what comes next rather than a showcase of what exists, which
82
+ is the point of ADR 026 and worth saying plainly so nobody builds it
83
+ expecting a wall of charts.
84
+
85
+ If a gallery a host gets for free ever matters more than the engine's
86
+ pages staying JavaScript free, this is the decision to supersede, and
87
+ ADR 018 goes with it.
@@ -0,0 +1,75 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 006, ADR 007, ADR 009, ADR 024, ADR 025
5
+ Supersedes: ADR 025, in the predicate list and in what it says about snapshots
6
+ Triggers:
7
+ - changing which predicates a dimension allows
8
+ - adding a predicate a click can produce
9
+ - reasoning about what a stored snapshot validates when it is read
10
+ Topics: security, scope, urls, snapshots
11
+ ---
12
+
13
+ # ADR 028: The Predicate List ADR 025 Named Was Not Quite Right
14
+
15
+ ## Context
16
+
17
+ ADR 025 decided that Janela bounds what a filter can ask for, and it was
18
+ right about the problem: every one of Ransack's 62 predicates was
19
+ reachable on any attribute a dimension declared, a grouped query carried
20
+ no `LIMIT`, and one filter accepted 5001 values. Building it (#8) proved
21
+ all three still true and closed them.
22
+
23
+ Two things the ADR said turned out not to survive contact with the code.
24
+ An accepted ADR is not rewritten, so this records what is true instead.
25
+
26
+ **The list it named would have broken behaviour the project already
27
+ documents.** ADR 025 gave a categorical dimension `eq`, `in` and `null`,
28
+ on the grounds that those are what a click produces (ADR 024). But
29
+ `not_null` is already used, already tested in `definition_query_test.rb`,
30
+ and has nothing to do with the hole being closed. Shipping the list as
31
+ written would have refused a filter a host is entitled to use, loudly, in
32
+ the name of security it does not buy.
33
+
34
+ **The snapshot risk it described does not exist.** ADR 025 warned that a
35
+ stored snapshot taken under a predicate later disallowed "would raise
36
+ when read". It cannot. `Snapshot#stored_result` looks a pane up by key
37
+ and returns the stored JSON; it never re-runs the query and never touches
38
+ Ransack again. A snapshot's `filters` are metadata `Snapshot.take` built
39
+ with, not something a read validates against. The fear was reasonable and
40
+ the code does not have it.
41
+
42
+ ## Decision
43
+
44
+ **A categorical dimension allows `eq`, `in`, `null` and `not_null`. A
45
+ time dimension allows those plus `gteq`, `gt`, `lteq` and `lt`. Reading a
46
+ stored snapshot validates no predicates, because it runs no query.**
47
+
48
+ Everything else ADR 025 decided stands: predicates are allowed by the
49
+ kind of dimension rather than globally, anything outside the list raises
50
+ `Janela::BadRequest` naming the attribute and what is allowed, and one
51
+ ceiling of 1000 bounds both an unlimited grouped query and the number of
52
+ values a single filter may carry (ADR 007).
53
+
54
+ The rule for adding to the list, so this does not become a place things
55
+ accumulate: a predicate belongs there if a dashboard produces it, or if
56
+ it is a boolean test of presence rather than a way to phrase a match.
57
+ `not_null` qualifies on the second. `cont`, `matches` and `start` do not
58
+ qualify on either, which is the whole point of the bound.
59
+
60
+ ## Consequences
61
+
62
+ The built list is one predicate wider than the decided one, and a host
63
+ using `not_null` keeps working rather than being broken by a security
64
+ fix. That is the right trade, and it is worth naming why it was close: a
65
+ list written from first principles in an ADR, without running it against
66
+ the tests, refused something real.
67
+
68
+ A regression test in `snapshot_test.rb` now pins the snapshot behaviour,
69
+ so the failure mode ADR 025 imagined cannot appear later without a test
70
+ noticing.
71
+
72
+ The general lesson, which is why this is an ADR rather than a commit
73
+ message: an ADR that names a specific list is making a claim about the
74
+ code, not only about the design, and that claim needs checking against
75
+ the code before the ADR is accepted rather than while it is built.