janela 0.7.0 → 0.9.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 +38 -0
- data/README.md +36 -5
- data/UPGRADING.md +111 -0
- data/app/assets/javascripts/janela/frame_controller.js +15 -0
- data/app/assets/stylesheets/janela.css +29 -0
- data/app/controllers/janela/application_controller.rb +7 -0
- data/app/controllers/janela/panes_controller.rb +22 -7
- data/app/controllers/janela/queries_controller.rb +2 -1
- data/app/helpers/janela/frames_helper.rb +26 -7
- data/app/models/janela/frame.rb +20 -0
- data/app/models/janela/pane.rb +67 -4
- data/app/models/janela/query.rb +7 -2
- data/app/views/janela/frames/_content.html.erb +26 -0
- data/app/views/janela/frames/_frame.html.erb +9 -1
- data/app/views/janela/frames/_pane.html.erb +2 -2
- data/app/views/janela/panes/_content_form.html.erb +33 -0
- data/app/views/janela/panes/_row.html.erb +1 -1
- data/app/views/janela/panes/edit.html.erb +5 -1
- data/app/views/janela/panes/new.html.erb +6 -1
- data/app/views/janela/queries/_query.html.erb +15 -11
- data/config/locales/en.yml +5 -0
- data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
- data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
- data/docs/composing.md +269 -0
- data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +1 -0
- data/docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md +262 -0
- data/docs/decisions/036-janela-publishes-what-a-theme-may-target.md +171 -0
- data/docs/decisions/037-what-1-0-means.md +172 -0
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
- data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
- data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
- data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
- data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
- data/docs/decisions/INDEX.md +26 -15
- data/docs/multi-tenancy.md +22 -0
- data/docs/naming.md +7 -0
- data/docs/roadmap.md +218 -0
- data/docs/theming.md +179 -0
- data/lib/janela/definition.rb +16 -1
- data/lib/janela/doctor.rb +121 -32
- data/lib/janela/model.rb +6 -1
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +47 -3
- metadata +30 -15
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-24
|
|
3
|
+
Status: Proposed
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 007, ADR 020, ADR 025
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a measure kind, or a sixth aggregate to the DSL
|
|
7
|
+
- deciding how a measure's value is scaled or formatted
|
|
8
|
+
- changing how a grouped pane is ordered
|
|
9
|
+
- a host wanting a rate or a percentage over a boolean column
|
|
10
|
+
- wondering whether a measure option belongs on the measure or on a pane
|
|
11
|
+
Topics: DSL, query layer, measures, formatting, ordering
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 038: A Ratio Is a Measure of Its Own, Stored as a Fraction and Read as a Percentage
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
A boolean column is how an application stores a yes or no fact, and
|
|
19
|
+
averaging one is how a person asks what share of rows are yes. Janela
|
|
20
|
+
refuses that today, correctly, and the refusal leaves nowhere to go
|
|
21
|
+
(#20, #27).
|
|
22
|
+
|
|
23
|
+
Four things were measured against the demo on `c6922da`, Rails 8.1.3.1
|
|
24
|
+
and SQLite, with `orders.expedited` as a real boolean column.
|
|
25
|
+
|
|
26
|
+
**The cast is total, not partial.** `Order.average(:expedited)` returns
|
|
27
|
+
`true` where the true ratio is `0.333`, and returns `true` again where
|
|
28
|
+
the true ratio is `0.0`, because Rails' boolean cast does not read a
|
|
29
|
+
float `0.0` as false. There is no value of the ratio that renders as
|
|
30
|
+
anything other than `true`. #20 described this as a cast losing
|
|
31
|
+
information; it loses all of it.
|
|
32
|
+
|
|
33
|
+
**The existing machinery already carries the fix.**
|
|
34
|
+
`relation.average(Arel.sql("CASE WHEN ... THEN 1.0 ELSE 0.0 END"))`
|
|
35
|
+
answers `0.3333333333333333` as a Float ungrouped, and grouped it
|
|
36
|
+
answers a hash per bucket. Nothing new is needed for grouping,
|
|
37
|
+
gap filling, cross filtering or snapshots, because the ratio goes
|
|
38
|
+
through the same `Measure#apply` every other measure goes through.
|
|
39
|
+
|
|
40
|
+
**Ordering breaks, which #27 did not mention.** `Definition` orders a
|
|
41
|
+
grouped query with `order(Arel.sql("#{measure.sql_alias} DESC"))`, and
|
|
42
|
+
`sql_alias` is `"#{aggregate}_#{column}"`, the alias ADR 007 names.
|
|
43
|
+
There is no such alias for an expression: the string becomes
|
|
44
|
+
`average_CASE WHEN orders.expedited THEN 1.0 ELSE 0.0 END DESC` and
|
|
45
|
+
raises `ActiveRecord::StatementInvalid`. Ordering by the expression
|
|
46
|
+
itself works and sorts correctly.
|
|
47
|
+
|
|
48
|
+
**The null claim in #27 is backwards.** On one true, one false and one
|
|
49
|
+
null row:
|
|
50
|
+
|
|
51
|
+
| expression | result |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `AVG(CASE WHEN f THEN 1.0 ELSE 0.0 END)`, null as false | 0.333 |
|
|
54
|
+
| the same with a `WHEN f IS NULL THEN NULL` arm | 0.5 |
|
|
55
|
+
| `AVG(f)`, plain | 0.5 |
|
|
56
|
+
|
|
57
|
+
#27 says excluding null "differs from `AVG` in SQL". Excluding null is
|
|
58
|
+
exactly what `AVG` does. The naive `CASE` is the form that differs, and
|
|
59
|
+
it counts every unknown as a failure, which is this library's worst
|
|
60
|
+
failure mode: a wrong number that looks like a right one.
|
|
61
|
+
|
|
62
|
+
### Fraction or percentage, and where that choice lives
|
|
63
|
+
|
|
64
|
+
The formatter does not scale. `format(0.6667)` with `suffix: "%"`
|
|
65
|
+
renders `"0.7%"`, wrong by a hundred; `format(66.67)` renders
|
|
66
|
+
`"66.7%"`. ADR 020's own example, `measure :pass_rate, average: :score,
|
|
67
|
+
precision: 1, suffix: "%"`, only reads correctly if the value is
|
|
68
|
+
already on a nought to a hundred scale.
|
|
69
|
+
|
|
70
|
+
Three positions were considered.
|
|
71
|
+
|
|
72
|
+
**Storing nought to a hundred** was rejected. ADR 020 holds that the
|
|
73
|
+
number itself reaches a snapshot, an order clause and a comparison at
|
|
74
|
+
full precision. Storing a presentation scale puts a display choice into
|
|
75
|
+
a frozen snapshot and into an `ORDER BY`, where it is no longer a
|
|
76
|
+
display choice at all. A ratio is a proportion, and a proportion is
|
|
77
|
+
nought to one.
|
|
78
|
+
|
|
79
|
+
**Leaving the scale to each pane** was rejected, and ADR 020 had
|
|
80
|
+
already rejected it: `janela_panes` carries `model`, `measure`,
|
|
81
|
+
`dimension`, `renderer`, `granularity`, `limit` and `position`, and no
|
|
82
|
+
formatting columns, deliberately. Two panes of one measure could
|
|
83
|
+
otherwise freeze different numbers into different snapshots and serve
|
|
84
|
+
both as fact. ADR 020's consequences say per pane formatting, if it
|
|
85
|
+
ever arrives, overrides the measure's format rather than replacing the
|
|
86
|
+
idea, and calls that the reason to think twice.
|
|
87
|
+
|
|
88
|
+
**A fraction with an opt in percentage**, such as `percent: true` or a
|
|
89
|
+
remembered `suffix: "%"`, was considered and is the option this
|
|
90
|
+
decision narrows rather than takes. A proportion shown to a person is a
|
|
91
|
+
percentage; making that a flag means every host that declares a rate
|
|
92
|
+
also remembers a second thing, and a host that forgets gets `0.31` on a
|
|
93
|
+
dashboard where `31.2%` was meant. ADR 001 prefers one obvious way over
|
|
94
|
+
a knob.
|
|
95
|
+
|
|
96
|
+
## Decision
|
|
97
|
+
|
|
98
|
+
**A ratio is a measure kind of its own. It computes a fraction, and it
|
|
99
|
+
renders as a percentage.**
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
janela do
|
|
103
|
+
measure :expedited_rate, ratio: :expedited # a boolean column
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**The kind sits beside the five aggregates rather than inside them.**
|
|
108
|
+
ADR 002 says a measure takes exactly one aggregate of `sum`, `count`,
|
|
109
|
+
`average`, `minimum`, `maximum`. `ratio:` is a sixth option in that
|
|
110
|
+
position and not a sixth aggregate, because it names a column and an
|
|
111
|
+
intent rather than an SQL function. `Measure::AGGREGATES` is unchanged
|
|
112
|
+
and the error a host already sees when it declares two of them is
|
|
113
|
+
unchanged.
|
|
114
|
+
|
|
115
|
+
**It requires a boolean column, which is the mirror of the refusal it
|
|
116
|
+
answers.** `average:` rejects a boolean column and names `ratio:`;
|
|
117
|
+
`ratio:` rejects anything that is not one. Both look the column up
|
|
118
|
+
through `model.type_for_attribute`, so the expression is built from a
|
|
119
|
+
column the model has confirmed, never from a string that arrived over
|
|
120
|
+
HTTP. ADR 025 bounds what a filter may ask for; this is the same
|
|
121
|
+
principle one layer down.
|
|
122
|
+
|
|
123
|
+
**Null is excluded, so a ratio agrees with `AVG`.**
|
|
124
|
+
|
|
125
|
+
```sql
|
|
126
|
+
AVG(CASE WHEN col IS NULL THEN NULL WHEN col THEN 1.0 ELSE 0.0 END)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
An unknown is not a failure. A host that wants unknowns counted as
|
|
130
|
+
failures says so in its own schema with a `NOT NULL` default, which is
|
|
131
|
+
a decision about the data rather than about the dashboard.
|
|
132
|
+
|
|
133
|
+
**A condition form is not included.** `ratio: { status: "paid" }` was
|
|
134
|
+
considered and rejected: it is a second query language growing inside
|
|
135
|
+
the measure, it needs the same bounding ADR 025 gives filters, and the
|
|
136
|
+
case it serves is already served. Where a column is not already a yes
|
|
137
|
+
or no fact, the split is the honest answer and cross filters better,
|
|
138
|
+
which is what #20's refusal says and what ADR 002 says to every other
|
|
139
|
+
variation. The boolean case earns its own kind precisely because the
|
|
140
|
+
split cannot answer it: "the share that passed, over time" is one
|
|
141
|
+
series, and a dimension gives two.
|
|
142
|
+
|
|
143
|
+
**The stored number is the fraction. The rendered string is the
|
|
144
|
+
percentage.** The value that reaches a snapshot, an `ORDER BY`, a chart
|
|
145
|
+
axis and a comparison is `0.3119`. The string a table cell, a single
|
|
146
|
+
value and a chart tooltip show is `31.2%`. This follows ADR 020 rather
|
|
147
|
+
than bending it: that decision forbids transforming the number, and a
|
|
148
|
+
ratio's number is never transformed. Only the string is.
|
|
149
|
+
|
|
150
|
+
Precision defaults to one decimal place for a ratio rather than ADR
|
|
151
|
+
020's fallback of two, because a percentage carries two more significant
|
|
152
|
+
figures than the fraction it came from and `31.19%` is noise. A measure
|
|
153
|
+
may still declare its own.
|
|
154
|
+
|
|
155
|
+
**Declaring `prefix:` or `suffix:` on a ratio raises.** The kind already
|
|
156
|
+
says what unit it is, and a host that writes `suffix: "%"` out of habit
|
|
157
|
+
would otherwise render `31.2%%`. Raising names the conflict rather than
|
|
158
|
+
guessing which was meant, which is what this library does everywhere
|
|
159
|
+
else it is given two answers.
|
|
160
|
+
|
|
161
|
+
**A measure answers what it is ordered by, rather than what its column
|
|
162
|
+
alias is.** `Measure#sql_alias` becomes `Measure#order_by`: for an
|
|
163
|
+
aggregate it returns the alias ActiveRecord already gives, unchanged,
|
|
164
|
+
and for a ratio it returns the `AVG(CASE ...)` expression. ADR 007's
|
|
165
|
+
decision is untouched, since a category pane is still ordered by its
|
|
166
|
+
measure, largest first, with no way to turn it off. Only the mechanism
|
|
167
|
+
it named has to widen, because that ADR assumed every measure is an
|
|
168
|
+
aggregate with an alias and a ratio is not.
|
|
169
|
+
|
|
170
|
+
## Consequences
|
|
171
|
+
|
|
172
|
+
- A host with a boolean column declares one line and gets a rate that
|
|
173
|
+
cross filters, gap fills, snapshots and orders like any other measure.
|
|
174
|
+
- `rails janela:doctor` has nothing to add. The declaration either
|
|
175
|
+
raises at boot or is correct, so there is no silent state for a check
|
|
176
|
+
to find.
|
|
177
|
+
- **A measure that wants the fraction rather than the percentage cannot
|
|
178
|
+
have it.** That is the cost of refusing the flag, and it is a real
|
|
179
|
+
one: a host wanting `0.31` on a dashboard has to use `average:` over
|
|
180
|
+
a numeric column of its own. If that turns up in practice, the answer
|
|
181
|
+
is a format on the measure, not on the pane, and ADR 020 already says
|
|
182
|
+
which layer that is.
|
|
183
|
+
- `Measure#sql_alias` is gone. It is internal rather than documented
|
|
184
|
+
surface, with one call site in the gem, but a fork that reached for it
|
|
185
|
+
will not find it, so it needs a line in `CHANGELOG.md` under Changed
|
|
186
|
+
rather than an `UPGRADING.md` step (ADR 015): nothing a host declares
|
|
187
|
+
changes.
|
|
188
|
+
- #20's error message names `ratio:`, which closes the loop the refusal
|
|
189
|
+
opened. That string is what a host actually meets, so it is part of
|
|
190
|
+
the work rather than a nicety.
|
|
191
|
+
- What would change this decision: a host that needs a rate over
|
|
192
|
+
something that is not a boolean column and for which the split is
|
|
193
|
+
genuinely wrong. That is the case the condition form was rejected for,
|
|
194
|
+
and one real report of it is better evidence than the argument above.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-24
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 009, ADR 012, ADR 016, ADR 036, ADR 037
|
|
5
|
+
Triggers:
|
|
6
|
+
- putting a heading, a paragraph, a link, an icon or an image on a stored frame
|
|
7
|
+
- adding a kind of pane that is not a query
|
|
8
|
+
- letting an analyst's text reach a page
|
|
9
|
+
- storing HTML, Markdown or a template in a row
|
|
10
|
+
- deciding whether a frame draws its own name
|
|
11
|
+
Topics: frames, panes, persistence, layout, authorisation, html
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 039: A Pane Can Hold Words, and Only Code Writes Markup
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 012 made a dashboard data so that its author, the analyst, can
|
|
19
|
+
change it without a deploy. It gave the analyst one thing to arrange:
|
|
20
|
+
a pane that names a measure the model declared. That was the whole
|
|
21
|
+
vocabulary, and it is why a stored frame is safe to hand over. A row
|
|
22
|
+
cannot invent a query, reach a model nobody exposed, or put anything on
|
|
23
|
+
the page that a developer did not write.
|
|
24
|
+
|
|
25
|
+
A real dashboard is more than query results. The first one a host asked
|
|
26
|
+
about had, above its numbers, a heading with an icon, a count of done
|
|
27
|
+
out of total, a percentage, a "view all" link and a progress bar. A
|
|
28
|
+
block form frame can have all of it, because the page around the panes
|
|
29
|
+
is the host's own ERB (docs/composing.md). A stored frame can have none
|
|
30
|
+
of it. `janela/frames/_frame` renders `frame.panes` and nothing else,
|
|
31
|
+
and `Janela::Pane` requires a `measure` and validates it against a
|
|
32
|
+
`janela` block, so there is no row a heading could be. The frame's
|
|
33
|
+
`name` is never drawn either. The analyst who owns the dashboard can
|
|
34
|
+
choose every number on it and cannot label them.
|
|
35
|
+
|
|
36
|
+
So the question is not whether a stored frame should hold content. It
|
|
37
|
+
has to, or the frame the analyst owns is always the unlabelled half of
|
|
38
|
+
the page. The question is what an analyst may put in a row without
|
|
39
|
+
breaking the property ADR 012 was built on.
|
|
40
|
+
|
|
41
|
+
### What was considered
|
|
42
|
+
|
|
43
|
+
**Stored HTML**, a column of markup rendered as it is, was rejected.
|
|
44
|
+
It is the one thing ADR 012 exists to prevent: an analyst's row would
|
|
45
|
+
put arbitrary markup on a page that every other reader of that frame
|
|
46
|
+
loads, which is stored cross site scripting with a dashboard for a
|
|
47
|
+
delivery mechanism. Running it through Rails' `sanitize` narrows the
|
|
48
|
+
tags but not the problem. The allowlist becomes a security boundary
|
|
49
|
+
Janela has to maintain, and what survives it, links anywhere and
|
|
50
|
+
styling that imitates the host's own chrome, is still content a
|
|
51
|
+
developer did not write appearing as if they had.
|
|
52
|
+
|
|
53
|
+
**Markdown** was rejected for the same reason one step removed. It
|
|
54
|
+
compiles to HTML, it needs a parser as a dependency (ADR 001 is spare
|
|
55
|
+
with those), and a link in it can point anywhere. What an analyst
|
|
56
|
+
actually needs from it, a heading and a paragraph, is two fields.
|
|
57
|
+
|
|
58
|
+
**A sandboxed iframe**, `<iframe srcdoc sandbox="allow-scripts">`, was
|
|
59
|
+
considered because an application built on this gem already renders
|
|
60
|
+
agent-authored slides that way, and it works: the sandbox gives the
|
|
61
|
+
content an opaque origin, so it cannot read the page or its cookies.
|
|
62
|
+
It was rejected for a public gem. The surface is large, the
|
|
63
|
+
guarantees depend on attributes a host can loosen, and an iframe does
|
|
64
|
+
not size to its content, so it cannot sit in a CSS grid beside a pane
|
|
65
|
+
without fixed heights, which is exactly what #24 is trying to remove
|
|
66
|
+
for charts. It stays available to any host that wants it, in its own
|
|
67
|
+
code, around a frame.
|
|
68
|
+
|
|
69
|
+
**Drawing the frame's name as a heading** was rejected as the whole
|
|
70
|
+
answer. It would change every existing frame's appearance on upgrade,
|
|
71
|
+
it gives one heading per frame when a dashboard often wants one per
|
|
72
|
+
row, and it still leaves no icon, no paragraph and no link.
|
|
73
|
+
|
|
74
|
+
**A separate table of content rows** beside `janela_panes` was
|
|
75
|
+
rejected because position is the frame's single ordering. Two tables
|
|
76
|
+
would need a shared position sequence across them, and ADR 012 kept
|
|
77
|
+
one level between a frame and what it shows on purpose.
|
|
78
|
+
|
|
79
|
+
## Decision
|
|
80
|
+
|
|
81
|
+
**A pane can hold words instead of a query. An analyst writes text
|
|
82
|
+
into a row and Janela escapes it; anything that needs markup is a
|
|
83
|
+
partial the host wrote in code, which the analyst places by name.**
|
|
84
|
+
|
|
85
|
+
This is ADR 012's division of labour applied to content: developers
|
|
86
|
+
define what can be shown, analysts arrange it and write the words.
|
|
87
|
+
|
|
88
|
+
**A pane gains a `kind`.** `query` is today's pane and the default, so
|
|
89
|
+
every existing row is unchanged. Two more kinds:
|
|
90
|
+
|
|
91
|
+
- **`text`**: a `heading`, a `body` and an optional `link`, each a
|
|
92
|
+
plain string. Janela renders the heading as a heading, the body as
|
|
93
|
+
paragraphs split on blank lines, and the link as an anchor, and
|
|
94
|
+
escapes all three. No markup an analyst types reaches the page as
|
|
95
|
+
markup.
|
|
96
|
+
- **`partial`**: a `partial` name, plus the same `heading`, `body` and
|
|
97
|
+
`link` passed to it as locals. The partial is the host's, under
|
|
98
|
+
`app/views/janela_content/`, so the host writes the icon, the image,
|
|
99
|
+
the layout and the look, and the analyst writes the words that go in
|
|
100
|
+
it. A name must match `/\A[a-z0-9_]+\z/` and a partial must exist in
|
|
101
|
+
that directory, so a row cannot reach any other template. There is
|
|
102
|
+
no registry and no setting: the directory is the allowlist, the way
|
|
103
|
+
a controller's views directory already is. It sits outside
|
|
104
|
+
`app/views/janela/` on purpose. A host view at an engine's path
|
|
105
|
+
replaces the engine's own, and `janela/panes/` already holds the
|
|
106
|
+
engine's `_form` and `_row`, so a content partial named `form` there
|
|
107
|
+
would silently replace the pane editing form.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
frame.panes.create!(kind: "text", heading: "Refunds are excluded",
|
|
111
|
+
body: "Figures are in the store's own currency.")
|
|
112
|
+
frame.panes.create!(kind: "partial", partial: "overview_heading",
|
|
113
|
+
heading: "Project overview", link: "/cards", span: 3)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A `text` or `partial` pane has no `model` or `measure`. Those columns
|
|
117
|
+
become nullable, and a validation requires them for `query` and
|
|
118
|
+
refuses them for the other two, so the database cannot hold a
|
|
119
|
+
half-query.
|
|
120
|
+
|
|
121
|
+
**A link is a path, not a URL.** `link` must start with `/`, and not
|
|
122
|
+
`//`, so an analyst can point readers at another page of the same
|
|
123
|
+
application and cannot send them to another site. A host that wants an
|
|
124
|
+
external link writes it in a partial.
|
|
125
|
+
|
|
126
|
+
**Images and icons are the host's.** Janela stores no files and ships
|
|
127
|
+
no icon set (ADR 036). A heading with an icon is a partial that draws
|
|
128
|
+
the icon beside the heading it is given. An image is a partial that
|
|
129
|
+
reads it from wherever the host already keeps images. Janela does not
|
|
130
|
+
take a dependency on Active Storage to do something a host's own
|
|
131
|
+
partial already can.
|
|
132
|
+
|
|
133
|
+
**A content pane sits in the grid like any other.** It spans columns,
|
|
134
|
+
it is ordered by `position`, it is drawn as a `janela-pane`, and under
|
|
135
|
+
vitral it is a pane of glass. It does not take part in cross filtering:
|
|
136
|
+
it has no query, so the frame controller has no `src` to rewrite, and a
|
|
137
|
+
content pane renders inline rather than inside a turbo frame.
|
|
138
|
+
|
|
139
|
+
## Consequences
|
|
140
|
+
|
|
141
|
+
- An analyst can label a stored frame, explain it and link out of it
|
|
142
|
+
without a deploy, which is the half of ADR 012 that was missing.
|
|
143
|
+
- The property ADR 012 stated plainly still holds, extended: a row can
|
|
144
|
+
name only a declared measure, an escaped string, a same-site path, or
|
|
145
|
+
a partial a developer wrote. Nothing an analyst types is markup.
|
|
146
|
+
- `janela_panes` gains `kind`, `heading`, `body`, `link` and `partial`,
|
|
147
|
+
and `model` and `measure` become nullable. That is a migration a host
|
|
148
|
+
has to install, so it needs an `UPGRADING.md` entry (ADR 015). The
|
|
149
|
+
engine's own forms need a way to add each kind.
|
|
150
|
+
- The pane partial becomes a branch on `kind`. `_pane.html.erb` is the
|
|
151
|
+
smallest file this touches and should stay that way: one partial per
|
|
152
|
+
kind under `janela/frames/`, chosen by name.
|
|
153
|
+
- Snapshots store results, not HTML (ADR 009). A snapshot is a list of
|
|
154
|
+
pane results with no frame behind it, so a content pane has no result
|
|
155
|
+
to store and is not in one. Words that explain a signed off figure
|
|
156
|
+
have to live in whatever page shows the snapshot. If a snapshot ever
|
|
157
|
+
needs to carry the text that sat beside its numbers on the day, that
|
|
158
|
+
is a separate decision.
|
|
159
|
+
- The pane class names a theme may target (ADR 036) gain one for the
|
|
160
|
+
content kinds, `janela-content`, and it goes into docs/theming.md in
|
|
161
|
+
the same change.
|
|
162
|
+
- Not in 1.0 (ADR 037). It adds to the public surface rather than
|
|
163
|
+
changing it, so it does not block a stable release and can land
|
|
164
|
+
before or after one.
|
|
165
|
+
- What would change this decision: a host that genuinely needs an
|
|
166
|
+
analyst to author markup, with a real reason a partial cannot serve.
|
|
167
|
+
The sandboxed iframe is the fallback that case would reopen, and this
|
|
168
|
+
record says why it was set aside.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-24
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 003, ADR 008, ADR 012, ADR 024, ADR 025, ADR 030, ADR 032, ADR 034
|
|
5
|
+
Triggers:
|
|
6
|
+
- showing a frame on a record's own page, filtered to that record
|
|
7
|
+
- reusing one frame across many records
|
|
8
|
+
- carrying a host's filter in q[...] and finding it cleared
|
|
9
|
+
- deciding whether a filter is a view choice or an authorisation boundary
|
|
10
|
+
- changing how a pane URL is built, or what clear() and toggle() touch
|
|
11
|
+
Topics: frames, filters, cross-filtering, urls, authorisation
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 040: A Host Can Fix a Frame's Filter, and No Click Removes It
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
A host wants the same dashboard on every record's page, narrowed to
|
|
19
|
+
that record: a frame of the work in a queue, shown at the top of each
|
|
20
|
+
queue's page. Since ADR 012 a frame is a row an analyst edits, so the
|
|
21
|
+
panes are the same everywhere and only the record changes. Janela has
|
|
22
|
+
no way to say "this frame, for this record" (#57).
|
|
23
|
+
|
|
24
|
+
The only filter Janela knows is the one in the page URL, `q[...]`
|
|
25
|
+
(ADR 008). So a host redirects the record's page to carry one,
|
|
26
|
+
`/queues/42?q[queue_id_eq]=42`, and the panes read it. Measured on the
|
|
27
|
+
demo with `q[status_eq]=paid` standing in for the host's filter, it
|
|
28
|
+
does not hold:
|
|
29
|
+
|
|
30
|
+
- Opened with the filter, the revenue pane read $469,097.85 and every
|
|
31
|
+
pane's `src` carried `q[status_eq]=paid`.
|
|
32
|
+
- **Clear filters**, the button a dashboard shows, took it off. Revenue
|
|
33
|
+
read $664,639.01, the whole table, and the page URL lost the
|
|
34
|
+
parameter.
|
|
35
|
+
- **Escape**, with focus inside the frame, did the same.
|
|
36
|
+
- A click on a `status` value would replace it, because `toggle()`
|
|
37
|
+
clears every predicate on the dimension it is changing (ADR 024).
|
|
38
|
+
|
|
39
|
+
None of this is a bug in the frame controller. `q[...]` is Janela's by
|
|
40
|
+
design: `stripFilters` removes every `q[` key before writing the
|
|
41
|
+
current selection, and `clear()` empties the frame's filters, because
|
|
42
|
+
the selection is the reader's to change. A host's filter placed there
|
|
43
|
+
becomes the reader's to change too. On a record's page the reader then
|
|
44
|
+
sees every record their scope allows under that record's heading.
|
|
45
|
+
Nothing leaks, since `policy_scope` still bounds every pane (ADR 032).
|
|
46
|
+
The numbers are wrong, and they look right.
|
|
47
|
+
|
|
48
|
+
### What was considered
|
|
49
|
+
|
|
50
|
+
**Making `clear()` leave the host's keys alone** was rejected. The
|
|
51
|
+
controller cannot tell a host's `q[queue_id_eq]` from a reader's link
|
|
52
|
+
that carries the same key, and ADR 008 means a shared link carries
|
|
53
|
+
exactly that. Any rule for telling them apart would be a second
|
|
54
|
+
convention inside one parameter.
|
|
55
|
+
|
|
56
|
+
**The host's `policy_scope`** was rejected. It answers who may see
|
|
57
|
+
which rows, and it runs in the engine's pane requests, which know
|
|
58
|
+
nothing about the page that holds the frame. Putting a page's record
|
|
59
|
+
into it would make authorisation depend on which page a pane was
|
|
60
|
+
loaded from, and ADR 032's checks would be reasoning about a scope that
|
|
61
|
+
changes per page.
|
|
62
|
+
|
|
63
|
+
**Storing the filter on the frame**, a `filters` column on
|
|
64
|
+
`janela_frames`, was rejected as the answer to this case. The case is
|
|
65
|
+
one frame reused across many records, so the filter belongs to the
|
|
66
|
+
render, not to the row. A frame that should always be narrowed the
|
|
67
|
+
same way is a smaller, different case, and it can be built on this
|
|
68
|
+
later.
|
|
69
|
+
|
|
70
|
+
**Signing the filter**, carrying it as a `MessageVerifier` token so a
|
|
71
|
+
reader cannot edit it, was rejected. It would imply a security
|
|
72
|
+
guarantee this is not. A reader who edits the fixed filter out of a
|
|
73
|
+
pane URL sees rows `policy_scope` already lets them see, which they
|
|
74
|
+
could see on any other dashboard. Signing adds a key to rotate and a
|
|
75
|
+
failure mode to explain, to protect nothing that authorisation does
|
|
76
|
+
not already protect.
|
|
77
|
+
|
|
78
|
+
## Decision
|
|
79
|
+
|
|
80
|
+
**A host passes a filter when it renders a frame, and Janela applies it
|
|
81
|
+
to every pane beneath the reader's selection, where no click, clear or
|
|
82
|
+
link can remove it.**
|
|
83
|
+
|
|
84
|
+
```erb
|
|
85
|
+
<%= janela_frame @frame, where: { queue_id_eq: @queue.id } %>
|
|
86
|
+
|
|
87
|
+
<%= janela_frame where: { queue_id_eq: @queue.id } do %>
|
|
88
|
+
<%= janela_pane Card, :count, by: :status %>
|
|
89
|
+
<% end %>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`where:` is the name `Definition#query` already uses for the same
|
|
93
|
+
thing, Ransack conditions narrowing a relation, so a host learns one
|
|
94
|
+
word for it.
|
|
95
|
+
|
|
96
|
+
**It travels in the pane's base URL under its own key, `where[...]`.**
|
|
97
|
+
The helper writes it into each pane's `data-janela-src`, the base the
|
|
98
|
+
frame controller builds every request from, and into the first
|
|
99
|
+
render's `src`. The controller never reads or writes `where[...]`:
|
|
100
|
+
`stripFilters` removes only `q[` keys, `clear()` empties only the
|
|
101
|
+
frame's filters, and `toggle()` changes only `q[`. So the fixed filter
|
|
102
|
+
survives every click without the controller learning anything about
|
|
103
|
+
it. It is not written into the page URL, which already says what the
|
|
104
|
+
record is.
|
|
105
|
+
|
|
106
|
+
**The server applies it first and separately.** The pane endpoints
|
|
107
|
+
read `params[:where]` and `params[:q]` and narrow the relation twice,
|
|
108
|
+
fixed filter first, so the reader's selection can only narrow further.
|
|
109
|
+
The same key in both is two conditions ANDed, never one replacing the
|
|
110
|
+
other. The fixed filter goes through `Definition#filter`, so it is
|
|
111
|
+
bounded exactly as `q[...]` is: only declared dimensions, only the
|
|
112
|
+
predicates ADR 025 allows for each kind, and no more than 1000 values.
|
|
113
|
+
A host cannot fix a filter on a column it has not declared, which is
|
|
114
|
+
the same rule a reader already meets.
|
|
115
|
+
|
|
116
|
+
**It is a view filter, not an authorisation boundary, and the
|
|
117
|
+
documentation says so in those words.** It is visible in every pane's
|
|
118
|
+
URL, and a reader can remove it by editing one. What they reach by
|
|
119
|
+
doing that is what `policy_scope` already allows. A host that needs a
|
|
120
|
+
reader not to see other records writes that in its scope, where it
|
|
121
|
+
applies to every pane on every page.
|
|
122
|
+
|
|
123
|
+
## Consequences
|
|
124
|
+
|
|
125
|
+
- One frame serves every record's page. An analyst edits it once, and
|
|
126
|
+
the host needs no column pointing at a frame per record.
|
|
127
|
+
- A host that carries its filter in `q[...]` today, by redirect, moves
|
|
128
|
+
it to `where:` and drops the redirect. Nothing breaks if it does not
|
|
129
|
+
move; the old way keeps its old weakness. The guide for this goes in
|
|
130
|
+
docs/composing.md.
|
|
131
|
+
- The pane URL shape gains `where[...]`. ADR 037 names the pane URL as
|
|
132
|
+
part of the surface that stops moving at 1.0, so this is better
|
|
133
|
+
decided before 1.0 than after, and it is additive: every existing URL
|
|
134
|
+
means what it meant.
|
|
135
|
+
- A snapshot is taken with the filters its caller passes (ADR 009,
|
|
136
|
+
ADR 034), not from a rendered page. A host snapshotting a record's
|
|
137
|
+
frame passes the fixed filter in `filters:`, and nothing does that
|
|
138
|
+
for it. Worth a line in the snapshot documentation, not a mechanism.
|
|
139
|
+
- The dimension the host fixes should not also be offered as a
|
|
140
|
+
clickable pane. Clicking it would AND a second condition on the same
|
|
141
|
+
column, and any value but the fixed one returns nothing. That is
|
|
142
|
+
correct and confusing, so the guide says not to.
|
|
143
|
+
- What would change this decision: a host that needs the fixed filter
|
|
144
|
+
hidden from the reader for a reason `policy_scope` cannot express.
|
|
145
|
+
That would reopen signing, and this record says why it was left out.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-24
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 012, ADR 013, ADR 014, ADR 019, ADR 040
|
|
5
|
+
Triggers:
|
|
6
|
+
- giving a record of the host's its own frame
|
|
7
|
+
- finding a frame from code without storing its id
|
|
8
|
+
- an owner that has more than one frame
|
|
9
|
+
- adding a slug, a handle or any second identifier to a frame
|
|
10
|
+
Topics: frames, persistence, naming, host-integration
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 041: A Host Finds Its Frame by Owner and Key
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
A host that shows a frame on one of its own pages has to find that
|
|
18
|
+
frame from code. ADR 012 made frames rows an analyst edits, and ADR 040
|
|
19
|
+
made one frame usable across many records, so the host needs a way to
|
|
20
|
+
say "the frame for this" without a deploy creating it and without
|
|
21
|
+
remembering an id.
|
|
22
|
+
|
|
23
|
+
The owner looked like the answer. It is a nullable polymorphic
|
|
24
|
+
reference that Janela never reads (ADR 014, ADR 019), so a host can
|
|
25
|
+
point it at the record the frame is for, and
|
|
26
|
+
`Janela::Frame.find_or_create_by!(owner: queue)` is a frame's `dom_id`:
|
|
27
|
+
two columns, nothing added to the host's schema.
|
|
28
|
+
|
|
29
|
+
It holds for exactly one frame per owner, and owners rarely have one. A
|
|
30
|
+
tenant owns every frame an analyst makes from the engine's New button,
|
|
31
|
+
through `janela_frame_owner`, and a host wants frames of its own for
|
|
32
|
+
particular pages on top of those. `find_by(owner: account)` then returns
|
|
33
|
+
whichever row the database gives first. On a real installation it
|
|
34
|
+
returned the wrong frame: the first the tenant had ever made, not the
|
|
35
|
+
one the page was for (#59).
|
|
36
|
+
|
|
37
|
+
### What was considered
|
|
38
|
+
|
|
39
|
+
**`name` as the key** was rejected. It is the analyst's to rename, so a
|
|
40
|
+
host looking a frame up by name loses it the day someone edits the
|
|
41
|
+
title.
|
|
42
|
+
|
|
43
|
+
**A column in the host's table** pointing at the frame was rejected as
|
|
44
|
+
the answer the owner was meant to save a host from. It needs a
|
|
45
|
+
migration on every table whose records want a frame, and a second one
|
|
46
|
+
for each extra frame per record.
|
|
47
|
+
|
|
48
|
+
**A slug**, which ADR 013 turned down, is not what is being asked for,
|
|
49
|
+
and the difference is the reason this is allowed. ADR 013 rejected a
|
|
50
|
+
slug as a way to *address* a frame: in a URL a person reads, derived
|
|
51
|
+
from the name, needing uniqueness rules, reserved words and regeneration
|
|
52
|
+
on rename, and a second lookup path to keep working. What a host needs
|
|
53
|
+
is none of those. It is an identifier the host writes in its own code,
|
|
54
|
+
never in a URL, never derived from the name, never shown to an analyst,
|
|
55
|
+
and never changed once set.
|
|
56
|
+
|
|
57
|
+
**Janela choosing the frame for a record**, a registry mapping host
|
|
58
|
+
classes to frames, was rejected as configuration for something one line
|
|
59
|
+
of the host's code already says.
|
|
60
|
+
|
|
61
|
+
## Decision
|
|
62
|
+
|
|
63
|
+
**A frame may carry a `key` the host sets. A frame is found by its
|
|
64
|
+
owner and its key together, and each owner has at most one frame per
|
|
65
|
+
key.**
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Janela::Frame.for(account, :overview) # the tenant's overview
|
|
69
|
+
Janela::Frame.for(queue, :analytics) # this queue's frame
|
|
70
|
+
Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`for` finds the frame with that owner and key, or creates it. The block
|
|
74
|
+
runs only when it creates one, to set what the host wants a new frame to
|
|
75
|
+
start as; a new frame is named after its key unless the block says
|
|
76
|
+
otherwise. The owner may be `nil` for a host with one tenant.
|
|
77
|
+
|
|
78
|
+
**The key is the host's and nobody else's.** Janela reads it only in
|
|
79
|
+
`for`. It is not in any route and not in the engine's forms, so an
|
|
80
|
+
analyst can rename, regrid and recompose a keyed frame without being
|
|
81
|
+
able to detach it from the page that finds it. A frame without a key,
|
|
82
|
+
which is every frame an analyst makes, is unchanged.
|
|
83
|
+
|
|
84
|
+
**Uniqueness is per owner, in the database.** A unique index on owner
|
|
85
|
+
and key, so two requests creating the same frame at once end with one
|
|
86
|
+
row, and `for` retries the find when the insert loses that race. Frames
|
|
87
|
+
with no key are not constrained by it.
|
|
88
|
+
|
|
89
|
+
`key` is a short lowercase string matching `/\A[a-z0-9_]+\z/`, the
|
|
90
|
+
shape of the symbols a host will pass, so `:analytics` and `"analytics"`
|
|
91
|
+
are the same key and nothing that looks like a URL or a name gets in.
|
|
92
|
+
|
|
93
|
+
## Consequences
|
|
94
|
+
|
|
95
|
+
- A host gives any record any number of frames with no column of its
|
|
96
|
+
own: owner and key are the `dom_id`, and the key is the suffix
|
|
97
|
+
`dom_id(queue, :analytics)` would carry.
|
|
98
|
+
- `janela_frames` gains `key` and a unique index, so a migration and an
|
|
99
|
+
`UPGRADING.md` entry (ADR 015). Existing frames keep a nil key.
|
|
100
|
+
- The owner now does two jobs for a host that uses `for`: tenancy for
|
|
101
|
+
the policy scope, and which record a frame belongs to. ADR 019 said
|
|
102
|
+
Janela never reads the owner; `for` queries by it, and only there. A
|
|
103
|
+
host whose owner is a record rather than its tenant has to make its
|
|
104
|
+
policy scope see frames owned by its own records, which the
|
|
105
|
+
multi-tenancy guide shows.
|
|
106
|
+
- A frame deleted from the engine's pages is created again, empty, the
|
|
107
|
+
next time its page is visited. That is the right answer for a frame a
|
|
108
|
+
page depends on, and it is worth a line in the README so it is not a
|
|
109
|
+
surprise.
|
|
110
|
+
- What would change this decision: a host needing to find a frame by
|
|
111
|
+
something that is not stable in its own code. That is the addressing
|
|
112
|
+
problem ADR 013 decided, and it would be decided there again.
|