janela 0.4.0 → 0.5.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 +18 -0
- data/README.md +17 -2
- data/UPGRADING.md +53 -0
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +1 -0
- data/docs/decisions/025-janela-bounds-what-a-filter-can-ask-for.md +131 -0
- 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/INDEX.md +22 -17
- data/lib/janela/definition.rb +38 -2
- data/lib/janela/dimension.rb +13 -0
- data/lib/janela/doctor.rb +29 -1
- data/lib/janela/host_routes.rb +6 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +15 -0
- metadata +13 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e79c9733a355bce01c020c4dd98b34599167b88aa86ecbd20ffe8fdd2ea8842b
|
|
4
|
+
data.tar.gz: da0d442d1607b6347e5851ba136984da8009eac44ac8e453e2f8024a927016e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ac5148b2676651bfdf2fabc046b960c475c3abd93ee4797b80cb869c733575933ce863360c455511cd12fb4b64c0a524ac5c21578ef271a34b4070e1df6ce21
|
|
7
|
+
data.tar.gz: 4898a621a1ed7312b8a13f5d42908bfd0e8ec10360984318f090b0b7c12258a390bd9025c2112f7a16d9dfe033d0c403225c4e6659cdf18548a413a03663328d
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.5.0] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Janela.renderers`, `Janela.granularities` and `Janela.offered_limits`, alongside the existing `Janela.definitions`, so a gallery of what Janela can draw asks the gem rather than reading `Janela::Query::RENDERERS`, `Janela::Dimension::GRANULARITIES` or `Janela::Pane::OFFERED_LIMITS` directly. `test/dummy`'s `/gallery` is the reference page ADR 027 describes, built from exactly that surface plus `janela_pane`: a live pane per renderer per model, with the declaration that produced it beside it. A renderer a model cannot demonstrate, for want of a suitable dimension, is shown as unavailable rather than hidden, and a host with no `janela` models yet gets an explanation rather than a blank page (ADR 026, ADR 027, #39).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Breaking.** A filter is bound to what kind of dimension it names rather than to every predicate Ransack knows. Every one of Ransack's 62 predicates worked on any allowed attribute, `_matches` sharpest among them: an arbitrary `LIKE` pattern, a leading wildcard scan away, on a page a host had already authorised someone to read. A categorical dimension now takes `eq`, `in`, `null` and `not_null`; a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, which is exactly what a click produces (ADR 024) plus the range narrowing ADR 006 already documented. Anything else raises `Janela::BadRequest` naming the filter and what the dimension allows, rather than Ransack silently dropping it and a pane showing a number nobody asked for. A grouped query with no `limit` now gets one anyway, at the existing ceiling of 1000 (ADR 007); a single filter may carry at most 1000 values. `rails janela:doctor` finds a hardcoded filter that used a predicate no longer allowed, when it is written in the host's own source rather than read from a URL (ADR 025, #8).
|
|
17
|
+
|
|
18
|
+
## [0.4.1] - 2026-09-17
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `Janela::HostRoutes.forwarded` could return an empty set. Route loading is lazy, so calling it before anything had drawn the host's routes, such as from a host's own initializer, silently forwarded nothing rather than raising. It now draws the routes first if they are not already loaded (#37).
|
|
23
|
+
|
|
8
24
|
## [0.4.0] - 2026-09-17
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -122,6 +138,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
122
138
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
123
139
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
124
140
|
|
|
141
|
+
[0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
|
|
142
|
+
[0.4.1]: https://github.com/retail-tasker/janela/releases/tag/v0.4.1
|
|
125
143
|
[0.4.0]: https://github.com/retail-tasker/janela/releases/tag/v0.4.0
|
|
126
144
|
[0.3.0]: https://github.com/retail-tasker/janela/releases/tag/v0.3.0
|
|
127
145
|
[0.2.1]: https://github.com/retail-tasker/janela/releases/tag/v0.2.1
|
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.5"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```ruby
|
|
@@ -166,6 +166,8 @@ Scope a query to whatever the current user is allowed to see with `on:`:
|
|
|
166
166
|
Order.janela.query(:revenue, by: :status, on: policy_scope(Order))
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
A filter is bound to what kind of dimension it names, not to every predicate Ransack knows (ADR 025). A categorical dimension takes `eq`, `in`, `null` and `not_null`; a time dimension additionally takes `gteq`, `gt`, `lteq` and `lt`, so a range still narrows it. Anything else, such as `_cont` or `_matches`, raises `Janela::BadRequest` naming what is allowed. A grouped query with no `limit` gets one anyway, capped at 1000, and a single filter may carry at most 1000 values.
|
|
170
|
+
|
|
169
171
|
Declaring a dimension makes that attribute filterable, so Janela defines the model's Ransack allowlist for you. A model that already defines its own keeps it. A `through:` dimension also needs the **associated** model to allow the attribute, because Ransack's allowlist is per-class:
|
|
170
172
|
|
|
171
173
|
```ruby
|
|
@@ -294,6 +296,19 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
|
|
|
294
296
|
|
|
295
297
|
The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the grammar, ADR 011 the layout it renders in.
|
|
296
298
|
|
|
299
|
+
### What Janela can draw
|
|
300
|
+
|
|
301
|
+
`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):
|
|
302
|
+
|
|
303
|
+
```erb
|
|
304
|
+
<% Janela.definitions.each do |definition| %>
|
|
305
|
+
<h2><%= definition.model.model_name.human %></h2>
|
|
306
|
+
<%= janela_pane definition.model, definition.measures.keys.first %>
|
|
307
|
+
<% end %>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`test/dummy`'s `/gallery` is the reference: every renderer, per model, with the declaration that produced it beside it. A renderer a model cannot demonstrate, for want of a suitable dimension, shows as unavailable rather than disappearing, and a model with no `janela` block anywhere yet gets told so rather than an empty page.
|
|
311
|
+
|
|
297
312
|
### Snapshots
|
|
298
313
|
|
|
299
314
|
A snapshot freezes the results of several panes at one instant, under one set of filters, so an audience sees exactly what was signed off while the live dashboard stays editable. Results are stored, not HTML; a stored pane can still be drawn as a table or a chart. It needs the same migrations frames do.
|
|
@@ -435,7 +450,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
|
|
|
435
450
|
|
|
436
451
|
## Status
|
|
437
452
|
|
|
438
|
-
**v0.
|
|
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).
|
|
439
454
|
|
|
440
455
|
## Development
|
|
441
456
|
|
data/UPGRADING.md
CHANGED
|
@@ -12,6 +12,59 @@ bin/rails janela:doctor
|
|
|
12
12
|
|
|
13
13
|
It reads your application and lists what still needs changing.
|
|
14
14
|
|
|
15
|
+
## 0.4.1 to 0.5.0
|
|
16
|
+
|
|
17
|
+
A filter is now bound to what kind of dimension it names (ADR 025). Most
|
|
18
|
+
hosts do nothing: a click already writes `_eq` or `_in`, both still
|
|
19
|
+
allowed. Three things need you only if you have gone further than that.
|
|
20
|
+
|
|
21
|
+
**1. A predicate outside a dimension's allowlist now raises.**
|
|
22
|
+
|
|
23
|
+
A categorical dimension (`dimension :status`) allows `eq`, `in`, `null`
|
|
24
|
+
and `not_null`. A time dimension (`dimension :placed_on, granularity:
|
|
25
|
+
:day`) additionally allows `gteq`, `gt`, `lteq` and `lt`. Anything else,
|
|
26
|
+
most often `_cont`, `_matches`, `_start` or `_end`, now raises
|
|
27
|
+
`Janela::BadRequest` instead of quietly filtering:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
- Order.janela.query(:revenue, where: { status_cont: params[:q] })
|
|
31
|
+
+ Order.janela.query(:revenue, where: { status_eq: params[:q] })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If you need a real pattern search, pass a relation you have already
|
|
35
|
+
filtered through `on:`, where you write the condition yourself, under
|
|
36
|
+
your own authorisation, rather than accepting one from a URL:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
Order.janela.query(:revenue, on: Order.where("status LIKE ?", "%#{params[:q]}%"))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run `bin/rails janela:doctor` after upgrading: it finds a disallowed
|
|
43
|
+
predicate hardcoded in your own source, the same way it finds a stale
|
|
44
|
+
identifier. It cannot find one built from a URL param at request time;
|
|
45
|
+
there is nothing in your source to read.
|
|
46
|
+
|
|
47
|
+
**2. A grouped query with no `limit` now gets one anyway.**
|
|
48
|
+
|
|
49
|
+
A breakdown over more than 1000 values used to return all of them and
|
|
50
|
+
now returns the top 1000, ordered by the measure (ADR 007). Pass
|
|
51
|
+
`limit:` yourself if you want a different cut:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
- Order.janela.query(:revenue, by: :customer)
|
|
55
|
+
+ Order.janela.query(:revenue, by: :customer, limit: 1000) # unchanged
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Nothing to do if your dashboard already has fewer than 1000 groups, or
|
|
59
|
+
already passes `limit:`.
|
|
60
|
+
|
|
61
|
+
**3. A single filter may not carry more than 1000 values.**
|
|
62
|
+
|
|
63
|
+
`status_in` (or any other array predicate) with more than 1000 values
|
|
64
|
+
now raises `Janela::BadRequest` instead of being answered. Nothing to
|
|
65
|
+
do unless you build a filter with more values than that yourself; a
|
|
66
|
+
click never does.
|
|
67
|
+
|
|
15
68
|
## 0.3.0 to 0.4.0
|
|
16
69
|
|
|
17
70
|
Selecting more than one value in a dimension (ADR 024). Most hosts do
|
|
@@ -3,6 +3,7 @@ Date: 2026-09-15
|
|
|
3
3
|
Status: Accepted
|
|
4
4
|
Related: ADR 001, ADR 005, ADR 009, ADR 012, ADR 014
|
|
5
5
|
Superseded in part by: ADR 012
|
|
6
|
+
Not implemented: the host skill, the install task and the agent definition
|
|
6
7
|
Triggers:
|
|
7
8
|
- writing or changing guidance for AI agents about using Janela
|
|
8
9
|
- proposing that Janela ship an agent, a skill or an MCP surface to hosts
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-17
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 005, ADR 006, ADR 007, ADR 008, ADR 015, ADR 024
|
|
5
|
+
Triggers:
|
|
6
|
+
- a filter reaching Ransack from a URL
|
|
7
|
+
- adding a predicate, or widening what a filter may contain
|
|
8
|
+
- a query with no LIMIT, or a grouped query over a high cardinality dimension
|
|
9
|
+
- deciding whether a bound belongs to Janela or to the host
|
|
10
|
+
Topics: security, ransack, urls, performance, cross-filtering
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 025: Janela Bounds What a Filter Can Ask For
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 002 chose Ransack so that a host's filters are the Ransack params a
|
|
18
|
+
Rails developer already reads, and made a dimension declaration the
|
|
19
|
+
allowlist of attributes. That part holds. What it left open is which
|
|
20
|
+
*predicates* may be used on an allowed attribute, and issue #8 asked the
|
|
21
|
+
question.
|
|
22
|
+
|
|
23
|
+
Measured against the demo before writing this:
|
|
24
|
+
|
|
25
|
+
| Filter passed | Result today |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `status_eq: "paid"` | 300.0 |
|
|
28
|
+
| `status_cont: "pai"` | 300.0 |
|
|
29
|
+
| `status_start: "p"` | 325.0 |
|
|
30
|
+
| `status_matches: "%aid"` | 300.0 |
|
|
31
|
+
| `customer_name_cont: "cm"` | 150.0 |
|
|
32
|
+
| `amount_gt: 60` | `BadRequest`, `amount` is not a dimension |
|
|
33
|
+
| `status_frobnicate: "x"` | `BadRequest`, no such predicate |
|
|
34
|
+
|
|
35
|
+
So the attribute allowlist works, and every predicate Ransack knows is
|
|
36
|
+
reachable on an allowed attribute, including through an association.
|
|
37
|
+
`Ransack::Predicate.names` has 62 entries. The sharpest is `_matches`,
|
|
38
|
+
which takes an arbitrary `LIKE` pattern, so a leading wildcard scan of an
|
|
39
|
+
indexed column is one URL edit away, in a page a host has already
|
|
40
|
+
authorised someone to read.
|
|
41
|
+
|
|
42
|
+
Two other bounds are missing. A grouped query with no `limit` carries no
|
|
43
|
+
`LIMIT` in SQL at all, so a breakdown by a high cardinality dimension
|
|
44
|
+
returns a row per value. And a single filter can carry any number of
|
|
45
|
+
values: `status_in` with 5001 of them was answered rather than refused,
|
|
46
|
+
which matters more since ADR 024 made `_in` the shape a click writes.
|
|
47
|
+
|
|
48
|
+
**The question worth deciding is whose job this is.** ADR 002
|
|
49
|
+
deliberately spent Ransack's familiarity on the host, and the obvious
|
|
50
|
+
objection to bounding anything here is that a host can restrict filtering
|
|
51
|
+
itself by defining `ransackable_attributes`. That objection is false, and
|
|
52
|
+
the measurement above is why: `ransackable_attributes` allows *columns*,
|
|
53
|
+
and Ransack has no per-attribute predicate allowlist. Once a host allows
|
|
54
|
+
a column for equality, it has allowed `_matches` on that column too.
|
|
55
|
+
There is nowhere else for this bound to live.
|
|
56
|
+
|
|
57
|
+
Options considered and rejected:
|
|
58
|
+
|
|
59
|
+
- **Leave it, and document it.** A dashboard is often the first page a
|
|
60
|
+
company exposes to people it would not give SQL to, and the library
|
|
61
|
+
hands them a pattern scan. Documenting a sharp edge is not the same as
|
|
62
|
+
not having one.
|
|
63
|
+
- **Restrict to `_eq` and `_in`,** as issue #8 proposed. It would delete
|
|
64
|
+
documented behaviour: ADR 006 allows `placed_on_gteq` and
|
|
65
|
+
`placed_on_lt` so a host can narrow a time range, and the README says
|
|
66
|
+
so. A range filter on a time dimension is a dashboard's ordinary
|
|
67
|
+
question, not an escape hatch.
|
|
68
|
+
- **A setting listing the allowed predicates.** Rejected on the test
|
|
69
|
+
ADR 021 and ADR 023 set: a setting earns itself when the judgement is
|
|
70
|
+
made once about the whole application and cannot be inferred. This one
|
|
71
|
+
can be inferred, from what kind of dimension is being filtered.
|
|
72
|
+
|
|
73
|
+
## Decision
|
|
74
|
+
|
|
75
|
+
**Janela allows only the predicates a dashboard asks in, bounds what a
|
|
76
|
+
grouped query returns, and bounds how many values one filter may carry.**
|
|
77
|
+
|
|
78
|
+
**Predicates are allowed by the kind of dimension.** A categorical
|
|
79
|
+
dimension takes `eq`, `in` and `null`, which is exactly what a click
|
|
80
|
+
produces (ADR 024). A time dimension additionally takes `gteq`, `gt`,
|
|
81
|
+
`lteq` and `lt`, because narrowing a range is how a time dimension is
|
|
82
|
+
filtered (ADR 006). Anything else raises `Janela::BadRequest` naming the
|
|
83
|
+
attribute and the predicates that are allowed, the way a disallowed
|
|
84
|
+
attribute already does.
|
|
85
|
+
|
|
86
|
+
The check belongs beside the existing one in `Definition#filter`, which
|
|
87
|
+
already raises for a filter Ransack silently dropped. `Ransack::Predicate
|
|
88
|
+
.detect_from_string` splits a key into attribute and predicate, so this
|
|
89
|
+
is a comparison, not a parser.
|
|
90
|
+
|
|
91
|
+
**One ceiling, 1000, in both places.** A grouped query with no `limit`
|
|
92
|
+
gets `LIMIT 1000`, and a single filter may carry at most 1000 values.
|
|
93
|
+
1000 is not a new number: ADR 007 already fixed it as the maximum a host
|
|
94
|
+
may ask for, so the ceiling is the existing maximum applied by default
|
|
95
|
+
rather than a second constant to reason about. A pane that hits the
|
|
96
|
+
ceiling is unreadable long before it is reached, so this is a bound on
|
|
97
|
+
harm, not on usefulness.
|
|
98
|
+
|
|
99
|
+
**Time panes keep their exemption.** ADR 007 exempted them from `limit`
|
|
100
|
+
because a time series shows its whole range, and gap filled buckets are
|
|
101
|
+
generated rather than returned by the database. The ceiling does not
|
|
102
|
+
apply to them. A host that wants fewer buckets narrows the range or
|
|
103
|
+
coarsens the granularity, which ADR 006 already allows.
|
|
104
|
+
|
|
105
|
+
**No setting is added.** A host that genuinely needs a pattern search
|
|
106
|
+
already has the documented way in: pass a pre-filtered relation through
|
|
107
|
+
`on:`, where it writes the condition itself in its own code, under its
|
|
108
|
+
own authorisation, rather than accepting one from a URL.
|
|
109
|
+
|
|
110
|
+
## Consequences
|
|
111
|
+
|
|
112
|
+
- The reachable filter surface goes from 62 predicates to three on a
|
|
113
|
+
categorical dimension and seven on a time dimension. That is the point.
|
|
114
|
+
- **Breaking for a host that passes anything else.** A `_cont` or
|
|
115
|
+
`_matches` filter that works today raises `BadRequest` after this. It
|
|
116
|
+
needs an `UPGRADING.md` entry naming the allowed predicates and the
|
|
117
|
+
`on:` alternative, and the release carrying it is minor (ADR 015).
|
|
118
|
+
The doctor cannot find this one by reading source, because the filter
|
|
119
|
+
is usually built at runtime; the upgrade note is the whole mitigation.
|
|
120
|
+
- A stored snapshot holds the filters it was taken under (ADR 009). One
|
|
121
|
+
taken with a now disallowed predicate would raise when read. Whether to
|
|
122
|
+
refuse those at read time or leave stored results alone is not decided
|
|
123
|
+
here and should be settled while building this.
|
|
124
|
+
- The default ceiling changes a number a host can see: a breakdown over
|
|
125
|
+
more than 1000 values silently showed all of them and will now show
|
|
126
|
+
1000. It is ordered by the measure, so what is cut is the smallest, and
|
|
127
|
+
a host that wants a different cut passes `limit`.
|
|
128
|
+
- What would change this: a host with a real need for a predicate not on
|
|
129
|
+
the list. The answer is to add that predicate to the list for that kind
|
|
130
|
+
of dimension, in an ADR that says which dashboard question needed it,
|
|
131
|
+
not to make the list configurable.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-18
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 016, ADR 023, ADR 001, ADR 012
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a visualisation or a renderer
|
|
7
|
+
- reaching for a charting library
|
|
8
|
+
- anything that would make how a pane is drawn configurable
|
|
9
|
+
- building the gallery
|
|
10
|
+
Topics: rendering, styling, configuration, host-integration
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 026: A Renderer Is the Seam, and HTML Comes First
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
Janela draws bar and line panes with Chart.js today, tables with plain
|
|
18
|
+
HTML, and a value pane with a number in a div. That mix happened rather
|
|
19
|
+
than being decided, and the next visualisation makes the question
|
|
20
|
+
unavoidable: is Janela a wrapper around a charting library, or something
|
|
21
|
+
that draws with whatever suits and reaches for a library only when it
|
|
22
|
+
has to?
|
|
23
|
+
|
|
24
|
+
Three things are being asked at once, and they have one answer between
|
|
25
|
+
them.
|
|
26
|
+
|
|
27
|
+
**Where a charting library sits.** A pane already names its renderer, so
|
|
28
|
+
`renderer: "bar"` is a name on a record and the code behind it is ours.
|
|
29
|
+
Nothing about Chart.js is in that name. Treating the renderer as the
|
|
30
|
+
seam means a visualisation can change how it is drawn without any host
|
|
31
|
+
noticing, and a host that wants a different bar chart replaces one small
|
|
32
|
+
piece rather than the library.
|
|
33
|
+
|
|
34
|
+
**Whether a visualisation needs JavaScript at all.** Most do not. A bar
|
|
35
|
+
chart is a div with a percentage width. A ranked list is a table with a
|
|
36
|
+
bar behind each row. A sparkline is inline SVG the server can render
|
|
37
|
+
whole. Drawn that way the pane is in the HTML: it is there before any
|
|
38
|
+
JavaScript loads, it prints, a screen reader can read it, and a click to
|
|
39
|
+
cross-filter is a real button, which is already how the table renderer
|
|
40
|
+
behaves and already how ADR 024's keyboard support works. A canvas can
|
|
41
|
+
do none of that without being taught each one.
|
|
42
|
+
|
|
43
|
+
The engine's own frame page already proves the point from the other
|
|
44
|
+
side: it loads no chart runtime, so it renders a chart pane as a table,
|
|
45
|
+
and the pane is still useful.
|
|
46
|
+
|
|
47
|
+
**Whether a host should choose the charting library.** A setting for
|
|
48
|
+
this is the obvious idea and the wrong one. It would mean every renderer
|
|
49
|
+
written against an interface wide enough for any library, an adapter per
|
|
50
|
+
library, and a test matrix multiplied by the number of libraries anyone
|
|
51
|
+
has configured. ADR 001 kept Janela to a load bearing 5%, and the
|
|
52
|
+
project's stance is that forking a small piece is a normal way to use
|
|
53
|
+
this library, not a fallback. A renderer small enough to read in one
|
|
54
|
+
sitting is worth more than a plugin system.
|
|
55
|
+
|
|
56
|
+
## Decision
|
|
57
|
+
|
|
58
|
+
**A renderer is the seam. Server rendered HTML and CSS are the default,
|
|
59
|
+
a JavaScript library is an implementation detail of the renderers that
|
|
60
|
+
need one, and there is no setting for choosing it.**
|
|
61
|
+
|
|
62
|
+
1. A pane's `renderer` names a way of drawing, not a technology. What is
|
|
63
|
+
behind that name can change without a host changing anything.
|
|
64
|
+
2. A new visualisation is built in HTML and CSS unless it cannot be. A
|
|
65
|
+
library earns its place by doing something the document cannot:
|
|
66
|
+
dense data, animation, interaction a button cannot express.
|
|
67
|
+
3. Every renderer degrades to something readable with no JavaScript,
|
|
68
|
+
because a pane is rendered on the server before anything runs.
|
|
69
|
+
4. No `Janela.chart_library` or equivalent. A host that wants a
|
|
70
|
+
different chart replaces a renderer, and the renderers stay small
|
|
71
|
+
enough that this is reasonable.
|
|
72
|
+
5. Vitral reaches into the visualisation, not only the page around it.
|
|
73
|
+
The palette a bar takes, how a pane is framed and how a selection is
|
|
74
|
+
shown are part of the theme (ADR 023), which is what makes a
|
|
75
|
+
visualisation gallery a property of the gem rather than of the demo.
|
|
76
|
+
|
|
77
|
+
## Consequences
|
|
78
|
+
|
|
79
|
+
The gallery becomes gem work: a mountable page listing every renderer as
|
|
80
|
+
a live pane against whatever data the host has, with the declaration
|
|
81
|
+
that produced it beside it, and the theme applied. It is documentation
|
|
82
|
+
that cannot go stale, because it is the real thing rendering. It is also
|
|
83
|
+
the roadmap, since a renderer that does not exist yet is a visible gap
|
|
84
|
+
rather than a line in a backlog.
|
|
85
|
+
|
|
86
|
+
Chart.js stays for bar and line until a renderer that does not need it
|
|
87
|
+
is written and measured against it. Nothing here asks a host to change
|
|
88
|
+
anything today.
|
|
89
|
+
|
|
90
|
+
The cost is that some visualisations are more work in HTML and CSS than
|
|
91
|
+
in a library, and we accept that where the result stays readable without
|
|
92
|
+
JavaScript. Where it does not, the library is the right answer and the
|
|
93
|
+
renderer says so.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-18
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 011, ADR 016, ADR 018, ADR 023, ADR 026
|
|
5
|
+
Triggers:
|
|
6
|
+
- building or changing the gallery
|
|
7
|
+
- adding a renderer
|
|
8
|
+
- exposing what Janela can draw to a host
|
|
9
|
+
- anything that would put JavaScript on the engine's own pages
|
|
10
|
+
Topics: rendering, host-integration, layouts, styling
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 027: The Gallery Is a Host Page, Built from the Gem's Helpers
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 026 decided that a gallery of renderers belongs in the gem rather
|
|
18
|
+
than in the demo, because vitral reaches into the visualisation and a
|
|
19
|
+
host should see its own data drawn its own way. It did not say what "in
|
|
20
|
+
the gem" means, and investigating #39 showed that the obvious reading is
|
|
21
|
+
the one that cannot work.
|
|
22
|
+
|
|
23
|
+
The obvious reading is a page the engine serves, at the mount path,
|
|
24
|
+
alongside the frames index. ADR 018 rules it out in its own words: "the
|
|
25
|
+
engine's own pages deliberately load no Stimulus and no Chart.js." That
|
|
26
|
+
is why a chart pane renders as a table there, and `frames_test.rb`
|
|
27
|
+
asserts exactly that. A gallery of every renderer served by the engine
|
|
28
|
+
would render table as a table, bar as a table and line as a table. It
|
|
29
|
+
could not demonstrate the thing it exists to demonstrate.
|
|
30
|
+
|
|
31
|
+
The same constraint takes the live configuration panel with it. The
|
|
32
|
+
two-step form for adding a pane exists because, as the comment on
|
|
33
|
+
`panes/new.html.erb` puts it, "the engine's pages run no JavaScript, so
|
|
34
|
+
one select cannot refill another". A panel where changing a select
|
|
35
|
+
re-renders the pane beside it is that, exactly.
|
|
36
|
+
|
|
37
|
+
The way out is already built. `janela_pane(model, measure, by:, as:,
|
|
38
|
+
granularity:, limit:)` renders any pane into a host's own page, where
|
|
39
|
+
that host's JavaScript, layout and theme already are. ADR 011 sent panes
|
|
40
|
+
there deliberately. A gallery is a page of panes.
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
**The gallery is a page the host owns, built from helpers and
|
|
45
|
+
enumerations the gem provides. The engine serves no gallery, and no
|
|
46
|
+
JavaScript is added to the engine's own pages.**
|
|
47
|
+
|
|
48
|
+
1. The gem's job is to make the page trivial to build: rendering a pane
|
|
49
|
+
into a host page already works, and what can be drawn becomes a
|
|
50
|
+
public enumeration rather than something a host reads out of a
|
|
51
|
+
constant. A gallery has to ask what renderers exist, what
|
|
52
|
+
granularities and limits are offered, and what a host's models
|
|
53
|
+
declare, and each of those is a supported question.
|
|
54
|
+
2. The page itself is the host's: its route, its layout, its words. That
|
|
55
|
+
is the same trade ADR 011 made and the reason a host's theme and
|
|
56
|
+
assets are present at all.
|
|
57
|
+
3. The demo carries the reference implementation, and it is the one we
|
|
58
|
+
look at. It is a host page like any other, so what works there works
|
|
59
|
+
for a host that copies it.
|
|
60
|
+
4. Anything interactive in it is the host's JavaScript, which means the
|
|
61
|
+
gem's own Stimulus controllers are available there as they already
|
|
62
|
+
are for frames and charts. Nothing changes on the engine's pages,
|
|
63
|
+
and ADR 011 and ADR 018 stand untouched.
|
|
64
|
+
|
|
65
|
+
## Consequences
|
|
66
|
+
|
|
67
|
+
A host gets a gallery by mounting a page rather than by installing the
|
|
68
|
+
gem, which is a real cost: it is not there on day one the way the frames
|
|
69
|
+
index is (ADR 013). We accept it, because a gallery that cannot draw a
|
|
70
|
+
chart would be worse than no gallery, and because the page is small when
|
|
71
|
+
the helpers and enumerations are right.
|
|
72
|
+
|
|
73
|
+
What the gem owes the page is now the work: an enumeration of renderers
|
|
74
|
+
and their options that does not require reaching into
|
|
75
|
+
`Janela::Query::RENDERERS`, and an answer for a host whose models
|
|
76
|
+
declare nothing yet, since a gallery with no data to draw still has to
|
|
77
|
+
say something useful.
|
|
78
|
+
|
|
79
|
+
There are three renderers today, table, bar and line, one of which is
|
|
80
|
+
the fallback the others degrade to. The gallery is therefore mostly a
|
|
81
|
+
frame for what comes next rather than a showcase of what exists, which
|
|
82
|
+
is the point of ADR 026 and worth saying plainly so nobody builds it
|
|
83
|
+
expecting a wall of charts.
|
|
84
|
+
|
|
85
|
+
If a gallery a host gets for free ever matters more than the engine's
|
|
86
|
+
pages staying JavaScript free, this is the decision to supersede, and
|
|
87
|
+
ADR 018 goes with it.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-18
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 006, ADR 007, ADR 009, ADR 024, ADR 025
|
|
5
|
+
Supersedes: ADR 025, in the predicate list and in what it says about snapshots
|
|
6
|
+
Triggers:
|
|
7
|
+
- changing which predicates a dimension allows
|
|
8
|
+
- adding a predicate a click can produce
|
|
9
|
+
- reasoning about what a stored snapshot validates when it is read
|
|
10
|
+
Topics: security, scope, urls, snapshots
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 028: The Predicate List ADR 025 Named Was Not Quite Right
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 025 decided that Janela bounds what a filter can ask for, and it was
|
|
18
|
+
right about the problem: every one of Ransack's 62 predicates was
|
|
19
|
+
reachable on any attribute a dimension declared, a grouped query carried
|
|
20
|
+
no `LIMIT`, and one filter accepted 5001 values. Building it (#8) proved
|
|
21
|
+
all three still true and closed them.
|
|
22
|
+
|
|
23
|
+
Two things the ADR said turned out not to survive contact with the code.
|
|
24
|
+
An accepted ADR is not rewritten, so this records what is true instead.
|
|
25
|
+
|
|
26
|
+
**The list it named would have broken behaviour the project already
|
|
27
|
+
documents.** ADR 025 gave a categorical dimension `eq`, `in` and `null`,
|
|
28
|
+
on the grounds that those are what a click produces (ADR 024). But
|
|
29
|
+
`not_null` is already used, already tested in `definition_query_test.rb`,
|
|
30
|
+
and has nothing to do with the hole being closed. Shipping the list as
|
|
31
|
+
written would have refused a filter a host is entitled to use, loudly, in
|
|
32
|
+
the name of security it does not buy.
|
|
33
|
+
|
|
34
|
+
**The snapshot risk it described does not exist.** ADR 025 warned that a
|
|
35
|
+
stored snapshot taken under a predicate later disallowed "would raise
|
|
36
|
+
when read". It cannot. `Snapshot#stored_result` looks a pane up by key
|
|
37
|
+
and returns the stored JSON; it never re-runs the query and never touches
|
|
38
|
+
Ransack again. A snapshot's `filters` are metadata `Snapshot.take` built
|
|
39
|
+
with, not something a read validates against. The fear was reasonable and
|
|
40
|
+
the code does not have it.
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
**A categorical dimension allows `eq`, `in`, `null` and `not_null`. A
|
|
45
|
+
time dimension allows those plus `gteq`, `gt`, `lteq` and `lt`. Reading a
|
|
46
|
+
stored snapshot validates no predicates, because it runs no query.**
|
|
47
|
+
|
|
48
|
+
Everything else ADR 025 decided stands: predicates are allowed by the
|
|
49
|
+
kind of dimension rather than globally, anything outside the list raises
|
|
50
|
+
`Janela::BadRequest` naming the attribute and what is allowed, and one
|
|
51
|
+
ceiling of 1000 bounds both an unlimited grouped query and the number of
|
|
52
|
+
values a single filter may carry (ADR 007).
|
|
53
|
+
|
|
54
|
+
The rule for adding to the list, so this does not become a place things
|
|
55
|
+
accumulate: a predicate belongs there if a dashboard produces it, or if
|
|
56
|
+
it is a boolean test of presence rather than a way to phrase a match.
|
|
57
|
+
`not_null` qualifies on the second. `cont`, `matches` and `start` do not
|
|
58
|
+
qualify on either, which is the whole point of the bound.
|
|
59
|
+
|
|
60
|
+
## Consequences
|
|
61
|
+
|
|
62
|
+
The built list is one predicate wider than the decided one, and a host
|
|
63
|
+
using `not_null` keeps working rather than being broken by a security
|
|
64
|
+
fix. That is the right trade, and it is worth naming why it was close: a
|
|
65
|
+
list written from first principles in an ADR, without running it against
|
|
66
|
+
the tests, refused something real.
|
|
67
|
+
|
|
68
|
+
A regression test in `snapshot_test.rb` now pins the snapshot behaviour,
|
|
69
|
+
so the failure mode ADR 025 imagined cannot appear later without a test
|
|
70
|
+
noticing.
|
|
71
|
+
|
|
72
|
+
The general lesson, which is why this is an ADR rather than a commit
|
|
73
|
+
message: an ADR that names a specific list is making a claim about the
|
|
74
|
+
code, not only about the design, and that claim needs checking against
|
|
75
|
+
the code before the ADR is accepted rather than while it is built.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -21,23 +21,24 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
21
21
|
| Topic | ADRs |
|
|
22
22
|
|-------|------|
|
|
23
23
|
| **Vision, scope, forkability** | 001, 010, 012 |
|
|
24
|
-
| **Open-source & host-decoupling** | 001 |
|
|
25
|
-
| **DSL & query layer** | 002, 006, 007 |
|
|
26
|
-
| **Dependencies** | 002, 003, 004, 006, 017 |
|
|
27
|
-
| **Authorisation** | 002, 003, 004, 009, 017, 019 |
|
|
28
|
-
| **Performance & storage** | 007, 017 |
|
|
29
|
-
| **Cross-filtering & Hotwire** | 003, 004, 005, 008 |
|
|
30
|
-
| **Layouts & views** | 011, 012, 016, 018 |
|
|
31
|
-
| **CSS & styling** | 016, 018 |
|
|
24
|
+
| **Open-source & host-decoupling** | 001, 022 |
|
|
25
|
+
| **DSL & query layer** | 002, 006, 007, 020, 025 |
|
|
26
|
+
| **Dependencies** | 002, 003, 004, 006, 017, 025 |
|
|
27
|
+
| **Authorisation** | 002, 003, 004, 009, 017, 019, 022 |
|
|
28
|
+
| **Performance & storage** | 007, 017, 025 |
|
|
29
|
+
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025 |
|
|
30
|
+
| **Layouts & views** | 011, 012, 016, 018, 020, 027 |
|
|
31
|
+
| **CSS & styling** | 016, 018, 023, 026, 027 |
|
|
32
32
|
| **Frames, panes & persistence** | 012, 013, 014, 019 |
|
|
33
|
-
| **Naming rule** | 014 |
|
|
34
|
-
| **JavaScript delivery & charts** | 004, 006 |
|
|
35
|
-
| **Time dimensions** | 006 |
|
|
36
|
-
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013 |
|
|
37
|
-
| **Snapshots & publishing** | 009 |
|
|
38
|
-
| **AI agents & guidance** | 010, 015 |
|
|
39
|
-
| **Releases & upgrades** | 015 |
|
|
40
|
-
| **
|
|
33
|
+
| **Naming rule** | 014, 023 |
|
|
34
|
+
| **JavaScript delivery & charts** | 004, 006, 026 |
|
|
35
|
+
| **Time dimensions** | 006, 025 |
|
|
36
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025 |
|
|
37
|
+
| **Snapshots & publishing** | 009, 020, 028 |
|
|
38
|
+
| **AI agents & guidance** | 010, 015, 021 |
|
|
39
|
+
| **Releases & upgrades** | 015, 021 |
|
|
40
|
+
| **Accessibility & keyboard** | 024 |
|
|
41
|
+
| **Security** | 003, 025, 028 |
|
|
41
42
|
| **Testing** | 003 |
|
|
42
43
|
|
|
43
44
|
## Chronological
|
|
@@ -68,7 +69,11 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
68
69
|
| 022 | A Host's Route Helpers Work Inside the Engine | 2026-09-16 | Accepted |
|
|
69
70
|
| 023 | Vitral Is a Theme, Not the Stylesheet | 2026-09-16 | Accepted |
|
|
70
71
|
| 024 | Selecting More Than One Value | 2026-09-16 | Accepted |
|
|
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 |
|
|
71
76
|
|
|
72
77
|
## Next number
|
|
73
78
|
|
|
74
|
-
Next ADR:
|
|
79
|
+
Next ADR: 029
|
data/lib/janela/definition.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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
9
|
def initialize(model)
|
|
@@ -35,7 +39,7 @@ module Janela
|
|
|
35
39
|
measure.apply(buckets).transform_keys { |bucket| dimension.label(bucket, granularity) }
|
|
36
40
|
else
|
|
37
41
|
grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
|
|
38
|
-
grouped = grouped.limit(limit!(limit)
|
|
42
|
+
grouped = grouped.limit(limit ? limit!(limit) : MAXIMUM)
|
|
39
43
|
measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
|
|
40
44
|
end
|
|
41
45
|
end
|
|
@@ -64,7 +68,7 @@ module Janela
|
|
|
64
68
|
|
|
65
69
|
def limit!(value)
|
|
66
70
|
limit = Integer(value, exception: false)
|
|
67
|
-
raise BadRequest, "limit must be a whole number from 1 to
|
|
71
|
+
raise BadRequest, "limit must be a whole number from 1 to #{MAXIMUM}, got #{value.inspect}" unless limit&.between?(1, MAXIMUM)
|
|
68
72
|
limit
|
|
69
73
|
end
|
|
70
74
|
|
|
@@ -82,6 +86,8 @@ module Janela
|
|
|
82
86
|
|
|
83
87
|
search = relation.ransack(params)
|
|
84
88
|
reject_dropped_filters!(search, params)
|
|
89
|
+
reject_disallowed_predicates!(search)
|
|
90
|
+
reject_oversized_filters!(search)
|
|
85
91
|
search.result
|
|
86
92
|
end
|
|
87
93
|
|
|
@@ -95,5 +101,35 @@ module Janela
|
|
|
95
101
|
raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
|
|
96
102
|
"Declare a janela dimension, or add it to ransackable_attributes."
|
|
97
103
|
end
|
|
104
|
+
|
|
105
|
+
# An allowed attribute still reaches every predicate Ransack knows,
|
|
106
|
+
# including _matches, an arbitrary LIKE pattern (ADR 025). A dashboard
|
|
107
|
+
# asks in only the predicates its kind of dimension needs.
|
|
108
|
+
def reject_disallowed_predicates!(search)
|
|
109
|
+
by_ransack_name = dimensions.values.index_by(&:ransack_name)
|
|
110
|
+
|
|
111
|
+
search.conditions.each do |condition|
|
|
112
|
+
condition.attributes.each do |attribute|
|
|
113
|
+
dimension = by_ransack_name.fetch(attribute.name)
|
|
114
|
+
next if dimension.allowed_predicates.include?(condition.predicate_name)
|
|
115
|
+
|
|
116
|
+
allowed = dimension.allowed_predicates.map { |predicate| "#{attribute.name}_#{predicate}" }
|
|
117
|
+
raise BadRequest, "#{model} does not allow #{attribute.name}_#{condition.predicate_name}. " \
|
|
118
|
+
"This dimension allows #{allowed.join(', ')}."
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A click writes _in (ADR 024), which takes an array Ransack does not
|
|
124
|
+
# otherwise bound. 5001 values answered rather than being refused.
|
|
125
|
+
def reject_oversized_filters!(search)
|
|
126
|
+
search.conditions.each do |condition|
|
|
127
|
+
next unless condition.predicate.wants_array
|
|
128
|
+
next if condition.values.size <= MAXIMUM
|
|
129
|
+
|
|
130
|
+
raise BadRequest, "#{model} does not allow a filter to carry more than #{MAXIMUM} values, " \
|
|
131
|
+
"got #{condition.values.size} for #{condition.attributes.map(&:name).join(', ')}."
|
|
132
|
+
end
|
|
133
|
+
end
|
|
98
134
|
end
|
|
99
135
|
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/host_routes.rb
CHANGED
|
@@ -25,6 +25,12 @@ module Janela
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def self.forwarded(host: Rails.application.routes, engine: Janela::Engine.routes)
|
|
28
|
+
# Route loading is lazy, so a call before anything else has drawn the
|
|
29
|
+
# host's routes would otherwise see an empty set rather than an error
|
|
30
|
+
# (issue #37). This does not recurse: define! runs from
|
|
31
|
+
# after_routes_loaded, by which point the reloader has already marked
|
|
32
|
+
# itself loaded.
|
|
33
|
+
Rails.application.reload_routes_unless_loaded
|
|
28
34
|
host.named_routes.helper_names - engine.named_routes.helper_names
|
|
29
35
|
end
|
|
30
36
|
end
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -66,4 +66,19 @@ module Janela
|
|
|
66
66
|
|
|
67
67
|
class_name.constantize.janela
|
|
68
68
|
end
|
|
69
|
+
|
|
70
|
+
# What Janela can draw, so a gallery asks rather than reaching into
|
|
71
|
+
# Janela::Query::RENDERERS, Janela::Dimension::GRANULARITIES or
|
|
72
|
+
# Janela::Pane::OFFERED_LIMITS itself (ADR 027, #39).
|
|
73
|
+
def self.renderers
|
|
74
|
+
Query::RENDERERS
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.granularities
|
|
78
|
+
Dimension::GRANULARITIES
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.offered_limits
|
|
82
|
+
Pane::OFFERED_LIMITS
|
|
83
|
+
end
|
|
69
84
|
end
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -160,6 +160,10 @@ files:
|
|
|
160
160
|
- docs/decisions/022-host-route-helpers-work-inside-the-engine.md
|
|
161
161
|
- docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md
|
|
162
162
|
- docs/decisions/024-selecting-more-than-one-value.md
|
|
163
|
+
- docs/decisions/025-janela-bounds-what-a-filter-can-ask-for.md
|
|
164
|
+
- docs/decisions/026-a-renderer-is-the-seam-and-html-comes-first.md
|
|
165
|
+
- docs/decisions/027-the-gallery-is-a-host-page.md
|
|
166
|
+
- docs/decisions/028-the-predicate-list-adr-025-named.md
|
|
163
167
|
- docs/decisions/INDEX.md
|
|
164
168
|
- docs/multi-tenancy.md
|
|
165
169
|
- docs/naming.md
|
|
@@ -183,14 +187,17 @@ metadata:
|
|
|
183
187
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
184
188
|
rubygems_mfa_required: 'true'
|
|
185
189
|
post_install_message: |
|
|
186
|
-
Janela 0.
|
|
187
|
-
need do nothing
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
191
196
|
|
|
192
197
|
Steps: UPGRADING.md in this gem, or
|
|
193
198
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|
|
199
|
+
|
|
200
|
+
Then run: bin/rails janela:doctor
|
|
194
201
|
rdoc_options: []
|
|
195
202
|
require_paths:
|
|
196
203
|
- lib
|