janela 0.5.0 → 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: e79c9733a355bce01c020c4dd98b34599167b88aa86ecbd20ffe8fdd2ea8842b
4
- data.tar.gz: da0d442d1607b6347e5851ba136984da8009eac44ac8e453e2f8024a927016e0
3
+ metadata.gz: 2fd31f19d1b100c312826e8077aefc291873c0cc153410aa9c53199dcdf5ba18
4
+ data.tar.gz: ec551cd6c640de00e6ba13266ae2920945f68ad4a576c344e157830c9442563d
5
5
  SHA512:
6
- metadata.gz: 5ac5148b2676651bfdf2fabc046b960c475c3abd93ee4797b80cb869c733575933ce863360c455511cd12fb4b64c0a524ac5c21578ef271a34b4070e1df6ce21
7
- data.tar.gz: 4898a621a1ed7312b8a13f5d42908bfd0e8ec10360984318f090b0b7c12258a390bd9025c2112f7a16d9dfe033d0c403225c4e6659cdf18548a413a03663328d
6
+ metadata.gz: 692add30c86d3633d3144b6fe1fbb138246762e974f618430db2f290f1980a91bb55441104b2a1d183e67e6e16ca3ffd75aaab8d8e0156d77444950bc388dc21
7
+ data.tar.gz: 794ac1f1352ee3e30357d8ef2ff7bd2d0692895f932b3aec5d3acc5df55ab21e592397f111df855a120c547eea271273807e870d988a602a49c6575987ddaa9a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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
+
8
22
  ## [0.5.0] - 2026-09-18
9
23
 
10
24
  ### Added
@@ -138,6 +152,7 @@ First alpha, installed from GitHub for testing in a single host application.
138
152
  - Only models that declare a `janela` block are addressable over HTTP.
139
153
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
140
154
 
155
+ [0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.0
141
156
  [0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
142
157
  [0.4.1]: https://github.com/retail-tasker/janela/releases/tag/v0.4.1
143
158
  [0.4.0]: https://github.com/retail-tasker/janela/releases/tag/v0.4.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.5"
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):
@@ -194,6 +209,23 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
194
209
 
195
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.
196
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
+
197
229
  ### Frames
198
230
 
199
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):
@@ -296,6 +328,8 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
296
328
 
297
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.
298
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
+
299
333
  ### What Janela can draw
300
334
 
301
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):
@@ -450,7 +484,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
450
484
 
451
485
  ## Status
452
486
 
453
- **v0.5.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, 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).
454
488
 
455
489
  ## Development
456
490
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,60 @@ 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
+
15
69
  ## 0.4.1 to 0.5.0
16
70
 
17
71
  A filter is now bound to what kind of dimension it names (ADR 025). Most
@@ -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,112 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005, ADR 012, ADR 014, ADR 024
5
+ Superseded in part by: ADR 030
6
+ Triggers:
7
+ - changing how a pane's turbo frame is identified
8
+ - adding a parameter to a pane URL
9
+ - a host wanting a control over a pane's own settings
10
+ - a frame that does not update when it should
11
+ Topics: cross-filtering, panes, urls, host-integration
12
+ ---
13
+
14
+ # ADR 029: A Pane's Frame Is Identified by Who It Is, Not by What It Shows
15
+
16
+ ## Context
17
+
18
+ Issue #42 found that pointing a pane's turbo frame at a URL differing only
19
+ in `limit`, `granularity` or `as` makes Turbo fetch the response and then
20
+ silently do nothing: no render, no `frame-missing`, no console error, the
21
+ frame left showing the old numbers forever. Measured in a browser, not
22
+ inferred.
23
+
24
+ The cause is that `Query.turbo_frame_id` builds the id out of the query
25
+ itself, renderer, granularity and limit included, so the response comes
26
+ back wearing a different id from the frame that asked for it. Turbo has
27
+ nothing to reconcile and gives up quietly.
28
+
29
+ **The project has already solved this once, in the half that does not have
30
+ the bug.** A pane that is a record uses `Pane#turbo_frame_id`, which is
31
+ `janela_pane_#{id}`, and the comment on it says exactly why:
32
+
33
+ > The DOM id is the row, not the query it runs: two rows in one frame may
34
+ > show the same measure by the same dimension, and a fingerprint of the
35
+ > query would give them the same turbo frame for Turbo to replace.
36
+
37
+ That is ADR 014's reasoning, and it is right. A record-backed pane can
38
+ change its renderer, its granularity and its limit all day and its frame
39
+ id never moves, because the id says who the pane is rather than what it
40
+ is currently showing.
41
+
42
+ The helper path has no row to point at, so it fingerprints the query
43
+ instead. The fingerprint is not arbitrary: without it, two `janela_pane`
44
+ calls for the same measure by the same dimension would collide, and one
45
+ would replace the other. So the fingerprint solves a real problem and
46
+ creates this one.
47
+
48
+ The reason this has not bitten the library itself is worth stating: a
49
+ click changes filters, and filters are deliberately not in the id, so
50
+ every navigation Janela performs keeps the id stable. It is only a host
51
+ reaching for the URL's other documented parameters that falls in, and
52
+ what it gets is stale numbers with no error, which is the worst failure
53
+ this library has (ADR 003).
54
+
55
+ ## Decision
56
+
57
+ **`janela_pane` accepts an `id:`, and a pane given one is identified by
58
+ it rather than by a fingerprint of its query.**
59
+
60
+ 1. The fingerprint stays the default. It is correct for the common case,
61
+ a page of unlike panes with no controls over them, and it is what
62
+ keeps two alike panes apart.
63
+ 2. A host that wants to change a pane's settings in place names that
64
+ frame itself. The id is then stable by construction, because it comes
65
+ from the host rather than from the query, and Turbo reconciles
66
+ normally. This is the same move ADR 012 made for a record-backed pane,
67
+ made available to a pane that is not a record.
68
+ 3. The response wears the id the frame asked for rather than one derived
69
+ again from the query. This needs no new surface at all: Turbo already
70
+ sends the requesting frame's id in the `Turbo-Frame` header, and
71
+ turbo-rails exposes it as `turbo_frame_request_id`. So a pane rendered
72
+ into a frame request answers to the frame that asked, and a pane
73
+ rendered any other way keeps deriving its own id as it does now. The
74
+ pane URL does not grow a parameter, which was the first thing this
75
+ decision reached for and did not need.
76
+ 4. The failure is documented rather than left to be discovered. A host
77
+ that changes a pane's URL without naming its frame gets the silent
78
+ staleness described above, and the README says so where it describes
79
+ the pane URL's parameters.
80
+
81
+ ## What this turns down
82
+
83
+ **Taking renderer, granularity and limit out of the fingerprint.** It
84
+ would fix #42 and reintroduce the collision ADR 014 avoided: the gallery
85
+ renders the same measure by the same dimension as a bar and as a line on
86
+ one page, and those two must not share a frame. The fingerprint is doing
87
+ real work.
88
+
89
+ **Leaving it to every host to fetch and swap the frame themselves**, which
90
+ is what the demo's gallery does today and what proved the diagnosis. It
91
+ works, and it asks each host to write the same Stimulus controller and to
92
+ know a thing about Turbo's id matching that nothing told them. ADR 001
93
+ says ship the load-bearing 5%: a frame that updates when its URL changes
94
+ is inside that, and a host reimplementing frame reconciliation is not.
95
+ (Superseded in part by ADR 030: a frame does not update when its URL
96
+ changes, because `src` is not a channel a host can speak through. What is
97
+ inside the 5% is a pane that goes where it is asked to, which is the same
98
+ thing said correctly.)
99
+
100
+ ## Consequences
101
+
102
+ `janela_pane` grows one optional keyword argument, and a host that never
103
+ passes it sees no change at all. The demo's gallery control becomes
104
+ smaller, since the frame can be navigated rather than swapped by hand,
105
+ and that is the check on whether this decision is right: if the control
106
+ does not get simpler, the decision was wrong.
107
+
108
+ Two alike panes given the same `id` by a host would collide, which the
109
+ fingerprint prevented automatically. That is the cost of letting a host
110
+ name things, it is the same cost a host already carries for every DOM id
111
+ it writes, and the doctor is the place to notice it if it turns out to
112
+ happen.
@@ -0,0 +1,93 @@
1
+ ---
2
+ Date: 2026-09-18
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005, ADR 024, ADR 029
5
+ Supersedes: part of ADR 029
6
+ Triggers:
7
+ - a host changing what a pane shows from JavaScript
8
+ - writing to a turbo frame's src from anything but Turbo
9
+ - adding a data attribute the frame controller reads
10
+ - anything that would reintroduce reading intent out of the DOM
11
+ Topics: cross-filtering, panes, host-integration, javascript
12
+ ---
13
+
14
+ # ADR 030: A Pane's src Belongs to Turbo, So a Host Talks to the Frame
15
+
16
+ ## Context
17
+
18
+ ADR 029 gave a host a way to name a pane's frame so it could be
19
+ reconfigured in place, and said that "a frame that updates when its URL
20
+ changes is inside" the load-bearing core. Building it showed that is not
21
+ true, and the reason is a decision this project already made.
22
+
23
+ #33 established that a frame's `src` does not describe what that frame
24
+ is showing. Turbo writes `src` back onto a frame when a response lands,
25
+ including a late one for a request that has since been superseded. So
26
+ `janela--frame` keeps its own record of what was asked for and reverts
27
+ anything that does not match it:
28
+
29
+ > The request for what Janela last asked for wins, and any other is
30
+ > reverted. What was asked for is the only honest rule (#33).
31
+
32
+ That rule is right, and its consequence was not drawn at the time: if
33
+ the controller's own record is the only trustworthy statement of intent,
34
+ then `src` is no longer a channel a host can speak through. A host that
35
+ follows ADR 029, names a pane and writes `frame.src`, has its request
36
+ aborted and the frame put back, with no error and the old numbers still
37
+ on screen. That is the failure #42 was about, reached by following the
38
+ instructions written to fix #42.
39
+
40
+ There is no way to tell a host's `src` write from Turbo's. Any attempt,
41
+ a mutation observer, a flag, a heuristic on the attribute, re-infers
42
+ intent from the DOM, which is exactly what #33 removed because it
43
+ produced wrong numbers.
44
+
45
+ And the record is not one attribute. A pane also carries the base URL
46
+ the controller rebuilds from on the next click, so a host writing the
47
+ record by hand has to write two attributes in the right order, and
48
+ reapply the frame's current filters itself. Only the frame controller
49
+ knows those filters. The demo's own gallery control got that wrong:
50
+ reconfigure a pane while a filter is active and it refetches unfiltered
51
+ while every pane beside it stays filtered (#43).
52
+
53
+ ## Decision
54
+
55
+ **A host changes what a pane shows by asking `janela--frame`, and never
56
+ by writing `src` or a data attribute.**
57
+
58
+ The controller already does this for itself when the frame's filters
59
+ change. Making that reachable is extraction rather than new surface: one
60
+ way to ask a pane to go to a different query, which records what was
61
+ asked, reapplies the frame's filters and sets `src` in the order the
62
+ guard expects.
63
+
64
+ Three things follow.
65
+
66
+ 1. **The data attributes are private.** `janelaAsked` and `janelaSrc` are
67
+ how the controller remembers, not an interface. A host that writes
68
+ them is relying on something that may change.
69
+ 2. **A reconfigured pane stays cross-filtered.** Reapplying the frame's
70
+ current filters is part of repointing, not something a caller
71
+ remembers, because forgetting it shows numbers for the wrong filter
72
+ state and says nothing (ADR 003).
73
+ 3. **`id:` is still necessary.** ADR 029 stands: without a stable frame
74
+ id there is nothing to repoint. It is necessary and it was not
75
+ sufficient, which is what this records.
76
+
77
+ ## Consequences
78
+
79
+ ADR 029's claim that a frame updates when its URL changes is untrue as
80
+ written, and this supersedes that sentence rather than the decision. The
81
+ `id:` keyword and answering a frame request with the id Turbo sent are
82
+ both correct and stay.
83
+
84
+ A host reconfiguring a pane writes one call instead of three writes it
85
+ was never told about, and gets the filter behaviour right by default
86
+ rather than by knowing to. The demo's gallery control is the check, the
87
+ same way it was for ADR 029: it should get smaller again, and its filter
88
+ bug should disappear rather than be fixed separately.
89
+
90
+ The cost is one public method on a Stimulus controller, which is a
91
+ surface this project did not have before. It earns itself by removing a
92
+ class of silent wrongness rather than by adding capability, which is the
93
+ kind of addition ADR 001 leaves room for.
@@ -0,0 +1,110 @@
1
+ ---
2
+ Date: 2026-09-19
3
+ Status: Accepted
4
+ Related: ADR 002, ADR 013, ADR 014, ADR 025, ADR 028
5
+ Triggers:
6
+ - subclassing a model that declares a janela block
7
+ - changing what is addressable over HTTP
8
+ - changing how the registry is populated
9
+ - adding to the Ransack allowlist a model gets from Janela
10
+ Topics: configuration, urls, security, scope
11
+ ---
12
+
13
+ # ADR 031: A Subclass Inherits the Dashboard Its Parent Declared
14
+
15
+ ## Context
16
+
17
+ `janela` stores its definition in a plain class instance variable, so a
18
+ subclass of a model that declares one gets `nil` from `.janela` and is
19
+ not in the registry (#11).
20
+
21
+ Reproducing it found something the issue did not say. The subclass does
22
+ inherit the Ransack allowlist, because
23
+ `define_janela_ransack_allowlist` defines singleton methods on the
24
+ parent and singleton methods inherit down the singleton class chain:
25
+
26
+ ```
27
+ subclass .janela: nil
28
+ subclass ransackable: ["status", "channel", "placed_on"]
29
+ ```
30
+
31
+ So a subclass is already half declared: filterable on the parent's
32
+ dimensions, with no definition behind it saying what those dimensions
33
+ are. Neither half was decided. One was written and the other happened.
34
+
35
+ The instinct is to walk the ancestors in `janela`, which fixes `.janela`
36
+ and does not fix the bug. What a host wants from an STI subclass is a
37
+ dashboard of it, and that needs the subclass registered:
38
+
39
+ ```ruby
40
+ janela_pane PaidOrder, :revenue # src is /paid_orders/revenue
41
+ ```
42
+
43
+ An inherited definition with no registration renders that pane and then
44
+ 404s it. That is worse than today, because today the failure is loud at
45
+ the call site and that one is a dead pane on a page.
46
+
47
+ So the question is not whether the definition inherits. It is whether
48
+ being a subclass makes a model addressable, and `lib/janela.rb` states
49
+ the current answer plainly:
50
+
51
+ > Only models that declare a janela block are addressable over HTTP
52
+
53
+ ## Decision
54
+
55
+ **A subclass inherits its parent's definition and is addressable on its
56
+ own route key. Declaring is what a family of classes does once.**
57
+
58
+ The argument that settles it is about data rather than URLs. An STI
59
+ subclass is a subset of its parent's rows. If the parent is addressable,
60
+ the subclass reveals no row the parent does not already total, and it is
61
+ read through the same host scope as everything else (ADR 014). So this
62
+ adds URL surface and no data surface, which is a much smaller thing than
63
+ the rule above makes it sound.
64
+
65
+ Three things follow.
66
+
67
+ 1. **The halves agree.** The definition inherits, the registration
68
+ inherits, and the Ransack allowlist keeps inheriting as it already
69
+ does. A subclass is either a Janela model or it is not, rather than
70
+ being one in the part nobody chose.
71
+ 2. **STI scoping is ActiveRecord's, not Janela's.** A definition whose
72
+ model is the subclass runs its query on that class and ActiveRecord
73
+ adds the `type` condition itself. Janela learns nothing about STI,
74
+ which is the right amount for it to know.
75
+ 3. **A subclass may still declare its own block**, and then it has its
76
+ own definition rather than its parent's, which is how a subclass says
77
+ its dashboard is different.
78
+
79
+ Registration cannot happen where declaration happens, because the
80
+ subclass does not exist when the parent declares. It happens when the
81
+ subclass is created.
82
+
83
+ ## What this turns down
84
+
85
+ **Inheriting nothing, and documenting that each subclass declares its
86
+ own.** It is the most conservative reading and it asks a host with five
87
+ STI types to retype the same measures and dimensions five times. ADR 002
88
+ spent Ransack's familiarity on the host deliberately; spending their
89
+ typing on a class hierarchy Rails already models is a worse trade.
90
+
91
+ **Inheriting the definition without registering.** Rejected above: a
92
+ helper that renders a pane which cannot load.
93
+
94
+ ## Consequences
95
+
96
+ The honest cost is not security, it is noise. `Janela.definitions` feeds
97
+ the form that offers a choice of model when an analyst adds a pane
98
+ (ADR 012), and a host with a dozen STI types will see a dozen entries
99
+ where it expected one. That is a real cost and it is the thing to watch:
100
+ if it becomes the complaint, the answer is a way for a family to say
101
+ which of its classes are worth offering, and that is a later decision
102
+ rather than a setting invented now.
103
+
104
+ The check on whether this decision is right is what a host writes. If
105
+ adding a dashboard for an STI subclass still takes anything beyond
106
+ creating the class, the decision did not deliver.
107
+
108
+ The demo has no STI model, so proving this takes one: a `type` column on
109
+ a table and a subclass in the dummy. That is a fixture, and adding it is
110
+ part of the work rather than a reason to avoid it.
@@ -29,7 +29,7 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
29
29
  | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025 |
30
30
  | **Layouts & views** | 011, 012, 016, 018, 020, 027 |
31
31
  | **CSS & styling** | 016, 018, 023, 026, 027 |
32
- | **Frames, panes & persistence** | 012, 013, 014, 019 |
32
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030 |
33
33
  | **Naming rule** | 014, 023 |
34
34
  | **JavaScript delivery & charts** | 004, 006, 026 |
35
35
  | **Time dimensions** | 006, 025 |
@@ -38,7 +38,7 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
38
38
  | **AI agents & guidance** | 010, 015, 021 |
39
39
  | **Releases & upgrades** | 015, 021 |
40
40
  | **Accessibility & keyboard** | 024 |
41
- | **Security** | 003, 025, 028 |
41
+ | **Security** | 003, 025, 028, 031 |
42
42
  | **Testing** | 003 |
43
43
 
44
44
  ## Chronological
@@ -73,7 +73,10 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
73
73
  | 026 | A Renderer Is the Seam, and HTML Comes First | 2026-09-18 | Accepted |
74
74
  | 027 | The Gallery Is a Host Page, Built from the Gem's Helpers | 2026-09-18 | Accepted |
75
75
  | 028 | The Predicate List ADR 025 Named Was Not Quite Right | 2026-09-18 | Accepted |
76
+ | 029 | A Pane's Frame Is Identified by Who It Is, Not by What It Shows | 2026-09-18 | Accepted |
77
+ | 030 | A Pane's src Belongs to Turbo, So a Host Talks to the Frame | 2026-09-18 | Accepted |
78
+ | 031 | A Subclass Inherits the Dashboard Its Parent Declared | 2026-09-19 | Accepted |
76
79
 
77
80
  ## Next number
78
81
 
79
- Next ADR: 029
82
+ Next ADR: 032
@@ -6,10 +6,20 @@ module Janela
6
6
 
7
7
  attr_reader :model, :measures, :dimensions
8
8
 
9
- def initialize(model)
9
+ def initialize(model, &block)
10
10
  @model = model
11
11
  @measures = {}
12
12
  @dimensions = {}
13
+ @block = block
14
+ instance_eval(&block) if block
15
+ end
16
+
17
+ # The same declaration read against another model, which is how a subclass
18
+ # inherits a dashboard: its measures and dimensions are its parent's, and
19
+ # the queries they run are its own, because ActiveRecord adds the type
20
+ # condition to a relation on the subclass (ADR 031).
21
+ def for(model)
22
+ self.class.new(model, &@block)
13
23
  end
14
24
 
15
25
  def measure(name, **aggregate)
data/lib/janela/model.rb CHANGED
@@ -1,25 +1,61 @@
1
1
  module Janela
2
2
  module Model
3
+ # Dimensions are the only things Janela filters on, so they are the
4
+ # Ransack allowlist. This asks for the definition when it is called rather
5
+ # than closing over one, so a subclass answers with the definition it
6
+ # reports, whether that is its parent's or one it declared itself. The two
7
+ # halves cannot then disagree, which is what they did before ADR 031: a
8
+ # subclass inherited this list and reported no definition behind it.
9
+ module RansackAllowlist
10
+ def ransackable_attributes(_auth_object = nil)
11
+ janela.ransackable_attributes
12
+ end
13
+
14
+ def ransackable_associations(_auth_object = nil)
15
+ janela.ransackable_associations
16
+ end
17
+ end
18
+
3
19
  def janela(&block)
4
- return @janela_definition unless block
20
+ return @janela_definition ||= inherited_janela_definition unless block
5
21
 
6
- @janela_definition = Definition.new(self)
7
- @janela_definition.instance_eval(&block)
22
+ @janela_definition = Definition.new(self, &block)
8
23
  define_janela_ransack_allowlist
9
24
  Janela.register(self)
10
25
  @janela_definition
11
26
  end
12
27
 
28
+ # A subclass cannot be registered where its parent declares, because it
29
+ # does not exist yet, so it registers as it is created (ADR 031). An
30
+ # anonymous class has no route key to be addressed by; naming it is the
31
+ # host's move and declaring on it is the host's other one.
32
+ def inherited(subclass)
33
+ super
34
+ Janela.register_subclass(subclass) if subclass.name && janela
35
+ end
36
+
13
37
  private
14
- # Dimensions are the only things Janela filters on, so they are the
15
- # Ransack allowlist. A model that already declares its own allowlist
16
- # keeps it.
38
+ # What a subclass inherits is the declaration, not the definition
39
+ # object. A definition holds the model it queries, so a subclass handed
40
+ # its parent's would report the right dashboard and then total the
41
+ # parent's rows behind it.
42
+ def inherited_janela_definition
43
+ superclass.janela&.for(self) if superclass.respond_to?(:janela)
44
+ end
45
+
46
+ # A model that already answers for itself keeps its answer, whether it
47
+ # said so here or on a class above. Ransack's own default lives on
48
+ # ActiveRecord::Base, so anything nearer than that was somebody's
49
+ # decision and is not ours to replace.
17
50
  def define_janela_ransack_allowlist
18
- return if singleton_class.method_defined?(:ransackable_attributes, false)
51
+ return if janela_ransack_allowlist_answered?
52
+
53
+ extend RansackAllowlist
54
+ end
19
55
 
20
- definition = @janela_definition
21
- define_singleton_method(:ransackable_attributes) { |_auth_object = nil| definition.ransackable_attributes }
22
- define_singleton_method(:ransackable_associations) { |_auth_object = nil| definition.ransackable_associations }
56
+ def janela_ransack_allowlist_answered?
57
+ owner = singleton_class.instance_method(:ransackable_attributes).owner
58
+ !ActiveRecord::Base.singleton_class.ancestors.include?(owner)
23
59
  end
24
60
  end
25
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -37,9 +37,10 @@ module Janela
37
37
  # is for (ADR 021).
38
38
  mattr_accessor :silenced_checks, default: []
39
39
 
40
- # Only models that declare a janela block are addressable over HTTP, keyed by
41
- # the route key that appears in pane URLs (orders, sales_orders). Names are
42
- # stored rather than classes so a reloaded model leaves nothing stale behind.
40
+ # A model that declares a janela block is addressable over HTTP, and so is a
41
+ # subclass of one, keyed by the route key that appears in pane URLs (orders,
42
+ # sales_orders). Names are stored rather than classes so a reloaded model
43
+ # leaves nothing stale behind.
43
44
  def self.registry
44
45
  @registry ||= {}
45
46
  end
@@ -48,6 +49,18 @@ module Janela
48
49
  registry[model.model_name.route_key] = model.name
49
50
  end
50
51
 
52
+ # A subclass registers itself as it is created (ADR 031), so unlike a
53
+ # declaration it is not a host writing a line of code. It never takes a
54
+ # route key another class already holds: a host that gives a subclass its
55
+ # parent's model_name, so the two share a route and a form, would otherwise
56
+ # find the parent's URL answering with a subset of its rows.
57
+ def self.register_subclass(model)
58
+ route_key = model.model_name.route_key
59
+ return if registry.key?(route_key) && registry[route_key] != model.name
60
+
61
+ register(model)
62
+ end
63
+
51
64
  # Every model that declares a janela block, for a form that offers a choice
52
65
  # of them. Eager loading first, because a model nobody has referenced yet has
53
66
  # not registered. A name that no longer resolves is left out rather than
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -164,6 +164,9 @@ files:
164
164
  - docs/decisions/026-a-renderer-is-the-seam-and-html-comes-first.md
165
165
  - docs/decisions/027-the-gallery-is-a-host-page.md
166
166
  - docs/decisions/028-the-predicate-list-adr-025-named.md
167
+ - docs/decisions/029-a-panes-frame-is-identified-by-who-it-is.md
168
+ - docs/decisions/030-a-panes-src-belongs-to-turbo.md
169
+ - docs/decisions/031-a-subclass-inherits-the-dashboard.md
167
170
  - docs/decisions/INDEX.md
168
171
  - docs/multi-tenancy.md
169
172
  - docs/naming.md
@@ -187,12 +190,14 @@ metadata:
187
190
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
188
191
  rubygems_mfa_required: 'true'
189
192
  post_install_message: |
190
- Janela 0.5.0 bounds what a filter predicate can ask for (ADR 025). Most
191
- applications need do nothing: a click already writes eq or in, both still
192
- allowed. You need to act only if you pass a filter yourself using _cont,
193
- _matches, _start, _end or another predicate outside a dimension's
194
- allowlist, which now raises Janela::BadRequest instead of being quietly
195
- answered.
193
+ Janela 0.6.0 changes how single table inheritance is handled (ADR 031).
194
+ You need to act only if your application has STI subclasses under a model
195
+ that declares a janela block. Every named subclass of one is now
196
+ registered and addressable on its own route key, and Janela.definitions
197
+ returns one entry per subclass where a form offering a choice of model
198
+ previously showed one.
199
+
200
+ Everyone else: nothing to do.
196
201
 
197
202
  Steps: UPGRADING.md in this gem, or
198
203
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md