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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +36 -2
- data/UPGRADING.md +54 -0
- data/app/assets/javascripts/janela/frame_controller.js +38 -3
- data/app/helpers/janela/frames_helper.rb +14 -3
- data/app/views/janela/frames/_frame.html.erb +1 -1
- data/app/views/janela/queries/show.html.erb +6 -1
- data/docs/decisions/029-a-panes-frame-is-identified-by-who-it-is.md +112 -0
- data/docs/decisions/030-a-panes-src-belongs-to-turbo.md +93 -0
- data/docs/decisions/031-a-subclass-inherits-the-dashboard.md +110 -0
- data/docs/decisions/INDEX.md +6 -3
- data/lib/janela/definition.rb +11 -1
- data/lib/janela/model.rb +46 -10
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +16 -3
- metadata +12 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fd31f19d1b100c312826e8077aefc291873c0cc153410aa9c53199dcdf5ba18
|
|
4
|
+
data.tar.gz: ec551cd6c640de00e6ba13266ae2920945f68ad4a576c344e157830c9442563d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -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:
|
|
82
|
+
Next ADR: 032
|
data/lib/janela/definition.rb
CHANGED
|
@@ -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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
51
|
+
return if janela_ransack_allowlist_answered?
|
|
52
|
+
|
|
53
|
+
extend RansackAllowlist
|
|
54
|
+
end
|
|
19
55
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
data/lib/janela/version.rb
CHANGED
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
|
-
#
|
|
41
|
-
# the route key that appears in pane URLs (orders,
|
|
42
|
-
# stored rather than classes so a reloaded model
|
|
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.
|
|
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.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|