janela 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +51 -2
- data/UPGRADING.md +107 -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/026-a-renderer-is-the-seam-and-html-comes-first.md +93 -0
- data/docs/decisions/027-the-gallery-is-a-host-page.md +87 -0
- data/docs/decisions/028-the-predicate-list-adr-025-named.md +75 -0
- 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 +13 -7
- data/lib/janela/definition.rb +49 -3
- data/lib/janela/dimension.rb +13 -0
- data/lib/janela/doctor.rb +29 -1
- data/lib/janela/model.rb +46 -10
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +31 -3
- metadata +21 -1
|
@@ -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
|
@@ -27,18 +27,18 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
27
27
|
| **Authorisation** | 002, 003, 004, 009, 017, 019, 022 |
|
|
28
28
|
| **Performance & storage** | 007, 017, 025 |
|
|
29
29
|
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025 |
|
|
30
|
-
| **Layouts & views** | 011, 012, 016, 018, 020 |
|
|
31
|
-
| **CSS & styling** | 016, 018, 023 |
|
|
32
|
-
| **Frames, panes & persistence** | 012, 013, 014, 019 |
|
|
30
|
+
| **Layouts & views** | 011, 012, 016, 018, 020, 027 |
|
|
31
|
+
| **CSS & styling** | 016, 018, 023, 026, 027 |
|
|
32
|
+
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030 |
|
|
33
33
|
| **Naming rule** | 014, 023 |
|
|
34
|
-
| **JavaScript delivery & charts** | 004, 006 |
|
|
34
|
+
| **JavaScript delivery & charts** | 004, 006, 026 |
|
|
35
35
|
| **Time dimensions** | 006, 025 |
|
|
36
36
|
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025 |
|
|
37
|
-
| **Snapshots & publishing** | 009, 020 |
|
|
37
|
+
| **Snapshots & publishing** | 009, 020, 028 |
|
|
38
38
|
| **AI agents & guidance** | 010, 015, 021 |
|
|
39
39
|
| **Releases & upgrades** | 015, 021 |
|
|
40
40
|
| **Accessibility & keyboard** | 024 |
|
|
41
|
-
| **Security** | 003, 025 |
|
|
41
|
+
| **Security** | 003, 025, 028, 031 |
|
|
42
42
|
| **Testing** | 003 |
|
|
43
43
|
|
|
44
44
|
## Chronological
|
|
@@ -70,7 +70,13 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
70
70
|
| 023 | Vitral Is a Theme, Not the Stylesheet | 2026-09-16 | Accepted |
|
|
71
71
|
| 024 | Selecting More Than One Value | 2026-09-16 | Accepted |
|
|
72
72
|
| 025 | Janela Bounds What a Filter Can Ask For | 2026-09-17 | Accepted |
|
|
73
|
+
| 026 | A Renderer Is the Seam, and HTML Comes First | 2026-09-18 | Accepted |
|
|
74
|
+
| 027 | The Gallery Is a Host Page, Built from the Gem's Helpers | 2026-09-18 | Accepted |
|
|
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 |
|
|
73
79
|
|
|
74
80
|
## Next number
|
|
75
81
|
|
|
76
|
-
Next ADR:
|
|
82
|
+
Next ADR: 032
|
data/lib/janela/definition.rb
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
class Definition
|
|
3
|
+
# ADR 007's existing ceiling on a limit, reused by ADR 025 as the default
|
|
4
|
+
# applied when a host asks for none, and as the bound on one filter's values.
|
|
5
|
+
MAXIMUM = 1000
|
|
6
|
+
|
|
3
7
|
attr_reader :model, :measures, :dimensions
|
|
4
8
|
|
|
5
|
-
def initialize(model)
|
|
9
|
+
def initialize(model, &block)
|
|
6
10
|
@model = model
|
|
7
11
|
@measures = {}
|
|
8
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)
|
|
9
23
|
end
|
|
10
24
|
|
|
11
25
|
def measure(name, **aggregate)
|
|
@@ -35,7 +49,7 @@ module Janela
|
|
|
35
49
|
measure.apply(buckets).transform_keys { |bucket| dimension.label(bucket, granularity) }
|
|
36
50
|
else
|
|
37
51
|
grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
|
|
38
|
-
grouped = grouped.limit(limit!(limit)
|
|
52
|
+
grouped = grouped.limit(limit ? limit!(limit) : MAXIMUM)
|
|
39
53
|
measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
|
|
40
54
|
end
|
|
41
55
|
end
|
|
@@ -64,7 +78,7 @@ module Janela
|
|
|
64
78
|
|
|
65
79
|
def limit!(value)
|
|
66
80
|
limit = Integer(value, exception: false)
|
|
67
|
-
raise BadRequest, "limit must be a whole number from 1 to
|
|
81
|
+
raise BadRequest, "limit must be a whole number from 1 to #{MAXIMUM}, got #{value.inspect}" unless limit&.between?(1, MAXIMUM)
|
|
68
82
|
limit
|
|
69
83
|
end
|
|
70
84
|
|
|
@@ -82,6 +96,8 @@ module Janela
|
|
|
82
96
|
|
|
83
97
|
search = relation.ransack(params)
|
|
84
98
|
reject_dropped_filters!(search, params)
|
|
99
|
+
reject_disallowed_predicates!(search)
|
|
100
|
+
reject_oversized_filters!(search)
|
|
85
101
|
search.result
|
|
86
102
|
end
|
|
87
103
|
|
|
@@ -95,5 +111,35 @@ module Janela
|
|
|
95
111
|
raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
|
|
96
112
|
"Declare a janela dimension, or add it to ransackable_attributes."
|
|
97
113
|
end
|
|
114
|
+
|
|
115
|
+
# An allowed attribute still reaches every predicate Ransack knows,
|
|
116
|
+
# including _matches, an arbitrary LIKE pattern (ADR 025). A dashboard
|
|
117
|
+
# asks in only the predicates its kind of dimension needs.
|
|
118
|
+
def reject_disallowed_predicates!(search)
|
|
119
|
+
by_ransack_name = dimensions.values.index_by(&:ransack_name)
|
|
120
|
+
|
|
121
|
+
search.conditions.each do |condition|
|
|
122
|
+
condition.attributes.each do |attribute|
|
|
123
|
+
dimension = by_ransack_name.fetch(attribute.name)
|
|
124
|
+
next if dimension.allowed_predicates.include?(condition.predicate_name)
|
|
125
|
+
|
|
126
|
+
allowed = dimension.allowed_predicates.map { |predicate| "#{attribute.name}_#{predicate}" }
|
|
127
|
+
raise BadRequest, "#{model} does not allow #{attribute.name}_#{condition.predicate_name}. " \
|
|
128
|
+
"This dimension allows #{allowed.join(', ')}."
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A click writes _in (ADR 024), which takes an array Ransack does not
|
|
134
|
+
# otherwise bound. 5001 values answered rather than being refused.
|
|
135
|
+
def reject_oversized_filters!(search)
|
|
136
|
+
search.conditions.each do |condition|
|
|
137
|
+
next unless condition.predicate.wants_array
|
|
138
|
+
next if condition.values.size <= MAXIMUM
|
|
139
|
+
|
|
140
|
+
raise BadRequest, "#{model} does not allow a filter to carry more than #{MAXIMUM} values, " \
|
|
141
|
+
"got #{condition.values.size} for #{condition.attributes.map(&:name).join(', ')}."
|
|
142
|
+
end
|
|
143
|
+
end
|
|
98
144
|
end
|
|
99
145
|
end
|
data/lib/janela/dimension.rb
CHANGED
|
@@ -2,6 +2,14 @@ module Janela
|
|
|
2
2
|
class Dimension
|
|
3
3
|
GRANULARITIES = %w[hour day week month quarter year].freeze
|
|
4
4
|
|
|
5
|
+
# What a click produces (ADR 024), plus not_null: excluding the null
|
|
6
|
+
# group is documented, tested behaviour ADR 025 did not measure and would
|
|
7
|
+
# otherwise silently break.
|
|
8
|
+
CATEGORICAL_PREDICATES = %w[eq in null not_null].freeze
|
|
9
|
+
|
|
10
|
+
# A time dimension additionally narrows a range (ADR 006).
|
|
11
|
+
TIME_PREDICATES = (CATEGORICAL_PREDICATES + %w[gteq gt lteq lt]).freeze
|
|
12
|
+
|
|
5
13
|
# A group of rows whose dimension is null. Labelled rather than blank, and
|
|
6
14
|
# filtered with Ransack's null predicate rather than an empty string.
|
|
7
15
|
NONE = "(none)".freeze
|
|
@@ -41,6 +49,11 @@ module Janela
|
|
|
41
49
|
!granularity.nil?
|
|
42
50
|
end
|
|
43
51
|
|
|
52
|
+
# Which Ransack predicates a filter on this dimension may use (ADR 025).
|
|
53
|
+
def allowed_predicates
|
|
54
|
+
time? ? TIME_PREDICATES : CATEGORICAL_PREDICATES
|
|
55
|
+
end
|
|
56
|
+
|
|
44
57
|
def attribute
|
|
45
58
|
klass.arel_table[column]
|
|
46
59
|
end
|
data/lib/janela/doctor.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Janela
|
|
|
11
11
|
# silences a check by that name (ADR 021).
|
|
12
12
|
CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
|
|
13
13
|
through_dimensions_without_an_allowlist frames_nobody_will_own
|
|
14
|
-
unauthenticated_endpoints].freeze
|
|
14
|
+
unauthenticated_endpoints hardcoded_disallowed_predicates].freeze
|
|
15
15
|
|
|
16
16
|
# Identifiers a previous version of Janela used, and what replaced them.
|
|
17
17
|
RENAMED = {
|
|
@@ -153,6 +153,34 @@ module Janela
|
|
|
153
153
|
end
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
# A filter read from a URL param is runtime state the doctor cannot see,
|
|
157
|
+
# but one written into the host's own Ruby is source like any other
|
|
158
|
+
# identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
|
|
159
|
+
def hardcoded_disallowed_predicates
|
|
160
|
+
janela_models.flat_map do |model|
|
|
161
|
+
model.janela.dimensions.values.flat_map { |dimension| disallowed_uses(model, dimension) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def disallowed_uses(model, dimension)
|
|
166
|
+
pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
|
|
167
|
+
source_files.flat_map do |file|
|
|
168
|
+
content = file.read
|
|
169
|
+
content.scan(pattern).flatten.uniq.filter_map do |candidate|
|
|
170
|
+
predicate = Ransack::Predicate.detect_from_string(candidate.dup)
|
|
171
|
+
next unless predicate
|
|
172
|
+
next if dimension.allowed_predicates.include?(predicate)
|
|
173
|
+
|
|
174
|
+
key = "#{dimension.ransack_name}_#{predicate}"
|
|
175
|
+
allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
|
|
176
|
+
Finding.new(severity: :error,
|
|
177
|
+
summary: "#{model} does not allow #{key}",
|
|
178
|
+
detail: " #{file.relative_path_from(@root)} filters #{model} on #{key}, which Janela now " \
|
|
179
|
+
"refuses (ADR 025). Allowed here: #{allowed.join(', ')}.")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
156
184
|
# A host whose policy filters frames by owner, but which never tells
|
|
157
185
|
# Janela what owns a new one, creates frames its own scope then hides.
|
|
158
186
|
# The failure is silent, and a typo in the method name looks the same as
|
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
|
|
@@ -66,4 +79,19 @@ module Janela
|
|
|
66
79
|
|
|
67
80
|
class_name.constantize.janela
|
|
68
81
|
end
|
|
82
|
+
|
|
83
|
+
# What Janela can draw, so a gallery asks rather than reaching into
|
|
84
|
+
# Janela::Query::RENDERERS, Janela::Dimension::GRANULARITIES or
|
|
85
|
+
# Janela::Pane::OFFERED_LIMITS itself (ADR 027, #39).
|
|
86
|
+
def self.renderers
|
|
87
|
+
Query::RENDERERS
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.granularities
|
|
91
|
+
Dimension::GRANULARITIES
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.offered_limits
|
|
95
|
+
Pane::OFFERED_LIMITS
|
|
96
|
+
end
|
|
69
97
|
end
|