janela 0.2.1 → 0.3.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 -1
- data/README.md +200 -20
- data/UPGRADING.md +136 -0
- data/app/assets/javascripts/janela/chart_controller.js +12 -5
- data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
- data/app/assets/stylesheets/janela.css +163 -0
- data/app/controllers/janela/application_controller.rb +18 -0
- data/app/controllers/janela/frames_controller.rb +57 -0
- data/app/controllers/janela/panes_controller.rb +66 -13
- data/app/controllers/janela/queries_controller.rb +17 -0
- data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
- data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +149 -0
- data/app/models/janela/snapshot.rb +5 -5
- data/app/views/janela/frames/_card.html.erb +7 -0
- data/app/views/janela/frames/_form.html.erb +23 -0
- data/app/views/janela/frames/_frame.html.erb +4 -0
- data/app/views/janela/frames/_pane.html.erb +12 -0
- data/app/views/janela/frames/edit.html.erb +25 -0
- data/app/views/janela/frames/index.html.erb +15 -0
- data/app/views/janela/frames/new.html.erb +5 -0
- data/app/views/janela/frames/show.html.erb +9 -0
- data/app/views/janela/panes/_form.html.erb +58 -0
- data/app/views/janela/panes/_row.html.erb +12 -0
- data/app/views/janela/panes/edit.html.erb +5 -0
- data/app/views/janela/panes/new.html.erb +22 -0
- data/app/views/janela/panes/show.html.erb +3 -46
- data/app/views/janela/queries/_query.html.erb +47 -0
- data/app/views/janela/queries/show.html.erb +4 -0
- data/app/views/janela/shared/_errors.html.erb +7 -0
- data/app/views/layouts/janela/application.html.erb +13 -5
- data/config/importmap.rb +1 -1
- data/config/locales/en.yml +64 -0
- data/config/routes.rb +15 -2
- data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
- data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
- data/docs/decisions/001-built-to-be-forked.md +4 -0
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
- data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
- data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
- data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
- data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
- data/docs/decisions/016-the-styling-vocabulary.md +100 -0
- data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
- data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
- data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
- data/docs/decisions/INDEX.md +22 -7
- data/docs/multi-tenancy.md +175 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +15 -2
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +17 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +54 -4
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 004, ADR 012, ADR 014
|
|
5
|
+
Triggers:
|
|
6
|
+
- creating a frame or any other record on a host's behalf
|
|
7
|
+
- adding a configuration setting to Janela
|
|
8
|
+
- a host needing to influence what the engine writes
|
|
9
|
+
- a record being invisible to the scope that was meant to find it
|
|
10
|
+
Topics: authorisation, tenancy, persistence, host-integration, configuration
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 019: A Created Frame Asks the Host Who Owns It
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 014 gave a frame a nullable polymorphic owner purely so a host's
|
|
18
|
+
Pundit scope has a column to filter on, and said Janela never
|
|
19
|
+
interprets it. That was right while frames were only created in a
|
|
20
|
+
console or a seed, where whoever created one could set it.
|
|
21
|
+
|
|
22
|
+
Build 4 lets an analyst create a frame through the engine's own form,
|
|
23
|
+
and the gap becomes a fault: Janela writes the row, so Janela decides
|
|
24
|
+
the owner, and it has nothing to decide with. A frame saved with no
|
|
25
|
+
owner is hidden by the very scope meant to find it, so the analyst
|
|
26
|
+
creates a dashboard and it disappears. That failure has already
|
|
27
|
+
happened twice in this project by other routes, once in a test and
|
|
28
|
+
once on the live demo, which is how confidently it can be predicted
|
|
29
|
+
here.
|
|
30
|
+
|
|
31
|
+
Three ways out. A configuration setting naming the owner. The engine
|
|
32
|
+
not creating frames at all, leaving it to each host, which defeats the
|
|
33
|
+
purpose of build 4 since the analyst is the person who cannot deploy.
|
|
34
|
+
Or asking the host at the moment of writing.
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
**If the host's controller responds to `janela_frame_owner`, the
|
|
39
|
+
engine assigns its return value as the owner of a frame it creates.**
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class ApplicationController < ActionController::Base
|
|
43
|
+
private
|
|
44
|
+
def janela_frame_owner
|
|
45
|
+
Current.account
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
A host that defines nothing gets a nil owner, which is correct for a
|
|
51
|
+
single tenant application and is what the demo did before it had
|
|
52
|
+
tenancy at all.
|
|
53
|
+
|
|
54
|
+
This is the pattern `policy_scope` already established (ADR 004):
|
|
55
|
+
Janela asks the host's controller a question by duck typing, and takes
|
|
56
|
+
silence as an answer rather than requiring configuration. It keeps
|
|
57
|
+
Janela's settings at one, `parent_controller`, and it puts the
|
|
58
|
+
decision in the only place that can make it, inside a request where
|
|
59
|
+
the current user and tenant exist.
|
|
60
|
+
|
|
61
|
+
**Janela still never interprets the owner.** It assigns what it is
|
|
62
|
+
handed and reads nothing from it. Scoping remains entirely the host's
|
|
63
|
+
policy, exactly as ADR 014 said.
|
|
64
|
+
|
|
65
|
+
## Consequences
|
|
66
|
+
|
|
67
|
+
- An analyst can create a dashboard and see it, which is the whole
|
|
68
|
+
point of build 4 and was impossible without this.
|
|
69
|
+
- A multi tenant host that forgets to define the method gets frames
|
|
70
|
+
nobody can see. That is a quiet failure, so it is what the doctor
|
|
71
|
+
task should notice: a host with a `policy_scope` that filters frames
|
|
72
|
+
by owner, but no `janela_frame_owner`, is misconfigured. Worth
|
|
73
|
+
adding to ADR 015's checks.
|
|
74
|
+
- Duck typing means a typo in the method name fails silently, which is
|
|
75
|
+
the cost of not having configuration. The doctor check above is the
|
|
76
|
+
mitigation.
|
|
77
|
+
- The same question will arrive for any other record Janela writes on
|
|
78
|
+
a host's behalf. The answer is this pattern rather than a second
|
|
79
|
+
setting, and this ADR is the precedent to cite.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 009, ADR 012, ADR 018
|
|
5
|
+
Triggers:
|
|
6
|
+
- a number rendering with more precision than it means
|
|
7
|
+
- adding an option to a pane, a frame or a pane URL
|
|
8
|
+
- units, currency or percentages
|
|
9
|
+
- a chart and a table showing the same number differently
|
|
10
|
+
Topics: dsl, rendering, charts, snapshots, configuration
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 020: Formatting Belongs to the Measure
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
An averaged measure rendered as `928.8767833333333`. Tables ran
|
|
18
|
+
values through `number_with_delimiter`, which handles thousands and
|
|
19
|
+
nothing else, and a chart tooltip showed whatever the database
|
|
20
|
+
returned. Nothing anywhere said how many decimal places a number
|
|
21
|
+
means, or that it is money, or a percentage.
|
|
22
|
+
|
|
23
|
+
There are four layers that could say, and each was a real candidate.
|
|
24
|
+
|
|
25
|
+
The **pane** is where an analyst works, so it is the tempting answer:
|
|
26
|
+
this chart in thousands, that one to the cent. But a pane is a
|
|
27
|
+
database row and a form field, so every knob there is a migration, a
|
|
28
|
+
validation and another decision put to someone who should not have to
|
|
29
|
+
know that `amount` is a decimal with a scale of two. Worse, the same
|
|
30
|
+
measure on two frames could disagree about itself.
|
|
31
|
+
|
|
32
|
+
The **pane URL** would make it ad hoc, `?precision=0`. That widens the
|
|
33
|
+
grammar ADR 005 fixed, and it lets anyone holding a link change what a
|
|
34
|
+
number appears to say. Precision is not a question a reader asks.
|
|
35
|
+
|
|
36
|
+
The **frame** is the wrong shape. One frame holds panes from different
|
|
37
|
+
models, so a single setting would be right for the currency and wrong
|
|
38
|
+
for the count sitting next to it.
|
|
39
|
+
|
|
40
|
+
The **measure** is the only one of the four that knows what the number
|
|
41
|
+
*is*. The other three know where it appears.
|
|
42
|
+
|
|
43
|
+
## Decision
|
|
44
|
+
|
|
45
|
+
**A measure declares its own format, and every renderer asks the
|
|
46
|
+
measure.**
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
janela do
|
|
50
|
+
measure :revenue, sum: :amount, prefix: "$"
|
|
51
|
+
measure :pass_rate, average: :score, precision: 1, suffix: "%"
|
|
52
|
+
measure :orders, count: true
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Almost nothing needs declaring, because the schema already knows.**
|
|
57
|
+
Precision is the measure's own if it declares one, otherwise the
|
|
58
|
+
column's: counting rows has no decimal places, a `decimal(10, 2)`
|
|
59
|
+
column has two, summing an integer column stays whole. Only where
|
|
60
|
+
neither says anything, such as averaging an integer, does it fall back
|
|
61
|
+
to two places. Thousands are delimited with the host's own locale.
|
|
62
|
+
|
|
63
|
+
`prefix` and `suffix` carry the unit, which no amount of rounding can
|
|
64
|
+
say. They are two plain strings rather than a currency vocabulary,
|
|
65
|
+
because a string is the thing a forker can read and replace in a
|
|
66
|
+
minute.
|
|
67
|
+
|
|
68
|
+
**Formatting is rendering, never rounding.** The number itself reaches
|
|
69
|
+
a snapshot, an order clause and a comparison at full precision. A
|
|
70
|
+
stored pane is data (ADR 009), so a snapshot taken last month reads
|
|
71
|
+
back under a format declared today.
|
|
72
|
+
|
|
73
|
+
**One formatter serves every renderer.** A table cell, a single value
|
|
74
|
+
and a chart tooltip all show the string the measure produced, and the
|
|
75
|
+
chart is handed those strings rather than formatting a second time in
|
|
76
|
+
JavaScript. The chart still plots raw numbers, because an axis is a
|
|
77
|
+
scale and not a label.
|
|
78
|
+
|
|
79
|
+
## Consequences
|
|
80
|
+
|
|
81
|
+
- The common measures read correctly with nothing declared, which is
|
|
82
|
+
the test this had to pass: money as money, counts as counts.
|
|
83
|
+
- A host changes how every one of its numbers reads by editing its
|
|
84
|
+
model, not by touching a dashboard, a URL or a template.
|
|
85
|
+
- A measure that wants two formats is two measures. That is a real
|
|
86
|
+
limit, and it is the same answer ADR 002 gives to every other
|
|
87
|
+
variation: declare what you mean and name it.
|
|
88
|
+
- The chart controller now takes a `formatted` value alongside
|
|
89
|
+
`values`. A host that renders its own chart from Janela's data has
|
|
90
|
+
the strings available and is not obliged to use them.
|
|
91
|
+
- If per pane formatting ever proves necessary, the measure's format
|
|
92
|
+
is the default it would override, not something to be undone first.
|
|
93
|
+
This is the layer to add it to, and the reason to think twice.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-16
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 010, ADR 015, ADR 019
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a check to janela:doctor
|
|
7
|
+
- adding a setting to Janela
|
|
8
|
+
- a warning that is a false alarm for some applications
|
|
9
|
+
- running the doctor in CI
|
|
10
|
+
Topics: configuration, tooling, upgrades, agent-guidance
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 021: A Check Has a Name, and a Host Can Silence It
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
`janela:doctor` reports what a host still has to do, and one of its
|
|
18
|
+
checks cannot be certain. Authentication is whatever the host does, so
|
|
19
|
+
the check reads the controller's filters and says what it sees. For an
|
|
20
|
+
application that authenticates another way, that warning is wrong
|
|
21
|
+
every single run.
|
|
22
|
+
|
|
23
|
+
A warning that is always wrong is worse than no warning. People learn
|
|
24
|
+
to skip the output, and the next finding, the true one, goes unread
|
|
25
|
+
with it. In CI it is worse again, because the only way to keep a green
|
|
26
|
+
build is to stop running the task.
|
|
27
|
+
|
|
28
|
+
The pattern here is settled elsewhere. Django's system check framework
|
|
29
|
+
gives every check an identifier and lets a project list the ones to
|
|
30
|
+
silence. Homebrew, Flutter, npm, Bundler and RubyGems all ship a
|
|
31
|
+
doctor of some shape, and the mature ones can be told to be quiet
|
|
32
|
+
about a thing the maintainer has already judged.
|
|
33
|
+
|
|
34
|
+
Janela had neither half: findings were prose with no stable handle, so
|
|
35
|
+
there was nothing to name in a conversation, a commit message or a
|
|
36
|
+
setting.
|
|
37
|
+
|
|
38
|
+
## Decision
|
|
39
|
+
|
|
40
|
+
**Every finding carries the name of the check that produced it**, in
|
|
41
|
+
the output and on the finding itself:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
WARNING (unauthenticated-endpoints): no authentication filter found on ApplicationController
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The name is the check's own method name, dasherised by the runner
|
|
48
|
+
rather than written out per check, so a code and the thing it names
|
|
49
|
+
cannot drift apart. Words rather than numbers: there are seven checks,
|
|
50
|
+
not seven hundred, and `unmigrated-tables` needs no lookup table the
|
|
51
|
+
way `janela.E004` would.
|
|
52
|
+
|
|
53
|
+
**A host silences a check by that name**:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# config/initializers/janela.rb
|
|
57
|
+
Janela.silenced_checks = %w[unauthenticated-endpoints]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**A silenced check is named every run**, in one line at the end, even
|
|
61
|
+
when there is nothing else to report. A silence nobody remembers is
|
|
62
|
+
how a real finding goes unread, which is the problem this is meant to
|
|
63
|
+
solve rather than reproduce.
|
|
64
|
+
|
|
65
|
+
This is Janela's second setting, and deliberately so. ADR 019 turned
|
|
66
|
+
down configuration for what owns a frame because ownership is
|
|
67
|
+
per-request state that a setting cannot hold. Silencing is the
|
|
68
|
+
opposite: a judgement made once, about the application as a whole,
|
|
69
|
+
that does not change between requests. That is what configuration is
|
|
70
|
+
for, and the distinction is the rule to apply next time rather than a
|
|
71
|
+
preference about this one.
|
|
72
|
+
|
|
73
|
+
## Consequences
|
|
74
|
+
|
|
75
|
+
- The doctor can run in CI on an application it cannot fully
|
|
76
|
+
understand, which is the only way it gets run at all.
|
|
77
|
+
- Silencing an error passes the task. That is the point and it is a
|
|
78
|
+
loaded gun, which is why the silence is printed every run.
|
|
79
|
+
- A check's name is now public. Renaming a check method renames what a
|
|
80
|
+
host silences, so a rename is a breaking change and goes in
|
|
81
|
+
UPGRADING.md like any other (ADR 015).
|
|
82
|
+
- Agents get a stable handle too. "Silence unmigrated-tables" is an
|
|
83
|
+
instruction that can be given and followed without quoting a
|
|
84
|
+
sentence of prose (ADR 010).
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -20,18 +20,23 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
20
20
|
|
|
21
21
|
| Topic | ADRs |
|
|
22
22
|
|-------|------|
|
|
23
|
-
| **Vision, scope, forkability** | 001, 010 |
|
|
23
|
+
| **Vision, scope, forkability** | 001, 010, 012 |
|
|
24
24
|
| **Open-source & host-decoupling** | 001 |
|
|
25
25
|
| **DSL & query layer** | 002, 006, 007 |
|
|
26
|
-
| **Dependencies** | 002, 003, 004, 006 |
|
|
27
|
-
| **Authorisation** | 002, 003, 004, 009 |
|
|
26
|
+
| **Dependencies** | 002, 003, 004, 006, 017 |
|
|
27
|
+
| **Authorisation** | 002, 003, 004, 009, 017, 019 |
|
|
28
|
+
| **Performance & storage** | 007, 017 |
|
|
28
29
|
| **Cross-filtering & Hotwire** | 003, 004, 005, 008 |
|
|
29
|
-
| **Layouts & views** | 011 |
|
|
30
|
+
| **Layouts & views** | 011, 012, 016, 018 |
|
|
31
|
+
| **CSS & styling** | 016, 018 |
|
|
32
|
+
| **Frames, panes & persistence** | 012, 013, 014, 019 |
|
|
33
|
+
| **Naming rule** | 014 |
|
|
30
34
|
| **JavaScript delivery & charts** | 004, 006 |
|
|
31
35
|
| **Time dimensions** | 006 |
|
|
32
|
-
| **Routes, URLs & naming** | 005, 007, 008, 009, 011 |
|
|
36
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013 |
|
|
33
37
|
| **Snapshots & publishing** | 009 |
|
|
34
|
-
| **AI agents & guidance** | 010 |
|
|
38
|
+
| **AI agents & guidance** | 010, 015 |
|
|
39
|
+
| **Releases & upgrades** | 015 |
|
|
35
40
|
| **Security** | 003 |
|
|
36
41
|
| **Testing** | 003 |
|
|
37
42
|
|
|
@@ -50,7 +55,17 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
50
55
|
| 009 | Snapshots | 2026-09-15 | Accepted |
|
|
51
56
|
| 010 | Agent Guidance Ships, the Agent Waits | 2026-09-15 | Accepted |
|
|
52
57
|
| 011 | Panes Do Not Render in the Host Layout | 2026-09-15 | Accepted |
|
|
58
|
+
| 012 | Frames and Panes Are Data | 2026-09-15 | Accepted |
|
|
59
|
+
| 013 | Naming and Addressing Frames | 2026-09-15 | Accepted |
|
|
60
|
+
| 014 | Corrections Before Frames Are Built | 2026-09-15 | Accepted |
|
|
61
|
+
| 015 | How Breaking Change Is Communicated | 2026-09-16 | Accepted |
|
|
62
|
+
| 016 | The Styling Vocabulary | 2026-09-16 | Accepted |
|
|
63
|
+
| 017 | Janela Owns No Data Store | 2026-09-16 | Accepted |
|
|
64
|
+
| 018 | A Table Is the Universal Renderer | 2026-09-16 | Accepted |
|
|
65
|
+
| 019 | A Created Frame Asks the Host Who Owns It | 2026-09-16 | Accepted |
|
|
66
|
+
| 020 | Formatting Belongs to the Measure | 2026-09-16 | Accepted |
|
|
67
|
+
| 021 | A Check Has a Name, and a Host Can Silence It | 2026-09-16 | Accepted |
|
|
53
68
|
|
|
54
69
|
## Next number
|
|
55
70
|
|
|
56
|
-
Next ADR:
|
|
71
|
+
Next ADR: 022
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Fitting Janela into a multi tenant application
|
|
2
|
+
|
|
3
|
+
Janela holds no tenancy of its own. There is no tenant setting, no
|
|
4
|
+
default scope of ours on your models, and nothing to configure. The
|
|
5
|
+
engine asks your application two questions and does what it is told.
|
|
6
|
+
|
|
7
|
+
| Question | How your application answers | If it says nothing |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| What may this request read? | `policy_scope(model)` on the controller Janela inherits | Everything: `model.all` |
|
|
10
|
+
| What owns a frame being created? | `janela_frame_owner` on the same controller | Nothing: a nil owner |
|
|
11
|
+
|
|
12
|
+
Both are ordinary methods on your `ApplicationController`, found by
|
|
13
|
+
duck typing. Pundit defines the first for you. Anything else, you
|
|
14
|
+
define in about five lines. ADR 019 has the reasoning for asking
|
|
15
|
+
rather than being configured: ownership is per-request state, and a
|
|
16
|
+
setting cannot hold it.
|
|
17
|
+
|
|
18
|
+
## What goes through your scope
|
|
19
|
+
|
|
20
|
+
Everything. There is no path through the engine that reads a record
|
|
21
|
+
without asking first:
|
|
22
|
+
|
|
23
|
+
- The index of frames, and each frame page.
|
|
24
|
+
- Every editing action on a frame or a pane, so another tenant's frame
|
|
25
|
+
is a 404 to rename or delete as much as to read.
|
|
26
|
+
- Every pane's own query, on Janela's pages and inline in yours. The
|
|
27
|
+
measure is calculated over `policy_scope(Order)`, not over `Order`.
|
|
28
|
+
- An ad hoc pane URL, which is the same query by another route.
|
|
29
|
+
- A snapshot, read through `policy_scope(Janela::Snapshot)`.
|
|
30
|
+
|
|
31
|
+
A pane rendered inline in your own page runs in your request, so the
|
|
32
|
+
same scope applies there as in the engine's controllers. Nothing is
|
|
33
|
+
calculated in a background context where the current tenant would have
|
|
34
|
+
gone missing.
|
|
35
|
+
|
|
36
|
+
## With Pundit
|
|
37
|
+
|
|
38
|
+
Write a policy for Janela's own record. The class name is namespaced,
|
|
39
|
+
because the model is:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# app/policies/janela/frame_policy.rb
|
|
43
|
+
module Janela
|
|
44
|
+
class FramePolicy < ApplicationPolicy
|
|
45
|
+
class Scope < ApplicationPolicy::Scope
|
|
46
|
+
def resolve
|
|
47
|
+
scope.where(owner: Current.account)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class ApplicationController < ActionController::Base
|
|
56
|
+
include Pundit::Authorization
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
def janela_frame_owner
|
|
60
|
+
Current.account
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Your own models keep the policies they already have. Janela calls
|
|
66
|
+
`policy_scope(Order)` and gets whatever `OrderPolicy::Scope` returns.
|
|
67
|
+
|
|
68
|
+
## With acts_as_tenant
|
|
69
|
+
|
|
70
|
+
Your own models are already scoped, so the only thing Janela needs
|
|
71
|
+
told about is its own tables. Define `policy_scope` as the adapter:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class ApplicationController < ActionController::Base
|
|
75
|
+
set_current_tenant_through_filter
|
|
76
|
+
before_action :set_tenant
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
def policy_scope(model)
|
|
80
|
+
case model.name
|
|
81
|
+
when "Janela::Frame" then model.where(owner: ActsAsTenant.current_tenant)
|
|
82
|
+
else model.all # acts_as_tenant has already scoped your own models
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def janela_frame_owner
|
|
87
|
+
ActsAsTenant.current_tenant
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## With CanCanCan, or with your own
|
|
93
|
+
|
|
94
|
+
The same adapter, pointed at whatever you use:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
def policy_scope(model)
|
|
98
|
+
model.accessible_by(current_ability)
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Janela never asks how the answer was arrived at. A method that takes a
|
|
103
|
+
class and returns a relation is the whole contract.
|
|
104
|
+
|
|
105
|
+
## With one tenant
|
|
106
|
+
|
|
107
|
+
Define nothing. `policy_scope` is absent, every query runs over
|
|
108
|
+
`model.all`, and a frame is created with a nil owner because nothing
|
|
109
|
+
is filtering on one.
|
|
110
|
+
|
|
111
|
+
## What owns a frame
|
|
112
|
+
|
|
113
|
+
`Janela::Frame belongs_to :owner, polymorphic: true, optional: true`.
|
|
114
|
+
It can point at an account, a team, a user or anything else you scope
|
|
115
|
+
by, and Janela reads nothing from it. It exists so your scope has a
|
|
116
|
+
column.
|
|
117
|
+
|
|
118
|
+
Return the tenant from `janela_frame_owner` and the engine assigns it
|
|
119
|
+
to a frame it creates. Skip that method while your scope filters by
|
|
120
|
+
owner and the analyst's new dashboard is saved with no owner, then
|
|
121
|
+
hidden by your own policy the instant it is saved. It looks like the
|
|
122
|
+
save failed silently. `bin/rails janela:doctor` reports this for you:
|
|
123
|
+
it asks your policy for a scope over frames and looks for an owner in
|
|
124
|
+
what comes back.
|
|
125
|
+
|
|
126
|
+
## Snapshots are the rough edge
|
|
127
|
+
|
|
128
|
+
`janela_snapshots` has no owner column. A snapshot is a name, an
|
|
129
|
+
instant, the filters it was taken under and the results. So a multi
|
|
130
|
+
tenant application has to scope it by something else:
|
|
131
|
+
|
|
132
|
+
- Keep snapshots to one tenant, or to whoever publishes.
|
|
133
|
+
- Add your own column with a migration on `janela_snapshots` and scope
|
|
134
|
+
on that.
|
|
135
|
+
- Encode the tenant in the filters a snapshot is taken under, and
|
|
136
|
+
scope on the stored name.
|
|
137
|
+
|
|
138
|
+
None of those is as clean as a frame's owner. Giving a snapshot the
|
|
139
|
+
same polymorphic owner is [issue #32](https://github.com/retail-tasker/janela/issues/32).
|
|
140
|
+
|
|
141
|
+
## Proving your wiring
|
|
142
|
+
|
|
143
|
+
Run the doctor first. It reads your application and reports the traps
|
|
144
|
+
that are visible from outside a request:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
bin/rails janela:doctor
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then write the test that matters, which is the one that fails if a
|
|
151
|
+
scope is ever loosened. Create a frame as one tenant and ask for it as
|
|
152
|
+
another:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
test "another tenant's frame is a 404" do
|
|
156
|
+
frame = Janela::Frame.create!(name: "Theirs", owner: accounts(:acme))
|
|
157
|
+
|
|
158
|
+
sign_in users(:globex_analyst)
|
|
159
|
+
get janela.frame_path(frame)
|
|
160
|
+
|
|
161
|
+
assert_response :not_found
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This repository's dummy application is a worked example of all of the
|
|
166
|
+
above, in
|
|
167
|
+
[test/dummy/app/controllers/application_controller.rb](https://github.com/retail-tasker/janela/blob/main/test/dummy/app/controllers/application_controller.rb),
|
|
168
|
+
scoped by a `?tenant=` parameter standing in for a session.
|
|
169
|
+
|
|
170
|
+
## What Janela will never do
|
|
171
|
+
|
|
172
|
+
Add a tenant filter of its own. Your scope is the only one, so there
|
|
173
|
+
is nothing to double filter and nothing that looks enforced while
|
|
174
|
+
being unenforceable. Janela can be handed any relation through `on:`,
|
|
175
|
+
so a guarantee made here would be a guarantee it cannot keep.
|
data/lib/janela/definition.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Janela
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def measure(name, **aggregate)
|
|
12
|
-
measures[name] = Measure.build(name, **aggregate).tap { |measure| reject_boolean_column!(measure) }
|
|
12
|
+
measures[name] = Measure.build(name, model: model, **aggregate).tap { |measure| reject_boolean_column!(measure) }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def dimension(name, through: nil, column: nil, granularity: nil)
|
|
@@ -44,6 +44,10 @@ module Janela
|
|
|
44
44
|
dimensions.fetch(name) { raise NotFound, "#{model} has no janela dimension #{name.inspect}" }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def measure!(name)
|
|
48
|
+
measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
47
51
|
# ActiveRecord casts an aggregate back through the column's own type, so
|
|
48
52
|
# AVG over a boolean returns true rather than a ratio. Say so at
|
|
49
53
|
# declaration rather than rendering a meaningless pane.
|
|
@@ -91,9 +95,5 @@ module Janela
|
|
|
91
95
|
raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
|
|
92
96
|
"Declare a janela dimension, or add it to ransackable_attributes."
|
|
93
97
|
end
|
|
94
|
-
|
|
95
|
-
def measure!(name)
|
|
96
|
-
measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
|
|
97
|
-
end
|
|
98
98
|
end
|
|
99
99
|
end
|