janela 0.3.0 → 0.4.1
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 +32 -0
- data/LICENSE.txt +1 -1
- data/README.md +47 -6
- data/UPGRADING.md +37 -1
- data/app/assets/javascripts/janela/chart_controller.js +12 -6
- data/app/assets/javascripts/janela/frame_controller.js +140 -17
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +1 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +7 -0
- data/app/helpers/janela/frames_helper.rb +2 -1
- data/app/models/janela/query.rb +18 -5
- data/app/views/janela/frames/_frame.html.erb +2 -1
- data/app/views/janela/queries/_query.html.erb +2 -2
- data/app/views/layouts/janela/application.html.erb +10 -1
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +1 -0
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +1 -0
- data/docs/decisions/022-host-route-helpers-work-inside-the-engine.md +104 -0
- data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
- data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
- data/docs/decisions/025-janela-bounds-what-a-filter-can-ask-for.md +131 -0
- data/docs/decisions/INDEX.md +21 -16
- data/docs/naming.md +172 -0
- data/lib/janela/engine.rb +10 -1
- data/lib/janela/host_routes.rb +37 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +7 -0
- metadata +9 -14
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 004, ADR 005, ADR 011, ADR 019
|
|
5
|
+
Triggers:
|
|
6
|
+
- a route helper raising inside Janela's controllers
|
|
7
|
+
- host code inherited from ApplicationController running in the engine
|
|
8
|
+
- authentication redirects
|
|
9
|
+
- anything that would make the engine less isolated
|
|
10
|
+
Topics: host-integration, routing, engine-isolation, authentication
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 022: A Host's Route Helpers Work Inside the Engine
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
`isolate_namespace Janela` points every route helper evaluated inside
|
|
18
|
+
Janela's controllers at the engine's own route set. That is the right
|
|
19
|
+
default for the engine's own code. It is the wrong answer for the
|
|
20
|
+
host's, and the host's runs there constantly, because
|
|
21
|
+
`Janela::ApplicationController` inherits the host's
|
|
22
|
+
`ApplicationController` on purpose (ADR 004).
|
|
23
|
+
|
|
24
|
+
Three reports, one cause, and each host found it separately:
|
|
25
|
+
|
|
26
|
+
1. A host's application layout calling `root_path` in its nav, which
|
|
27
|
+
made every pane 500. Solved another way in 0.2.1 by not rendering
|
|
28
|
+
panes in the host's layout (ADR 011).
|
|
29
|
+
2. A host authenticating per controller, whose `before_action`
|
|
30
|
+
redirected to `new_session_path`.
|
|
31
|
+
3. A host authenticating globally, whose `Authentication` concern
|
|
32
|
+
redirected to `new_session_path` from inside the engine and raised
|
|
33
|
+
`ActionController::UrlGenerationError`. An unauthenticated visitor
|
|
34
|
+
got a 500 instead of a sign-in page.
|
|
35
|
+
|
|
36
|
+
Case 3 is the one that shows the shape of the problem. The README had
|
|
37
|
+
framed the fix as something to do "if your app authenticates per
|
|
38
|
+
controller", which is not the trigger at all. The trigger is that a
|
|
39
|
+
URL is generated by host code while it is inside the engine. A
|
|
40
|
+
`rescue_from` that redirects, an `after_action`, a flash partial and an
|
|
41
|
+
audit callback all qualify. Each host discovers it on its own, in
|
|
42
|
+
production, as a 500.
|
|
43
|
+
|
|
44
|
+
`main_app.new_session_path` is the documented fix and it works. It
|
|
45
|
+
also asks every host to know that Janela is an isolated engine, and to
|
|
46
|
+
remember it in code that has nothing to do with Janela. That is the
|
|
47
|
+
opposite of what installing this gem is supposed to feel like.
|
|
48
|
+
|
|
49
|
+
The option of including the host's `url_helpers` wholesale was
|
|
50
|
+
considered and rejected. Both sets of helpers would then be present,
|
|
51
|
+
and a name in both would resolve by module order, so `frames_path`
|
|
52
|
+
inside Janela's own view could silently become the host's `/frames`.
|
|
53
|
+
Trading a loud `UrlGenerationError` for a quietly wrong link is a bad
|
|
54
|
+
trade.
|
|
55
|
+
|
|
56
|
+
## Decision
|
|
57
|
+
|
|
58
|
+
**Janela forwards to the host exactly those route helpers the engine
|
|
59
|
+
does not define, and no others.**
|
|
60
|
+
|
|
61
|
+
The set is computed when routes are loaded, as the host's helper names
|
|
62
|
+
minus the engine's, and each one is defined to call `main_app`. It is
|
|
63
|
+
rebuilt whenever routes reload, so a name that goes away goes away
|
|
64
|
+
here too.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# In a host's ApplicationController, running inside Janela:
|
|
68
|
+
redirect_to new_session_path # works, forwarded to the host
|
|
69
|
+
frames_path # Janela's, never the host's /frames
|
|
70
|
+
main_app.frames_path # the host's, said unambiguously
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Two properties matter more than the convenience:
|
|
74
|
+
|
|
75
|
+
**The engine's own routes can never be shadowed.** A name is forwarded
|
|
76
|
+
only when Janela does not define it, so no host route can change what
|
|
77
|
+
one of Janela's own views means. Precedence is decided by
|
|
78
|
+
construction, not by the order modules happened to be included.
|
|
79
|
+
|
|
80
|
+
**`main_app` still means what it means.** Nothing is taken away. A
|
|
81
|
+
host that prefers to be explicit, or that needs a name Janela also
|
|
82
|
+
uses, says `main_app.` and is unambiguous.
|
|
83
|
+
|
|
84
|
+
**This covers named helpers only.** `url_for(record)` and
|
|
85
|
+
`redirect_to @record` resolve polymorphically at call time, so there
|
|
86
|
+
is no name to forward and they still need `main_app.`. That boundary
|
|
87
|
+
is documented rather than papered over.
|
|
88
|
+
|
|
89
|
+
## Consequences
|
|
90
|
+
|
|
91
|
+
- A host's authentication redirect works inside Janela with no
|
|
92
|
+
configuration, no reopened class and no knowledge that an engine is
|
|
93
|
+
involved. That was the whole complaint in three reports.
|
|
94
|
+
- Janela is slightly less isolated than a textbook engine, in one
|
|
95
|
+
direction only: host names in, never Janela names out. ADR 005's
|
|
96
|
+
mount independence is untouched, since the engine still generates
|
|
97
|
+
every one of its own URLs through its own routes.
|
|
98
|
+
- A host route helper shares a name with one of Janela's and the host
|
|
99
|
+
loses the bare name inside the engine. Janela's route names are few
|
|
100
|
+
and dull, which makes this rare, and `main_app.` remains.
|
|
101
|
+
- The README's explanation was wrong about the cause and is corrected
|
|
102
|
+
here and there.
|
|
103
|
+
- Polymorphic routing is a known gap. If it bites someone the answer
|
|
104
|
+
is another ADR, not a quiet widening of this one.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 011, ADR 016, ADR 021
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing how Janela looks
|
|
7
|
+
- adding a stylesheet, a class or a custom property
|
|
8
|
+
- adding a Stimulus controller to the gem
|
|
9
|
+
- adding a setting
|
|
10
|
+
Topics: styling, host-integration, configuration, naming
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 023: Vitral Is a Theme, Not the Stylesheet
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 016 gave Janela one stylesheet, deliberately plain: the grid classes
|
|
18
|
+
an analyst's numbers choose from, and just enough style that a pane is
|
|
19
|
+
legible on install. That was right, and it left an obvious gap. A host
|
|
20
|
+
that wants a dashboard to look like something has to write all of it,
|
|
21
|
+
and the first thing anyone does with a new library is judge how it
|
|
22
|
+
looks.
|
|
23
|
+
|
|
24
|
+
The temptation is to make `janela.css` prettier. That would be a
|
|
25
|
+
mistake. Structure and taste have different lifetimes: the grid classes
|
|
26
|
+
are load bearing and must not change, while taste is the first thing a
|
|
27
|
+
host will want to replace and the first thing we will want to revise.
|
|
28
|
+
Putting both in one file means every visual revision risks the layout,
|
|
29
|
+
and every host that dislikes the look has to fight rules it also needs.
|
|
30
|
+
|
|
31
|
+
## Decision
|
|
32
|
+
|
|
33
|
+
**A second stylesheet, `vitral.css`, optional and separate.**
|
|
34
|
+
|
|
35
|
+
A *vitral* is a stained glass window. Janela is a window, its parts are
|
|
36
|
+
frames and panes, and this is what the glass looks like. The name
|
|
37
|
+
follows the same rule as the rest: an uncommon but conceivable word, so
|
|
38
|
+
it never collides with the vocabulary a host already uses for its own
|
|
39
|
+
things.
|
|
40
|
+
|
|
41
|
+
```erb
|
|
42
|
+
<%= stylesheet_link_tag "janela" %>
|
|
43
|
+
<%= stylesheet_link_tag "vitral" %>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Four rules hold it in place.
|
|
47
|
+
|
|
48
|
+
**Nothing is repainted until asked.** The light and the leading hang off
|
|
49
|
+
a `vitral` class. A host that links the stylesheet and adds no class
|
|
50
|
+
gets nothing, which means linking it can never be the thing that broke
|
|
51
|
+
a page.
|
|
52
|
+
|
|
53
|
+
**It is a small library, not a private skin.** `vitral-pane`,
|
|
54
|
+
`vitral-panes` and `vitral-button` are public, so the page around a
|
|
55
|
+
dashboard can be made of the same window. Everything else is a custom
|
|
56
|
+
property, so a host rethemes from its own stylesheet rather than by
|
|
57
|
+
forking this one.
|
|
58
|
+
|
|
59
|
+
**The theme must be complete without JavaScript.** Janela's own pages
|
|
60
|
+
load none at all (ADR 011), so the stained glass is CSS, and the part
|
|
61
|
+
that answers the pointer is a separate optional controller. The
|
|
62
|
+
stylesheet carries a static lattice for everyone who never loads it.
|
|
63
|
+
|
|
64
|
+
**Janela's own pages wear it by name.** `Janela.theme = "vitral"` is the
|
|
65
|
+
third setting this gem has, and it earns that the same way the second
|
|
66
|
+
did: which theme, if any, is a judgement made once about the whole
|
|
67
|
+
application, and that is what configuration is for (ADR 021). A host
|
|
68
|
+
that leaves it unset gets the structural stylesheet, exactly as before.
|
|
69
|
+
|
|
70
|
+
## Consequences
|
|
71
|
+
|
|
72
|
+
- Janela looks like something out of the box, and a host that hates it
|
|
73
|
+
removes one line rather than overriding a hundred rules.
|
|
74
|
+
- `janela.css` can stay frozen and boring while the theme moves. A
|
|
75
|
+
revision to the look is not a revision to the grid.
|
|
76
|
+
- The lattice is an inline SVG data URI rather than an image, because a
|
|
77
|
+
stylesheet shipped in a gem cannot rely on a host's asset pipeline to
|
|
78
|
+
resolve a `url()`.
|
|
79
|
+
- Accessibility is the theme's problem, not the host's: reduced motion,
|
|
80
|
+
reduced transparency and increased contrast each fall back to a
|
|
81
|
+
still, solid window, and so does a browser without `backdrop-filter`.
|
|
82
|
+
- A visual change to vitral is visible to every host that opted in, so
|
|
83
|
+
it belongs in the changelog like any other change a host can see
|
|
84
|
+
(ADR 015). It is not a breaking change, because nothing depends on it.
|
|
85
|
+
- A third stylesheet is a third thing for a Sprockets host to declare
|
|
86
|
+
for precompilation. The engine declares both itself.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 003, ADR 005, ADR 008, ADR 009, ADR 018
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing what a click on a value does
|
|
7
|
+
- adding a filter predicate or changing the URL grammar
|
|
8
|
+
- adding a keyboard shortcut
|
|
9
|
+
- anything a mouse can do that a keyboard cannot
|
|
10
|
+
Topics: cross-filtering, urls, accessibility, ransack
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 024: Selecting More Than One Value
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
A click on a value writes one Ransack condition, `q[status_eq]=paid`,
|
|
18
|
+
and clicking the same value again removes it. One value per dimension.
|
|
19
|
+
Every real dashboard eventually needs two: paid and pending, APAC and
|
|
20
|
+
EU. A commercial tool does this with a modifier click, and everyone
|
|
21
|
+
already knows the gesture.
|
|
22
|
+
|
|
23
|
+
The gesture is the easy part. `toggle(event)` already receives the
|
|
24
|
+
event, so `ctrlKey || metaKey` is there for a table button, and
|
|
25
|
+
Chart.js hands its own click handler the native event. What needs
|
|
26
|
+
deciding is what the URL says, what happens to the null group, and
|
|
27
|
+
what someone without a mouse does instead, because a modifier click is
|
|
28
|
+
invisible, absent on touch and unreachable from a keyboard. Deciding
|
|
29
|
+
the gesture without deciding that last part would build a feature only
|
|
30
|
+
some people can use (#35, #36).
|
|
31
|
+
|
|
32
|
+
Three things were measured against the dummy before writing this,
|
|
33
|
+
rather than assumed:
|
|
34
|
+
|
|
35
|
+
- `status_in: [paid, pending]` passes Janela's allowlist guard and
|
|
36
|
+
returns the sum of both. A dimension's allowlist is per attribute, so
|
|
37
|
+
the predicate needs nothing added.
|
|
38
|
+
- `status_eq: paid` still works, so an existing link keeps working
|
|
39
|
+
whatever a new click writes.
|
|
40
|
+
- `channel_null: 1` together with `channel_in: [web]` **returns zero**.
|
|
41
|
+
Ransack ANDs its conditions, so that combination asks for rows whose
|
|
42
|
+
channel is both null and web.
|
|
43
|
+
|
|
44
|
+
That last one is the whole difficulty. It does not raise. It renders
|
|
45
|
+
an empty dashboard, which reads as a bug in Janela rather than as an
|
|
46
|
+
impossible question.
|
|
47
|
+
|
|
48
|
+
## Decision
|
|
49
|
+
|
|
50
|
+
**A click selects, a modifier click adds, and the selection is a set.**
|
|
51
|
+
|
|
52
|
+
Following the convention every list in every operating system already
|
|
53
|
+
uses, so that nothing has to be learned:
|
|
54
|
+
|
|
55
|
+
| Gesture | Result |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Click an unselected value | That value alone is selected |
|
|
58
|
+
| Click the only selected value | The dimension is cleared |
|
|
59
|
+
| Click a value while others are selected | That value replaces them |
|
|
60
|
+
| Ctrl or Cmd click | That value is added or removed, the rest stay |
|
|
61
|
+
|
|
62
|
+
**Janela writes `_in`, and keeps reading `_eq`.** A click always
|
|
63
|
+
produces `q[status_in][]=paid`, one value or five, so there is one
|
|
64
|
+
shape in the controller, one in the view and one in a stored snapshot.
|
|
65
|
+
A hand written or previously shared `_eq` link keeps working, because
|
|
66
|
+
Ransack accepts it and because a URL somebody already sent should not
|
|
67
|
+
stop working to suit us (ADR 005).
|
|
68
|
+
|
|
69
|
+
**The null group is exclusive within its dimension.** Selecting
|
|
70
|
+
`(none)` clears the other values of that dimension, and selecting a
|
|
71
|
+
value clears `(none)`. The combination is unanswerable, and the honest
|
|
72
|
+
options are to refuse it or to render nothing and let it look broken.
|
|
73
|
+
|
|
74
|
+
Ransack can express the OR through its `g[]` grouping, and that is
|
|
75
|
+
rejected. It would make the URL unreadable, and ADR 005's grammar is
|
|
76
|
+
built on a URL a person can read and edit. It would also have to be
|
|
77
|
+
understood by every stored snapshot and every hand written link
|
|
78
|
+
forever, to serve a question almost nobody asks.
|
|
79
|
+
|
|
80
|
+
**The keyboard gets the same two gestures, not a different feature.**
|
|
81
|
+
A value is already a real `<button>` (ADR 018), so Enter is a click
|
|
82
|
+
and **Ctrl or Cmd with Enter is a modifier click**, using the same
|
|
83
|
+
flags on the same event. Nothing new is invented, and nothing has to
|
|
84
|
+
be learned twice.
|
|
85
|
+
|
|
86
|
+
**Janela binds exactly two keys, and only inside the frame.**
|
|
87
|
+
|
|
88
|
+
- `Escape` clears the frame's filters.
|
|
89
|
+
- `Enter` and `Space` act on the focused value, as they already do.
|
|
90
|
+
|
|
91
|
+
No single letter keys. A single letter belongs to the host
|
|
92
|
+
application, to its own shortcuts, and to any text field on the page.
|
|
93
|
+
Janela is a guest in someone else's application and will not take a
|
|
94
|
+
key that could mean something there. A host that wants `c` for clear
|
|
95
|
+
binds its own control to `janela--frame#clear`, which is how the clear
|
|
96
|
+
button already works.
|
|
97
|
+
|
|
98
|
+
**A chart stays a mouse surface, and the table is the accessible one.**
|
|
99
|
+
A bar is painted pixels with nothing focusable behind it. Rather than
|
|
100
|
+
build a parallel focus model inside a canvas, Janela says plainly that
|
|
101
|
+
the table renders the same data and is operable by keyboard, which is
|
|
102
|
+
what ADR 018 made a table the universal renderer for. A chart
|
|
103
|
+
highlights every selected value rather than one.
|
|
104
|
+
|
|
105
|
+
## Consequences
|
|
106
|
+
|
|
107
|
+
- A dashboard can answer "paid and pending", which is the ordinary
|
|
108
|
+
question this could not previously express.
|
|
109
|
+
- `Query#selected_value` becomes `selected_values` and returns an
|
|
110
|
+
array. A host that overrode a pane view touches it, so this is a
|
|
111
|
+
breaking change, and it goes in UPGRADING.md with the version that
|
|
112
|
+
carries it (ADR 015).
|
|
113
|
+
- A shared link's shape changes from `q[status_eq]=paid` to
|
|
114
|
+
`q[status_in][]=paid`. Older links keep working, so nothing that was
|
|
115
|
+
sent stops working.
|
|
116
|
+
- The page URL and every pane `src` now carry repeated parameters. The
|
|
117
|
+
controller sorts filters so an unchanged `src` is never reloaded, and
|
|
118
|
+
it must sort the values inside a dimension too or a set will
|
|
119
|
+
serialise two ways and refetch every pane for nothing.
|
|
120
|
+
- Touch has no modifier key, so a touch user gets replace-only
|
|
121
|
+
selection. That is a real gap and this ADR does not close it. The
|
|
122
|
+
answer, when someone needs it, is a control the host can render that
|
|
123
|
+
makes the next clicks additive, with the modifier as its accelerator
|
|
124
|
+
rather than the only path. Not built, because the simple thing has
|
|
125
|
+
not yet failed.
|
|
126
|
+
- Snapshots store filters as JSON, so an array needs nothing new, and
|
|
127
|
+
a snapshot taken under `_eq` still reads.
|
|
@@ -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.
|
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 |
|
|
31
|
+
| **CSS & styling** | 016, 018, 023 |
|
|
32
32
|
| **Frames, panes & persistence** | 012, 013, 014, 019 |
|
|
33
|
-
| **Naming rule** | 014 |
|
|
33
|
+
| **Naming rule** | 014, 023 |
|
|
34
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
|
-
| **
|
|
35
|
+
| **Time dimensions** | 006, 025 |
|
|
36
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025 |
|
|
37
|
+
| **Snapshots & publishing** | 009, 020 |
|
|
38
|
+
| **AI agents & guidance** | 010, 015, 021 |
|
|
39
|
+
| **Releases & upgrades** | 015, 021 |
|
|
40
|
+
| **Accessibility & keyboard** | 024 |
|
|
41
|
+
| **Security** | 003, 025 |
|
|
41
42
|
| **Testing** | 003 |
|
|
42
43
|
|
|
43
44
|
## Chronological
|
|
@@ -65,7 +66,11 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
65
66
|
| 019 | A Created Frame Asks the Host Who Owns It | 2026-09-16 | Accepted |
|
|
66
67
|
| 020 | Formatting Belongs to the Measure | 2026-09-16 | Accepted |
|
|
67
68
|
| 021 | A Check Has a Name, and a Host Can Silence It | 2026-09-16 | Accepted |
|
|
69
|
+
| 022 | A Host's Route Helpers Work Inside the Engine | 2026-09-16 | Accepted |
|
|
70
|
+
| 023 | Vitral Is a Theme, Not the Stylesheet | 2026-09-16 | Accepted |
|
|
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 |
|
|
68
73
|
|
|
69
74
|
## Next number
|
|
70
75
|
|
|
71
|
-
Next ADR:
|
|
76
|
+
Next ADR: 026
|
data/docs/naming.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
Topics: naming, vocabulary, design, renaming
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Naming Things Is Hard
|
|
6
|
+
|
|
7
|
+
There are two hard problems in computer science, and this page is about
|
|
8
|
+
the one that is not cache invalidation. Every name in Janela was chosen
|
|
9
|
+
on purpose, several were changed after they shipped, and one rule sits
|
|
10
|
+
under all of them.
|
|
11
|
+
|
|
12
|
+
## The rule
|
|
13
|
+
|
|
14
|
+
> With words like Janela, Frame and Pane I am purposefully selecting
|
|
15
|
+
> uncommon but understandable, conceivable words so that people aren't
|
|
16
|
+
> pigeonholed and can select their own nomenclature.
|
|
17
|
+
|
|
18
|
+
A library that calls its main idea a Dashboard has taken that word from
|
|
19
|
+
every application that installs it. Your app probably already has a
|
|
20
|
+
`Dashboard`, or a `Report`, or an `Insight`, and whatever you call yours
|
|
21
|
+
is the word your users know. So Janela's own vocabulary is deliberately
|
|
22
|
+
a step to the side: close enough to understand on first read, unusual
|
|
23
|
+
enough that it never collides with the words you already use, and never
|
|
24
|
+
the word your users have to see.
|
|
25
|
+
|
|
26
|
+
That gives two tests for any name in this codebase:
|
|
27
|
+
|
|
28
|
+
- **Conceivable.** Someone reading it for the first time can guess what
|
|
29
|
+
it is without looking it up.
|
|
30
|
+
- **Uncommon.** It is unlikely to already be a model, a table, a route
|
|
31
|
+
or a word on your screens.
|
|
32
|
+
|
|
33
|
+
## The window
|
|
34
|
+
|
|
35
|
+
*Janela* is Portuguese for window. The word is the same in Portugal and
|
|
36
|
+
in Brazil. Once the library is a window, the rest of its anatomy follows
|
|
37
|
+
from the thing itself rather than from a list of synonyms for "chart".
|
|
38
|
+
|
|
39
|
+
| Name | What it is | Why this word |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| **Janela** | The library | A window onto your data. Uncommon in English, obvious once explained. |
|
|
42
|
+
| **Frame** | A dashboard: a name and a grid of panes | A window frame holds the panes. It is also what Turbo calls the element that makes cross-filtering work (ADR 003), which is a happy accident rather than the reason. |
|
|
43
|
+
| **Pane** | One visual inside a frame, stored as a row | A pane of glass sits in a frame. You look through each one at part of the picture. |
|
|
44
|
+
| **Query** | The runtime object that calculates one pane | Not a window word, on purpose. It is an implementation detail rather than something a person arranges, so it gets the plain name for what it does. |
|
|
45
|
+
| **Grid** | How a frame is divided: `columns`, `gap`, and each pane's `span` | A window is divided into panes, and the grid is the division. Small integers that choose a class the stylesheet already defines, so nothing an analyst types reaches CSS (ADR 016). |
|
|
46
|
+
| **Vitral** | The optional stained glass theme | A stained glass window, in the same language. See ADR 023. |
|
|
47
|
+
|
|
48
|
+
The anatomy was argued over before it was settled. *Sash* was
|
|
49
|
+
considered for the dashboard and rejected: a sash is one layer inside a
|
|
50
|
+
window, the moving part that holds glass, and the thing that holds
|
|
51
|
+
panes is the frame.
|
|
52
|
+
|
|
53
|
+
## Words that stay ordinary
|
|
54
|
+
|
|
55
|
+
Not everything gets an unusual name, and the exceptions follow the same
|
|
56
|
+
reasoning.
|
|
57
|
+
|
|
58
|
+
**Measure and dimension** are the words the business intelligence field
|
|
59
|
+
already agreed on. An analyst who has used any tool of that kind knows
|
|
60
|
+
exactly what `measure :revenue` and `dimension :region` mean. They are
|
|
61
|
+
also method names inside a model's `janela` block rather than classes
|
|
62
|
+
sitting in your namespace, so there is nothing for them to collide with.
|
|
63
|
+
A domain language should speak its reader's language (ADR 002).
|
|
64
|
+
|
|
65
|
+
**Snapshot** and **owner** are plain because what they describe is
|
|
66
|
+
plain: the numbers frozen at an instant, and whatever a frame belongs
|
|
67
|
+
to. Janela assigns an owner and never reads it, so it has no reason to
|
|
68
|
+
give it a clever name (ADR 009, ADR 019).
|
|
69
|
+
|
|
70
|
+
**The doctor** is the conventional name for a command that reads your
|
|
71
|
+
setup and tells you what is wrong. Homebrew, Flutter, npm and Bundler
|
|
72
|
+
all ship one, so the word already means the right thing (ADR 021).
|
|
73
|
+
|
|
74
|
+
## Your words, not ours
|
|
75
|
+
|
|
76
|
+
None of Janela's vocabulary has to reach your users. Two places decide
|
|
77
|
+
what they see.
|
|
78
|
+
|
|
79
|
+
**The noun comes from your locale file.** Every heading the engine
|
|
80
|
+
renders uses `Janela::Frame.model_name.human`, so your users read
|
|
81
|
+
whatever you call them:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
en:
|
|
85
|
+
activerecord:
|
|
86
|
+
models:
|
|
87
|
+
janela/frame:
|
|
88
|
+
one: "Report"
|
|
89
|
+
other: "Reports"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**The address is wherever you mount it.** Frames live at the mount root
|
|
93
|
+
and a pane's URL sits beneath the same path, so the words in the address
|
|
94
|
+
bar are yours too:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
mount Janela::Engine => "/insights"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
/insights every frame
|
|
102
|
+
/insights/3 one frame
|
|
103
|
+
/insights/orders/revenue a pane
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
ADR 013 first gave frames a configurable path segment of their own. ADR
|
|
107
|
+
014 took it back out, because a segment named `dashboards` or `reports`
|
|
108
|
+
is exactly the kind of word a host model already owns, and it shadowed
|
|
109
|
+
that model's panes. Keeping Janela's own words out of your URLs turned
|
|
110
|
+
out to be the fix as well as the principle.
|
|
111
|
+
|
|
112
|
+
## One word, one meaning
|
|
113
|
+
|
|
114
|
+
A single name used for two things is worse than an awkward name, so
|
|
115
|
+
some sentences in this codebase are spelled more carefully than they
|
|
116
|
+
would be in conversation.
|
|
117
|
+
|
|
118
|
+
**Frame** on its own always means the dashboard. The HTML element is
|
|
119
|
+
always written **turbo frame**, in prose, in comments and in commit
|
|
120
|
+
messages, so the two can never be confused.
|
|
121
|
+
|
|
122
|
+
**Grid** always means a frame's layout, its columns and gap. The lines
|
|
123
|
+
drawn between panes by the theme are leading, never grid.
|
|
124
|
+
|
|
125
|
+
**Pane** always means the stored row. That is why the runtime object
|
|
126
|
+
had to give the name up: it was `Janela::Pane` until ADR 014 renamed it
|
|
127
|
+
`Janela::Query` so the record could take the word it deserved.
|
|
128
|
+
|
|
129
|
+
## When a name turns out wrong
|
|
130
|
+
|
|
131
|
+
Names were changed after release, and each change was treated as
|
|
132
|
+
breaking rather than tidied away:
|
|
133
|
+
|
|
134
|
+
- `janela_dashboard` became `janela_frame`, and the Stimulus controller
|
|
135
|
+
`janela--dashboard` became `janela--frame`.
|
|
136
|
+
- `Janela::Pane` became `Janela::Query`, freeing `Pane` for the record.
|
|
137
|
+
- `Janela::DashboardHelper` became `Janela::FramesHelper`.
|
|
138
|
+
|
|
139
|
+
Every one of those is listed in `UPGRADING.md` with the exact
|
|
140
|
+
replacement, and `bin/rails janela:doctor` finds any old name left in
|
|
141
|
+
your code and names what replaced it (ADR 015). Renaming is allowed. A
|
|
142
|
+
rename a user has to discover for themselves is not.
|
|
143
|
+
|
|
144
|
+
## The look follows the name
|
|
145
|
+
|
|
146
|
+
The demo and the vitral theme are not decoration picked separately.
|
|
147
|
+
Once the library is a window, the design had one obvious direction.
|
|
148
|
+
|
|
149
|
+
- **The panes are glass.** Each one holds its own colour, and the colour
|
|
150
|
+
cycles by position so a row is never monochrome.
|
|
151
|
+
- **The lines between them are leading,** dark and slightly uneven, the
|
|
152
|
+
way lead holds real stained glass. The theme calls its colour
|
|
153
|
+
`--vitral-came`, after the lead strip itself, but that is a styling
|
|
154
|
+
detail to override rather than a word you need to know.
|
|
155
|
+
- **The light comes from behind.** Shafts fall from a sun at the top
|
|
156
|
+
right, and hovering the mark fans light through the window, splitting
|
|
157
|
+
into its colours as it comes out the front.
|
|
158
|
+
- **The lattice leans toward you.** Its nodes reach for the cursor,
|
|
159
|
+
because a dashboard is meant to respond to the person looking at it.
|
|
160
|
+
|
|
161
|
+
## Naming something new
|
|
162
|
+
|
|
163
|
+
If you are adding to Janela or forking it, the same checks apply:
|
|
164
|
+
|
|
165
|
+
1. Would a stranger guess what it is from the name alone?
|
|
166
|
+
2. Is it a word a Rails application is likely to have already?
|
|
167
|
+
3. Does it already mean something else in this codebase, or in Rails,
|
|
168
|
+
or in Turbo?
|
|
169
|
+
4. Is it something a person arranges, which earns a window word, or an
|
|
170
|
+
implementation detail, which gets a plain one?
|
|
171
|
+
5. If you are renaming, have you added it to `UPGRADING.md` and taught
|
|
172
|
+
the doctor to find the old name?
|